Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca2ffe272 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 5bc3440370 | |||
| 7dc92191e0 | |||
| 5028956cea | |||
| d72450f42d | |||
| 1ed0d5aa75 | |||
| aef4febd1c | |||
| d99ee6fc84 | |||
| 9b034764ad | |||
| bfb11c3526 | |||
| 9e37c7b73d | |||
| f7a945d417 | |||
| 8748b43070 | |||
| a28395c318 | |||
| 9f0c064f2a | |||
| 1ea8fd3989 | |||
| 4fee87c5e8 | |||
| a74d25e414 | |||
| d213a055a1 | |||
| 8219d2445d | |||
| 0d871d9734 | |||
| 608ddbf9f7 | |||
| 6cb146693e | |||
| 18571451ed | |||
| 2bcbd54ed7 | |||
| d1c83de698 | |||
| 2849123bd7 | |||
| d6ab413dcd | |||
| c47aed95fc | |||
| 8e2e1e1357 | |||
| b0f2e4712a | |||
| d60a963b62 | |||
| d698d78b35 | |||
| f8bc252ded |
@@ -0,0 +1,165 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
# 根据目标分支决定检查哪些门禁
|
||||
TARGET_BRANCH="${GITHUB_BASE_REF}"
|
||||
echo "目标分支: ${TARGET_BRANCH}"
|
||||
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
elif [ "$TARGET_BRANCH" = "main" ]; then
|
||||
# main分支只检查required statuses: Validate + Frontend Lint
|
||||
# 不检查Tests/test(不是required门禁)
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
)
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
+122
-12
@@ -1,21 +1,131 @@
|
||||
name: Auto Merge PRs
|
||||
name: Auto Merge PRs (main)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved (main)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合main分支
|
||||
if [ "$BASE_REF" != "main" ]; then
|
||||
echo "Skip: 目标分支不是main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# main分支门禁:Validate + Frontend Lint
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查门禁: ${#CONTEXTS[@]} 项"
|
||||
echo
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
|
||||
echo " 审批: $APPROVAL_RESULT"
|
||||
HAS_APPROVAL=false
|
||||
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
|
||||
HAS_APPROVAL=true
|
||||
fi
|
||||
|
||||
# 全部满足 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行merge(main分支用merge,保留历史)
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
echo "合并失败(405),可能有冲突或门禁未通过"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
|
||||
+81
-232
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
@@ -11,9 +11,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
@@ -93,9 +95,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""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")
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -38,6 +39,11 @@ 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"],
|
||||
|
||||
@@ -7,14 +7,18 @@ 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 fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
@@ -64,6 +68,7 @@ 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", []),
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +89,7 @@ def list_assets(
|
||||
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),
|
||||
@@ -96,12 +102,19 @@ def list_assets(
|
||||
# kind → file_type 映射(voice 对应 audio)
|
||||
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
||||
|
||||
def _apply_filters(items):
|
||||
"""依次应用 kind / keyword / gender / style 过滤。"""
|
||||
# 解析 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 kind:
|
||||
ft = kind_to_file_type.get(kind)
|
||||
result = [i for i in result if i.mime_type and i.mime_type.startswith(ft or "")]
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
result = [i for i in result if kw in (i.name or "").lower()]
|
||||
@@ -109,9 +122,90 @@ def list_assets(
|
||||
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
|
||||
|
||||
# 模式1:指定 library_id → 返回该库的素材
|
||||
# ── 优化路径:无内存过滤时,使用 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 时,加载全量后内存过滤 ──
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
@@ -121,41 +215,24 @@ def list_assets(
|
||||
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)
|
||||
filtered = _apply_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:
|
||||
elif project_id:
|
||||
_check_project_access(project_id, user_id, project_repository)
|
||||
all_items = asset_repository.find_by_project(project_id)
|
||||
filtered = _apply_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,
|
||||
)
|
||||
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))
|
||||
|
||||
# 模式3:都不传 → 返回用户可访问的所有项目的所有素材
|
||||
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))
|
||||
filtered = _apply_filters(all_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(
|
||||
@@ -191,6 +268,35 @@ 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,
|
||||
@@ -244,6 +350,48 @@ def delete_asset(
|
||||
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,
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
)
|
||||
@@ -42,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
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
|
||||
@@ -360,6 +374,7 @@ async def complete_chunked_upload(
|
||||
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:
|
||||
@@ -423,6 +438,29 @@ async def complete_chunked_upload(
|
||||
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(
|
||||
@@ -430,6 +468,7 @@ async def complete_chunked_upload(
|
||||
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])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -51,6 +54,8 @@ 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,
|
||||
@@ -85,6 +90,49 @@ 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,
|
||||
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
|
||||
return project_id, asset_library_id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -130,12 +178,13 @@ 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),
|
||||
) -> GenerationTaskResponse:
|
||||
) -> BatchGenerationTaskResponse:
|
||||
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):
|
||||
@@ -144,23 +193,42 @@ 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)
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
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))
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
@@ -237,6 +305,7 @@ 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])
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""标签 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)
|
||||
+153
-10
@@ -9,22 +9,28 @@ 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.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
SaveToLibraryRequest,
|
||||
SaveToLibraryResponse,
|
||||
TTSJobResponse,
|
||||
TTSStatusResponse,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, 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,
|
||||
@@ -34,6 +40,12 @@ 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__)
|
||||
|
||||
@@ -130,18 +142,25 @@ def synthesize(
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if task_id:
|
||||
try:
|
||||
# 分段合成任务 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:
|
||||
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,
|
||||
@@ -225,3 +244,127 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
)
|
||||
@@ -99,6 +100,7 @@ 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(
|
||||
@@ -106,6 +108,7 @@ 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])
|
||||
@@ -176,6 +179,7 @@ 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:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
@@ -199,11 +203,32 @@ 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)
|
||||
|
||||
@@ -218,15 +243,38 @@ 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)
|
||||
|
||||
@@ -255,6 +303,7 @@ async def upload_asset(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
|
||||
@@ -39,6 +39,7 @@ 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,
|
||||
)
|
||||
@@ -58,6 +59,7 @@ 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
|
||||
@@ -138,6 +140,13 @@ 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:
|
||||
|
||||
@@ -51,6 +51,20 @@ 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):
|
||||
|
||||
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
|
||||
class ChunkedUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteResponse(BaseModel):
|
||||
storage_key: str = Field(..., description="Storage key")
|
||||
ingest_job_id: str = Field(..., description="Ingest job ID")
|
||||
url: str = Field(..., description="File URL")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -21,6 +21,16 @@ 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":
|
||||
@@ -46,12 +56,21 @@ 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)。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""标签相关 Schema。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateTagRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ListTagsResponse(BaseModel):
|
||||
items: list[TagResponse]
|
||||
total: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class TagAssetsRequest(BaseModel):
|
||||
tag_ids: list[str] = Field(..., min_length=1, max_length=50)
|
||||
@@ -83,3 +83,21 @@ 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,12 +6,7 @@ class UploadAssetRequest(BaseModel):
|
||||
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
@@ -20,6 +15,7 @@ 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):
|
||||
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface AssetItem {
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -144,12 +145,18 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: { keyword?: string; gender?: string; style?: 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 || [];
|
||||
};
|
||||
@@ -233,10 +240,11 @@ 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();
|
||||
@@ -255,13 +263,25 @@ export const uploadAssetDirect = async (data: {
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
method: prepared.method,
|
||||
body: directForm,
|
||||
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
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 {
|
||||
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
|
||||
xhr.send(directForm);
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 标签 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}`);
|
||||
};
|
||||
@@ -122,6 +122,20 @@ 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}`);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -37,7 +36,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type AssetKind = "video" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -67,7 +66,6 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
@@ -110,14 +108,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
item.classification_status ?? undefined,
|
||||
);
|
||||
const metadata = item.metadata || {};
|
||||
const kind = inferKind(item.mime_type || "");
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: inferKind(item.mime_type || ""),
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined),
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl:
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.file_url as string | undefined),
|
||||
@@ -147,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -158,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -170,8 +166,6 @@ 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%)";
|
||||
}
|
||||
@@ -244,7 +238,7 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
{asset.kind === "video" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -361,6 +355,7 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -434,18 +429,25 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||||
}
|
||||
await uploadAssetDirect({ file, library_id: effectiveLibId });
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
});
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -532,6 +534,54 @@ 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">
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
@@ -588,7 +638,7 @@ const AssetLibrary: React.FC = () => {
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,audio/*,image/*"
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
@@ -598,7 +648,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -621,7 +671,6 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -752,7 +801,6 @@ const AssetLibrary: React.FC = () => {
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -587,3 +587,40 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1968,7 +1968,9 @@
|
||||
border: 1px dashed var(--primary, #6366f1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-upload-btn:hover {
|
||||
@@ -1996,7 +1998,9 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-refresh-btn:hover {
|
||||
@@ -2035,7 +2039,9 @@
|
||||
border: 1px solid var(--border-color, #334155);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.ep-voice-preview-btn:hover {
|
||||
|
||||
@@ -6,21 +6,27 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Typography, message } from "antd";
|
||||
import { Typography, message, Select } from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets";
|
||||
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
import { fetchPresetVoices } from "@/api/voices";
|
||||
@@ -28,9 +34,10 @@ import type { PresetVoiceItem } from "@/api/voices";
|
||||
import { formatDuration } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
|
||||
import { getTags, createTag } from "@/api/tags";
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { getEditPlan } from "@/api/editPlans";
|
||||
import "./generate.css";
|
||||
|
||||
@@ -50,13 +57,6 @@ const MODE_GRADIENTS: Record<string, string> = {
|
||||
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
||||
};
|
||||
const MODE_ABBRS: Record<string, string> = {
|
||||
pip: "PIP",
|
||||
one_take: "ONE",
|
||||
voice_over: "VOI",
|
||||
voice_pip: "VP",
|
||||
};
|
||||
|
||||
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
|
||||
const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
female: "🎀",
|
||||
@@ -97,6 +97,8 @@ const STEPS = [
|
||||
================================================================ */
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
@@ -116,6 +118,8 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
|
||||
/* 素材选择模式:手动选择 / 自动匹配 */
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
|
||||
|
||||
/* ── 标题 ── */
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -140,6 +144,9 @@ const GeneratePage: React.FC = () => {
|
||||
);
|
||||
const [customVoiceText, setCustomVoiceText] = useState("");
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1);
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false);
|
||||
@@ -156,6 +163,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -225,6 +233,23 @@ const GeneratePage: React.FC = () => {
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null);
|
||||
const [ttsError, setTtsError] = useState<string | null>(null);
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
|
||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ── 存为素材弹窗状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([]);
|
||||
const [saveNewTag, setSaveNewTag] = useState("");
|
||||
|
||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
/* ── 素材数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
@@ -310,6 +335,7 @@ const GeneratePage: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url);
|
||||
setCompletedTtsJobId(ttsJobId);
|
||||
setTtsJobId(null);
|
||||
setTtsError(null);
|
||||
message.success("语音合成完成!");
|
||||
@@ -351,12 +377,98 @@ const GeneratePage: React.FC = () => {
|
||||
});
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation]);
|
||||
|
||||
/* ── 存为素材 mutation ── */
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音素材库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去素材库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
});
|
||||
setSaveModalOpen(false);
|
||||
setSaveName("");
|
||||
setSaveTagIds([]);
|
||||
setSaveNewTag("");
|
||||
setCompletedTtsJobId(null);
|
||||
setCustomAudioUrl(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`);
|
||||
},
|
||||
});
|
||||
|
||||
/** 打开存为素材弹窗 */
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("");
|
||||
setSaveTagIds([]);
|
||||
setSaveNewTag("");
|
||||
setSaveModalOpen(true);
|
||||
}, []);
|
||||
|
||||
/** 确认保存 */
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return;
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
});
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]);
|
||||
|
||||
/** 在弹窗中新增标签(先创建再选中) */
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim();
|
||||
if (!trimmed) return;
|
||||
/* 已在选中列表则跳过 */
|
||||
const existing = allTags.find((t) => t.name === trimmed);
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed);
|
||||
setSaveTagIds((prev) => [...prev, created.id]);
|
||||
setSaveNewTag("");
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`);
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
);
|
||||
|
||||
/** 保存成功后跳转到素材库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials");
|
||||
}, [navigate]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
title,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
});
|
||||
if (!title.trim()) {
|
||||
message.warning("请先选择或输入标题");
|
||||
return;
|
||||
}
|
||||
if (selectedMaterials.length === 0) {
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材");
|
||||
return;
|
||||
}
|
||||
@@ -369,6 +481,7 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerating(true);
|
||||
setProgress(0);
|
||||
setGenerated(false);
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
@@ -394,6 +507,8 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
@@ -427,6 +542,7 @@ const GeneratePage: React.FC = () => {
|
||||
)?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试";
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data);
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
@@ -456,20 +572,36 @@ const GeneratePage: React.FC = () => {
|
||||
typeof setInterval
|
||||
>;
|
||||
} catch (err: unknown) {
|
||||
console.error("生成失败:", err);
|
||||
console.error("[handleGenerate] 生成失败:", err);
|
||||
setGenerating(false);
|
||||
// 提取 axios 响应中的后端错误信息
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: string; detail?: string };
|
||||
data?: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
msg?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
const backendMsg =
|
||||
axiosErr.response?.data?.message ||
|
||||
axiosErr.response?.data?.error ||
|
||||
axiosErr.response?.data?.detail ||
|
||||
axiosErr.response?.data?.msg ||
|
||||
axiosErr.message ||
|
||||
"";
|
||||
message.error(backendMsg || "生成失败,请重试");
|
||||
console.error(
|
||||
"[handleGenerate] 错误信息:",
|
||||
backendMsg,
|
||||
"完整错误:",
|
||||
axiosErr,
|
||||
);
|
||||
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
@@ -486,6 +618,8 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
editPlanId,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -494,7 +628,11 @@ const GeneratePage: React.FC = () => {
|
||||
message.warning("请先选择一个模板");
|
||||
return;
|
||||
}
|
||||
if (currentStep === 2 && selectedMaterials.length === 0) {
|
||||
if (
|
||||
currentStep === 2 &&
|
||||
materialMode === "manual" &&
|
||||
selectedMaterials.length === 0
|
||||
) {
|
||||
message.warning("请至少选择一个素材");
|
||||
return;
|
||||
}
|
||||
@@ -505,7 +643,13 @@ const GeneratePage: React.FC = () => {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep((s) => s + 1);
|
||||
}
|
||||
}, [currentStep, selectedTemplate, selectedMaterials.length, title]);
|
||||
}, [
|
||||
currentStep,
|
||||
selectedTemplate,
|
||||
selectedMaterials.length,
|
||||
title,
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
@@ -565,11 +709,10 @@ const GeneratePage: React.FC = () => {
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
{MODE_ABBRS[tpl.mode] || "TPL"}
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
@@ -604,11 +747,31 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 步骤 2:选择素材 */
|
||||
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
|
||||
const renderStep2 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
<div className="xx-form-field">
|
||||
|
||||
{/* ── 模式切换 Tab ── */}
|
||||
<div className="xx-material-mode-tabs">
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
|
||||
onClick={() => setMaterialMode("manual")}
|
||||
type="button"
|
||||
>
|
||||
手动选择素材
|
||||
</button>
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
|
||||
onClick={() => setMaterialMode("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择素材库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 素材库选择(两种模式共用) ── */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择素材库</label>
|
||||
<select
|
||||
value={selectedLibraryId}
|
||||
@@ -621,90 +784,138 @@ const GeneratePage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">
|
||||
已选 {selectedMaterials.length} 个素材
|
||||
</span>
|
||||
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
|
||||
系统将自动选择最合适的素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
加载素材中…
|
||||
</Text>
|
||||
) : materials.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked
|
||||
? "var(--primary-soft, #eef2ff)"
|
||||
: "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedMaterials((prev) =>
|
||||
prev.includes(m.id)
|
||||
? prev.filter((id) => id !== m.id)
|
||||
: [...prev, m.id],
|
||||
);
|
||||
}}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{/* ── 手动选择模式 ── */}
|
||||
{materialMode === "manual" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">
|
||||
已选 {selectedMaterials.length} 个素材
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text
|
||||
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
|
||||
>
|
||||
加载素材中…
|
||||
</Text>
|
||||
) : materials.length === 0 ? (
|
||||
<Text
|
||||
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
|
||||
>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked
|
||||
? "var(--primary-soft, #eef2ff)"
|
||||
: "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedMaterials((prev) =>
|
||||
prev.includes(m.id)
|
||||
? prev.filter((id) => id !== m.id)
|
||||
: [...prev, m.id],
|
||||
);
|
||||
}}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 自动匹配模式 ── */}
|
||||
{materialMode === "auto" && (
|
||||
<div className="xx-auto-match-card">
|
||||
<div className="xx-auto-match-icon">🤖</div>
|
||||
<div className="xx-auto-match-body">
|
||||
<h4 className="xx-auto-match-title">智能素材匹配</h4>
|
||||
<p className="xx-auto-match-desc">
|
||||
系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。
|
||||
无需手动挑选,AI
|
||||
会综合素材质量、时长、内容相关性等维度进行智能筛选。
|
||||
</p>
|
||||
<div className="xx-auto-match-features">
|
||||
<span className="xx-auto-match-feature">📊 质量评分筛选</span>
|
||||
<span className="xx-auto-match-feature">🎯 内容相关性匹配</span>
|
||||
<span className="xx-auto-match-feature">⏱️ 时长智能分配</span>
|
||||
</div>
|
||||
</div>
|
||||
{materialsLoading ? (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
扫描素材库中…
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
当前素材库共 {materials.length} 个素材可供匹配
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -714,14 +925,30 @@ const GeneratePage: React.FC = () => {
|
||||
<h3>📝 选择标题</h3>
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<select value={title} onChange={(e) => setTitle(e.target.value)}>
|
||||
<option value="">请选择标题…</option>
|
||||
{userTitles.map((t) => (
|
||||
<option key={t.id} value={t.content}>
|
||||
{t.content}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={title || undefined}
|
||||
onChange={(val) => setTitle(val || "")}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
@@ -910,16 +1137,136 @@ const GeneratePage: React.FC = () => {
|
||||
{ttsError}
|
||||
</Text>
|
||||
)}
|
||||
{customAudioUrl && (
|
||||
<Text
|
||||
{customAudioUrl && completedTtsJobId && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--success, #10b981)",
|
||||
marginTop: 8,
|
||||
display: "block",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
✓ 语音合成完成
|
||||
</Text>
|
||||
<Text style={{ color: "var(--success, #10b981)" }}>
|
||||
✓ 语音合成完成
|
||||
</Text>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
<SaveOutlined /> 存为素材
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 存为素材弹窗 ── */}
|
||||
{saveModalOpen && (
|
||||
<div
|
||||
className="xx-save-modal-overlay"
|
||||
onClick={() => setSaveModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="xx-save-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="xx-save-modal-header">
|
||||
<span>保存到配音素材库</span>
|
||||
<button
|
||||
className="xx-save-modal-close"
|
||||
onClick={() => setSaveModalOpen(false)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-save-modal-body">
|
||||
<label className="xx-save-modal-label">素材名称</label>
|
||||
<input
|
||||
className="xx-save-modal-input"
|
||||
placeholder="留空则自动生成名称"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="xx-save-modal-label">
|
||||
标签
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
(可选)
|
||||
</span>
|
||||
</label>
|
||||
<div className="xx-save-modal-tags">
|
||||
{saveTagIds.map((id) => {
|
||||
const tag = allTags.find((t) => t.id === id);
|
||||
return tag ? (
|
||||
<span key={id} className="xx-save-modal-tag active">
|
||||
{tag.name}
|
||||
<CloseOutlined
|
||||
className="xx-save-modal-tag-remove"
|
||||
onClick={() =>
|
||||
setSaveTagIds((prev) =>
|
||||
prev.filter((x) => x !== id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
<input
|
||||
className="xx-save-modal-tag-input"
|
||||
placeholder="输入标签名回车添加"
|
||||
value={saveNewTag}
|
||||
onChange={(e) => setSaveNewTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddTagInModal(saveNewTag);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{allTags.length > 0 && (
|
||||
<div className="xx-save-modal-tag-presets">
|
||||
{allTags
|
||||
.filter((t) => !saveTagIds.includes(t.id))
|
||||
.slice(0, 12)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="xx-save-modal-tag-preset"
|
||||
onClick={() =>
|
||||
setSaveTagIds((prev) => [...prev, t.id])
|
||||
}
|
||||
>
|
||||
{t.name}
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 10, marginLeft: 4 }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-save-modal-footer">
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
onClick={() => setSaveModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
disabled={saveToLibraryMutation.isPending}
|
||||
onClick={handleConfirmSave}
|
||||
>
|
||||
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1046,7 +1393,9 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">
|
||||
{selectedMaterials.length} 个素材
|
||||
{materialMode === "auto"
|
||||
? "自动匹配"
|
||||
: `${selectedMaterials.length} 个素材`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
@@ -1057,20 +1406,107 @@ const GeneratePage: React.FC = () => {
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{getVoiceName()}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 */}
|
||||
{generating && (
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="xx-progress-bar">
|
||||
{generating && (
|
||||
<>
|
||||
<div className="xx-progress-bar">
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(82, 196, 26, 0.08)",
|
||||
border: "1px solid rgba(82, 196, 26, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
|
||||
>
|
||||
视频生成完成!
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
可在右侧预览或前往成片库查看
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255, 77, 79, 0.08)",
|
||||
border: "1px solid rgba(255, 77, 79, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 18,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
|
||||
>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{generateError}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1181,10 +1617,16 @@ const GeneratePage: React.FC = () => {
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || generated}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -883,3 +883,332 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -737,11 +737,39 @@ 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>{error?.message || "加载失败"}</p>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</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,9 +370,10 @@ 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" : ""}`}
|
||||
@@ -386,7 +387,6 @@ 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,7 +483,9 @@ 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,6 +233,24 @@
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,9 @@
|
||||
|
||||
.vmat-card.playing {
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 1px var(--primary-500), var(--shadow-md);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--primary-500),
|
||||
var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 性别色带 */
|
||||
@@ -812,3 +814,449 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 批量操作栏 ─────────────────────────────────────────── */
|
||||
|
||||
.vmat-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
border: 1px solid var(--primary-color, #6366f1);
|
||||
border-radius: 8px;
|
||||
animation: vmat-batch-bar-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes vmat-batch-bar-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.vmat-batch-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vmat-batch-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vmat-select-all {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vmat-select-all:hover {
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
.vmat-batch-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* ─── 自定义 checkbox ────────────────────────────────────── */
|
||||
|
||||
.vmat-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--neutral-300, #cbd5e1);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg-primary, #fff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vmat-checkbox:hover {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
.vmat-checkbox.checked {
|
||||
background: var(--primary-color, #6366f1);
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ─── 卡片 checkbox 覆盖 ─────────────────────────────────── */
|
||||
|
||||
.vmat-card-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vmat-row-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 选中态 + 批量模式 ──────────────────────────────────── */
|
||||
|
||||
.vmat-card.selected {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
box-shadow: 0 0 0 1px var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
.vmat-row.selected {
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.vmat-card.batch-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-row.batch-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── 进度条拖拽 thumb ───────────────────────────────────── */
|
||||
|
||||
.vmat-progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #6366f1);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── 音量控制 ────────────────────────────────────────────── */
|
||||
|
||||
.vmat-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vmat-volume-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.vmat-volume-btn:hover {
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
.vmat-volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 48px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--neutral-200, #e2e8f0);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #6366f1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-volume-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #6366f1);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ─── 上传进度条 ──────────────────────────────────────────── */
|
||||
|
||||
.vmat-upload-progress {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
background: var(--neutral-100, #f1f5f9);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vmat-upload-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color, #6366f1), #818cf8);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.vmat-upload-progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* ─── 批量打标签 Popover ──────────────────────────────────── */
|
||||
|
||||
.vmat-tag-popover {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.vmat-tag-pop-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--neutral-200, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-primary, #fff);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.vmat-tag-pop-btn:hover {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
color: var(--primary-color, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
/* ─── 列表 checkbox 列 ────────────────────────────────────── */
|
||||
|
||||
.vmat-lh-checkbox {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.vmat-list.batch-mode .vmat-list-header,
|
||||
.vmat-list.batch-mode .vmat-row {
|
||||
grid-template-columns: 30px 48px 1fr 80px 160px 120px 60px 70px 80px;
|
||||
}
|
||||
|
||||
/* ─── 标签筛选药丸条 ─────────────────────────────────────── */
|
||||
|
||||
.vmat-tag-filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 12px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.vmat-tag-filter-bar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.vmat-tag-filter-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--neutral-300, #cbd5e1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.vmat-filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vmat-filter-pill:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--text-primary);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.vmat-filter-pill:focus-visible {
|
||||
outline: 2px solid var(--primary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.vmat-filter-pill.active {
|
||||
background: var(--primary-500);
|
||||
color: #fff;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.vmat-filter-pill.active .vmat-filter-pill-count {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.vmat-filter-pill-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ─── TagSelector ──────────────────────────────────────────── */
|
||||
|
||||
.vmat-tag-selector-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vmat-tag-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
min-height: 42px;
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.vmat-tag-selector:focus-within {
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 2px var(--primary-100);
|
||||
}
|
||||
|
||||
.vmat-tag-selector-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vmat-tag-selector-input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.vmat-tag-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.1));
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vmat-tag-suggestion-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.vmat-tag-suggestion-item:hover {
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.vmat-tag-selector-presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.vmat-tag-selector-preset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.vmat-tag-selector-preset:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--primary-600);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.vmat-tag-selector-preset.selected {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── 标签溢出 + 空态 ─────────────────────────────────────── */
|
||||
|
||||
.vmat-tag-empty {
|
||||
font-size: 12px;
|
||||
color: var(--primary-500);
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--primary-300);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.vmat-tag-empty:hover {
|
||||
background: var(--primary-50);
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.vmat-tag-overflow {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 批量打标签 Popover 自定义输入 ── */
|
||||
.vmat-tag-pop-input-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,68 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -41,6 +41,10 @@ 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
|
||||
|
||||
@@ -62,6 +66,7 @@ __all__ = [
|
||||
"extract_background_task",
|
||||
"process_voice_clone",
|
||||
"process_tts_synthesis",
|
||||
"process_tts_segment_synthesis",
|
||||
"run_ai_recommend",
|
||||
"run_generate_cover",
|
||||
]
|
||||
|
||||
@@ -139,14 +139,16 @@ 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:
|
||||
下载成功的视频文件路径列表
|
||||
@@ -161,16 +163,15 @@ def _download_library_assets(
|
||||
|
||||
try:
|
||||
# 查询素材库中的视频素材
|
||||
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()
|
||||
query = session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
)
|
||||
# 如果指定了 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}")
|
||||
@@ -259,6 +260,8 @@ 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()
|
||||
|
||||
@@ -275,8 +278,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
temp_path = Path(temp_dir)
|
||||
output_path = temp_path / output_name
|
||||
|
||||
# 从素材库下载视频素材
|
||||
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
|
||||
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
|
||||
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
|
||||
|
||||
audio_path = None
|
||||
if voice_library_id:
|
||||
@@ -297,6 +300,32 @@ 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,
|
||||
@@ -314,3 +343,85 @@ 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()
|
||||
|
||||
@@ -179,6 +179,7 @@ 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,3 +104,77 @@ 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()
|
||||
|
||||
@@ -95,6 +95,39 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -241,6 +274,14 @@
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "file_hash",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(64)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "metadata",
|
||||
@@ -288,6 +329,13 @@
|
||||
"name": "ix_assets_created_at",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"file_hash"
|
||||
],
|
||||
"name": "ix_assets_file_hash",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"file_type"
|
||||
@@ -1469,6 +1517,22 @@
|
||||
"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",
|
||||
@@ -1494,6 +1558,13 @@
|
||||
"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"
|
||||
@@ -1599,6 +1670,14 @@
|
||||
"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",
|
||||
@@ -1617,6 +1696,13 @@
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"columns": [
|
||||
"file_hash"
|
||||
],
|
||||
"name": "ix_ingest_jobs_file_hash",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"library_id"
|
||||
@@ -2056,6 +2142,54 @@
|
||||
"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": [
|
||||
{
|
||||
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/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
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/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.conf,staging 需要挂载 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启动
|
||||
@@ -39,6 +39,15 @@ 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;
|
||||
|
||||
@@ -42,3 +42,50 @@ 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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""标签 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
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@@ -83,10 +83,13 @@ 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
|
||||
|
||||
@@ -108,7 +111,10 @@ 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
|
||||
|
||||
@@ -120,6 +126,14 @@ 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()
|
||||
|
||||
@@ -195,6 +209,11 @@ 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,
|
||||
@@ -213,7 +232,61 @@ 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.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
models = (
|
||||
@@ -86,4 +86,40 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
|
||||
.all()
|
||||
)
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ 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,
|
||||
)
|
||||
|
||||
@@ -52,6 +54,8 @@ 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)
|
||||
@@ -123,5 +127,7 @@ 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,6 +17,7 @@ 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,
|
||||
)
|
||||
@@ -36,6 +37,7 @@ 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,
|
||||
)
|
||||
@@ -51,6 +53,7 @@ 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,11 +85,35 @@ 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 模型
|
||||
|
||||
@@ -187,6 +211,7 @@ 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))
|
||||
|
||||
@@ -228,6 +253,8 @@ 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))
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""标签 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,
|
||||
)
|
||||
@@ -19,6 +19,8 @@ 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:
|
||||
@@ -44,6 +46,8 @@ 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)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class SubmitIngestJobCommand:
|
||||
project_id: str
|
||||
library_id: str
|
||||
storage_key: str
|
||||
file_hash: str = ""
|
||||
|
||||
|
||||
class SubmitIngestJobUseCase:
|
||||
@@ -22,5 +23,6 @@ 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)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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
|
||||
@@ -0,0 +1,70 @@
|
||||
"""长文本分段工具 — 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]
|
||||
@@ -9,19 +9,35 @@
|
||||
|
||||
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 合成工作流异常。"""
|
||||
@@ -46,9 +62,55 @@ 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) 元组。
|
||||
转存失败时回退到原始临时 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:
|
||||
# 下载临时音频
|
||||
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,
|
||||
@@ -80,6 +142,10 @@ 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,
|
||||
@@ -93,17 +159,19 @@ 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,直接标记完成
|
||||
# 如果 CosyVoice 同步返回了 audio_url,转存 OSS 后标记完成
|
||||
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=audio_url,
|
||||
output_audio_url=permanent_url,
|
||||
output_audio_key=storage_key,
|
||||
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={audio_url}")
|
||||
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={permanent_url}")
|
||||
return job
|
||||
|
||||
job.metadata = job_metadata
|
||||
@@ -133,6 +201,11 @@ 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")
|
||||
@@ -171,13 +244,17 @@ 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=audio_url,
|
||||
output_audio_url=permanent_url,
|
||||
output_audio_key=storage_key,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
)
|
||||
job = self.repository.update(job)
|
||||
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}")
|
||||
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
|
||||
return job
|
||||
|
||||
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
|
||||
@@ -201,3 +278,224 @@ 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}")
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -56,6 +57,7 @@ __all__ = [
|
||||
"JobStatus",
|
||||
"JobType",
|
||||
"Project",
|
||||
"Tag",
|
||||
"TemplateClipConfig",
|
||||
"TransitionEffect",
|
||||
"User",
|
||||
|
||||
+20
-14
@@ -161,8 +161,9 @@ 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)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
tag_ids: 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))
|
||||
|
||||
@@ -187,6 +188,7 @@ 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:
|
||||
@@ -213,24 +215,25 @@ 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 {},
|
||||
tags=[],
|
||||
tag_ids=[],
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def remove_tag(self, tag: str) -> None:
|
||||
"""删除标签。如果标签不存在,不报错(幂等性)。"""
|
||||
clean_tag = tag.strip()
|
||||
if clean_tag in self.tags:
|
||||
self.tags.remove(clean_tag)
|
||||
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)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -243,6 +246,7 @@ 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))
|
||||
|
||||
@@ -252,6 +256,7 @@ class IngestJob:
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
file_hash: str = "",
|
||||
) -> "IngestJob":
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
@@ -264,4 +269,5 @@ class IngestJob:
|
||||
project_id=project_id.strip(),
|
||||
library_id=library_id.strip(),
|
||||
storage_key=storage_key.strip(),
|
||||
file_hash=file_hash.strip(),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,8 @@ 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
|
||||
@@ -59,6 +61,8 @@ 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 至少需要提供一个")
|
||||
@@ -76,4 +80,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""标签领域实体。"""
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -12,6 +13,7 @@ __all__ = [
|
||||
"AssetRepository",
|
||||
"IngestJobRepository",
|
||||
"ProjectRepository",
|
||||
"TagRepository",
|
||||
"TitleLibraryRepository",
|
||||
"VoiceLibraryRepository",
|
||||
]
|
||||
|
||||
@@ -50,6 +50,11 @@ 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
|
||||
@@ -78,3 +83,22 @@ 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
|
||||
|
||||
@@ -13,3 +13,5 @@ class GeneratedVideoRepository(Protocol):
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""标签仓储接口定义。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from packages.domain import Tag
|
||||
|
||||
|
||||
class TagRepository(ABC):
|
||||
@abstractmethod
|
||||
def create(self, tag: Tag) -> Tag:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get(self, tag_id: str) -> Tag | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_name(self, user_id: str, name: str) -> Tag | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Tag]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, tag_id: str) -> bool:
|
||||
pass
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查指定commit的CI status状态。
|
||||
|
||||
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
|
||||
返回: 打印状态 (success/failure/pending/error)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
sha = sys.argv[3]
|
||||
target_context = sys.argv[4]
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
statuses = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
print(s.get("status", "pending"))
|
||||
return
|
||||
|
||||
print("pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查PR是否有至少N个APPROVED审批。
|
||||
|
||||
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
|
||||
返回: 打印 "approved" 或 "pending"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
pr_number = sys.argv[3]
|
||||
min_approval = int(sys.argv[4])
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
reviews = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
|
||||
approvers = set()
|
||||
for r in reviews:
|
||||
if r.get("state") == "APPROVED":
|
||||
approvers.add(r.get("user", {}).get("login", ""))
|
||||
|
||||
if len(approvers) >= min_approval:
|
||||
print(f"approved ({len(approvers)})")
|
||||
else:
|
||||
print(f"pending ({len(approvers)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
|
||||
作为短作业模式的兜底机制,每5分钟运行一次
|
||||
|
||||
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def api_request(token, repo, endpoint, method="GET", data=None):
|
||||
"""Gitea API请求"""
|
||||
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
# 跳过SSL验证
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read().decode()), resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
"""获取所有open的PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
prs.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(token, repo, sha):
|
||||
"""获取commit的CI状态汇总"""
|
||||
data, code = api_request(token, repo, f"commits/{sha}/status")
|
||||
if code != 200:
|
||||
return {}, "error"
|
||||
return data, data.get("state", "unknown")
|
||||
|
||||
|
||||
def check_required_contexts(token, repo, sha, contexts):
|
||||
"""检查指定的context是否都通过"""
|
||||
data, _ = get_commit_status(token, repo, sha)
|
||||
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
|
||||
|
||||
all_success = True
|
||||
any_pending = False
|
||||
any_failed = False
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
if state in ("failure", "error"):
|
||||
any_failed = True
|
||||
|
||||
return all_success, any_pending, any_failed, statuses
|
||||
|
||||
|
||||
def get_pr_files(token, repo, pr_number):
|
||||
"""获取PR变更文件"""
|
||||
files = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
files.extend(data)
|
||||
if len(data) < 300:
|
||||
break
|
||||
page += 1
|
||||
return [f["filename"] for f in files]
|
||||
|
||||
|
||||
def is_frontend_only(files):
|
||||
"""判断是否纯前端改动"""
|
||||
if not files:
|
||||
return False
|
||||
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
|
||||
backend_count = len(files) - frontend_count
|
||||
return backend_count == 0 and frontend_count > 0
|
||||
|
||||
|
||||
def has_approval(token, repo, pr_number):
|
||||
"""检查PR是否已有审批"""
|
||||
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
|
||||
if code != 200:
|
||||
return False
|
||||
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
|
||||
|
||||
|
||||
def get_ai_review_result(token, repo, pr_number):
|
||||
"""
|
||||
检查AI代码审查结果,返回 (has_critical, review_body)
|
||||
has_critical: 是否有严重问题(需修改的问题 > 0)
|
||||
review_body: 最新的AI审查评论文本
|
||||
"""
|
||||
# AI审查评论标记
|
||||
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
|
||||
|
||||
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
|
||||
if code != 200:
|
||||
return False, None
|
||||
|
||||
# 找最新的AI审查评论
|
||||
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
|
||||
|
||||
if not ai_comments:
|
||||
return False, None
|
||||
|
||||
# 按时间排序,取最新的
|
||||
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
|
||||
body = latest.get("body", "")
|
||||
|
||||
# 解析严重问题数量
|
||||
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
|
||||
critical_count = 0
|
||||
|
||||
# 方式1:直接匹配数字
|
||||
match = re.search(r"严重问题数量[::]\s*(\d+)\s*个", body)
|
||||
if match:
|
||||
critical_count = int(match.group(1))
|
||||
else:
|
||||
# 方式2:数 "需修改的问题" 章节下的条目数
|
||||
critical_section = re.search(
|
||||
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
|
||||
body,
|
||||
re.DOTALL,
|
||||
)
|
||||
if critical_section:
|
||||
section_text = critical_section.group(1)
|
||||
# 数编号条目 1. 2. 3.
|
||||
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
|
||||
critical_count = len(items)
|
||||
|
||||
has_critical = critical_count > 0
|
||||
return has_critical, body
|
||||
|
||||
|
||||
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
|
||||
"""审批PR"""
|
||||
# 创建review
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews",
|
||||
method="POST",
|
||||
data={"event": "PENDING", "body": reason},
|
||||
)
|
||||
|
||||
if code not in (200, 201):
|
||||
return False, f"创建review失败: HTTP {code}"
|
||||
|
||||
review_id = data.get("id")
|
||||
if data.get("state") == "APPROVED":
|
||||
return True, "直接创建APPROVED成功"
|
||||
|
||||
if not review_id:
|
||||
return False, "未获取到review ID"
|
||||
|
||||
# submit为APPROVED
|
||||
data2, code2 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}/events",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
|
||||
if code2 in (200, 201):
|
||||
return True, "审批提交成功"
|
||||
else:
|
||||
# 尝试另一个端点
|
||||
data3, code3 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
if code3 in (200, 201):
|
||||
return True, "审批提交成功(备用端点)"
|
||||
return False, f"审批提交失败: HTTP {code2}/{code3}"
|
||||
|
||||
|
||||
def add_pr_label(token, repo, pr_number, label):
|
||||
"""给PR添加标签"""
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"issues/{pr_number}/labels",
|
||||
method="POST",
|
||||
data={"labels": [label]},
|
||||
)
|
||||
return code in (200, 201)
|
||||
|
||||
|
||||
def merge_pr(token, repo, pr_number):
|
||||
"""合并PR(squash merge)"""
|
||||
# 等待几秒让状态同步
|
||||
time.sleep(30)
|
||||
|
||||
# 检查PR状态
|
||||
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
|
||||
if code != 200:
|
||||
return False, f"获取PR状态失败: HTTP {code}"
|
||||
if pr_data.get("state") != "open":
|
||||
return False, f"PR状态不是open: {pr_data.get('state')}"
|
||||
|
||||
# 执行squash merge
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/merge",
|
||||
method="POST",
|
||||
data={
|
||||
"do": "squash",
|
||||
"merge_title_field": "",
|
||||
"merge_message_field": "",
|
||||
"delete_branch_after_merge": True,
|
||||
"force_merge": False,
|
||||
},
|
||||
)
|
||||
|
||||
if code == 200:
|
||||
return True, "合并成功"
|
||||
elif code == 405:
|
||||
return False, "合并返回405(门禁未满足或冲突)"
|
||||
else:
|
||||
return False, f"合并失败: HTTP {code}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PR自动扫描器")
|
||||
parser.add_argument("--token", required=True, help="Gitea API token")
|
||||
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
|
||||
parser.add_argument("--base", default="develop", help="目标分支")
|
||||
parser.add_argument("--approve", action="store_true", help="执行自动审批")
|
||||
parser.add_argument("--merge", action="store_true", help="执行自动合并")
|
||||
parser.add_argument("--dry-run", default="false", help="试运行模式")
|
||||
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
|
||||
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry_run.lower() == "true"
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
print(f"获取 {args.base} 分支的open PR...")
|
||||
prs = get_open_prs(args.token, args.repo, args.base)
|
||||
print(f"找到 {len(prs)} 个open PR")
|
||||
|
||||
approved_count = 0
|
||||
merged_count = 0
|
||||
skipped_count = 0
|
||||
ai_blocked_count = 0
|
||||
|
||||
for pr in prs[: args.max_prs]:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 跳过目标分支不对的
|
||||
if base_ref != args.base:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
|
||||
|
||||
# 判断是否纯前端
|
||||
files = get_pr_files(args.token, args.repo, pr_num)
|
||||
frontend_only = is_frontend_only(files)
|
||||
|
||||
if frontend_only:
|
||||
approve_contexts = FRONTEND_ONLY_CONTEXT
|
||||
merge_contexts = FRONTEND_ONLY_CONTEXT
|
||||
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
|
||||
else:
|
||||
approve_contexts = REQUIRED_CONTEXTS_APPROVE
|
||||
merge_contexts = REQUIRED_CONTEXTS_FULL
|
||||
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
|
||||
|
||||
# 检查审批用的CI状态
|
||||
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
|
||||
|
||||
# === AI审查检查 ===
|
||||
ai_has_critical = False
|
||||
if not args.skip_ai_review and all_ok and not failed and args.approve:
|
||||
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
|
||||
if ai_has_critical:
|
||||
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
|
||||
ai_blocked_count += 1
|
||||
# 给PR打标签便于人工识别
|
||||
if not dry_run:
|
||||
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
|
||||
|
||||
# === 自动审批 ===
|
||||
if args.approve and all_ok and not failed and not ai_has_critical:
|
||||
if has_approval(args.token, args.repo, pr_num):
|
||||
print(" ✅ 已有审批,跳过")
|
||||
else:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动审批")
|
||||
else:
|
||||
print(" 🎯 执行自动审批...")
|
||||
ok, msg = approve_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 审批成功: {msg}")
|
||||
approved_count += 1
|
||||
else:
|
||||
print(f" ❌ 审批失败: {msg}")
|
||||
elif ai_has_critical:
|
||||
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
|
||||
elif failed:
|
||||
print(" ❌ CI有失败项,跳过审批")
|
||||
elif pending:
|
||||
print(" ⏳ CI仍在运行,跳过")
|
||||
|
||||
# === 自动合并 ===
|
||||
if args.merge:
|
||||
# 检查合并用的CI状态
|
||||
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
|
||||
args.token, args.repo, head_sha, merge_contexts
|
||||
)
|
||||
|
||||
# 检查审批
|
||||
approved = has_approval(args.token, args.repo, pr_num)
|
||||
|
||||
if merge_ok and approved and not merge_failed:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动合并")
|
||||
else:
|
||||
print(" 🎯 执行自动合并...")
|
||||
ok, msg = merge_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 合并成功: {msg}")
|
||||
merged_count += 1
|
||||
else:
|
||||
print(f" ⚠️ 合并失败: {msg}")
|
||||
elif merge_pending:
|
||||
print(" ⏳ 合并条件未满足: CI运行中")
|
||||
elif merge_failed:
|
||||
print(" ❌ 合并条件未满足: CI有失败")
|
||||
elif not approved:
|
||||
print(" ⏳ 合并条件未满足: 无审批")
|
||||
|
||||
print("\n=== 扫描结果 ===")
|
||||
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
|
||||
print(f" 自动审批: {approved_count} 个")
|
||||
print(f" 自动合并: {merged_count} 个")
|
||||
print(f" AI审查阻止: {ai_blocked_count} 个")
|
||||
print(f" 跳过: {skipped_count} 个")
|
||||
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,7 +4,7 @@ from packages.domain import Asset
|
||||
|
||||
|
||||
def test_add_tag_to_asset():
|
||||
"""测试添加标签到 Asset。"""
|
||||
"""测试添加标签 ID 到 Asset。"""
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
@@ -13,16 +13,16 @@ def test_add_tag_to_asset():
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
asset.add_tag("风景")
|
||||
asset.add_tag("自然")
|
||||
asset.add_tag("tag-1")
|
||||
asset.add_tag("tag-2")
|
||||
|
||||
assert len(asset.tags) == 2
|
||||
assert "风景" in asset.tags
|
||||
assert "自然" in asset.tags
|
||||
assert len(asset.tag_ids) == 2
|
||||
assert "tag-1" in asset.tag_ids
|
||||
assert "tag-2" in asset.tag_ids
|
||||
|
||||
|
||||
def test_add_duplicate_tag_should_ignore():
|
||||
"""测试添加重复标签应自动去重。"""
|
||||
"""测试添加重复标签 ID 应自动去重。"""
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
@@ -31,15 +31,15 @@ def test_add_duplicate_tag_should_ignore():
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
asset.add_tag("风景")
|
||||
asset.add_tag("风景") # 重复
|
||||
asset.add_tag("tag-1")
|
||||
asset.add_tag("tag-1") # 重复
|
||||
|
||||
assert len(asset.tags) == 1
|
||||
assert asset.tags.count("风景") == 1
|
||||
assert len(asset.tag_ids) == 1
|
||||
assert asset.tag_ids.count("tag-1") == 1
|
||||
|
||||
|
||||
def test_add_empty_tag_should_fail():
|
||||
"""测试添加空标签应失败。"""
|
||||
"""测试添加空标签 ID 应失败。"""
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
@@ -48,15 +48,15 @@ def test_add_empty_tag_should_fail():
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="标签不能为空"):
|
||||
with pytest.raises(ValueError, match="标签 ID 不能为空"):
|
||||
asset.add_tag("")
|
||||
|
||||
with pytest.raises(ValueError, match="标签不能为空"):
|
||||
with pytest.raises(ValueError, match="标签 ID 不能为空"):
|
||||
asset.add_tag(" ") # 仅空格
|
||||
|
||||
|
||||
def test_remove_tag_from_asset():
|
||||
"""测试从 Asset 删除标签。"""
|
||||
"""测试从 Asset 删除标签 ID。"""
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
@@ -65,18 +65,18 @@ def test_remove_tag_from_asset():
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
asset.add_tag("风景")
|
||||
asset.add_tag("自然")
|
||||
asset.add_tag("tag-1")
|
||||
asset.add_tag("tag-2")
|
||||
|
||||
asset.remove_tag("风景")
|
||||
asset.remove_tag("tag-1")
|
||||
|
||||
assert len(asset.tags) == 1
|
||||
assert "风景" not in asset.tags
|
||||
assert "自然" in asset.tags
|
||||
assert len(asset.tag_ids) == 1
|
||||
assert "tag-1" not in asset.tag_ids
|
||||
assert "tag-2" in asset.tag_ids
|
||||
|
||||
|
||||
def test_remove_nonexistent_tag_should_be_idempotent():
|
||||
"""测试删除不存在的标签应幂等(不报错)。"""
|
||||
"""测试删除不存在的标签 ID 应幂等(不报错)。"""
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
@@ -85,10 +85,10 @@ def test_remove_nonexistent_tag_should_be_idempotent():
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
asset.add_tag("风景")
|
||||
asset.add_tag("tag-1")
|
||||
|
||||
# 删除不存在的标签,不应报错
|
||||
asset.remove_tag("不存在的标签")
|
||||
# 删除不存在的标签 ID,不应报错
|
||||
asset.remove_tag("nonexistent-tag")
|
||||
|
||||
assert len(asset.tags) == 1
|
||||
assert "风景" in asset.tags
|
||||
assert len(asset.tag_ids) == 1
|
||||
assert "tag-1" in asset.tag_ids
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""批量删除素材 + 分页优化 单元测试。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
class TestBatchDelete:
|
||||
"""batch_delete 仓储方法测试。"""
|
||||
|
||||
def _make_repo_with_assets(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
for i in range(5):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"voice_{i}.mp3",
|
||||
storage_key=f"uploads/voice_{i}.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
return repo
|
||||
|
||||
def test_batch_delete_removes_multiple(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(5):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"voice_{i}.mp3",
|
||||
storage_key=f"uploads/voice_{i}.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
ids_to_delete = [assets[0].id, assets[2].id, assets[4].id]
|
||||
deleted_count = repo.batch_delete(ids_to_delete)
|
||||
|
||||
assert deleted_count == 3
|
||||
# 验证确实被删了
|
||||
assert repo.get(assets[0].id) is None
|
||||
assert repo.get(assets[2].id) is None
|
||||
assert repo.get(assets[4].id) is None
|
||||
# 验证其他还在
|
||||
assert repo.get(assets[1].id) is not None
|
||||
assert repo.get(assets[3].id) is not None
|
||||
|
||||
def test_batch_delete_empty_list(self):
|
||||
repo = self._make_repo_with_assets()
|
||||
assert repo.batch_delete([]) == 0
|
||||
|
||||
def test_batch_delete_nonexistent_ids(self):
|
||||
repo = self._make_repo_with_assets()
|
||||
deleted = repo.batch_delete(["nonexistent-1", "nonexistent-2"])
|
||||
assert deleted == 0
|
||||
|
||||
def test_batch_delete_mixed_existing_and_nonexistent(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="voice.mp3",
|
||||
storage_key="uploads/voice.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
)
|
||||
repo.create(asset)
|
||||
|
||||
deleted = repo.batch_delete([asset.id, "nonexistent"])
|
||||
assert deleted == 1
|
||||
assert repo.get(asset.id) is None
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
素材重复上传检测 单元测试
|
||||
|
||||
覆盖:
|
||||
- 表单上传(multipart)命中去重 → 直接返回已有 asset_id,不上传 OSS
|
||||
- 直传 OSS complete 命中去重 → 直接返回已有 asset_id,不创建 ingest job
|
||||
- 未命中去重 → 正常创建 ingest job
|
||||
- file_hash 为空 → 跳过去重检测
|
||||
- IngestJob 透传 file_hash 到 Asset
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def get(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_id(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
class StubAssetLibraryRepository:
|
||||
def __init__(self, libraries: dict | None = None):
|
||||
self._libraries = libraries or {}
|
||||
|
||||
def find_by_project(self, project_id: str, kind=None) -> list:
|
||||
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [lib for lib in items if lib.kind == kind]
|
||||
return items
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
"""支持 find_by_library_and_file_hash 去重检测。"""
|
||||
|
||||
def __init__(self, assets: list[Asset] | None = None):
|
||||
self._assets = assets or []
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
|
||||
for a in self._assets:
|
||||
if a.library_id == library_id and a.file_hash == file_hash:
|
||||
return a
|
||||
return None
|
||||
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
self._assets.append(asset)
|
||||
return asset
|
||||
|
||||
|
||||
class StubIngestJobRepository:
|
||||
def __init__(self):
|
||||
self._jobs: dict[str, IngestJob] = {}
|
||||
|
||||
def create(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DUPE_HASH = "a" * 32
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
|
||||
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
def _make_existing_asset(
|
||||
id: str = "existing-asset-1",
|
||||
library_id: str = "lib-1",
|
||||
file_hash: str = DUPE_HASH,
|
||||
) -> Asset:
|
||||
return Asset(
|
||||
id=id,
|
||||
project_id="proj-1",
|
||||
library_id=library_id,
|
||||
name="existing.mp4",
|
||||
storage_key="uploads/existing/existing.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_hash=file_hash,
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(
|
||||
project_repo=None,
|
||||
library_repo=None,
|
||||
asset_repo=None,
|
||||
ingest_repo=None,
|
||||
storage=None,
|
||||
):
|
||||
from app.api.routes.upload import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
project_repo = project_repo or StubProjectRepository()
|
||||
library_repo = library_repo or StubAssetLibraryRepository()
|
||||
asset_repo = asset_repo or StubAssetRepository()
|
||||
ingest_repo = ingest_repo or StubIngestJobRepository()
|
||||
storage = storage or MagicMock()
|
||||
storage.is_configured = True
|
||||
storage._normalize_storage_key = lambda key: key
|
||||
storage.file_exists = lambda key: True
|
||||
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||||
|
||||
mock_user = MagicMock(spec=AuthenticatedUser)
|
||||
mock_user.id = "user-1"
|
||||
mock_user.email = "test@example.com"
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
app.dependency_overrides[get_storage_service] = lambda: storage
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _client(**kwargs) -> TestClient:
|
||||
return TestClient(_build_app(**kwargs))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试用例
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipartUploadDedup:
|
||||
"""表单上传(POST /api/v1/assets)去重检测。"""
|
||||
|
||||
def test_dedup_hit_returns_existing_asset(self):
|
||||
"""file_hash 命中已有素材 → 返回 duplicated=true + asset_id,不上传 OSS。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": DUPE_HASH,
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is True
|
||||
assert body["asset_id"] == existing.id
|
||||
assert body["ingest_job_id"] == ""
|
||||
|
||||
def test_dedup_miss_creates_ingest_job(self):
|
||||
"""file_hash 未命中 → 正常上传并创建 ingest job。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]), # 无已有素材
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": "b" * 32, # 新的 hash
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
assert body["ingest_job_id"] != ""
|
||||
|
||||
def test_empty_hash_skips_dedup(self):
|
||||
"""file_hash 为空 → 跳过去重检测,直接上传。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
# 不传 file_hash
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
|
||||
|
||||
class TestDirectUploadDedup:
|
||||
"""直传 OSS complete(POST /api/v1/direct/complete)去重检测。"""
|
||||
|
||||
def test_dedup_hit_returns_existing_asset(self):
|
||||
"""complete 阶段 file_hash 命中 → 返回 duplicated=true。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"storage_key": "uploads/abc/test.mp4",
|
||||
"file_hash": DUPE_HASH,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is True
|
||||
assert body["asset_id"] == existing.id
|
||||
assert body["ingest_job_id"] == ""
|
||||
|
||||
def test_dedup_miss_creates_ingest_job(self):
|
||||
"""complete 阶段 file_hash 未命中 → 创建 ingest job。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"storage_key": "uploads/abc/test.mp4",
|
||||
"file_hash": "c" * 32,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
assert body["ingest_job_id"] != ""
|
||||
|
||||
|
||||
class TestIngestJobFileHashPassthrough:
|
||||
"""file_hash 从上传接口透传到 IngestJob。"""
|
||||
|
||||
def test_ingest_job_stores_file_hash(self):
|
||||
"""上传时传入的 file_hash 应保存到 IngestJob 实体。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]),
|
||||
ingest_repo=ingest_repo,
|
||||
)
|
||||
|
||||
new_hash = "d" * 32
|
||||
client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": new_hash,
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
|
||||
# 验证 IngestJob 存储了 file_hash
|
||||
assert len(ingest_repo._jobs) == 1
|
||||
job = list(ingest_repo._jobs.values())[0]
|
||||
assert job.file_hash == new_hash
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
素材库自动匹配 单元测试
|
||||
|
||||
覆盖:
|
||||
- all 模式:返回全部 ready 视频素材 ID
|
||||
- random 模式:随机选取 N 个
|
||||
- smart 模式:按质量分/时长评分降序选取
|
||||
- 无 ready 视频素材时返回空列表
|
||||
- count=0 时返回全部(random/smart 模式)
|
||||
- 非视频素材和非 ready 状态素材被过滤
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import _select_assets_from_library
|
||||
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
def _asset(
|
||||
id: str,
|
||||
name: str,
|
||||
mime_type: str = "video/mp4",
|
||||
status: AssetStatus = AssetStatus.READY,
|
||||
quality_score: float | None = None,
|
||||
duration: float | None = None,
|
||||
) -> Asset:
|
||||
a = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"uploads/{name}",
|
||||
mime_type=mime_type,
|
||||
file_size=1024,
|
||||
status=status,
|
||||
quality_score=quality_score,
|
||||
duration=duration,
|
||||
)
|
||||
# create() 会覆盖 id,手动设置
|
||||
a.id = id
|
||||
return a
|
||||
|
||||
|
||||
class TestSelectAssetsAllMode:
|
||||
"""all 模式:返回全部 ready 视频素材。"""
|
||||
|
||||
def test_returns_all_ready_video_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
_asset("a3", "v3.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert sorted(result) == ["a1", "a2", "a3"]
|
||||
|
||||
def test_ignores_count_in_all_mode(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=1)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_filters_non_video_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", mime_type="video/mp4"),
|
||||
_asset("a2", "img.jpg", mime_type="image/jpeg"),
|
||||
_asset("a3", "v2.mov", mime_type="video/quicktime"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert sorted(result) == ["a1", "a3"]
|
||||
|
||||
def test_filters_non_ready_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", status=AssetStatus.READY),
|
||||
_asset("a2", "v2.mp4", status=AssetStatus.UPLOADING),
|
||||
_asset("a3", "v3.mp4", status=AssetStatus.PROCESSING),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert result == ["a1"]
|
||||
|
||||
def test_empty_library_returns_empty(self):
|
||||
result = _select_assets_from_library([], mode="all", count=0)
|
||||
assert result == []
|
||||
|
||||
def test_no_ready_video_returns_empty(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", status=AssetStatus.UPLOADING),
|
||||
_asset("a2", "img.jpg", mime_type="image/jpeg", status=AssetStatus.READY),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSelectAssetsRandomMode:
|
||||
"""random 模式:随机选取 N 个。"""
|
||||
|
||||
def test_random_selects_exact_count(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(10)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=3)
|
||||
assert len(result) == 3
|
||||
assert all(rid in [a.id for a in assets] for rid in result)
|
||||
|
||||
def test_random_count_zero_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(5)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=0)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_random_count_exceeds_total_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(3)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=100)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestSelectAssetsSmartMode:
|
||||
"""smart 模式:按质量分/时长评分降序选取。"""
|
||||
|
||||
def test_smart_sorts_by_quality_score_desc(self):
|
||||
assets = [
|
||||
_asset("low", "low.mp4", quality_score=0.3),
|
||||
_asset("high", "high.mp4", quality_score=0.9),
|
||||
_asset("mid", "mid.mp4", quality_score=0.6),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["high", "mid", "low"]
|
||||
|
||||
def test_smart_tiebreak_by_duration_desc(self):
|
||||
assets = [
|
||||
_asset("short", "short.mp4", quality_score=0.8, duration=10.0),
|
||||
_asset("long", "long.mp4", quality_score=0.8, duration=60.0),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["long", "short"]
|
||||
|
||||
def test_smart_with_count_limits_results(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", quality_score=0.9),
|
||||
_asset("a2", "v2.mp4", quality_score=0.7),
|
||||
_asset("a3", "v3.mp4", quality_score=0.5),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2)
|
||||
assert result == ["a1", "a2"]
|
||||
|
||||
def test_smart_null_quality_treated_as_zero(self):
|
||||
assets = [
|
||||
_asset("scored", "scored.mp4", quality_score=0.5),
|
||||
_asset("unscored", "unscored.mp4", quality_score=None),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["scored", "unscored"]
|
||||
|
||||
def test_smart_count_zero_returns_all_sorted(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", quality_score=0.1),
|
||||
_asset("a2", "v2.mp4", quality_score=0.9),
|
||||
_asset("a3", "v3.mp4", quality_score=0.5),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["a2", "a3", "a1"]
|
||||
|
||||
|
||||
class TestSelectAssetsDefaultMode:
|
||||
"""默认模式(未知 mode 字符串)应回退到 all。"""
|
||||
|
||||
def test_unknown_mode_falls_back_to_all(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="unknown", count=0)
|
||||
assert len(result) == 2
|
||||
@@ -0,0 +1,130 @@
|
||||
"""素材打标/取消标签单元测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.adapters.in_memory.tag_repository import InMemoryTagRepository
|
||||
from packages.domain import Asset, Tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_repo():
|
||||
return InMemoryAssetRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tag_repo():
|
||||
return InMemoryTagRepository()
|
||||
|
||||
|
||||
def _create_asset(asset_repo, **kwargs):
|
||||
defaults = dict(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="video.mp4",
|
||||
storage_key="uploads/abc/video.mp4",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
asset = Asset.create(**defaults)
|
||||
return asset_repo.create(asset)
|
||||
|
||||
|
||||
def test_tag_asset(asset_repo, tag_repo):
|
||||
"""测试给素材打标签。"""
|
||||
asset = _create_asset(asset_repo)
|
||||
tag = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
|
||||
asset.add_tag(tag.id)
|
||||
asset_repo.update(asset)
|
||||
|
||||
loaded = asset_repo.get(asset.id)
|
||||
assert tag.id in loaded.tag_ids
|
||||
|
||||
|
||||
def test_untag_asset(asset_repo, tag_repo):
|
||||
"""测试取消素材标签。"""
|
||||
asset = _create_asset(asset_repo)
|
||||
tag = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
|
||||
asset.add_tag(tag.id)
|
||||
asset_repo.update(asset)
|
||||
|
||||
asset.remove_tag(tag.id)
|
||||
asset_repo.update(asset)
|
||||
|
||||
loaded = asset_repo.get(asset.id)
|
||||
assert tag.id not in loaded.tag_ids
|
||||
|
||||
|
||||
def test_tag_multiple_assets(asset_repo, tag_repo):
|
||||
"""测试同一标签打给多个素材。"""
|
||||
a1 = _create_asset(asset_repo, name="a.mp4")
|
||||
a2 = _create_asset(asset_repo, name="b.mp4")
|
||||
tag = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
|
||||
a1.add_tag(tag.id)
|
||||
a2.add_tag(tag.id)
|
||||
asset_repo.update(a1)
|
||||
asset_repo.update(a2)
|
||||
|
||||
assert tag.id in asset_repo.get(a1.id).tag_ids
|
||||
assert tag.id in asset_repo.get(a2.id).tag_ids
|
||||
|
||||
|
||||
def test_find_by_tag_ids(asset_repo, tag_repo):
|
||||
"""测试按标签 ID 筛选素材。"""
|
||||
tag1 = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
tag2 = tag_repo.create(Tag.create(user_id="user-1", name="自然"))
|
||||
|
||||
a1 = _create_asset(asset_repo, name="a.mp4")
|
||||
a1.add_tag(tag1.id)
|
||||
a1.add_tag(tag2.id)
|
||||
asset_repo.update(a1)
|
||||
|
||||
a2 = _create_asset(asset_repo, name="b.mp4")
|
||||
a2.add_tag(tag1.id)
|
||||
asset_repo.update(a2)
|
||||
|
||||
a3 = _create_asset(asset_repo, name="c.mp4")
|
||||
# 无标签
|
||||
|
||||
# 按 tag1 筛选 → a1, a2
|
||||
result = asset_repo.find_by_tag_ids([tag1.id])
|
||||
ids = {a.id for a in result}
|
||||
assert ids == {a1.id, a2.id}
|
||||
|
||||
# 按 tag1 + tag2 筛选(交集)→ a1
|
||||
result = asset_repo.find_by_tag_ids([tag1.id, tag2.id])
|
||||
ids = {a.id for a in result}
|
||||
assert ids == {a1.id}
|
||||
|
||||
# 空 tag_ids → 空结果
|
||||
assert asset_repo.find_by_tag_ids([]) == []
|
||||
|
||||
|
||||
def test_delete_tag_cleans_associations(asset_repo, tag_repo):
|
||||
"""测试删除标签后素材的 tag_ids 不受影响(关联表由仓储层清理)。"""
|
||||
asset = _create_asset(asset_repo)
|
||||
tag = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
|
||||
asset.add_tag(tag.id)
|
||||
asset_repo.update(asset)
|
||||
|
||||
# 删除标签
|
||||
tag_repo.delete(tag.id)
|
||||
assert tag_repo.get(tag.id) is None
|
||||
|
||||
# 素材的 tag_ids 在内存中仍有,但重新加载后 InMemory 不感知关联表
|
||||
# 实际 SQLAlchemy 实现中 _sync_asset_tags 会在 update 时清理
|
||||
|
||||
|
||||
def test_duplicate_tag_id_ignored(asset_repo, tag_repo):
|
||||
"""测试重复打同一标签自动去重。"""
|
||||
asset = _create_asset(asset_repo)
|
||||
tag = tag_repo.create(Tag.create(user_id="user-1", name="风景"))
|
||||
|
||||
asset.add_tag(tag.id)
|
||||
asset.add_tag(tag.id) # 重复
|
||||
|
||||
assert asset.tag_ids.count(tag.id) == 1
|
||||
@@ -461,3 +461,171 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
assert result["similarity"] == 1.0 # avg_distance = 0
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
|
||||
class TestVideoDeduplicatorCheckBatchDuplicate:
|
||||
"""VideoDeduplicator.check_batch_duplicate() 测试。
|
||||
|
||||
批次内查重逻辑与历史查重一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def deduplicator(self):
|
||||
return VideoDeduplicator()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
return MagicMock()
|
||||
|
||||
def _make_batch_video(self, video_id, md5, phashes=None):
|
||||
video = MagicMock()
|
||||
video.id = video_id
|
||||
video.video_fingerprint = {
|
||||
"md5": md5,
|
||||
"keyframe_phashes": phashes or [],
|
||||
"color_histograms": [],
|
||||
}
|
||||
return video
|
||||
|
||||
def _patch_repo(self, mock_repo):
|
||||
import apps.worker.video_processing.dedup as dedup_module
|
||||
|
||||
original = dedup_module.SQLAlchemyGeneratedVideoRepository
|
||||
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
|
||||
return original, dedup_module
|
||||
|
||||
def _restore_repo(self, dedup_module, original):
|
||||
dedup_module.SQLAlchemyGeneratedVideoRepository = original
|
||||
|
||||
def test_batch_exact_md5_match(self, deduplicator, mock_session):
|
||||
"""批次内 MD5 完全匹配应返回 duplicate。"""
|
||||
other = self._make_batch_video("vid-other", "abc123")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc123",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is not None
|
||||
assert result["duplicate"] is True
|
||||
assert result["reason"] == "batch_exact_md5_match"
|
||||
assert result["similarity"] == 1.0
|
||||
assert result["duplicate_of"] == "vid-other"
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_phash_similar(self, deduplicator, mock_session):
|
||||
"""批次内 pHash 距离 < 阈值应判定为重复。"""
|
||||
other = self._make_batch_video("vid-other", "md5_diff", phashes=["abcdef01"])
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="md5_new",
|
||||
keyframe_phashes=["abcdef01"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is not None
|
||||
assert result["duplicate"] is True
|
||||
assert result["reason"] == "batch_phash_similar"
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_excludes_self(self, deduplicator, mock_session):
|
||||
"""批次查重应排除自身视频。"""
|
||||
self_video = self._make_batch_video("vid-self", "abc123")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [self_video]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc123",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_no_match(self, deduplicator, mock_session):
|
||||
"""批次内无重复时应返回 None。"""
|
||||
other = self._make_batch_video("vid-other", "md5_a", phashes=["0000000000000000"])
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="md5_b",
|
||||
keyframe_phashes=["ffffffffffffffff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_empty_returns_none(self, deduplicator, mock_session):
|
||||
"""空批次应返回 None。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = []
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_skips_no_fingerprint(self, deduplicator, mock_session):
|
||||
"""批次内无指纹的视频应被跳过。"""
|
||||
other = MagicMock()
|
||||
other.id = "vid-other"
|
||||
other.video_fingerprint = None
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""标签 CRUD 单元测试(使用 InMemoryTagRepository)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.adapters.in_memory.tag_repository import InMemoryTagRepository
|
||||
from packages.domain import Tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tag_repo():
|
||||
return InMemoryTagRepository()
|
||||
|
||||
|
||||
def test_create_tag(tag_repo):
|
||||
"""测试创建标签。"""
|
||||
tag = Tag.create(user_id="user-1", name="风景")
|
||||
created = tag_repo.create(tag)
|
||||
|
||||
assert created.id == tag.id
|
||||
assert created.user_id == "user-1"
|
||||
assert created.name == "风景"
|
||||
|
||||
|
||||
def test_create_tag_strips_whitespace(tag_repo):
|
||||
"""测试创建标签时自动去除首尾空格。"""
|
||||
tag = Tag.create(user_id="user-1", name=" 风景 ")
|
||||
assert tag.name == "风景"
|
||||
|
||||
|
||||
def test_create_tag_empty_name_raises():
|
||||
"""测试空名称抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user-1", name="")
|
||||
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user-1", name=" ")
|
||||
|
||||
|
||||
def test_get_tag(tag_repo):
|
||||
"""测试按 ID 获取标签。"""
|
||||
tag = Tag.create(user_id="user-1", name="风景")
|
||||
tag_repo.create(tag)
|
||||
|
||||
found = tag_repo.get(tag.id)
|
||||
assert found is not None
|
||||
assert found.name == "风景"
|
||||
|
||||
assert tag_repo.get("nonexistent") is None
|
||||
|
||||
|
||||
def test_find_by_name(tag_repo):
|
||||
"""测试按用户 ID + 名称查找标签。"""
|
||||
tag = Tag.create(user_id="user-1", name="风景")
|
||||
tag_repo.create(tag)
|
||||
|
||||
found = tag_repo.find_by_name("user-1", "风景")
|
||||
assert found is not None
|
||||
assert found.id == tag.id
|
||||
|
||||
# 不同用户同名标签不冲突
|
||||
assert tag_repo.find_by_name("user-2", "风景") is None
|
||||
|
||||
# 不存在的名称
|
||||
assert tag_repo.find_by_name("user-1", "不存在") is None
|
||||
|
||||
|
||||
def test_list_by_user(tag_repo):
|
||||
"""测试按用户列出标签(分页)。"""
|
||||
for i in range(5):
|
||||
tag_repo.create(Tag.create(user_id="user-1", name=f"标签{i}"))
|
||||
# 另一个用户的标签
|
||||
tag_repo.create(Tag.create(user_id="user-2", name="其他用户标签"))
|
||||
|
||||
items = tag_repo.list_by_user("user-1")
|
||||
assert len(items) == 5
|
||||
|
||||
# 分页
|
||||
items_page = tag_repo.list_by_user("user-1", skip=2, limit=2)
|
||||
assert len(items_page) == 2
|
||||
|
||||
|
||||
def test_count_by_user(tag_repo):
|
||||
"""测试按用户统计标签数量。"""
|
||||
for i in range(3):
|
||||
tag_repo.create(Tag.create(user_id="user-1", name=f"标签{i}"))
|
||||
tag_repo.create(Tag.create(user_id="user-2", name="其他"))
|
||||
|
||||
assert tag_repo.count_by_user("user-1") == 3
|
||||
assert tag_repo.count_by_user("user-2") == 1
|
||||
assert tag_repo.count_by_user("user-3") == 0
|
||||
|
||||
|
||||
def test_delete_tag(tag_repo):
|
||||
"""测试删除标签。"""
|
||||
tag = Tag.create(user_id="user-1", name="风景")
|
||||
tag_repo.create(tag)
|
||||
|
||||
assert tag_repo.delete(tag.id) is True
|
||||
assert tag_repo.get(tag.id) is None
|
||||
|
||||
# 重复删除返回 False
|
||||
assert tag_repo.delete(tag.id) is False
|
||||
@@ -0,0 +1,299 @@
|
||||
"""TTS 音频转存 OSS 单元测试。
|
||||
|
||||
验证 TTSWorkflowService 在合成完成后将 CosyVoice 临时音频转存到 OSS,
|
||||
存储永久 URL 到 TTSJob.output_audio_url,OSS key 到 output_audio_key。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
|
||||
def _make_job(**kwargs) -> TTSJob:
|
||||
defaults = {
|
||||
"id": "test_job_001",
|
||||
"user_id": "user_001",
|
||||
"input_text": "测试文本",
|
||||
"voice_id": "voice_001",
|
||||
"voice_model": "",
|
||||
"project_id": "",
|
||||
"voice_clone_profile_id": "",
|
||||
"status": TTSJobStatus.PENDING,
|
||||
"output_audio_url": "",
|
||||
"output_audio_key": "",
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
"error_message": "",
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"metadata": {},
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return TTSJob(**defaults)
|
||||
|
||||
|
||||
def _make_workflow(
|
||||
cosyvoice_service: MagicMock | None = None,
|
||||
repo: MagicMock | None = None,
|
||||
storage: MagicMock | None = None,
|
||||
) -> TTSWorkflowService:
|
||||
if cosyvoice_service is None:
|
||||
cosyvoice_service = MagicMock(spec=CosyVoiceService)
|
||||
if repo is None:
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
if storage is None:
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||||
return TTSWorkflowService(
|
||||
repository=repo,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
storage_service=storage,
|
||||
)
|
||||
|
||||
|
||||
class TestTransferAudioToOSS:
|
||||
"""测试 _transfer_audio_to_oss 方法。"""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_success_download_and_upload(self, mock_httpx: MagicMock) -> None:
|
||||
"""成功下载音频并上传到 OSS,返回永久 URL 和 storage_key。"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"fake audio data"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
|
||||
|
||||
workflow = _make_workflow(storage=storage)
|
||||
url, key = workflow._transfer_audio_to_oss(
|
||||
"https://cosyvoice-temp.com/audio.mp3",
|
||||
"user_001",
|
||||
"job_123",
|
||||
"mp3",
|
||||
)
|
||||
|
||||
assert url == "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
|
||||
assert key == "tts-outputs/user_001/job_123.mp3"
|
||||
|
||||
mock_httpx.get.assert_called_once_with(
|
||||
"https://cosyvoice-temp.com/audio.mp3",
|
||||
timeout=60.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
storage.upload_file.assert_called_once()
|
||||
call_args = storage.upload_file.call_args
|
||||
assert call_args[0][1] == "tts-outputs/user_001/job_123.mp3"
|
||||
assert call_args[1]["content_type"] == "audio/mpeg"
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_download_failure_fallback(self, mock_httpx: MagicMock) -> None:
|
||||
"""下载失败时回退到原始临时 URL,storage_key 为空。"""
|
||||
mock_httpx.get.side_effect = Exception("Network error")
|
||||
|
||||
workflow = _make_workflow()
|
||||
url, key = workflow._transfer_audio_to_oss(
|
||||
"https://cosyvoice-temp.com/audio.mp3",
|
||||
"user_001",
|
||||
"job_123",
|
||||
)
|
||||
|
||||
assert url == "https://cosyvoice-temp.com/audio.mp3"
|
||||
assert key == ""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_upload_failure_fallback(self, mock_httpx: MagicMock) -> None:
|
||||
"""上传 OSS 失败时回退到原始临时 URL。"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"fake audio data"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.side_effect = Exception("OSS bucket error")
|
||||
|
||||
workflow = _make_workflow(storage=storage)
|
||||
url, key = workflow._transfer_audio_to_oss(
|
||||
"https://cosyvoice-temp.com/audio.mp3",
|
||||
"user_001",
|
||||
"job_123",
|
||||
)
|
||||
|
||||
assert url == "https://cosyvoice-temp.com/audio.mp3"
|
||||
assert key == ""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_wav_content_type(self, mock_httpx: MagicMock) -> None:
|
||||
"""wav 格式使用正确的 content_type。"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"fake wav data"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/audio.wav"
|
||||
|
||||
workflow = _make_workflow(storage=storage)
|
||||
workflow._transfer_audio_to_oss(
|
||||
"https://cosyvoice-temp.com/audio.wav",
|
||||
"user_001",
|
||||
"job_456",
|
||||
"wav",
|
||||
)
|
||||
|
||||
call_args = storage.upload_file.call_args
|
||||
assert call_args[1]["content_type"] == "audio/wav"
|
||||
assert call_args[0][1] == "tts-outputs/user_001/job_456.wav"
|
||||
|
||||
|
||||
class TestProcessSynthesisResultWithOSS:
|
||||
"""测试 process_synthesis_result 集成 OSS 转存。"""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_stores_permanent_url_and_key(self, mock_httpx: MagicMock) -> None:
|
||||
"""合成结果存 OSS 永久 URL 和 storage_key。"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"audio bytes"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(repo=repo, storage=storage)
|
||||
result = workflow.process_synthesis_result(
|
||||
"test_job_001",
|
||||
audio_url="https://cosyvoice-temp.com/expiring.mp3",
|
||||
duration=5.0,
|
||||
file_size=50000,
|
||||
)
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED
|
||||
assert result.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||||
assert result.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
|
||||
assert result.duration == 5.0
|
||||
assert result.file_size == 50000
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_fallback_to_temp_url_on_oss_failure(self, mock_httpx: MagicMock) -> None:
|
||||
"""OSS 转存失败时,使用 CosyVoice 临时 URL(不阻塞合成流程)。"""
|
||||
mock_httpx.get.side_effect = Exception("Download failed")
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(repo=repo)
|
||||
result = workflow.process_synthesis_result(
|
||||
"test_job_001",
|
||||
audio_url="https://cosyvoice-temp.com/expiring.mp3",
|
||||
)
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED
|
||||
assert result.output_audio_url == "https://cosyvoice-temp.com/expiring.mp3"
|
||||
assert result.output_audio_key == ""
|
||||
|
||||
|
||||
class TestStartSynthesisSyncWithOSS:
|
||||
"""测试 start_synthesis 同步路径的 OSS 转存。"""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_sync_path_transfers_to_oss(self, mock_httpx: MagicMock) -> None:
|
||||
"""CosyVoice 同步返回 audio_url 时,也走 OSS 转存。"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"sync audio bytes"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "",
|
||||
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
|
||||
"duration": 2.0,
|
||||
"file_size": 20000,
|
||||
"request_id": "req_sync",
|
||||
}
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||||
assert job.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
|
||||
assert job.duration == 2.0
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_sync_path_oss_failure_stores_temp_url(self, mock_httpx: MagicMock) -> None:
|
||||
"""同步路径 OSS 失败时,降级存储临时 URL。"""
|
||||
mock_httpx.get.side_effect = Exception("Network error")
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "",
|
||||
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
|
||||
"duration": 2.0,
|
||||
"file_size": 20000,
|
||||
"request_id": "req_sync",
|
||||
}
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "https://cosyvoice-temp.com/sync.mp3"
|
||||
assert job.output_audio_key == ""
|
||||
|
||||
def test_async_path_no_oss_transfer(self) -> None:
|
||||
"""异步路径(返回 task_id,无 audio_url)不触发 OSS 转存。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "cosy_task_async",
|
||||
"audio_url": "",
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
"request_id": "req_async",
|
||||
}
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
storage = MagicMock()
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||||
job = workflow.start_synthesis("test_job_001")
|
||||
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
# 异步路径不应调用 OSS 上传
|
||||
storage.upload_file.assert_not_called()
|
||||
@@ -0,0 +1,344 @@
|
||||
"""最后一公里:TTS 合成结果保存到配音库 单元测试。
|
||||
|
||||
覆盖:
|
||||
- 正常保存已完成 TTS job 到配音库
|
||||
- 自动携带元信息(音色名、时长、语速等)
|
||||
- 自定义名称
|
||||
- TTS job 不存在 → 404
|
||||
- TTS job 未完成 → 400
|
||||
- 配音库配额已满 → 429
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
from packages.domain.voice_library import VoiceLibraryItem
|
||||
|
||||
|
||||
def _make_completed_job(**kwargs) -> TTSJob:
|
||||
"""构造一个已完成的 TTSJob。"""
|
||||
defaults = {
|
||||
"id": "tts_job_001",
|
||||
"user_id": "user_001",
|
||||
"input_text": "你好世界",
|
||||
"voice_id": "voice_001",
|
||||
"voice_model": "CosyVoice-v1",
|
||||
"project_id": "proj_001",
|
||||
"voice_clone_profile_id": "",
|
||||
"status": TTSJobStatus.COMPLETED,
|
||||
"output_audio_url": "https://oss.example.com/audio.mp3",
|
||||
"output_audio_key": "tts-outputs/user_001/tts_job_001.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
"error_message": "",
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"metadata": {"speed": 1.0, "language": "zh-CN"},
|
||||
"started_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc),
|
||||
"completed_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc),
|
||||
"created_at": datetime(2026, 7, 7, 10, 0, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 7, 7, 10, 0, 5, tzinfo=timezone.utc),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return TTSJob(**defaults)
|
||||
|
||||
|
||||
def _make_voice_library_item(**kwargs) -> VoiceLibraryItem:
|
||||
"""构造一个配音库条目。"""
|
||||
defaults = {
|
||||
"id": "voice_lib_001",
|
||||
"user_id": "user_001",
|
||||
"name": "TTS-tts_job_",
|
||||
"text": "你好世界",
|
||||
"voice_provider": "cosyvoice",
|
||||
"voice_id": "voice_001",
|
||||
"voice_name": "CosyVoice-v1",
|
||||
"audio_url": "https://oss.example.com/audio.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
"status": "completed",
|
||||
"project_id": "proj_001",
|
||||
"tags": [],
|
||||
"metadata_": {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": "tts_job_001",
|
||||
"format": "mp3",
|
||||
"sample_rate": 22050,
|
||||
"speed": 1.0,
|
||||
"language": "zh-CN",
|
||||
},
|
||||
"created_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 7, 7, 10, 1, 0, tzinfo=timezone.utc),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return VoiceLibraryItem(**defaults)
|
||||
|
||||
|
||||
class TestSaveToLibraryMapping:
|
||||
"""测试 TTSJob → VoiceLibraryItem 字段映射。"""
|
||||
|
||||
def test_completed_job_maps_correctly(self) -> None:
|
||||
"""已完成的 TTS job 字段正确映射到配音库条目。"""
|
||||
job = _make_completed_job()
|
||||
|
||||
# 验证 is_completed 属性
|
||||
assert job.is_completed is True
|
||||
|
||||
# 验证关键字段映射
|
||||
assert job.output_audio_url == "https://oss.example.com/audio.mp3"
|
||||
assert job.duration == 5.5
|
||||
assert job.file_size == 88000
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "CosyVoice-v1"
|
||||
assert job.input_text == "你好世界"
|
||||
assert job.format == "mp3"
|
||||
assert job.sample_rate == 22050
|
||||
|
||||
def test_metadata_carries_speed_and_format(self) -> None:
|
||||
"""元信息携带语速、格式等。"""
|
||||
job = _make_completed_job()
|
||||
|
||||
metadata = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
}
|
||||
if job.metadata:
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata[key] = job.metadata[key]
|
||||
|
||||
assert metadata["format"] == "mp3"
|
||||
assert metadata["sample_rate"] == 22050
|
||||
assert metadata["speed"] == 1.0
|
||||
assert metadata["language"] == "zh-CN"
|
||||
assert metadata["source"] == "tts_job"
|
||||
|
||||
def test_name_auto_generated_when_empty(self) -> None:
|
||||
"""未提供名称时自动生成。"""
|
||||
job = _make_completed_job()
|
||||
name = None # 模拟未提供名称
|
||||
generated_name = name or f"TTS-{job.id[:8]}"
|
||||
assert generated_name == "TTS-tts_job_"
|
||||
|
||||
def test_name_uses_custom_when_provided(self) -> None:
|
||||
"""提供自定义名称时使用自定义名称。"""
|
||||
custom_name = "我的配音"
|
||||
generated_name = custom_name or "TTS-fallback"
|
||||
assert generated_name == "我的配音"
|
||||
|
||||
|
||||
class TestSaveToLibraryNotCompleted:
|
||||
"""测试未完成 job 不能保存。"""
|
||||
|
||||
def test_pending_job_not_completed(self) -> None:
|
||||
"""pending 状态的 job 不能保存。"""
|
||||
job = _make_completed_job(status=TTSJobStatus.PENDING)
|
||||
assert job.is_completed is False
|
||||
|
||||
def test_processing_job_not_completed(self) -> None:
|
||||
"""processing 状态的 job 不能保存。"""
|
||||
job = _make_completed_job(status=TTSJobStatus.PROCESSING)
|
||||
assert job.is_completed is False
|
||||
|
||||
def test_failed_job_not_completed(self) -> None:
|
||||
"""failed 状态的 job 不能保存。"""
|
||||
job = _make_completed_job(status=TTSJobStatus.FAILED)
|
||||
assert job.is_completed is False
|
||||
|
||||
def test_completed_without_url_not_completed(self) -> None:
|
||||
"""status=completed 但没有 audio_url 的 job 不算完成。"""
|
||||
job = _make_completed_job(
|
||||
status=TTSJobStatus.COMPLETED,
|
||||
output_audio_url="",
|
||||
)
|
||||
assert job.is_completed is False
|
||||
|
||||
|
||||
class TestSaveToLibraryQuota:
|
||||
"""测试配额检查。"""
|
||||
|
||||
def test_quota_exceeded_raises(self) -> None:
|
||||
"""配音库配额已满时抛出 QuotaExceededError。"""
|
||||
from packages.application.voice_library.use_cases import QuotaExceededError
|
||||
|
||||
error = QuotaExceededError(dimension="max_voiceovers", limit=10, used=10)
|
||||
assert "10/10" in str(error)
|
||||
|
||||
def test_quota_under_limit_passes(self) -> None:
|
||||
"""配额未满时不报错。"""
|
||||
from packages.domain.quota import QuotaDimension, quota_checker
|
||||
|
||||
result = quota_checker.check("free", QuotaDimension.MAX_VOICEOVERS.value, 5)
|
||||
assert result.allowed is True
|
||||
|
||||
|
||||
class TestSaveToLibraryCreateCommand:
|
||||
"""测试 CreateVoiceLibraryCommand 构建。"""
|
||||
|
||||
def test_command_fields_from_tts_job(self) -> None:
|
||||
"""从 TTSJob 构建的 Command 字段正确。"""
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
|
||||
job = _make_completed_job()
|
||||
metadata_ = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
"speed": 1.0,
|
||||
"language": "zh-CN",
|
||||
}
|
||||
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=job.user_id,
|
||||
name=f"TTS-{job.id[:8]}",
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model,
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id,
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
)
|
||||
|
||||
assert command.user_id == "user_001"
|
||||
assert command.name == "TTS-tts_job_"
|
||||
assert command.text == "你好世界"
|
||||
assert command.voice_provider == "cosyvoice"
|
||||
assert command.voice_id == "voice_001"
|
||||
assert command.voice_name == "CosyVoice-v1"
|
||||
assert command.audio_url == "https://oss.example.com/audio.mp3"
|
||||
assert command.duration == 5.5
|
||||
assert command.file_size == 88000
|
||||
assert command.status == "completed"
|
||||
assert command.project_id == "proj_001"
|
||||
assert command.metadata_["source"] == "tts_job"
|
||||
|
||||
def test_command_with_empty_project_id(self) -> None:
|
||||
"""project_id 为空时传空字符串。"""
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
|
||||
job = _make_completed_job(project_id="")
|
||||
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=job.user_id,
|
||||
name="test",
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name="",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
assert command.project_id == ""
|
||||
|
||||
def test_command_voice_name_fallback(self) -> None:
|
||||
"""voice_model 为空时 voice_name 回退为空字符串。"""
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
|
||||
job = _make_completed_job(voice_model="")
|
||||
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=job.user_id,
|
||||
name="test",
|
||||
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="",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
assert command.voice_name == ""
|
||||
|
||||
|
||||
class TestSaveToLibraryUseCase:
|
||||
"""测试 CreateVoiceLibraryUseCase 调用。"""
|
||||
|
||||
def test_use_case_creates_item(self) -> None:
|
||||
"""UseCase 正确创建配音库条目。"""
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import CreateVoiceLibraryUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
repo.count_by_user.return_value = 0 # 配额未满
|
||||
|
||||
expected_item = _make_voice_library_item()
|
||||
repo.create.side_effect = lambda item: item
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(repo)
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id="user_001",
|
||||
name="test",
|
||||
text="你好",
|
||||
voice_provider="cosyvoice",
|
||||
voice_id="v1",
|
||||
voice_name="Voice1",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
duration=3.0,
|
||||
file_size=5000,
|
||||
status="completed",
|
||||
project_id="",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
item = use_case.execute(command, plan_name="free")
|
||||
|
||||
repo.create.assert_called_once()
|
||||
assert item is not None
|
||||
|
||||
def test_use_case_quota_exceeded(self) -> None:
|
||||
"""UseCase 配额已满时抛出 QuotaExceededError。"""
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
|
||||
repo = MagicMock()
|
||||
repo.count_by_user.return_value = 100 # 超过 premium 配额
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(repo)
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id="user_001",
|
||||
name="test",
|
||||
text="你好",
|
||||
voice_provider="cosyvoice",
|
||||
voice_id="v1",
|
||||
voice_name="Voice1",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
duration=3.0,
|
||||
file_size=5000,
|
||||
status="completed",
|
||||
project_id="",
|
||||
tags=[],
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
with pytest.raises(QuotaExceededError):
|
||||
use_case.execute(command, plan_name="premium")
|
||||
@@ -0,0 +1,485 @@
|
||||
"""P1 长文本分段合成单元测试。
|
||||
|
||||
覆盖:
|
||||
- text_splitter.split_text 分段逻辑
|
||||
- audio_merger.AudioMerger 合并逻辑
|
||||
- workflow 分段合成路径(同步 / 异步 / 失败)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
# ── text_splitter ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSplitText:
|
||||
"""测试文本分段工具。"""
|
||||
|
||||
def test_short_text_no_split(self) -> None:
|
||||
"""短文本不拆分。"""
|
||||
assert split_text("你好世界", max_chars=500) == ["你好世界"]
|
||||
|
||||
def test_empty_text(self) -> None:
|
||||
"""空文本返回空列表。"""
|
||||
assert split_text("") == []
|
||||
assert split_text(" ") == []
|
||||
|
||||
def test_exact_threshold(self) -> None:
|
||||
"""恰好等于阈值不拆分。"""
|
||||
text = "a" * 500
|
||||
assert split_text(text, max_chars=500) == [text]
|
||||
|
||||
def test_split_at_sentence_boundary(self) -> None:
|
||||
"""在句子边界处分段。"""
|
||||
text = "第一句话。" * 60 # 300 chars
|
||||
text += "第二句话。" * 60 # 300 chars → total 600
|
||||
segments = split_text(text, max_chars=500)
|
||||
assert len(segments) >= 2
|
||||
for seg in segments:
|
||||
assert len(seg) <= 500
|
||||
|
||||
def test_split_at_newline(self) -> None:
|
||||
"""在换行符处分段。"""
|
||||
text = "段落一\n" * 100 # 300 chars
|
||||
text += "段落二\n" * 100 # 300 chars
|
||||
segments = split_text(text, max_chars=500)
|
||||
assert len(segments) >= 2
|
||||
|
||||
def test_long_sentence_hard_split(self) -> None:
|
||||
"""超长句子硬切。"""
|
||||
text = "a" * 1200
|
||||
segments = split_text(text, max_chars=500)
|
||||
assert len(segments) >= 3
|
||||
for seg in segments:
|
||||
assert len(seg) <= 500
|
||||
|
||||
def test_merge_short_segments(self) -> None:
|
||||
"""短段合并减少 API 调用。"""
|
||||
# 多个短句子应该被合并
|
||||
text = "你好。" * 120 # 360 chars, each sentence 3 chars
|
||||
segments = split_text(text, max_chars=500)
|
||||
# 短段应该被合并,段数应该比较少
|
||||
assert len(segments) < 120
|
||||
|
||||
def test_preserves_order(self) -> None:
|
||||
"""分段保持原始顺序。"""
|
||||
text = "第一段。第二段。第三段。" + "x" * 490
|
||||
segments = split_text(text, max_chars=500)
|
||||
# 第一个段应该以 "第一段" 开头
|
||||
assert segments[0].startswith("第一段")
|
||||
|
||||
|
||||
# ── audio_merger ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAudioMerger:
|
||||
"""测试 FFmpeg 音频合并器。"""
|
||||
|
||||
def test_empty_list_raises(self) -> None:
|
||||
"""空列表抛出 AudioMergeError。"""
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError, match="没有可合并"):
|
||||
merger.merge([])
|
||||
|
||||
def test_single_file_returns_bytes(self) -> None:
|
||||
"""单文件直接返回内容。"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"fake audio content")
|
||||
f.flush()
|
||||
path = f.name
|
||||
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
data = merger.merge([path])
|
||||
assert data == b"fake audio content"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
@patch("packages.application.tts_job.audio_merger.subprocess.run")
|
||||
def test_ffmpeg_called_correctly(self, mock_run: MagicMock) -> None:
|
||||
"""多文件调用 FFmpeg concat。"""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
# 创建临时文件
|
||||
paths = []
|
||||
for i in range(3):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"audio")
|
||||
paths.append(f.name)
|
||||
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
# Mock open for reading the merged output
|
||||
with patch("builtins.open", create=True) as mock_open:
|
||||
mock_open.return_value.__enter__ = lambda s: s
|
||||
mock_open.return_value.read = lambda: b"merged audio"
|
||||
try:
|
||||
merger.merge(paths, output_format="mp3")
|
||||
except (FileNotFoundError, OSError):
|
||||
pass # Expected since we're mocking
|
||||
|
||||
# 验证 FFmpeg 被调用
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-f" in cmd
|
||||
assert "concat" in cmd
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
@patch("packages.application.tts_job.audio_merger.subprocess.run")
|
||||
def test_ffmpeg_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
"""FFmpeg 失败抛出 AudioMergeError。"""
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="error details")
|
||||
|
||||
paths = []
|
||||
for i in range(2):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
f.write(b"audio")
|
||||
paths.append(f.name)
|
||||
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError, match="FFmpeg 合并失败"):
|
||||
merger.merge(paths)
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
|
||||
# ── workflow segment methods ─────────────────────────────────
|
||||
|
||||
|
||||
def _make_job(**kwargs) -> TTSJob:
|
||||
defaults = {
|
||||
"id": "test_job_seg",
|
||||
"user_id": "user_001",
|
||||
"input_text": "x" * 600, # > 500 threshold
|
||||
"voice_id": "voice_001",
|
||||
"voice_model": "",
|
||||
"project_id": "",
|
||||
"voice_clone_profile_id": "",
|
||||
"status": TTSJobStatus.PENDING,
|
||||
"output_audio_url": "",
|
||||
"output_audio_key": "",
|
||||
"duration": 0.0,
|
||||
"file_size": 0,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
"error_message": "",
|
||||
"retry_count": 0,
|
||||
"max_retries": 3,
|
||||
"metadata": {},
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return TTSJob(**defaults)
|
||||
|
||||
|
||||
def _make_workflow(
|
||||
cosyvoice_service: MagicMock | None = None,
|
||||
repo: MagicMock | None = None,
|
||||
storage: MagicMock | None = None,
|
||||
) -> TTSWorkflowService:
|
||||
if cosyvoice_service is None:
|
||||
cosyvoice_service = MagicMock(spec=CosyVoiceService)
|
||||
if repo is None:
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
if storage is None:
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
|
||||
return TTSWorkflowService(
|
||||
repository=repo,
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
storage_service=storage,
|
||||
)
|
||||
|
||||
|
||||
class TestStartSegmentSynthesis:
|
||||
"""测试 _start_segment_synthesis 分段合成入口。"""
|
||||
|
||||
def test_short_text_no_segment(self) -> None:
|
||||
"""短文本不触发分段。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.return_value = {
|
||||
"task_id": "task_1",
|
||||
"audio_url": "",
|
||||
}
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job(input_text="短文本")
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
job = workflow.start_synthesis("test_job_seg")
|
||||
|
||||
# 短文本走普通路径,不调用分段
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_long_text_sync_segments(self, mock_httpx: MagicMock) -> None:
|
||||
"""长文本同步分段:所有段立即返回 audio_url,直接合并。"""
|
||||
# Mock 分段音频下载
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"segment audio"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
# 每个分段都同步返回 audio_url
|
||||
service.submit_synthesize_task.side_effect = [
|
||||
{"task_id": "", "audio_url": "https://temp.com/seg1.mp3", "duration": 2.0, "file_size": 1000},
|
||||
{"task_id": "", "audio_url": "https://temp.com/seg2.mp3", "duration": 3.0, "file_size": 1500},
|
||||
]
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||||
|
||||
# Mock AudioMerger 避免真实 FFmpeg 调用
|
||||
with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
|
||||
mock_merger = MagicMock()
|
||||
mock_merger.merge.return_value = b"merged audio data"
|
||||
MockMerger.return_value = mock_merger
|
||||
|
||||
job = workflow.start_synthesis("test_job_seg")
|
||||
|
||||
assert job.status == TTSJobStatus.COMPLETED
|
||||
assert job.output_audio_url == "https://oss.example.com/merged.mp3"
|
||||
assert job.duration == 5.0 # 2.0 + 3.0
|
||||
|
||||
def test_long_text_async_segments(self) -> None:
|
||||
"""长文本异步分段:返回 task_id,存入 metadata。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
# 每个分段返回 task_id(异步)
|
||||
service.submit_synthesize_task.side_effect = [
|
||||
{"task_id": "seg_task_1", "audio_url": "", "duration": 0.0, "file_size": 0},
|
||||
{"task_id": "seg_task_2", "audio_url": "", "duration": 0.0, "file_size": 0},
|
||||
]
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
job = workflow.start_synthesis("test_job_seg")
|
||||
|
||||
assert job.status == TTSJobStatus.PROCESSING
|
||||
assert "segment_task_ids" in job.metadata
|
||||
assert job.metadata["segment_task_ids"] == ["seg_task_1", "seg_task_2"]
|
||||
|
||||
def test_segment_submit_failure_marks_failed(self) -> None:
|
||||
"""分段提交失败时标记 job 为 failed。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.submit_synthesize_task.side_effect = CosyVoiceError("API error")
|
||||
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = _make_job()
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
job = workflow.start_synthesis("test_job_seg")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
|
||||
|
||||
class TestUploadMergedToOSS:
|
||||
"""测试 _upload_merged_to_oss 辅助方法。"""
|
||||
|
||||
def test_success(self) -> None:
|
||||
"""成功上传返回 URL 和 key。"""
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
|
||||
|
||||
workflow = _make_workflow(storage=storage)
|
||||
url, key = workflow._upload_merged_to_oss(b"audio data", "user_001", "job_001", "mp3")
|
||||
|
||||
assert url == "https://oss.example.com/merged.mp3"
|
||||
assert key == "tts-outputs/user_001/job_001.mp3"
|
||||
storage.upload_file.assert_called_once()
|
||||
call_args = storage.upload_file.call_args
|
||||
assert call_args[1]["content_type"] == "audio/mpeg"
|
||||
|
||||
def test_failure_returns_empty(self) -> None:
|
||||
"""上传失败返回空字符串。"""
|
||||
storage = MagicMock()
|
||||
storage.upload_file.side_effect = Exception("OSS error")
|
||||
|
||||
workflow = _make_workflow(storage=storage)
|
||||
url, key = workflow._upload_merged_to_oss(b"audio data", "user_001", "job_001", "mp3")
|
||||
|
||||
assert url == ""
|
||||
assert key == ""
|
||||
|
||||
|
||||
class TestHandleSegmentFailure:
|
||||
"""测试 _handle_segment_failure。"""
|
||||
|
||||
def test_marks_job_failed(self) -> None:
|
||||
"""标记 job 为 failed 并更新。"""
|
||||
repo = MagicMock()
|
||||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(repo=repo)
|
||||
workflow._handle_segment_failure(job, "分段 1 合成失败")
|
||||
|
||||
assert job.status == TTSJobStatus.FAILED
|
||||
assert "分段 1 合成失败" in job.error_message
|
||||
repo.update.assert_called_once()
|
||||
|
||||
|
||||
class TestPollSegmentTasks:
|
||||
"""测试 _poll_segment_tasks 异步轮询。"""
|
||||
|
||||
@patch("packages.application.tts_job.workflow.time")
|
||||
@patch("packages.application.tts_job.workflow.httpx")
|
||||
def test_all_segments_done(self, mock_httpx: MagicMock, mock_time: MagicMock) -> None:
|
||||
"""所有分段完成后合并并标记完成。"""
|
||||
# Mock time.monotonic 让循环只执行一次
|
||||
mock_time.monotonic.side_effect = [0.0, 1.0, 2.0]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
# Mock 下载分段音频
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"seg audio"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.poll_synthesize_task.side_effect = [
|
||||
{"audio_url": "https://temp.com/seg1.mp3", "duration": 2.0, "file_size": 100},
|
||||
{"audio_url": "https://temp.com/seg2.mp3", "duration": 3.0, "file_size": 200},
|
||||
]
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/merged.mp3"
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(
|
||||
status=TTSJobStatus.PROCESSING,
|
||||
metadata={
|
||||
"segment_task_ids": ["task_1", "task_2"],
|
||||
"segment_audio_urls": ["", ""],
|
||||
"segment_format": "mp3",
|
||||
},
|
||||
)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.AudioMerger") as MockMerger:
|
||||
mock_merger = MagicMock()
|
||||
mock_merger.merge.return_value = b"merged data"
|
||||
MockMerger.return_value = mock_merger
|
||||
|
||||
result = workflow._poll_segment_tasks(job)
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED
|
||||
|
||||
@patch("packages.application.tts_job.workflow.time")
|
||||
def test_segment_poll_failure(self, mock_time: MagicMock) -> None:
|
||||
"""分段轮询失败时标记 job failed。"""
|
||||
mock_time.monotonic.side_effect = [0.0, 1.0]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.poll_synthesize_task.side_effect = CosyVoiceError("Poll failed")
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(
|
||||
status=TTSJobStatus.PROCESSING,
|
||||
metadata={
|
||||
"segment_task_ids": ["task_1"],
|
||||
"segment_audio_urls": [""],
|
||||
},
|
||||
)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
result = workflow._poll_segment_tasks(job)
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED
|
||||
|
||||
|
||||
class TestPollAndProcessSynthesisSegmentDetection:
|
||||
"""测试 poll_and_process_synthesis 正确识别分段任务。"""
|
||||
|
||||
def test_detects_segment_task(self) -> None:
|
||||
"""metadata 中有 segment_task_ids 时走分段轮询路径。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(
|
||||
status=TTSJobStatus.PROCESSING,
|
||||
metadata={
|
||||
"segment_task_ids": ["task_1", "task_2"],
|
||||
"segment_audio_urls": ["", ""],
|
||||
},
|
||||
)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||||
|
||||
with patch.object(workflow, "_poll_segment_tasks") as mock_poll:
|
||||
mock_poll.return_value = job
|
||||
workflow.poll_and_process_synthesis("test_job_seg")
|
||||
mock_poll.assert_called_once()
|
||||
|
||||
def test_normal_task_no_segment(self) -> None:
|
||||
"""普通任务不走分段路径。"""
|
||||
service = MagicMock(spec=CosyVoiceService)
|
||||
service.poll_synthesize_task.return_value = {
|
||||
"audio_url": "https://temp.com/audio.mp3",
|
||||
"duration": 5.0,
|
||||
"file_size": 5000,
|
||||
}
|
||||
|
||||
repo = MagicMock()
|
||||
job = _make_job(
|
||||
status=TTSJobStatus.PROCESSING,
|
||||
metadata={"cosyvoice_task_id": "task_normal"},
|
||||
)
|
||||
repo.get.return_value = job
|
||||
repo.update.side_effect = lambda j: j
|
||||
|
||||
storage = MagicMock()
|
||||
storage.upload_file.return_value = "https://oss.example.com/audio.mp3"
|
||||
|
||||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.httpx") as mock_httpx:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"audio"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
result = workflow.poll_and_process_synthesis("test_job_seg")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED
|
||||
@@ -0,0 +1,239 @@
|
||||
"""P2 WebSocket 流式合成单元测试。
|
||||
|
||||
覆盖:
|
||||
- TTSStreamingService 流式合成逻辑
|
||||
- 短文本流式合成
|
||||
- 长文本分段流式合成
|
||||
- 错误处理
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
TTSStreamingError,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
|
||||
class MockWebSocket:
|
||||
"""Mock WebSocket for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent_json = []
|
||||
self.sent_bytes = []
|
||||
self.accepted = False
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def send_json(self, data):
|
||||
self.sent_json.append(data)
|
||||
|
||||
async def send_bytes(self, data):
|
||||
self.sent_bytes.append(data)
|
||||
|
||||
async def receive_json(self):
|
||||
return {}
|
||||
|
||||
|
||||
class TestTTSStreamingService:
|
||||
"""测试 TTS 流式合成服务。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_returns_error(self):
|
||||
"""空文本返回错误。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "", "voice_id": "test_voice"}
|
||||
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
assert len(ws.sent_json) == 1
|
||||
assert ws.sent_json[0]["type"] == "error"
|
||||
assert "文本不能为空" in ws.sent_json[0]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_too_long_returns_error(self):
|
||||
"""超长文本返回错误。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "x" * 10001, "voice_id": "test_voice"}
|
||||
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
assert len(ws.sent_json) == 1
|
||||
assert ws.sent_json[0]["type"] == "error"
|
||||
assert "文本过长" in ws.sent_json[0]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_stream_success(self):
|
||||
"""短文本流式合成成功。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
cosyvoice.submit_synthesize_task.return_value = {
|
||||
"task_id": "task_1",
|
||||
"audio_url": "https://temp.com/audio.mp3",
|
||||
"duration": 5.0,
|
||||
"file_size": 10000,
|
||||
}
|
||||
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "测试文本", "voice_id": "test_voice", "format": "mp3"}
|
||||
|
||||
with patch.object(service, "_download_audio", return_value=b"fake audio data"):
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
# 验证发送了 started 帧
|
||||
assert ws.sent_json[0]["type"] == "started"
|
||||
assert ws.sent_json[0]["segment_count"] == 1
|
||||
|
||||
# 验证发送了二进制音频数据
|
||||
assert len(ws.sent_bytes) > 0
|
||||
|
||||
# 验证发送了 done 帧
|
||||
assert ws.sent_json[-1]["type"] == "done"
|
||||
assert ws.sent_json[-1]["format"] == "mp3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_cosyvoice_error(self):
|
||||
"""短文本合成时 CosyVoice 报错。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("API error")
|
||||
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "测试文本", "voice_id": "test_voice"}
|
||||
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
# 验证发送了 started 帧和 error 帧
|
||||
assert len(ws.sent_json) == 2
|
||||
assert ws.sent_json[0]["type"] == "started"
|
||||
assert ws.sent_json[1]["type"] == "error"
|
||||
assert "API error" in ws.sent_json[1]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_no_audio_url(self):
|
||||
"""短文本合成未返回 audio_url。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
cosyvoice.submit_synthesize_task.return_value = {
|
||||
"task_id": "task_1",
|
||||
"audio_url": "",
|
||||
}
|
||||
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "测试文本", "voice_id": "test_voice"}
|
||||
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
# 验证发送了 started 帧和 error 帧
|
||||
assert len(ws.sent_json) == 2
|
||||
assert ws.sent_json[0]["type"] == "started"
|
||||
assert ws.sent_json[1]["type"] == "error"
|
||||
assert "未返回音频 URL" in ws.sent_json[1]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_text_stream_success(self):
|
||||
"""长文本分段流式合成成功。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
# 每个分段都返回 audio_url
|
||||
cosyvoice.submit_synthesize_task.side_effect = [
|
||||
{"task_id": "", "audio_url": "https://temp.com/seg1.mp3", "duration": 2.0},
|
||||
{"task_id": "", "audio_url": "https://temp.com/seg2.mp3", "duration": 3.0},
|
||||
]
|
||||
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
# 超过 500 字的文本
|
||||
params = {"text": "x" * 600, "voice_id": "test_voice", "format": "mp3"}
|
||||
|
||||
with patch.object(service, "_download_audio", return_value=b"segment audio"):
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
# 验证发送了 started 帧(多段)
|
||||
assert ws.sent_json[0]["type"] == "started"
|
||||
assert ws.sent_json[0]["segment_count"] >= 2
|
||||
|
||||
# 验证发送了 segment_done 帧
|
||||
segment_done_count = sum(1 for msg in ws.sent_json if msg["type"] == "segment_done")
|
||||
assert segment_done_count >= 2
|
||||
|
||||
# 验证发送了 done 帧
|
||||
assert ws.sent_json[-1]["type"] == "done"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_text_segment_failure(self):
|
||||
"""长文本分段合成失败。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Segment error")
|
||||
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
params = {"text": "x" * 600, "voice_id": "test_voice"}
|
||||
|
||||
await service.synthesize_and_stream(ws, params)
|
||||
|
||||
# 验证发送了错误帧
|
||||
error_msgs = [msg for msg in ws.sent_json if msg["type"] == "error"]
|
||||
assert len(error_msgs) > 0
|
||||
assert "合成失败" in error_msgs[0]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_audio_chunks(self):
|
||||
"""音频分块推送。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
audio_data = b"x" * 10000 # 10KB
|
||||
|
||||
total = await service._stream_audio_chunks(ws, audio_data)
|
||||
|
||||
assert total == 10000
|
||||
# 验证分块发送(4KB per chunk)
|
||||
assert len(ws.sent_bytes) == 3 # 4096 + 4096 + 1808
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_json_suppresses_exceptions(self):
|
||||
"""_send_json 抑制异常。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
ws = MockWebSocket()
|
||||
ws.send_json = AsyncMock(side_effect=Exception("Send failed"))
|
||||
|
||||
# 不应该抛出异常
|
||||
await service._send_json(ws, {"type": "test"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_audio(self):
|
||||
"""下载音频数据。"""
|
||||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
service = TTSStreamingService(cosyvoice)
|
||||
|
||||
with patch("packages.application.tts_job.streaming_service.httpx") as mock_httpx:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.content = b"audio data"
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_httpx.get.return_value = mock_resp
|
||||
|
||||
result = service._download_audio("https://example.com/audio.mp3")
|
||||
|
||||
assert result == b"audio data"
|
||||
mock_httpx.get.assert_called_once()
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
测试视频上传失败排查修复:
|
||||
1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致
|
||||
2. generated_video_repository list 方法无 N+1 查询
|
||||
3. 成片库 API 端点可正常返回数据
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ───
|
||||
|
||||
|
||||
class TestChunkedUploadMIMEConsistency:
|
||||
"""chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。"""
|
||||
|
||||
def _get_upload_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def _get_chunked_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def test_chunked_upload_has_all_upload_mime_types(self):
|
||||
"""chunked_upload 白名单必须覆盖 upload.py 的全部类型。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
missing = upload_types - chunked_types
|
||||
assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}"
|
||||
|
||||
def test_chunked_upload_supports_video_mpeg(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/mpeg" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_matroska(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/x-matroska" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_3gpp(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/3gpp" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_flac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/flac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_aac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/aac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_m4a(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/x-m4a" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_webm(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/webm" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_extra_image_types(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "image/bmp" in ALLOWED_MIME_TYPES
|
||||
assert "image/tiff" in ALLOWED_MIME_TYPES
|
||||
assert "image/svg+xml" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_both_have_same_core_video_types(self):
|
||||
"""两条路径的核心视频类型必须一致。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
core_video = {"video/mp4", "video/quicktime", "video/webm"}
|
||||
for vt in core_video:
|
||||
assert vt in upload_types, f"upload.py 缺少 {vt}"
|
||||
assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}"
|
||||
|
||||
|
||||
# ─── 2. GeneratedVideo Repository N+1 修复验证 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoRepositoryNoNPlus1:
|
||||
"""list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。"""
|
||||
|
||||
def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"):
|
||||
model = MagicMock()
|
||||
model.id = video_id
|
||||
model.project_id = project_id
|
||||
model.generation_task_id = task_id
|
||||
model.name = f"video-{video_id}.mp4"
|
||||
model.file_url = f"https://oss.example.com/{video_id}.mp4"
|
||||
model.file_size = 1024
|
||||
model.duration = 5.0
|
||||
model.thumbnail_url = None
|
||||
model.width = 1280
|
||||
model.height = 720
|
||||
model.fps = 25.0
|
||||
model.status = "completed"
|
||||
model.review_status = "pending_review"
|
||||
model.generation_params = json.dumps({"mode": "one_take"})
|
||||
model.video_fingerprint = None
|
||||
model.is_duplicate = False
|
||||
model.duplicate_of = None
|
||||
model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
return model
|
||||
|
||||
def test_list_by_project_uses_to_domain(self):
|
||||
"""list_by_project 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}") for i in range(5)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_project("proj-1")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 5
|
||||
assert all(v.id.startswith("v") for v in result)
|
||||
|
||||
def test_list_by_generation_task_uses_to_domain(self):
|
||||
"""list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_generation_task("task-42")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
def test_list_by_project_returns_empty_when_no_videos(self):
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
result = repo.list_by_project("empty-project")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─── 3. 成片库 API 端点可用性确认 ───
|
||||
|
||||
|
||||
class TestGeneratedVideosAPIAvailability:
|
||||
"""确认成片库 API 路由注册正确,端点可正常返回数据。"""
|
||||
|
||||
def test_generated_videos_routes_registered(self):
|
||||
"""成片库路由已注册到 router。"""
|
||||
from apps.api.app.api.router import api_router
|
||||
|
||||
# 检查 router 包含 generated-videos 路径
|
||||
routes = [r for r in api_router.routes if hasattr(r, "path")]
|
||||
gv_routes = [r for r in routes if "generated-videos" in r.path]
|
||||
assert len(gv_routes) > 0, "generated-videos 路由未注册"
|
||||
|
||||
def test_generated_videos_list_endpoint_exists(self):
|
||||
"""GET /generated-videos 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "" in paths, "GET /generated-videos 列表端点不存在"
|
||||
|
||||
def test_generated_videos_detail_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id} 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在"
|
||||
|
||||
def test_generated_videos_review_endpoint_exists(self):
|
||||
"""PATCH /generated-videos/{video_id}/review 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/review" in paths, "PATCH review 端点不存在"
|
||||
|
||||
def test_generated_videos_download_url_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id}/download-url 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/download-url" in paths, "download-url 端点不存在"
|
||||
|
||||
def test_generated_video_response_schema_complete(self):
|
||||
"""GeneratedVideoResponse 包含所有必要字段。"""
|
||||
from apps.api.app.schemas.generated_video import GeneratedVideoResponse
|
||||
|
||||
fields = GeneratedVideoResponse.model_fields
|
||||
required_fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"name",
|
||||
"file_url",
|
||||
"status",
|
||||
"review_status",
|
||||
"download_url",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}"
|
||||
|
||||
def test_list_generated_videos_response_schema(self):
|
||||
"""ListGeneratedVideosResponse 包含 items 列表。"""
|
||||
from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse
|
||||
|
||||
fields = ListGeneratedVideosResponse.model_fields
|
||||
assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段"
|
||||
|
||||
def test_generation_task_results_endpoint_exists(self):
|
||||
"""GET /generation/tasks/{task_id}/results 端点存在。"""
|
||||
from apps.api.app.api.routes.generation_tasks import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在"
|
||||
|
||||
|
||||
# ─── 4. GeneratedVideo Use Cases 可用性 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoUseCases:
|
||||
"""确认成片库 Use Case 层可正常工作。"""
|
||||
|
||||
def _make_video(self, video_id: str, project_id: str = "proj-1"):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
generation_task_id="task-1",
|
||||
name=f"video-{video_id}.mp4",
|
||||
file_url=f"https://oss.example.com/{video_id}.mp4",
|
||||
file_size=1024,
|
||||
duration=5.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
def test_list_generated_videos_use_case(self):
|
||||
"""ListGeneratedVideosUseCase 可正常列出视频。"""
|
||||
from packages.application import ListGeneratedVideosUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(3)]
|
||||
repo.list_by_project.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosUseCase(repo)
|
||||
result = use_case.execute("proj-1")
|
||||
|
||||
repo.list_by_project.assert_called_once_with("proj-1")
|
||||
assert len(result) == 3
|
||||
|
||||
def test_get_generated_video_use_case(self):
|
||||
"""GetGeneratedVideoUseCase 可正常获取单个视频。"""
|
||||
from packages.application import GetGeneratedVideoUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
video = self._make_video("v1")
|
||||
repo.get.return_value = video
|
||||
|
||||
use_case = GetGeneratedVideoUseCase(repo)
|
||||
result = use_case.execute("v1")
|
||||
|
||||
repo.get.assert_called_once_with("v1")
|
||||
assert result.id == "v1"
|
||||
|
||||
def test_list_by_task_use_case(self):
|
||||
"""ListGeneratedVideosByTaskUseCase 可按任务列出视频。"""
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(2)]
|
||||
repo.list_by_generation_task.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosByTaskUseCase(repo)
|
||||
result = use_case.execute("task-1")
|
||||
|
||||
repo.list_by_generation_task.assert_called_once_with("task-1")
|
||||
assert len(result) == 2
|
||||
Reference in New Issue
Block a user