Compare commits

..

1 Commits

Author SHA1 Message Date
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h48m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h48m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h46m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-07 08:05:42 +08:00
141 changed files with 1692 additions and 28408 deletions
+5 -10
View File
@@ -7,20 +7,15 @@ on:
jobs:
auto-merge:
runs-on: saas
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
- 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
+481 -296
View File
File diff suppressed because one or more lines are too long
-550
View File
@@ -1,550 +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
bash scripts/ci_checkout.sh
- 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
bash scripts/ci_checkout.sh
- 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
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- 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" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
EXIT_CODE=${PIPESTATUS[0]}
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== Staging E2E 测试报告 =========="
echo "环境: https://staging.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
if [ "$EXIT_CODE" -eq 0 ]; then
echo "结果: PASS"
echo "report=PASS" >> "${GITHUB_OUTPUT}"
else
echo "结果: FAIL"
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi
echo "=========================================="
exit $EXIT_CODE
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
steps:
- name: Run performance baseline checks
id: perf
shell: sh
run: |
set +e
START_TIME=$(date +%s)
echo "=========================================="
echo " 性能基线巡检 - Staging API"
echo " 目标: https://staging-api.xiaoxiajianji.com"
echo "=========================================="
echo ""
TOTAL=0
PASS=0
FAIL=0
WARN=0
WARN_LIST=""
FAIL_LIST=""
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
# 核心接口(core): 500ms
# 普通接口(normal): 1000ms
# 重操作接口(heavy): 3000ms
ENDPOINTS="
登录|/api/v1/auth/login|POST|500|3000
获取当前用户|/api/v1/auth/me|GET|500|3000
项目列表|/api/v1/projects|GET|500|3000
素材列表|/api/v1/assets|GET|500|3000
模板列表|/api/v1/templates|GET|500|3000
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
生成任务列表|/api/v1/generation/tasks|GET|500|3000
订阅信息|/api/v1/subscription/current|GET|500|3000
音色列表|/api/v1/voices|GET|1000|5000
健康检查|/health|GET|200|1000
"
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
if [ -n "$TOKEN" ]; then
echo "Token 获取成功"
else
echo "Token 解析失败,部分接口可能无法测试"
TOKEN=""
fi
else
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
TOKEN=""
fi
echo ""
echo "--- 开始性能测试 ---"
echo ""
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
[ -z "$name" ] && continue
TOTAL=$((TOTAL + 1))
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
# 执行请求
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
PASS=$((PASS + 1))
else
PASS=$((PASS + 1))
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
fi
done
# 由于 while 在子 shell 中执行,用文件传递结果
# 重新跑一次用文件计数方式
echo ""
echo "--- 汇总性能数据 ---"
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "耗时: ${ELAPSED}s"
echo "======================================"
- name: Generate performance report
id: report
shell: sh
run: |
set +e
echo ""
echo "=========================================="
echo " 性能基线巡检 - 详细报告"
echo "=========================================="
TOTAL=0
PASS=0
FAIL=0
WARN=0
RESULTS=""
START_TIME=$(date +%s)
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
TOKEN=""
if [ "$AUTH_CODE" = "200" ]; then
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
fi
run_perf_test() {
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
TOTAL=$((TOTAL + 1))
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
fi
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
if echo "$HTTP_CODE" | grep -q "^[5]"; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
FAIL=$((FAIL + 1))
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
return 1
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
WARN=$((WARN + 1))
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
return 0
else
PASS=$((PASS + 1))
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
return 0
fi
}
echo ""
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
echo ""
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
echo ""
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo ""
echo "========== 性能基线巡检报告 =========="
echo "环境: https://staging-api.xiaoxiajianji.com"
echo "总接口: ${TOTAL}"
echo "通过: ${PASS}"
echo "失败: ${FAIL}"
echo "警告: ${WARN}"
echo "耗时: ${ELAPSED}s"
echo "======================================"
# 写入结果文件供 report job 使用
echo "${TOTAL}" > /tmp/perf_total
echo "${PASS}" > /tmp/perf_pass
echo "${FAIL}" > /tmp/perf_fail
echo "${WARN}" > /tmp/perf_warn
echo "${ELAPSED}" > /tmp/perf_elapsed
if [ "$FAIL" -gt 0 ]; then
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
exit 1
else
echo "report=PASS" >> "${GITHUB_OUTPUT}"
if [ "$WARN" -gt 0 ]; then
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
else
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
fi
exit 0
fi
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: saas
timeout-minutes: 2
if: always()
needs:
- production-smoke
- staging-api-tests
- staging-e2e
- performance-check
steps:
- name: Print summary report
shell: sh
run: |
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ 每日巡检报告 ║"
echo "╠══════════════════════════════════════════════════════╣"
# 获取各 job 状态
PROD_STATUS="${{ needs.production-smoke.result }}"
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
PERF_STATUS="${{ needs.performance-check.result }}"
format_result() {
if [ "$1" = "success" ]; then
echo "✅ PASS"
elif [ "$1" = "failure" ]; then
echo "❌ FAIL"
elif [ "$1" = "skipped" ]; then
echo "⏭️ SKIP"
else
echo "❓ UNKNOWN ($1)"
fi
}
echo "║"
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
echo "║"
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
echo "║"
# 判断整体状态
ALL_PASS=true
FAILED_ITEMS=""
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
STATUS=$(echo "$status_name" | cut -d: -f1)
NAME=$(echo "$status_name" | cut -d: -f2)
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
ALL_PASS=false
FAILED_ITEMS="$FAILED_ITEMS $NAME"
fi
done
echo "╠══════════════════════════════════════════════════════╣"
if [ "$ALL_PASS" = "true" ]; then
echo "║ 整体状态: ✅ 全部通过 ║"
else
echo "║ 整体状态: ❌ 存在失败 ║"
echo "║ 失败项: ${FAILED_ITEMS} ║"
fi
echo "╚══════════════════════════════════════════════════════╝"
echo ""
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
if [ "$ALL_PASS" = "false" ]; then
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
fi
@@ -1,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
-6
View File
@@ -16,7 +16,6 @@ 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
@@ -39,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"],
+2 -90
View File
@@ -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,7 +15,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibraryKind
router = APIRouter()
@@ -48,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),
@@ -72,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])
@@ -101,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)
+22 -279
View File
@@ -7,23 +7,14 @@ 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
@@ -68,7 +59,6 @@ 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", []),
)
@@ -85,13 +75,6 @@ def _check_project_access(project_id: str, user_id: str, project_repository) ->
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),
@@ -99,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))
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):
@@ -268,130 +135,6 @@ def update_asset_review_status(
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,
+124 -163
View File
@@ -19,7 +19,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,
)
@@ -43,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
@@ -274,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,
@@ -493,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()
+41 -186
View File
@@ -22,28 +22,16 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -212,9 +200,9 @@ def _check_project_access(project_id: str, user_id: str, project_repository: Any
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
raise HTTPException(status_code=403, detail="Access denied to project")
def _to_response(p: EditPlan) -> EditPlanResponse:
@@ -265,7 +253,7 @@ def list_plans(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
detail=(f"无效的状态值: {status_filter}" f"可选值: draft, editing, rendering, completed, failed"),
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
@@ -391,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:
@@ -447,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:
"""触发剪辑计划渲染生成
@@ -468,121 +454,6 @@ def generate_plan(
if plan_check.project_id:
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
# 回退到旧模型 template_segments
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main", # 旧模型无结构角色,统一为主体片段
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = [] # 已分配完
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
# 筛选 ready 状态的视频素材
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
# 随机选取,按片段数轮询分配
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
@@ -597,64 +468,48 @@ def generate_plan(
detail=reason,
)
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
try:
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 将 pending 片段标记为 ready
clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 将 generation_task_id 存入 plan config
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 流转状态为 rendering
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
# 获取最新状态
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception as exc:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
@router.get(
@@ -801,7 +656,7 @@ def ai_recommend_clips(
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
)
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
@@ -852,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(
+17 -86
View File
@@ -1,5 +1,3 @@
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
@@ -16,7 +14,6 @@ from app.schemas.generated_video import (
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
BatchGenerationTaskResponse,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
@@ -54,8 +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", ""),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -90,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,
@@ -170,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),
@@ -178,13 +130,12 @@ 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:
) -> GenerationTaskResponse:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# 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):
@@ -193,42 +144,23 @@ def create_generation_task(
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 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,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
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])
created_tasks.append(task)
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
@@ -305,7 +237,6 @@ def retry_generation_task(
voice_ids=task.voice_ids,
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", ""),
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
-67
View File
@@ -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)
+11 -174
View File
@@ -6,31 +6,21 @@ import logging
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
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,
@@ -40,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__)
@@ -89,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 合成任务。
@@ -97,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,
@@ -142,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,
@@ -244,127 +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),
) -> 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=item.audio_url,
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
-49
View File
@@ -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,
)
@@ -100,7 +99,6 @@ def _submit_ingest_job(
library_id: str,
storage_key: str,
ingest_job_repository: Any,
file_hash: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -108,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])
@@ -179,7 +176,6 @@ 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:
"""确认浏览器直传完成并创建导入任务。"""
@@ -203,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)
@@ -243,38 +218,15 @@ 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,
)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -303,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(
-9
View File
@@ -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,
)
@@ -59,7 +58,6 @@ 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.tag_repository import TagRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
@@ -140,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:
-23
View File
@@ -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)
-5
View File
@@ -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)$")
-3
View File
@@ -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_idduplicated=true 时返回)")
-19
View File
@@ -21,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":
@@ -56,21 +46,12 @@ 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
class BatchGenerationTaskResponse(BaseModel):
"""批量生成任务响应。"""
items: list[GenerationTaskResponse]
total: int
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
-24
View File
@@ -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)
-18
View File
@@ -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 -13
View File
@@ -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_idduplicated=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_idduplicated=true 时返回)")
+2 -2
View File
@@ -447,12 +447,12 @@ class EditPlanService:
# 检查状态
if plan.status != EditPlanStatus.EDITING:
return False, "请先编辑并保存模板后再生成视频"
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
# 检查是否有片段
clips = self._clip_repo.list_by_plan(plan_id)
if not clips:
return False, "请先添加片段后再生成视频"
return False, "计划下没有片段,无法触发渲染"
return True, ""
-626
View File
@@ -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/);
});
});
-554
View File
@@ -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/);
});
});
-480
View File
@@ -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();
});
});
-670
View File
@@ -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_urlvideo 元素应能渲染)
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/);
});
});
-474
View File
@@ -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 });
});
});
-298
View File
@@ -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();
});
});
-600
View File
@@ -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();
});
});
-628
View File
@@ -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 });
});
});
-538
View File
@@ -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();
});
});
-504
View File
@@ -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();
}
}
});
});
-432
View File
@@ -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);
});
});
+415 -2
View File
@@ -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
View File
@@ -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,
+1 -20
View File
@@ -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")) {
-46
View File
@@ -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}`);
};
+4 -8
View File
@@ -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);
};
/** 创建标题 */
-14
View File
@@ -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}`);
@@ -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");
-12
View File
@@ -67,12 +67,6 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
@@ -159,12 +153,6 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
+27 -140
View File
@@ -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: "图片" },
]}
/>
-66
View File
@@ -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
-329
View File
@@ -883,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;
}
+3 -5
View File
@@ -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);
+1 -29
View File
@@ -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;
+182 -84
View File
@@ -2,11 +2,10 @@
* 标题库页面 — V21 设计系统
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
* 对接后端真实 APIGET/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
File diff suppressed because it is too large Load Diff
+2 -737
View File
@@ -30,7 +30,6 @@ import {
ReloadOutlined,
CloseCircleOutlined,
} from "@ant-design/icons";
import { Modal, Upload, message } from "antd";
import { Button, Input, Select, Tooltip } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
@@ -45,12 +44,6 @@ import {
toVoiceClone,
type VoiceClone,
} from "@/api/voiceClone";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import {
uploadAssetDirect,
getAssetLibraries,
createAsset,
} from "@/api/assets";
import CloneModal from "@/components/voice/CloneModal";
import "./voices.css";
@@ -600,41 +593,6 @@ const CloneCardSkeleton: React.FC = () => (
</div>
);
/* ── 辅助函数 ─────────────────────────────────────────── */
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getAudioDuration = (file: File): Promise<number> =>
new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener("error", () => {
resolve(0);
URL.revokeObjectURL(url);
});
audio.src = url;
});
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
return metadata;
};
/* ============================================================
* 主组件
* ============================================================ */
@@ -655,27 +613,6 @@ const VoiceLibrary: React.FC = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
const [cloneModalOpen, setCloneModalOpen] = useState(false);
/* ── 上传音频弹窗状态 ── */
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadName, setUploadName] = useState("");
const [uploadGender, setUploadGender] = useState<VoiceGender>("female");
const [uploadDesc, setUploadDesc] = useState("");
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
/* ── AI 配音弹窗状态 ── */
const [ttsOpen, setTtsOpen] = useState(false);
const [ttsText, setTtsText] = useState("");
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
const [ttsSpeed, setTtsSpeed] = useState(1.0);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
const [ttsStatus, setTtsStatus] = useState<
"idle" | "synthesizing" | "done" | "error"
>("idle");
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdSeq;
setToasts((prev) => [...prev, { id, message, type }]);
@@ -708,130 +645,6 @@ const VoiceLibrary: React.FC = () => {
},
});
/* ── 上传音频 mutation ── */
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File;
name: string;
gender: VoiceGender;
description: string;
}) => {
setUploadProgress(0);
try {
/* 获取或创建默认配音素材库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建");
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
onProgress: (p) => setUploadProgress(p),
});
/* 获取音频时长 */
const duration = await getAudioDuration(data.file);
/* 创建素材记录 */
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildVoiceMetadata({
gender: data.gender,
description: data.description,
duration,
}),
});
} finally {
setUploadProgress(null);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
showToast("上传成功", "success");
},
onError: (err: Error) => {
showToast(err.message || "上传失败,请重试", "error");
},
});
/* ── TTS 合成 ── */
const handleTtsSynthesize = useCallback(async () => {
if (!ttsText.trim()) {
message.warning("请输入要合成的文本");
return;
}
setTtsError(null);
setTtsStatus("synthesizing");
setTtsAudioUrl(null);
setTtsJobId(null);
try {
const resp = await synthesizeSpeech({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
});
setTtsJobId(resp.job_id);
/* 轮询状态 */
ttsTimerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(resp.job_id);
if (job.status === "completed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("done");
setTtsAudioUrl(job.output_audio_url);
} else if (job.status === "failed") {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError(job.error_message || "合成失败");
}
} catch {
clearInterval(ttsTimerRef.current!);
ttsTimerRef.current = null;
setTtsStatus("error");
setTtsError("查询合成状态失败");
}
}, 2000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "合成请求失败";
setTtsStatus("error");
setTtsError(msg);
}
}, [ttsText, ttsVoiceId, ttsSpeed]);
/* ── TTS 保存到素材库 ── */
const handleTtsSave = useCallback(async () => {
if (!ttsJobId) return;
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) });
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
showToast("已保存到配音素材库", "success");
setTtsOpen(false);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败";
showToast(msg, "error");
}
}, [ttsJobId, ttsText, queryClient, showToast]);
/* ── TTS 定时器清理 ── */
useEffect(() => {
return () => {
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
};
}, []);
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
/** 预置音色列表 */
@@ -975,12 +788,7 @@ const VoiceLibrary: React.FC = () => {
const pageActions = (
<div className="xx-voices-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<UploadOutlined />}
onClick={() => setUploadOpen(true)}
>
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
</Button>
<Button
@@ -991,12 +799,7 @@ const VoiceLibrary: React.FC = () => {
>
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setTtsOpen(true)}
>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />}>
AI
</Button>
</div>
@@ -1205,544 +1008,6 @@ const VoiceLibrary: React.FC = () => {
/>
)}
{/* ── 上传音频弹窗 ── */}
<Modal
title="上传音频"
open={uploadOpen}
onCancel={() => {
if (uploadProgress !== null) return; // 上传中不可关闭
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
footer={null}
width={520}
maskClosable={uploadProgress === null}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 拖拽上传区 */}
<Upload.Dragger
accept="audio/*"
maxCount={1}
beforeUpload={(file) => {
setUploadFile(file);
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""));
return false;
}}
onRemove={() => {
setUploadFile(null);
setUploadProgress(null);
}}
showUploadList={false}
disabled={uploadProgress !== null}
>
<p
style={{
fontSize: 32,
color: "var(--primary-color)",
marginBottom: 8,
}}
>
<UploadOutlined />
</p>
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
</p>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
margin: 0,
}}
>
MP3WAVAACFLAC 200MB
</p>
</Upload.Dragger>
{/* 已选文件信息 */}
{uploadFile && (
<div
style={{
padding: "10px 12px",
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<SoundOutlined
style={{ fontSize: 18, color: "var(--primary-color)" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{uploadFile.name}
</div>
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
{formatFileSize(uploadFile.size)}
</div>
</div>
</div>
)}
{/* 上传进度 */}
{uploadProgress !== null && (
<div style={{ textAlign: "center", padding: "8px 0" }}>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--primary-color)",
}}
>
{uploadProgress}%
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{uploadProgress < 100 ? "上传中..." : "处理中..."}
</div>
<div
style={{
height: 4,
background: "var(--bg-tertiary)",
borderRadius: 2,
marginTop: 8,
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${uploadProgress}%`,
background: "var(--primary-color)",
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{/* 名称 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<input
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="输入素材名称"
maxLength={100}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
/>
</div>
{/* 性别选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<div style={{ display: "flex", gap: 8 }}>
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
<button
key={g}
type="button"
onClick={() => setUploadGender(g)}
disabled={uploadProgress !== null}
style={{
flex: 1,
padding: "6px 0",
borderRadius: 8,
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
background:
uploadGender === g
? "var(--primary-soft)"
: "transparent",
color:
uploadGender === g
? "var(--primary-color)"
: "var(--text-secondary)",
fontSize: 13,
fontWeight: uploadGender === g ? 600 : 400,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
transition: "all 0.2s",
}}
>
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
</button>
))}
</div>
</div>
{/* 描述 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={uploadDesc}
onChange={(e) => setUploadDesc(e.target.value)}
placeholder="描述这个音色的特点..."
maxLength={500}
rows={2}
disabled={uploadProgress !== null}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
}}
/>
</div>
{/* 操作按钮 */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
paddingTop: 4,
}}
>
<button
type="button"
onClick={() => {
setUploadOpen(false);
setUploadFile(null);
setUploadName("");
setUploadDesc("");
}}
disabled={uploadProgress !== null}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "1px solid var(--border-color)",
background: "transparent",
fontSize: 13,
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
color: "var(--text-secondary)",
}}
>
</button>
<button
type="button"
onClick={() => {
if (!uploadFile) {
message.warning("请先选择音频文件");
return;
}
if (!uploadName.trim()) {
message.warning("请输入素材名称");
return;
}
uploadMutation.mutate({
file: uploadFile,
name: uploadName.trim(),
gender: uploadGender,
description: uploadDesc.trim(),
});
}}
disabled={
!uploadFile || !uploadName.trim() || uploadProgress !== null
}
style={{
padding: "8px 20px",
borderRadius: 8,
border: "none",
background:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 13,
fontWeight: 600,
cursor:
!uploadFile || !uploadName.trim() || uploadProgress !== null
? "not-allowed"
: "pointer",
}}
>
{uploadProgress !== null ? "上传中..." : "开始上传"}
</button>
</div>
</div>
</Modal>
{/* ── AI 配音弹窗 ── */}
<Modal
title="AI 配音"
open={ttsOpen}
onCancel={() => {
setTtsOpen(false);
setTtsText("");
setTtsVoiceId("");
setTtsSpeed(1.0);
setTtsStatus("idle");
setTtsAudioUrl(null);
setTtsError(null);
setTtsJobId(null);
}}
footer={null}
width={560}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
{/* 文本输入 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<textarea
value={ttsText}
onChange={(e) => setTtsText(e.target.value)}
placeholder="输入要配音的文本内容..."
maxLength={2000}
rows={4}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
lineHeight: 1.6,
}}
/>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
textAlign: "right",
marginTop: 4,
}}
>
{ttsText.length}/2000
</div>
</div>
{/* 音色选择 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={ttsVoiceId}
onChange={(e) => setTtsVoiceId(e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border-color)",
borderRadius: 8,
fontSize: 13,
background: "var(--bg-primary)",
color: "var(--text-primary)",
outline: "none",
}}
>
<option value=""></option>
{presetVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {genderLabel(v.gender)}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
{ttsSpeed.toFixed(1)}x
</div>
<input
type="range"
min={0.5}
max={2.0}
step={0.1}
value={ttsSpeed}
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--primary-color)" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--text-tertiary)",
}}
>
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 合成按钮 */}
<button
type="button"
onClick={handleTtsSynthesize}
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
style={{
width: "100%",
padding: "10px 0",
borderRadius: 8,
border: "none",
background:
ttsStatus === "synthesizing" || !ttsText.trim()
? "var(--text-tertiary)"
: "var(--primary-color)",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor:
ttsStatus === "synthesizing" || !ttsText.trim()
? "not-allowed"
: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<RobotOutlined />
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
</button>
{/* 错误提示 */}
{ttsError && (
<div
style={{
padding: "10px 12px",
background: "var(--error-soft, #fff2f0)",
borderRadius: 8,
color: "var(--error-color, #ff4d4f)",
fontSize: 13,
}}
>
{ttsError}
</div>
)}
{/* 合成结果 */}
{ttsStatus === "done" && ttsAudioUrl && (
<div
style={{
padding: 12,
background: "var(--bg-secondary)",
borderRadius: 8,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--success-color, #52c41a)",
}}
>
</div>
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
<button
type="button"
onClick={handleTtsSave}
style={{
padding: "8px 0",
borderRadius: 8,
border: "1px solid var(--primary-color)",
background: "var(--primary-soft)",
color: "var(--primary-color)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
</button>
</div>
)}
</div>
</Modal>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
-7
View File
@@ -156,13 +156,6 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
-62
View File
@@ -206,68 +206,6 @@ class VideoDeduplicator:
return None
def check_batch_duplicate(
self,
fingerprint: VideoFingerprint,
batch_id: str,
current_video_id: str,
session: Session,
) -> Optional[dict]:
"""检查视频是否与同批次内其他视频重复。
逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
Args:
fingerprint: 待检测视频的指纹
batch_id: 批次 ID
current_video_id: 当前视频 ID(排除自身)
session: 数据库会话
Returns:
重复信息字典,或 None 表示未找到重复
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
batch_videos = video_repo.list_by_batch(batch_id)
for existing in batch_videos:
if existing.id == current_video_id:
continue
if not existing.video_fingerprint:
continue
ef = existing.video_fingerprint
if fingerprint.md5 == ef.get("md5"):
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_exact_md5_match",
"similarity": 1.0,
}
existing_phashes = ef.get("keyframe_phashes", [])
if not existing_phashes:
continue
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100
if avg_distance >= self.PHASH_THRESHOLD:
continue
phash_similarity = 1.0 - (avg_distance / 64)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_similar",
"similarity": phash_similarity,
}
return None
@staticmethod
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
"""
-5
View File
@@ -41,10 +41,6 @@ def __getattr__(name: str):
from .tts_synthesis import process_tts_synthesis
return process_tts_synthesis
elif name == "process_tts_segment_synthesis":
from .tts_synthesis import process_tts_segment_synthesis
return process_tts_segment_synthesis
elif name == "run_ai_recommend":
from .ai_tasks import run_ai_recommend
@@ -66,7 +62,6 @@ __all__ = [
"extract_background_task",
"process_voice_clone",
"process_tts_synthesis",
"process_tts_segment_synthesis",
"run_ai_recommend",
"run_generate_cover",
]
@@ -215,8 +215,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
"""
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
generation_task_id = ""
for repos in _get_repos():
plan_repo, clip_repo, gen_task_repo, db = repos
@@ -227,9 +225,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.error("剪辑计划不存在: %s", plan_id)
return {"status": "error", "message": f"计划不存在: {plan_id}"}
# 获取 generation_task_id(提前读取,确保 except 块可用)
generation_task_id = plan.config.get("generation_task_id", "")
# 2. 加载片段列表(按 order 排序)
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
@@ -238,6 +233,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
plan_repo.update(plan)
return {"status": "error", "message": "没有可渲染的片段"}
# 获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id", "")
# 更新 GenerationTask 状态为 running
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
@@ -347,31 +345,15 @@ def render_edit_plan(self, plan_id: str) -> dict:
except Exception as exc:
logger.exception("渲染剪辑计划异常: %s", plan_id)
# 尝试标记计划和 GenerationTask 为失败
# 尝试标记计划为失败
try:
plan = plan_repo.get(plan_id)
if plan and plan.status.value == "rendering":
plan.mark_failed()
plan_repo.update(plan)
except Exception as e:
logger.warning("标记计划失败时异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
# 更新 GenerationTask 状态为 failed,前端轮询能看到失败状态
try:
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.status.value != "failed":
gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
generation_task_id,
plan_id,
)
except Exception as e:
logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True
)
raise self.retry(exc=exc, countdown=60)
+12 -123
View File
@@ -139,16 +139,14 @@ def _download_library_assets(
asset_library_id: str,
temp_path: Path,
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
asset_ids: list[str] | None = None,
) -> list[str]:
"""
从素材库下载视频素材
从素材库下载所有视频素材
Args:
asset_library_id: 素材库 ID
temp_path: 临时目录路径
video_extensions: 支持的视频扩展名
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
Returns:
下载成功的视频文件路径列表
@@ -163,15 +161,16 @@ def _download_library_assets(
try:
# 查询素材库中的视频素材
query = session.query(AssetModel).filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
assets = (
session.query(AssetModel)
.filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
.order_by(AssetModel.created_at)
.all()
)
# 如果指定了 asset_ids,则只下载这些素材
if asset_ids:
query = query.filter(AssetModel.id.in_(asset_ids))
assets = query.order_by(AssetModel.created_at).all()
if not assets:
logger.info(f"No video assets found in library {asset_library_id}")
@@ -260,8 +259,6 @@ def generate_video(self, task_id: str) -> dict:
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
mode = gen_task.strategy_id or "one_take"
task_asset_ids = list(gen_task.asset_ids or [])
batch_id = getattr(gen_task, "batch_id", "") or ""
finally:
session.close()
@@ -278,8 +275,8 @@ def generate_video(self, task_id: str) -> dict:
temp_path = Path(temp_dir)
output_path = temp_path / output_name
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
# 从素材库下载视频素材
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
audio_path = None
if voice_library_id:
@@ -300,32 +297,6 @@ def generate_video(self, task_id: str) -> dict:
file_size = output_path.stat().st_size
duration = _probe_duration(output_path)
# 上传到 OSS
bucket = _oss_bucket()
if bucket:
try:
bucket.put_object_from_file(storage_key, str(output_path))
except Exception as oss_err:
logger.warning(f"OSS upload failed: {oss_err}")
# 构建视频 URL
if bucket:
file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}"
else:
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
# 创建 GeneratedVideo 记录 + 查重
_create_video_record_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
)
return {
"status": "completed",
"task_id": task_id,
@@ -343,85 +314,3 @@ def generate_video(self, task_id: str) -> dict:
"task_id": task_id,
"error": str(error),
}
def _create_video_record_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
) -> None:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
from uuid import uuid4
from video_processing.dedup import VideoDeduplicator
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
session = SessionLocal()
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=task_id,
name=f"generated-{task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
fps=OUTPUT_FPS,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
session.commit()
return
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} "
f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})"
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
except Exception as e:
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
session.rollback()
finally:
session.close()
-1
View File
@@ -179,7 +179,6 @@ def ingest_asset(job_id: str) -> dict:
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
status=AssetStatus.READY,
file_hash=job.file_hash,
)
asset_repo.create(asset)
@@ -104,77 +104,3 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
finally:
if session is not None:
session.close()
@celery_app.task(bind=True, max_retries=2, name="worker.process_tts_segment_synthesis")
def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
"""分段合成轮询任务 — 轮询多个 CosyVoice 子任务并合并音频。
与 process_tts_synthesis 类似,但超时更长(300s),
因为分段任务需要等待所有子任务完成。
"""
session = None
try:
session = SessionLocal()
repo = SQLAlchemyTTSJobRepository(session)
workflow = TTSWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(),
)
updated_job = workflow.poll_and_process_synthesis(job_id, timeout=300)
session.commit()
logger.info(f"TTS segment synthesis completed: job_id={job_id}, " f"audio_url={updated_job.output_audio_url}")
return {
"ok": True,
"job_id": job_id,
"audio_url": updated_job.output_audio_url,
}
except Retry:
raise
except CosyVoiceTimeoutError as e:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None:
session.rollback()
raise self.retry(exc=e, countdown=60)
except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
except Exception as e:
logger.error(f"TTS segment synthesis unexpected error for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session is not None:
session.close()
-134
View File
@@ -95,39 +95,6 @@
"id"
]
},
"asset_tags": {
"columns": [
{
"index": false,
"name": "asset_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "tag_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [],
"primary_key": [
"asset_id",
"tag_id"
]
},
"assets": {
"columns": [
{
@@ -274,14 +241,6 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -329,13 +288,6 @@
"name": "ix_assets_created_at",
"unique": false
},
{
"columns": [
"file_hash"
],
"name": "ix_assets_file_hash",
"unique": false
},
{
"columns": [
"file_type"
@@ -1517,22 +1469,6 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "asset_select_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": true,
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -1558,13 +1494,6 @@
"name": "ix_generation_tasks_asset_library_id",
"unique": false
},
{
"columns": [
"batch_id"
],
"name": "ix_generation_tasks_batch_id",
"unique": false
},
{
"columns": [
"created_by_user_id"
@@ -1670,14 +1599,6 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1696,13 +1617,6 @@
}
],
"indexes": [
{
"columns": [
"file_hash"
],
"name": "ix_ingest_jobs_file_hash",
"unique": false
},
{
"columns": [
"library_id"
@@ -2142,54 +2056,6 @@
"id"
]
},
"tags": {
"columns": [
{
"index": false,
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "name",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(100)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [
{
"columns": [
"user_id"
],
"name": "ix_tags_user_id",
"unique": false
}
],
"primary_key": [
"id"
]
},
"template_categories": {
"columns": [
{
+1 -1
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
FROM python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
FROM python:3.12-slim
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=0 \
-240
View File
@@ -1,240 +0,0 @@
#!/bin/sh
set -eu
# ============================================
# Production 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<version> REGISTRY_TOKEN=<token> sh deploy-production-registry.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 三镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 备份旧版 assets(部署期间缓存用户不 404 ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 assets)
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
fi
rm -rf "$_tmpdir"
else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理超过 7 天的旧 assets 文件(避免无限增长)
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 确保基础设施容器在运行 ----
echo "Checking infrastructure containers..."
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保生产网络存在 ----
docker network create xiaoxia-net-production 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 2>/dev/null || true
# ---- 日志配置(所有容器共用) ----
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
# nginx 配置中 assets location 有 fallback 逻辑
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-production
exit 1
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-production
exit 1
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Production deployment complete ==="
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
-178
View File
@@ -1,178 +0,0 @@
#!/bin/sh
set -eu
# ============================================
# Staging 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<sha|version> REGISTRY_TOKEN=<token> sh deploy-staging.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 三镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="${REGISTRY}/xiaoxia-saas-api:staging"
LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging"
LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 确保基础设施容器在运行 ----
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f {{.State.Status}} "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保 staging 网络存在 ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Web 镜像默认打包 production nginx.confstaging 需要挂载 staging 配置
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
if [ ! -f "$NGINX_CONF" ]; then
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
NGINX_VOLUME=""
else
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
$NGINX_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 30 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/30)"
sleep 2
done
if [ "$i" -ge 30 ]; then
echo "ERROR: API did not become healthy within 60s"
docker logs --tail 30 xiaoxia-api-staging
exit 1
fi
# ---- 清理旧镜像 ----
docker image prune -af --filter "until=72h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging
# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动
-9
View File
@@ -39,15 +39,6 @@ server {
alias /app/generated/;
}
# Assets with legacy fallback (higher priority than generic static regex)
# 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri /assets-legacy$uri =404;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY apps/web/dist ./
+2 -2
View File
@@ -1,5 +1,5 @@
# Build stage
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
FROM docker.m.daocloud.io/library/node:20 AS builder
WORKDIR /app
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
ENV VITE_API_URL=$VITE_API_URL
@@ -11,7 +11,7 @@ COPY apps/web/ ./
RUN npm run build
# Production stage with nginx
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
ARG NGINX_CONF=infra/docker/nginx.conf
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/apps/web/dist ./
+2 -2
View File
@@ -4,7 +4,7 @@
# ============================================================
# 基础镜像:Python 3.12 + ffmpeg
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
FROM python:3.12-slim-bookworm
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
@@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libsm6 \
libxext6 \
libgl1 \
libgl1-mesa-glx \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
@@ -16,9 +16,6 @@ class InMemoryAssetLibraryRepository:
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def find_by_id(self, library_id: str) -> AssetLibrary | None:
return self.get(library_id)
def find_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
items = [library for library in self._libraries.values() if library.project_id == project_id]
if kind is not None:
@@ -26,13 +26,6 @@ class InMemoryAssetRepository:
"""Alias for list_by_library to match the port interface."""
return self.list_by_library(library_id)
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
return [
asset
for asset in self._assets.values()
if asset.library_id == library_id and asset.mime_type and asset.mime_type.startswith(file_type)
]
def update(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
@@ -42,50 +35,3 @@ class InMemoryAssetRepository:
del self._assets[asset_id]
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
count = 0
for aid in asset_ids:
if aid in self._assets:
del self._assets[aid]
count += 1
return count
def find_by_project(
self,
project_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
items = [a for a in self._assets.values() if a.project_id == project_id]
return items[skip : skip + limit]
def find_by_id(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
tag_set = set(tag_ids)
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
return items[skip : skip + limit]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and asset.file_hash == file_hash:
return asset
return None
@@ -1,40 +0,0 @@
"""标签 InMemory 仓储实现。"""
from packages.domain import Tag
class InMemoryTagRepository:
def __init__(self):
self._tags: dict[str, Tag] = {}
def create(self, tag: Tag) -> Tag:
self._tags[tag.id] = tag
return tag
def get(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def find_by_name(self, user_id: str, name: str) -> Tag | None:
for tag in self._tags.values():
if tag.user_id == user_id and tag.name == name:
return tag
return None
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
tags = [tag for tag in self._tags.values() if tag.user_id == user_id]
tags.sort(key=lambda t: t.created_at, reverse=True)
return tags[skip : skip + limit]
def count_by_user(self, user_id: str) -> int:
return sum(1 for tag in self._tags.values() if tag.user_id == user_id)
def delete(self, tag_id: str) -> bool:
if tag_id in self._tags:
del self._tags[tag_id]
return True
return False
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain import Asset, AssetStatus, ClassificationStatus
@@ -37,22 +37,6 @@ class SQLAlchemyAssetRepository:
)
return [self._to_domain(model) for model in models]
def find_by_library_and_file_type(
self,
library_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
models = (
self.session.query(AssetModel)
.filter(AssetModel.asset_library_id == library_id, AssetModel.file_type == file_type)
.offset(skip)
.limit(limit)
.all()
)
return [self._to_domain(model) for model in models]
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
@@ -83,13 +67,10 @@ class SQLAlchemyAssetRepository:
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
quality_score=asset.quality_score,
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
file_hash=asset.file_hash or None,
created_at=asset.created_at,
updated_at=now,
)
self.session.add(model)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -111,10 +92,7 @@ class SQLAlchemyAssetRepository:
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
model.quality_score = asset.quality_score
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
model.file_hash = asset.file_hash or model.file_hash
model.updated_at = datetime.now(timezone.utc)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -126,14 +104,6 @@ class SQLAlchemyAssetRepository:
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
if not asset_ids:
return 0
count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False)
self.session.commit()
return count
def count_by_project(self, project_id: str) -> int:
return self.session.query(AssetModel).filter(AssetModel.project_id == project_id).count()
@@ -209,11 +179,6 @@ class SQLAlchemyAssetRepository:
"audio": "audio/mpeg",
"image": "image/jpeg",
}.get(mime_type, mime_type)
# 查询关联的 tag_ids
tag_ids = [
row.tag_id
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == model.id).all()
]
return Asset(
id=model.id,
project_id=model.project_id,
@@ -232,61 +197,7 @@ class SQLAlchemyAssetRepository:
classification_status=ClassificationStatus(model.classification_status),
quality_score=model.quality_score,
uploaded_by_user_id=model.uploaded_by_user_id,
file_hash=model.file_hash or "",
metadata=metadata,
tag_ids=tag_ids,
created_at=model.created_at,
updated_at=model.updated_at,
)
def _sync_asset_tags(self, asset_id: str, tag_ids: list[str]) -> None:
"""同步素材-标签关联表(全量替换)。"""
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == asset_id).delete(synchronize_session=False)
for tag_id in tag_ids:
self.session.add(AssetTagModel(asset_id=asset_id, tag_id=tag_id))
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
from sqlalchemy import func
# 找出同时拥有所有指定 tag_id 的 asset_id
tag_set = set(tag_ids)
asset_ids = (
self.session.query(AssetTagModel.asset_id)
.filter(AssetTagModel.tag_id.in_(tag_set))
.group_by(AssetTagModel.asset_id)
.having(func.count(AssetTagModel.tag_id) == len(tag_set))
.all()
)
ids = [row[0] for row in asset_ids]
if not ids:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
return [self._to_domain(m) for m in models]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
model = (
self.session.query(AssetModel)
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.file_hash == file_hash,
)
.first()
)
if model is None:
return None
return self._to_domain(model)
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self._to_domain(model) for model in models]
return [self.get(model.id) for model in models if self.get(model.id) is not None]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
models = (
@@ -86,40 +86,4 @@ class SQLAlchemyGeneratedVideoRepository:
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
.all()
)
return [self._to_domain(model) for model in models]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
task_ids = (
self.session.query(GenerationTaskModel.id).filter(GenerationTaskModel.batch_id == batch_id).subquery()
)
models = (
self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id.in_(task_ids)).all()
)
return [self._to_domain(model) for model in models]
@staticmethod
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
file_size=int(model.file_size or 0),
duration=model.duration,
thumbnail_url=model.thumbnail_url,
width=int(model.width or 0),
height=int(model.height or 0),
fps=model.fps,
status=getattr(model, "status", "completed"),
review_status=getattr(model, "review_status", "pending_review"),
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
return [self.get(model.id) for model in models if self.get(model.id) is not None]
@@ -25,8 +25,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
created_at=model.created_at,
)
@@ -54,8 +52,6 @@ class SQLAlchemyGenerationTaskRepository:
completed_at=task.completed_at,
created_by_user_id=task.created_by_user_id,
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
created_at=task.created_at,
)
self.session.add(model)
@@ -127,7 +123,5 @@ class SQLAlchemyGenerationTaskRepository:
model.started_at = task.started_at
model.completed_at = task.completed_at
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
self.session.commit()
return task
@@ -17,7 +17,6 @@ class SQLAlchemyIngestJobRepository:
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
file_hash=job.file_hash,
created_at=job.created_at,
updated_at=job.updated_at,
)
@@ -37,7 +36,6 @@ class SQLAlchemyIngestJobRepository:
status=IngestJobStatus(model.status),
error_message=model.error_message,
result_asset_id=model.result_asset_id,
file_hash=model.file_hash or "",
created_at=model.created_at,
updated_at=model.updated_at,
)
@@ -53,7 +51,6 @@ class SQLAlchemyIngestJobRepository:
model.status = job.status.value
model.error_message = job.error_message
model.result_asset_id = job.result_asset_id
model.file_hash = job.file_hash
model.updated_at = job.updated_at
self.session.commit()
return job
@@ -85,35 +85,11 @@ class AssetModel(Base):
classification_result = Column(Text, nullable=True)
quality_score = Column(Float, nullable=True)
uploaded_by_user_id = Column(String(36), nullable=False)
file_hash = Column(String(64), nullable=True, index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TagModel(Base):
"""标签 ORM 模型。"""
__tablename__ = "tags"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_name"),)
class AssetTagModel(Base):
"""素材-标签关联表 ORM 模型。"""
__tablename__ = "asset_tags"
asset_id = Column(String(36), primary_key=True)
tag_id = Column(String(36), primary_key=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class EditTemplateModel(Base):
"""Phase 8 剪辑模板 ORM 模型
@@ -211,7 +187,6 @@ class IngestJobModel(Base):
status = Column(String(20), nullable=False, default="pending")
error_message = Column(Text, nullable=False, default="")
result_asset_id = Column(String(32), nullable=False, default="")
file_hash = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -253,8 +228,6 @@ class GenerationTaskModel(Base):
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(32), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -1,73 +0,0 @@
"""标签 SQLAlchemy 仓储实现。"""
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
from packages.domain import Tag
class SQLAlchemyTagRepository:
def __init__(self, session: Session):
self.session = session
def create(self, tag: Tag) -> Tag:
model = TagModel(
id=tag.id,
user_id=tag.user_id,
name=tag.name,
created_at=tag.created_at,
)
self.session.add(model)
self.session.commit()
return tag
def get(self, tag_id: str) -> Tag | None:
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
if model is None:
return None
return self._to_domain(model)
def find_by_name(self, user_id: str, name: str) -> Tag | None:
model = self.session.query(TagModel).filter(TagModel.user_id == user_id, TagModel.name == name).first()
if model is None:
return None
return self._to_domain(model)
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
models = (
self.session.query(TagModel)
.filter(TagModel.user_id == user_id)
.order_by(TagModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [self._to_domain(m) for m in models]
def count_by_user(self, user_id: str) -> int:
return self.session.query(TagModel).filter(TagModel.user_id == user_id).count()
def delete(self, tag_id: str) -> bool:
# 先清理关联表
self.session.query(AssetTagModel).filter(AssetTagModel.tag_id == tag_id).delete(synchronize_session=False)
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
if model is None:
self.session.commit()
return False
self.session.delete(model)
self.session.commit()
return True
@staticmethod
def _to_domain(model: TagModel) -> Tag:
return Tag(
id=model.id,
user_id=model.user_id,
name=model.name,
created_at=model.created_at,
)
-4
View File
@@ -19,8 +19,6 @@ class CreateGenerationTaskCommand:
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
class CreateGenerationTaskUseCase:
@@ -46,8 +44,6 @@ class CreateGenerationTaskUseCase:
completed_at=None,
created_by_user_id=command.created_by_user_id,
source_edit_plan_id=command.source_edit_plan_id,
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
)
return self.generation_task_repository.create(task)
-2
View File
@@ -11,7 +11,6 @@ class SubmitIngestJobCommand:
project_id: str
library_id: str
storage_key: str
file_hash: str = ""
class SubmitIngestJobUseCase:
@@ -23,6 +22,5 @@ class SubmitIngestJobUseCase:
project_id=command.project_id,
library_id=command.library_id,
storage_key=command.storage_key,
file_hash=command.file_hash,
)
return self.ingest_job_repository.create(job)
@@ -1,96 +0,0 @@
"""FFmpeg 音频合并器 — P1 长文本分段合成。
将多个分段音频文件合并为一个完整音频文件
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
logger = logging.getLogger(__name__)
class AudioMergeError(Exception):
"""音频合并异常。"""
pass
class AudioMerger:
"""使用 FFmpeg 合并多个音频文件。"""
def merge(self, audio_paths: list[str], output_format: str = "mp3") -> bytes:
"""合并多个音频文件,返回合并后的音频数据。
使用 FFmpeg concat demuxer 按顺序拼接音频
所有输入文件必须为相同格式和采样率
Args:
audio_paths: 音频文件路径列表按合成顺序
output_format: 输出格式mp3/wav/pcm
Returns:
合并后的音频文件字节数据
Raises:
AudioMergeError: 合并失败
"""
if not audio_paths:
raise AudioMergeError("没有可合并的音频文件")
if len(audio_paths) == 1:
with open(audio_paths[0], "rb") as f:
return f.read()
temp_dir = tempfile.mkdtemp(prefix="tts_merge_")
try:
# 生成 concat demuxer 列表文件
list_path = os.path.join(temp_dir, "concat_list.txt")
with open(list_path, "w") as f:
for path in audio_paths:
# FFmpeg concat 文件需要 file: 前缀,路径中的 ' 和 \n 需转义
escaped = path.replace("'", "'\\''").replace("\n", "\\n")
f.write(f"file '{escaped}'\n")
output_path = os.path.join(temp_dir, f"merged.{output_format}")
cmd = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
list_path,
"-c",
"copy",
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
with open(output_path, "rb") as f:
return f.read()
except subprocess.TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError:
raise
except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -1,251 +0,0 @@
"""P2: TTS 流式合成服务 — WebSocket 实时音频推送。
通过 WebSocket 将合成音频以二进制帧实时推送给客户端
- 短文本500 合成完整音频后分块推送
- 长文本>500 分段并发合成逐段推送音频
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Optional
import httpx
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.text_splitter import split_text
logger = logging.getLogger(__name__)
# WebSocket 二进制帧块大小(4KB
_AUDIO_CHUNK_SIZE = 4096
# 分段并发上限
_MAX_STREAMING_SEGMENT_WORKERS = 5
# 长文本分段阈值
_SEGMENT_THRESHOLD = 500
# WebSocket 最大文本长度
_MAX_TEXT_LENGTH = 10000
class TTSStreamingError(Exception):
"""TTS 流式合成异常。"""
pass
class TTSStreamingService:
"""TTS 流式合成服务。
通过 WebSocket 实时推送合成音频
使用 CosyVoiceService同步 REST API合成
通过 asyncio.to_thread 桥接到异步 WebSocket
"""
def __init__(self, cosyvoice_service: CosyVoiceService) -> None:
self._cosyvoice = cosyvoice_service
async def synthesize_and_stream(self, websocket: Any, params: dict) -> None:
"""根据文本长度选择流式合成策略。
Args:
websocket: FastAPI WebSocket 连接
params: 合成参数text, voice_id, sample_rate, format, speed
"""
text = params.get("text", "")
if not text:
await self._send_json(websocket, {"type": "error", "message": "文本不能为空"})
return
if len(text) > _MAX_TEXT_LENGTH:
await self._send_json(
websocket,
{"type": "error", "message": f"文本过长,最大 {_MAX_TEXT_LENGTH}"},
)
return
if len(text) <= _SEGMENT_THRESHOLD:
await self._stream_short_text(websocket, params)
else:
await self._stream_long_text(websocket, params)
# ── 短文本流式合成 ────────────────────────────────────────
async def _stream_short_text(self, websocket: Any, params: dict) -> None:
"""短文本:合成完整音频后分块推送。"""
text = params["text"]
voice_id = params.get("voice_id", "")
sample_rate = params.get("sample_rate", 0)
audio_format = params.get("format", "mp3")
speed = params.get("speed", 1.0)
await self._send_json(
websocket,
{"type": "started", "segment_count": 1, "total_segments": 1},
)
# 在线程池中执行同步合成
try:
result = await asyncio.to_thread(
self._cosyvoice.submit_synthesize_task,
text=text,
voice_id=voice_id,
sample_rate=sample_rate,
format=audio_format,
speed=speed,
)
except CosyVoiceError as e:
logger.error(f"流式合成失败: {e}")
await self._send_json(websocket, {"type": "error", "message": str(e)})
return
except Exception as e:
logger.error(f"流式合成意外错误: {e}")
await self._send_json(websocket, {"type": "error", "message": f"合成失败: {e}"})
return
audio_url = result.get("audio_url", "")
if not audio_url:
await self._send_json(websocket, {"type": "error", "message": "合成未返回音频 URL"})
return
# 下载并流式推送音频
try:
audio_data = await asyncio.to_thread(self._download_audio, audio_url)
total_bytes = await self._stream_audio_chunks(websocket, audio_data)
await self._send_json(
websocket,
{
"type": "done",
"duration": result.get("duration", 0.0),
"file_size": total_bytes,
"format": audio_format,
},
)
except Exception as e:
logger.error(f"音频流式推送失败: {e}")
await self._send_json(websocket, {"type": "error", "message": f"音频推送失败: {e}"})
# ── 长文本分段流式合成 ────────────────────────────────────
async def _stream_long_text(self, websocket: Any, params: dict) -> None:
"""长文本:分段并发合成,逐段推送音频。"""
text = params["text"]
voice_id = params.get("voice_id", "")
sample_rate = params.get("sample_rate", 0)
audio_format = params.get("format", "mp3")
speed = params.get("speed", 1.0)
segments = split_text(text, max_chars=_SEGMENT_THRESHOLD)
segment_count = len(segments)
logger.info(f"流式分段合成: 原文={len(text)}字, 段数={segment_count}")
await self._send_json(
websocket,
{"type": "started", "segment_count": segment_count, "total_segments": segment_count},
)
# 并发合成所有分段,按顺序流式推送
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
completed_count = 0
async def _synthesize_one(idx: int, seg_text: str) -> None:
"""合成单个分段并放入队列。"""
try:
result = await asyncio.to_thread(
self._cosyvoice.submit_synthesize_task,
text=seg_text,
voice_id=voice_id,
sample_rate=sample_rate,
format=audio_format,
speed=speed,
)
audio_url = result.get("audio_url", "")
if audio_url:
audio_data = await asyncio.to_thread(self._download_audio, audio_url)
await queue.put((idx, audio_data, None))
else:
await queue.put((idx, None, "合成未返回音频 URL"))
except Exception as e:
await queue.put((idx, None, str(e)))
# 启动并发合成任务
workers = [asyncio.create_task(_synthesize_one(idx, seg)) for idx, seg in enumerate(segments)]
# 按顺序消费队列,流式推送
total_bytes = 0
total_duration = 0.0
consumed = 0
try:
while consumed < segment_count:
idx, audio_data, error = await queue.get()
consumed += 1
if error:
logger.error(f"分段 {idx + 1} 合成失败: {error}")
await self._send_json(
websocket,
{"type": "error", "message": f"分段 {idx + 1} 合成失败: {error}"},
)
# 取消剩余 worker
for w in workers:
w.cancel()
return
if audio_data:
seg_bytes = await self._stream_audio_chunks(websocket, audio_data)
total_bytes += seg_bytes
await self._send_json(
websocket,
{"type": "segment_done", "segment": idx + 1, "total": segment_count},
)
# 等待所有 worker 完成
await asyncio.gather(*workers, return_exceptions=True)
await self._send_json(
websocket,
{
"type": "done",
"duration": total_duration,
"file_size": total_bytes,
"format": audio_format,
},
)
except Exception as e:
logger.error(f"流式分段推送失败: {e}")
await self._send_json(websocket, {"type": "error", "message": f"推送失败: {e}"})
for w in workers:
w.cancel()
# ── 工具方法 ────────────────────────────────────────────
def _download_audio(self, url: str) -> bytes:
"""下载音频数据。"""
resp = httpx.get(url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
return resp.content
async def _stream_audio_chunks(self, websocket: Any, audio_data: bytes) -> int:
"""将音频数据分块通过 WebSocket 推送。
Returns:
推送的总字节数
"""
total = 0
for offset in range(0, len(audio_data), _AUDIO_CHUNK_SIZE):
chunk = audio_data[offset : offset + _AUDIO_CHUNK_SIZE]
await websocket.send_bytes(chunk)
total += len(chunk)
return total
async def _send_json(self, websocket: Any, data: dict) -> None:
"""安全发送 JSON 帧。"""
try:
await websocket.send_json(data)
except Exception:
pass
@@ -1,70 +0,0 @@
"""长文本分段工具 — P1 长文本分段合成。
将超过阈值的文本按句子边界分段 CosyVoice 并发合成后合并
"""
from __future__ import annotations
# 中文句子结束符(含全角/半角)
_SENTENCE_ENDS = frozenset("。!?;\n.!?;")
def split_text(text: str, max_chars: int = 500) -> list[str]:
"""将文本分段,每段不超过 max_chars 个字符。
优先在句子边界句号问号感叹号换行符处分段
若单个句子超过 max_chars则在逗号等次级标点处拆分
若仍超长则硬切
Args:
text: 待分段文本
max_chars: 每段最大字符数
Returns:
分段列表每段 max_chars文本为空时返回空列表
"""
text = text.strip()
if not text:
return []
if len(text) <= max_chars:
return [text]
segments: list[str] = []
current = ""
for char in text:
current += char
if char in _SENTENCE_ENDS and len(current) >= 50:
# 句子边界且长度合理,切段
segments.append(current.strip())
current = ""
elif len(current) >= max_chars:
# 达到上限,强制切段
segments.append(current.strip())
current = ""
if current.strip():
segments.append(current.strip())
# 合并过短的段(< 50 字符且不是最后一段),减少 API 调用次数
merged: list[str] = []
buffer = ""
for seg in segments:
if buffer:
combined = buffer + seg
if len(combined) <= max_chars:
buffer = combined
continue
merged.append(buffer)
buffer = ""
if len(seg) < 50:
buffer = seg
else:
merged.append(seg)
if buffer:
if merged and len(merged[-1]) + len(buffer) <= max_chars:
merged[-1] = merged[-1] + buffer
else:
merged.append(buffer)
return [s for s in merged if s]
+5 -303
View File
@@ -9,35 +9,19 @@
from __future__ import annotations
import io
import logging
import os
import shutil
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import httpx
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
)
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
from packages.shared.storage import SharedStorageService, get_shared_storage_service
logger = logging.getLogger(__name__)
# 长文本分段阈值:超过此字符数自动分段合成
_SEGMENT_THRESHOLD = 500
# 分段并发上限
_MAX_SEGMENT_WORKERS = 5
class TTSWorkflowError(Exception):
"""TTS 合成工作流异常。"""
@@ -62,55 +46,9 @@ class TTSWorkflowService:
self,
repository: TTSJobRepository,
cosyvoice_service: CosyVoiceService,
storage_service: Optional[SharedStorageService] = None,
) -> None:
self.repository = repository
self.cosyvoice_service = cosyvoice_service
self._storage_service = storage_service
@property
def _storage(self) -> SharedStorageService:
if self._storage_service is None:
self._storage_service = get_shared_storage_service()
return self._storage_service
def _transfer_audio_to_oss(
self,
temp_url: str,
user_id: str,
job_id: str,
audio_format: str = "mp3",
) -> tuple[str, str]:
"""下载 CosyVoice 临时音频并转存到 OSS。
Returns:
(permanent_url, storage_key) 元组
转存失败时回退到原始临时 URLstorage_key 为空字符串
"""
storage_key = f"tts-outputs/{user_id}/{job_id}.{audio_format}"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
# 下载临时音频
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
audio_data = resp.content
# 上传到 OSS
file_obj = io.BytesIO(audio_data)
permanent_url = self._storage.upload_file(file_obj, storage_key, content_type=content_type)
logger.info(f"音频转存 OSS 成功: job_id={job_id}, " f"storage_key={storage_key}, size={len(audio_data)}")
return permanent_url, storage_key
except Exception as e:
logger.warning(f"音频转存 OSS 失败,使用临时 URL: " f"job_id={job_id}, error={e}")
return temp_url, ""
def start_synthesis(
self,
@@ -142,10 +80,6 @@ class TTSWorkflowService:
job.mark_processing()
job = self.repository.update(job)
# 长文本自动分段合成
if len(job.input_text) > _SEGMENT_THRESHOLD:
return self._start_segment_synthesis(job)
try:
submit_result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
@@ -159,19 +93,17 @@ class TTSWorkflowService:
job_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
job_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
# 如果 CosyVoice 同步返回了 audio_url转存 OSS 后标记完成
# 如果 CosyVoice 同步返回了 audio_url直接标记完成
audio_url = submit_result.get("audio_url", "")
if audio_url:
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
output_audio_url=audio_url,
duration=submit_result.get("duration", 0.0),
file_size=submit_result.get("file_size", 0),
)
job.metadata = job_metadata
job = self.repository.update(job)
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={permanent_url}")
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}")
return job
job.metadata = job_metadata
@@ -201,11 +133,6 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 检查是否为分段合成任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
if segment_task_ids:
return self._poll_segment_tasks(job)
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
if not task_id:
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
@@ -244,17 +171,13 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 转存音频到 OSS,获取永久 URL
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
output_audio_url=audio_url,
duration=duration,
file_size=file_size,
)
job = self.repository.update(job)
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}")
return job
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
@@ -278,224 +201,3 @@ class TTSWorkflowService:
job = self.repository.update(job)
logger.error(f"TTS 合成失败: job_id={job_id}, error={error_message}")
return job
# ── P1: 长文本分段合成 ─────────────────────────────────────
def _upload_merged_to_oss(
self, merged_data: bytes, user_id: str, job_id: str, audio_format: str
) -> tuple[str, str]:
"""上传合并后的音频数据到 OSS。
Returns:
(permanent_url, storage_key) 元组
上传失败时返回 ("", "")
"""
storage_key = f"tts-outputs/{user_id}/{job_id}.{audio_format}"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
file_obj = io.BytesIO(merged_data)
permanent_url = self._storage.upload_file(file_obj, storage_key, content_type=content_type)
return permanent_url, storage_key
except Exception as e:
logger.warning(f"分段合并音频转存 OSS 失败: job_id={job_id}, error={e}")
return "", ""
def _start_segment_synthesis(self, job: TTSJob) -> TTSJob:
"""长文本分段合成入口。
将文本分段后并发提交到 CosyVoice根据同步/异步结果走不同路径
"""
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
logger.info(f"长文本分段合成: job_id={job.id}, " f"原文={len(job.input_text)}字, 段数={len(segments)}")
# 记录分段信息到 metadata
job_metadata = dict(job.metadata)
job_metadata["segment_count"] = len(segments)
# 并发提交所有分段
results = self._submit_segments_concurrent(segments, job)
if results is None:
# 提交阶段已失败,_submit_segments_concurrent 内部已标记 failed
return self.repository.get(job.id)
# 判断同步还是异步
has_audio_urls = any(r.get("audio_url", "") for r in results)
has_task_ids = any(r.get("task_id", "") for r in results)
if has_audio_urls and not has_task_ids:
# 所有分段同步返回音频,直接合并
return self._process_segments_sync(job, results)
# 异步路径:保存各分段的 task_id 供后续轮询
segment_task_ids = [r.get("task_id", "") for r in results]
segment_audio_urls = [r.get("audio_url", "") for r in results]
job_metadata["segment_task_ids"] = segment_task_ids
job_metadata["segment_audio_urls"] = segment_audio_urls
job_metadata["segment_format"] = job.format
job.metadata = job_metadata
job = self.repository.update(job)
logger.info(f"分段合成任务已提交(异步): job_id={job.id}, " f"段数={len(segments)}")
return job
def _submit_segments_concurrent(self, segments: list[str], job: TTSJob) -> list[dict] | None:
"""并发提交分段合成任务。
Returns:
各分段的结果列表保持顺序提交失败时返回 None
"""
max_workers = min(len(segments), _MAX_SEGMENT_WORKERS)
results: list[dict | None] = [None] * len(segments)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {}
for idx, segment_text in enumerate(segments):
future = executor.submit(
self.cosyvoice_service.submit_synthesize_task,
text=segment_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
results[idx] = future.result()
except Exception as e:
logger.error(f"分段合成提交失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 合成提交失败: {e}")
return None
return results # type: ignore[return-value]
def _process_segments_sync(self, job: TTSJob, results: list[dict]) -> TTSJob:
"""同步路径:所有分段已返回 audio_url,下载合并后转存 OSS。"""
merged_data, total_duration = self._download_and_merge_segments(results, job)
# 直接上传合并后的音频 bytes 到 OSS
permanent_url, storage_key = self._upload_merged_to_oss(merged_data, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成完成: job_id={job.id}, " f"merged_size={len(merged_data)}, duration={total_duration:.1f}")
return job
def _download_and_merge_segments(self, results: list[dict], job: TTSJob) -> tuple[bytes, float]:
"""下载各分段音频并合并。
Returns:
(merged_audio_bytes, total_duration)
"""
temp_dir = tempfile.mkdtemp(prefix="tts_segments_")
try:
audio_paths: list[str] = []
total_duration = 0.0
for idx, result in enumerate(results):
audio_url = result.get("audio_url", "")
if not audio_url:
raise TTSWorkflowError(f"分段 {idx + 1} 没有返回 audio_url")
total_duration += result.get("duration", 0.0)
# 下载分段音频到临时文件
resp = httpx.get(audio_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
seg_path = os.path.join(temp_dir, f"seg_{idx:03d}.{job.format}")
with open(seg_path, "wb") as f:
f.write(resp.content)
audio_paths.append(seg_path)
# 合并
merger = AudioMerger()
merged_data = merger.merge(audio_paths, output_format=job.format)
return merged_data, total_duration
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def _poll_segment_tasks(self, job: TTSJob) -> TTSJob:
"""轮询所有分段异步任务,全部完成后合并音频。"""
segment_task_ids: list[str] = (job.metadata or {}).get("segment_task_ids", [])
segment_audio_urls: list[str] = (job.metadata or {}).get("segment_audio_urls", [])
segment_count = len(segment_task_ids)
poll_start = time.monotonic()
poll_timeout = 300.0 # 分段任务超时更长
poll_interval = 2.0
while time.monotonic() - poll_start < poll_timeout:
all_done = True
results: list[dict | None] = [None] * segment_count
for idx, task_id in enumerate(segment_task_ids):
# 已经有音频的分段跳过轮询
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
continue
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=poll_timeout)
results[idx] = result
except Exception as e:
logger.error(f"分段任务轮询失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 轮询失败: {e}")
return self.repository.get(job.id)
if results[idx] is None:
all_done = False
if all_done and all(r is not None for r in results):
# 所有分段完成,下载合并
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
# 转存 OSS
permanent_url, storage_key = self._upload_merged_to_oss(
merged_data, job.user_id, job.id, job.format
)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成轮询完成: job_id={job.id}, " f"merged_size={len(merged_data)}")
return job
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
# 等待后重试
time.sleep(poll_interval)
# 超时
self._handle_segment_failure(job, "分段合成轮询超时(300 秒)")
return self.repository.get(job.id)
def _handle_segment_failure(self, job: TTSJob, error_message: str) -> None:
"""分段合成失败处理。"""
job.mark_failed(error_message)
self.repository.update(job)
logger.error(f"分段合成失败: job_id={job.id}, error={error_message}")
-2
View File
@@ -24,7 +24,6 @@ from .entities import (
from .generated_video import GeneratedVideo
from .generation_task import GenerationTask, GenerationTaskStatus
from .job import Job, JobStatus, JobType
from .tag import Tag
from .template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
from .title_library import TitleLibraryItem
from .voice_library import VoiceLibraryItem
@@ -57,7 +56,6 @@ __all__ = [
"JobStatus",
"JobType",
"Project",
"Tag",
"TemplateClipConfig",
"TransitionEffect",
"User",
+14 -20
View File
@@ -161,9 +161,8 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING
quality_score: float | None = None
uploaded_by_user_id: str = ""
file_hash: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
tag_ids: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -188,7 +187,6 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
quality_score: float | None = None,
uploaded_by_user_id: str = "",
file_hash: str = "",
) -> "Asset":
clean_name = name.strip()
if not clean_name:
@@ -215,25 +213,24 @@ class Asset:
classification_status=classification_status,
quality_score=quality_score,
uploaded_by_user_id=uploaded_by_user_id.strip(),
file_hash=file_hash.strip(),
metadata=metadata or {},
tag_ids=[],
tags=[],
)
def add_tag(self, tag_id: str) -> None:
"""添加标签 ID。空 ID 会被忽略,自动去重。"""
clean_id = tag_id.strip()
if not clean_id:
raise ValueError("标签 ID 不能为空")
if clean_id not in self.tag_ids:
self.tag_ids.append(clean_id)
def add_tag(self, tag: str) -> None:
"""添加标签。空标签会被忽略,自动去重。"""
clean_tag = tag.strip()
if not clean_tag:
raise ValueError("标签不能为空")
if clean_tag not in self.tags:
self.tags.append(clean_tag)
self.updated_at = datetime.now(timezone.utc)
def remove_tag(self, tag_id: str) -> None:
"""删除标签 ID。如果标签不存在,不报错(幂等性)。"""
clean_id = tag_id.strip()
if clean_id in self.tag_ids:
self.tag_ids.remove(clean_id)
def remove_tag(self, tag: str) -> None:
"""删除标签。如果标签不存在,不报错(幂等性)。"""
clean_tag = tag.strip()
if clean_tag in self.tags:
self.tags.remove(clean_tag)
self.updated_at = datetime.now(timezone.utc)
@@ -246,7 +243,6 @@ class IngestJob:
status: IngestJobStatus = IngestJobStatus.PENDING
error_message: str = ""
result_asset_id: str = ""
file_hash: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -256,7 +252,6 @@ class IngestJob:
project_id: str,
library_id: str,
storage_key: str,
file_hash: str = "",
) -> "IngestJob":
if not project_id.strip():
raise ValueError("project_id 不能为空")
@@ -269,5 +264,4 @@ class IngestJob:
project_id=project_id.strip(),
library_id=library_id.strip(),
storage_key=storage_key.strip(),
file_hash=file_hash.strip(),
)
-6
View File
@@ -43,8 +43,6 @@ class GenerationTask:
completed_at: datetime | None = None
source_edit_plan_id: str = ""
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
@@ -61,8 +59,6 @@ class GenerationTask:
voice_ids: list[str] | None = None,
created_by_user_id: str = "",
source_edit_plan_id: str = "",
asset_select_mode: str = "",
batch_id: str = "",
) -> "GenerationTask":
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
@@ -80,6 +76,4 @@ class GenerationTask:
voice_ids=list(voice_ids) if voice_ids else [],
created_by_user_id=created_by_user_id.strip(),
source_edit_plan_id=source_edit_plan_id.strip(),
asset_select_mode=asset_select_mode,
batch_id=batch_id,
)
-24
View File
@@ -1,24 +0,0 @@
"""标签领域实体。"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import uuid4
@dataclass(slots=True)
class Tag:
id: str
user_id: str
name: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(cls, user_id: str, name: str) -> "Tag":
clean_name = name.strip()
if not clean_name:
raise ValueError("标签名称不能为空")
return cls(
id=uuid4().hex,
user_id=user_id,
name=clean_name,
)
-2
View File
@@ -4,7 +4,6 @@ from .asset_library_repository import AssetLibraryRepository
from .asset_repository import AssetRepository
from .ingest_job_repository import IngestJobRepository
from .project_repository import ProjectRepository
from .tag_repository import TagRepository
from .title_library_repository import TitleLibraryRepository
from .voice_library_repository import VoiceLibraryRepository
@@ -13,7 +12,6 @@ __all__ = [
"AssetRepository",
"IngestJobRepository",
"ProjectRepository",
"TagRepository",
"TitleLibraryRepository",
"VoiceLibraryRepository",
]
-34
View File
@@ -32,16 +32,6 @@ class AssetRepository(ABC):
) -> list[Asset]:
pass
@abstractmethod
def find_by_library_and_file_type(
self,
library_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
pass
@abstractmethod
def update(self, asset: Asset) -> Asset:
pass
@@ -50,11 +40,6 @@ class AssetRepository(ABC):
def delete(self, asset_id: str) -> bool:
pass
@abstractmethod
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
pass
@abstractmethod
def count_by_project(self, project_id: str) -> int:
pass
@@ -83,22 +68,3 @@ class AssetRepository(ABC):
) -> list[Asset]:
"""按筛选条件搜索候选素材,按质量分降序排列。"""
pass
@abstractmethod
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
pass
@abstractmethod
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
pass

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