Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d59ee5336 |
+4
-12
@@ -3,7 +3,6 @@
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
APP_ENV=development
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||
@@ -36,8 +35,7 @@ ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
@@ -46,15 +44,9 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
# ==================== CosyVoice 语音合成配置 ====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
||||
COSYVOICE_MODEL=cosyvoice-v1
|
||||
COSYVOICE_VOICE=longxiaochun
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
Executable → Regular
+3
-8
@@ -42,15 +42,10 @@ OSS_DIRECT_UPLOAD_MAX_MB=2000
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
|
||||
# ==================== CosyVoice 语音合成(必须配置)====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
||||
COSYVOICE_MODEL=cosyvoice-v1
|
||||
COSYVOICE_VOICE=longxiaochun
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
max-line-length = 120
|
||||
exclude =
|
||||
.git,
|
||||
.cache,
|
||||
__pycache__,
|
||||
.venv,
|
||||
venv,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Auto Merge PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
+299
-704
File diff suppressed because it is too large
Load Diff
@@ -1,658 +0,0 @@
|
||||
name: Daily Health Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨 3:00
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: saas
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
env:
|
||||
SMOKE_ENV: production
|
||||
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
|
||||
MODULES: health,assets,generation,subscription,nginx
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
BASE_URL="https://api.xiaoxiajianji.com" \
|
||||
WEB_URL="https://saas.xiaoxiajianji.com" \
|
||||
SMOKE_ENV="${SMOKE_ENV}" \
|
||||
EXISTING_TOKEN="${EXISTING_TOKEN}" \
|
||||
MODULES="${MODULES}" \
|
||||
CLEANUP_ENABLED=0 \
|
||||
PERF_CHECK_ENABLED=1 \
|
||||
PERF_WARN_THRESHOLD_MS=500 \
|
||||
PERF_FAIL_THRESHOLD_MS=5000 \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/prod-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 生产冒烟测试报告 =========="
|
||||
echo "环境: https://api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
# 提取通过/失败数
|
||||
grep "测试完成:" /tmp/prod-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/prod-smoke.log || true
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "======================================"
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
docker run --rm \
|
||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||
-e TEST_USER=18314979086@163.com \
|
||||
-e TEST_PASSWORD=Ying1234 \
|
||||
-e CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
-e PERF_FAIL_THRESHOLD_MS=3000 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 冒烟测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep "测试完成:" /tmp/staging-api-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "api_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/staging-api-smoke.log || true
|
||||
echo "api_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
- name: Run Staging API Integration Tests (Playwright)
|
||||
id: e2e_api
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1 | tee /tmp/staging-api-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 集成测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-api-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "int_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "int_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Set report output
|
||||
id: report
|
||||
shell: sh
|
||||
run: |
|
||||
if [ "${{ steps.smoke.outputs.api_report }}" = "PASS" ] && [ "${{ steps.e2e_api.outputs.int_report }}" = "PASS" ]; then
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging E2E 测试报告 =========="
|
||||
echo "环境: https://staging.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=========================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Run performance baseline checks
|
||||
id: perf
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - Staging API"
|
||||
echo " 目标: https://staging-api.xiaoxiajianji.com"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
WARN_LIST=""
|
||||
FAIL_LIST=""
|
||||
|
||||
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
|
||||
# 核心接口(core): 500ms
|
||||
# 普通接口(normal): 1000ms
|
||||
# 重操作接口(heavy): 3000ms
|
||||
ENDPOINTS="
|
||||
登录|/api/v1/auth/login|POST|500|3000
|
||||
获取当前用户|/api/v1/auth/me|GET|500|3000
|
||||
项目列表|/api/v1/projects|GET|500|3000
|
||||
素材列表|/api/v1/assets|GET|500|3000
|
||||
模板列表|/api/v1/templates|GET|500|3000
|
||||
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
|
||||
生成任务列表|/api/v1/generation/tasks|GET|500|3000
|
||||
订阅信息|/api/v1/subscription/current|GET|500|3000
|
||||
音色列表|/api/v1/voices|GET|1000|5000
|
||||
健康检查|/health|GET|200|1000
|
||||
"
|
||||
|
||||
# 先登录获取 token
|
||||
echo "--- 准备: 获取测试 Token ---"
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo "Token 获取成功"
|
||||
else
|
||||
echo "Token 解析失败,部分接口可能无法测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
else
|
||||
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 开始性能测试 ---"
|
||||
echo ""
|
||||
|
||||
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
|
||||
[ -z "$name" ] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# 构建 curl 命令
|
||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
# 执行请求
|
||||
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 由于 while 在子 shell 中执行,用文件传递结果
|
||||
# 重新跑一次用文件计数方式
|
||||
echo ""
|
||||
echo "--- 汇总性能数据 ---"
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - 详细报告"
|
||||
echo "=========================================="
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
RESULTS=""
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# 先登录获取 token
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
TOKEN=""
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
run_perf_test() {
|
||||
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if echo "$HTTP_CODE" | grep -q "^[5]"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
|
||||
return 0
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
|
||||
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
|
||||
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
|
||||
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
|
||||
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
|
||||
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
|
||||
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
|
||||
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
|
||||
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
|
||||
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
|
||||
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "总接口: ${TOTAL}"
|
||||
echo "通过: ${PASS}"
|
||||
echo "失败: ${FAIL}"
|
||||
echo "警告: ${WARN}"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
# 写入结果文件供 report job 使用
|
||||
echo "${TOTAL}" > /tmp/perf_total
|
||||
echo "${PASS}" > /tmp/perf_pass
|
||||
echo "${FAIL}" > /tmp/perf_fail
|
||||
echo "${WARN}" > /tmp/perf_warn
|
||||
echo "${ELAPSED}" > /tmp/perf_elapsed
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
exit 1
|
||||
else
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
if [ "$WARN" -gt 0 ]; then
|
||||
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
- production-smoke
|
||||
- staging-api-tests
|
||||
- staging-e2e
|
||||
- performance-check
|
||||
|
||||
steps:
|
||||
- name: Print summary report
|
||||
shell: sh
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ 每日巡检报告 ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
|
||||
# 获取各 job 状态
|
||||
PROD_STATUS="${{ needs.production-smoke.result }}"
|
||||
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
|
||||
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
|
||||
PERF_STATUS="${{ needs.performance-check.result }}"
|
||||
|
||||
format_result() {
|
||||
if [ "$1" = "success" ]; then
|
||||
echo "✅ PASS"
|
||||
elif [ "$1" = "failure" ]; then
|
||||
echo "❌ FAIL"
|
||||
elif [ "$1" = "skipped" ]; then
|
||||
echo "⏭️ SKIP"
|
||||
else
|
||||
echo "❓ UNKNOWN ($1)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "║"
|
||||
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
|
||||
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
|
||||
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
|
||||
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
|
||||
echo "║"
|
||||
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "║"
|
||||
|
||||
# 判断整体状态
|
||||
ALL_PASS=true
|
||||
FAILED_ITEMS=""
|
||||
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
|
||||
STATUS=$(echo "$status_name" | cut -d: -f1)
|
||||
NAME=$(echo "$status_name" | cut -d: -f2)
|
||||
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
|
||||
ALL_PASS=false
|
||||
FAILED_ITEMS="$FAILED_ITEMS $NAME"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
if [ "$ALL_PASS" = "true" ]; then
|
||||
echo "║ 整体状态: ✅ 全部通过 ║"
|
||||
else
|
||||
echo "║ 整体状态: ❌ 存在失败 ║"
|
||||
echo "║ 失败项: ${FAILED_ITEMS} ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
|
||||
if [ "$ALL_PASS" = "false" ]; then
|
||||
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: runtime-builder
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Show Python version
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python -m pip --version
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run unit tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
- name: Run integration tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/integration -q --timeout=60 -x
|
||||
|
||||
lint:
|
||||
runs-on: runtime-builder
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
# Retry up to 5 times with backoff for transient 5xx errors
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run Black (check only)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
|
||||
- name: Run Flake8
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
@@ -6,7 +6,6 @@ dist/
|
||||
coverage/
|
||||
|
||||
# Python / backend
|
||||
.cache/
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci-root/
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Add tags and asset_tags tables
|
||||
|
||||
Revision ID: 030
|
||||
Revises: 029
|
||||
Create Date: 2026-07-07
|
||||
|
||||
新增标签表和素材-标签关联表,支持规范化多对多标签管理。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "030"
|
||||
down_revision = "029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table: str) -> bool:
|
||||
ctx = op.get_context()
|
||||
if ctx.as_sql:
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"),
|
||||
{"table": table},
|
||||
)
|
||||
return (result.scalar() or 0) > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"),
|
||||
)
|
||||
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||
|
||||
if not _table_exists("asset_tags"):
|
||||
op.create_table(
|
||||
"asset_tags",
|
||||
sa.Column("asset_id", sa.String(36), primary_key=True),
|
||||
sa.Column("tag_id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags")
|
||||
op.drop_table("asset_tags")
|
||||
op.drop_index("ix_tags_user_id", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Add file_hash to assets and ingest_jobs
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-07-07
|
||||
|
||||
为素材去重检测功能添加 file_hash 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
|
||||
|
||||
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "file_hash")
|
||||
|
||||
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
|
||||
op.drop_column("assets", "file_hash")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Add asset_select_mode to generation_tasks
|
||||
|
||||
Revision ID: 032
|
||||
Revises: 031
|
||||
Create Date: 2026-07-07
|
||||
|
||||
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "032"
|
||||
down_revision = "031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "asset_select_mode")
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Add batch_id to generation_tasks
|
||||
|
||||
Revision ID: 033
|
||||
Revises: 032
|
||||
Create Date: 2026-07-07
|
||||
|
||||
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
|
||||
用于关联同一次批量生成请求中的多个任务。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "033"
|
||||
down_revision = "032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "batch_id")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""CMS Enhancements (placeholder - manually applied on production)
|
||||
|
||||
Revision ID: 034_cms_enhance
|
||||
Revises: 033
|
||||
Create Date: 2026-07-09
|
||||
|
||||
占位迁移文件:生产数据库已手动升级到此版本,
|
||||
此文件用于让 alembic 识别当前版本,避免部署时迁移失败。
|
||||
实际的表结构变更(helpcenter, tickets, partners, site_settings 等)
|
||||
已在生产环境手动执行。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "034_cms_enhance"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""占位 - 变更已在生产环境手动应用"""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""占位 - 不执行实际回退"""
|
||||
pass
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add editing_mode to edit_templates
|
||||
|
||||
Revision ID: 035_editing_mode
|
||||
Revises: 034_cms_enhance
|
||||
Create Date: 2026-07-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "035_editing_mode"
|
||||
down_revision = "034_cms_enhance"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_templates", "editing_mode")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Expand UUID fields from varchar(32) to varchar(36)
|
||||
|
||||
All UUID fields across all tables were varchar(32), but standard UUIDs with
|
||||
hyphens are 36 characters (e.g. 550e8400-e29b-41d4-a716-446655440000).
|
||||
This caused StringDataRightTruncation errors on insert.
|
||||
|
||||
Revision ID: 036_expand_uuid_36
|
||||
Revises: 035_editing_mode
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "036_expand_uuid_36"
|
||||
down_revision = "035_editing_mode"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# ── 表 → 需要扩容的列 ─────────────────────────────────────────────────────────
|
||||
|
||||
_TABLES: dict[str, list[str]] = {
|
||||
"projects": ["id", "owner_user_id"],
|
||||
"edit_templates": ["id"],
|
||||
"edit_plans": ["id", "template_id", "source_edit_plan_id", "project_id", "created_by_user_id"],
|
||||
"template_clip_configs": ["id", "template_id"],
|
||||
"edit_plan_clips": ["id", "plan_id", "template_clip_config_id", "asset_id"],
|
||||
"ingest_jobs": ["id", "project_id", "library_id", "result_asset_id"],
|
||||
"classification_jobs": ["id", "project_id", "asset_id"],
|
||||
"generation_tasks": [
|
||||
"id",
|
||||
"project_id",
|
||||
"strategy_id",
|
||||
"asset_library_id",
|
||||
"voice_library_id",
|
||||
"created_by_user_id",
|
||||
"source_edit_plan_id",
|
||||
"batch_id",
|
||||
],
|
||||
"generated_videos": ["id", "project_id", "generation_task_id", "duplicate_of"],
|
||||
"jobs": ["id", "project_id", "source_id", "created_by_user_id"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table, columns in _TABLES.items():
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.String(36),
|
||||
existing_nullable=None,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table, columns in reversed(list(_TABLES.items())):
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(36),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=None,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add logs field to generation_tasks
|
||||
|
||||
Revision ID: 037_generation_logs
|
||||
Revises: 036_expand_uuid_36
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "037_generation_logs"
|
||||
down_revision = "036_expand_uuid_36"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("logs", sa.Text(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "logs")
|
||||
@@ -0,0 +1 @@
|
||||
"""API application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
@@ -8,17 +8,14 @@ from app.api.routes.dashboard import router as dashboard_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.edit_templates import router as edit_templates_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.jobs import router as jobs_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.recipes import router as recipes_router
|
||||
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.titles import router as titles_router
|
||||
@@ -41,11 +38,6 @@ api_router.include_router(
|
||||
prefix="/projects",
|
||||
tags=["Project"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tags_router,
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
@@ -153,11 +145,3 @@ api_router.include_router(
|
||||
prefix="/tts",
|
||||
tags=["TTS"],
|
||||
)
|
||||
api_router.include_router(
|
||||
feature_flags_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
api_router.include_router(
|
||||
internal_render_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from packages.application import GetProjectUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
|
||||
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限。
|
||||
|
||||
合并自 asset_libraries.py / edit_plans.py 的同名函数。
|
||||
- 空 project_id 直接放行(兼容 edit_plans 中 project_id 可选的场景)
|
||||
- 错误信息使用中文,与项目其他路由保持一致
|
||||
"""
|
||||
if not project_id or not project_id.strip():
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
|
||||
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
"""获取用户的订阅计划名称。"""
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
def require_project_and_library(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
"""Verify project and asset library exist."""
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
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")
|
||||
@@ -1,15 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
EnsureDefaultLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -20,13 +15,20 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
@@ -41,7 +43,6 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str | None = Query(None),
|
||||
kind: str | None = Query(None, pattern="^(video|voice|image)$"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
@@ -65,11 +66,6 @@ def list_asset_libraries(
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
# 按 kind 过滤(可选)
|
||||
if kind:
|
||||
kind_enum = AssetLibraryKind(kind)
|
||||
items = [item for item in items if item.kind == kind_enum]
|
||||
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@@ -94,80 +90,3 @@ def create_asset_library(
|
||||
)
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
|
||||
# 默认素材库名称映射
|
||||
_DEFAULT_LIBRARY_NAMES = {
|
||||
"video": "视频素材库",
|
||||
"voice": "配音素材库",
|
||||
"image": "图片素材库",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=AssetLibraryResponse)
|
||||
def ensure_default_library(
|
||||
request: EnsureDefaultLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
"""确保项目下指定 kind 的默认素材库存在,已存在则直接返回,不存在则自动创建。"""
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# 查找该项目下同 kind 的素材库,返回第一个
|
||||
existing = asset_library_repository.find_by_project(request.project_id)
|
||||
for lib in existing:
|
||||
if lib.kind == kind:
|
||||
return _to_asset_library_response(lib)
|
||||
|
||||
# 不存在 → 自动创建
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
|
||||
library = AssetLibrary(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=request.project_id,
|
||||
name=default_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_asset_library(
|
||||
library_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除素材库,同时删除库内所有素材。"""
|
||||
# 查找素材库
|
||||
library = asset_library_repository.find_by_id(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
|
||||
|
||||
# 权限校验:检查用户是否有项目访问权限
|
||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 删除库内所有素材(无 FK 级联,需手动清理)
|
||||
assets_in_library = asset_repository.find_by_library(library_id)
|
||||
if assets_in_library:
|
||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
||||
asset_repository.batch_delete(asset_ids_to_delete)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
@@ -7,28 +7,17 @@ from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_tag_repository,
|
||||
)
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchDeleteRequest,
|
||||
BatchDeleteResponse,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -70,22 +59,22 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
tag_ids=getattr(item, "tag_ids", []),
|
||||
)
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: Optional[str] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
|
||||
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
|
||||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
@@ -93,148 +82,32 @@ def list_assets(
|
||||
) -> ListAssetsResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# kind → file_type 映射(voice 对应 audio)
|
||||
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
||||
|
||||
# 解析 tag_ids 参数(逗号分隔)
|
||||
filter_tag_ids: list[str] | None = None
|
||||
if tag_ids:
|
||||
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
|
||||
if not filter_tag_ids:
|
||||
filter_tag_ids = None
|
||||
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
||||
|
||||
def _apply_memory_filters(items):
|
||||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
||||
result = items
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
result = [i for i in result if kw in (i.name or "").lower()]
|
||||
if gender:
|
||||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||||
if style:
|
||||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||||
if filter_tag_ids:
|
||||
tag_set = set(filter_tag_ids)
|
||||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||||
return result
|
||||
|
||||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||||
if not needs_memory_filter:
|
||||
ft = kind_to_file_type.get(kind) if kind else None
|
||||
|
||||
# 模式1:指定 library_id
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
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)
|
||||
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)
|
||||
total = asset_repository.count_by_project(library.project_id)
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式2:指定 project_id
|
||||
if project_id:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
if ft:
|
||||
# 无直接方法,加载后按 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)
|
||||
total = asset_repository.count_by_project(project_id)
|
||||
paged = items
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式3:跨项目(无 library_id/project_id)
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
project_ids = [p.id for p in projects]
|
||||
if not project_ids:
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
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],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
|
||||
# 模式1:指定 library_id → 返回该库的素材
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
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])
|
||||
else:
|
||||
all_items = asset_repository.find_by_library(library_id)
|
||||
elif project_id:
|
||||
check_project_access(project_id, user_id, project_repository)
|
||||
all_items = asset_repository.find_by_project(project_id)
|
||||
else:
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||
_check_project_access(library.project_id, user_id, project_repository)
|
||||
items = asset_repository.find_by_library(library_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||||
if kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
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]
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
# 模式2:指定 project_id → 返回该项目所有素材
|
||||
if project_id:
|
||||
_check_project_access(project_id, user_id, project_repository)
|
||||
items = asset_repository.find_by_project(project_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
# 模式3:都不传 → 返回用户可访问的所有项目的所有素材
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[])
|
||||
|
||||
all_items = []
|
||||
for proj in projects:
|
||||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in all_items])
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
@@ -256,136 +129,12 @@ def update_asset_review_status(
|
||||
item = asset_repository.get(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
_apply_asset_review_status(item, request.review_status)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
|
||||
for asset_id in request.ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_ids.append(asset_id)
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
deleted_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_ids.append(asset_id)
|
||||
|
||||
if deleted_ids:
|
||||
asset_repository.batch_delete(deleted_ids)
|
||||
|
||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
def get_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
return _to_asset_response(item)
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||||
def update_asset(
|
||||
asset_id: str,
|
||||
request: UpdateAssetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 合并可修改字段
|
||||
if request.name is not None:
|
||||
item.name = request.name
|
||||
if request.metadata is not None:
|
||||
item.metadata = {**item.metadata, **request.metadata}
|
||||
if request.tags is not None:
|
||||
item.metadata = {**item.metadata, "tags": request.tags}
|
||||
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
def delete_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
asset_repository.delete(asset_id)
|
||||
|
||||
|
||||
@router.post("/{asset_id}/tags", response_model=AssetResponse)
|
||||
def tag_asset(
|
||||
asset_id: str,
|
||||
request: TagAssetsRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> AssetResponse:
|
||||
"""给素材打标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
|
||||
if tag.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
|
||||
item.add_tag(tag_id)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
|
||||
def untag_asset(
|
||||
asset_id: str,
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""取消素材的标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
item.remove_tag(tag_id)
|
||||
asset_repository.update(item)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
|
||||
@@ -206,7 +206,7 @@ async def verify_email_post(
|
||||
return _verify_email_token(request.token, user_repository)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: PasswordResetRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
@@ -223,7 +223,7 @@ async def forgot_password(
|
||||
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
@router.post("/password/reset", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
@@ -243,6 +243,7 @@ async def logout(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""登出 - 将当前 token 加入黑名单"""
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
if credentials:
|
||||
try:
|
||||
|
||||
@@ -14,11 +14,11 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -34,8 +34,6 @@ from fastapi.params import File
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,33 +42,20 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
@@ -114,6 +99,22 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _require_project_and_library(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
"""Verify project and asset library exist"""
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
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 _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
||||
"""Load upload metadata"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
@@ -191,6 +192,7 @@ async def init_chunked_upload(
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> ChunkedUploadInitResponse:
|
||||
"""Initialize chunked upload"""
|
||||
settings = get_settings()
|
||||
|
||||
# Validate file size
|
||||
if request.file_size > MAX_FILE_SIZE:
|
||||
@@ -205,7 +207,7 @@ async def init_chunked_upload(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
# Verify asset library
|
||||
require_project_and_library(
|
||||
_require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
@@ -258,154 +260,6 @@ async def init_chunked_upload(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
|
||||
async def get_upload_status(
|
||||
upload_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ChunkedUploadStatusResponse:
|
||||
"""Get upload status (for resume)"""
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return ChunkedUploadStatusResponse(
|
||||
upload_id=upload_id,
|
||||
filename=meta["filename"],
|
||||
file_size=meta["file_size"],
|
||||
total_chunks=meta["total_chunks"],
|
||||
uploaded_chunks=sorted(meta["uploaded_chunks"]),
|
||||
status=meta["status"],
|
||||
created_at=datetime.fromisoformat(meta["created_at"]),
|
||||
expires_at=datetime.fromisoformat(meta["expires_at"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/complete", response_model=ChunkedUploadCompleteResponse)
|
||||
async def complete_chunked_upload(
|
||||
upload_id: str,
|
||||
request: ChunkedUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
"""Complete chunked upload, merge chunks"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Verify project ID and library ID
|
||||
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
|
||||
|
||||
# Verify all chunks are uploaded
|
||||
expected_chunks = set(range(meta["total_chunks"]))
|
||||
uploaded_chunks = set(meta["uploaded_chunks"])
|
||||
missing_chunks = expected_chunks - uploaded_chunks
|
||||
|
||||
if missing_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing chunks: {sorted(missing_chunks)}. Please upload remaining chunks first.",
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
sample_chunk_path = chunk_dir / "chunk_000000"
|
||||
if sample_chunk_path.exists():
|
||||
with open(sample_chunk_path, "rb") as f:
|
||||
sample_data = f.read(8192) # Read first 8KB for type detection
|
||||
detected_mime = _validate_file_type(sample_data, meta["filename"])
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}",
|
||||
)
|
||||
|
||||
# Merge chunks to temp file
|
||||
temp_file_path = CHUNK_STORAGE_ROOT / f"{upload_id}_complete.tmp"
|
||||
try:
|
||||
with open(temp_file_path, "wb") as out_file:
|
||||
for i in range(meta["total_chunks"]):
|
||||
chunk_path = chunk_dir / f"chunk_{i:06d}"
|
||||
with open(chunk_path, "rb") as in_file:
|
||||
shutil.copyfileobj(in_file, out_file)
|
||||
|
||||
# Verify file size
|
||||
actual_size = temp_file_path.stat().st_size
|
||||
if actual_size != meta["file_size"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"File size mismatch. Expected {meta['file_size']}, got {actual_size}",
|
||||
)
|
||||
|
||||
# Upload to OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = meta["filename"]
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
str(temp_file_path),
|
||||
storage_key,
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id="",
|
||||
url=file_url,
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
url=file_url,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temp file and chunks
|
||||
if temp_file_path.exists():
|
||||
temp_file_path.unlink()
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
# Delete metadata file
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
|
||||
@router.post("/{upload_id}/{chunk_index}")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
@@ -477,3 +331,126 @@ async def upload_chunk(
|
||||
"uploaded_chunks": len(meta["uploaded_chunks"]),
|
||||
"total_chunks": meta["total_chunks"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
|
||||
async def get_upload_status(
|
||||
upload_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ChunkedUploadStatusResponse:
|
||||
"""Get upload status (for resume)"""
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return ChunkedUploadStatusResponse(
|
||||
upload_id=upload_id,
|
||||
filename=meta["filename"],
|
||||
file_size=meta["file_size"],
|
||||
total_chunks=meta["total_chunks"],
|
||||
uploaded_chunks=sorted(meta["uploaded_chunks"]),
|
||||
status=meta["status"],
|
||||
created_at=datetime.fromisoformat(meta["created_at"]),
|
||||
expires_at=datetime.fromisoformat(meta["expires_at"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/complete", response_model=ChunkedUploadCompleteResponse)
|
||||
async def complete_chunked_upload(
|
||||
upload_id: str,
|
||||
request: ChunkedUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
"""Complete chunked upload, merge chunks"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Verify project ID and library ID
|
||||
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
|
||||
|
||||
# Verify all chunks are uploaded
|
||||
expected_chunks = set(range(meta["total_chunks"]))
|
||||
uploaded_chunks = set(meta["uploaded_chunks"])
|
||||
missing_chunks = expected_chunks - uploaded_chunks
|
||||
|
||||
if missing_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing chunks: {sorted(missing_chunks)}. Please upload remaining chunks first.",
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
sample_chunk_path = chunk_dir / "chunk_000000"
|
||||
if sample_chunk_path.exists():
|
||||
with open(sample_chunk_path, "rb") as f:
|
||||
sample_data = f.read(8192) # Read first 8KB for type detection
|
||||
detected_mime = _validate_file_type(sample_data, meta["filename"])
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}",
|
||||
)
|
||||
|
||||
# Merge chunks to temp file
|
||||
temp_file_path = CHUNK_STORAGE_ROOT / f"{upload_id}_complete.tmp"
|
||||
try:
|
||||
with open(temp_file_path, "wb") as out_file:
|
||||
for i in range(meta["total_chunks"]):
|
||||
chunk_path = chunk_dir / f"chunk_{i:06d}"
|
||||
with open(chunk_path, "rb") as in_file:
|
||||
shutil.copyfileobj(in_file, out_file)
|
||||
|
||||
# Verify file size
|
||||
actual_size = temp_file_path.stat().st_size
|
||||
if actual_size != meta["file_size"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"File size mismatch. Expected {meta['file_size']}, got {actual_size}",
|
||||
)
|
||||
|
||||
# Upload to OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = meta["filename"]
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
str(temp_file_path),
|
||||
storage_key,
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
url=file_url,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temp file and chunks
|
||||
if temp_file_path.exists():
|
||||
temp_file_path.unlink()
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
# Delete metadata file
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
Executable → Regular
+63
-344
@@ -10,8 +10,6 @@ RESTful CRUD for EditPlan:
|
||||
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
|
||||
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
|
||||
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
|
||||
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
|
||||
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
|
||||
|
||||
业务逻辑委托给 EditPlanService 服务层。
|
||||
"""
|
||||
@@ -24,10 +22,9 @@ from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService, PlanGeneratorService
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -35,18 +32,10 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
@@ -202,47 +191,20 @@ class GenerateCoverResponse(BaseModel):
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateFromTemplateRequest(BaseModel):
|
||||
"""基于模板生成剪辑计划请求体"""
|
||||
|
||||
template_id: str = Field(..., description="剪辑模板 ID")
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||
project_id: str = Field(default="", description="所属项目 ID")
|
||||
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
|
||||
|
||||
|
||||
class _PlanClipItem(BaseModel):
|
||||
"""片段响应体"""
|
||||
|
||||
id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str
|
||||
text_content: str
|
||||
start_time: float
|
||||
duration: float
|
||||
transition_effect: str
|
||||
status: str
|
||||
config: Optional[dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GenerateFromTemplateResponse(BaseModel):
|
||||
"""基于模板生成剪辑计划响应体"""
|
||||
|
||||
plan: EditPlanResponse
|
||||
clips: List[_PlanClipItem]
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
|
||||
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
|
||||
if not project_id or not project_id.strip():
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=p.id,
|
||||
@@ -291,12 +253,12 @@ def list_plans(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的筛选条件,请选择正确的状态",
|
||||
detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
|
||||
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||
if project_id:
|
||||
check_project_access(project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
plans = svc.list_plans(
|
||||
@@ -338,7 +300,7 @@ def get_plan(
|
||||
)
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return _to_response(plan)
|
||||
|
||||
|
||||
@@ -354,7 +316,7 @@ def create_plan(
|
||||
project_id = (body.project_id or "").strip()
|
||||
# 项目鉴权
|
||||
if project_id:
|
||||
check_project_access(project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(project_id, current_user.user.id, project_repository)
|
||||
svc = EditPlanService(db)
|
||||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||
normalized_config = normalize_plan_config(body.config)
|
||||
@@ -396,7 +358,7 @@ def update_plan(
|
||||
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)
|
||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 基础字段更新
|
||||
try:
|
||||
@@ -417,7 +379,7 @@ def update_plan(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的状态值,请选择正确的状态",
|
||||
detail=(f"无效的状态值: {body.status}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
svc.transition_status(plan_id, target_status)
|
||||
except ValueError as exc:
|
||||
@@ -450,7 +412,7 @@ def delete_plan(
|
||||
# 项目鉴权
|
||||
existing = svc.get_plan(plan_id)
|
||||
if existing and existing.project_id:
|
||||
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
deleted = svc.delete_plan(plan_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
@@ -473,8 +435,6 @@ def generate_plan(
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
@@ -492,122 +452,7 @@ def generate_plan(
|
||||
if plan_check is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan_check.project_id:
|
||||
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||
else:
|
||||
# 回退到旧模型 template_segments
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main", # 旧模型无结构角色,统一为主体片段
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||
|
||||
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
|
||||
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = [] # 已分配完
|
||||
|
||||
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
|
||||
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
|
||||
import random
|
||||
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 找到项目的视频素材库
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
# 筛选 ready 状态的视频素材
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
# 随机选取,按片段数轮询分配
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 检查是否可生成
|
||||
try:
|
||||
@@ -623,89 +468,48 @@ def generate_plan(
|
||||
detail=reason,
|
||||
)
|
||||
|
||||
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
|
||||
try:
|
||||
# 将 pending 片段标记为 ready
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
# 将 pending 片段标记为 ready
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
|
||||
# 创建 GenerationTask
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 队列限流预检查(repository 不支持计数时跳过)
|
||||
user_id = current_user.user.id
|
||||
try:
|
||||
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
||||
gen_task_repo, "count_pending_total"
|
||||
)
|
||||
if has_count:
|
||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||
global_pending = gen_task_repo.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
# 创建 GenerationTask
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 将 generation_task_id 存入 plan config
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
# 将 generation_task_id 存入 plan config
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
|
||||
# 流转状态为 rendering
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
# 流转状态为 rendering
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
|
||||
# 调度 Celery 任务
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
# 调度 Celery 任务
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
# 获取最新状态
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
# 获取最新状态
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
# 已处理的 HTTP 异常直接透传
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
||||
try:
|
||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -734,7 +538,7 @@ def get_generation_status(
|
||||
plan = gen_status["plan"]
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
clips = gen_status["clips"]
|
||||
|
||||
clip_items = [
|
||||
@@ -776,7 +580,7 @@ def list_plan_generations(
|
||||
# 验证计划存在 + 项目鉴权
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
@@ -845,14 +649,14 @@ def ai_recommend_clips(
|
||||
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证状态:只允许 draft 或 editing
|
||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||
if plan_status not in ("draft", "editing"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
|
||||
)
|
||||
|
||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||||
@@ -894,7 +698,7 @@ def ai_recommend_clips(
|
||||
config=normalized_config,
|
||||
total_duration=result["total_duration"],
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||
# 尝试回滚未提交的变更
|
||||
try:
|
||||
@@ -903,7 +707,7 @@ def ai_recommend_clips(
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
detail=f"AI 推荐结果写入失败: {exc}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -975,7 +779,7 @@ def generate_cover(
|
||||
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 调用 AI 封面生成服务
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
@@ -1095,7 +899,7 @@ def get_plan_timeline(
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
# 项目鉴权
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||
# 按 order 排序
|
||||
@@ -1129,88 +933,3 @@ def get_plan_timeline(
|
||||
total_duration=total_duration,
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
|
||||
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/generate-from-template",
|
||||
response_model=GenerateFromTemplateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def generate_from_template(
|
||||
body: GenerateFromTemplateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> GenerateFromTemplateResponse:
|
||||
"""基于模板 + 素材自动生成剪辑计划
|
||||
|
||||
流程:
|
||||
1. 获取模板及其片段配置
|
||||
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
|
||||
3. 返回完整的计划和片段列表
|
||||
"""
|
||||
from app.services import EditTemplateService
|
||||
|
||||
# 项目鉴权
|
||||
if body.project_id:
|
||||
check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||
|
||||
template_svc = EditTemplateService(db)
|
||||
|
||||
# 获取模板
|
||||
try:
|
||||
template = template_svc.get_template_or_raise(body.template_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
# 获取模板片段配置
|
||||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||
|
||||
# 调用 PlanGeneratorService 生成计划
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=body.asset_ids,
|
||||
project_id=body.project_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
name=body.name,
|
||||
)
|
||||
|
||||
plan = result["plan"]
|
||||
clips = result["clips"]
|
||||
|
||||
logger.info(
|
||||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
||||
plan.id,
|
||||
body.template_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateFromTemplateResponse(
|
||||
plan=_to_response(plan),
|
||||
clips=[
|
||||
_PlanClipItem(
|
||||
id=c.id,
|
||||
clip_type=c.clip_type,
|
||||
order=c.order,
|
||||
asset_id=c.asset_id,
|
||||
text_content=c.text_content,
|
||||
start_time=c.start_time,
|
||||
duration=c.duration,
|
||||
transition_effect=c.transition_effect,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
config=c.config,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
for c in clips
|
||||
],
|
||||
)
|
||||
|
||||
@@ -41,9 +41,6 @@ class EditTemplateCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
description: str = Field(default="", max_length=2000, description="模板描述")
|
||||
template_type: str = Field(default="default", max_length=50, description="模板类型")
|
||||
editing_mode: str = Field(
|
||||
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
||||
)
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
|
||||
preview_url: str = Field(default="", max_length=500, description="预览地址")
|
||||
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
|
||||
@@ -55,9 +52,6 @@ class EditTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
||||
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
|
||||
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
|
||||
editing_mode: Optional[str] = Field(
|
||||
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
|
||||
)
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
|
||||
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
|
||||
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
|
||||
@@ -71,7 +65,6 @@ class EditTemplateResponse(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
template_type: str
|
||||
editing_mode: str
|
||||
config: dict[str, Any]
|
||||
preview_url: str
|
||||
sort_weight: int
|
||||
@@ -109,7 +102,6 @@ def _to_response(t: EditTemplate) -> EditTemplateResponse:
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
template_type=t.template_type,
|
||||
editing_mode=t.editing_mode,
|
||||
config=t.config,
|
||||
preview_url=t.preview_url,
|
||||
sort_weight=t.sort_weight,
|
||||
@@ -203,7 +195,6 @@ def create_template(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
editing_mode=body.editing_mode,
|
||||
config=normalized_config,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
@@ -248,7 +239,6 @@ def update_template(
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
editing_mode=body.editing_mode,
|
||||
config=config_to_update,
|
||||
preview_url=body.preview_url,
|
||||
sort_weight=body.sort_weight,
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Feature Flag 内部管理接口。
|
||||
|
||||
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
|
||||
主要用于灰度发布期间的动态开关控制。
|
||||
|
||||
API:
|
||||
GET /api/v1/internal/feature-flags - 列出所有 flag
|
||||
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
|
||||
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
|
||||
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
|
||||
|
||||
鉴权:X-API-Key header,走内部 API Key 验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes.auth import _verify_internal_api_key
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FEATURE_FLAG_REDIS_PREFIX,
|
||||
FeatureFlagConfig,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||
|
||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||
ALLOWED_FLAGS = {
|
||||
"render_engine",
|
||||
}
|
||||
|
||||
|
||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||
"""获取 Feature Flag 存储实例。"""
|
||||
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
|
||||
|
||||
|
||||
class FeatureFlagUpdateRequest(BaseModel):
|
||||
"""Feature Flag 更新请求体。"""
|
||||
|
||||
enabled: bool = Field(..., description="是否启用")
|
||||
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
|
||||
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id)")
|
||||
|
||||
|
||||
class FeatureFlagResponse(BaseModel):
|
||||
"""Feature Flag 响应。"""
|
||||
|
||||
name: str
|
||||
enabled: bool
|
||||
percentage: int
|
||||
whitelist: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
|
||||
return cls(
|
||||
name=config.name,
|
||||
enabled=config.enabled,
|
||||
percentage=config.percentage,
|
||||
whitelist=sorted(config.whitelist),
|
||||
)
|
||||
|
||||
|
||||
class FeatureFlagCheckResponse(BaseModel):
|
||||
"""Flag 激活检查响应。"""
|
||||
|
||||
name: str
|
||||
active: bool
|
||||
identifier: Optional[str] = None
|
||||
|
||||
|
||||
def _validate_flag_name(name: str) -> None:
|
||||
"""校验 flag 名称是否在允许列表中。"""
|
||||
if name not in ALLOWED_FLAGS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FeatureFlagResponse])
|
||||
async def list_feature_flags(
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""列出所有 Feature Flag。"""
|
||||
try:
|
||||
flags = store.list_all()
|
||||
# 同时返回预定义的 flag(即使未设置也显示默认值)
|
||||
result = []
|
||||
for name in sorted(ALLOWED_FLAGS):
|
||||
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
# 加上已存在但不在白名单中的 flag(只读展示)
|
||||
for name, config in flags.items():
|
||||
if name not in ALLOWED_FLAGS:
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
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}")
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||
async def get_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""获取单个 Feature Flag 配置。"""
|
||||
try:
|
||||
config = store.get(name)
|
||||
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}")
|
||||
|
||||
|
||||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||
async def check_feature_flag(
|
||||
name: str,
|
||||
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""检查某个标识符是否命中 Feature Flag。"""
|
||||
try:
|
||||
active = store.is_active(name, identifier=identifier)
|
||||
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}")
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||
async def update_feature_flag(
|
||||
name: str,
|
||||
request: FeatureFlagUpdateRequest,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""更新 Feature Flag 配置。
|
||||
|
||||
只允许修改 ALLOWED_FLAGS 列表中的 flag。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
config = FeatureFlagConfig(
|
||||
name=name,
|
||||
enabled=request.enabled,
|
||||
percentage=request.percentage,
|
||||
whitelist=set(request.whitelist),
|
||||
)
|
||||
store.set(config)
|
||||
logger.info(
|
||||
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
|
||||
name,
|
||||
config.enabled,
|
||||
config.percentage,
|
||||
len(config.whitelist),
|
||||
)
|
||||
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}")
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
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。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
deleted = store.delete(name)
|
||||
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||||
return None
|
||||
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}")
|
||||
Executable → Regular
+38
-223
@@ -1,18 +1,7 @@
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
check_queue_limits,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
@@ -25,15 +14,12 @@ from app.schemas.generated_video import (
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
@@ -41,10 +27,18 @@ from packages.application import (
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
@@ -57,9 +51,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -67,7 +58,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
@@ -80,7 +71,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,49 +85,6 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
"""
|
||||
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
|
||||
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
if mode == "random":
|
||||
selected = (
|
||||
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
|
||||
)
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||
sorted_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
a.quality_score if a.quality_score is not None else 0.0,
|
||||
a.duration if a.duration is not None else 0.0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||
return [a.id for a in selected]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -175,7 +122,7 @@ def _resolve_project_and_library(
|
||||
return project_id, asset_library_id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -183,130 +130,37 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
authenticated_user.user.id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.asset_select_mode,
|
||||
request.count,
|
||||
) -> GenerationTaskResponse:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
|
||||
try:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
|
||||
resolved_asset_ids: list[str] = list(request.asset_ids)
|
||||
if asset_library_id:
|
||||
library = asset_library_repository.get(asset_library_id)
|
||||
if library is None or (project_id and library.project_id != project_id):
|
||||
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
|
||||
|
||||
assets = asset_repository.find_by_library(asset_library_id)
|
||||
try:
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
failed_tasks = []
|
||||
user_id = authenticated_user.user.id
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
# 预检查:批量提交前先看会不会超限,避免建一半才拒
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
try:
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
)
|
||||
break
|
||||
except GlobalQueueFull:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=request.asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
@@ -332,7 +186,7 @@ def get_generation_task(
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@@ -343,20 +197,15 @@ def list_generation_results(
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
responses = []
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
|
||||
@@ -375,21 +224,6 @@ def retry_generation_task(
|
||||
if status_val != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查:创建前判断,>= 上限就拒绝
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -401,28 +235,9 @@ def retry_generation_task(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""渲染结果内部下载接口。
|
||||
|
||||
通过内部 API Key 鉴权,为灰度对比工具等内部系统提供渲染结果下载能力。
|
||||
|
||||
API:
|
||||
GET /api/v1/internal/render/videos/{video_id}/download-url - 获取单个视频下载URL
|
||||
GET /api/v1/internal/render/tasks/{task_id}/videos - 获取任务下所有视频及下载URL
|
||||
|
||||
鉴权:X-API-Key header,走内部 API Key 验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.auth import _verify_internal_api_key
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/render", tags=["Internal"])
|
||||
|
||||
|
||||
class InternalRenderVideoItem(BaseModel):
|
||||
"""内部渲染视频项。"""
|
||||
|
||||
video_id: str
|
||||
generation_task_id: str
|
||||
project_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
status: str
|
||||
download_url: str
|
||||
|
||||
|
||||
class InternalRenderTaskVideosResponse(BaseModel):
|
||||
"""任务下所有渲染视频响应。"""
|
||||
|
||||
task_id: str
|
||||
count: int
|
||||
videos: list[InternalRenderVideoItem]
|
||||
|
||||
|
||||
class InternalRenderDownloadUrlResponse(BaseModel):
|
||||
"""单个视频下载URL响应。"""
|
||||
|
||||
video_id: str
|
||||
download_url: str
|
||||
|
||||
|
||||
def _video_to_item(video: Any, download_url: str) -> InternalRenderVideoItem:
|
||||
"""将 GeneratedVideo 领域对象转为响应项。"""
|
||||
return InternalRenderVideoItem(
|
||||
video_id=video.id,
|
||||
generation_task_id=video.generation_task_id,
|
||||
project_id=video.project_id,
|
||||
name=video.name,
|
||||
file_url=video.file_url,
|
||||
file_size=getattr(video, "file_size", None),
|
||||
duration=getattr(video, "duration", None),
|
||||
width=getattr(video, "width", None),
|
||||
height=getattr(video, "height", None),
|
||||
fps=getattr(video, "fps", None),
|
||||
status=video.status,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}/download-url", response_model=InternalRenderDownloadUrlResponse)
|
||||
def get_render_video_download_url(
|
||||
video_id: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> InternalRenderDownloadUrlResponse:
|
||||
"""获取单个渲染视频的下载URL(预签名)。"""
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
logger.info("内部渲染下载URL生成: video_id=%s", video_id)
|
||||
return InternalRenderDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/videos", response_model=InternalRenderTaskVideosResponse)
|
||||
def get_render_task_videos(
|
||||
task_id: str,
|
||||
status: str | None = Query(None, description="按状态筛选,如 completed/failed"),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> InternalRenderTaskVideosResponse:
|
||||
"""获取生成任务下所有渲染视频及下载URL。"""
|
||||
videos = generated_video_repository.list_by_generation_task(task_id)
|
||||
|
||||
# 状态筛选
|
||||
if status:
|
||||
videos = [v for v in videos if v.status == status]
|
||||
|
||||
items = []
|
||||
for video in videos:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
items.append(_video_to_item(video, download_url))
|
||||
|
||||
logger.info("内部渲染任务视频查询: task_id=%s count=%d", task_id, len(items))
|
||||
return InternalRenderTaskVideosResponse(
|
||||
task_id=task_id,
|
||||
count=len(items),
|
||||
videos=items,
|
||||
)
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_job_repository, get_project_repository
|
||||
from app.dependencies import get_db_session, get_job_repository, get_project_repository
|
||||
from app.schemas.job import (
|
||||
CompleteJobRequest,
|
||||
CreateJobRequest,
|
||||
@@ -51,8 +51,6 @@ from packages.application.jobs import (
|
||||
)
|
||||
from packages.domain.job import JobType
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -68,6 +66,15 @@ _JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限。"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -82,7 +89,7 @@ def create_job(
|
||||
|
||||
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
|
||||
"""
|
||||
check_project_access(request.project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 校验 job_type
|
||||
try:
|
||||
@@ -175,7 +182,7 @@ def list_project_jobs(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ListJobsResponse:
|
||||
"""获取项目下的任务列表。"""
|
||||
check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
use_case = ListJobsUseCase(job_repo)
|
||||
jobs = use_case.execute(
|
||||
@@ -197,7 +204,7 @@ def get_job_statistics(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> JobStatisticsResponse:
|
||||
"""获取项目任务统计摘要。"""
|
||||
check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
use_case = GetJobStatisticsUseCase(job_repo)
|
||||
stats = use_case.execute(project_id)
|
||||
|
||||
@@ -33,8 +33,6 @@ from packages.application.recipe.use_cases import (
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -42,6 +40,13 @@ def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlc
|
||||
return SQLAlchemyRecipeRepository(session)
|
||||
|
||||
|
||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
def _item_to_response(item) -> RecipeItemResponse:
|
||||
return RecipeItemResponse(
|
||||
id=item.id,
|
||||
@@ -189,7 +194,7 @@ def use_recipe(
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UseRecipeResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = get_user_plan(user_id, user_repository)
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
use_case = UseRecipeUseCase(recipe_repository)
|
||||
try:
|
||||
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
|
||||
|
||||
@@ -232,7 +232,7 @@ async def payment_callback(
|
||||
|
||||
# 创建账单记录
|
||||
record_id = uuid.uuid4().hex
|
||||
repo.create(
|
||||
record = repo.create(
|
||||
{
|
||||
"id": record_id,
|
||||
"user_id": user_id,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""标签 CRUD 路由。"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_tag_repository
|
||||
from app.schemas.tag import (
|
||||
CreateTagRequest,
|
||||
ListTagsResponse,
|
||||
TagResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=ListTagsResponse)
|
||||
def list_tags(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> ListTagsResponse:
|
||||
"""列出当前用户的标签。"""
|
||||
user_id = authenticated_user.user.id
|
||||
items = tag_repository.list_by_user(user_id, skip=skip, limit=limit)
|
||||
total = tag_repository.count_by_user(user_id)
|
||||
return ListTagsResponse(
|
||||
items=[TagResponse(id=t.id, name=t.name, created_at=t.created_at) for t in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=201)
|
||||
def create_tag(
|
||||
request: CreateTagRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> TagResponse:
|
||||
"""创建标签(同用户同名去重,返回 409)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
existing = tag_repository.find_by_name(user_id, request.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="标签名称已存在")
|
||||
tag = Tag.create(user_id=user_id, name=request.name)
|
||||
created = tag_repository.create(tag)
|
||||
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
def delete_tag(
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> None:
|
||||
"""删除标签(同时清理素材关联)。"""
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="标签不存在")
|
||||
if tag.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该标签")
|
||||
tag_repository.delete(tag_id)
|
||||
Executable → Regular
+5
-73
@@ -1,15 +1,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
@@ -30,10 +22,9 @@ from packages.application import (
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
@@ -148,21 +139,6 @@ def retry_task_by_id(
|
||||
if _status_value(task.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -174,24 +150,10 @@ def retry_task_by_id(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
):
|
||||
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return UserTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
@@ -259,22 +221,6 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
if _status_value(task.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
@@ -286,24 +232,10 @@ def retry_project_task(
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
):
|
||||
logger.warning("[任务中心] 项目级重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
return _generation_task_to_project_response(retried)
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
|
||||
@@ -28,8 +28,6 @@ from packages.application.title_library.use_cases import (
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -53,6 +51,13 @@ def _to_response(item) -> TitleLibraryItemResponse:
|
||||
)
|
||||
|
||||
|
||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
@router.get("", response_model=ListTitleLibraryResponse)
|
||||
def list_titles(
|
||||
category: Optional[str] = Query(None),
|
||||
@@ -93,7 +98,7 @@ def create_title(
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = get_user_plan(user_id, user_repository)
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
command = CreateTitleLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
|
||||
Executable → Regular
+16
-190
@@ -6,32 +6,21 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_user_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
from app.dependencies import get_cosyvoice_service, get_db_session
|
||||
from app.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
SaveToLibraryRequest,
|
||||
SaveToLibraryResponse,
|
||||
TTSJobResponse,
|
||||
TTSStatusResponse,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
SQLAlchemyTTSJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
from packages.application.tts_job.use_cases import (
|
||||
CreateTTSJobUseCase,
|
||||
DeleteTTSJobUseCase,
|
||||
@@ -41,12 +30,6 @@ from packages.application.tts_job.use_cases import (
|
||||
TTSJobNotFoundError,
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,10 +40,7 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS
|
||||
return SQLAlchemyTTSJobRepository(session)
|
||||
|
||||
|
||||
def _to_response(job, sign_url=None) -> TTSJobResponse:
|
||||
output_url = job.output_audio_url
|
||||
if sign_url and output_url:
|
||||
output_url = sign_url(output_url)
|
||||
def _to_response(job) -> TTSJobResponse:
|
||||
return TTSJobResponse(
|
||||
id=job.id,
|
||||
user_id=job.user_id,
|
||||
@@ -70,7 +50,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
|
||||
project_id=job.project_id,
|
||||
voice_clone_profile_id=job.voice_clone_profile_id,
|
||||
status=job.status,
|
||||
output_audio_url=output_url,
|
||||
output_audio_url=job.output_audio_url,
|
||||
output_audio_key=job.output_audio_key,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
@@ -93,7 +73,6 @@ def synthesize(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSSynthesizeResponse:
|
||||
"""发起 TTS 合成任务。
|
||||
|
||||
@@ -101,21 +80,6 @@ def synthesize(
|
||||
与音色克隆接口保持一致:CosyVoice 失败时不抛 500,而是返回 201 + failed 状态任务记录。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
|
||||
if request.voice_clone_profile_id:
|
||||
profile = voice_clone_repo.get(request.voice_clone_profile_id)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Voice clone profile not found",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to voice clone profile",
|
||||
)
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -146,25 +110,18 @@ def synthesize(
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
# 分段合成任务 vs 普通单段任务
|
||||
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
|
||||
is_segment = len(segment_task_ids) > 0
|
||||
|
||||
try:
|
||||
if is_segment:
|
||||
from worker_app.tasks import process_tts_segment_synthesis
|
||||
|
||||
process_tts_segment_synthesis.delay(job.id)
|
||||
else:
|
||||
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_tts_synthesis
|
||||
|
||||
process_tts_synthesis.delay(job.id)
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
|
||||
|
||||
return TTSSynthesizeResponse(
|
||||
job_id=job.id,
|
||||
@@ -180,7 +137,6 @@ def list_tts_jobs(
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> ListTTSJobResponse:
|
||||
"""列出用户的 TTS 合成任务。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -188,7 +144,7 @@ def list_tts_jobs(
|
||||
skip = (page - 1) * page_size
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
|
||||
return ListTTSJobResponse(
|
||||
items=[_to_response(j, sign_url) for j in items],
|
||||
items=[_to_response(j) for j in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
@@ -200,7 +156,6 @@ def get_tts_job(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> TTSJobResponse:
|
||||
"""获取 TTS 任务详情。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -209,7 +164,7 @@ def get_tts_job(
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return _to_response(job, sign_url)
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
|
||||
@@ -217,7 +172,6 @@ def get_tts_job_status(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> TTSStatusResponse:
|
||||
"""查询 TTS 合成状态(用于前端轮询)。"""
|
||||
user_id = authenticated_user.user.id
|
||||
@@ -226,13 +180,10 @@ def get_tts_job_status(
|
||||
job = use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
output_url = job.output_audio_url
|
||||
if output_url:
|
||||
output_url = sign_url(output_url)
|
||||
return TTSStatusResponse(
|
||||
id=job.id,
|
||||
status=job.status,
|
||||
output_audio_url=output_url,
|
||||
output_audio_url=job.output_audio_url,
|
||||
error_message=job.error_message,
|
||||
duration=job.duration,
|
||||
retry_count=job.retry_count,
|
||||
@@ -254,128 +205,3 @@ def delete_tts_job(
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/save-to-library",
|
||||
response_model=SaveToLibraryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def save_tts_job_to_library(
|
||||
job_id: str,
|
||||
request: SaveToLibraryRequest = SaveToLibraryRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
|
||||
自动携带音色名、时长、语速等元信息。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 获取 TTS job
|
||||
get_use_case = GetTTSJobUseCase(tts_repository)
|
||||
try:
|
||||
job = get_use_case.execute(job_id, user_id)
|
||||
except TTSJobNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
|
||||
# 校验已完成
|
||||
if not job.is_completed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TTS job is not completed yet",
|
||||
)
|
||||
|
||||
# 构建配音素材名称
|
||||
name = request.name or f"TTS-{job.id[:8]}"
|
||||
|
||||
# 构建元信息
|
||||
metadata_ = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
}
|
||||
if job.metadata:
|
||||
# 保留原始 job 的有用元信息
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
# 获取用户套餐(用于配额检查)
|
||||
user = user_repository.find_by_id(user_id)
|
||||
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
|
||||
|
||||
# 构建命令并执行
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
)
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name or "free")
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
audio_url=sign_url(item.audio_url) if item.audio_url else "",
|
||||
duration=item.duration,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
status=item.status,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/tts/stream")
|
||||
async def tts_websocket_stream(
|
||||
websocket: WebSocket,
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> None:
|
||||
"""WebSocket 流式 TTS 合成。
|
||||
|
||||
协议:
|
||||
1. 客户端发送 JSON 文本帧: {"text": "...", "voice_id": "...", ...}
|
||||
2. 服务端发送 JSON 状态帧 + 二进制音频帧
|
||||
3. 完成时发送 JSON 结束帧
|
||||
"""
|
||||
await websocket.accept()
|
||||
try:
|
||||
message = await websocket.receive_json()
|
||||
params = {
|
||||
"text": message.get("text", ""),
|
||||
"voice_id": message.get("voice_id", ""),
|
||||
"sample_rate": message.get("sample_rate", 0),
|
||||
"format": message.get("format", "mp3"),
|
||||
"speed": message.get("speed", 1.0),
|
||||
}
|
||||
streaming_service = TTSStreamingService(cosyvoice_service)
|
||||
await streaming_service.synthesize_and_stream(websocket, params)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket 客户端断开连接")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -8,7 +8,6 @@ from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -17,13 +16,12 @@ from app.schemas.upload import (
|
||||
DirectUploadCompleteResponse,
|
||||
DirectUploadPrepareRequest,
|
||||
DirectUploadPrepareResponse,
|
||||
UploadAssetRequest,
|
||||
UploadAssetResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,12 +79,26 @@ def _validate_mime_type(content_type: str | None) -> str:
|
||||
return base_type
|
||||
|
||||
|
||||
def _require_project_and_library(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
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 _submit_ingest_job(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
file_hash: str = "",
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -94,7 +106,6 @@ def _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
@@ -121,7 +132,7 @@ async def prepare_direct_upload(
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(request.content_type)
|
||||
|
||||
require_project_and_library(
|
||||
_require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
@@ -165,11 +176,10 @@ async def complete_direct_upload(
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
require_project_and_library(
|
||||
_require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
@@ -189,32 +199,11 @@ async def complete_direct_upload(
|
||||
if not file_exists:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
@@ -229,37 +218,14 @@ async def upload_asset(
|
||||
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
||||
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
@@ -289,7 +255,6 @@ async def upload_asset(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
|
||||
@@ -28,6 +28,7 @@ from packages.application.voice_clone.use_cases import (
|
||||
VoiceCloneNotRetryableError,
|
||||
)
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowError,
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
|
||||
@@ -37,7 +38,6 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||
return VoiceCloneProfileResponse(
|
||||
id=profile.id,
|
||||
user_id=profile.user_id,
|
||||
|
||||
Executable → Regular
+18
-25
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.schemas.voice import (
|
||||
PresetVoiceItemResponse,
|
||||
PresetVoiceListResponse,
|
||||
@@ -39,8 +39,6 @@ from packages.application.voice_library.use_cases import (
|
||||
from packages.domain.preset_voices import PRESET_VOICES
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -52,10 +50,7 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) ->
|
||||
return SQLAlchemyVoiceCloneProfileRepository(session)
|
||||
|
||||
|
||||
def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
|
||||
audio = item.audio_url
|
||||
if sign_url and audio:
|
||||
audio = sign_url(audio)
|
||||
def _to_response(item) -> VoiceLibraryItemResponse:
|
||||
return VoiceLibraryItemResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
@@ -64,7 +59,7 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
|
||||
voice_provider=item.voice_provider,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
audio_url=audio,
|
||||
audio_url=item.audio_url,
|
||||
duration=item.duration,
|
||||
file_size=item.file_size,
|
||||
status=item.status,
|
||||
@@ -75,20 +70,16 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse:
|
||||
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
|
||||
"""将数据库音色转换为统一响应格式。
|
||||
|
||||
Args:
|
||||
item: VoiceLibraryItem
|
||||
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
|
||||
sign_url: 音频URL预签名函数
|
||||
"""
|
||||
profile_id = None
|
||||
if profile_id_map and item.voice_id:
|
||||
profile_id = profile_id_map.get(item.voice_id)
|
||||
audio = item.audio_url
|
||||
if sign_url and audio:
|
||||
audio = sign_url(audio)
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=item.id,
|
||||
type="clone",
|
||||
@@ -98,7 +89,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
|
||||
language="zh-CN",
|
||||
voice_id=item.voice_id,
|
||||
voice_provider=item.voice_provider or "cosyvoice",
|
||||
audio_url=audio,
|
||||
audio_url=item.audio_url,
|
||||
duration=item.duration,
|
||||
file_size=item.file_size,
|
||||
status=item.status,
|
||||
@@ -127,6 +118,13 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||
)
|
||||
|
||||
|
||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||
|
||||
|
||||
@@ -142,7 +140,6 @@ def list_voices_unified(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> UnifiedVoiceListResponse:
|
||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||
|
||||
@@ -170,7 +167,7 @@ def list_voices_unified(
|
||||
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
||||
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
||||
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
|
||||
|
||||
# 组装结果
|
||||
if type == "preset":
|
||||
@@ -227,7 +224,6 @@ def list_voices_legacy(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> ListVoiceLibraryResponse:
|
||||
"""原有配音列表接口(仅返回用户克隆音色)。
|
||||
|
||||
@@ -237,7 +233,7 @@ def list_voices_legacy(
|
||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
return ListVoiceLibraryResponse(
|
||||
items=[_to_response(i, sign_url) for i in items],
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -247,14 +243,13 @@ def get_voice(
|
||||
voice_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = GetVoiceLibraryUseCase(voice_repository)
|
||||
item = use_case.execute(voice_id, user_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item, sign_url)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -263,10 +258,9 @@ def create_voice(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
plan_name = get_user_plan(user_id, user_repository)
|
||||
plan_name = _get_user_plan(user_id, user_repository)
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
@@ -289,7 +283,7 @@ def create_voice(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
)
|
||||
return _to_response(item, sign_url)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
||||
@@ -298,7 +292,6 @@ def update_voice(
|
||||
request: UpdateVoiceLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> VoiceLibraryItemResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
command = UpdateVoiceLibraryCommand(
|
||||
@@ -320,7 +313,7 @@ def update_voice(
|
||||
item = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return _to_response(item, sign_url)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
|
||||
+1
-30
@@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYCLE: int = 3600
|
||||
DATABASE_POOL_RECYLE: int = 3600
|
||||
USE_IN_MEMORY_DB: bool = False
|
||||
AUTO_CREATE_SCHEMA: bool = False
|
||||
|
||||
@@ -41,11 +41,6 @@ class Settings(BaseSettings):
|
||||
# 密钥轮换天数(到达此天数后建议更换密钥)
|
||||
SECRET_ROTATION_DAYS: int = 90
|
||||
|
||||
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
@@ -84,27 +79,6 @@ class Settings(BaseSettings):
|
||||
OSS_ACCESS_KEY_ID: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_id(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_secret(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
||||
default=2000,
|
||||
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
||||
@@ -114,9 +88,6 @@ class Settings(BaseSettings):
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
RENDER_ENGINE: str = "legacy"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core configuration package."""
|
||||
@@ -34,18 +34,13 @@ class OSSStorageService:
|
||||
if has_key_id and has_key_secret:
|
||||
if oss2 is not None:
|
||||
try:
|
||||
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||
# 否则 sign_url 默认生成 HTTP URL。
|
||||
bucket_endpoint = settings.OSS_ENDPOINT
|
||||
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||
auth = oss2.Auth(
|
||||
settings.OSS_ACCESS_KEY_ID,
|
||||
settings.OSS_ACCESS_KEY_SECRET,
|
||||
)
|
||||
self.bucket = oss2.Bucket(
|
||||
auth,
|
||||
bucket_endpoint,
|
||||
settings.OSS_ENDPOINT,
|
||||
settings.OSS_BUCKET_NAME,
|
||||
)
|
||||
logger.info(
|
||||
@@ -69,26 +64,6 @@ class OSSStorageService:
|
||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||
self.endpoint = settings.OSS_ENDPOINT
|
||||
|
||||
def diagnose(self) -> None:
|
||||
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||
key_id_display = (
|
||||
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||
)
|
||||
logger.info(
|
||||
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
||||
self.endpoint,
|
||||
self.bucket_name,
|
||||
key_id_display,
|
||||
)
|
||||
if self.bucket is None:
|
||||
logger.error(
|
||||
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||
)
|
||||
else:
|
||||
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||
|
||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||
@@ -191,26 +166,12 @@ class OSSStorageService:
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
@@ -279,5 +240,4 @@ def get_storage_service() -> OSSStorageService:
|
||||
global _storage_service
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
_storage_service.diagnose()
|
||||
return _storage_service
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
|
||||
|
||||
class UserPendingLimitExceeded(Exception):
|
||||
"""用户 pending 任务数超限,返回 429。"""
|
||||
|
||||
def __init__(self, user_id: str, pending_count: int, limit: int):
|
||||
self.user_id = user_id
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
class GlobalQueueFull(Exception):
|
||||
"""全局限流,返回 503。"""
|
||||
|
||||
def __init__(self, pending_count: int, limit: int):
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
def check_queue_limits(
|
||||
user_id: str,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
user_pending_limit: int = USER_PENDING_LIMIT,
|
||||
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
|
||||
) -> None:
|
||||
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
|
||||
|
||||
边界语义:>= 上限即拒绝(达到上限就不能再加新任务)。
|
||||
|
||||
Args:
|
||||
user_id: 用户 ID
|
||||
generation_task_repository: 任务仓储
|
||||
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
|
||||
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局超限时抛出(优先级更高,先查全局)
|
||||
UserPendingLimitExceeded: 用户超限时抛出
|
||||
"""
|
||||
# 先查全局(系统级保护优先级更高)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if global_pending >= global_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 全局 pending 任务数超限: %d/%d, user_id=%s",
|
||||
global_pending,
|
||||
global_pending_limit,
|
||||
user_id,
|
||||
)
|
||||
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
|
||||
# 再查用户级
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending >= user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
|
||||
|
||||
def _mark_task_failed_safely(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
log_prefix: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""安全地把任务标记为 failed,更新失败只打日志不崩溃。"""
|
||||
try:
|
||||
task.mark_failed(f"任务被限流拒绝: {reason}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"%s 限流后更新状态也失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def safe_enqueue_generation_task(
|
||||
task: Any,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
user_id: str = "",
|
||||
log_prefix: str = "[任务队列]",
|
||||
log_task_status: bool = False,
|
||||
user_pending_limit: int = USER_PENDING_LIMIT,
|
||||
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
|
||||
) -> bool:
|
||||
"""安全入队:入队前限流检查 → 发送 Celery 任务 → 入队后最终校验兜底。
|
||||
|
||||
边界说明:
|
||||
入队前检查用 > 而非 >=。因为调用此函数时 task 已经是 pending 状态并计入 DB,
|
||||
pending 总数包含了当前任务本身。pending > limit 等价于"其他任务数 >= limit",
|
||||
与预检查的 >= 语义一致(都是达到上限就拒绝新任务)。
|
||||
|
||||
入队后最终校验:发送 Celery 成功后再查一次 DB 计数,处理并发竞态场景
|
||||
(两个请求同时通过入队前检查,后到的那个在这里被兜住)。
|
||||
|
||||
Args:
|
||||
task: 生成任务对象,需有 id 属性和 mark_failed 方法(状态已为 pending)
|
||||
generation_task_repository: 任务仓储,用于更新状态
|
||||
user_id: 用户 ID,传了才做用户级限流检查
|
||||
log_prefix: 日志前缀,便于区分调用来源
|
||||
log_task_status: 成功日志中是否额外打印任务状态
|
||||
user_pending_limit: 单用户 pending 上限,默认 USER_PENDING_LIMIT
|
||||
global_pending_limit: 全局 pending 上限,默认 GLOBAL_PENDING_LIMIT
|
||||
|
||||
Returns:
|
||||
True 表示入队成功,False 表示入队失败(已标记为 failed)
|
||||
|
||||
Raises:
|
||||
GlobalQueueFull: 全局 pending 超限时抛出,任务会被标记为 failed
|
||||
UserPendingLimitExceeded: 用户 pending 超限时抛出,任务会被标记为 failed
|
||||
"""
|
||||
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
|
||||
|
||||
# 全局限流检查(始终生效)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if global_pending > global_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 全局 pending 任务数超限(入队前): %d/%d, user_id=%s",
|
||||
global_pending,
|
||||
global_pending_limit,
|
||||
user_id or "unknown",
|
||||
)
|
||||
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# 用户级限流检查(传了 user_id 才做)
|
||||
if user_id:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
if user_pending > user_pending_limit:
|
||||
logger.warning(
|
||||
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
|
||||
user_id,
|
||||
user_pending,
|
||||
user_pending_limit,
|
||||
)
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
# ── 发送 Celery 任务 ──
|
||||
try:
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"%s 入队失败,标记为失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
task.mark_failed(f"任务入队失败: {e}")
|
||||
generation_task_repository.update(task)
|
||||
except Exception as update_err:
|
||||
logger.error(
|
||||
"%s 入队失败后更新状态也失败: task_id=%s error=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
update_err,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
# ── 入队后最终校验:并发竞态兜底 ──
|
||||
# 发送成功后再查一次,防止两个请求同时通过入队前检查导致超限
|
||||
global_after = generation_task_repository.count_pending_total()
|
||||
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
|
||||
|
||||
global_over = global_after > global_pending_limit
|
||||
user_over = bool(user_id and user_after > user_pending_limit)
|
||||
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
else:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
|
||||
logger.warning(
|
||||
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
|
||||
reason,
|
||||
task.id,
|
||||
user_id or "unknown",
|
||||
)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, reason)
|
||||
raise exc
|
||||
|
||||
# 入队成功日志
|
||||
if log_task_status:
|
||||
logger.info(
|
||||
"%s 入队成功: task_id=%s, status=%s",
|
||||
log_prefix,
|
||||
task.id,
|
||||
task.status,
|
||||
)
|
||||
else:
|
||||
logger.info("%s 入队成功: task_id=%s", log_prefix, task.id)
|
||||
return True
|
||||
Regular → Executable
+14
-41
@@ -39,7 +39,6 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
SQLAlchemyProjectRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.tag_repository import SQLAlchemyTagRepository
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import (
|
||||
SQLAlchemyTitleLibraryRepository,
|
||||
)
|
||||
@@ -50,8 +49,19 @@ from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
||||
SQLAlchemyVoiceLibraryRepository,
|
||||
)
|
||||
from packages.ports.tag_repository import TagRepository
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.classification_job_repository import ClassificationJobRepository
|
||||
from packages.ports.duplication_repository import DuplicationRecordRepository
|
||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
||||
from packages.ports.ingest_job_repository import IngestJobRepository
|
||||
from packages.ports.job_repository import JobRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.ports.title_library_repository import TitleLibraryRepository
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||||
from packages.ports.voice_library_repository import VoiceLibraryRepository
|
||||
|
||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||
|
||||
@@ -128,13 +138,6 @@ def get_project_repository(
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
"""Provide the SQLAlchemy tag repository implementation."""
|
||||
return SQLAlchemyTagRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
@@ -189,37 +192,7 @@ def get_voice_clone_profile_repository(
|
||||
|
||||
|
||||
def get_cosyvoice_service():
|
||||
"""Provide the CosyVoice service instance.
|
||||
|
||||
注入 OSS 音频URL预签名函数,确保私有bucket下的参考音频
|
||||
能被 CosyVoice 服务器下载。
|
||||
"""
|
||||
from app.core.storage import get_storage_service
|
||||
|
||||
"""Provide the CosyVoice service instance."""
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
def _sign_audio_url(url: str) -> str:
|
||||
"""对音频URL做预签名,私有bucket下 CosyVoice 服务器才能下载."""
|
||||
return storage.get_download_url(url, expires_seconds=86400)
|
||||
|
||||
return CosyVoiceService(audio_url_signer=_sign_audio_url)
|
||||
|
||||
|
||||
def get_audio_url_signer():
|
||||
"""提供音频URL预签名函数(24小时有效期)。
|
||||
|
||||
用于所有 API 返回给前端的音频 URL,确保私有 OSS bucket 下可正常访问。
|
||||
空 URL、非 OSS URL 直接原样返回;签名失败时回退到原始 URL。
|
||||
"""
|
||||
from app.core.storage import get_storage_service
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
def sign_audio_url(url: str) -> str:
|
||||
if not url:
|
||||
return url
|
||||
return storage.get_download_url(url, expires_seconds=86400)
|
||||
|
||||
return sign_audio_url
|
||||
return CosyVoiceService()
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
from app.dependencies import get_user_repository
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,12 +25,6 @@ class UpdateAssetReviewRequest(BaseModel):
|
||||
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
|
||||
|
||||
|
||||
class UpdateAssetRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
metadata: dict[str, object] | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
@@ -51,24 +45,7 @@ class AssetResponse(BaseModel):
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应。"""
|
||||
|
||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
items: list[AssetResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1)
|
||||
|
||||
@@ -18,8 +18,3 @@ class AssetLibraryResponse(BaseModel):
|
||||
|
||||
class ListAssetLibrariesResponse(BaseModel):
|
||||
items: list[AssetLibraryResponse]
|
||||
|
||||
|
||||
class EnsureDefaultLibraryRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
kind: str = Field(..., pattern="^(video|voice|image)$")
|
||||
|
||||
@@ -36,12 +36,9 @@ class ChunkedUploadStatusResponse(BaseModel):
|
||||
class ChunkedUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteResponse(BaseModel):
|
||||
storage_key: str = Field(..., description="Storage key")
|
||||
ingest_job_id: str = Field(..., description="Ingest job ID")
|
||||
url: str = Field(..., description="File URL")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
@@ -23,16 +21,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 批量生成 ──
|
||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||
# ── 素材库自动匹配 ──
|
||||
asset_select_mode: str = Field(
|
||||
default="all",
|
||||
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
|
||||
)
|
||||
asset_select_count: int = Field(
|
||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -58,34 +46,10 @@ class GenerationTaskResponse(BaseModel):
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
logs: list[dict] = Field(default_factory=list)
|
||||
|
||||
@field_validator("logs", mode="before")
|
||||
@classmethod
|
||||
def _parse_logs(cls, v: object) -> list[dict]:
|
||||
"""将 JSON 字符串解析为 list[dict]。"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return []
|
||||
|
||||
|
||||
class BatchGenerationTaskResponse(BaseModel):
|
||||
"""批量生成任务响应。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ListGenerationTasksResponse(BaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""标签相关 Schema。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateTagRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ListTagsResponse(BaseModel):
|
||||
items: list[TagResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class TagAssetsRequest(BaseModel):
|
||||
tag_ids: list[str] = Field(..., min_length=1, max_length=50)
|
||||
@@ -83,21 +83,3 @@ class ListTTSJobResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SaveToLibraryRequest(BaseModel):
|
||||
"""保存到配音库请求。"""
|
||||
|
||||
name: Optional[str] = Field(None, description="配音素材名称,留空则自动生成")
|
||||
|
||||
|
||||
class SaveToLibraryResponse(BaseModel):
|
||||
"""保存到配音库响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
audio_url: str
|
||||
duration: float
|
||||
voice_id: str
|
||||
voice_name: str
|
||||
status: str
|
||||
|
||||
@@ -6,7 +6,12 @@ class UploadAssetRequest(BaseModel):
|
||||
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
@@ -15,7 +20,6 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -31,19 +35,8 @@ class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -4,7 +4,6 @@ from .auto_clip_service import AutoClipService
|
||||
from .edit_plan_service import EditPlanService
|
||||
from .edit_template_service import EditTemplateService
|
||||
from .job_service import JobService
|
||||
from .plan_generator_service import PlanGeneratorService
|
||||
from .video_compose_service import VideoComposeService
|
||||
|
||||
__all__ = [
|
||||
@@ -12,6 +11,5 @@ __all__ = [
|
||||
"EditPlanService",
|
||||
"EditTemplateService",
|
||||
"JobService",
|
||||
"PlanGeneratorService",
|
||||
"VideoComposeService",
|
||||
]
|
||||
|
||||
@@ -24,7 +24,7 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
)
|
||||
from packages.domain.asset import AssetType
|
||||
from packages.domain.classification import AssetClassification
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from packages.adapters.sqlalchemy_impl import (
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.generation_task import GenerationTaskStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -446,12 +447,12 @@ class EditPlanService:
|
||||
|
||||
# 检查状态
|
||||
if plan.status != EditPlanStatus.EDITING:
|
||||
return False, "请先编辑并保存模板后再生成视频"
|
||||
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
|
||||
|
||||
# 检查是否有片段
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not clips:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
return False, "计划下没有片段,无法触发渲染"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ class EditTemplateService:
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "default",
|
||||
editing_mode: str = "one_take",
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
preview_url: str = "",
|
||||
sort_weight: int = 0,
|
||||
@@ -125,7 +124,6 @@ class EditTemplateService:
|
||||
name=clean_name,
|
||||
description=description,
|
||||
template_type=template_type,
|
||||
editing_mode=editing_mode,
|
||||
config=config,
|
||||
preview_url=preview_url,
|
||||
sort_weight=sort_weight,
|
||||
@@ -141,7 +139,6 @@ class EditTemplateService:
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
template_type: Optional[str] = None,
|
||||
editing_mode: Optional[str] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
preview_url: Optional[str] = None,
|
||||
sort_weight: Optional[int] = None,
|
||||
@@ -168,7 +165,6 @@ class EditTemplateService:
|
||||
name=new_name,
|
||||
description=description.strip() if description is not None else existing.description,
|
||||
template_type=template_type.strip() if template_type is not None else existing.template_type,
|
||||
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
|
||||
config=config if config is not None else existing.config,
|
||||
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
|
||||
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application.jobs import (
|
||||
CancelJobUseCase,
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划.
|
||||
|
||||
核心职责:
|
||||
- 根据 EditTemplate 的 editing_mode 和 TemplateClipConfig 列表,
|
||||
自动生成 EditPlan + EditPlanClip 列表
|
||||
- 四种模式素材分配策略:
|
||||
- ONE_TAKE: 素材顺序分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll),标记需要配音叠加
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_template import EditTemplate
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
|
||||
_DEFAULT_CLIP_DURATION = 5.0
|
||||
_DEFAULT_INTRO_DURATION = 3.0
|
||||
_DEFAULT_OUTRO_DURATION = 3.0
|
||||
|
||||
|
||||
class PlanGeneratorService:
|
||||
"""剪辑计划生成器
|
||||
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_from_template(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
asset_ids: List[str],
|
||||
*,
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""基于模板+素材生成剪辑计划
|
||||
|
||||
Args:
|
||||
template: 剪辑模板实体
|
||||
clip_configs: 模板片段配置列表(可为空,自动生成默认结构)
|
||||
asset_ids: 素材 ID 列表
|
||||
project_id: 所属项目 ID
|
||||
created_by_user_id: 创建者用户 ID
|
||||
name: 计划名称(为空则自动取模板名)
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
"""
|
||||
editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value
|
||||
plan_name = name.strip() or f"{template.name} - 剪辑计划"
|
||||
|
||||
# 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode)
|
||||
plan_config = self._build_plan_config(template, editing_mode)
|
||||
|
||||
# 2. 创建 EditPlan
|
||||
plan = EditPlan.create(
|
||||
template_id=template.id,
|
||||
name=plan_name,
|
||||
config=plan_config,
|
||||
total_duration=0.0,
|
||||
project_id=project_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
plan = self._plan_repo.create(plan)
|
||||
logger.info(
|
||||
"生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d",
|
||||
plan.id,
|
||||
template.id,
|
||||
editing_mode,
|
||||
len(asset_ids),
|
||||
)
|
||||
|
||||
# 3. 生成片段列表
|
||||
if clip_configs:
|
||||
clips = self._create_clips_from_configs(plan.id, clip_configs)
|
||||
else:
|
||||
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: List[EditPlanClip] = []
|
||||
total_duration = 0.0
|
||||
for clip in clips:
|
||||
saved = self._clip_repo.create(clip)
|
||||
created_clips.append(saved)
|
||||
total_duration += saved.duration
|
||||
|
||||
# 6. 更新 plan 的 total_duration
|
||||
plan.total_duration = total_duration
|
||||
plan = self._plan_repo.update(plan)
|
||||
|
||||
# 7. 流转到 editing 状态
|
||||
try:
|
||||
plan.start_editing()
|
||||
plan = self._plan_repo.update(plan)
|
||||
except ValueError as exc:
|
||||
logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc)
|
||||
|
||||
logger.info(
|
||||
"剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f",
|
||||
plan.id,
|
||||
len(created_clips),
|
||||
total_duration,
|
||||
)
|
||||
|
||||
return {"plan": plan, "clips": created_clips}
|
||||
|
||||
# ── 内部方法 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_plan_config(
|
||||
self,
|
||||
template: EditTemplate,
|
||||
editing_mode: str,
|
||||
) -> dict[str, Any]:
|
||||
"""从模板配置构建 plan config"""
|
||||
template_config = template.config or {}
|
||||
plan_config: dict[str, Any] = {
|
||||
"editing_mode": editing_mode,
|
||||
}
|
||||
# 继承模板的 cover/title/subtitle/bgm 配置
|
||||
for key in ("cover", "title", "subtitle", "bgm"):
|
||||
if key in template_config:
|
||||
plan_config[key] = template_config[key]
|
||||
|
||||
return normalize_plan_config(plan_config)
|
||||
|
||||
def _create_clips_from_configs(
|
||||
self,
|
||||
plan_id: str,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||
clips: List[EditPlanClip] = []
|
||||
# 按 order 排序
|
||||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||
|
||||
for cfg in sorted_configs:
|
||||
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||||
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||||
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||||
elif cfg.min_duration > 0:
|
||||
duration = cfg.min_duration
|
||||
elif cfg.max_duration > 0:
|
||||
duration = cfg.max_duration
|
||||
else:
|
||||
duration = _DEFAULT_CLIP_DURATION
|
||||
|
||||
# clip_type 可能是枚举或字符串
|
||||
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||
|
||||
# transition_effect 可能是枚举或字符串
|
||||
transition = (
|
||||
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||
)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
text_content=getattr(cfg, "text_template", "") or "",
|
||||
duration=duration,
|
||||
transition_effect=transition or "cut",
|
||||
)
|
||||
clips.append(clip)
|
||||
|
||||
return clips
|
||||
|
||||
def _generate_default_clips(
|
||||
self,
|
||||
plan_id: str,
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||
|
||||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||
"""
|
||||
n = max(asset_count, 1)
|
||||
clips: List[EditPlanClip] = []
|
||||
order = 0
|
||||
|
||||
if editing_mode == EditingMode.PIP.value:
|
||||
# 1 个 main(全屏背景)
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 overlay
|
||||
for i in range(1, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="overlay",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
# N 个 main clips(B-roll)
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
# 1 个 background
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="background",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 1 个 corner_voice
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="corner_voice",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
# 剩余为 b_roll
|
||||
for i in range(2, n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type="b_roll",
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
else:
|
||||
# ONE_TAKE: N 个 main clips
|
||||
for i in range(n):
|
||||
clips.append(
|
||||
EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=order,
|
||||
duration=_DEFAULT_CLIP_DURATION,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
return clips
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||
|
||||
分配策略:
|
||||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll)
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
|
||||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.PIP.value:
|
||||
self._distribute_pip(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
self._distribute_voice_over(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
self._distribute_voice_pip(clips, asset_ids)
|
||||
else:
|
||||
# 未知模式,退化为 one_take
|
||||
self._distribute_one_take(clips, asset_ids)
|
||||
|
||||
def _distribute_one_take(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
|
||||
# 第1个素材 → main clip
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
if main_clips and asset_ids:
|
||||
main_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 其余素材 → overlay clips
|
||||
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||
remaining = asset_ids[1:]
|
||||
for i, clip in enumerate(overlay_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
|
||||
def _distribute_voice_over(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_OVER: 素材→main clips (B-roll)"""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
|
||||
def _distribute_voice_pip(
|
||||
self,
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
) -> None:
|
||||
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
|
||||
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||||
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||||
|
||||
# 第1个素材 → background
|
||||
if bg_clips and len(asset_ids) > 0:
|
||||
bg_clips[0].assign_asset(asset_ids[0])
|
||||
|
||||
# 第2个素材 → corner_voice
|
||||
if corner_clips and len(asset_ids) > 1:
|
||||
corner_clips[0].assign_asset(asset_ids[1])
|
||||
|
||||
# 其余素材 → b_roll
|
||||
remaining = asset_ids[2:]
|
||||
for i, clip in enumerate(broll_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
@@ -16,6 +16,7 @@ FFmpeg 视频合成编排服务:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -27,7 +28,7 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
/**
|
||||
* 素材库页面完整 E2E 测试
|
||||
*
|
||||
* 覆盖:页面加载、创建素材库、切换素材库、搜索/筛选、素材详情、
|
||||
* 删除素材、批量删除、空状态
|
||||
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
async function createProject(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 创建素材库 */
|
||||
async function createLibrary(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
projectId: string,
|
||||
name: string,
|
||||
kind: "video" | "image" = "video",
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name, kind },
|
||||
});
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 创建素材记录 */
|
||||
async function createAsset(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
projectId: string,
|
||||
libraryId: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
status: string = "ready",
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
project_id: projectId,
|
||||
library_id: libraryId,
|
||||
name,
|
||||
storage_key: `uploads/e2e/${Date.now()}/${name}`,
|
||||
mime_type: "video/mp4",
|
||||
file_size: 1024000,
|
||||
status,
|
||||
uploaded_by_user_id: userId,
|
||||
metadata: { duration: 15.5, resolution: "1080p" },
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("素材库页面 - 完整交互测试", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-load",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
|
||||
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible();
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible();
|
||||
|
||||
// 无错误提示
|
||||
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 创建素材库 ────────────────────────────────────
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-create",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`;
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
|
||||
// 类型选择默认是 video,保持即可
|
||||
|
||||
// 监听创建请求
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/asset-libraries") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// 点击创建
|
||||
await modal.getByRole("button", { name: "创建" }).click();
|
||||
|
||||
const resp = await createPromise;
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 新素材库应出现在列表中
|
||||
await expect(
|
||||
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ─── 切换素材库 ────────────────────────────────────
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-switch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
const videoLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibName,
|
||||
"video",
|
||||
);
|
||||
const imageLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
imageLibName,
|
||||
"image",
|
||||
);
|
||||
|
||||
// 在视频库里创建一个素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibId,
|
||||
userId,
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: videoLibName });
|
||||
await videoLibItem.click({ force: true });
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: imageLibName });
|
||||
await imageLibItem.click({ force: true });
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-search",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"搜索测试库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "搜索测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
|
||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 筛选类型 ──────────────────────────────────────
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-filter",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"筛选测试库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "筛选测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
await expect(filterSelect).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 素材详情/播放 ────────────────────────────────
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-detail",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "详情测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 找到素材卡片并点击播放按钮
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "play_test.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击播放按钮
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
await modal.locator(".ant-modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 删除素材 ──────────────────────────────────────
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-delete",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "删除测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "to_delete.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 悬停显示删除按钮
|
||||
await assetCard.hover();
|
||||
|
||||
// 点击删除
|
||||
const deleteBtn = assetCard.locator(".xx-asset-delete");
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// 点击确认删除
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
const resp = await deletePromise;
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 素材应从列表中消失
|
||||
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 批量删除素材 ──────────────────────────────────
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-batch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"批量删除库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "批量删除库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
// 点击全选
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" });
|
||||
await expect(selectAllBtn).toBeVisible();
|
||||
await selectAllBtn.click();
|
||||
|
||||
// 批量操作栏出现
|
||||
const batchBar = page.locator(".xx-assets-batch-bar");
|
||||
await expect(batchBar).toBeVisible();
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
|
||||
|
||||
// 点击批量删除
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
|
||||
await expect(batchDeleteBtn).toBeVisible();
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
// 验证素材已删除(通过 API 确认)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const resp = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
if (!resp.ok()) return "error";
|
||||
const data = await resp.json();
|
||||
const items = data.items || [];
|
||||
return items.length;
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-empty",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "空素材库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/assets");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,554 +0,0 @@
|
||||
/**
|
||||
* 去重流程 E2E 测试
|
||||
*
|
||||
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
|
||||
* 删除记录、重试去重
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("去重流程", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 上传页面加载 ──────────────────────────────────
|
||||
|
||||
test("去重上传页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
|
||||
|
||||
// 描述
|
||||
await expect(
|
||||
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 上传区域展示 ──────────────────────────────────
|
||||
|
||||
test("上传区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-upload-zone",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 拖拽上传区域
|
||||
const uploadZone = page.locator(".dup-upload-zone");
|
||||
await expect(uploadZone).toBeVisible();
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
||||
|
||||
// 格式提示
|
||||
await expect(
|
||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
||||
).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
|
||||
// 选择文件按钮
|
||||
const selectBtn = page.getByRole("button", { name: "选择文件" });
|
||||
await expect(selectBtn).toBeVisible();
|
||||
|
||||
// 隐藏的文件 input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ─── 格式说明区 ────────────────────────────────────
|
||||
|
||||
test("格式说明和提示区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 右侧说明区
|
||||
const infoCard = page.locator(".dup-info-card");
|
||||
await expect(infoCard).toBeVisible();
|
||||
|
||||
// 查重说明
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible();
|
||||
|
||||
// 支持格式
|
||||
await expect(infoCard.getByText("支持格式")).toBeVisible();
|
||||
|
||||
// 温馨提示
|
||||
await expect(infoCard.getByText("温馨提示")).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-format-tags")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 去重记录列表页面 ──────────────────────────────
|
||||
|
||||
test("去重记录列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
|
||||
|
||||
// 筛选按钮
|
||||
await expect(page.locator(".dup-filter")).toBeVisible();
|
||||
|
||||
// 上传查重按钮
|
||||
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("去重记录列表 - 空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list-empty",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 空状态(新用户没有记录)
|
||||
const emptyState = page.locator(".dup-results-empty");
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 });
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 筛选按钮存在
|
||||
const filterBtns = page.locator(".dup-filter-btn");
|
||||
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
|
||||
|
||||
// 验证按钮文本
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部");
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险");
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险");
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险");
|
||||
|
||||
// 默认选中"全部"
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/);
|
||||
|
||||
// 点击低风险
|
||||
await filterBtns.nth(1).click();
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击上传查重按钮
|
||||
await page.getByRole("button", { name: "上传查重" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/duplication$/);
|
||||
await expect(page.locator(".dup-upload-zone")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 去重详情页 ────────────────────────────────────
|
||||
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
);
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_test.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 如果查重 API 不可用,跳过详情页测试
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
|
||||
// 页面应正常渲染
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 验证无错误
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 删除记录 ──────────────────────────────────────
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_delete.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
// 验证记录存在
|
||||
const listResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
if (listResp.ok()) {
|
||||
const records = await listResp.json();
|
||||
const recordExists = Array.isArray(records)
|
||||
? records.some((r: { id: string }) => r.id === recordId)
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId);
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy();
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/duplication/records/${recordId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
deleteResp.ok(),
|
||||
`删除查重记录应成功: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证记录已删除
|
||||
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
if (listAfterResp.ok()) {
|
||||
const recordsAfter = await listAfterResp.json();
|
||||
const recordStillExists = Array.isArray(recordsAfter)
|
||||
? recordsAfter.some((r: { id: string }) => r.id === recordId)
|
||||
: (recordsAfter.items || []).some(
|
||||
(r: { id: string }) => r.id === recordId,
|
||||
);
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_ui_delete.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication ui delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
const deleteBtn = resultCard.getByRole("button").filter({
|
||||
hasText: "🗑️",
|
||||
});
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
|
||||
// 删除按钮点击 - 会触发 confirm 对话框
|
||||
// 这里我们通过监听 confirm 来确认删除
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toContain("确定删除");
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => null);
|
||||
|
||||
await deleteBtn.click();
|
||||
|
||||
const deleteResp = await deletePromise;
|
||||
if (deleteResp) {
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 重试去重 ──────────────────────────────────────
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_retry.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication retry test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
|
||||
|
||||
// 检查是否有重试按钮(失败状态才显示)
|
||||
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
|
||||
// 这里只验证 API 重试接口可用
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/duplication/records/${recordId}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
// 重试接口应返回 2xx 或明确的状态码
|
||||
expect(retryResp.status()).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,480 +0,0 @@
|
||||
/**
|
||||
* 剪辑策划页面 E2E 测试
|
||||
*
|
||||
* 覆盖:页面加载、模板列表、模式切换、创建/编辑/删除剪辑计划、
|
||||
* AI推荐片段、详情页、空状态、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个编辑模板并返回 id */
|
||||
async function createEditingTemplate(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 剪辑计划 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "E2E 测试创建的剪辑计划",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
description: "开场片段",
|
||||
},
|
||||
{
|
||||
segment_order: 2,
|
||||
duration_min: 10,
|
||||
duration_max: 20,
|
||||
material_type: "video",
|
||||
description: "主体内容",
|
||||
},
|
||||
],
|
||||
tags: ["e2e", "test"],
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
test.describe("剪辑策划页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("剪辑策划页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
// 验证顶栏存在
|
||||
await expect(page.locator(".ep-top-bar")).toBeVisible();
|
||||
// 验证模式栏存在
|
||||
await expect(page.locator(".ep-mode-bar")).toBeVisible();
|
||||
// 验证主体区域存在
|
||||
await expect(page.locator(".ep-main-body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("剪辑模式切换正常显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-mode",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-mode",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证模式按钮存在(画中画、人物口播等)
|
||||
const modeBtns = page.locator(".ep-mode-btn");
|
||||
await expect(modeBtns.first()).toBeVisible();
|
||||
const modeCount = await modeBtns.count();
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑计划 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 创建测试 ${suffix}`;
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: templateName,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "测试创建剪辑计划",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 10,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy();
|
||||
expect(data.name).toBe(templateName);
|
||||
expect(data.mode).toBe("pip");
|
||||
});
|
||||
|
||||
test("列出剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建 2 个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 列表测试 A ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 列表测试 B ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出模板应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("获取剪辑计划详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-detail");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const response = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(templateId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.mode).toBeTruthy();
|
||||
});
|
||||
|
||||
test("编辑剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-update");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const newName = `更新后的剪辑计划 ${Date.now()}`;
|
||||
const response = await request.patch(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
description: "更新后的描述",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新后的数据
|
||||
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("删除剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-delete");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-badmode");
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "无效 mode 测试",
|
||||
mode: "invalid_mode",
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/templates/nonexistent-template-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
data: {
|
||||
name: "未登录测试",
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 已模板数据加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("已创建的模板在页面中显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "ep-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createEditingTemplate(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-data",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证状态栏存在
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible();
|
||||
});
|
||||
|
||||
test("撤销/重做按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-undo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-undo",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
|
||||
await expect(topBarBtns.first()).toBeVisible();
|
||||
const btnCount = await topBarBtns.count();
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("生成按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-gen",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-gen",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证主操作按钮存在
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
/**
|
||||
* 作品库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:作品库列表加载、状态展示、作品详情、视频播放、下载按钮、
|
||||
* 删除作品、空状态、筛选
|
||||
*
|
||||
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
|
||||
* 真实的生成流程测试见 core-generation.spec.ts。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 产品数据 */
|
||||
function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
const products = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const status = statuses[i % statuses.length];
|
||||
products.push({
|
||||
id: `mock-prod-${Date.now()}-${i}`,
|
||||
title: `测试作品 ${i + 1}`,
|
||||
status,
|
||||
duration_seconds: 30 + i * 10,
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Mock 产品列表 API */
|
||||
async function mockProductsApi(
|
||||
page: import("@playwright/test").Page,
|
||||
products: unknown[],
|
||||
) {
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
// 列表
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个产品详情
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = (products as Array<{ id: string }>).find(
|
||||
(p) => p.id === productId,
|
||||
);
|
||||
if (product) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product),
|
||||
});
|
||||
} else {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Not found" }),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载链接
|
||||
if (method === "GET" && url.includes("/download-url")) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "https://example.com/download.mp4",
|
||||
expires_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("作品库页面", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("作品库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-load",
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
|
||||
|
||||
// 筛选栏
|
||||
await expect(page.locator(".xx-products-filters")).toBeVisible();
|
||||
|
||||
// 卡片网格
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 作品卡片存在
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 状态展示 ──────────────────────────────────────
|
||||
|
||||
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-status",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待卡片加载
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 验证各状态标签存在
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
||||
"已完成",
|
||||
);
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中作品" });
|
||||
await expect(
|
||||
processingCard.locator(".xx-product-status.processing"),
|
||||
).toHaveText("处理中");
|
||||
|
||||
const failedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "失败作品" });
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
|
||||
"失败",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 作品详情页 ────────────────────────────────────
|
||||
|
||||
test("作品详情页打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-detail",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "详情页测试作品";
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
|
||||
// 验证 URL
|
||||
await expect(page).toHaveURL(/\/app\/products\//);
|
||||
|
||||
// 页面应正常渲染(无错误)
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 视频播放 ──────────────────────────────────────
|
||||
|
||||
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-play",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "播放测试作品";
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击作品卡片打开播放
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "播放测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 点击播放按钮
|
||||
await productCard.locator(".xx-product-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放测试作品" })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(videoVisible || modalVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 下载按钮 ──────────────────────────────────────
|
||||
|
||||
test("下载按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-download",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "下载测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 下载按钮存在且可用(已完成状态)
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
await expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("处理中作品下载按钮禁用", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-disabled",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["processing"]);
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中下载测试" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 处理中的作品下载按钮应禁用
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
const isDisabled = await downloadBtn.isDisabled();
|
||||
const hasDisabled = await downloadBtn.evaluate(
|
||||
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
|
||||
);
|
||||
expect(isDisabled || hasDisabled).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 删除作品 ──────────────────────────────────────
|
||||
|
||||
test("删除作品 - API 调用正确", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-delete",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "待删除作品";
|
||||
let deleteCalled = false;
|
||||
let deletedId = "";
|
||||
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
deleteCalled = true;
|
||||
deletedId = detailMatch[1];
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = products.find((p) => p.id === productId);
|
||||
route.fulfill({
|
||||
status: product ? 200 : 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product || { detail: "Not found" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "待删除作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 验证 DELETE API 存在于 products API 中
|
||||
// 我们通过检查实际 API 来确认删除功能可用
|
||||
// (mock 只是为了测试 UI 行为)
|
||||
expect(deleteCalled).toBe(false); // 初始状态未调用
|
||||
expect(deletedId).toBe("");
|
||||
});
|
||||
|
||||
test("删除作品 API 端点存在", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
|
||||
expect([200, 204, 403, 404]).toContain(resp.status());
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空状态展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-empty",
|
||||
);
|
||||
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-products-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 搜索筛选 ──────────────────────────────────────
|
||||
|
||||
test("作品搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-search",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible();
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-filter-status",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 两个都可见
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible();
|
||||
|
||||
// 状态筛选下拉存在
|
||||
const selects = page.locator(".xx-products-filters-left select");
|
||||
const count = await selects.count();
|
||||
if (count >= 2) {
|
||||
// 第2个 select 是状态筛选
|
||||
await selects.nth(1).selectOption({ label: "已完成" });
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible();
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 批量操作 ──────────────────────────────────────
|
||||
|
||||
test("批量选择和批量操作栏", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-batch",
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed"]);
|
||||
products[0].title = "批量测试 1";
|
||||
products[1].title = "批量测试 2";
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 三张卡片
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 点击第一张卡片的复选框
|
||||
const firstCard = page.locator(".xx-product-card").first();
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox");
|
||||
await expect(checkbox).toBeVisible();
|
||||
await checkbox.click();
|
||||
|
||||
// 批量操作栏应出现
|
||||
const batchBar = page.locator(".xx-products-batch-bar");
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 });
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
/**
|
||||
* 个人设置页面 E2E 测试
|
||||
*
|
||||
* 覆盖:设置页面加载、个人信息展示、修改昵称/头像、修改密码、
|
||||
* 账号安全区域、退出登录按钮、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("个人设置页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/profile");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("设置页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-title",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-title",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证页面包含"个人设置"标题
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ });
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 个人信息展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("个人信息卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证设置卡片存在
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名、邮箱字段展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-fields",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-fields",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证表单字段存在
|
||||
const fields = page.locator(".xx-settings-field");
|
||||
await expect(fields.first()).toBeVisible();
|
||||
const fieldCount = await fields.count();
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("用户名标签和输入框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-username",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-username",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证用户名标签
|
||||
const usernameLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "用户名",
|
||||
});
|
||||
await expect(usernameLabel).toBeVisible();
|
||||
|
||||
// 验证邮箱标签
|
||||
const emailLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "邮箱",
|
||||
});
|
||||
await expect(emailLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test("显示名称字段可编辑", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-dispname",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-dispname",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找显示名称输入框
|
||||
const displayNameField = page.locator(".xx-settings-field").filter({
|
||||
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
|
||||
});
|
||||
if (await displayNameField.isVisible()) {
|
||||
const input = displayNameField.locator("input");
|
||||
if (await input.isVisible()) {
|
||||
// 验证输入框存在且可输入
|
||||
await expect(input).toBeVisible();
|
||||
const initialValue = await input.inputValue();
|
||||
await input.fill("新的显示名称");
|
||||
await expect(input).toHaveValue("新的显示名称");
|
||||
// 恢复原值
|
||||
await input.fill(initialValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 修改密码", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("修改密码 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-chpwd");
|
||||
|
||||
const newPassword = "NewPass123456!";
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// 修改密码可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 如果成功,用新密码登录验证
|
||||
if (response.ok()) {
|
||||
const loginResp = await loginWithRetry(request, email, newPassword);
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("修改密码 - 旧密码错误反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-badpwd");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: "WrongOldPass123!",
|
||||
new_password: "NewPass123456!",
|
||||
},
|
||||
});
|
||||
|
||||
// 如果接口存在,应该返回 400/401
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 401]).toContain(response.status());
|
||||
}
|
||||
// 接口不存在(404)也正常
|
||||
});
|
||||
|
||||
test("修改密码 - 新密码太弱反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-weakpwd");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: "123",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录修改密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
data: {
|
||||
old_password: "old",
|
||||
new_password: "new",
|
||||
},
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 账号安全", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const { headers, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-me",
|
||||
);
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test("账号安全区域提示信息存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-security",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-security",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证通知区域存在
|
||||
const notice = page.locator(".xx-settings-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`登出应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 登出后 token 应失效
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(meResp.status());
|
||||
});
|
||||
|
||||
test("登出后页面跳转登录页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout-ui",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-logout-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 清除 localStorage 模拟登出
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("auth-storage");
|
||||
});
|
||||
|
||||
// 刷新页面应该重定向到登录页
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 保存按钮", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("保存按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-save",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-save",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证按钮存在
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ });
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* 注册页面 E2E 测试
|
||||
*
|
||||
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("注册页面", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
// ─── 页面渲染 ──────────────────────────────────────
|
||||
|
||||
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 品牌标识
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
|
||||
// 标题/描述
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("用户名")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByLabel("确认密码")).toBeVisible();
|
||||
|
||||
// 提交按钮
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 表单验证 ──────────────────────────────────────
|
||||
|
||||
test("空提交 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
await expect(page.getByText("请输入密码")).toBeVisible();
|
||||
await expect(page.getByText("请确认密码")).toBeVisible();
|
||||
});
|
||||
|
||||
test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill("not-an-email");
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
});
|
||||
|
||||
test("密码太短 - 显示长度错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
});
|
||||
|
||||
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名为空 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
|
||||
await page.getByLabel("用户名").fill("");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 成功注册 ──────────────────────────────────────
|
||||
|
||||
test("成功注册 - 提交有效表单", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-ok");
|
||||
const username = uniqueUsername("reguiok");
|
||||
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
// 监听注册请求
|
||||
const registerResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/auth/register") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const url = page.url();
|
||||
// 可能跳转到 login,也可能在当前页显示成功消息
|
||||
if (url.includes("/login")) return "redirected";
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible();
|
||||
return hasSuccess ? "success_msg" : url;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/redirected|success_msg/);
|
||||
});
|
||||
|
||||
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-dup");
|
||||
const username1 = uniqueUsername("reguidup1");
|
||||
const username2 = uniqueUsername("reguidup2");
|
||||
|
||||
// 先通过 API 注册一个账号
|
||||
const firstReg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
});
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
|
||||
|
||||
// 再在 UI 上用相同邮箱注册
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username2);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe("error_shown");
|
||||
});
|
||||
|
||||
// ─── 跳转链接 ──────────────────────────────────────
|
||||
|
||||
test("跳转到登录页的链接", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByRole("link", { name: "立即登录" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
});
|
||||
|
||||
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await page.getByRole("link", { name: "立即注册" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
|
||||
test("登录页有忘记密码链接", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /忘记密码/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/forgot-password/);
|
||||
});
|
||||
|
||||
// ─── 路由守卫 - 已登录用户访问注册页 ──────────────
|
||||
|
||||
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = uniqueEmail("reg-auth");
|
||||
const username = uniqueUsername("regauth");
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
||||
});
|
||||
|
||||
// 登录
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), "登录应成功").toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
// 设置登录态
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: loginData.user_id,
|
||||
user_id: loginData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/register");
|
||||
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,600 +0,0 @@
|
||||
/**
|
||||
* 订阅完整流程 E2E 测试
|
||||
*
|
||||
* 覆盖:订阅套餐页、套餐卡片展示、升级套餐交互、账单列表页、
|
||||
* 取消订阅(确认流程)、自动续费切换、支付流程、未登录重定向
|
||||
*
|
||||
* 注意:subscription.spec.ts 已覆盖 API 基础测试和路由守卫,
|
||||
* 本文件专注于页面交互和完整流程。
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("订阅套餐页 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("订阅套餐页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("套餐卡片网格展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证套餐卡片存在
|
||||
const planCards = page.locator(".xx-plan-card");
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
|
||||
const cardCount = await planCards.count();
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cardinfo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cardinfo",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-plan-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 验证价格区域存在
|
||||
await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
|
||||
// 验证特性列表存在
|
||||
await expect(firstCard.locator(".xx-features")).toBeVisible();
|
||||
// 验证订阅按钮存在
|
||||
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
|
||||
});
|
||||
|
||||
test("推荐套餐有特殊标识", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-recommended",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-recommended",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证有推荐标签
|
||||
const featuredCard = page.locator(".xx-plan-card.featured");
|
||||
if (await featuredCard.isVisible({ timeout: 5_000 })) {
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅套餐页 - 升级交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-btn",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-btn",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击一个订阅按钮
|
||||
const subscribeBtn = page.locator(".xx-subscribe-btn").first();
|
||||
if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
|
||||
await subscribeBtn.click();
|
||||
// 可能跳转到升级页或打开支付弹窗
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("升级套餐升级页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-page",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-page",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/upgrade");
|
||||
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
|
||||
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 账单列表页", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("账单页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("账单概览区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-overview",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-overview",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证概览区域存在
|
||||
const overview = page.locator(".xx-billing-overview");
|
||||
if (await overview.isVisible({ timeout: 5_000 })) {
|
||||
await expect(overview).toBeVisible();
|
||||
// 验证套餐信息
|
||||
await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("自动续费开关存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-autorenew-ui",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-autorenew-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证自动续费区域存在
|
||||
const autoRenew = page.locator(".xx-billing-auto-renew");
|
||||
if (await autoRenew.isVisible({ timeout: 5_000 })) {
|
||||
await expect(autoRenew).toBeVisible();
|
||||
// 验证开关组件存在
|
||||
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("账单记录 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-bills-api");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取账单记录应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 自动续费切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("切换自动续费 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-api");
|
||||
|
||||
// 关闭自动续费
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("切换自动续费 - 无效参数反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
|
||||
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 取消订阅", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("取消订阅 API - 免费用户反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 免费用户取消订阅可能返回错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy();
|
||||
}
|
||||
// 如果成功了也没问题(某些实现可能允许)
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐变更", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-upgrade-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`升级套餐应成功: ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取当前订阅信息 - 验证升级", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-current-api");
|
||||
|
||||
// 先升级
|
||||
await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// 获取当前订阅
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
|
||||
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
downgrade.status() < 500,
|
||||
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("切换到无效套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 支付流程", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
// 应返回订单 ID 或支付链接
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐列表 API", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取套餐列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-plans-api");
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/plans`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 套餐列表可能需要登录也可能公开
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items;
|
||||
if (Array.isArray(plans)) {
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
// 如果需要登录也正常
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取套餐列表", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/plans`);
|
||||
// 套餐列表可能公开也可能需要登录
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
/**
|
||||
* 模板库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:模板列表加载、分类切换、模板详情、收藏/取消收藏、
|
||||
* 使用模板入口、搜索功能、我的模板tab、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("模板库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/templates");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("模板库头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-head",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证搜索框
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("分类切换按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cat",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cat",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证分类按钮存在
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categoryBtns.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 模板展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-cards");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 模板展示 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "测试模板展示",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e", "展示"],
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待模板卡片出现
|
||||
const cards = page.locator(".xx-template-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("模板卡片包含名称和类型", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-info");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `模板信息测试 ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
description: "测试信息展示",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证信息区域存在
|
||||
const info = firstCard.locator(".xx-template-info");
|
||||
await expect(info).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("模板预览弹窗功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-preview");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `预览测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "预览测试描述",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
description: "片段一",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-preview",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击第一个模板卡片打开预览
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
// 预览弹窗应该出现
|
||||
const modal = page.locator(".xx-template-modal");
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
// 验证预览内容存在
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 分类切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("切换分类筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-switch",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-switch",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
const firstBtn = categoryBtns.first();
|
||||
|
||||
if (await firstBtn.isVisible({ timeout: 10_000 })) {
|
||||
await firstBtn.click();
|
||||
// 验证按钮被选中
|
||||
await expect(firstBtn).toHaveClass(/active/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 搜索", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框可输入并筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`;
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: templateName,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "搜索测试专用模板",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
if (await searchInput.isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.fill(suffix);
|
||||
// 验证页面正常响应
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取模板列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `API 列表测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取模板列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建模板
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `收藏测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
const templateId = created.id;
|
||||
|
||||
// 收藏
|
||||
const favResp = await request.post(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
// 收藏可能成功或接口不存在
|
||||
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
|
||||
// 取消收藏
|
||||
const unfavResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `详情测试 ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
description: "详情测试描述",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
description: "测试片段",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/templates/${created.id}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("使用模板接口 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-use");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `使用测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
// 使用模板(生成)
|
||||
const genResp = await request.post(
|
||||
`${apiBase}/templates/${created.id}/generate`,
|
||||
{ headers, data: {} },
|
||||
);
|
||||
// 生成可能成功或返回业务错误
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取模板列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/templates`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 我的模板 Tab", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("我的模板页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my",
|
||||
});
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-my-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `我的模板测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "我的模板展示测试",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my-data",
|
||||
});
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证卡片容器存在
|
||||
const cards = page.locator(".mt-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,538 +0,0 @@
|
||||
/**
|
||||
* 标题库完整交互 E2E 测试
|
||||
*
|
||||
* 覆盖:创建新标题(完整流程)、编辑标题、删除标题、分类/标签筛选、
|
||||
* 搜索功能、批量操作、空状态
|
||||
*
|
||||
* 注意:core-titles.spec.ts 已覆盖基础加载和API创建/列表,
|
||||
* 本文件专注于完整交互和边界场景。
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个标题并返回 id */
|
||||
async function createTitle(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 标题 ${suffix}`,
|
||||
text: `这是一个 E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
tags: ["e2e", "test"],
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
test.describe("标题库 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("新用户标题页面显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"title-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 新用户应该能看到页面主体
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 搜索功能", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找搜索框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
|
||||
);
|
||||
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.first().fill("测试搜索");
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - API 完整操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建标题 - 完整参数", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-create-full");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `完整参数测试 ${suffix}`,
|
||||
text: `这是一个完整参数的标题测试 ${suffix}`,
|
||||
category: "种草",
|
||||
tags: ["e2e", "完整测试", "种草"],
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy();
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`);
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
text: newText,
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新
|
||||
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
expect(verifyData.text).toBe(newText);
|
||||
});
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
test("批量导入标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-batch");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
headers,
|
||||
data: { titles },
|
||||
});
|
||||
|
||||
// 批量导入可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("创建标题 - 名称为空反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "",
|
||||
text: "有内容但名称为空",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-missing");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "缺少 text 字段",
|
||||
// 缺少 text 字段
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("更新不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update-404");
|
||||
|
||||
const response = await request.patch(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
},
|
||||
);
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("删除不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-del-404");
|
||||
|
||||
const response = await request.delete(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[404, 200, 204].includes(response.status()),
|
||||
"删除不存在的标题应返回 404 或幂等 2xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录创建标题 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
data: {
|
||||
name: "未登录测试",
|
||||
text: "未登录创建标题",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录删除标题 - 反向", async ({ request }) => {
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 分类/标签筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("标题分类 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-cat");
|
||||
|
||||
// 获取标题列表,检查分类字段
|
||||
const response = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.titles || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
|
||||
// 如果有标题,验证有分类字段
|
||||
if (items.length > 0) {
|
||||
expect(items[0].category !== undefined).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("按分类筛选标题", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建不同分类的标题
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `种草标题 ${suffix}`,
|
||||
text: "种草内容",
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `知识标题 ${suffix}`,
|
||||
text: "知识内容",
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
|
||||
// 按分类筛选
|
||||
const response = await request.get(`${apiBase}/titles`, {
|
||||
headers,
|
||||
params: { category: "种草" },
|
||||
});
|
||||
|
||||
// 筛选可能支持也可能不支持
|
||||
expect(
|
||||
response.ok(),
|
||||
`筛选请求应成功,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 页面交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("标题卡片展示完整信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-card");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-card",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证标题文本
|
||||
const titleText = firstCard.locator(".xx-title-card-text");
|
||||
if (await titleText.isVisible()) {
|
||||
await expect(titleText).toBeVisible();
|
||||
}
|
||||
// 验证统计信息
|
||||
const titleStat = firstCard.locator(".xx-title-card-stat");
|
||||
if (await titleStat.isVisible()) {
|
||||
await expect(titleStat).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("标题卡片可点击查看详情", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-detail",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
// 点击后页面应该有响应(可能是弹窗或跳转)
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 批量操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("多选复选框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-batch-ui");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建多个标题
|
||||
await createTitle(request, headers, `${suffix}-1`);
|
||||
await createTitle(request, headers, `${suffix}-2`);
|
||||
await createTitle(request, headers, `${suffix}-3`);
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-batch-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 检查是否有批量操作相关 UI
|
||||
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
|
||||
// 页面正常加载即可,批量操作是可选功能
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
/**
|
||||
* 声音克隆页面 E2E 测试
|
||||
*
|
||||
* 覆盖:克隆页面加载、上传区域展示、克隆列表、克隆状态展示、
|
||||
* 克隆详情、删除克隆、重试克隆、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("声音克隆页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("声音克隆页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题和描述存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-title",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-title",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证页面标题包含"克隆"或"音色"相关文字
|
||||
const pageTitle = page.getByRole("heading", { level: 1 });
|
||||
// 只要页面正常加载即可,标题可能在 PageHead 组件中
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("克隆新音色按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-newbtn",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-newbtn",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证克隆新音色按钮存在
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
|
||||
// 按钮可能在不同位置,只要页面加载成功即可
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("无克隆音色时显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 新用户应该显示空状态
|
||||
const emptyState = page.locator(".vc-empty");
|
||||
if (await emptyState.isVisible({ timeout: 10_000 })) {
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取克隆列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-list");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("创建音色克隆 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务(上传音频文件)
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `E2E 克隆音色 ${suffix}`,
|
||||
description: "E2E 测试创建的克隆音色",
|
||||
file: {
|
||||
name: `sample_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("fake audio data for e2e test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
|
||||
// 只要不是 500 错误即可
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy();
|
||||
expect(data.status, "应返回状态").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-detail");
|
||||
|
||||
// 先获取列表看看有没有数据
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
|
||||
if (items.length > 0) {
|
||||
const cloneId = items[0].id;
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(cloneId);
|
||||
}
|
||||
// 如果没有数据,测试也通过(新用户正常情况)
|
||||
});
|
||||
|
||||
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-del");
|
||||
|
||||
// 先创建一个克隆
|
||||
const suffix = Date.now().toString(36);
|
||||
const createResp = await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `待删除 ${suffix}`,
|
||||
file: {
|
||||
name: `del_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("delete me"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (createResp.ok()) {
|
||||
const created = await createResp.json();
|
||||
const cloneId = created.id;
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
});
|
||||
|
||||
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-retry");
|
||||
|
||||
// 先获取列表
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
|
||||
// 找一个失败状态的克隆进行重试
|
||||
const failedClone = items.find(
|
||||
(item: { status: string }) => item.status === "failed",
|
||||
);
|
||||
|
||||
if (failedClone) {
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/voice-clones/${failedClone.id}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
retryResp.ok(),
|
||||
`重试应返回 2xx,实际: ${retryResp.status()}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
// 如果没有失败的克隆,测试通过
|
||||
});
|
||||
|
||||
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-clone-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录获取克隆列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录创建克隆 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
multipart: {
|
||||
name: "未登录测试",
|
||||
file: {
|
||||
name: "test.wav",
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆卡片网格布局展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-grid",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-grid",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证网格容器或空状态存在
|
||||
const grid = page.locator(".vc-grid");
|
||||
const empty = page.locator(".vc-empty");
|
||||
|
||||
// 至少一个应该可见
|
||||
const gridVisible = await grid.isVisible().catch(() => false);
|
||||
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||
expect(gridVisible || emptyVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
test("克隆状态标签展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "vc-status");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务
|
||||
await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `E2E 状态测试 ${suffix}`,
|
||||
file: {
|
||||
name: `status_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("status test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-status",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 如果有卡片,验证状态标签存在
|
||||
const cards = page.locator(".vc-card");
|
||||
if ((await cards.count()) > 0) {
|
||||
const firstCard = cards.first();
|
||||
const statusPill = firstCard.locator(".vc-status-pill");
|
||||
if (await statusPill.isVisible()) {
|
||||
await expect(statusPill).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 上传区域", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-upload",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-upload",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,432 +0,0 @@
|
||||
/**
|
||||
* 音色库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:音色列表加载、预设音色展示、我的音色展示、音色详情查看、
|
||||
* 音色播放试听、搜索/筛选功能、创建自定义音色入口、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("音色库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voices");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-head",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 预设音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("预设音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-preset");
|
||||
|
||||
const response = await request.get(`${apiBase}/voices/preset`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 预设音色接口可能返回数组或包装对象
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取预设音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voices || data;
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("预设音色卡片在页面中展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待音色卡片加载(预设音色应该有数据)
|
||||
const voiceCards = page.locator(".xx-voice-card");
|
||||
// 等待至少一张卡片出现
|
||||
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await voiceCards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("音色卡片包含名称和信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 验证音色名称存在
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
|
||||
// 验证头像存在
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 我的克隆音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-cln-api");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 切换到"我的克隆"tab(如果有tab的话)
|
||||
const clonedTab = page.getByText("我的克隆").first();
|
||||
if (await clonedTab.isVisible()) {
|
||||
await clonedTab.click();
|
||||
}
|
||||
|
||||
// 页面至少应该是可访问的
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("创建克隆音色入口存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-create",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-create",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
|
||||
const createBtn = page.getByRole("button", {
|
||||
name: /克隆|新建|创建|\+/,
|
||||
});
|
||||
// 不强制断言一定存在,因为不同页面结构可能不同
|
||||
// 只验证页面正常加载即可
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 搜索和筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-search",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找搜索输入框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
|
||||
);
|
||||
const firstInput = searchInput.first();
|
||||
|
||||
if (await firstInput.isVisible({ timeout: 5_000 })) {
|
||||
await firstInput.fill("测试搜索");
|
||||
await expect(firstInput).toHaveValue("测试搜索");
|
||||
}
|
||||
});
|
||||
|
||||
test("性别/语言筛选选项存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-filter",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-filter",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证筛选相关元素存在(可能是下拉选择器或标签)
|
||||
const filterSelect = page.locator("select, .xx-voices-filter");
|
||||
// 页面正常加载即通过
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 播放试听", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色播放按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-play",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-play",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证播放按钮存在
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn");
|
||||
if (await playBtn.isVisible()) {
|
||||
await expect(playBtn).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - API 边界测试", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("未登录获取预设音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voices/preset`);
|
||||
// 预设音色可能不需要登录,也可能需要,两种情况都接受
|
||||
// 但如果需要登录,应返回 401/403
|
||||
if (!response.ok()) {
|
||||
expect([401, 403]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录获取克隆音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
});
|
||||
Generated
+415
-2
@@ -14,7 +14,10 @@
|
||||
"axios": "^1.7.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1412,6 +1415,42 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
@@ -1785,6 +1824,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
@@ -1954,6 +2005,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1999,6 +2113,12 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
|
||||
@@ -2812,6 +2932,15 @@
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2929,6 +3058,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -2973,6 +3223,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
|
||||
@@ -3135,6 +3391,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.47.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
||||
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -3405,6 +3671,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@@ -3951,8 +4223,6 @@
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
@@ -4014,6 +4284,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -5563,6 +5842,52 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5605,6 +5930,36 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5619,6 +5974,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -5626,6 +5996,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
@@ -6009,6 +6385,12 @@
|
||||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6242,6 +6624,28 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -6576,6 +6980,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
+7
-110
@@ -19,7 +19,6 @@ export interface AssetItem {
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -115,18 +114,6 @@ export const createAssetLibrary = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
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}`);
|
||||
@@ -142,46 +129,6 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string;
|
||||
gender?: string;
|
||||
style?: string;
|
||||
tag_ids?: string[];
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
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?: Record<string, unknown>;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: Record<string, unknown> },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
@@ -240,11 +187,10 @@ export const completeDirectUpload = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
/** 直传上传(大文件推荐) */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File;
|
||||
library_id: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
// 后端要求 project_id,前端自动获取默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
@@ -263,62 +209,13 @@ export const uploadAssetDirect = async (data: {
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = "";
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
|
||||
const msgMatch = xhr.responseText.match(
|
||||
/<Message>([^<]+)<\/Message>/,
|
||||
);
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
});
|
||||
reject(new Error(detail));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
|
||||
};
|
||||
xhr.send(directForm);
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
method: prepared.method,
|
||||
body: directForm,
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
|
||||
@@ -122,27 +122,8 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
|
||||
const data = error.response?.data;
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg;
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return safeExtractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const serverMsg = safeExtractString(rawServerMsg);
|
||||
const serverMsg = data?.detail || data?.message || data?.msg;
|
||||
let handled = false;
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 仪表盘 API
|
||||
* Phase 1 新增:用户仪表盘概览
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/** 仪表盘概览数据 */
|
||||
export interface DashboardOverview {
|
||||
/** 素材总数 */
|
||||
total_assets: number;
|
||||
/** 已用存储(字节) */
|
||||
used_storage_bytes: number;
|
||||
/** 总标题数 */
|
||||
total_titles: number;
|
||||
/** 总配音数 */
|
||||
total_voices: number;
|
||||
/** 生成任务总数 */
|
||||
total_tasks: number;
|
||||
/** 成品总数 */
|
||||
total_products: number;
|
||||
/** 最近生成任务 */
|
||||
recent_tasks: Array<{
|
||||
id: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
user_message: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
/** 订阅信息 */
|
||||
subscription: {
|
||||
plan: "free" | "pro" | "enterprise";
|
||||
status: "active" | "inactive" | "expired";
|
||||
expires_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取仪表盘概览数据 */
|
||||
export const getDashboardOverview = async (): Promise<DashboardOverview> => {
|
||||
const response = await apiClient.get("/dashboard/overview");
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 标签 CRUD API
|
||||
* P3 标签体系:对接后端标签表
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
export interface TagItem {
|
||||
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 || [];
|
||||
};
|
||||
|
||||
/** 创建标签(同名返回 409) */
|
||||
export const createTag = async (name: string): Promise<TagItem> => {
|
||||
const response = await apiClient.post("/tags", { name });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除标签(同时清理素材关联) */
|
||||
export const deleteTag = async (tagId: string): Promise<void> => {
|
||||
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 untagAsset = async (
|
||||
assetId: string,
|
||||
tagId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
|
||||
};
|
||||
@@ -68,14 +68,10 @@ export interface CreateTitleRequest {
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<
|
||||
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
|
||||
>("/titles");
|
||||
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
|
||||
const items = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.items || [];
|
||||
return items.map(toTitleItem);
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] }>(
|
||||
"/titles",
|
||||
);
|
||||
return (response.data.items || []).map(toTitleItem);
|
||||
};
|
||||
|
||||
/** 创建标题 */
|
||||
|
||||
@@ -122,20 +122,6 @@ export const getTTSJobs = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string;
|
||||
tag_ids?: string[];
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音素材库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {});
|
||||
};
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/* V21 业务组件统一样式 */
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.xx-primary-btn {
|
||||
background: var(--gradient-primary) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
padding: 10px 20px !important;
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
transition: var(--transition-all) !important;
|
||||
cursor: pointer;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.xx-primary-btn:hover {
|
||||
box-shadow: var(--shadow-hover) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-ghost-btn {
|
||||
background: transparent !important;
|
||||
color: var(--primary-color) !important;
|
||||
border: 2px solid var(--primary-color) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
padding: var(--space-sm) 18px !important;
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
transition: var(--transition-all) !important;
|
||||
cursor: pointer;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.xx-ghost-btn:hover {
|
||||
background: var(--primary-soft) !important;
|
||||
}
|
||||
|
||||
/* ==================== 卡片 ==================== */
|
||||
.xx-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: 20px;
|
||||
transition: all var(--transition-slow);
|
||||
}
|
||||
|
||||
.xx-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ==================== 页面结构 ==================== */
|
||||
.xx-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 26px;
|
||||
font-weight: var(--font-weight-extrabold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==================== 表格样式 ==================== */
|
||||
.xx-table-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 表格包装器 */
|
||||
.xx-table-wrapper {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 标签/Tag ==================== */
|
||||
.xx-tag {
|
||||
padding: var(--space-xs) 12px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.xx-tag-indigo {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--color-primary-200);
|
||||
}
|
||||
|
||||
.xx-tag-success {
|
||||
background: var(--success-soft);
|
||||
color: var(--color-secondary-500);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.xx-tag-warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--accent-dark);
|
||||
border: 1px solid var(--color-accent-200);
|
||||
}
|
||||
|
||||
.xx-tag-error {
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
/* ==================== 搜索栏 ==================== */
|
||||
.xx-search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.xx-search-input {
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
background: var(--bg-primary);
|
||||
transition: var(--transition-all);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ==================== Modal ==================== */
|
||||
.xx-modal .ant-modal-content {
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-header {
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
padding: 20px var(--space-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-modal .ant-modal-footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
/* ==================== 空状态 ==================== */
|
||||
.xx-empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-3xl) var(--space-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* ==================== 网格布局 ==================== */
|
||||
.xx-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-grid-2,
|
||||
.xx-grid-3,
|
||||
.xx-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 配额展示 ==================== */
|
||||
.xx-quota-item {
|
||||
padding: 20px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-quota-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 8px 24px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ==================== 进度条 ==================== */
|
||||
.xx-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ==================== Ant Design 覆盖样式 ==================== */
|
||||
/* Table overrides */
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary) !important;
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-bottom: 2px solid var(--border-color) !important;
|
||||
padding: 14px var(--space-md) !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr > td {
|
||||
padding: 14px var(--space-md) !important;
|
||||
border-bottom: 1px solid var(--color-gray-100) !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
|
||||
background: var(--color-gray-50) !important;
|
||||
}
|
||||
|
||||
/* Card overrides */
|
||||
.ant-card {
|
||||
border-radius: var(--radius-xl) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
min-height: 52px !important;
|
||||
padding: 0 var(--space-lg) !important;
|
||||
}
|
||||
|
||||
.ant-card-head-title {
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
font-size: var(--font-size-md) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 20px var(--space-lg) !important;
|
||||
}
|
||||
|
||||
/* Modal overrides */
|
||||
.ant-modal-content {
|
||||
border-radius: var(--radius-xl) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 20px var(--space-lg) !important;
|
||||
background: var(--bg-primary) !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
font-size: var(--font-size-lg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: var(--space-lg) !important;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: var(--space-md) var(--space-lg) !important;
|
||||
}
|
||||
|
||||
/* Button overrides */
|
||||
.ant-btn-primary {
|
||||
background: var(--gradient-primary) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
height: auto !important;
|
||||
padding: 10px 20px !important;
|
||||
font-weight: var(--font-weight-bold) !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: var(--gradient-primary) !important;
|
||||
box-shadow: var(--shadow-hover) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Tag overrides */
|
||||
.ant-tag {
|
||||
border-radius: var(--radius-xs) !important;
|
||||
padding: var(--space-xs) 12px !important;
|
||||
font-weight: var(--font-weight-medium) !important;
|
||||
}
|
||||
|
||||
/* Select overrides */
|
||||
.ant-select-selector {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Input overrides */
|
||||
.ant-input {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
padding: 10px 14px !important;
|
||||
}
|
||||
|
||||
.ant-input:hover {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.ant-input:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Progress overrides */
|
||||
.ant-progress-inner {
|
||||
background: var(--color-gray-100) !important;
|
||||
border-radius: var(--radius-xs) !important;
|
||||
}
|
||||
|
||||
.ant-progress-bg {
|
||||
border-radius: var(--radius-xs) !important;
|
||||
}
|
||||
@@ -62,7 +62,6 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/editing-planner": "剪辑规划",
|
||||
"/app/my-templates": "我的模板",
|
||||
"/app/voice-clone": "我的音色",
|
||||
"/app/voice-materials": "配音素材库",
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
|
||||
@@ -10,7 +10,6 @@ import React, { useState, useCallback, useRef } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { uploadAsset } from "@/api/assets";
|
||||
import "./clone-voice-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
@@ -122,24 +121,12 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
setStep("uploading");
|
||||
|
||||
try {
|
||||
// 先上传音频文件获取真实 URL
|
||||
let audioUrl: string;
|
||||
if (selectedFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("kind", "voice");
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
audioUrl = uploadResult.url;
|
||||
} else {
|
||||
// 录制功能暂未实现,提示用户上传
|
||||
setStep("input");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提交克隆请求
|
||||
// Mock:模拟上传 + 克隆过程
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: audioUrl,
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
});
|
||||
|
||||
setStep("success");
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 统一导航配置
|
||||
* Header 和 Sidebar 共用此数据源
|
||||
*/
|
||||
import React from "react";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileOutlined,
|
||||
AudioOutlined,
|
||||
FileTextOutlined,
|
||||
TrophyOutlined,
|
||||
AppstoreOutlined,
|
||||
HistoryOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
ScanOutlined,
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
/** 导航项定义 */
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 导航分组定义 */
|
||||
export interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平导航列表(Header 使用)
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/app/editing-planner",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/app/my-templates",
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/app/duplication",
|
||||
icon: <ScanOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 分组导航列表(Sidebar 使用)
|
||||
*/
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "创作工具",
|
||||
items: [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "首页",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "资源管理",
|
||||
items: [
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成片库",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统",
|
||||
items: [
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/app/admin",
|
||||
icon: <ControlOutlined />,
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/app/subscription",
|
||||
icon: <CrownOutlined />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -75,6 +75,35 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* V21 卡片 */
|
||||
.xx-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 24px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.xx-card:hover {
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.xx-card .ant-card-head {
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.xx-card .ant-card-head-title {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
color: var(--slate, #0f172a);
|
||||
}
|
||||
|
||||
.xx-card .ant-card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 统计卡片网格 - 4列 */
|
||||
.xx-grid-4 {
|
||||
display: grid;
|
||||
@@ -273,6 +302,17 @@
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
|
||||
}
|
||||
|
||||
/* V21 Select */
|
||||
.xx-select {
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.xx-select:hover,
|
||||
.xx-select:focus {
|
||||
border-color: var(--indigo, #4f46e5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
|
||||
}
|
||||
|
||||
/* V21 Tag */
|
||||
.xx-tag {
|
||||
border-radius: var(--radius-xs) !important;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -36,7 +37,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "image";
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -66,23 +67,15 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||||
/** 根据 quality_score 推断前端状态 */
|
||||
const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string } => {
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" };
|
||||
if (score >= 70) return { status: "ok", label: "合格" };
|
||||
if (score >= 40) return { status: "warn", label: "待优化" };
|
||||
return { status: "bad", label: "不合格" };
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (
|
||||
classificationStatus === "processing" ||
|
||||
classificationStatus === "pending"
|
||||
@@ -106,7 +99,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
kind: item.kind || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
@@ -115,19 +108,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
);
|
||||
const metadata = item.metadata || {};
|
||||
const kind = inferKind(item.mime_type || "");
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
kind: inferKind(item.mime_type || ""),
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined),
|
||||
fileUrl:
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.file_url as string | undefined),
|
||||
@@ -157,6 +147,8 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -166,6 +158,8 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -176,6 +170,8 @@ const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
|
||||
}
|
||||
@@ -229,16 +225,7 @@ const AssetCard: React.FC<{
|
||||
onToggle: () => void;
|
||||
onDiagnose: () => void;
|
||||
onPlay: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
|
||||
<div
|
||||
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
|
||||
onClick={onToggle}
|
||||
@@ -257,7 +244,7 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{asset.kind === "video" && (
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -269,24 +256,6 @@ const AssetCard: React.FC<{
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
@@ -385,8 +354,6 @@ const AssetLibrary: React.FC = () => {
|
||||
/* 状态 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
@@ -394,7 +361,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -460,37 +426,28 @@ const AssetLibrary: React.FC = () => {
|
||||
const handleUpload = async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个素材库");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
});
|
||||
await uploadAssetDirect({ file, library_id: effectiveLibId });
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
console.error("[handleUpload] 上传失败:", err);
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/* 新建素材库 */
|
||||
@@ -543,24 +500,6 @@ const AssetLibrary: React.FC = () => {
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
/* 单个素材删除 */
|
||||
const handleSingleDelete = async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
// 从选中集合中移除
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(assetId);
|
||||
return next;
|
||||
});
|
||||
message.success("素材已删除");
|
||||
} catch {
|
||||
message.error("删除失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
@@ -593,54 +532,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
|
||||
<AntModal
|
||||
open={uploading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg
|
||||
className="xx-upload-progress-ring"
|
||||
viewBox="0 0 120 120"
|
||||
width={120}
|
||||
height={120}
|
||||
>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
@@ -694,15 +585,10 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-content">
|
||||
{/* 上传区域 */}
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
// 同步返回 false 阻止 antd 默认上传行为
|
||||
// 异步上传由 handleUpload 处理
|
||||
handleUpload(file as File);
|
||||
return false;
|
||||
}}
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
accept="video/*,audio/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
@@ -712,7 +598,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -735,6 +621,7 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -820,7 +707,6 @@ const AssetLibrary: React.FC = () => {
|
||||
onToggle={() => toggleSelect(asset.id)}
|
||||
onDiagnose={() => handleDiagnose(asset)}
|
||||
onPlay={() => setPlayingAsset(asset)}
|
||||
onDelete={() => handleSingleDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -866,6 +752,7 @@ const AssetLibrary: React.FC = () => {
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -282,35 +282,6 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.xx-asset-delete {
|
||||
position: absolute;
|
||||
bottom: var(--space-sm, 8px);
|
||||
right: var(--space-sm, 8px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: var(--transition-all, all 0.2s ease);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-asset-card:hover .xx-asset-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-asset-delete:hover {
|
||||
background: rgba(255, 77, 79, 0.85);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 选中态 */
|
||||
.xx-asset-card-selected {
|
||||
border-color: var(--primary-color) !important;
|
||||
@@ -616,40 +587,3 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 上传进度弹窗 ─── */
|
||||
.xx-upload-progress-modal .ant-modal-content {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.xx-upload-progress-ring {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-upload-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-upload-progress-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@@ -1935,128 +1935,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══ 配音素材选择器 ═══ */
|
||||
.ep-clip-detail-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
background: var(--bg-tertiary, #1e293b);
|
||||
border: 1px solid var(--border-color, #334155);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-detail-select:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
}
|
||||
|
||||
.ep-clip-detail-select:focus {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.ep-voice-upload-btn {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
color: var(--primary, #6366f1);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--primary, #6366f1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-upload-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
/* 配音标签行(含刷新按钮) */
|
||||
.ep-clip-detail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ep-voice-refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-refresh-btn:hover {
|
||||
color: var(--primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.ep-voice-refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 配音选择行(select + 试听按钮) */
|
||||
.ep-voice-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ep-voice-select-row .ep-clip-detail-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ep-voice-preview-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
background: var(--bg-tertiary, #1e293b);
|
||||
border: 1px solid var(--border-color, #334155);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-preview-btn:hover {
|
||||
background: var(--bg-hover, #2d3a4f);
|
||||
border-color: var(--primary, #6366f1);
|
||||
}
|
||||
|
||||
.ep-voice-loading,
|
||||
.ep-voice-empty {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ═══ 预览区 — 补充样式 ═══ */
|
||||
.ep-phone-empty-hint {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { message } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
@@ -31,12 +30,6 @@ import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType } from "./types";
|
||||
import {
|
||||
ensureDefaultLibrary,
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
} from "@/api/assets";
|
||||
import { getOrCreateDefaultProject } from "@/api/projects";
|
||||
|
||||
import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
@@ -164,37 +157,6 @@ const EditingPlanner: React.FC = () => {
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: async () => {
|
||||
const project = await getOrCreateDefaultProject();
|
||||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" });
|
||||
const assets = await getAssetsByKind("voice");
|
||||
return assets;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [];
|
||||
|
||||
/** 为片段选择配音素材 */
|
||||
const handleClipVoiceSelect = useCallback(
|
||||
(clipId: string, asset: AssetItem | null) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
voice_asset_id: asset?.id ?? undefined,
|
||||
voice_file_url: asset?.file_url ?? undefined,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ──────────── 加载 ──────────── */
|
||||
|
||||
/**
|
||||
@@ -298,13 +260,6 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
setCurrentMode(mode);
|
||||
// 切换纯单类型模式时,自动转换所有已有片段的类型
|
||||
if (mode === "voice_over") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })));
|
||||
} else if (mode === "pip") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })));
|
||||
}
|
||||
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
|
||||
};
|
||||
|
||||
const handleClipSelect = (clipId: string) => {
|
||||
@@ -477,8 +432,6 @@ const EditingPlanner: React.FC = () => {
|
||||
duration: c.duration,
|
||||
template_segment_id: c.template_segment_id,
|
||||
script_text: c.script_text,
|
||||
voice_asset_id: c.voice_asset_id,
|
||||
voice_file_url: c.voice_file_url,
|
||||
})),
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
@@ -502,15 +455,11 @@ const EditingPlanner: React.FC = () => {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
voice_ids: [],
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
@@ -716,10 +665,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import React from "react";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
@@ -45,14 +43,6 @@ interface ClipPropertiesPanelProps {
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
voiceMaterialsLoading?: boolean;
|
||||
/** 刷新配音素材列表 */
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -274,46 +264,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 配音试听 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
/** 试听配音素材 */
|
||||
const handlePreviewVoice = useCallback(
|
||||
(asset: AssetItem) => {
|
||||
// 点击同一个 → 暂停
|
||||
if (previewingId === asset.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
}
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const url = asset.file_url || (asset.metadata?.preview_url as string);
|
||||
if (!url) return;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(asset.id);
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
|
||||
/** 从 metadata 取性别标签 */
|
||||
const getGenderLabel = (m: AssetItem): string => {
|
||||
const g = (m.metadata?.gender as string) || "";
|
||||
if (g === "male") return "男";
|
||||
if (g === "female") return "女";
|
||||
return "";
|
||||
};
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
@@ -674,102 +625,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音素材选择 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={selectedClip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value;
|
||||
if (!onClipVoiceSelect) return;
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(selectedClip.id, null);
|
||||
} else {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === assetId,
|
||||
);
|
||||
if (asset)
|
||||
onClipVoiceSelect(selectedClip.id, asset);
|
||||
}
|
||||
// 切换选择时停止试听
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m);
|
||||
const label = gender
|
||||
? `${m.name}(${gender})`
|
||||
: m.name;
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{/* 试听按钮 */}
|
||||
{selectedClip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={
|
||||
previewingId === selectedClip.voice_asset_id
|
||||
? "暂停"
|
||||
: "试听"
|
||||
}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === selectedClip.voice_asset_id,
|
||||
);
|
||||
if (asset) handlePreviewVoice(asset);
|
||||
}}
|
||||
>
|
||||
{previewingId === selectedClip.voice_asset_id
|
||||
? "⏸"
|
||||
: "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">
|
||||
暂无配音素材,请先上传
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="ep-voice-upload-btn"
|
||||
onClick={() => navigate("/app/voice-materials")}
|
||||
>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
@@ -55,34 +54,18 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"], // voice_pip / one_take / 默认
|
||||
[currentMode],
|
||||
);
|
||||
|
||||
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice";
|
||||
if (currentMode === "pip") return "pip";
|
||||
return "voice";
|
||||
}, [currentMode]);
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType);
|
||||
const [addType, setAddType] = useState<ClipType>("voice");
|
||||
const [addDuration, setAddDuration] = useState<number>(5);
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType);
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType]);
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] =
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"]; // voice_pip 或默认
|
||||
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
@@ -109,14 +92,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
// 打开面板时,默认选中当前模式下的第一个可用类型
|
||||
const defaultType =
|
||||
currentMode === "pip"
|
||||
? "pip"
|
||||
: currentMode === "voice_over"
|
||||
? "voice"
|
||||
: "voice";
|
||||
setAddType(defaultType);
|
||||
updatePickerPosition();
|
||||
}
|
||||
setShowAddPicker((v) => !v);
|
||||
|
||||
@@ -14,8 +14,4 @@ export interface ClipData {
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
order?: number;
|
||||
/** 配音素材 ID(voice 类型片段使用) */
|
||||
voice_asset_id?: string;
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -612,6 +612,54 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
按钮(匹配原型 .btn .ghost / .btn .primary)
|
||||
============================================================ */
|
||||
.xx-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 42px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
|
||||
.xx-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
|
||||
.xx-btn-ghost {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-btn-ghost:hover:not(:disabled) {
|
||||
border-color: var(--info-border);
|
||||
color: var(--primary-dark);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
右侧预览区 generate-preview
|
||||
============================================================ */
|
||||
@@ -835,332 +883,3 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 存为素材弹窗 ── */
|
||||
.xx-save-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: xxFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.xx-save-modal {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
animation: xxSlideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-save-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-close:hover {
|
||||
background: var(--bg-hover, #f8fafc);
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.xx-save-modal-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #475569);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.xx-save-modal-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.xx-save-modal-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-primary, #0f172a);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-input:focus {
|
||||
border-color: var(--primary-500, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tags:focus-within {
|
||||
border-color: var(--primary-500, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary-50, #eef2ff);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
border: 1px solid var(--primary-200, #c7d2fe);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-remove {
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-preset {
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #475569);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-save-modal-tag-preset:hover {
|
||||
border-color: var(--primary-300, #a5b4fc);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-save-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
@keyframes xxFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xxSlideUp {
|
||||
from {
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 生成数量步进器 ── */
|
||||
.xx-count-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-400, #818cf8);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-count-stepper-value {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-count-stepper-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── 素材选择模式切换 Tab ── */
|
||||
.xx-material-mode-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:first-child {
|
||||
border-right: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:hover {
|
||||
background: var(--primary-50, #eef2ff);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab.active {
|
||||
background: var(--primary-500, #6366f1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 自动匹配卡片 ── */
|
||||
.xx-auto-match-card {
|
||||
margin-top: 14px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-auto-match-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-auto-match-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-auto-match-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.xx-auto-match-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-feature {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@@ -207,11 +207,9 @@ const MyVoices: React.FC = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
if (!voice.sample_url) {
|
||||
showToast("暂无试听音频", "error");
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(voice.sample_url);
|
||||
// Mock: 使用 sample_url 或占位 URL
|
||||
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
|
||||
audio.onended = () => setPlayingId(null);
|
||||
|
||||
@@ -737,39 +737,11 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error);
|
||||
const errorMsg = error?.message || "加载失败";
|
||||
// 404 视为空数据(API 尚未就绪或无数据)
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
||||
<p>{error?.message || "加载失败"}</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description ?? "",
|
||||
description: item.description,
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
@@ -370,10 +370,9 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
{template.description.slice(0, 80)}...
|
||||
</div>
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
@@ -387,6 +386,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{/* 信息区 */}
|
||||
<div className="xx-template-info">
|
||||
<div className="xx-template-info-top">
|
||||
<h4 className="xx-template-name">{template.name}</h4>
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{template.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<p className="xx-template-desc">{template.description}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usageCount} 次
|
||||
@@ -483,9 +483,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -233,24 +233,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 缩略图底部名称 */
|
||||
.xx-template-thumb-name {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px 14px 10px;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 预览提示(hover 显示) */
|
||||
.xx-template-preview-hint {
|
||||
position: absolute;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* 标题库页面 — V21 设计系统
|
||||
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
|
||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -20,13 +19,6 @@ import {
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
getTitles,
|
||||
createTitle,
|
||||
updateTitle,
|
||||
deleteTitle,
|
||||
type TitleItem,
|
||||
} from "@/api/titles";
|
||||
import "./titles.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -64,16 +56,143 @@ const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
{ id: "cat-5", name: "教育学习", count: 2 },
|
||||
];
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
const toTitleData = (item: TitleItem): TitleData => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
type: (item.category as TitleType) || "normal",
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: item.created_at?.slice(0, 10) || "",
|
||||
});
|
||||
const MOCK_TITLES: TitleData[] = [
|
||||
{
|
||||
id: "t-1",
|
||||
content: "这家隐藏在巷子里的小店,味道绝了!",
|
||||
type: "hot",
|
||||
industry: "food",
|
||||
usageCount: 128,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "t-2",
|
||||
content: "2026 年最值得入手的 5 款蓝牙耳机",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 96,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "t-3",
|
||||
content: "周末在家做了一道妈妈的味道",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 42,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "t-4",
|
||||
content: "用 AI 帮我写了一周的小红书文案,效果惊人",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 215,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "t-5",
|
||||
content: "今天穿了一套被路人要链接的衣服",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 67,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "t-6",
|
||||
content: "分享我的早起 5 点俱乐部 30 天打卡体验",
|
||||
type: "normal",
|
||||
industry: "education",
|
||||
usageCount: 38,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "t-7",
|
||||
content: "这个平价面霜居然比大牌还好用?",
|
||||
type: "hot",
|
||||
industry: "beauty",
|
||||
usageCount: 183,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "t-8",
|
||||
content: "一个人的旅行也可以很精彩",
|
||||
type: "normal",
|
||||
industry: "travel",
|
||||
usageCount: 55,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "t-9",
|
||||
content: "考研上岸!我的备考时间管理方法全公开",
|
||||
type: "hot",
|
||||
industry: "education",
|
||||
usageCount: 147,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "t-10",
|
||||
content: "把旧 T 恤改造成时尚单品,零成本!",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 29,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
{
|
||||
id: "t-11",
|
||||
content: "这家咖啡馆的氛围感也太好了吧",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 74,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-18",
|
||||
},
|
||||
{
|
||||
id: "t-12",
|
||||
content: "手机摄影技巧:拍出电影感画面",
|
||||
type: "creative",
|
||||
industry: "tech",
|
||||
usageCount: 61,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-17",
|
||||
},
|
||||
{
|
||||
id: "t-13",
|
||||
content: "带娃旅行必备清单,少带一样都崩溃",
|
||||
type: "hot",
|
||||
industry: "travel",
|
||||
usageCount: 109,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-16",
|
||||
},
|
||||
{
|
||||
id: "t-14",
|
||||
content: "30 天学会一门新语言?我的实验记录",
|
||||
type: "creative",
|
||||
industry: "education",
|
||||
usageCount: 33,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-15",
|
||||
},
|
||||
{
|
||||
id: "t-15",
|
||||
content: "今天做了一道让全家惊艳的菜",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 48,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-14",
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
@@ -248,48 +367,12 @@ const TitleCard: React.FC<{
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const TitleLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* 分类数据 */
|
||||
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
|
||||
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
|
||||
|
||||
/* 标题数据 — 真实 API */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const titles: TitleData[] = useMemo(
|
||||
() => apiTitles.map(toTitleData),
|
||||
[apiTitles],
|
||||
);
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) =>
|
||||
updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
});
|
||||
/* 标题数据 */
|
||||
const [titles, setTitles] = useState<TitleData[]>(MOCK_TITLES);
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -380,9 +463,13 @@ const TitleLibrary: React.FC = () => {
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 收藏切换(暂不支持,待后端 API) */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线");
|
||||
/* 收藏切换 */
|
||||
const handleToggleFavorite = useCallback((id: string) => {
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/* 复制 */
|
||||
@@ -406,13 +493,15 @@ const TitleLibrary: React.FC = () => {
|
||||
message.warning("标题内容不能为空");
|
||||
return;
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, content: editText.trim() });
|
||||
}
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === editingId ? { ...t, content: editText.trim() } : t,
|
||||
),
|
||||
);
|
||||
setEditingId(null);
|
||||
setEditText("");
|
||||
message.success("标题已更新");
|
||||
}, [editingId, editText, updateMutation]);
|
||||
}, [editingId, editText]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
@@ -420,13 +509,10 @@ const TitleLibrary: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
message.success("标题已删除");
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setTitles((prev) => prev.filter((t) => t.id !== id));
|
||||
message.success("标题已删除");
|
||||
}, []);
|
||||
|
||||
/* 新建分类 */
|
||||
const handleCreateCategory = () => {
|
||||
@@ -461,14 +547,20 @@ const TitleLibrary: React.FC = () => {
|
||||
message.warning("请输入标题内容");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(newTitleContent.trim(), {
|
||||
onSuccess: () => {
|
||||
setCreateTitleModalOpen(false);
|
||||
setNewTitleContent("");
|
||||
setNewTitleType("normal");
|
||||
message.success("标题创建成功");
|
||||
},
|
||||
});
|
||||
const title: TitleData = {
|
||||
id: `t-${Date.now()}`,
|
||||
content: newTitleContent.trim(),
|
||||
type: newTitleType,
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
setTitles((prev) => [title, ...prev]);
|
||||
setCreateTitleModalOpen(false);
|
||||
setNewTitleContent("");
|
||||
setNewTitleType("normal");
|
||||
message.success("标题创建成功");
|
||||
};
|
||||
|
||||
/* AI 生成标题 */
|
||||
@@ -497,11 +589,17 @@ const TitleLibrary: React.FC = () => {
|
||||
|
||||
/* 采纳 AI 生成的标题 */
|
||||
const handleAdoptAITitle = (text: string) => {
|
||||
createMutation.mutate(text, {
|
||||
onSuccess: () => {
|
||||
message.success("标题已采纳并添加到标题库");
|
||||
},
|
||||
});
|
||||
const title: TitleData = {
|
||||
id: `t-${Date.now()}`,
|
||||
content: text,
|
||||
type: "creative",
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
setTitles((prev) => [title, ...prev]);
|
||||
message.success("标题已采纳并添加到标题库");
|
||||
};
|
||||
|
||||
/* 复制 AI 生成的标题 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user