Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf572b225 | |||
| 375692e838 | |||
| 2f237c80cf | |||
| ee916acb1b | |||
| f9f7eae37d | |||
| 5a9b6d9890 | |||
| 27cb7381ad | |||
| 8cec4069fa | |||
| 2ee710ca16 | |||
| 5c5aabd311 | |||
| 5c260c1c87 | |||
| b5ad67830e | |||
| 545293fe5c | |||
| df7dafb8c4 | |||
| b701182f6b | |||
| 5aa8a96a43 | |||
| ad3e2f4834 | |||
| 72b62a60c4 | |||
| 26cc6a88b7 | |||
| 9d3a8852e5 | |||
| f3ca061055 | |||
| c14fd21eaa | |||
| ac724a07a0 | |||
| 833c604fd7 | |||
| b42c81e690 | |||
| 550fecdd47 | |||
| 6a873e057b | |||
| 99227bd9eb | |||
| a78e8c3480 | |||
| 62ebac4d21 | |||
| 09869ce69c | |||
| 18a1f4e43d | |||
| cd7e75a845 | |||
| 4016f0eca8 | |||
| adb3a2e269 | |||
| fcdf693707 | |||
| 3e48248c83 | |||
| 0f6496a945 | |||
| 7421a76e5a | |||
| 8aaef6e964 | |||
| 614a8e95be |
@@ -10,6 +10,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00,每日全量CI回归
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -22,6 +22,15 @@ jobs:
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: "🔍 脚本语法自检"
|
||||
shell: bash
|
||||
run: |
|
||||
ERROR=0
|
||||
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
||||
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
||||
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
|
||||
echo "✅ 脚本语法自检通过"
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
@@ -30,153 +39,7 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 还有CI在跑 → 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
|
||||
bash scripts/ci/auto_approve.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -193,7 +56,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -202,6 +65,31 @@ jobs:
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: "🔍 脚本语法自检(防止脚本bug导致所有PR挂掉)"
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== CI脚本语法自检 ==="
|
||||
ERROR=0
|
||||
for f in scripts/ci/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! bash -n "$f" 2>&1; then
|
||||
echo "FAIL: $f"
|
||||
ERROR=1
|
||||
fi
|
||||
done
|
||||
for f in scripts/ci/*.py; do
|
||||
[ -f "$f" ] || continue
|
||||
if ! python3 -m py_compile "$f" 2>&1; then
|
||||
echo "FAIL: $f"
|
||||
ERROR=1
|
||||
fi
|
||||
done
|
||||
if [ "$ERROR" -ne 0 ]; then
|
||||
echo "❌ 脚本语法自检失败"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ 所有CI脚本语法自检通过"
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
@@ -211,148 +99,7 @@ jobs:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 405连续计数器
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 18); do # 短作业模式:最多等3分钟(18次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
||||
exit 0
|
||||
bash scripts/ci/auto_merge.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
"""#P3-2 - 视频分享表 video_shares
|
||||
|
||||
Revision ID: 050
|
||||
Revises: 049
|
||||
Create Date: 2026-07-22
|
||||
|
||||
Changes:
|
||||
1. 新建 video_shares 表,支持视频匿名分享链接
|
||||
2. share_token 唯一索引,用于公开分享URL
|
||||
3. 支持密码保护、有效期、浏览/下载计数
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "050_video_shares"
|
||||
down_revision = "049_wechat_login_phone"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查表是否已存在(幂等)
|
||||
result = conn.execute(sa.text("SELECT to_regclass('public.video_shares')"))
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"video_shares",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("video_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("user_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("share_token", sa.String(16), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String(255), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("view_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("download_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("video_shares")
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""#632 - 一键生成输出分辨率可配置
|
||||
|
||||
Revision ID: 051
|
||||
Revises: 050
|
||||
Create Date: 2026-07-23
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 resolution 字段,存储用户指定的输出分辨率(如 "1280x720")
|
||||
2. 为空时使用默认值(1280x720)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "051_generation_task_resolution"
|
||||
down_revision = "050_video_shares"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
# 检查列是否已存在(幂等)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("resolution", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if context.get_context().dialect.name == "postgresql":
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is None:
|
||||
return
|
||||
|
||||
op.drop_column("generation_tasks", "resolution")
|
||||
@@ -9,8 +9,10 @@ from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.share import router as share_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
|
||||
@@ -104,6 +106,10 @@ api_router.include_router(
|
||||
videos_router,
|
||||
tags=["VideoCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
share_router,
|
||||
tags=["Share"],
|
||||
)
|
||||
api_router.include_router(
|
||||
duplication_router,
|
||||
prefix="/duplication",
|
||||
@@ -129,6 +135,11 @@ api_router.include_router(
|
||||
prefix="/tts",
|
||||
tags=["TTS"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_router,
|
||||
prefix="/ai",
|
||||
tags=["AI"],
|
||||
)
|
||||
api_router.include_router(
|
||||
feature_flags_router,
|
||||
tags=["Internal"],
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
"""AI 相关接口 — 智能标题、智能素材匹配等.
|
||||
|
||||
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── 请求/响应模型 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateTitlesRequest(BaseModel):
|
||||
"""智能标题生成请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="视频内容描述")
|
||||
style: Literal["viral", "emotional", "informative"] = Field(
|
||||
default="viral",
|
||||
description="标题风格:viral爆款 / emotional情感 / informative信息",
|
||||
)
|
||||
count: int = Field(default=5, ge=3, le=10, description="生成数量,3-10个")
|
||||
|
||||
|
||||
class GenerateTitlesResponse(BaseModel):
|
||||
"""智能标题生成响应."""
|
||||
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
style: str = Field(..., description="实际使用的风格")
|
||||
source: str = Field(..., description="来源:doubao 或 fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
|
||||
|
||||
class TitleStyleInfo(BaseModel):
|
||||
"""标题风格信息."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AssetMatchItem(BaseModel):
|
||||
"""待匹配素材项."""
|
||||
|
||||
id: str = Field(..., description="素材ID")
|
||||
name: str = Field(default="", description="素材名称")
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
description: str = Field(default="", description="素材描述")
|
||||
|
||||
|
||||
class SemanticMatchRequest(BaseModel):
|
||||
"""语义匹配请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
||||
|
||||
|
||||
class SemanticMatchResultItem(AssetMatchItem):
|
||||
"""匹配结果项."""
|
||||
|
||||
match_score: float = Field(..., description="匹配度评分 0-1")
|
||||
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
|
||||
|
||||
|
||||
class SemanticMatchResponse(BaseModel):
|
||||
"""语义匹配响应."""
|
||||
|
||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
source: str = Field(..., description="来源:doubao / fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
total: int = Field(..., description="输入素材总数")
|
||||
|
||||
|
||||
# ── 路由 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/titles/generate", response_model=GenerateTitlesResponse)
|
||||
def generate_titles(request: GenerateTitlesRequest):
|
||||
"""生成智能标题.
|
||||
|
||||
根据视频描述生成指定风格的标题,支持爆款、情感、信息三种风格。
|
||||
未配置豆包 API Key 时自动降级为本地规则生成。
|
||||
"""
|
||||
result = generate_smart_titles(
|
||||
description=request.description,
|
||||
style=request.style,
|
||||
count=request.count,
|
||||
)
|
||||
return GenerateTitlesResponse(**result)
|
||||
|
||||
|
||||
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
|
||||
def list_title_styles():
|
||||
"""获取支持的标题风格列表."""
|
||||
return [
|
||||
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
|
||||
for key, info in TITLE_STYLES.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assets/match", response_model=SemanticMatchResponse)
|
||||
def match_assets(request: SemanticMatchRequest):
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,对素材列表做语义匹配并按匹配度排序。
|
||||
未配置豆包 API Key 时自动降级为关键词匹配。
|
||||
|
||||
- 支持最多 100 个素材同时匹配
|
||||
- 返回 match_score (0-1),按降序排列
|
||||
- top_k 可限制返回数量
|
||||
"""
|
||||
# 转为 dict 传给服务层
|
||||
assets_dict = [asset.model_dump() for asset in request.assets]
|
||||
|
||||
result = semantic_match_assets(
|
||||
description=request.description,
|
||||
assets=assets_dict,
|
||||
top_k=request.top_k,
|
||||
)
|
||||
|
||||
return SemanticMatchResponse(
|
||||
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
|
||||
source=result["source"],
|
||||
description=result["description"],
|
||||
total=result["total"],
|
||||
)
|
||||
@@ -30,6 +30,7 @@ from app.schemas.generation_task import (
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from app.services.smart_asset_selector import SmartAssetSelector
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
@@ -59,6 +60,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -104,7 +106,7 @@ def _select_assets_from_library(
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
@@ -122,17 +124,19 @@ def _select_assets_from_library(
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||
sorted_assets = sorted(
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_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,
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||
return [a.id for a in selected]
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
@@ -223,6 +227,20 @@ def create_generation_task(
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
||||
assets = asset_repository.find_by_project(project_id)
|
||||
if assets:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
if not resolved_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
@@ -270,6 +288,7 @@ def create_generation_task(
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
resolution=request.resolution,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
)
|
||||
@@ -408,6 +427,7 @@ def retry_generation_task(
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
Executable
+298
@@ -0,0 +1,298 @@
|
||||
"""视频分享 API 路由."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes._helpers import format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_db_session, get_generated_video_repository
|
||||
from app.schemas.video_share import (
|
||||
CreateShareRequest,
|
||||
ShareAccessResponse,
|
||||
ShareListResponse,
|
||||
ShareMetaResponse,
|
||||
ShareResponse,
|
||||
UpdateShareRequest,
|
||||
VerifySharePasswordRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.video_share_repository import (
|
||||
SQLAlchemyVideoShareRepository,
|
||||
)
|
||||
from packages.application.video_share.commands import (
|
||||
CreateShareCommand,
|
||||
UpdateShareCommand,
|
||||
)
|
||||
from packages.application.video_share.use_cases import (
|
||||
AccessShareUseCase,
|
||||
CreateShareUseCase,
|
||||
GetShareByTokenUseCase,
|
||||
InvalidPasswordError,
|
||||
ListSharesByUserUseCase,
|
||||
ListSharesByVideoUseCase,
|
||||
NotFoundError,
|
||||
PasswordRequiredError,
|
||||
RecordShareDownloadUseCase,
|
||||
RevokeShareUseCase,
|
||||
ShareExpiredError,
|
||||
UpdateShareUseCase,
|
||||
VideoNotFoundError,
|
||||
)
|
||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_share_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyVideoShareRepository:
|
||||
return SQLAlchemyVideoShareRepository(session)
|
||||
|
||||
|
||||
def _to_share_response(share) -> ShareResponse:
|
||||
return ShareResponse(
|
||||
id=share.id,
|
||||
video_id=share.video_id,
|
||||
share_token=share.share_token,
|
||||
has_password=share.has_password,
|
||||
expires_at=share.expires_at,
|
||||
view_count=share.view_count,
|
||||
download_count=share.download_count,
|
||||
is_active=share.is_active,
|
||||
created_at=format_utc_datetime(share.created_at),
|
||||
updated_at=format_utc_datetime(share.updated_at),
|
||||
)
|
||||
|
||||
|
||||
# ── 用户侧:创建/管理分享 ──────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/videos/{video_id}/share", response_model=ShareResponse)
|
||||
def create_share(
|
||||
video_id: str,
|
||||
request: CreateShareRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
) -> ShareResponse:
|
||||
"""为视频创建分享链接."""
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
try:
|
||||
share = use_case.execute(
|
||||
CreateShareCommand(
|
||||
video_id=video_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
password=request.password,
|
||||
expires_at=request.expires_at,
|
||||
)
|
||||
)
|
||||
except VideoNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
logger.info(
|
||||
"Share created: video_id=%s share_id=%s token=%s user=%s",
|
||||
video_id,
|
||||
share.id,
|
||||
share.share_token,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
return _to_share_response(share)
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}/shares", response_model=ShareListResponse)
|
||||
def list_video_shares(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
) -> ShareListResponse:
|
||||
"""获取某个视频的所有分享记录."""
|
||||
use_case = ListSharesByVideoUseCase(share_repo)
|
||||
items = use_case.execute(video_id, authenticated_user.user.id)
|
||||
return ShareListResponse(
|
||||
items=[_to_share_response(s) for s in items],
|
||||
total=len(items),
|
||||
skip=0,
|
||||
limit=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/shares", response_model=ShareListResponse)
|
||||
def list_user_shares(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
) -> ShareListResponse:
|
||||
"""获取用户创建的所有分享记录."""
|
||||
use_case = ListSharesByUserUseCase(share_repo)
|
||||
items, total = use_case.execute(authenticated_user.user.id, skip=skip, limit=limit)
|
||||
return ShareListResponse(
|
||||
items=[_to_share_response(s) for s in items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/shares/{share_id}", response_model=ShareResponse)
|
||||
def update_share(
|
||||
share_id: str,
|
||||
request: UpdateShareRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
) -> ShareResponse:
|
||||
"""更新分享配置(密码、有效期等)."""
|
||||
use_case = UpdateShareUseCase(share_repo)
|
||||
try:
|
||||
share = use_case.execute(
|
||||
UpdateShareCommand(
|
||||
share_id=share_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
password=request.password,
|
||||
expires_at=request.expires_at,
|
||||
)
|
||||
)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return _to_share_response(share)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/shares/{share_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
)
|
||||
def revoke_share(
|
||||
share_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
) -> Response:
|
||||
"""撤销/删除分享链接."""
|
||||
use_case = RevokeShareUseCase(share_repo)
|
||||
try:
|
||||
use_case.execute(share_id, authenticated_user.user.id)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ── 公开侧:访问分享内容(无需登录) ──────────────────────
|
||||
|
||||
|
||||
@router.get("/share/{token}/meta", response_model=ShareMetaResponse)
|
||||
def get_share_meta(
|
||||
token: str,
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ShareMetaResponse:
|
||||
"""获取分享元信息(不需要密码,用于分享页加载前判断)。"""
|
||||
use_case = GetShareByTokenUseCase(share_repo)
|
||||
try:
|
||||
share = use_case.execute(token)
|
||||
except (NotFoundError, ShareExpiredError) as e:
|
||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
||||
|
||||
video = video_repo.get(share.video_id)
|
||||
video_name = video.name if video else ""
|
||||
video_duration = video.duration if video else 0.0
|
||||
thumbnail_url = None
|
||||
if video and video.thumbnail_url:
|
||||
try:
|
||||
thumbnail_url = storage.get_download_url(video.thumbnail_url)
|
||||
except Exception:
|
||||
thumbnail_url = video.thumbnail_url
|
||||
|
||||
return ShareMetaResponse(
|
||||
share_token=share.share_token,
|
||||
has_password=share.has_password,
|
||||
is_expired=share.is_expired,
|
||||
is_active=share.is_active,
|
||||
video_name=video_name,
|
||||
video_duration=video_duration,
|
||||
thumbnail_url=thumbnail_url,
|
||||
created_at=format_utc_datetime(share.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/share/{token}/access", response_model=ShareAccessResponse)
|
||||
def access_share(
|
||||
token: str,
|
||||
request: Optional[VerifySharePasswordRequest] = None,
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ShareAccessResponse:
|
||||
"""访问分享内容(验证密码后返回视频信息+播放/下载地址)。"""
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
password = request.password if request else None
|
||||
try:
|
||||
result = use_case.execute(token, password=password)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
||||
except ShareExpiredError as e:
|
||||
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
||||
except PasswordRequiredError as e:
|
||||
raise HTTPException(status_code=403, detail="需要访问密码") from e
|
||||
except InvalidPasswordError as e:
|
||||
raise HTTPException(status_code=403, detail="密码错误") from e
|
||||
except VideoNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="视频不存在") from e
|
||||
|
||||
# 生成下载URL
|
||||
download_url = None
|
||||
if result.video.file_url:
|
||||
try:
|
||||
download_url = storage.get_download_url(result.video.file_url)
|
||||
except Exception:
|
||||
download_url = result.video.file_url
|
||||
|
||||
# 缩略图URL
|
||||
thumbnail_url = None
|
||||
if result.video.thumbnail_url:
|
||||
try:
|
||||
thumbnail_url = storage.get_download_url(result.video.thumbnail_url)
|
||||
except Exception:
|
||||
thumbnail_url = result.video.thumbnail_url
|
||||
|
||||
return ShareAccessResponse(
|
||||
share=_to_share_response(result.share),
|
||||
video_name=result.video.name,
|
||||
video_duration=result.video.duration,
|
||||
video_size=result.video.file_size,
|
||||
thumbnail_url=thumbnail_url,
|
||||
download_url=download_url,
|
||||
password_verified=result.password_verified,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/share/{token}/download")
|
||||
def record_share_download(
|
||||
token: str,
|
||||
request: Optional[VerifySharePasswordRequest] = None,
|
||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
||||
) -> dict:
|
||||
"""记录分享下载(下载计数+1)。"""
|
||||
use_case = RecordShareDownloadUseCase(share_repo)
|
||||
password = request.password if request else None
|
||||
try:
|
||||
use_case.execute(token, password=password)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
||||
except ShareExpiredError as e:
|
||||
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
||||
except InvalidPasswordError as e:
|
||||
raise HTTPException(status_code=403, detail="密码错误") from e
|
||||
return {"success": True}
|
||||
@@ -46,6 +46,11 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
le=5,
|
||||
description="最大自动重试次数,0表示不自动重试,最大5次",
|
||||
)
|
||||
# ── 输出分辨率 ──
|
||||
resolution: str = Field(
|
||||
default="",
|
||||
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -74,6 +79,7 @@ class GenerationTaskResponse(BaseModel):
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
"""视频分享相关 schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateShareRequest(BaseModel):
|
||||
"""创建分享请求."""
|
||||
|
||||
password: Optional[str] = Field(
|
||||
None,
|
||||
description="访问密码(可选,不设置则无需密码)",
|
||||
min_length=0,
|
||||
max_length=50,
|
||||
)
|
||||
expires_at: Optional[datetime] = Field(
|
||||
None,
|
||||
description="过期时间(可选,不设置则永久有效)",
|
||||
)
|
||||
|
||||
|
||||
class UpdateShareRequest(BaseModel):
|
||||
"""更新分享配置请求."""
|
||||
|
||||
password: Optional[str] = Field(
|
||||
None,
|
||||
description="新密码(传空字符串清除密码,不传则不修改)",
|
||||
max_length=50,
|
||||
)
|
||||
expires_at: Optional[datetime] = Field(
|
||||
None,
|
||||
description="新的过期时间(不传则不修改)",
|
||||
)
|
||||
|
||||
|
||||
class VerifySharePasswordRequest(BaseModel):
|
||||
"""验证分享密码请求."""
|
||||
|
||||
password: str = Field(..., description="访问密码")
|
||||
|
||||
|
||||
class ShareResponse(BaseModel):
|
||||
"""分享记录响应."""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
share_token: str
|
||||
has_password: bool = False
|
||||
expires_at: Optional[datetime] = None
|
||||
view_count: int = 0
|
||||
download_count: int = 0
|
||||
is_active: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ShareListResponse(BaseModel):
|
||||
"""分享列表响应."""
|
||||
|
||||
items: List[ShareResponse]
|
||||
total: int = 0
|
||||
skip: int = 0
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class ShareAccessResponse(BaseModel):
|
||||
"""分享访问成功响应(含视频信息)."""
|
||||
|
||||
share: ShareResponse
|
||||
video_name: str
|
||||
video_duration: float = 0.0
|
||||
video_size: int = 0
|
||||
thumbnail_url: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
password_verified: bool = True
|
||||
|
||||
|
||||
class ShareMetaResponse(BaseModel):
|
||||
"""分享元信息响应(访问前获取,用于判断是否需要密码)。"""
|
||||
|
||||
share_token: str
|
||||
has_password: bool = False
|
||||
is_expired: bool = False
|
||||
is_active: bool = True
|
||||
video_name: str = ""
|
||||
video_duration: float = 0.0
|
||||
thumbnail_url: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
Executable
+535
@@ -0,0 +1,535 @@
|
||||
"""统一 AI 服务层 — 豆包大模型接入.
|
||||
|
||||
提供基于字节跳动豆包大模型的 AI 能力:
|
||||
- 智能标题生成(爆款/情感/信息三种风格)
|
||||
- 后续扩展:智能素材匹配、AI 推荐片段编排等
|
||||
|
||||
设计原则:
|
||||
1. 无 API Key 或调用失败时自动降级为本地模拟,不阻塞主流程
|
||||
2. 统一的客户端封装,新增能力只需加方法
|
||||
3. 所有模型相关配置集中在 Settings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 智能标题风格定义 ─────────────────────────────────────────────────────────
|
||||
|
||||
TITLE_STYLES = {
|
||||
"viral": {
|
||||
"name": "爆款",
|
||||
"description": "吸引点击、引发好奇的爆款标题,带有数字、疑问或反差感",
|
||||
"examples": [
|
||||
"3个方法让你效率翻倍,第2个最绝",
|
||||
"为什么越努力越穷?真相扎心了",
|
||||
"看完这个,我删掉了手机里一半的APP",
|
||||
],
|
||||
},
|
||||
"emotional": {
|
||||
"name": "情感",
|
||||
"description": "触动人心、引发共鸣的情感向标题",
|
||||
"examples": [
|
||||
"那些年我们一起追过的梦想",
|
||||
"生活不易,但请相信光",
|
||||
"致每一个在城市里打拼的你",
|
||||
],
|
||||
},
|
||||
"informative": {
|
||||
"name": "信息",
|
||||
"description": "清晰直白、传递核心信息的干货标题",
|
||||
"examples": [
|
||||
"2026年最新个税政策解读,一文讲透",
|
||||
"新手剪辑入门:从0到1完整指南",
|
||||
"产品对比:10款热门手机深度评测",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _generate_titles_fallback(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> List[str]:
|
||||
"""本地降级:基于模板规则生成标题.
|
||||
|
||||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
||||
"""
|
||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||
examples = style_info["examples"]
|
||||
|
||||
# 从描述中提取关键词(取前几个词)
|
||||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||||
keyword = keywords[0] if keywords else "精彩内容"
|
||||
|
||||
# 基于模板生成
|
||||
templates = [
|
||||
f"「{keyword}」{examples[0][:10]}...",
|
||||
f"{keyword}:{examples[1]}",
|
||||
f"关于{keyword},你不知道的3件事",
|
||||
f"{keyword}入门指南,新手必看",
|
||||
f"深度解析:{keyword}背后的秘密",
|
||||
f"{keyword}怎么做?手把手教你",
|
||||
f"干货分享 | {keyword}全攻略",
|
||||
f"建议收藏:{keyword}实用技巧",
|
||||
f"{keyword}避坑指南,别再踩雷了",
|
||||
f"一分钟搞懂{keyword}",
|
||||
]
|
||||
|
||||
random.shuffle(templates)
|
||||
return templates[: min(count, len(templates))]
|
||||
|
||||
|
||||
def _parse_titles_from_response(content: str) -> List[str]:
|
||||
"""从模型返回中解析标题列表.
|
||||
|
||||
支持多种返回格式:
|
||||
- JSON 数组: ["标题1", "标题2"]
|
||||
- 编号列表: 1. 标题1 / 2. 标题2
|
||||
- 换行分隔: 标题1\n标题2
|
||||
- 带破折号: - 标题1
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
# 清理可能的 markdown 代码块标记
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
if isinstance(data, list):
|
||||
return [str(item).strip() for item in data if str(item).strip()]
|
||||
if isinstance(data, dict) and "titles" in data:
|
||||
titles = data["titles"]
|
||||
if isinstance(titles, list):
|
||||
return [str(t).strip() for t in titles if str(t).strip()]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 尝试按行解析
|
||||
titles: List[str] = []
|
||||
for line in content.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 去掉编号前缀 "1. " "1、" "(1)"
|
||||
import re
|
||||
|
||||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||||
# 去掉破折号前缀 "- " "• "
|
||||
line = re.sub(r"^[-•·]\s*", "", line)
|
||||
# 去掉引号
|
||||
line = line.strip('"').strip("'").strip("「」")
|
||||
if line and len(line) < 100: # 过滤过长的行
|
||||
titles.append(line)
|
||||
|
||||
return titles
|
||||
|
||||
|
||||
def generate_smart_titles(
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成智能标题.
|
||||
|
||||
Args:
|
||||
description: 视频内容描述
|
||||
style: 标题风格 viral/emotional/informative
|
||||
count: 生成数量(5-10)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"titles": [...],
|
||||
"style": "viral",
|
||||
"source": "doubao" | "fallback", # 实际来源
|
||||
"description": "...",
|
||||
}
|
||||
"""
|
||||
# 参数校验与边界处理
|
||||
if style not in TITLE_STYLES:
|
||||
style = "viral"
|
||||
count = max(3, min(10, count)) # 3-10 个
|
||||
description = (description or "").strip()
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级生成标题")
|
||||
titles = _generate_titles_fallback(description, style, count)
|
||||
return {
|
||||
"titles": titles,
|
||||
"style": style,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
}
|
||||
|
||||
style_info = TITLE_STYLES[style]
|
||||
system_prompt = (
|
||||
f"你是一个专业的短视频标题创作专家,擅长根据视频内容生成吸引人的标题。\n"
|
||||
f"请根据以下视频描述,生成{count}个{style_info['name']}风格的标题。\n"
|
||||
f"风格说明:{style_info['description']}\n"
|
||||
f"要求:\n"
|
||||
f"1. 每个标题控制在8-25字之间\n"
|
||||
f"2. 直接返回JSON数组格式,不要其他文字\n"
|
||||
f"3. 标题要贴合内容,有吸引力"
|
||||
)
|
||||
|
||||
user_prompt = f"视频描述:{description}\n\n请生成标题:"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
||||
if result:
|
||||
titles = _parse_titles_from_response(result)
|
||||
if len(titles) >= 2: # 至少解析出2个才算成功
|
||||
titles = titles[:count]
|
||||
logger.info(
|
||||
"豆包智能标题生成成功: style=%s count=%d description=%s...",
|
||||
style,
|
||||
len(titles),
|
||||
description[:20],
|
||||
)
|
||||
return {
|
||||
"titles": titles,
|
||||
"style": style,
|
||||
"source": "doubao",
|
||||
"description": description,
|
||||
}
|
||||
logger.warning("豆包返回内容解析失败,降级到本地生成: %s", result[:100])
|
||||
|
||||
# 降级到本地生成
|
||||
titles = _generate_titles_fallback(description, style, count)
|
||||
return {
|
||||
"titles": titles,
|
||||
"style": style,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
}
|
||||
|
||||
|
||||
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _semantic_match_fallback(
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""本地降级:基于关键词的简单匹配.
|
||||
|
||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||
作为匹配度评分。0-1分。
|
||||
"""
|
||||
import re
|
||||
|
||||
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||
desc = description.lower()
|
||||
# 简单分词:提取2字以上的中文字符串和英文单词
|
||||
keywords = set()
|
||||
# 英文单词
|
||||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||
keywords.add(word)
|
||||
# 中文2-4字片段
|
||||
for i in range(len(desc)):
|
||||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||
fragment = desc[i:j]
|
||||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||
keywords.add(fragment)
|
||||
|
||||
if not keywords:
|
||||
# 没有关键词时给所有素材中等分数
|
||||
for asset in assets:
|
||||
asset["match_score"] = 0.5
|
||||
asset["match_reason"] = "fallback_default"
|
||||
return assets
|
||||
|
||||
results = []
|
||||
for asset in assets:
|
||||
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||
asset_text_parts = [
|
||||
str(asset.get("name", "")).lower(),
|
||||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||
str(asset.get("description", "")).lower(),
|
||||
]
|
||||
asset_text = " | ".join(asset_text_parts)
|
||||
|
||||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||
hit_count = 0
|
||||
hit_keywords = []
|
||||
for kw in keywords:
|
||||
if kw in asset_text:
|
||||
hit_count += 1
|
||||
hit_keywords.append(kw)
|
||||
|
||||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||
|
||||
# 名称命中加分(名称匹配更重要)
|
||||
name = str(asset.get("name", "")).lower()
|
||||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||
name_bonus = min(0.2, name_hits * 0.05)
|
||||
|
||||
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||
score = round(score, 3)
|
||||
|
||||
results.append({
|
||||
**asset,
|
||||
"match_score": score,
|
||||
"match_reason": "fallback_keyword",
|
||||
})
|
||||
|
||||
# 按匹配度降序
|
||||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度.
|
||||
|
||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||
score 范围 0-1。
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
|
||||
result: Dict[str, float] = {}
|
||||
|
||||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||
if isinstance(data, dict):
|
||||
if "matches" in data and isinstance(data["matches"], list):
|
||||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||
for item in data["matches"]:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (int, float)):
|
||||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||
|
||||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
|
||||
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
||||
return result
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def semantic_match_assets(
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,评估每个素材的语义匹配度并排序。
|
||||
|
||||
Args:
|
||||
description: 用户描述的目标视频内容
|
||||
assets: 素材列表,每个素材需含 id/name/tags/description 等字段
|
||||
top_k: 返回前K个,0表示返回全部
|
||||
|
||||
Returns:
|
||||
{
|
||||
"matches": [{"asset_id": ..., "match_score": ..., ...}],
|
||||
"source": "doubao" | "fallback",
|
||||
"description": "...",
|
||||
"total": 总数,
|
||||
}
|
||||
"""
|
||||
description = (description or "").strip()
|
||||
if not assets:
|
||||
return {"matches": [], "source": "fallback", "description": description, "total": 0}
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
|
||||
matched = _semantic_match_fallback(description, assets)
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
|
||||
# 构建素材信息(控制 token 数量)
|
||||
asset_summaries = []
|
||||
for asset in assets[:50]: # 最多传50个素材给模型
|
||||
aid = asset.get("id", "")
|
||||
name = asset.get("name", "")[:50]
|
||||
tags = asset.get("tags", [])
|
||||
tags_str = ",".join(str(t) for t in tags[:5])
|
||||
desc = str(asset.get("description", ""))[:80]
|
||||
asset_summaries.append(
|
||||
f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}"
|
||||
)
|
||||
|
||||
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
|
||||
|
||||
system_prompt = (
|
||||
"你是一个专业的视频素材匹配助手。"
|
||||
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
|
||||
"评分规则:\n"
|
||||
"- 0.0-0.3: 完全不相关\n"
|
||||
"- 0.3-0.6: 有一定关联但不够匹配\n"
|
||||
"- 0.6-0.8: 比较匹配,适合使用\n"
|
||||
"- 0.8-1.0: 高度匹配,非常适合\n"
|
||||
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
|
||||
"不要其他文字说明。"
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"目标视频描述:{description}\n\n"
|
||||
f"素材列表:\n" + "\n".join(asset_summaries) +
|
||||
f"\n\n请返回每个素材的匹配分数JSON:"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=1024,
|
||||
)
|
||||
|
||||
if result:
|
||||
scores = _parse_semantic_match_response(result, asset_ids)
|
||||
if scores:
|
||||
# 把评分填回素材
|
||||
matched = []
|
||||
for asset in assets:
|
||||
aid = str(asset.get("id", ""))
|
||||
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
|
||||
matched.append({
|
||||
**asset,
|
||||
"match_score": round(score, 3),
|
||||
"match_reason": "doubao_semantic",
|
||||
})
|
||||
matched.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
|
||||
logger.info(
|
||||
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
|
||||
len(matched),
|
||||
matched[0]["match_score"] if matched else 0,
|
||||
description[:20],
|
||||
)
|
||||
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "doubao",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
|
||||
|
||||
# 降级
|
||||
matched = _semantic_match_fallback(description, assets)
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
|
||||
|
||||
# ── 单例入口 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_ai_service() -> "AIService":
|
||||
"""获取 AI 服务单例."""
|
||||
global _ai_service
|
||||
if _ai_service is None:
|
||||
_ai_service = AIService()
|
||||
return _ai_service
|
||||
|
||||
|
||||
_ai_service: Optional["AIService"] = None
|
||||
|
||||
|
||||
class AIService:
|
||||
"""AI 服务统一入口,便于后续扩展更多能力."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = get_doubao_client()
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._client.is_available
|
||||
|
||||
def generate_titles(
|
||||
self,
|
||||
description: str,
|
||||
style: str = "viral",
|
||||
count: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
return generate_smart_titles(description, style, count)
|
||||
|
||||
def semantic_match(
|
||||
self,
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
return semantic_match_assets(description, assets, top_k)
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""SmartAssetSelector — 智能素材选择服务.
|
||||
|
||||
根据多维度评分从素材库中自动选择最优视频素材,
|
||||
用于一键生成等需要自动选取素材的场景。
|
||||
|
||||
评分维度(加权求和,总分 0-1):
|
||||
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
||||
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
||||
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
||||
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
||||
|
||||
特性:
|
||||
- 最低质量分门槛:自动过滤低质量素材
|
||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||
- 兼容全部模式:素材库模式和项目模式都可用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 评分权重 ──────────────────────────────────────────────────────────────────
|
||||
_WEIGHT_QUALITY = 0.5
|
||||
_WEIGHT_RESOLUTION = 0.2
|
||||
_WEIGHT_DURATION = 0.2
|
||||
_WEIGHT_BITRATE = 0.1
|
||||
|
||||
# ── 评分参数 ──────────────────────────────────────────────────────────────────
|
||||
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
|
||||
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
|
||||
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
|
||||
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
|
||||
_OPTIMAL_DURATION_MAX = 30.0
|
||||
|
||||
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
|
||||
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
|
||||
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
|
||||
# 长素材:> 15s
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartSelectResult:
|
||||
"""智能选择结果."""
|
||||
|
||||
selected_ids: list[str]
|
||||
total_candidates: int
|
||||
filtered_out: int # 被质量门槛过滤的数量
|
||||
avg_score: float
|
||||
details: list[AssetScoreDetail]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetScoreDetail:
|
||||
"""单个素材的评分详情."""
|
||||
|
||||
asset_id: str
|
||||
total_score: float
|
||||
quality_score: float
|
||||
resolution_score: float
|
||||
duration_score: float
|
||||
bitrate_score: float
|
||||
duration: float | None
|
||||
|
||||
|
||||
class SmartAssetSelector:
|
||||
"""智能素材选择器.
|
||||
|
||||
从一组素材中按综合评分选择最优的 N 个,
|
||||
同时保证时长分布的多样性。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_quality_score: float = _MIN_QUALITY_SCORE,
|
||||
target_width: int = _TARGET_WIDTH,
|
||||
target_height: int = _TARGET_HEIGHT,
|
||||
):
|
||||
self.min_quality_score = min_quality_score
|
||||
self.target_width = target_width
|
||||
self.target_height = target_height
|
||||
|
||||
# ── 公开方法 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def select(
|
||||
self,
|
||||
assets: list,
|
||||
count: int = 0,
|
||||
*,
|
||||
ensure_diversity: bool = True,
|
||||
) -> SmartSelectResult:
|
||||
"""从素材列表中智能选择最优素材.
|
||||
|
||||
Args:
|
||||
assets: Asset 实体列表(需要有 id/quality_score/width/height/duration/file_size 属性)
|
||||
count: 选取数量,0 表示全部符合条件的
|
||||
ensure_diversity: 是否保证时长多样性(默认开启)
|
||||
|
||||
Returns:
|
||||
SmartSelectResult 选择结果
|
||||
"""
|
||||
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
||||
candidates = []
|
||||
filtered_out = 0
|
||||
for asset in assets:
|
||||
status = getattr(asset, "status", None)
|
||||
status_val = status.value if hasattr(status, "value") else str(status)
|
||||
if status_val != "ready":
|
||||
continue
|
||||
mime_type = getattr(asset, "mime_type", "") or ""
|
||||
if not mime_type.startswith("video"):
|
||||
continue
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
if quality is not None and quality < self.min_quality_score:
|
||||
filtered_out += 1
|
||||
continue
|
||||
candidates.append(asset)
|
||||
|
||||
if not candidates:
|
||||
return SmartSelectResult(
|
||||
selected_ids=[],
|
||||
total_candidates=0,
|
||||
filtered_out=filtered_out,
|
||||
avg_score=0.0,
|
||||
details=[],
|
||||
)
|
||||
|
||||
# 2. 对每个候选素材评分
|
||||
scored: list[AssetScoreDetail] = []
|
||||
for asset in candidates:
|
||||
detail = self._score_asset(asset)
|
||||
scored.append(detail)
|
||||
|
||||
# 3. 按总分降序排列
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
|
||||
# 4. 多样性选择(如果需要且数量有限制)
|
||||
if ensure_diversity and count > 0 and len(scored) > count:
|
||||
selected = self._diverse_selection(scored, count)
|
||||
else:
|
||||
# 无数量限制或不要求多样性,直接按排名取
|
||||
selected = scored if count <= 0 else scored[:count]
|
||||
|
||||
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
|
||||
|
||||
result = SmartSelectResult(
|
||||
selected_ids=[d.asset_id for d in selected],
|
||||
total_candidates=len(candidates),
|
||||
filtered_out=filtered_out,
|
||||
avg_score=avg_score,
|
||||
details=selected,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
|
||||
result.total_candidates,
|
||||
result.filtered_out,
|
||||
len(result.selected_ids),
|
||||
result.avg_score,
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分."""
|
||||
# 质量分
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||
|
||||
# 分辨率评分:越接近目标分辨率得分越高
|
||||
width = getattr(asset, "width", None)
|
||||
height = getattr(asset, "height", None)
|
||||
resolution_score = self._score_resolution(width, height)
|
||||
|
||||
# 时长评分:在最佳区间内得分高,过短过长扣分
|
||||
duration = getattr(asset, "duration", None)
|
||||
duration_score = self._score_duration(duration)
|
||||
|
||||
# 码率评分:用 file_size/duration 估算,适中得分高
|
||||
file_size = getattr(asset, "file_size", 0) or 0
|
||||
bitrate_score = self._score_bitrate(file_size, duration)
|
||||
|
||||
# 加权总分
|
||||
total = (
|
||||
_WEIGHT_QUALITY * quality_score
|
||||
+ _WEIGHT_RESOLUTION * resolution_score
|
||||
+ _WEIGHT_DURATION * duration_score
|
||||
+ _WEIGHT_BITRATE * bitrate_score
|
||||
)
|
||||
|
||||
return AssetScoreDetail(
|
||||
asset_id=asset.id,
|
||||
total_score=round(total, 4),
|
||||
quality_score=round(quality_score, 4),
|
||||
resolution_score=round(resolution_score, 4),
|
||||
duration_score=round(duration_score, 4),
|
||||
bitrate_score=round(bitrate_score, 4),
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
|
||||
target_pixels = self.target_width * self.target_height
|
||||
actual_pixels = width * height
|
||||
|
||||
# 计算像素数比例
|
||||
ratio = actual_pixels / target_pixels
|
||||
|
||||
if ratio >= 1.0:
|
||||
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||
return 1.0
|
||||
else:
|
||||
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
|
||||
score = 0.3 + 0.7 * ratio
|
||||
return max(0.1, min(1.0, score))
|
||||
|
||||
def _score_duration(self, duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分."""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
|
||||
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
|
||||
# 最佳区间:满分
|
||||
return 1.0
|
||||
|
||||
if duration < _OPTIMAL_DURATION_MIN:
|
||||
# 太短:线性衰减,1秒以下给 0.3
|
||||
ratio = duration / _OPTIMAL_DURATION_MIN
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
|
||||
excess = duration - _OPTIMAL_DURATION_MAX
|
||||
penalty = min(0.8, excess / 10.0 * 0.1)
|
||||
return max(0.2, 1.0 - penalty)
|
||||
|
||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
|
||||
# 估算码率(bps)
|
||||
bitrate = (file_size * 8) / duration
|
||||
|
||||
# 最佳码率范围:2-8 Mbps
|
||||
optimal_low = 2_000_000 # 2 Mbps
|
||||
optimal_high = 8_000_000 # 8 Mbps
|
||||
|
||||
if optimal_low <= bitrate <= optimal_high:
|
||||
return 1.0
|
||||
|
||||
if bitrate < optimal_low:
|
||||
# 码率太低:线性衰减
|
||||
ratio = bitrate / optimal_low
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 码率太高(文件太大):适度扣分
|
||||
excess = bitrate / optimal_high - 1.0
|
||||
penalty = min(0.5, excess * 0.2)
|
||||
return max(0.5, 1.0 - penalty)
|
||||
|
||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
|
||||
2. 每个桶配额 = max(1, count / 3)
|
||||
3. 先从每桶按配额取最高分的
|
||||
4. 剩余名额从全局最高分中取(不重复)
|
||||
"""
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||
medium_bucket = [
|
||||
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
]
|
||||
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||
unknown_bucket = [d for d in scored if d.duration is None]
|
||||
|
||||
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||
bucket_names = ["short", "medium", "long"]
|
||||
|
||||
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||
base_quota = max(1, count // 3)
|
||||
|
||||
selected: list[AssetScoreDetail] = []
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
# 桶内已经按分数排好序了,直接取前 quota 个
|
||||
for item in bucket[:quota]:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
return selected
|
||||
|
||||
# 剩余名额:从全局(未被选中的)中按分数高低取
|
||||
remaining_needed = count - len(selected)
|
||||
if remaining_needed > 0:
|
||||
for item in scored:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
# 如果还不够(不应该发生),加上未知时长的
|
||||
if len(selected) < count and unknown_bucket:
|
||||
for item in unknown_bucket:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
return selected[:count]
|
||||
Regular → Executable
+17
-1
@@ -20,7 +20,14 @@ export const useLogin = () => {
|
||||
const data = await mutation.mutateAsync(credentials)
|
||||
const refreshToken = data.refresh_token ?? null
|
||||
|
||||
// 获取用户信息
|
||||
// 先存 token 到 localStorage,确保后续请求拦截器能取到
|
||||
// (apiClient 拦截器从 localStorage 读 access_token)
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
}
|
||||
|
||||
// 再获取用户信息(这时候请求带 Authorization header)
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
@@ -54,6 +61,15 @@ export const useWechatCallback = () => {
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await mutation.mutateAsync({ code, state })
|
||||
|
||||
// 先存 token 到 localStorage,确保后续请求拦截器能取到
|
||||
// (apiClient 拦截器从 localStorage 读 access_token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 再获取用户信息(这时候请求带 Authorization header)
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 获取用户信息
|
||||
// 先把 token 存到 localStorage,让请求拦截器能拿到(getCurrentUser 需要带 token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 获取用户信息(这时候请求拦截器能拿到 token 了)
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
|
||||
getCurrentUser: vi.fn(),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: () => ({
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/auth/BindContactModal", () => ({
|
||||
default: ({ open }: { open: boolean }) => (
|
||||
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
|
||||
BindContactModal
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("WechatCallback Page", () => {
|
||||
beforeEach(() => {
|
||||
// mock localStorage,设置wechat_state匹配,让校验通过
|
||||
const store: Record<string, string> = {
|
||||
wechat_state: "test_state",
|
||||
}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
store[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete store[key]
|
||||
})
|
||||
})
|
||||
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should show loading state while processing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// wechatCallback 返回 pending promise,所以应该显示 loading
|
||||
expect(screen.getByText("正在登录...")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -483,6 +483,7 @@ class RenderAdapter:
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
rendered_clip_ids: list[str] | None = None,
|
||||
failed_clip_ids: list[str] | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
@@ -491,6 +492,7 @@ class RenderAdapter:
|
||||
Args:
|
||||
rendered_clip_ids: 成功下载/准备的 clip id 列表(render_plan 从下载阶段传入)
|
||||
failed_clip_ids: 失败的 clip id 列表
|
||||
voiceover_audio_path: 配音素材库音频本地路径(一键生成场景使用)
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
@@ -525,6 +527,7 @@ class RenderAdapter:
|
||||
output_height=output_height,
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
@@ -593,6 +596,7 @@ class RenderAdapter:
|
||||
job_id: str = "",
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
) -> RenderAdapterResult:
|
||||
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
|
||||
|
||||
@@ -606,6 +610,7 @@ class RenderAdapter:
|
||||
job_id: 关联的 Job ID
|
||||
work_dir: 工作目录,不传则用临时目录
|
||||
progress_cb: 进度回调
|
||||
voiceover_audio_path: 配音素材库音频本地路径
|
||||
|
||||
Returns:
|
||||
RenderAdapterResult
|
||||
@@ -649,6 +654,7 @@ class RenderAdapter:
|
||||
plan_id=actual_plan_id,
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
@@ -175,6 +175,7 @@ class UnifiedRenderService:
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -186,6 +187,7 @@ class UnifiedRenderService:
|
||||
self.transition_duration = transition_duration
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
@@ -226,6 +228,9 @@ class UnifiedRenderService:
|
||||
# 3.5 TTS 配音生成(如果配置了)
|
||||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
|
||||
|
||||
# 3.6 配音素材库音频(如果传入了本地路径)
|
||||
self._maybe_add_voice_library_layer(layers, video_duration=video_duration)
|
||||
|
||||
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
|
||||
ass_path = self._maybe_generate_ass(video_duration)
|
||||
|
||||
@@ -843,6 +848,107 @@ class UnifiedRenderService:
|
||||
logger.warning("TTS 配音异常,跳过: %s", e)
|
||||
return False
|
||||
|
||||
def _maybe_add_voice_library_layer(
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
*,
|
||||
video_duration: float,
|
||||
) -> bool:
|
||||
"""将配音素材库音频作为整段配音加到 audio 图层.
|
||||
|
||||
与 TTS 配音共享同一套 audio 图层混音架构,
|
||||
支持与 BGM、TTS 的音量平衡,不再走独立的后处理 mux 链路。
|
||||
|
||||
Returns:
|
||||
是否成功添加了配音音轨
|
||||
"""
|
||||
if not self.voiceover_audio_path:
|
||||
return False
|
||||
|
||||
audio_path = Path(self.voiceover_audio_path)
|
||||
if not audio_path.exists() or audio_path.stat().st_size == 0:
|
||||
logger.warning("配音素材库音频文件不存在或为空,跳过: %s", self.voiceover_audio_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
# 找到或创建 audio 图层
|
||||
audio_layer = None
|
||||
for layer in layers:
|
||||
if layer.role == "audio":
|
||||
audio_layer = layer
|
||||
break
|
||||
|
||||
if audio_layer is None:
|
||||
from video_processing.unified_render_service import _LAYER_Z_INDEX # type: ignore
|
||||
|
||||
z_index = _LAYER_Z_INDEX.get("audio", 2)
|
||||
audio_layer = RenderLayer(role="audio", z_index=z_index)
|
||||
layers.append(audio_layer)
|
||||
|
||||
# 配音素材作为整段配音:从 0 开始,覆盖整个视频时长
|
||||
# 音频不足视频时长时,混音层会按实际长度处理(amix 不自动循环)
|
||||
vo_clip = ResolvedClip(
|
||||
clip_id="voice_library_main",
|
||||
asset_id="voice_library",
|
||||
local_path=audio_path,
|
||||
clip_type="audio",
|
||||
order=len(audio_layer.clips),
|
||||
start_time=0.0,
|
||||
duration=video_duration,
|
||||
config={"volume": 1.0, "voice_library": True},
|
||||
actual_duration=video_duration,
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
logger.info(
|
||||
"配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs",
|
||||
self.plan.id,
|
||||
video_duration,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("配音素材库音频添加失败,跳过: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _resolve_watermark_config(plan_config: dict[str, Any] | None) -> WatermarkConfig | None:
|
||||
"""从 plan config 中解析水印配置,兼容两种存储格式.
|
||||
|
||||
支持格式:
|
||||
1. 嵌套格式:config.watermark = {enabled, mode, text, image_path, ...}
|
||||
2. 扁平格式(导出配置):config.export.watermark_enabled + config.export.watermark_text
|
||||
|
||||
Returns:
|
||||
WatermarkConfig 或 None(未启用水印时)
|
||||
"""
|
||||
if not plan_config or not isinstance(plan_config, dict):
|
||||
return None
|
||||
|
||||
# 格式1: 嵌套 watermark 对象(优先)
|
||||
wm_data = plan_config.get("watermark")
|
||||
if isinstance(wm_data, dict) and wm_data:
|
||||
config = WatermarkConfig.from_dict(wm_data)
|
||||
if config is not None:
|
||||
return config
|
||||
|
||||
# 格式2: 扁平 export.watermark_enabled + export.watermark_text
|
||||
export_cfg = plan_config.get("export")
|
||||
if isinstance(export_cfg, dict) and export_cfg:
|
||||
enabled = export_cfg.get("watermark_enabled", False)
|
||||
text = export_cfg.get("watermark_text", "") or ""
|
||||
if enabled and text:
|
||||
return WatermarkConfig(
|
||||
mode="text",
|
||||
text=str(text),
|
||||
position=export_cfg.get("watermark_position", "bottom_right"),
|
||||
opacity=float(export_cfg.get("watermark_opacity", 0.6)),
|
||||
font_size=int(export_cfg.get("watermark_font_size", 24)),
|
||||
font_color=str(export_cfg.get("watermark_font_color", "white")),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _can_use_pass_through(self, layers: list[RenderLayer]) -> bool:
|
||||
"""判断是否可以走直通优化路径。
|
||||
|
||||
@@ -864,15 +970,11 @@ class UnifiedRenderService:
|
||||
if isinstance(plan_config, dict) and plan_config.get("stickers"):
|
||||
return False
|
||||
|
||||
# 有水印时禁用直通(图片水印需要额外输入,统一走 filter_complex)
|
||||
try:
|
||||
from video_processing.watermark_engine import WatermarkConfig
|
||||
|
||||
wm_config = WatermarkConfig.from_dict(plan_config.get("watermark"))
|
||||
if wm_config is not None and wm_config.validate()[0]:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
# 有水印时禁用直通(图片水印需要额外输入,统一走 filter_complex;
|
||||
# 文字水印虽然可以 -vf 叠加,但为了保持路径统一也走 filter_complex)
|
||||
wm_config = UnifiedRenderService._resolve_watermark_config(plan_config)
|
||||
if wm_config is not None and wm_config.validate()[0]:
|
||||
return False
|
||||
|
||||
# 有调速时仍然可以走直通(视频调速通过 setpts 实现,单输入即可)
|
||||
|
||||
@@ -1117,11 +1219,11 @@ class UnifiedRenderService:
|
||||
filters.append(f"scale={pip_w}:{pip_h}")
|
||||
elif role == "background":
|
||||
# background: 铺满裁剪(作为底图,覆盖全屏)
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=increase")
|
||||
filters.append(f"crop={self.output_width}:{self.output_height}")
|
||||
else:
|
||||
# main / broll: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=decrease")
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
|
||||
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
|
||||
|
||||
# 调色滤镜
|
||||
@@ -1304,6 +1406,8 @@ class UnifiedRenderService:
|
||||
start_time=seg_start,
|
||||
duration=seg_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
@@ -1464,16 +1568,12 @@ class UnifiedRenderService:
|
||||
pip_h = int(self.output_height * _PIP_SCALE)
|
||||
filters.append(f"scale={pip_w}:{pip_h}")
|
||||
elif role == "background":
|
||||
filters.append(
|
||||
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
|
||||
)
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=increase")
|
||||
filters.append(f"crop={self.output_width}:{self.output_height}")
|
||||
else:
|
||||
# main / broll: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
|
||||
# concat 要求所有输入分辨率完全一致,pad 模式确保不同宽高比的素材都能正常拼接
|
||||
filters.append(
|
||||
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=decrease"
|
||||
)
|
||||
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
|
||||
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
|
||||
|
||||
# 调色滤镜(每个 clip 独立的 color grade 配置)
|
||||
@@ -1561,9 +1661,7 @@ class UnifiedRenderService:
|
||||
if role in layer_output_labels:
|
||||
base_label = layer_output_labels[role]
|
||||
combined_label = f"combined_{role}"
|
||||
filter_parts.append(
|
||||
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
|
||||
)
|
||||
filter_parts.append(f"[{final_video_label}][{base_label}]overlay=(W-w)/2:(H-h)/2[{combined_label}]")
|
||||
final_video_label = combined_label
|
||||
else:
|
||||
# 无 background 时,取 broll 或 main 作为基础
|
||||
@@ -1587,11 +1685,11 @@ class UnifiedRenderService:
|
||||
20,
|
||||
)
|
||||
combined_label = f"combined_{layer.role}"
|
||||
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
|
||||
filter_parts.append(f"[{final_video_label}][{overlay_label}]overlay={x}:{y}[{combined_label}]")
|
||||
final_video_label = combined_label
|
||||
|
||||
# 叠加水印(在字幕之前)
|
||||
watermark_config = WatermarkConfig.from_dict((self.plan.config or {}).get("watermark"))
|
||||
watermark_config = UnifiedRenderService._resolve_watermark_config(self.plan.config)
|
||||
if watermark_config is not None:
|
||||
wm_valid, wm_err = watermark_config.validate()
|
||||
if wm_valid:
|
||||
|
||||
Regular → Executable
+164
-5
@@ -10,12 +10,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,17 +25,16 @@ logger = logging.getLogger(__name__)
|
||||
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _call_ai_recommend_service(
|
||||
def _fallback_recommend_clips(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
target_duration: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务(stub)
|
||||
"""本地降级推荐方案(原 stub 逻辑).
|
||||
|
||||
TODO: 接入真实 AI 服务,分析素材内容并生成推荐方案。
|
||||
当前返回基于模板规则的模拟推荐数据。
|
||||
当豆包 API 不可用或调用失败时使用,基于模板规则生成模拟推荐数据。
|
||||
"""
|
||||
# 模拟 AI 分析耗时
|
||||
time.sleep(0.5)
|
||||
@@ -87,6 +88,7 @@ def _call_ai_recommend_service(
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
|
||||
# 生成推荐 config
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
@@ -101,6 +103,163 @@ def _call_ai_recommend_service(
|
||||
}
|
||||
|
||||
|
||||
def _parse_recommend_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
target_duration: float,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""解析豆包返回的推荐方案.
|
||||
|
||||
期望返回结构:
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro/showcase/outro", "order": 0,
|
||||
"text_content": "...", "duration": 3.0,
|
||||
"transition_effect": "fade/cut", "asset_id": "...",
|
||||
"start_time": 0.0, "config": {}}
|
||||
],
|
||||
"title": "视频标题",
|
||||
"confidence": 0.85
|
||||
}
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
try:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
clips_data = data.get("clips", [])
|
||||
if not isinstance(clips_data, list) or len(clips_data) == 0:
|
||||
return None
|
||||
|
||||
clips: List[Dict[str, Any]] = []
|
||||
for i, clip in enumerate(clips_data):
|
||||
if not isinstance(clip, dict):
|
||||
continue
|
||||
asset_id = str(clip.get("asset_id", ""))
|
||||
# 校验 asset_id 是否在输入列表中
|
||||
if asset_id and asset_id not in asset_ids:
|
||||
asset_id = ""
|
||||
clips.append({
|
||||
"clip_type": clip.get("clip_type", "showcase"),
|
||||
"order": clip.get("order", len(clips)),
|
||||
"text_content": str(clip.get("text_content", "")),
|
||||
"duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))),
|
||||
"transition_effect": clip.get("transition_effect", "cut"),
|
||||
"asset_id": asset_id,
|
||||
"start_time": max(0.0, float(clip.get("start_time", 0.0))),
|
||||
"config": clip.get("config", {}) or {},
|
||||
})
|
||||
|
||||
if not clips:
|
||||
return None
|
||||
|
||||
# 按 order 排序
|
||||
clips.sort(key=lambda c: c["order"])
|
||||
# 重新编号 order 保证连续
|
||||
for i, clip in enumerate(clips):
|
||||
clip["order"] = i
|
||||
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
title = data.get("title", "")
|
||||
if title:
|
||||
config["title"]["text"] = str(title)
|
||||
config["title"]["ai_auto"] = True
|
||||
|
||||
confidence = float(data.get("confidence", 0.7))
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
total_duration = round(sum(c["duration"] for c in clips), 1)
|
||||
|
||||
return {
|
||||
"clips": clips,
|
||||
"config": config,
|
||||
"total_duration": total_duration,
|
||||
"confidence": round(confidence, 2),
|
||||
}
|
||||
|
||||
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _call_ai_recommend_service(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
target_duration: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务生成片段编排方案.
|
||||
|
||||
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级生成AI推荐方案")
|
||||
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = (
|
||||
"你是一个专业的视频剪辑导演助手。"
|
||||
"根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
"要求:\n"
|
||||
"1. 片段类型分为三类:intro(开场)、showcase(展示)、outro(结尾)\n"
|
||||
"2. 每个片段包含:clip_type、order、text_content(字幕/标题文字)、"
|
||||
"duration(时长秒)、transition_effect(转场效果:fade/cut/dissolve)、"
|
||||
"asset_id(使用的素材ID)、start_time(素材起始时间秒)\n"
|
||||
"3. 总时长接近 target_duration,每个素材至少用一次\n"
|
||||
"4. 转场效果合理分配,不要全用cut\n"
|
||||
"5. 返回纯JSON,不要其他文字\n"
|
||||
"返回格式:{\"clips\": [...], \"title\": \"视频标题\", \"confidence\": 0.85}"
|
||||
)
|
||||
|
||||
assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])])
|
||||
user_prompt = (
|
||||
f"剪辑计划ID: {plan_id}\n"
|
||||
f"模板ID: {template_id}\n"
|
||||
f"剪辑模式: {editing_mode}\n"
|
||||
f"目标时长: {target_duration}秒\n"
|
||||
f"素材列表(共{len(asset_ids)}个):\n{assets_desc}\n\n"
|
||||
f"请设计完整的片段编排方案:"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
if result:
|
||||
parsed = _parse_recommend_response(result, asset_ids, target_duration)
|
||||
if parsed and len(parsed["clips"]) >= 2:
|
||||
logger.info(
|
||||
"豆包AI推荐生成成功: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
|
||||
plan_id,
|
||||
len(parsed["clips"]),
|
||||
parsed["total_duration"],
|
||||
parsed["confidence"],
|
||||
)
|
||||
return parsed
|
||||
logger.warning("豆包AI推荐返回解析失败,降级到本地方案: %s", result[:100])
|
||||
|
||||
# 降级
|
||||
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
|
||||
|
||||
|
||||
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Regular → Executable
+26
-18
@@ -171,6 +171,7 @@ class _VirtualClip:
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||||
playback_speed: float = 1.0
|
||||
status: str = "ready"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -279,7 +280,7 @@ def _apply_template_clip_effects(
|
||||
cfg_idx = min(i, len(main_configs) - 1)
|
||||
template_cfg = main_configs[cfg_idx]
|
||||
|
||||
# 1. 转场效果
|
||||
# 1. 转场效果 + 时长
|
||||
transition = (
|
||||
template_cfg.transition_effect.value
|
||||
if hasattr(template_cfg.transition_effect, "value")
|
||||
@@ -287,6 +288,16 @@ def _apply_template_clip_effects(
|
||||
)
|
||||
if transition and transition != "cut":
|
||||
clip.transition_effect = transition
|
||||
# 同步转场时长(模板 clip_config 里的 transition_duration)
|
||||
tpl_cfg = template_cfg.config or {}
|
||||
tpl_duration = tpl_cfg.get("transition_duration")
|
||||
if tpl_duration:
|
||||
try:
|
||||
dur_val = float(tpl_duration)
|
||||
if dur_val > 0:
|
||||
clip.transition_duration = dur_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. clip 级效果配置(滤镜、调速等)
|
||||
template_clip_config = template_cfg.config or {}
|
||||
@@ -1099,6 +1110,7 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"batch_id": getattr(gen_task, "batch_id", "") or "",
|
||||
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
|
||||
"video_title": getattr(gen_task, "video_title", "") or "",
|
||||
"resolution": getattr(gen_task, "resolution", "") or "",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1157,6 +1169,7 @@ def _render_video(
|
||||
user_id: str,
|
||||
temp_path: Path,
|
||||
output_name: str,
|
||||
resolution: str = "",
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1188,15 +1201,17 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720)
|
||||
# RenderAdapter 从 plan.config.export.resolution 读取,
|
||||
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
|
||||
# 确保输出分辨率配置存在
|
||||
# 优先级:用户指定 > 模板配置 > 默认 1280x720
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
export_cfg = plan_cfg.get("export", {}) or {}
|
||||
if not export_cfg.get("resolution"):
|
||||
if resolution:
|
||||
# 用户在 API 调用时指定的分辨率优先级最高
|
||||
export_cfg["resolution"] = resolution
|
||||
elif not export_cfg.get("resolution"):
|
||||
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
total_duration = sum(c.duration for c in virtual_clips)
|
||||
logger.info(
|
||||
@@ -1223,6 +1238,7 @@ def _render_video(
|
||||
plan_id=f"gen_{task_id}",
|
||||
job_id=task_id,
|
||||
work_dir=temp_path,
|
||||
voiceover_audio_path=voice_path,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1241,17 +1257,8 @@ def _render_video(
|
||||
render_duration,
|
||||
)
|
||||
|
||||
# 配音混音(素材库音频,后处理混音)
|
||||
if voice_path:
|
||||
final_path = temp_path / f"final-{task_id}.mp4"
|
||||
try:
|
||||
_mux_audio_track(render_output_path, voice_path, final_path)
|
||||
output_path = final_path
|
||||
except Exception as mux_err:
|
||||
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
|
||||
output_path = render_output_path
|
||||
else:
|
||||
output_path = render_output_path
|
||||
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
|
||||
output_path = render_output_path
|
||||
|
||||
return output_path, render_duration
|
||||
|
||||
@@ -1445,6 +1452,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
user_id=user_id,
|
||||
temp_path=temp_path,
|
||||
output_name=output_name,
|
||||
resolution=task_info.get("resolution", ""),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -34,6 +34,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
asset_select_mode=model.asset_select_mode or "",
|
||||
batch_id=model.batch_id or "",
|
||||
video_title=getattr(model, "video_title", "") or "",
|
||||
resolution=getattr(model, "resolution", "") or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -70,6 +71,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
asset_select_mode=task.asset_select_mode or "",
|
||||
batch_id=task.batch_id or "",
|
||||
video_title=task.video_title or "",
|
||||
resolution=task.resolution or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -230,6 +232,8 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.batch_id = task.batch_id or ""
|
||||
if hasattr(model, "video_title"):
|
||||
model.video_title = task.video_title or ""
|
||||
if hasattr(model, "resolution"):
|
||||
model.resolution = task.resolution or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -291,6 +291,7 @@ class GenerationTaskModel(Base):
|
||||
asset_select_mode = Column(String(20), nullable=False, default="")
|
||||
batch_id = Column(String(36), nullable=False, default="", index=True)
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -574,3 +575,21 @@ class VerificationCodeModel(Base):
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
attempts = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class VideoShareModel(Base):
|
||||
"""视频分享记录."""
|
||||
|
||||
__tablename__ = "video_shares"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
video_id = Column(String(32), nullable=False, index=True)
|
||||
user_id = Column(String(32), nullable=False, index=True)
|
||||
share_token = Column(String(16), nullable=False, unique=True)
|
||||
password_hash = Column(String(255), nullable=True)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
view_count = Column(Integer, nullable=False, default=0)
|
||||
download_count = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -67,13 +67,24 @@ class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
|
||||
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
# SQLAlchemy 从数据库读出的 DateTime 是 naive(不带时区),
|
||||
# 领域模型期望 aware datetime(带 timezone.utc),直接用会报
|
||||
# "can't compare offset-naive and offset-aware datetimes"
|
||||
def _ensure_aware(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
return VerificationCode(
|
||||
id=model.id,
|
||||
recipient=model.recipient,
|
||||
code=model.code,
|
||||
code_type=model.code_type,
|
||||
expires_at=model.expires_at,
|
||||
used_at=model.used_at,
|
||||
expires_at=_ensure_aware(model.expires_at),
|
||||
used_at=_ensure_aware(model.used_at),
|
||||
attempts=model.attempts,
|
||||
created_at=model.created_at,
|
||||
created_at=_ensure_aware(model.created_at),
|
||||
)
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"""视频分享 SQLAlchemy Repository 实现."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoShareModel
|
||||
from packages.domain.video_share import VideoShare
|
||||
from packages.ports.video_share_repository import VideoShareRepositoryPort
|
||||
|
||||
|
||||
def _model_to_domain(model: VideoShareModel) -> VideoShare:
|
||||
return VideoShare(
|
||||
id=model.id,
|
||||
video_id=model.video_id,
|
||||
user_id=model.user_id,
|
||||
share_token=model.share_token,
|
||||
password_hash=model.password_hash,
|
||||
expires_at=model.expires_at,
|
||||
view_count=model.view_count or 0,
|
||||
download_count=model.download_count or 0,
|
||||
is_active=model.is_active if model.is_active is not None else True,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SQLAlchemyVideoShareRepository(VideoShareRepositoryPort):
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, share: VideoShare) -> VideoShare:
|
||||
model = VideoShareModel(
|
||||
id=share.id,
|
||||
video_id=share.video_id,
|
||||
user_id=share.user_id,
|
||||
share_token=share.share_token,
|
||||
password_hash=share.password_hash,
|
||||
expires_at=share.expires_at,
|
||||
view_count=share.view_count,
|
||||
download_count=share.download_count,
|
||||
is_active=share.is_active,
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return share
|
||||
|
||||
def get_by_token(self, token: str) -> Optional[VideoShare]:
|
||||
model = self.session.query(VideoShareModel).filter(VideoShareModel.share_token == token).first()
|
||||
if model is None:
|
||||
return None
|
||||
return _model_to_domain(model)
|
||||
|
||||
def get_by_id(self, share_id: str, user_id: str) -> Optional[VideoShare]:
|
||||
model = (
|
||||
self.session.query(VideoShareModel)
|
||||
.filter(
|
||||
VideoShareModel.id == share_id,
|
||||
VideoShareModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return _model_to_domain(model)
|
||||
|
||||
def list_by_video(self, video_id: str, user_id: str) -> List[VideoShare]:
|
||||
models = (
|
||||
self.session.query(VideoShareModel)
|
||||
.filter(
|
||||
VideoShareModel.video_id == video_id,
|
||||
VideoShareModel.user_id == user_id,
|
||||
)
|
||||
.order_by(VideoShareModel.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_model_to_domain(m) for m in models]
|
||||
|
||||
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 20) -> List[VideoShare]:
|
||||
models = (
|
||||
self.session.query(VideoShareModel)
|
||||
.filter(VideoShareModel.user_id == user_id)
|
||||
.order_by(VideoShareModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [_model_to_domain(m) for m in models]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return self.session.query(VideoShareModel).filter(VideoShareModel.user_id == user_id).count()
|
||||
|
||||
def update(self, share: VideoShare) -> VideoShare:
|
||||
model = self.session.query(VideoShareModel).filter(VideoShareModel.id == share.id).first()
|
||||
if model is None:
|
||||
return share
|
||||
model.password_hash = share.password_hash
|
||||
model.expires_at = share.expires_at
|
||||
model.is_active = share.is_active
|
||||
model.view_count = share.view_count
|
||||
model.download_count = share.download_count
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return share
|
||||
|
||||
def delete(self, share_id: str, user_id: str) -> bool:
|
||||
model = (
|
||||
self.session.query(VideoShareModel)
|
||||
.filter(
|
||||
VideoShareModel.id == share_id,
|
||||
VideoShareModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def increment_view(self, share_id: str) -> None:
|
||||
self.session.query(VideoShareModel).filter(VideoShareModel.id == share_id).update(
|
||||
{
|
||||
"view_count": VideoShareModel.view_count + 1,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def increment_download(self, share_id: str) -> None:
|
||||
self.session.query(VideoShareModel).filter(VideoShareModel.id == share_id).update(
|
||||
{
|
||||
"download_count": VideoShareModel.download_count + 1,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
self.session.commit()
|
||||
@@ -22,6 +22,7 @@ class CreateGenerationTaskCommand:
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
|
||||
@@ -50,6 +51,7 @@ class CreateGenerationTaskUseCase:
|
||||
asset_select_mode=command.asset_select_mode,
|
||||
batch_id=command.batch_id,
|
||||
video_title=command.video_title,
|
||||
resolution=command.resolution,
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
)
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
"""视频分享 Commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateShareCommand:
|
||||
"""创建分享链接命令."""
|
||||
|
||||
video_id: str
|
||||
user_id: str
|
||||
password: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None # None表示永久有效
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifySharePasswordCommand:
|
||||
"""验证分享密码命令."""
|
||||
|
||||
share_token: str
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateShareCommand:
|
||||
"""更新分享配置命令."""
|
||||
|
||||
share_id: str
|
||||
user_id: str
|
||||
password: Optional[str] = None # None表示不修改,空字符串表示清除密码
|
||||
expires_at: Optional[datetime] = None # None表示不修改
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
"""视频分享 Use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from packages.application.video_share.commands import (
|
||||
CreateShareCommand,
|
||||
UpdateShareCommand,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
from packages.domain.video_share import VideoShare
|
||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
||||
from packages.ports.video_share_repository import VideoShareRepositoryPort
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""分享记录不存在."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class VideoNotFoundError(Exception):
|
||||
"""视频不存在."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ShareExpiredError(Exception):
|
||||
"""分享已过期或已撤销."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PasswordRequiredError(Exception):
|
||||
"""需要访问密码."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPasswordError(Exception):
|
||||
"""密码错误."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShareAccessResult:
|
||||
"""分享访问结果(验证通过后返回视频信息+分享记录)."""
|
||||
|
||||
share: VideoShare
|
||||
video: GeneratedVideo
|
||||
password_verified: bool = True
|
||||
|
||||
|
||||
class CreateShareUseCase:
|
||||
"""创建视频分享链接."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
share_repository: VideoShareRepositoryPort,
|
||||
video_repository: GeneratedVideoRepository,
|
||||
) -> None:
|
||||
self.share_repo = share_repository
|
||||
self.video_repo = video_repository
|
||||
|
||||
def execute(self, command: CreateShareCommand) -> VideoShare:
|
||||
# 校验视频存在且属于该用户
|
||||
video = self.video_repo.get(command.video_id)
|
||||
if video is None:
|
||||
raise VideoNotFoundError(f"Video {command.video_id} not found")
|
||||
|
||||
# 用 user_id 校验(视频的user_id需要匹配)
|
||||
if hasattr(video, "user_id") and video.user_id and video.user_id != command.user_id:
|
||||
raise VideoNotFoundError("Video not found")
|
||||
|
||||
share = VideoShare.create(
|
||||
video_id=command.video_id,
|
||||
user_id=command.user_id,
|
||||
password=command.password,
|
||||
expires_at=command.expires_at,
|
||||
)
|
||||
return self.share_repo.create(share)
|
||||
|
||||
|
||||
class GetShareByTokenUseCase:
|
||||
"""通过token获取分享信息(不带视频内容,仅元信息)。
|
||||
|
||||
用于分享页加载前判断:是否需要密码、是否过期等。
|
||||
"""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, token: str) -> VideoShare:
|
||||
share = self.share_repo.get_by_token(token)
|
||||
if share is None:
|
||||
raise NotFoundError(f"Share not found: {token}")
|
||||
if not share.is_accessible:
|
||||
raise ShareExpiredError("Share is not accessible")
|
||||
return share
|
||||
|
||||
|
||||
class AccessShareUseCase:
|
||||
"""访问分享内容(验证密码+返回视频信息+计数浏览量)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
share_repository: VideoShareRepositoryPort,
|
||||
video_repository: GeneratedVideoRepository,
|
||||
) -> None:
|
||||
self.share_repo = share_repository
|
||||
self.video_repo = video_repository
|
||||
|
||||
def execute(self, token: str, password: Optional[str] = None) -> ShareAccessResult:
|
||||
share = self.share_repo.get_by_token(token)
|
||||
if share is None:
|
||||
raise NotFoundError(f"Share not found: {token}")
|
||||
if not share.is_accessible:
|
||||
raise ShareExpiredError("Share is not accessible")
|
||||
|
||||
# 密码校验
|
||||
password_verified = True
|
||||
if share.has_password:
|
||||
if not password:
|
||||
raise PasswordRequiredError("Password required")
|
||||
if not share.verify_password(password):
|
||||
raise InvalidPasswordError("Invalid password")
|
||||
password_verified = True
|
||||
|
||||
# 获取视频信息
|
||||
video = self.video_repo.get(share.video_id)
|
||||
if video is None:
|
||||
raise VideoNotFoundError("Video not found")
|
||||
|
||||
# 浏览量+1
|
||||
self.share_repo.increment_view(share.id)
|
||||
share.view_count += 1
|
||||
|
||||
return ShareAccessResult(share=share, video=video, password_verified=password_verified)
|
||||
|
||||
|
||||
class ListSharesByVideoUseCase:
|
||||
"""列出某个视频的所有分享记录."""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, video_id: str, user_id: str) -> List[VideoShare]:
|
||||
return self.share_repo.list_by_video(video_id, user_id)
|
||||
|
||||
|
||||
class ListSharesByUserUseCase:
|
||||
"""列出用户创建的所有分享记录."""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, user_id: str, skip: int = 0, limit: int = 20) -> tuple[List[VideoShare], int]:
|
||||
items = self.share_repo.list_by_user(user_id, skip=skip, limit=limit)
|
||||
total = self.share_repo.count_by_user(user_id)
|
||||
return items, total
|
||||
|
||||
|
||||
class UpdateShareUseCase:
|
||||
"""更新分享配置(密码、有效期等)."""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, command: UpdateShareCommand) -> VideoShare:
|
||||
share = self.share_repo.get_by_id(command.share_id, command.user_id)
|
||||
if share is None:
|
||||
raise NotFoundError(f"Share {command.share_id} not found")
|
||||
|
||||
# password=None表示不修改;空字符串表示清除密码
|
||||
if command.password is not None:
|
||||
from packages.domain.video_share import _hash_password
|
||||
|
||||
if command.password == "":
|
||||
share.password_hash = None
|
||||
else:
|
||||
share.password_hash = _hash_password(command.password)
|
||||
|
||||
# expires_at=None表示不修改
|
||||
if command.expires_at is not None:
|
||||
if command.expires_at < datetime.now(timezone.utc):
|
||||
raise ValueError("expires_at cannot be in the past")
|
||||
share.expires_at = command.expires_at
|
||||
|
||||
return self.share_repo.update(share)
|
||||
|
||||
|
||||
class RevokeShareUseCase:
|
||||
"""撤销/删除分享."""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, share_id: str, user_id: str) -> bool:
|
||||
share = self.share_repo.get_by_id(share_id, user_id)
|
||||
if share is None:
|
||||
raise NotFoundError(f"Share {share_id} not found")
|
||||
return self.share_repo.delete(share_id, user_id)
|
||||
|
||||
|
||||
class RecordShareDownloadUseCase:
|
||||
"""记录分享下载(下载量+1)."""
|
||||
|
||||
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
|
||||
self.share_repo = share_repository
|
||||
|
||||
def execute(self, token: str, password: Optional[str] = None) -> None:
|
||||
share = self.share_repo.get_by_token(token)
|
||||
if share is None:
|
||||
raise NotFoundError(f"Share not found: {token}")
|
||||
if not share.is_accessible:
|
||||
raise ShareExpiredError("Share is not accessible")
|
||||
|
||||
# 密码校验
|
||||
if share.has_password:
|
||||
if not password or not share.verify_password(password):
|
||||
raise InvalidPasswordError("Invalid password")
|
||||
|
||||
self.share_repo.increment_download(share.id)
|
||||
@@ -91,6 +91,7 @@ class GenerationTask:
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -112,6 +113,7 @@ class GenerationTask:
|
||||
asset_select_mode: str = "",
|
||||
batch_id: str = "",
|
||||
video_title: str = "",
|
||||
resolution: str = "",
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
) -> "GenerationTask":
|
||||
@@ -134,6 +136,7 @@ class GenerationTask:
|
||||
asset_select_mode=asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=video_title.strip(),
|
||||
resolution=resolution.strip(),
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
)
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
"""视频分享领域实体."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""简单密码哈希(SHA-256 + salt)。
|
||||
|
||||
分享链接的密码保护安全级别要求不高,
|
||||
使用简单的加盐哈希即可,避免引入bcrypt等重依赖。
|
||||
"""
|
||||
if not password:
|
||||
return ""
|
||||
salt = "xiaoxia_share_salt"
|
||||
return sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
|
||||
|
||||
def generate_share_token(length: int = 12) -> str:
|
||||
"""生成URL友好的分享token."""
|
||||
# 使用urlsafe的base64,但去掉可能引起歧义的字符
|
||||
alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VideoShare:
|
||||
"""视频分享记录."""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
user_id: str
|
||||
share_token: str
|
||||
password_hash: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
view_count: int = 0
|
||||
download_count: int = 0
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
video_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
password: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> "VideoShare":
|
||||
if not video_id.strip():
|
||||
raise ValueError("video_id cannot be empty")
|
||||
if not user_id.strip():
|
||||
raise ValueError("user_id cannot be empty")
|
||||
if expires_at and expires_at < datetime.now(timezone.utc):
|
||||
raise ValueError("expires_at cannot be in the past")
|
||||
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
video_id=video_id.strip(),
|
||||
user_id=user_id.strip(),
|
||||
share_token=generate_share_token(),
|
||||
password_hash=_hash_password(password) if password else None,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
@property
|
||||
def has_password(self) -> bool:
|
||||
"""是否设置了访问密码."""
|
||||
return bool(self.password_hash)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""是否已过期."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return datetime.now(timezone.utc) > self.expires_at
|
||||
|
||||
@property
|
||||
def is_accessible(self) -> bool:
|
||||
"""是否可以访问(活跃且未过期)."""
|
||||
return self.is_active and not self.is_expired
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""验证访问密码."""
|
||||
if not self.password_hash:
|
||||
return True # 没有密码直接通过
|
||||
if not password:
|
||||
return False
|
||||
return _hash_password(password) == self.password_hash
|
||||
|
||||
def increment_view_count(self) -> None:
|
||||
"""浏览次数+1."""
|
||||
self.view_count += 1
|
||||
|
||||
def increment_download_count(self) -> None:
|
||||
"""下载次数+1."""
|
||||
self.download_count += 1
|
||||
|
||||
def revoke(self) -> None:
|
||||
"""撤销分享."""
|
||||
self.is_active = False
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
"""视频分享 Repository 端口."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
|
||||
from packages.domain.video_share import VideoShare
|
||||
|
||||
|
||||
class VideoShareRepositoryPort(ABC):
|
||||
"""视频分享 Repository 接口."""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, share: VideoShare) -> VideoShare:
|
||||
"""创建分享记录."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_by_token(self, token: str) -> Optional[VideoShare]:
|
||||
"""通过分享token获取分享记录."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_by_id(self, share_id: str, user_id: str) -> Optional[VideoShare]:
|
||||
"""通过ID获取分享记录(带用户校验)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_by_video(self, video_id: str, user_id: str) -> List[VideoShare]:
|
||||
"""列出某个视频的所有分享记录."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 20) -> List[VideoShare]:
|
||||
"""列出用户创建的所有分享记录."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
"""统计用户创建的分享数量."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def update(self, share: VideoShare) -> VideoShare:
|
||||
"""更新分享记录."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, share_id: str, user_id: str) -> bool:
|
||||
"""删除分享记录(软删除:is_active=False)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def increment_view(self, share_id: str) -> None:
|
||||
"""浏览次数+1."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def increment_download(self, share_id: str) -> None:
|
||||
"""下载次数+1."""
|
||||
...
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
"""豆包大模型 API 客户端(共享层).
|
||||
|
||||
API 和 Worker 两边共用。基于火山引擎方舟平台的 OpenAI 兼容接口。
|
||||
|
||||
使用方式:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
if client.is_available:
|
||||
result = client.chat_completion(messages=[...])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DoubaoClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
封装 OpenAI 兼容的 Chat Completion 接口,支持自动重试。
|
||||
未配置 API Key 时 is_available 为 False,调用方应降级处理。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_shared_settings()
|
||||
self.api_key: str = settings.doubao_api_key
|
||||
self.model: str = settings.doubao_model
|
||||
self.base_url: str = settings.doubao_base_url.rstrip("/")
|
||||
self.timeout: int = settings.doubao_timeout
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> Optional[str]:
|
||||
"""调用 Chat Completion 接口.
|
||||
|
||||
Args:
|
||||
messages: 对话消息列表,[{"role": "user"/"system"/"assistant", "content": "..."}]
|
||||
temperature: 采样温度,0-2,默认0.7
|
||||
max_tokens: 最大生成token数,默认1024
|
||||
|
||||
Returns:
|
||||
模型返回的文本内容,失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包API调用失败,%.1fs后重试 (第%d/%d次): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
|
||||
# ── 单例 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_client: Optional[DoubaoClient] = None
|
||||
|
||||
|
||||
def get_doubao_client() -> DoubaoClient:
|
||||
"""获取豆包客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = DoubaoClient()
|
||||
return _client
|
||||
@@ -39,6 +39,13 @@ class SharedSettings(BaseSettings):
|
||||
# 音色克隆模型名(固定为 voice-enrollment)
|
||||
cosyvoice_clone_model: str = "voice-enrollment"
|
||||
|
||||
# 豆包大模型(火山引擎方舟)
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-1-6-250615"
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
|
||||
# Environment
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
# 自动审批:CI全绿后自动approve PR
|
||||
# 环境变量:GITHUB_TOKEN, REVIEW_TOKEN, PR_NUMBER, PR_HEAD_SHA, GITHUB_API_URL, GITHUB_REPOSITORY
|
||||
set -eu
|
||||
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 还有CI在跑 → 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
|
||||
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
|
||||
set -eu
|
||||
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 405连续计数器
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得到Worker构建完成
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo
|
||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
||||
exit 0
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
|
||||
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
|
||||
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
@@ -25,60 +26,18 @@ else
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
|
||||
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
echo "=== PR Build: build only, no push, read-only cache ==="
|
||||
echo "=== PR Build: build only, no output, remote cache only ==="
|
||||
echo "Dockerfile: ${DOCKERFILE}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo ""
|
||||
|
||||
build_with_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=2
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
local build_output
|
||||
local exit_code
|
||||
set +e
|
||||
build_output=$(docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
. 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "$build_output"
|
||||
return 0
|
||||
fi
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
|
||||
echo "$build_output"
|
||||
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
|
||||
rm -rf "${LOCAL_CACHE_DIR}"
|
||||
mkdir -p "${LOCAL_CACHE_DIR}"
|
||||
docker buildx prune -f -a >/dev/null 2>&1 || true
|
||||
attempt=$((attempt + 1))
|
||||
else
|
||||
echo "$build_output"
|
||||
return $exit_code
|
||||
fi
|
||||
done
|
||||
echo "Local cache failed, building with registry cache only..."
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
.
|
||||
}
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
.
|
||||
|
||||
build_with_retry
|
||||
echo ""
|
||||
echo "PR build OK (not pushed): ${IMAGE_TAG}"
|
||||
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
|
||||
|
||||
+97
-18
@@ -2,11 +2,14 @@
|
||||
"""
|
||||
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
|
||||
作为短作业模式的兜底机制,每5分钟运行一次
|
||||
|
||||
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -110,7 +113,55 @@ def has_approval(token, repo, pr_number):
|
||||
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
|
||||
|
||||
|
||||
def approve_pr(token, repo, pr_number):
|
||||
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(
|
||||
@@ -118,7 +169,7 @@ def approve_pr(token, repo, pr_number):
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews",
|
||||
method="POST",
|
||||
data={"event": "PENDING", "body": "CI全绿,自动审批通过。"},
|
||||
data={"event": "PENDING", "body": reason},
|
||||
)
|
||||
|
||||
if code not in (200, 201):
|
||||
@@ -137,7 +188,7 @@ def approve_pr(token, repo, pr_number):
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}/events",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
|
||||
if code2 in (200, 201):
|
||||
@@ -149,13 +200,25 @@ def approve_pr(token, repo, pr_number):
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
|
||||
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)"""
|
||||
# 等待几秒让状态同步
|
||||
@@ -200,6 +263,7 @@ def main():
|
||||
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()
|
||||
|
||||
@@ -233,12 +297,13 @@ def main():
|
||||
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("ref", "")
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
@@ -269,25 +334,38 @@ def main():
|
||||
# 检查审批用的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:
|
||||
if args.approve and all_ok and not failed and not ai_has_critical:
|
||||
if has_approval(args.token, args.repo, pr_num):
|
||||
print(f" ✅ 已有审批,跳过")
|
||||
print(" ✅ 已有审批,跳过")
|
||||
else:
|
||||
if dry_run:
|
||||
print(f" 🎯 [DRY-RUN] 将自动审批")
|
||||
print(" 🎯 [DRY-RUN] 将自动审批")
|
||||
else:
|
||||
print(f" 🎯 执行自动审批...")
|
||||
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(f" ❌ CI有失败项,跳过审批")
|
||||
print(" ❌ CI有失败项,跳过审批")
|
||||
elif pending:
|
||||
print(f" ⏳ CI仍在运行,跳过")
|
||||
print(" ⏳ CI仍在运行,跳过")
|
||||
|
||||
# === 自动合并 ===
|
||||
if args.merge:
|
||||
@@ -301,9 +379,9 @@ def main():
|
||||
|
||||
if merge_ok and approved and not merge_failed:
|
||||
if dry_run:
|
||||
print(f" 🎯 [DRY-RUN] 将自动合并")
|
||||
print(" 🎯 [DRY-RUN] 将自动合并")
|
||||
else:
|
||||
print(f" 🎯 执行自动合并...")
|
||||
print(" 🎯 执行自动合并...")
|
||||
ok, msg = merge_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 合并成功: {msg}")
|
||||
@@ -311,18 +389,19 @@ def main():
|
||||
else:
|
||||
print(f" ⚠️ 合并失败: {msg}")
|
||||
elif merge_pending:
|
||||
print(f" ⏳ 合并条件未满足: CI运行中")
|
||||
print(" ⏳ 合并条件未满足: CI运行中")
|
||||
elif merge_failed:
|
||||
print(f" ❌ 合并条件未满足: CI有失败")
|
||||
print(" ❌ 合并条件未满足: CI有失败")
|
||||
elif not approved:
|
||||
print(f" ⏳ 合并条件未满足: 无审批")
|
||||
print(" ⏳ 合并条件未满足: 无审批")
|
||||
|
||||
print(f"\n=== 扫描结果 ===")
|
||||
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(f" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -252,29 +252,14 @@ echo ""
|
||||
echo "=== 运行集成测试(pytest-xdist 并行模式) ==="
|
||||
echo "CPU 核数: $(nproc 2>/dev/null || echo 'unknown')"
|
||||
|
||||
# 根据可用内存动态计算 worker 数,防止 OOM
|
||||
# 每个 worker 约占 200-300MB(含 DB 连接 + FastAPI test client)
|
||||
# 预留 1GB 给系统 + ffmpeg 等子进程
|
||||
AVAIL_MEM_MB=$(($(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 2097152) / 1024))
|
||||
RESERVED_MB=1024
|
||||
PER_WORKER_MB=256
|
||||
MAX_WORKERS=$(( (AVAIL_MEM_MB - RESERVED_MB) / PER_WORKER_MB ))
|
||||
# 下限 2,上限 8,CPU 核数也作为上限
|
||||
CPU_CORES=$(nproc 2>/dev/null || echo 4)
|
||||
XDIST_WORKERS=$MAX_WORKERS
|
||||
[ $XDIST_WORKERS -lt 2 ] && XDIST_WORKERS=2
|
||||
[ $XDIST_WORKERS -gt 8 ] && XDIST_WORKERS=8
|
||||
[ $XDIST_WORKERS -gt $CPU_CORES ] && XDIST_WORKERS=$CPU_CORES
|
||||
echo "可用内存: ${AVAIL_MEM_MB}MB, CPU核数: ${CPU_CORES}, xdist workers: ${XDIST_WORKERS}"
|
||||
|
||||
# 集成测试使用 pytest-xdist 并行加速(coverage 由单元测试负责,并行模式下 coverage 不稳定)
|
||||
# -n N: 并行 worker 数
|
||||
# -n auto: 自动使用 CPU 核数(DooD模式下加--maxprocesses=4防止OOM
|
||||
# --dist loadfile: 同一测试文件分配到同一 worker(共享 fixture 更高效)
|
||||
# --maxfail=3: 容忍少量失败(避免偶发 OOM 导致全挂)
|
||||
# --maxfail=1: 遇到失败停止调度新测试(并行模式下等价于 -x)
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration \
|
||||
-q --timeout=60 --maxfail=3 --reruns 2 --reruns-delay 1 \
|
||||
-q --timeout=60 --maxfail=1 --reruns 3 --reruns-delay 5 \
|
||||
-m "not performance" \
|
||||
-n $XDIST_WORKERS --dist loadfile \
|
||||
-n auto --maxprocesses=4 --dist loadfile \
|
||||
-p no:cacheprovider
|
||||
|
||||
echo "✅ 集成测试通过"
|
||||
@@ -286,7 +271,9 @@ set +e
|
||||
PERF_OUTPUT=$(mktemp)
|
||||
# 性能测试单独串行运行(不参与并行,避免资源竞争影响测量结果)
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \
|
||||
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT"
|
||||
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" \
|
||||
--reruns 3 \
|
||||
--reruns-delay=10
|
||||
echo ""
|
||||
echo "=== 性能测试摘要 ==="
|
||||
grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据"
|
||||
|
||||
@@ -7,6 +7,11 @@ JOB_NAME="${1:-Unit Tests}"
|
||||
|
||||
echo "=== CI Unit Tests 开始 ==="
|
||||
|
||||
# --- 配置 pip 国内源(加速下载,减少网络失败)---
|
||||
python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
|
||||
python3 -m pip config set global.timeout 120
|
||||
python3 -m pip config set global.retries 5
|
||||
|
||||
# --- 安装依赖 ---
|
||||
echo ""
|
||||
echo "=== 安装 Python 依赖 ==="
|
||||
@@ -23,6 +28,12 @@ for i in 1 2 3; do
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-worker.txt && break
|
||||
echo "pip install requirements-worker.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
@@ -64,8 +75,8 @@ echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
|
||||
|
||||
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
echo "=== 增量测试模式 ==="
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest $SELECTED_TEST_FILES -q
|
||||
@@ -73,8 +84,8 @@ if [ "$UNIT_TEST_MODE" = "incremental" ]; then
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=10 > /dev/null || true
|
||||
else
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,packages \
|
||||
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
|
||||
--source=apps/api/app,apps/worker/worker_app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
--branch \
|
||||
-m pytest tests/unit -q
|
||||
|
||||
@@ -1,96 +1,18 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端依赖安装(Docker Volume 持久化缓存方案)
|
||||
# 通过 Docker named volume 缓存 node_modules,按 package-lock.json hash 命名
|
||||
# 缓存命中时跳过 npm ci,直接复用已有 volume
|
||||
# CI 公共步骤:前端依赖安装
|
||||
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js),无需 Docker 嵌套
|
||||
set -eu
|
||||
|
||||
MODE="${1:-full}"
|
||||
|
||||
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
|
||||
|
||||
# npm国内镜像源(加速下载,减少网络失败)
|
||||
NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
cd apps/web
|
||||
|
||||
# 缓存配置 — 与 step_frontend_run.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
KEEP_CACHE_COUNT=5
|
||||
# 配置国内镜像源加速
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 计算 package-lock.json 的 md5 hash 作为缓存 key
|
||||
VOLUME_NAME=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
echo "缓存 key: $LOCK_HASH (volume: $VOLUME_NAME)"
|
||||
else
|
||||
echo "警告: 未找到 $LOCK_FILE,将不使用持久化缓存"
|
||||
fi
|
||||
|
||||
# 检查 volume 是否存在(缓存命中)
|
||||
CACHE_HIT=0
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
CACHE_HIT=1
|
||||
echo "缓存命中!复用 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "缓存未命中,创建 volume 并安装依赖..."
|
||||
# 创建 volume(失败则降级为无缓存模式)
|
||||
if ! docker volume create "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
echo "警告: 创建 volume 失败,降级为无缓存模式"
|
||||
VOLUME_NAME=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 构建 docker run 的 volume 挂载参数(空时不挂载)
|
||||
VOLUME_ARGS=""
|
||||
if [ -n "$VOLUME_NAME" ]; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
fi
|
||||
|
||||
# 缓存未命中时执行 npm ci
|
||||
if [ "$CACHE_HIT" -eq 0 ]; then
|
||||
for i in 1 2 3; do
|
||||
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
done
|
||||
else
|
||||
echo "跳过 npm ci,直接使用缓存的 node_modules"
|
||||
fi
|
||||
|
||||
# 清理旧缓存 volume(保留最近 N 个,防止磁盘占用无限增长)
|
||||
if [ -n "$VOLUME_PREFIX" ]; then
|
||||
echo "清理旧缓存 volume(保留最近 ${KEEP_CACHE_COUNT} 个)..."
|
||||
ALL_VOLUMES=$(docker volume ls -q --filter "name=${VOLUME_PREFIX}" 2>/dev/null || true)
|
||||
if [ -n "$ALL_VOLUMES" ]; then
|
||||
TOTAL=$(echo "$ALL_VOLUMES" | wc -l)
|
||||
if [ "$TOTAL" -gt "$KEEP_CACHE_COUNT" ]; then
|
||||
# 按创建时间排序,保留最新的 N 个
|
||||
SORTED_VOLUMES=$(for v in $ALL_VOLUMES; do
|
||||
CREATED=$(docker volume inspect --format '{{.CreatedAt}}' "$v" 2>/dev/null || echo "0")
|
||||
echo "$CREATED $v"
|
||||
done | sort | awk '{print $2}')
|
||||
|
||||
# 删除超出保留数量的旧 volume
|
||||
REMOVE_COUNT=$((TOTAL - KEEP_CACHE_COUNT))
|
||||
TO_DELETE=$(echo "$SORTED_VOLUMES" | head -n "$REMOVE_COUNT")
|
||||
REMOVED=0
|
||||
for v in $TO_DELETE; do
|
||||
# 跳过当前正在使用的 volume
|
||||
if [ "$v" != "$VOLUME_NAME" ]; then
|
||||
if docker volume rm "$v" >/dev/null 2>&1; then
|
||||
REMOVED=$((REMOVED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "已清理 $REMOVED 个旧缓存 volume,当前共 $((TOTAL - REMOVED)) 个"
|
||||
else
|
||||
echo "当前缓存 volume 数量: $TOTAL,无需清理"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 安装依赖
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端命令执行(在 docker node 容器中运行)
|
||||
# 用法:step_frontend_run.sh "要执行的命令"
|
||||
# 支持 Docker Volume 持久化缓存的 node_modules
|
||||
# CI 公共步骤:前端命令执行
|
||||
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js + pnpm),无需 Docker 嵌套
|
||||
set -eu
|
||||
|
||||
CMD="${1:-echo 'no command'}"
|
||||
|
||||
# 缓存配置 — 与 step_frontend_install.sh 保持一致
|
||||
LOCK_FILE="apps/web/package-lock.json"
|
||||
VOLUME_PREFIX="ci-web-nm-"
|
||||
|
||||
# 计算 package-lock.json 的 hash,挂载对应的 volume
|
||||
VOLUME_ARGS=""
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
|
||||
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
|
||||
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
|
||||
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
|
||||
echo "使用缓存 volume: $VOLUME_NAME"
|
||||
else
|
||||
echo "提示: 未找到缓存 volume $VOLUME_NAME,将使用源码目录 node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "$CMD"
|
||||
cd apps/web
|
||||
sh -lc "$CMD"
|
||||
|
||||
@@ -79,6 +79,25 @@ try:
|
||||
print(' '.join(py_files))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
# 新增文件(added)强制全量检查,防止增量漏检
|
||||
ADDED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added']
|
||||
print(' '.join(added))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')]
|
||||
print(' '.join(modified))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
@@ -174,13 +193,42 @@ echo "发现潜在死代码(可能包含框架装饰器注册的函数,为
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- Release 脚本语法校验 ---
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] Release scripts syntax validation ==="
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
echo "✅ Release scripts syntax OK"
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查所有 CI Python 脚本语法
|
||||
for script in scripts/ci/*.py; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! python3 -m py_compile "$script" 2>&1; then
|
||||
echo "❌ Python语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查 .gitea/workflows 下的脚本(如果有)
|
||||
for script in .gitea/workflows/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$SYNTAX_ERROR" -ne 0 ]; then
|
||||
echo "❌ CI脚本语法校验失败,见上方错误"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
|
||||
Regular → Executable
+7
-15
@@ -1,15 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Vitest 增量执行脚本
|
||||
# Vitest 增量执行脚本(在Docker Node容器中运行)
|
||||
# PR模式下只跑与改动文件相关的测试,大幅节省时间
|
||||
# 用法: bash scripts/ci/vitest_incremental.sh
|
||||
set -eu
|
||||
|
||||
cd apps/web
|
||||
|
||||
# 如果不是PR事件,直接全量跑
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,全量执行Vitest"
|
||||
npx --no-install vitest run --coverage
|
||||
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
@@ -17,7 +14,7 @@ fi
|
||||
PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "无法获取PR编号,全量执行Vitest"
|
||||
npx --no-install vitest run --coverage
|
||||
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
@@ -29,9 +26,7 @@ try:
|
||||
web_files = []
|
||||
for f in files:
|
||||
fname = f['filename']
|
||||
# 只关注前端源码文件
|
||||
if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed':
|
||||
# 去掉apps/web/前缀,变成相对路径
|
||||
web_files.append(fname.replace('apps/web/', ''))
|
||||
print(' '.join(web_files))
|
||||
except Exception as e:
|
||||
@@ -40,36 +35,33 @@ except Exception as e:
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "PR未改动前端源码文件,跳过Vitest"
|
||||
echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w)
|
||||
echo "PR改动了 $FILE_COUNT 个前端文件"
|
||||
echo "改动文件: $CHANGED_FILES"
|
||||
|
||||
# 如果改动文件太多(超过30个),全量跑更可靠
|
||||
if [ "$FILE_COUNT" -gt 30 ]; then
|
||||
echo "改动文件较多(>$FILE_COUNT),降级为全量执行以确保覆盖"
|
||||
npx --no-install vitest run --coverage
|
||||
echo "改动文件较多,全量执行Vitest"
|
||||
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# 使用vitest related 跑增量测试(子命令,非flag)
|
||||
echo ""
|
||||
echo "=== 增量执行 Vitest(只跑相关测试)==="
|
||||
echo "相关源文件: $CHANGED_FILES"
|
||||
echo ""
|
||||
|
||||
# 在Docker Node容器中执行增量测试
|
||||
set +e
|
||||
npx --no-install vitest run related $CHANGED_FILES
|
||||
bash scripts/ci/step_frontend_run.sh "npx vitest run related $CHANGED_FILES"
|
||||
VITEST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$VITEST_EXIT" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "✅ 增量测试通过"
|
||||
echo "(仅覆盖与改动相关的测试用例)"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Agent提交前自动格式化脚本
|
||||
# 用法:./scripts/format.sh [path1 path2 ...]
|
||||
# 不传参数则格式化所有后端代码
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== 代码格式化 ==="
|
||||
|
||||
# 后端:black + isort(顺序:先isort后black,与pyproject.toml配置一致)
|
||||
if command -v black &>/dev/null && command -v isort &>/dev/null; then
|
||||
TARGETS="${@:-alembic apps packages tests scripts}"
|
||||
echo "后端格式化: $TARGETS"
|
||||
python3 -m isort $TARGETS
|
||||
python3 -m black $TARGETS
|
||||
echo "✅ 后端格式化完成"
|
||||
else
|
||||
echo "⚠️ 未安装black/isort,跳过后端格式化"
|
||||
fi
|
||||
|
||||
# 前端:prettier + eslint --fix(如果有前端改动)
|
||||
if [ -d "apps/web" ] && command -v npx &>/dev/null; then
|
||||
if [ "$#" -eq 0 ] || echo "$@" | grep -q "apps/web"; then
|
||||
echo "前端格式化: apps/web"
|
||||
(cd apps/web && npx eslint src --ext .ts,.tsx --fix 2>/dev/null || true)
|
||||
(cd apps/web && npx prettier --write "src/**/*.{ts,tsx,css,json}" 2>/dev/null || true)
|
||||
echo "✅ 前端格式化完成"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== 格式化全部完成 ==="
|
||||
|
||||
Executable
+432
@@ -0,0 +1,432 @@
|
||||
"""AI 服务层单元测试.
|
||||
|
||||
测试覆盖:
|
||||
- DoubaoAIClient 可用性检测
|
||||
- 智能标题生成(降级模式)
|
||||
- 标题解析(多种返回格式)
|
||||
- 风格校验
|
||||
- 参数边界
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, "apps/api")
|
||||
|
||||
from app.services.ai_service import ( # noqa: E402
|
||||
TITLE_STYLES,
|
||||
_generate_titles_fallback,
|
||||
_parse_semantic_match_response,
|
||||
_parse_titles_from_response,
|
||||
_semantic_match_fallback,
|
||||
generate_smart_titles,
|
||||
semantic_match_assets,
|
||||
)
|
||||
|
||||
|
||||
class TestAIClientAvailability(unittest.TestCase):
|
||||
"""AI客户端可用性检测(通过mock get_doubao_client)."""
|
||||
|
||||
def test_generate_fallback_when_client_unavailable(self):
|
||||
"""客户端不可用时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试内容", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
# 不可用时不应调用 chat_completion
|
||||
mock_client.chat_completion.assert_not_called()
|
||||
|
||||
def test_generate_calls_client_when_available(self):
|
||||
"""客户端可用时调用API."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(
|
||||
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
|
||||
)
|
||||
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试", "viral", 5)
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
|
||||
class TestTitleParsing(unittest.TestCase):
|
||||
"""标题解析测试 — 覆盖多种返回格式."""
|
||||
|
||||
def test_parse_json_array(self):
|
||||
"""解析 JSON 数组格式."""
|
||||
content = json.dumps(["标题一", "标题二", "标题三"])
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertEqual(result[0], "标题一")
|
||||
|
||||
def test_parse_json_with_titles_key(self):
|
||||
"""解析带 titles 字段的 JSON 对象."""
|
||||
content = json.dumps({"titles": ["标题A", "标题B"]})
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_parse_markdown_code_block_json(self):
|
||||
"""解析 markdown 代码块包裹的 JSON."""
|
||||
content = '```json\n["标题1", "标题2"]\n```'
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_parse_numbered_list(self):
|
||||
"""解析编号列表."""
|
||||
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertIn("第一个标题", result)
|
||||
|
||||
def test_parse_dash_list(self):
|
||||
"""解析破折号列表."""
|
||||
content = "- 标题甲\n- 标题乙\n- 标题丙"
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_parse_chinese_numbered(self):
|
||||
"""解析中文数字编号."""
|
||||
content = "1、标题一\n2、标题二"
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_parse_empty_content(self):
|
||||
"""空内容返回空列表."""
|
||||
result = _parse_titles_from_response("")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_parse_filters_long_lines(self):
|
||||
"""过滤过长的行."""
|
||||
long_title = "这是一个非常长的标题" * 15 # 超过100字
|
||||
content = f"1. 正常标题\n2. {long_title}\n3. 另一个标题"
|
||||
result = _parse_titles_from_response(content)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertNotIn(long_title, result)
|
||||
|
||||
def test_parse_invalid_json_falls_back_to_lines(self):
|
||||
"""无效 JSON 回退到按行解析."""
|
||||
content = '["标题1", "标题2", 无效'
|
||||
result = _parse_titles_from_response(content)
|
||||
# 至少能解析出一些内容
|
||||
self.assertTrue(len(result) >= 0)
|
||||
|
||||
|
||||
class TestFallbackGeneration(unittest.TestCase):
|
||||
"""降级生成测试."""
|
||||
|
||||
def test_fallback_returns_requested_count(self):
|
||||
"""返回请求的数量."""
|
||||
result = _generate_titles_fallback("测试内容", "viral", 5)
|
||||
self.assertEqual(len(result), 5)
|
||||
|
||||
def test_fallback_max_10(self):
|
||||
"""最多返回10个."""
|
||||
result = _generate_titles_fallback("测试内容", "viral", 20)
|
||||
self.assertEqual(len(result), 10)
|
||||
|
||||
def test_fallback_different_styles(self):
|
||||
"""不同风格都能生成."""
|
||||
for style in ["viral", "emotional", "informative"]:
|
||||
result = _generate_titles_fallback("测试", style, 3)
|
||||
self.assertEqual(len(result), 3)
|
||||
for title in result:
|
||||
self.assertTrue(len(title) > 0)
|
||||
|
||||
def test_fallback_contains_keyword(self):
|
||||
"""标题包含关键词."""
|
||||
result = _generate_titles_fallback("旅行攻略", "viral", 5)
|
||||
has_keyword = any("旅行" in t for t in result)
|
||||
self.assertTrue(has_keyword)
|
||||
|
||||
|
||||
class TestGenerateSmartTitles(unittest.TestCase):
|
||||
"""智能标题生成集成测试."""
|
||||
|
||||
def test_generate_without_api_key_fallback(self):
|
||||
"""无 API Key 时走降级路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频内容", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(result["style"], "viral")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
|
||||
def test_generate_invalid_style_defaults_to_viral(self):
|
||||
"""无效风格默认 viral."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试", "invalid_style", 5)
|
||||
self.assertEqual(result["style"], "viral")
|
||||
|
||||
def test_generate_count_bounds(self):
|
||||
"""数量边界处理."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
# 小于最小值
|
||||
result = generate_smart_titles("测试", "viral", 1)
|
||||
self.assertEqual(len(result["titles"]), 3)
|
||||
# 大于最大值
|
||||
result = generate_smart_titles("测试", "viral", 100)
|
||||
self.assertEqual(len(result["titles"]), 10)
|
||||
|
||||
def test_generate_with_api_success(self):
|
||||
"""API 调用成功路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(
|
||||
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
|
||||
)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
self.assertIn("AI标题1", result["titles"])
|
||||
|
||||
def test_generate_with_api_failure_fallback(self):
|
||||
"""API 调用失败时降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(len(result["titles"]), 5)
|
||||
|
||||
def test_generate_api_returns_unparseable_fallback(self):
|
||||
"""API 返回无法解析时降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
# 返回无法解析的内容(只有一个标题且格式异常)
|
||||
mock_client.chat_completion = MagicMock(return_value="一段文字说明,不是标题列表")
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = generate_smart_titles("测试视频", "viral", 5)
|
||||
# 只有1个有效标题,不足2个触发降级
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
|
||||
|
||||
class TestTitleStyles(unittest.TestCase):
|
||||
"""标题风格定义测试."""
|
||||
|
||||
def test_all_styles_have_required_fields(self):
|
||||
"""所有风格都有必要字段."""
|
||||
for _key, info in TITLE_STYLES.items():
|
||||
self.assertIn("name", info)
|
||||
self.assertIn("description", info)
|
||||
self.assertIn("examples", info)
|
||||
self.assertTrue(len(info["examples"]) >= 2)
|
||||
|
||||
def test_three_styles_defined(self):
|
||||
"""定义了三种风格."""
|
||||
self.assertEqual(len(TITLE_STYLES), 3)
|
||||
self.assertIn("viral", TITLE_STYLES)
|
||||
self.assertIn("emotional", TITLE_STYLES)
|
||||
self.assertIn("informative", TITLE_STYLES)
|
||||
|
||||
|
||||
# ── 语义匹配测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSemanticMatchFallback(unittest.TestCase):
|
||||
"""降级关键词匹配测试."""
|
||||
|
||||
def _make_assets(self):
|
||||
return [
|
||||
{"id": "a1", "name": "海边日落风景", "tags": ["风景", "海边", "日落"], "description": "美丽的海边日落"},
|
||||
{"id": "a2", "name": "城市夜景航拍", "tags": ["城市", "夜景", "航拍"], "description": "城市夜景航拍素材"},
|
||||
{"id": "a3", "name": "美食制作过程", "tags": ["美食", "烹饪", "教程"], "description": "美食制作教程"},
|
||||
]
|
||||
|
||||
def test_fallback_returns_sorted_scores(self):
|
||||
"""返回按匹配度降序排列."""
|
||||
assets = self._make_assets()
|
||||
result = _semantic_match_fallback("海边日落风景视频", assets)
|
||||
self.assertEqual(len(result), 3)
|
||||
# 第一个应该是海边日落
|
||||
self.assertEqual(result[0]["id"], "a1")
|
||||
self.assertGreater(result[0]["match_score"], result[2]["match_score"])
|
||||
|
||||
def test_fallback_each_has_match_score(self):
|
||||
"""每个素材都有 match_score."""
|
||||
assets = self._make_assets()
|
||||
result = _semantic_match_fallback("测试", assets)
|
||||
for item in result:
|
||||
self.assertIn("match_score", item)
|
||||
self.assertGreaterEqual(item["match_score"], 0.0)
|
||||
self.assertLessEqual(item["match_score"], 1.0)
|
||||
self.assertIn("match_reason", item)
|
||||
|
||||
def test_fallback_unrelated_desc_low_scores(self):
|
||||
"""完全不相关的描述得分低."""
|
||||
assets = self._make_assets()
|
||||
result = _semantic_match_fallback("篮球比赛运动", assets)
|
||||
# 所有素材得分都应该较低
|
||||
for item in result:
|
||||
self.assertLess(item["match_score"], 0.8)
|
||||
|
||||
def test_fallback_empty_keywords_default_score(self):
|
||||
"""无有效关键词时给默认分."""
|
||||
assets = self._make_assets()
|
||||
result = _semantic_match_fallback("a", assets) # 单字符无有效关键词
|
||||
for item in result:
|
||||
self.assertEqual(item["match_score"], 0.5)
|
||||
self.assertEqual(item["match_reason"], "fallback_default")
|
||||
|
||||
def test_fallback_name_match_higher(self):
|
||||
"""名称命中得分更高."""
|
||||
assets = [
|
||||
{"id": "a1", "name": "美食探店vlog", "tags": [], "description": ""},
|
||||
{"id": "a2", "name": "风景视频", "tags": ["美食"], "description": ""},
|
||||
]
|
||||
result = _semantic_match_fallback("美食", assets)
|
||||
# a1名称含美食,a2标签含美食,名称命中应有额外加分
|
||||
self.assertEqual(result[0]["id"], "a1")
|
||||
self.assertGreater(result[0]["match_score"], result[1]["match_score"])
|
||||
|
||||
|
||||
class TestSemanticMatchParsing(unittest.TestCase):
|
||||
"""语义匹配返回解析测试."""
|
||||
|
||||
def test_parse_dict_format(self):
|
||||
"""解析 {id: score} 格式."""
|
||||
content = json.dumps({"asset1": 0.85, "asset2": 0.62, "asset3": 0.3})
|
||||
result = _parse_semantic_match_response(content, ["asset1", "asset2", "asset3"])
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertAlmostEqual(result["asset1"], 0.85)
|
||||
|
||||
def test_parse_matches_list_format(self):
|
||||
"""解析 {matches: [...]} 格式."""
|
||||
content = json.dumps(
|
||||
{
|
||||
"matches": [
|
||||
{"asset_id": "a1", "score": 0.9},
|
||||
{"asset_id": "a2", "score": 0.7},
|
||||
]
|
||||
}
|
||||
)
|
||||
result = _parse_semantic_match_response(content, ["a1", "a2"])
|
||||
self.assertIsNotNone(result)
|
||||
self.assertAlmostEqual(result["a1"], 0.9)
|
||||
self.assertAlmostEqual(result["a2"], 0.7)
|
||||
|
||||
def test_parse_array_format(self):
|
||||
"""解析数组格式."""
|
||||
content = json.dumps(
|
||||
[
|
||||
{"id": "x1", "score": 0.5},
|
||||
{"id": "x2", "score": 0.88},
|
||||
]
|
||||
)
|
||||
result = _parse_semantic_match_response(content, ["x1", "x2"])
|
||||
self.assertIsNotNone(result)
|
||||
self.assertAlmostEqual(result["x1"], 0.5)
|
||||
|
||||
def test_parse_score_clamped(self):
|
||||
"""分数被限制在0-1."""
|
||||
content = json.dumps({"a1": 1.5, "a2": -0.2})
|
||||
result = _parse_semantic_match_response(content, ["a1", "a2"])
|
||||
self.assertIsNotNone(result)
|
||||
self.assertAlmostEqual(result["a1"], 1.0)
|
||||
self.assertAlmostEqual(result["a2"], 0.0)
|
||||
|
||||
def test_parse_markdown_code_block(self):
|
||||
"""解析markdown代码块."""
|
||||
content = '```json\n{"a1": 0.7}\n```'
|
||||
result = _parse_semantic_match_response(content, ["a1", "a2"])
|
||||
# 只有1个素材评分,少于一半(需要至少1个,max(1, 2//2)=1)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertAlmostEqual(result["a1"], 0.7)
|
||||
|
||||
def test_parse_empty_returns_none(self):
|
||||
"""空内容返回None."""
|
||||
result = _parse_semantic_match_response("", ["a1"])
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_invalid_json_returns_none(self):
|
||||
"""无效JSON返回None."""
|
||||
result = _parse_semantic_match_response("不是json", ["a1", "a2", "a3"])
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestSemanticMatchAssets(unittest.TestCase):
|
||||
"""semantic_match_assets 集成测试."""
|
||||
|
||||
def _make_assets(self):
|
||||
return [
|
||||
{"id": "a1", "name": "海边日落", "tags": ["风景"], "description": ""},
|
||||
{"id": "a2", "name": "城市夜景", "tags": ["城市"], "description": ""},
|
||||
{"id": "a3", "name": "美食制作", "tags": ["美食"], "description": ""},
|
||||
]
|
||||
|
||||
def test_fallback_mode_without_api_key(self):
|
||||
"""无API Key时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("海边", self._make_assets())
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
self.assertEqual(result["total"], 3)
|
||||
self.assertEqual(len(result["matches"]), 3)
|
||||
|
||||
def test_empty_assets(self):
|
||||
"""空素材列表."""
|
||||
result = semantic_match_assets("test", [])
|
||||
self.assertEqual(result["total"], 0)
|
||||
self.assertEqual(len(result["matches"]), 0)
|
||||
|
||||
def test_top_k_limit(self):
|
||||
"""top_k 限制返回数量."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("测试", self._make_assets(), top_k=2)
|
||||
self.assertEqual(len(result["matches"]), 2)
|
||||
|
||||
def test_with_doubao_success(self):
|
||||
"""豆包调用成功路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=json.dumps({"a1": 0.9, "a2": 0.5, "a3": 0.2}))
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("风景视频", self._make_assets())
|
||||
self.assertEqual(result["source"], "doubao")
|
||||
self.assertEqual(len(result["matches"]), 3)
|
||||
# 按分数降序,a1最高
|
||||
self.assertEqual(result["matches"][0]["id"], "a1")
|
||||
self.assertAlmostEqual(result["matches"][0]["match_score"], 0.9)
|
||||
|
||||
def test_with_doubao_failure_fallback(self):
|
||||
"""豆包调用失败降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("测试", self._make_assets())
|
||||
self.assertEqual(result["source"], "fallback")
|
||||
|
||||
def test_each_match_has_required_fields(self):
|
||||
"""每个匹配结果都有必要字段."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = semantic_match_assets("测试", self._make_assets())
|
||||
for item in result["matches"]:
|
||||
self.assertIn("id", item)
|
||||
self.assertIn("match_score", item)
|
||||
self.assertIn("match_reason", item)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+359
@@ -0,0 +1,359 @@
|
||||
"""Worker AI 任务单元测试.
|
||||
|
||||
测试覆盖:
|
||||
- AI推荐(豆包调用成功/失败/降级)
|
||||
- 推荐响应解析(多种格式)
|
||||
- 封面生成降级
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, "apps/worker")
|
||||
sys.path.insert(0, "packages")
|
||||
|
||||
from worker_app.tasks.ai_tasks import ( # noqa: E402
|
||||
_fallback_recommend_clips,
|
||||
_parse_recommend_response,
|
||||
run_ai_recommend,
|
||||
run_generate_cover,
|
||||
)
|
||||
|
||||
|
||||
class TestFallbackRecommend(unittest.TestCase):
|
||||
"""降级推荐方案测试."""
|
||||
|
||||
def test_fallback_returns_expected_structure(self):
|
||||
"""降级推荐返回正确结构."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2", "a3"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
self.assertIn("clips", result)
|
||||
self.assertIn("config", result)
|
||||
self.assertIn("total_duration", result)
|
||||
self.assertIn("confidence", result)
|
||||
|
||||
def test_fallback_clips_structure(self):
|
||||
"""每个片段都有必要字段."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=20.0,
|
||||
)
|
||||
clips = result["clips"]
|
||||
self.assertTrue(len(clips) >= 3) # intro + showcase + outro
|
||||
for clip in clips:
|
||||
self.assertIn("clip_type", clip)
|
||||
self.assertIn("order", clip)
|
||||
self.assertIn("text_content", clip)
|
||||
self.assertIn("duration", clip)
|
||||
self.assertIn("transition_effect", clip)
|
||||
self.assertIn("asset_id", clip)
|
||||
self.assertIn("start_time", clip)
|
||||
self.assertIn("config", clip)
|
||||
|
||||
def test_fallback_first_is_intro_last_is_outro(self):
|
||||
"""第一个是开场,最后一个是结尾."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2", "a3"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
clips = result["clips"]
|
||||
self.assertEqual(clips[0]["clip_type"], "intro")
|
||||
self.assertEqual(clips[-1]["clip_type"], "outro")
|
||||
|
||||
def test_fallback_order_sequential(self):
|
||||
"""order 连续递增."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
for i, clip in enumerate(result["clips"]):
|
||||
self.assertEqual(clip["order"], i)
|
||||
|
||||
def test_fallback_empty_assets(self):
|
||||
"""空素材列表也能生成."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=[],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
|
||||
def test_fallback_confidence_in_range(self):
|
||||
"""置信度在0-1之间."""
|
||||
result = _fallback_recommend_clips(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
self.assertGreaterEqual(result["confidence"], 0.0)
|
||||
self.assertLessEqual(result["confidence"], 1.0)
|
||||
|
||||
|
||||
class TestRecommendResponseParsing(unittest.TestCase):
|
||||
"""推荐响应解析测试."""
|
||||
|
||||
def _asset_ids(self):
|
||||
return ["a1", "a2", "a3"]
|
||||
|
||||
def test_parse_valid_response(self):
|
||||
"""解析正常响应."""
|
||||
data = {
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "text_content": "开场",
|
||||
"duration": 3.0, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0.0, "config": {}},
|
||||
{"clip_type": "showcase", "order": 1, "text_content": "展示",
|
||||
"duration": 5.0, "transition_effect": "cut",
|
||||
"asset_id": "a2", "start_time": 1.0, "config": {}},
|
||||
{"clip_type": "outro", "order": 2, "text_content": "结尾",
|
||||
"duration": 2.0, "transition_effect": "fade",
|
||||
"asset_id": "", "start_time": 0.0, "config": {}},
|
||||
],
|
||||
"title": "精彩视频",
|
||||
"confidence": 0.85,
|
||||
}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result["clips"]), 3)
|
||||
self.assertEqual(result["clips"][0]["clip_type"], "intro")
|
||||
self.assertEqual(result["confidence"], 0.85)
|
||||
self.assertIn("精彩视频", result["config"].get("title", {}).get("text", ""))
|
||||
|
||||
def test_parse_markdown_code_block(self):
|
||||
"""解析markdown代码块."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
], "confidence": 0.7}
|
||||
content = "```json\n" + json.dumps(data) + "\n```"
|
||||
result = _parse_recommend_response(content, self._asset_ids(), 30.0)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(len(result["clips"]), 1)
|
||||
|
||||
def test_parse_empty_content(self):
|
||||
"""空内容返回None."""
|
||||
result = _parse_recommend_response("", self._asset_ids(), 30.0)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_invalid_json(self):
|
||||
"""无效JSON返回None."""
|
||||
result = _parse_recommend_response("不是json", self._asset_ids(), 30.0)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_no_clips(self):
|
||||
"""无clips字段返回None."""
|
||||
result = _parse_recommend_response(
|
||||
json.dumps({"title": "abc"}), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_parse_filters_invalid_asset_ids(self):
|
||||
"""过滤不在输入列表中的asset_id."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "fake-id", "start_time": 0, "config": {}}
|
||||
], "confidence": 0.7}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
# 非法asset_id被清空
|
||||
self.assertEqual(result["clips"][0]["asset_id"], "")
|
||||
|
||||
def test_parse_clamps_duration(self):
|
||||
"""时长被限制在合理范围."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 100, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
]}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertLessEqual(result["clips"][0]["duration"], 30.0)
|
||||
|
||||
def test_parse_reorders_clips(self):
|
||||
"""clips按order排序并重新编号."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 5, "text_content": "b",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a2", "start_time": 0, "config": {}},
|
||||
{"clip_type": "intro", "order": 0, "text_content": "a",
|
||||
"duration": 3, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}},
|
||||
]}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
# 第一个应该是order=0的intro
|
||||
self.assertEqual(result["clips"][0]["clip_type"], "intro")
|
||||
# order被重新编号为连续
|
||||
self.assertEqual(result["clips"][0]["order"], 0)
|
||||
self.assertEqual(result["clips"][1]["order"], 1)
|
||||
|
||||
def test_parse_confidence_clamped(self):
|
||||
"""confidence被限制在0-1."""
|
||||
data = {"clips": [
|
||||
{"clip_type": "showcase", "order": 0, "text_content": "t",
|
||||
"duration": 3, "transition_effect": "cut",
|
||||
"asset_id": "a1", "start_time": 0, "config": {}}
|
||||
], "confidence": 2.5}
|
||||
result = _parse_recommend_response(
|
||||
json.dumps(data), self._asset_ids(), 30.0
|
||||
)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertLessEqual(result["confidence"], 1.0)
|
||||
|
||||
|
||||
class TestRunAIRecommend(unittest.TestCase):
|
||||
"""run_ai_recommend 集成测试."""
|
||||
|
||||
def test_fallback_when_client_unavailable(self):
|
||||
"""客户端不可用时走降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=20.0,
|
||||
)
|
||||
self.assertIn("clips", result)
|
||||
self.assertIn("total_duration", result)
|
||||
mock_client.chat_completion.assert_not_called()
|
||||
|
||||
def test_doubao_success(self):
|
||||
"""豆包调用成功路径."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_response = {
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "text_content": "开场",
|
||||
"duration": 3.0, "transition_effect": "fade",
|
||||
"asset_id": "a1", "start_time": 0.0, "config": {}},
|
||||
{"clip_type": "outro", "order": 1, "text_content": "结尾",
|
||||
"duration": 2.0, "transition_effect": "fade",
|
||||
"asset_id": "a2", "start_time": 0.0, "config": {}},
|
||||
],
|
||||
"title": "AI生成标题",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
mock_client.chat_completion = MagicMock(return_value=json.dumps(mock_response))
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
editing_mode="one_take",
|
||||
target_duration=30.0,
|
||||
)
|
||||
self.assertEqual(result["confidence"], 0.9)
|
||||
self.assertEqual(len(result["clips"]), 2)
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
def test_doubao_failure_fallback(self):
|
||||
"""豆包调用失败降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
# 降级后有结果
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
def test_doubao_unparseable_fallback(self):
|
||||
"""豆包返回无法解析时降级."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value="一堆废话不是json")
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
asset_ids=["a1"],
|
||||
editing_mode="one_take",
|
||||
target_duration=10.0,
|
||||
)
|
||||
# 降级后有结果
|
||||
self.assertTrue(len(result["clips"]) >= 2)
|
||||
|
||||
|
||||
class TestGenerateCover(unittest.TestCase):
|
||||
"""封面生成测试(降级路径)."""
|
||||
|
||||
def test_ai_frame_type(self):
|
||||
"""AI封面模式返回预期结构."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="ai_frame",
|
||||
)
|
||||
self.assertIn("type", result)
|
||||
self.assertEqual(result["type"], "ai_frame")
|
||||
self.assertIn("image_url", result)
|
||||
|
||||
def test_manual_type(self):
|
||||
"""手动选帧模式."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="manual",
|
||||
frame_time=5.0,
|
||||
)
|
||||
self.assertEqual(result["type"], "manual")
|
||||
self.assertEqual(result["frame_time"], 5.0)
|
||||
|
||||
def test_upload_type(self):
|
||||
"""上传封面模式."""
|
||||
result = run_generate_cover(
|
||||
plan_id="plan-1",
|
||||
asset_ids=["a1"],
|
||||
cover_type="upload",
|
||||
)
|
||||
self.assertEqual(result["type"], "upload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+738
@@ -0,0 +1,738 @@
|
||||
"""Auth bind_contact + wechat_sync use cases unit tests.
|
||||
|
||||
Covers BindContactUseCase, SendVerificationCodeUseCase, WechatSyncUseCase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
BindContactRequest,
|
||||
BindContactResponse,
|
||||
BindContactUseCase,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
SendVerificationCodeUseCase,
|
||||
)
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncRequest,
|
||||
WechatSyncResponse,
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUser:
|
||||
id: str = "user-123"
|
||||
email: str = "test@example.com"
|
||||
display_name: str = "Test User"
|
||||
username: str = "testuser"
|
||||
password_hash: str = ""
|
||||
email_verified: bool = False
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
last_login_ip: str | None = None
|
||||
wechat_openid: str | None = None
|
||||
wechat_unionid: str | None = None
|
||||
email_verification_token: str | None = None
|
||||
password_reset_token: str | None = None
|
||||
password_reset_expires_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FakeUserRepository:
|
||||
def __init__(self, user=None):
|
||||
self._user = user
|
||||
self.saved_user = None
|
||||
self.save_called = 0
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
if self._user and self._user.id == user_id:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_email(self, email):
|
||||
if self._user and self._user.email == email:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_phone(self, phone):
|
||||
if self._user and self._user.phone == phone:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_username(self, username):
|
||||
if self._user and self._user.username == username:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_wechat_openid(self, openid):
|
||||
if self._user and self._user.wechat_openid == openid:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_wechat_unionid(self, unionid):
|
||||
if self._user and self._user.wechat_unionid == unionid:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def save(self, user):
|
||||
self.saved_user = user
|
||||
self.save_called += 1
|
||||
self._user = user
|
||||
return user
|
||||
|
||||
|
||||
class FakeVerificationCode:
|
||||
def __init__(self, code="123456", created_at=None, expires_at=None):
|
||||
self.code = code
|
||||
self.created_at = created_at or datetime.now(timezone.utc)
|
||||
self.expires_at = expires_at or (datetime.now(timezone.utc) + timedelta(minutes=5))
|
||||
|
||||
|
||||
class FakeVerificationCodeService:
|
||||
def __init__(self, verify_success=True, verify_error=None, generate_code="123456"):
|
||||
self._verify_success = verify_success
|
||||
self._verify_error = verify_error
|
||||
self._generate_code = generate_code
|
||||
self.verified = []
|
||||
self.generated = []
|
||||
|
||||
def verify(self, recipient, code_type, code_value):
|
||||
self.verified.append(
|
||||
{
|
||||
"recipient": recipient,
|
||||
"code_type": code_type,
|
||||
"code_value": code_value,
|
||||
}
|
||||
)
|
||||
return self._verify_success, self._verify_error
|
||||
|
||||
def generate(self, recipient, code_type):
|
||||
self.generated.append({"recipient": recipient, "code_type": code_type})
|
||||
return FakeVerificationCode(code=self._generate_code), None
|
||||
|
||||
|
||||
class FakeSessionStore:
|
||||
def __init__(self):
|
||||
self.saved_sessions = []
|
||||
|
||||
def save_session(self, **kwargs):
|
||||
self.saved_sessions.append(kwargs)
|
||||
return True
|
||||
|
||||
|
||||
class FakeSmsService:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_verification_code(self, phone, code):
|
||||
self.sent.append({"phone": phone, "code": code})
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_email(self, to, subject, body):
|
||||
self.sent.append({"to": to, "subject": subject, "body": body})
|
||||
|
||||
|
||||
# ── BindContactUseCase tests ────────────────────────────
|
||||
|
||||
|
||||
class TestBindContactUseCase:
|
||||
def _make_use_case(self, user_repo=None, verify_svc=None):
|
||||
return BindContactUseCase(
|
||||
user_repository=user_repo or FakeUserRepository(),
|
||||
verification_code_service=verify_svc or FakeVerificationCodeService(),
|
||||
)
|
||||
|
||||
def test_bind_phone_success(self):
|
||||
user = FakeUser(id="user-1", phone="", phone_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, BindContactResponse)
|
||||
assert response.user.phone == "13800138000"
|
||||
assert response.user.phone_verified is True
|
||||
|
||||
# verification was called
|
||||
assert len(verify_svc.verified) == 1
|
||||
assert verify_svc.verified[0]["recipient"] == "13800138000"
|
||||
|
||||
def test_bind_email_success(self):
|
||||
user = FakeUser(id="user-1", email="old@example.com", email_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
email="new@example.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user.email == "new@example.com"
|
||||
assert response.user.email_verified is True
|
||||
assert len(verify_svc.verified) == 1
|
||||
|
||||
def test_bind_both_phone_and_email(self):
|
||||
user = FakeUser(id="user-1", phone="", phone_verified=False, email_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
email="new@example.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user.phone == "13800138000"
|
||||
assert response.user.phone_verified is True
|
||||
assert response.user.email == "new@example.com"
|
||||
assert response.user.email_verified is True
|
||||
assert response.user.binding_completed_at is not None
|
||||
assert len(verify_svc.verified) == 2
|
||||
|
||||
def test_binding_complete_with_real_email(self):
|
||||
"""Both phone and email verified, real email (not wechat.local) → binding completed."""
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
email="",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
email_verified=False,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
email="user@real.com",
|
||||
email_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.user.binding_completed_at is not None
|
||||
assert response.to_dict()["user"]["binding_complete"] is True
|
||||
|
||||
def test_binding_not_complete_with_wechat_email(self):
|
||||
"""WeChat placeholder email doesn't count for binding completion."""
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(
|
||||
user_id="user-1",
|
||||
phone="13800138000",
|
||||
phone_code="123456",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
# wechat.local email doesn't count
|
||||
assert response.user.binding_completed_at is None
|
||||
assert response.to_dict()["user"]["binding_complete"] is False
|
||||
|
||||
def test_no_phone_no_email_returns_error(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "至少填写手机号或邮箱" in error
|
||||
|
||||
def test_user_not_found(self):
|
||||
repo = FakeUserRepository() # no user
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="nonexistent", phone="13800138000", phone_code="123")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "用户不存在" in error
|
||||
|
||||
def test_invalid_phone_format(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="123", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="not-an-email", email_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_phone_missing_code(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "请输入手机验证码" in error
|
||||
|
||||
def test_email_missing_code(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="a@b.com")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "请输入邮箱验证码" in error
|
||||
|
||||
def test_phone_already_bound_to_other_user(self):
|
||||
other_user = FakeUser(id="user-2", phone="13800138000")
|
||||
current_user = FakeUser(id="user-1", phone="")
|
||||
# repo only finds "other" user for phone lookup
|
||||
repo = FakeUserRepository(user=other_user)
|
||||
# But we also need find_by_id to find the current user
|
||||
# Our simple repo can only hold one user. Let's use MagicMock instead.
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.return_value = current_user
|
||||
repo.find_by_phone.return_value = other_user
|
||||
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "已被其他账号绑定" in error
|
||||
|
||||
def test_email_already_bound_to_other_user(self):
|
||||
current_user = FakeUser(id="user-1", email="old@example.com")
|
||||
other_user = FakeUser(id="user-2", email="new@example.com")
|
||||
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.return_value = current_user
|
||||
repo.find_by_email.return_value = other_user
|
||||
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="new@example.com", email_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "已被其他账号绑定" in error
|
||||
|
||||
def test_phone_verification_failed(self):
|
||||
user = FakeUser(id="user-1", phone="")
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误或已过期")
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="wrong")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "手机验证码错误" in error
|
||||
|
||||
def test_email_verification_failed(self):
|
||||
user = FakeUser(id="user-1")
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误")
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", email="a@b.com", email_code="wrong")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "邮箱验证码错误" in error
|
||||
|
||||
def test_bind_same_phone_to_self_ok(self):
|
||||
"""Binding the same phone to the same user should work (no conflict)."""
|
||||
user = FakeUser(id="user-1", phone="13800138000", phone_verified=False)
|
||||
repo = FakeUserRepository(user=user)
|
||||
verify_svc = FakeVerificationCodeService(verify_success=True)
|
||||
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_id.side_effect = RuntimeError("DB down")
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
|
||||
|
||||
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "绑定失败" in error
|
||||
|
||||
|
||||
# ── SendVerificationCodeUseCase tests ───────────────────
|
||||
|
||||
|
||||
class TestSendVerificationCodeUseCase:
|
||||
def test_send_phone_code_success(self):
|
||||
verify_svc = FakeVerificationCodeService(generate_code="654321")
|
||||
sms_svc = FakeSmsService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
sms_service=sms_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, SendVerificationCodeResponse)
|
||||
assert response.expires_in > 0
|
||||
assert response.resend_after == 60
|
||||
|
||||
assert len(verify_svc.generated) == 1
|
||||
assert verify_svc.generated[0]["code_type"] == "phone_bind"
|
||||
|
||||
# SMS was sent
|
||||
assert len(sms_svc.sent) == 1
|
||||
assert sms_svc.sent[0]["phone"] == "13800138000"
|
||||
assert sms_svc.sent[0]["code"] == "654321"
|
||||
|
||||
def test_send_email_code_success(self):
|
||||
verify_svc = FakeVerificationCodeService(generate_code="111222")
|
||||
email_svc = FakeEmailService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
email_service=email_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="test@example.com", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
assert len(verify_svc.generated) == 1
|
||||
assert verify_svc.generated[0]["code_type"] == "email_bind"
|
||||
|
||||
assert len(email_svc.sent) == 1
|
||||
assert email_svc.sent[0]["to"] == "test@example.com"
|
||||
assert "111222" in email_svc.sent[0]["body"]
|
||||
|
||||
def test_invalid_phone_format(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="123", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="not-email", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
|
||||
def test_unsupported_target_type(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="carrier_pigeon", value="hello", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "不支持的目标类型" in error
|
||||
|
||||
def test_email_recipient_lowercased(self):
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
email_svc = FakeEmailService()
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=verify_svc,
|
||||
email_service=email_svc,
|
||||
)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="TEST@Example.COM", purpose="login")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
# recipient should be lowercased
|
||||
assert verify_svc.generated[0]["recipient"] == "test@example.com"
|
||||
|
||||
def test_no_sms_service_phone_still_returns_success(self):
|
||||
"""If no SMS service is configured, code is generated but not sent."""
|
||||
verify_svc = FakeVerificationCodeService()
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="login")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
# code was generated
|
||||
assert len(verify_svc.generated) == 1
|
||||
|
||||
def test_exception_handling(self):
|
||||
verify_svc = MagicMock()
|
||||
verify_svc.generate.side_effect = RuntimeError("Redis down")
|
||||
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
|
||||
|
||||
req = SendVerificationCodeRequest(target="email", value="a@b.com", purpose="bind")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "发送失败" in error
|
||||
|
||||
def test_to_dict_returns_correct_fields(self):
|
||||
resp = SendVerificationCodeResponse(expires_in=300, resend_after=60)
|
||||
d = resp.to_dict()
|
||||
assert d["expires_in"] == 300
|
||||
assert d["resend_after"] == 60
|
||||
|
||||
|
||||
# ── WechatSyncUseCase tests ─────────────────────────────
|
||||
|
||||
|
||||
class TestWechatSyncUseCase:
|
||||
def _make_use_case(self, user_repo=None, session_store=None, secret_key="test-secret-key-for-jwt"):
|
||||
return WechatSyncUseCase(
|
||||
user_repository=user_repo or FakeUserRepository(),
|
||||
session_store=session_store or FakeSessionStore(),
|
||||
jwt_secret_key=secret_key,
|
||||
)
|
||||
|
||||
def test_existing_user_login_by_openid(self):
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
wechat_openid="openid-abc",
|
||||
display_name="WeChat User",
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
store = FakeSessionStore()
|
||||
use_case = self._make_use_case(user_repo=repo, session_store=store)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-abc")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, WechatSyncResponse)
|
||||
assert response.user_id == "user-1"
|
||||
assert response.is_new_user is False
|
||||
assert response.access_token
|
||||
assert response.refresh_token
|
||||
|
||||
# session created
|
||||
assert len(store.saved_sessions) == 1
|
||||
session = store.saved_sessions[0]
|
||||
assert session["user_id"] == "user-1"
|
||||
assert "wechat_" in session["device_info"]
|
||||
|
||||
# last login updated
|
||||
assert repo.saved_user.last_login_at is not None
|
||||
assert repo.saved_user.last_login_ip == "bff_gateway"
|
||||
|
||||
def test_existing_user_by_unionid_binds_openid(self):
|
||||
user = FakeUser(
|
||||
id="user-1",
|
||||
wechat_openid=None, # no openid
|
||||
wechat_unionid="unionid-xyz",
|
||||
display_name="Existing User",
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-new", unionid="unionid-xyz")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user_id == "user-1"
|
||||
assert response.is_new_user is False
|
||||
|
||||
# openid was bound
|
||||
assert repo.saved_user.wechat_openid == "openid-new"
|
||||
|
||||
def test_new_user_creation(self):
|
||||
repo = FakeUserRepository() # no existing user
|
||||
store = FakeSessionStore()
|
||||
use_case = self._make_use_case(user_repo=repo, session_store=store)
|
||||
|
||||
req = WechatSyncRequest(
|
||||
openid="openid-new123",
|
||||
nickname="微信昵称",
|
||||
avatar_url="https://example.com/avatar.png",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
assert response.nickname == "微信昵称"
|
||||
assert response.user_id
|
||||
assert len(response.user_id) == 32 # uuid4 hex
|
||||
|
||||
# user was saved
|
||||
assert repo.saved_user is not None
|
||||
assert repo.saved_user.wechat_openid == "openid-new123"
|
||||
assert repo.saved_user.email_verified is True
|
||||
assert "wechat.local" in repo.saved_user.email
|
||||
assert repo.saved_user.username.startswith("wx_")
|
||||
assert repo.saved_user.display_name == "微信昵称"
|
||||
|
||||
def test_empty_openid_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = WechatSyncRequest(openid="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "openid is required" in error
|
||||
|
||||
def test_default_nickname_when_empty(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-1", nickname="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.is_new_user is True
|
||||
assert response.nickname == "微信用户"
|
||||
|
||||
def test_username_uniqueness_suffix(self):
|
||||
"""When username already exists, a numeric suffix is added."""
|
||||
# First user with same openid prefix
|
||||
existing = FakeUser(username="wx_openidnew123_")
|
||||
repo = FakeUserRepository(user=existing)
|
||||
|
||||
# Our simple FakeUserRepository only holds one user.
|
||||
# Use MagicMock for more control.
|
||||
repo = MagicMock()
|
||||
repo.find_by_wechat_openid.return_value = None
|
||||
repo.find_by_wechat_unionid.return_value = None
|
||||
# first find_by_username returns a user (conflict), second time None (unique)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def mock_find_by_username(username):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return FakeUser(username=username) # conflict
|
||||
return None # unique on second try
|
||||
|
||||
repo.find_by_username.side_effect = mock_find_by_username
|
||||
repo.save = MagicMock(side_effect=lambda u: u)
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
user_repository=repo,
|
||||
session_store=FakeSessionStore(),
|
||||
jwt_secret_key="test-secret",
|
||||
)
|
||||
|
||||
req = WechatSyncRequest(openid="openid-new123", nickname="Test")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.is_new_user is True
|
||||
# username should have _1 suffix
|
||||
saved_user = repo.save.call_args[0][0]
|
||||
assert saved_user.username.endswith("_1")
|
||||
|
||||
def test_to_dict_has_token_alias_for_compat(self):
|
||||
user = FakeUser(id="u-1", wechat_openid="oid-1", display_name="Name")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="oid-1")
|
||||
response, _ = use_case.execute(req)
|
||||
|
||||
d = response.to_dict()
|
||||
assert d["access_token"] == d["token"] # compat alias
|
||||
assert d["is_new_user"] is False
|
||||
assert d["user"]["id"] == "u-1"
|
||||
assert d["user_info"]["display_name"] == "Name"
|
||||
|
||||
def test_access_token_has_correct_claims(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
user = FakeUser(id="user-99", wechat_openid="oid-99")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="oid-99")
|
||||
response, _ = use_case.execute(req)
|
||||
|
||||
payload = pyjwt.decode(response.access_token, "test-secret-key-for-jwt", algorithms=["HS256"])
|
||||
assert payload["sub"] == "user-99"
|
||||
assert payload["type"] == "user_auth"
|
||||
assert "sid" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_wechat_openid.side_effect = RuntimeError("DB down")
|
||||
use_case = self._make_use_case(user_repo=repo)
|
||||
|
||||
req = WechatSyncRequest(openid="abc")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Internal error" in error
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
JWT + Password 委托层单元测试(第二十一波)
|
||||
|
||||
覆盖:
|
||||
- JWTHandler (create/verify/configure/get)
|
||||
- PasswordHandler (hash/verify/needs_rehash/validate_strength/configure/get)
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
from packages.application.auth.jwt_handler import (
|
||||
JWTHandler,
|
||||
configure_jwt_handler,
|
||||
get_jwt_handler,
|
||||
)
|
||||
from packages.application.auth.password_handler import (
|
||||
PasswordHandler,
|
||||
configure_password_handler,
|
||||
get_password_handler,
|
||||
)
|
||||
|
||||
SECRET_KEY = "test-secret-key-for-unit-testing-only-not-for-production"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# JWTHandler
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJWTHandler:
|
||||
"""JWTHandler JWT 委托层"""
|
||||
|
||||
def test_create_and_verify_access_token(self):
|
||||
"""创建并验证 access_token"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY)
|
||||
token = handler.create_access_token(user_id="user-123", role="admin")
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "user-123"
|
||||
assert payload["role"] == "admin"
|
||||
assert "exp" in payload
|
||||
assert "type" in payload
|
||||
assert payload["type"] == "access"
|
||||
|
||||
def test_create_token_with_additional_claims(self):
|
||||
"""携带额外 claims"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY)
|
||||
token = handler.create_access_token(
|
||||
user_id="user-1",
|
||||
role="user",
|
||||
additional_claims={"email": "a@b.com", "org_id": "org-1"},
|
||||
)
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["email"] == "a@b.com"
|
||||
assert payload["org_id"] == "org-1"
|
||||
|
||||
def test_create_token_default_role(self):
|
||||
"""默认 role 为空字符串"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY)
|
||||
token = handler.create_access_token(user_id="user-1")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["role"] == ""
|
||||
|
||||
def test_verify_generic_token(self):
|
||||
"""verify_token 通用验证方法"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY)
|
||||
token = handler.create_access_token(user_id="user-1")
|
||||
payload = handler.verify_token(token)
|
||||
assert payload["sub"] == "user-1"
|
||||
|
||||
def test_verify_invalid_token_raises(self):
|
||||
"""无效 token 验证失败"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY)
|
||||
with pytest.raises(InvalidTokenError):
|
||||
handler.verify_access_token("invalid-token")
|
||||
|
||||
def test_verify_wrong_secret(self):
|
||||
"""用不同密钥签名的 token 验证失败"""
|
||||
handler1 = JWTHandler(secret_key="key-a")
|
||||
handler2 = JWTHandler(secret_key="key-b")
|
||||
|
||||
token = handler1.create_access_token(user_id="user-1")
|
||||
with pytest.raises(InvalidTokenError):
|
||||
handler2.verify_access_token(token)
|
||||
|
||||
def test_custom_algorithm(self):
|
||||
"""自定义算法"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY, algorithm="HS256")
|
||||
token = handler.create_access_token(user_id="user-1")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "user-1"
|
||||
|
||||
def test_custom_expire_minutes(self):
|
||||
"""自定义过期时间"""
|
||||
handler = JWTHandler(secret_key=SECRET_KEY, access_token_expire_minutes=60)
|
||||
token = handler.create_access_token(user_id="user-1")
|
||||
payload = handler.verify_access_token(token)
|
||||
assert payload["sub"] == "user-1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# JWTHandler - 全局配置
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestJWTGlobalConfig:
|
||||
"""JWT 全局配置与获取"""
|
||||
|
||||
def test_configure_and_get(self):
|
||||
"""配置后可以获取"""
|
||||
handler = configure_jwt_handler(secret_key=SECRET_KEY, access_token_expire_minutes=15)
|
||||
assert isinstance(handler, JWTHandler)
|
||||
|
||||
got = get_jwt_handler()
|
||||
assert got is handler
|
||||
|
||||
def test_reconfigure_replaces(self):
|
||||
"""重新配置会替换"""
|
||||
h1 = configure_jwt_handler(secret_key="key-a")
|
||||
h2 = configure_jwt_handler(secret_key="key-b")
|
||||
assert h1 is not h2
|
||||
assert get_jwt_handler() is h2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PasswordHandler
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestPasswordHandler:
|
||||
"""PasswordHandler 密码委托层"""
|
||||
|
||||
def test_hash_and_verify_correct(self):
|
||||
"""哈希并验证正确密码"""
|
||||
handler = PasswordHandler()
|
||||
hashed = handler.hash_password("MySecurePass123")
|
||||
|
||||
assert isinstance(hashed, str)
|
||||
assert hashed != "MySecurePass123"
|
||||
assert handler.verify_password("MySecurePass123", hashed) is True
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
"""验证错误密码"""
|
||||
handler = PasswordHandler()
|
||||
hashed = handler.hash_password("CorrectPass123")
|
||||
assert handler.verify_password("WrongPass456", hashed) is False
|
||||
|
||||
def test_hash_is_unique_each_time(self):
|
||||
"""同密码每次哈希不同(salt)"""
|
||||
handler = PasswordHandler()
|
||||
h1 = handler.hash_password("SamePass123")
|
||||
h2 = handler.hash_password("SamePass123")
|
||||
assert h1 != h2
|
||||
# 但都能验证通过
|
||||
assert handler.verify_password("SamePass123", h1)
|
||||
assert handler.verify_password("SamePass123", h2)
|
||||
|
||||
def test_needs_rehash_new_hash(self):
|
||||
"""新生成的哈希不需要重新计算"""
|
||||
handler = PasswordHandler()
|
||||
hashed = handler.hash_password("TestPass123")
|
||||
assert handler.needs_rehash(hashed) is False
|
||||
|
||||
def test_validate_strength_strong(self):
|
||||
"""强密码校验通过"""
|
||||
handler = PasswordHandler()
|
||||
ok, err = handler.validate_strength("StrongPass123")
|
||||
assert ok is True
|
||||
assert err is None or err == ""
|
||||
|
||||
def test_validate_strength_too_short(self):
|
||||
"""密码太短"""
|
||||
handler = PasswordHandler()
|
||||
ok, err = handler.validate_strength("Ab1")
|
||||
assert ok is False
|
||||
assert err is not None
|
||||
|
||||
def test_validate_strength_no_uppercase(self):
|
||||
"""缺少大写字母"""
|
||||
handler = PasswordHandler()
|
||||
ok, err = handler.validate_strength("lowercase123")
|
||||
assert ok is False
|
||||
assert err is not None
|
||||
|
||||
def test_validate_strength_no_lowercase(self):
|
||||
"""缺少小写字母"""
|
||||
handler = PasswordHandler()
|
||||
ok, err = handler.validate_strength("UPPERCASE123")
|
||||
assert ok is False
|
||||
assert err is not None
|
||||
|
||||
def test_validate_strength_no_digit(self):
|
||||
"""缺少数字"""
|
||||
handler = PasswordHandler()
|
||||
ok, err = handler.validate_strength("NoDigitHere")
|
||||
assert ok is False
|
||||
assert err is not None
|
||||
|
||||
def test_hash_empty_password(self):
|
||||
"""空密码哈希报错"""
|
||||
handler = PasswordHandler()
|
||||
with pytest.raises((ValueError, Exception)):
|
||||
handler.hash_password("")
|
||||
|
||||
def test_custom_rounds(self):
|
||||
"""自定义 rounds(用低轮次测试更快)"""
|
||||
handler = PasswordHandler(rounds=4)
|
||||
hashed = handler.hash_password("TestPass123")
|
||||
assert handler.verify_password("TestPass123", hashed)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PasswordHandler - 全局配置
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestPasswordGlobalConfig:
|
||||
"""Password 全局配置与获取"""
|
||||
|
||||
def test_get_default_handler(self):
|
||||
"""未配置时 get 返回默认实例"""
|
||||
# 重置默认实例
|
||||
with patch("packages.application.auth.password_handler._default_handler", None):
|
||||
handler = get_password_handler()
|
||||
assert isinstance(handler, PasswordHandler)
|
||||
|
||||
def test_configure_and_get(self):
|
||||
"""配置后可以获取"""
|
||||
handler = configure_password_handler(rounds=4)
|
||||
assert isinstance(handler, PasswordHandler)
|
||||
got = get_password_handler()
|
||||
assert got is handler
|
||||
|
||||
def test_reconfigure_replaces(self):
|
||||
"""重新配置会替换"""
|
||||
h1 = configure_password_handler(rounds=4)
|
||||
h2 = configure_password_handler(rounds=6)
|
||||
assert h1 is not h2
|
||||
Executable
+589
@@ -0,0 +1,589 @@
|
||||
"""Auth login use cases unit tests.
|
||||
|
||||
Covers LoginUseCase, RefreshTokenUseCase, LogoutUseCase, and helper functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.login_use_case import (
|
||||
LEGACY_SHA256_HEX_LENGTH,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginUseCase,
|
||||
LogoutRequest,
|
||||
LogoutUseCase,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenUseCase,
|
||||
_is_legacy_sha256_hash,
|
||||
_legacy_sha256,
|
||||
)
|
||||
from packages.application.auth.password_hasher import password_hasher
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUser:
|
||||
id: str = "user-123"
|
||||
email: str = "test@example.com"
|
||||
display_name: str = "Test User"
|
||||
username: str = "testuser"
|
||||
password_hash: str = ""
|
||||
email_verified: bool = True
|
||||
last_login_at: datetime | None = None
|
||||
last_login_ip: str | None = None
|
||||
wechat_openid: str | None = None
|
||||
wechat_unionid: str | None = None
|
||||
|
||||
|
||||
class FakeUserRepository:
|
||||
def __init__(self, user: FakeUser | None = None):
|
||||
self._user = user
|
||||
self.saved_user: FakeUser | None = None
|
||||
|
||||
def find_by_email(self, email: str) -> FakeUser | None:
|
||||
if self._user and self._user.email == email:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def get(self, user_id: str) -> FakeUser | None:
|
||||
if self._user and self._user.id == user_id:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def save(self, user: FakeUser) -> FakeUser:
|
||||
self.saved_user = user
|
||||
self._user = user
|
||||
return user
|
||||
|
||||
|
||||
class FakeSessionStore:
|
||||
def __init__(self):
|
||||
self._sessions: dict[str, dict] = {}
|
||||
self._refresh_index: dict[str, str] = {} # refresh_token -> session_id
|
||||
self.saved_sessions: list[dict] = []
|
||||
self.deleted_sessions: list[str] = []
|
||||
self.delete_all_called_for: str | None = None
|
||||
self.delete_all_return_value = 0
|
||||
|
||||
def save_session(self, **kwargs) -> bool:
|
||||
session_id = kwargs.get("session_id", "")
|
||||
self._sessions[session_id] = kwargs
|
||||
if kwargs.get("refresh_token"):
|
||||
self._refresh_index[kwargs["refresh_token"]] = session_id
|
||||
self.saved_sessions.append(kwargs)
|
||||
return True
|
||||
|
||||
def get_session_by_refresh_token(self, refresh_token: str) -> dict | None:
|
||||
session_id = self._refresh_index.get(refresh_token)
|
||||
if not session_id:
|
||||
return None
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def get_refresh_token(self, session_id: str) -> str | None:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return None
|
||||
return session.get("refresh_token")
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
self.deleted_sessions.append(session_id)
|
||||
if session_id in self._sessions:
|
||||
session = self._sessions.pop(session_id)
|
||||
rt = session.get("refresh_token")
|
||||
if rt and rt in self._refresh_index:
|
||||
del self._refresh_index[rt]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_all_user_sessions(self, user_id: str) -> int:
|
||||
self.delete_all_called_for = user_id
|
||||
count = self.delete_all_return_value
|
||||
# actually clean up
|
||||
to_delete = [sid for sid, s in self._sessions.items() if s.get("user_id") == user_id]
|
||||
for sid in to_delete:
|
||||
self.delete_session(sid)
|
||||
return count or len(to_delete)
|
||||
|
||||
|
||||
# ── Helper function tests ───────────────────────────────
|
||||
|
||||
|
||||
class TestIsLegacySha256Hash:
|
||||
def test_valid_sha256_hex(self):
|
||||
h = hashlib.sha256(b"password").hexdigest()
|
||||
assert _is_legacy_sha256_hash(h) is True
|
||||
|
||||
def test_bcrypt_hash_not_legacy(self):
|
||||
h = password_hasher.hash_password("password")
|
||||
assert _is_legacy_sha256_hash(h) is False
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _is_legacy_sha256_hash("") is False
|
||||
|
||||
def test_short_string(self):
|
||||
assert _is_legacy_sha256_hash("abc123") is False
|
||||
|
||||
def test_64_chars_non_hex(self):
|
||||
s = "g" * 64 # 'g' is not hex
|
||||
assert _is_legacy_sha256_hash(s) is False
|
||||
|
||||
def test_exact_64_hex_chars(self):
|
||||
s = "a" * 64
|
||||
assert _is_legacy_sha256_hash(s) is True
|
||||
|
||||
def test_mixed_case_hex(self):
|
||||
s = "AbCdEf01" * 8 # 64 chars mixed case hex
|
||||
assert len(s) == 64
|
||||
assert _is_legacy_sha256_hash(s) is True
|
||||
|
||||
|
||||
class TestLegacySha256:
|
||||
def test_matches_hashlib(self):
|
||||
password = "mypassword"
|
||||
expected = hashlib.sha256(password.encode()).hexdigest()
|
||||
assert _legacy_sha256(password) == expected
|
||||
|
||||
def test_empty_password(self):
|
||||
assert _legacy_sha256("") == hashlib.sha256(b"").hexdigest()
|
||||
|
||||
def test_unicode_password(self):
|
||||
result = _legacy_sha256("密码测试")
|
||||
assert len(result) == LEGACY_SHA256_HEX_LENGTH
|
||||
assert all(c in "0123456789abcdef" for c in result)
|
||||
|
||||
|
||||
# ── LoginRequest tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginRequest:
|
||||
def test_email_stripped_and_lowercased(self):
|
||||
req = LoginRequest(email=" Test@Example.COM ", password="pass")
|
||||
assert req.email == "test@example.com"
|
||||
|
||||
def test_default_device_info(self):
|
||||
req = LoginRequest(email="a@b.com", password="pass")
|
||||
assert req.device_info == "Unknown"
|
||||
|
||||
def test_default_ip_address(self):
|
||||
req = LoginRequest(email="a@b.com", password="pass")
|
||||
assert req.ip_address == "unknown"
|
||||
|
||||
def test_custom_device_and_ip(self):
|
||||
req = LoginRequest(email="a@b.com", password="pass", device_info="iPhone", ip_address="1.2.3.4")
|
||||
assert req.device_info == "iPhone"
|
||||
assert req.ip_address == "1.2.3.4"
|
||||
|
||||
|
||||
# ── LoginUseCase tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginUseCase:
|
||||
def _make_user_with_password(self, password: str = "password123") -> FakeUser:
|
||||
return FakeUser(password_hash=password_hasher.hash_password(password))
|
||||
|
||||
def test_successful_login(self):
|
||||
user = self._make_user_with_password("mypassword")
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="test@example.com", password="mypassword")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, LoginResponse)
|
||||
assert response.user_id == "user-123"
|
||||
assert response.email == "test@example.com"
|
||||
assert response.username == "testuser"
|
||||
assert response.display_name == "Test User"
|
||||
assert response.access_token
|
||||
assert response.refresh_token
|
||||
assert response.expires_in > 0
|
||||
|
||||
# session was saved
|
||||
assert len(store.saved_sessions) == 1
|
||||
saved = store.saved_sessions[0]
|
||||
assert saved["user_id"] == "user-123"
|
||||
assert saved["device_info"] == "Unknown"
|
||||
assert saved["ip_address"] == "unknown"
|
||||
assert saved["expires_in_seconds"] == 30 * 24 * 3600
|
||||
|
||||
# last_login updated
|
||||
assert repo.saved_user is not None
|
||||
assert repo.saved_user.last_login_at is not None
|
||||
assert repo.saved_user.last_login_ip == "unknown"
|
||||
|
||||
def test_successful_login_with_device_and_ip(self):
|
||||
user = self._make_user_with_password("pass")
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(
|
||||
email="test@example.com",
|
||||
password="pass",
|
||||
device_info="Chrome/Win10",
|
||||
ip_address="192.168.1.1",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
saved = store.saved_sessions[0]
|
||||
assert saved["device_info"] == "Chrome/Win10"
|
||||
assert saved["ip_address"] == "192.168.1.1"
|
||||
assert repo.saved_user.last_login_ip == "192.168.1.1"
|
||||
|
||||
def test_empty_email_returns_error(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="", password="pass")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Email is required" in error
|
||||
|
||||
def test_empty_password_returns_error(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="a@b.com", password="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Password is required" in error
|
||||
|
||||
def test_user_not_found_returns_error(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="nobody@example.com", password="pass")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid email or password" in error
|
||||
|
||||
def test_wrong_password_returns_error(self):
|
||||
user = self._make_user_with_password("correctpassword")
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="test@example.com", password="wrongpassword")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid email or password" in error
|
||||
# no session created
|
||||
assert len(store.saved_sessions) == 0
|
||||
|
||||
def test_legacy_sha256_hash_login_success_and_upgrade(self):
|
||||
password = "oldpassword"
|
||||
legacy_hash = _legacy_sha256(password)
|
||||
assert _is_legacy_sha256_hash(legacy_hash)
|
||||
|
||||
user = FakeUser(password_hash=legacy_hash)
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="test@example.com", password=password)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
# password should have been upgraded to bcrypt
|
||||
assert repo.saved_user is not None
|
||||
assert not _is_legacy_sha256_hash(repo.saved_user.password_hash)
|
||||
assert repo.saved_user.password_hash.startswith("$2b$")
|
||||
|
||||
# new hash should verify correctly
|
||||
assert password_hasher.verify_password(password, repo.saved_user.password_hash)
|
||||
|
||||
def test_legacy_sha256_hash_wrong_password(self):
|
||||
password = "rightpassword"
|
||||
legacy_hash = _legacy_sha256(password)
|
||||
user = FakeUser(password_hash=legacy_hash)
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="test@example.com", password="wrongpassword")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid email or password" in error
|
||||
# password hash unchanged
|
||||
assert repo.saved_user is None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_email.side_effect = RuntimeError("DB connection failed")
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="a@b.com", password="pass")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
assert "Login failed" in error
|
||||
|
||||
def test_access_token_contains_correct_claims(self):
|
||||
import jwt as pyjwt
|
||||
|
||||
user = self._make_user_with_password("pass")
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
use_case = LoginUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = LoginRequest(email="test@example.com", password="pass")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
|
||||
payload = pyjwt.decode(
|
||||
response.access_token,
|
||||
use_case.jwt_secret_key,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
assert payload["sub"] == "user-123"
|
||||
assert payload["type"] == "user_auth"
|
||||
assert "sid" in payload
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
# ── RefreshTokenUseCase tests ───────────────────────────
|
||||
|
||||
|
||||
class TestRefreshTokenUseCase:
|
||||
def test_successful_refresh(self):
|
||||
user = FakeUser()
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
|
||||
# create a session first
|
||||
store.save_session(
|
||||
session_id="sess-1",
|
||||
user_id="user-123",
|
||||
refresh_token="refresh-token-xyz",
|
||||
device_info="Chrome",
|
||||
ip_address="1.2.3.4",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="refresh-token-xyz")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user_id == "user-123"
|
||||
assert response.access_token
|
||||
# same refresh token returned
|
||||
assert response.refresh_token == "refresh-token-xyz"
|
||||
|
||||
def test_empty_refresh_token(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = RefreshTokenRequest(refresh_token="")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Refresh token is required" in error
|
||||
|
||||
def test_invalid_refresh_token(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
|
||||
req = RefreshTokenRequest(refresh_token="nonexistent-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid or expired refresh token" in error
|
||||
|
||||
def test_session_missing_session_id(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
# session with no session_id
|
||||
store._refresh_index["bad-token"] = "bad-sess"
|
||||
store._sessions["bad-sess"] = {"user_id": "user-123"} # no session_id
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="bad-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid session data" in error
|
||||
|
||||
def test_session_missing_user_id(self):
|
||||
repo = FakeUserRepository()
|
||||
store = FakeSessionStore()
|
||||
store._refresh_index["bad-token"] = "bad-sess"
|
||||
store._sessions["bad-sess"] = {"session_id": "bad-sess"} # no user_id
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="bad-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Invalid session data" in error
|
||||
|
||||
def test_refresh_token_mismatch(self):
|
||||
user = FakeUser()
|
||||
repo = FakeUserRepository(user)
|
||||
store = FakeSessionStore()
|
||||
|
||||
store.save_session(
|
||||
session_id="sess-1",
|
||||
user_id="user-123",
|
||||
refresh_token="original-token",
|
||||
device_info="Chrome",
|
||||
ip_address="1.2.3.4",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
|
||||
# Manually add a stale reverse index pointing to same session
|
||||
store._refresh_index["stale-token"] = "sess-1"
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="stale-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Refresh token mismatch" in error
|
||||
|
||||
def test_user_not_found(self):
|
||||
repo = FakeUserRepository() # no users
|
||||
store = FakeSessionStore()
|
||||
|
||||
store.save_session(
|
||||
session_id="sess-1",
|
||||
user_id="nonexistent-user",
|
||||
refresh_token="valid-token",
|
||||
device_info="Chrome",
|
||||
ip_address="1.2.3.4",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="valid-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "User not found" in error
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.get.side_effect = RuntimeError("DB down")
|
||||
store = FakeSessionStore()
|
||||
|
||||
store.save_session(
|
||||
session_id="sess-1",
|
||||
user_id="user-123",
|
||||
refresh_token="valid-token",
|
||||
device_info="Chrome",
|
||||
ip_address="1.2.3.4",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
|
||||
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
|
||||
req = RefreshTokenRequest(refresh_token="valid-token")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Token refresh failed" in error
|
||||
|
||||
|
||||
# ── LogoutUseCase tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestLogoutUseCase:
|
||||
def test_logout_single_device_success(self):
|
||||
store = FakeSessionStore()
|
||||
store.save_session(
|
||||
session_id="sess-1",
|
||||
user_id="user-123",
|
||||
refresh_token="token1",
|
||||
device_info="Chrome",
|
||||
ip_address="1.2.3.4",
|
||||
expires_in_seconds=3600,
|
||||
)
|
||||
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
req = LogoutRequest(user_id="user-123", session_id="sess-1")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert "sess-1" in store.deleted_sessions
|
||||
|
||||
def test_logout_single_device_no_session_id(self):
|
||||
store = FakeSessionStore()
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
req = LogoutRequest(user_id="user-123", session_id=None)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Session ID is required" in error
|
||||
|
||||
def test_logout_single_device_session_not_found(self):
|
||||
store = FakeSessionStore()
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
req = LogoutRequest(user_id="user-123", session_id="nonexistent")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Session not found" in error
|
||||
|
||||
def test_logout_all_devices(self):
|
||||
store = FakeSessionStore()
|
||||
store.delete_all_return_value = 3
|
||||
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
req = LogoutRequest(user_id="user-123", logout_all_devices=True)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert store.delete_all_called_for == "user-123"
|
||||
|
||||
def test_logout_all_with_empty_session_id(self):
|
||||
store = FakeSessionStore()
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
# logout_all should work even without session_id
|
||||
req = LogoutRequest(user_id="user-123", session_id=None, logout_all_devices=True)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
def test_exception_handling(self):
|
||||
store = MagicMock()
|
||||
store.delete_session.side_effect = RuntimeError("Redis down")
|
||||
|
||||
use_case = LogoutUseCase(session_store=store)
|
||||
req = LogoutRequest(user_id="user-123", session_id="sess-1")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Logout failed" in error
|
||||
+705
@@ -0,0 +1,705 @@
|
||||
"""Auth register + password reset use cases unit tests.
|
||||
|
||||
Covers RegisterUserUseCase, VerifyEmailUseCase,
|
||||
RequestPasswordResetUseCase, ResetPasswordUseCase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.password_hasher import password_hasher
|
||||
from packages.application.auth.password_reset_use_case import (
|
||||
RequestPasswordResetRequest,
|
||||
RequestPasswordResetUseCase,
|
||||
ResetPasswordRequest,
|
||||
ResetPasswordUseCase,
|
||||
)
|
||||
from packages.application.auth.register_user_use_case import (
|
||||
RegisterUserRequest,
|
||||
RegisterUserResponse,
|
||||
RegisterUserUseCase,
|
||||
VerifyEmailRequest,
|
||||
VerifyEmailUseCase,
|
||||
)
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeUser:
|
||||
def __init__(self, **kwargs):
|
||||
self.id = kwargs.get("id", "user-123")
|
||||
self.email = kwargs.get("email", "test@example.com")
|
||||
self.display_name = kwargs.get("display_name", "Test User")
|
||||
self.username = kwargs.get("username", "testuser")
|
||||
self.password_hash = kwargs.get("password_hash", "")
|
||||
self.email_verified = kwargs.get("email_verified", False)
|
||||
self.email_verification_token = kwargs.get("email_verification_token", None)
|
||||
self.password_reset_token = kwargs.get("password_reset_token", None)
|
||||
self.password_reset_expires_at = kwargs.get("password_reset_expires_at", None)
|
||||
self.created_at = kwargs.get("created_at", datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FakeUserRepository:
|
||||
def __init__(self, user=None):
|
||||
self._user = user
|
||||
self.saved_user = None
|
||||
self.save_called = 0
|
||||
|
||||
def find_by_email(self, email):
|
||||
if self._user and self._user.email == email:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_username(self, username):
|
||||
if self._user and self._user.username == username:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_verification_token(self, token):
|
||||
if self._user and self._user.email_verification_token == token:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def find_by_password_reset_token(self, token):
|
||||
if self._user and self._user.password_reset_token == token:
|
||||
return self._user
|
||||
return None
|
||||
|
||||
def save(self, user):
|
||||
self.saved_user = user
|
||||
self.save_called += 1
|
||||
self._user = user
|
||||
return user
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
def __init__(self, send_success=True, send_error=None):
|
||||
self._send_success = send_success
|
||||
self._send_error = send_error
|
||||
self.sent_emails = []
|
||||
self.verification_emails = []
|
||||
self.password_reset_emails = []
|
||||
|
||||
def send_verification_email(self, to_email, username, verification_url):
|
||||
self.verification_emails.append(
|
||||
{
|
||||
"to": to_email,
|
||||
"username": username,
|
||||
"url": verification_url,
|
||||
}
|
||||
)
|
||||
self.sent_emails.append(("verification", to_email))
|
||||
return self._send_success, self._send_error
|
||||
|
||||
def send_password_reset_email(self, to_email, username, reset_url):
|
||||
self.password_reset_emails.append(
|
||||
{
|
||||
"to": to_email,
|
||||
"username": username,
|
||||
"url": reset_url,
|
||||
}
|
||||
)
|
||||
self.sent_emails.append(("password_reset", to_email))
|
||||
return self._send_success, self._send_error
|
||||
|
||||
|
||||
class FailingEmailService:
|
||||
"""Email service that raises an exception."""
|
||||
|
||||
def send_verification_email(self, **kwargs):
|
||||
raise RuntimeError("SMTP connection failed")
|
||||
|
||||
def send_password_reset_email(self, **kwargs):
|
||||
raise RuntimeError("SMTP connection failed")
|
||||
|
||||
|
||||
# ── RegisterUserUseCase tests ───────────────────────────
|
||||
|
||||
|
||||
class TestRegisterUserUseCase:
|
||||
def _make_use_case(self, repo=None, email_service=None):
|
||||
return RegisterUserUseCase(
|
||||
user_repository=repo or FakeUserRepository(),
|
||||
base_url="https://app.example.com",
|
||||
email_service=email_service or FakeEmailService(),
|
||||
)
|
||||
|
||||
def test_successful_registration(self):
|
||||
repo = FakeUserRepository()
|
||||
email_svc = FakeEmailService()
|
||||
use_case = self._make_use_case(repo=repo, email_service=email_svc)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="newuser@example.com",
|
||||
password="StrongPass123!",
|
||||
username="newuser",
|
||||
display_name="New User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert isinstance(response, RegisterUserResponse)
|
||||
assert response.email == "newuser@example.com"
|
||||
assert response.username == "newuser"
|
||||
assert response.display_name == "New User"
|
||||
assert response.user_id
|
||||
assert response.email_verification_sent is True
|
||||
|
||||
# user was saved
|
||||
assert repo.saved_user is not None
|
||||
assert repo.saved_user.email == "newuser@example.com"
|
||||
assert repo.saved_user.email_verified is False
|
||||
assert repo.saved_user.email_verification_token is not None
|
||||
# password was hashed
|
||||
assert repo.saved_user.password_hash != "StrongPass123!"
|
||||
assert password_hasher.verify_password("StrongPass123!", repo.saved_user.password_hash)
|
||||
|
||||
# email was sent
|
||||
assert len(email_svc.verification_emails) == 1
|
||||
sent = email_svc.verification_emails[0]
|
||||
assert sent["to"] == "newuser@example.com"
|
||||
assert sent["username"] == "newuser"
|
||||
assert "verify-email?token=" in sent["url"]
|
||||
assert "https://app.example.com" in sent["url"]
|
||||
|
||||
def test_email_stripped_and_lowercased(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email=" NEWUSER@EXAMPLE.COM ",
|
||||
password="StrongPass123!",
|
||||
username="newuser",
|
||||
display_name="New User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.email == "newuser@example.com"
|
||||
assert repo.saved_user.email == "newuser@example.com"
|
||||
|
||||
def test_username_stripped(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="a@b.com",
|
||||
password="StrongPass123!",
|
||||
username=" myuser ",
|
||||
display_name="Display",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.username == "myuser"
|
||||
|
||||
def test_display_name_stripped(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="a@b.com",
|
||||
password="StrongPass123!",
|
||||
username="user",
|
||||
display_name=" My Name ",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response.display_name == "My Name"
|
||||
|
||||
def test_empty_email_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = RegisterUserRequest(email="", password="StrongPass123!", username="u", display_name="D")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Email is required" in error
|
||||
|
||||
def test_empty_username_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username=" ", display_name="D")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Username is required" in error
|
||||
|
||||
def test_empty_display_name_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username="u", display_name=" ")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Display name is required" in error
|
||||
|
||||
def test_weak_password_returns_error(self):
|
||||
use_case = self._make_use_case()
|
||||
req = RegisterUserRequest(email="a@b.com", password="weak", username="u", display_name="D")
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert error is not None
|
||||
# password validation error message
|
||||
assert len(error) > 0
|
||||
|
||||
def test_email_already_registered(self):
|
||||
existing = FakeUser(email="existing@example.com", username="existinguser")
|
||||
repo = FakeUserRepository(user=existing)
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="existing@example.com",
|
||||
password="StrongPass123!",
|
||||
username="newuser",
|
||||
display_name="New User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Email already registered" in error
|
||||
# no new user saved
|
||||
assert repo.save_called == 0
|
||||
|
||||
def test_username_already_taken(self):
|
||||
existing = FakeUser(email="other@example.com", username="taken")
|
||||
repo = FakeUserRepository(user=existing)
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="new@example.com",
|
||||
password="StrongPass123!",
|
||||
username="taken",
|
||||
display_name="New User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Username already taken" in error
|
||||
|
||||
def test_email_service_failure_user_still_created(self):
|
||||
repo = FakeUserRepository()
|
||||
email_svc = FakeEmailService(send_success=False, send_error="SMTP error")
|
||||
use_case = self._make_use_case(repo=repo, email_service=email_svc)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="a@b.com",
|
||||
password="StrongPass123!",
|
||||
username="user",
|
||||
display_name="User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
# user still created
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.email_verification_sent is False
|
||||
assert repo.saved_user is not None
|
||||
|
||||
def test_email_service_exception_user_still_created(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=FailingEmailService(),
|
||||
)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="a@b.com",
|
||||
password="StrongPass123!",
|
||||
username="user",
|
||||
display_name="User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.email_verification_sent is False
|
||||
assert repo.saved_user is not None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_email.side_effect = RuntimeError("DB down")
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(
|
||||
email="a@b.com",
|
||||
password="StrongPass123!",
|
||||
username="user",
|
||||
display_name="User",
|
||||
)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
assert response is None
|
||||
assert "Registration failed" in error
|
||||
|
||||
def test_user_id_is_generated(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username="u", display_name="D")
|
||||
response, _ = use_case.execute(req)
|
||||
|
||||
assert response.user_id
|
||||
assert len(response.user_id) == 32 # uuid4 hex
|
||||
|
||||
|
||||
# ── VerifyEmailUseCase tests ────────────────────────────
|
||||
|
||||
|
||||
class TestVerifyEmailUseCase:
|
||||
def test_successful_verification(self):
|
||||
user = FakeUser(email_verified=False, email_verification_token="test-token-123")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="test-token-123")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert repo.saved_user.email_verified is True
|
||||
assert repo.saved_user.email_verification_token is None
|
||||
|
||||
def test_empty_token(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Verification token is required" in error
|
||||
|
||||
def test_invalid_token(self):
|
||||
repo = FakeUserRepository()
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="nonexistent-token")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Invalid or expired verification token" in error
|
||||
|
||||
def test_already_verified_returns_success(self):
|
||||
user = FakeUser(email_verified=True, email_verification_token="some-token")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="some-token")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
# idempotent - no save needed
|
||||
# (depends on implementation - current returns early without save)
|
||||
|
||||
def test_token_cleared_after_verification(self):
|
||||
user = FakeUser(email_verified=False, email_verification_token="tok123")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="tok123")
|
||||
use_case.execute(req)
|
||||
|
||||
assert repo.saved_user.email_verification_token is None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_verification_token.side_effect = RuntimeError("DB down")
|
||||
use_case = VerifyEmailUseCase(user_repository=repo)
|
||||
|
||||
req = VerifyEmailRequest(token="token")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Email verification failed" in error
|
||||
|
||||
|
||||
# ── RequestPasswordResetUseCase tests ───────────────────
|
||||
|
||||
|
||||
class TestRequestPasswordResetUseCase:
|
||||
def _make_use_case(self, repo=None, email_service=None, expire_hours=1):
|
||||
return RequestPasswordResetUseCase(
|
||||
user_repository=repo or FakeUserRepository(),
|
||||
base_url="https://app.example.com",
|
||||
token_expire_hours=expire_hours,
|
||||
email_service=email_service or FakeEmailService(),
|
||||
)
|
||||
|
||||
def test_successful_request(self):
|
||||
user = FakeUser(email="user@example.com", username="testuser")
|
||||
repo = FakeUserRepository(user=user)
|
||||
email_svc = FakeEmailService()
|
||||
use_case = self._make_use_case(repo=repo, email_service=email_svc)
|
||||
|
||||
req = RequestPasswordResetRequest(email="user@example.com")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
# token set on user
|
||||
assert repo.saved_user.password_reset_token is not None
|
||||
assert len(repo.saved_user.password_reset_token) > 10
|
||||
assert repo.saved_user.password_reset_expires_at is not None
|
||||
|
||||
# email sent
|
||||
assert len(email_svc.password_reset_emails) == 1
|
||||
sent = email_svc.password_reset_emails[0]
|
||||
assert sent["to"] == "user@example.com"
|
||||
assert "reset-password?token=" in sent["url"]
|
||||
|
||||
def test_empty_email(self):
|
||||
use_case = self._make_use_case()
|
||||
req = RequestPasswordResetRequest(email="")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Email is required" in error
|
||||
|
||||
def test_nonexistent_user_returns_true_security(self):
|
||||
repo = FakeUserRepository() # no users
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RequestPasswordResetRequest(email="nobody@example.com")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
# Always returns true to prevent user enumeration
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert repo.save_called == 0 # no save
|
||||
|
||||
def test_token_expiry_set_correctly(self):
|
||||
user = FakeUser(email="u@e.com")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(repo=repo, expire_hours=2)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
req = RequestPasswordResetRequest(email="u@e.com")
|
||||
use_case.execute(req)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
expires_at = repo.saved_user.password_reset_expires_at
|
||||
assert expires_at is not None
|
||||
# should be ~2 hours from now
|
||||
min_expected = before + timedelta(hours=2)
|
||||
max_expected = after + timedelta(hours=2)
|
||||
assert min_expected <= expires_at <= max_expected + timedelta(seconds=1)
|
||||
|
||||
def test_default_expire_hours(self):
|
||||
user = FakeUser(email="u@e.com")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
user_repository=repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=FakeEmailService(),
|
||||
)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
req = RequestPasswordResetRequest(email="u@e.com")
|
||||
use_case.execute(req)
|
||||
|
||||
expires_at = repo.saved_user.password_reset_expires_at
|
||||
assert expires_at is not None
|
||||
# default is 1 hour
|
||||
assert timedelta(minutes=55) < (expires_at - before) < timedelta(hours=1, minutes=1)
|
||||
|
||||
def test_email_service_failure_still_returns_true(self):
|
||||
user = FakeUser(email="u@e.com")
|
||||
repo = FakeUserRepository(user=user)
|
||||
email_svc = FakeEmailService(send_success=False, send_error="SMTP down")
|
||||
use_case = self._make_use_case(repo=repo, email_service=email_svc)
|
||||
|
||||
req = RequestPasswordResetRequest(email="u@e.com")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
# still returns true for security
|
||||
assert success is True
|
||||
assert error is None
|
||||
# token still set
|
||||
assert repo.saved_user.password_reset_token is not None
|
||||
|
||||
def test_email_service_exception_still_returns_true(self):
|
||||
user = FakeUser(email="u@e.com")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = RequestPasswordResetUseCase(
|
||||
user_repository=repo,
|
||||
base_url="https://app.example.com",
|
||||
email_service=FailingEmailService(),
|
||||
)
|
||||
|
||||
req = RequestPasswordResetRequest(email="u@e.com")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert repo.saved_user.password_reset_token is not None
|
||||
|
||||
def test_email_stripped_lowercased(self):
|
||||
user = FakeUser(email="u@e.com")
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RequestPasswordResetRequest(email=" U@E.COM ")
|
||||
# email would be normalized but find_by_email should still find it
|
||||
# since our fake repo compares exact strings
|
||||
# Let's just check the normalization happens
|
||||
assert req.email == "u@e.com"
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_email.side_effect = RuntimeError("DB down")
|
||||
use_case = self._make_use_case(repo=repo)
|
||||
|
||||
req = RequestPasswordResetRequest(email="a@b.com")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Password reset request failed" in error
|
||||
|
||||
|
||||
# ── ResetPasswordUseCase tests ──────────────────────────
|
||||
|
||||
|
||||
class TestResetPasswordUseCase:
|
||||
def test_successful_reset(self):
|
||||
token = "reset-token-123"
|
||||
old_hash = password_hasher.hash_password("oldpassword")
|
||||
user = FakeUser(
|
||||
password_hash=old_hash,
|
||||
password_reset_token=token,
|
||||
password_reset_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token=token, new_password="NewStrongPass456!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
# password updated
|
||||
assert repo.saved_user.password_hash != old_hash
|
||||
assert password_hasher.verify_password("NewStrongPass456!", repo.saved_user.password_hash)
|
||||
|
||||
# token cleared
|
||||
assert repo.saved_user.password_reset_token is None
|
||||
assert repo.saved_user.password_reset_expires_at is None
|
||||
|
||||
def test_empty_token(self):
|
||||
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
|
||||
req = ResetPasswordRequest(token="", new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Reset token is required" in error
|
||||
|
||||
def test_empty_new_password(self):
|
||||
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
|
||||
req = ResetPasswordRequest(token="token", new_password="")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "New password is required" in error
|
||||
|
||||
def test_weak_new_password(self):
|
||||
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
|
||||
req = ResetPasswordRequest(token="token", new_password="weak")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert error is not None
|
||||
# some validation error
|
||||
assert len(error) > 0
|
||||
|
||||
def test_invalid_token(self):
|
||||
repo = FakeUserRepository() # no user with this token
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token="bad-token", new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Invalid or expired reset token" in error
|
||||
|
||||
def test_expired_token(self):
|
||||
token = "expired-token"
|
||||
user = FakeUser(
|
||||
password_reset_token=token,
|
||||
password_reset_expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "expired" in error.lower()
|
||||
|
||||
def test_naive_datetime_expiry_treated_as_utc(self):
|
||||
token = "naive-token"
|
||||
# naive datetime representing UTC time 1 hour in the past
|
||||
naive_expired = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
|
||||
user = FakeUser(
|
||||
password_reset_token=token,
|
||||
password_reset_expires_at=naive_expired,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "expired" in error.lower()
|
||||
|
||||
def test_no_expiry_set_does_not_expire(self):
|
||||
token = "no-expiry-token"
|
||||
user = FakeUser(
|
||||
password_reset_token=token,
|
||||
password_reset_expires_at=None,
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
# no expiry = should work
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
def test_token_cleared_after_reset(self):
|
||||
token = "clear-me"
|
||||
user = FakeUser(
|
||||
password_reset_token=token,
|
||||
password_reset_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
)
|
||||
repo = FakeUserRepository(user=user)
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
|
||||
use_case.execute(req)
|
||||
|
||||
assert repo.saved_user.password_reset_token is None
|
||||
assert repo.saved_user.password_reset_expires_at is None
|
||||
|
||||
def test_exception_handling(self):
|
||||
repo = MagicMock()
|
||||
repo.find_by_password_reset_token.side_effect = RuntimeError("DB down")
|
||||
use_case = ResetPasswordUseCase(user_repository=repo)
|
||||
|
||||
req = ResetPasswordRequest(token="token", new_password="StrongPass123!")
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
assert success is False
|
||||
assert "Password reset failed" in error
|
||||
@@ -456,25 +456,10 @@ class TestJWTService:
|
||||
service.verify_token(token)
|
||||
|
||||
def test_verify_token_tampered_signature(self, service):
|
||||
"""篡改payload的token无法验证(签名不匹配)"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
"""篡改签名的token无法验证"""
|
||||
token = service.create_access_token(user_id="user_123")
|
||||
parts = token.split(".")
|
||||
assert len(parts) == 3
|
||||
# 篡改 payload 部分(改用户ID),会导致签名不匹配
|
||||
payload_b64 = parts[1]
|
||||
# 补 padding 以便解码
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += "=" * padding
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
payload = json.loads(payload_bytes)
|
||||
payload["sub"] = "hacked_user"
|
||||
new_payload_bytes = json.dumps(payload).encode()
|
||||
new_payload_b64 = base64.urlsafe_b64encode(new_payload_bytes).rstrip(b"=").decode()
|
||||
tampered = f"{parts[0]}.{new_payload_b64}.{parts[2]}"
|
||||
# 篡改最后一个字符
|
||||
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||
with pytest.raises(InvalidTokenError):
|
||||
service.verify_token(tampered)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Regular → Executable
+4
@@ -73,6 +73,7 @@ class TestGenerationTaskCreate:
|
||||
asset_select_mode="smart",
|
||||
batch_id="batch-001",
|
||||
video_title="测试视频",
|
||||
resolution="1080x1920",
|
||||
auto_retry_enabled=True,
|
||||
auto_retry_max=3,
|
||||
)
|
||||
@@ -87,6 +88,7 @@ class TestGenerationTaskCreate:
|
||||
assert task.asset_select_mode == "smart"
|
||||
assert task.batch_id == "batch-001"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080x1920"
|
||||
assert task.auto_retry_enabled is True
|
||||
assert task.auto_retry_max == 3
|
||||
|
||||
@@ -134,12 +136,14 @@ class TestGenerationTaskCreate:
|
||||
strategy_id=" strat-789 ",
|
||||
template_id=" tmpl-001 ",
|
||||
video_title=" 测试视频 ",
|
||||
resolution=" 1080x1920 ",
|
||||
)
|
||||
assert task.project_id == "proj-123"
|
||||
assert task.asset_library_id == "lib-456"
|
||||
assert task.strategy_id == "strat-789"
|
||||
assert task.template_id == "tmpl-001"
|
||||
assert task.video_title == "测试视频"
|
||||
assert task.resolution == "1080x1920"
|
||||
|
||||
def test_create_default_empty_lists(self):
|
||||
"""测试 None 列表默认化为空列表"""
|
||||
|
||||
Executable
+320
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
生成任务应用层用例单元测试(第十九波)
|
||||
|
||||
覆盖:
|
||||
- CreateGenerationTaskUseCase
|
||||
- GetGenerationTaskUseCase
|
||||
- ListUserTasksFilteredUseCase
|
||||
- RetryGenerationTaskUseCase
|
||||
- Command / Filter / Result 对象
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGenerationTasksResult,
|
||||
ListTasksFilter,
|
||||
ListUserTasksFilteredUseCase,
|
||||
RetryGenerationTaskUseCase,
|
||||
)
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def make_task(status=GenerationTaskStatus.PENDING, **kwargs):
|
||||
task = GenerationTask(
|
||||
id="task-1",
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="strat-1",
|
||||
template_id="tmpl-1",
|
||||
asset_ids=["asset-1"],
|
||||
title_ids=["title-1"],
|
||||
voice_ids=["voice-1"],
|
||||
created_by_user_id="user-1",
|
||||
video_title="测试标题",
|
||||
)
|
||||
if status != GenerationTaskStatus.PENDING:
|
||||
object.__setattr__(task, "status", status)
|
||||
# 应用额外 kwargs
|
||||
for k, v in kwargs.items():
|
||||
object.__setattr__(task, k, v)
|
||||
return task
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CreateGenerationTaskUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCreateGenerationTaskUseCase:
|
||||
"""CreateGenerationTaskUseCase 创建生成任务"""
|
||||
|
||||
def test_create_success(self, mock_repo):
|
||||
"""正常创建任务"""
|
||||
mock_repo.create.side_effect = lambda t: t
|
||||
|
||||
cmd = CreateGenerationTaskCommand(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="strat-1",
|
||||
voice_library_id="vlib-1",
|
||||
template_id="tmpl-1",
|
||||
asset_ids=["a1", "a2"],
|
||||
title_ids=["t1"],
|
||||
voice_ids=["v1"],
|
||||
created_by_user_id="user-1",
|
||||
source_edit_plan_id="plan-1",
|
||||
asset_select_mode="auto",
|
||||
batch_id="batch-1",
|
||||
video_title="我的视频",
|
||||
auto_retry_enabled=True,
|
||||
auto_retry_max=3,
|
||||
)
|
||||
uc = CreateGenerationTaskUseCase(mock_repo)
|
||||
task = uc.execute(cmd)
|
||||
|
||||
assert task.project_id == "proj-1"
|
||||
assert task.asset_library_id == "lib-1"
|
||||
assert task.strategy_id == "strat-1"
|
||||
assert task.voice_library_id == "vlib-1"
|
||||
assert task.template_id == "tmpl-1"
|
||||
assert task.asset_ids == ["a1", "a2"]
|
||||
assert task.title_ids == ["t1"]
|
||||
assert task.voice_ids == ["v1"]
|
||||
assert task.created_by_user_id == "user-1"
|
||||
assert task.source_edit_plan_id == "plan-1"
|
||||
assert task.asset_select_mode == "auto"
|
||||
assert task.batch_id == "batch-1"
|
||||
assert task.video_title == "我的视频"
|
||||
assert task.auto_retry_enabled is True
|
||||
assert task.auto_retry_max == 3
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert task.progress == 0.0
|
||||
assert task.result_count == 0
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
def test_create_default_values(self, mock_repo):
|
||||
"""默认参数值"""
|
||||
mock_repo.create.side_effect = lambda t: t
|
||||
|
||||
cmd = CreateGenerationTaskCommand(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
)
|
||||
uc = CreateGenerationTaskUseCase(mock_repo)
|
||||
task = uc.execute(cmd)
|
||||
|
||||
assert task.asset_ids == []
|
||||
assert task.title_ids == []
|
||||
assert task.voice_ids == []
|
||||
assert task.created_by_user_id == ""
|
||||
assert task.video_title == ""
|
||||
assert task.auto_retry_enabled is False
|
||||
assert task.auto_retry_max == 0
|
||||
|
||||
def test_create_id_is_generated(self, mock_repo):
|
||||
"""ID 会自动生成"""
|
||||
mock_repo.create.side_effect = lambda t: t
|
||||
|
||||
cmd = CreateGenerationTaskCommand(project_id="proj-1", asset_library_id="lib-1")
|
||||
uc = CreateGenerationTaskUseCase(mock_repo)
|
||||
task = uc.execute(cmd)
|
||||
|
||||
assert task.id
|
||||
assert isinstance(task.id, str)
|
||||
assert len(task.id) > 10 # uuid hex
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GetGenerationTaskUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGetGenerationTaskUseCase:
|
||||
"""GetGenerationTaskUseCase 获取任务"""
|
||||
|
||||
def test_get_existing(self, mock_repo):
|
||||
"""获取存在的任务"""
|
||||
task = make_task()
|
||||
mock_repo.get.return_value = task
|
||||
|
||||
uc = GetGenerationTaskUseCase(mock_repo)
|
||||
result = uc.execute("task-1")
|
||||
|
||||
assert result is task
|
||||
mock_repo.get.assert_called_once_with("task-1")
|
||||
|
||||
def test_get_not_found(self, mock_repo):
|
||||
"""获取不存在的任务返回 None"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = GetGenerationTaskUseCase(mock_repo)
|
||||
result = uc.execute("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ListUserTasksFilteredUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestListUserTasksFilteredUseCase:
|
||||
"""ListUserTasksFilteredUseCase 按用户筛选任务"""
|
||||
|
||||
def test_list_without_filters(self, mock_repo):
|
||||
"""无筛选条件查询"""
|
||||
tasks = [make_task(), make_task()]
|
||||
mock_repo.list_by_user_filtered.return_value = tasks
|
||||
mock_repo.count_by_user_filtered.return_value = 2
|
||||
|
||||
uc = ListUserTasksFilteredUseCase(mock_repo)
|
||||
result = uc.execute("user-1")
|
||||
|
||||
assert isinstance(result, ListGenerationTasksResult)
|
||||
assert len(result.items) == 2
|
||||
assert result.total == 2
|
||||
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status=None, limit=None, offset=0)
|
||||
mock_repo.count_by_user_filtered.assert_called_once_with("user-1", status=None)
|
||||
|
||||
def test_list_with_status_filter(self, mock_repo):
|
||||
"""按状态筛选"""
|
||||
mock_repo.list_by_user_filtered.return_value = []
|
||||
mock_repo.count_by_user_filtered.return_value = 0
|
||||
|
||||
uc = ListUserTasksFilteredUseCase(mock_repo)
|
||||
uc.execute("user-1", status="running")
|
||||
|
||||
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status="running", limit=None, offset=0)
|
||||
mock_repo.count_by_user_filtered.assert_called_once_with("user-1", status="running")
|
||||
|
||||
def test_list_with_pagination(self, mock_repo):
|
||||
"""分页查询"""
|
||||
mock_repo.list_by_user_filtered.return_value = []
|
||||
mock_repo.count_by_user_filtered.return_value = 100
|
||||
|
||||
uc = ListUserTasksFilteredUseCase(mock_repo)
|
||||
result = uc.execute("user-1", limit=10, offset=20)
|
||||
|
||||
assert result.total == 100
|
||||
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status=None, limit=10, offset=20)
|
||||
|
||||
def test_list_empty_result(self, mock_repo):
|
||||
"""空结果"""
|
||||
mock_repo.list_by_user_filtered.return_value = []
|
||||
mock_repo.count_by_user_filtered.return_value = 0
|
||||
|
||||
uc = ListUserTasksFilteredUseCase(mock_repo)
|
||||
result = uc.execute("user-1", status="failed")
|
||||
|
||||
assert result.items == []
|
||||
assert result.total == 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RetryGenerationTaskUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestRetryGenerationTaskUseCase:
|
||||
"""RetryGenerationTaskUseCase 重试失败任务"""
|
||||
|
||||
def test_retry_success(self, mock_repo):
|
||||
"""失败任务重试成功"""
|
||||
task = make_task(
|
||||
status=GenerationTaskStatus.FAILED,
|
||||
error_message="网络超时",
|
||||
retry_count=0,
|
||||
)
|
||||
mock_repo.get.return_value = task
|
||||
mock_repo.update.side_effect = lambda t: t
|
||||
|
||||
uc = RetryGenerationTaskUseCase(mock_repo)
|
||||
result = uc.execute("task-1")
|
||||
|
||||
assert result.status == GenerationTaskStatus.PENDING
|
||||
assert result.retry_count == 1
|
||||
assert result.error_message == ""
|
||||
assert result.error_info == {}
|
||||
assert result.progress == 0.0
|
||||
assert result.result_count == 0
|
||||
assert result.started_at is None
|
||||
assert result.completed_at is None
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_retry_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = RetryGenerationTaskUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute("nonexistent")
|
||||
|
||||
def test_retry_not_failed(self, mock_repo):
|
||||
"""非失败状态不能重试"""
|
||||
task = make_task(status=GenerationTaskStatus.RUNNING)
|
||||
mock_repo.get.return_value = task
|
||||
|
||||
uc = RetryGenerationTaskUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="只有失败状态"):
|
||||
uc.execute("task-1")
|
||||
|
||||
def test_retry_pending_not_allowed(self, mock_repo):
|
||||
"""pending 状态不能重试"""
|
||||
task = make_task(status=GenerationTaskStatus.PENDING)
|
||||
mock_repo.get.return_value = task
|
||||
|
||||
uc = RetryGenerationTaskUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="只有失败状态"):
|
||||
uc.execute("task-1")
|
||||
|
||||
def test_retry_preserves_id(self, mock_repo):
|
||||
"""重试复用同一个 task_id"""
|
||||
task = make_task(status=GenerationTaskStatus.FAILED)
|
||||
original_id = task.id
|
||||
mock_repo.get.return_value = task
|
||||
mock_repo.update.side_effect = lambda t: t
|
||||
|
||||
uc = RetryGenerationTaskUseCase(mock_repo)
|
||||
result = uc.execute("task-1")
|
||||
|
||||
assert result.id == original_id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Command / Filter / Result 对象
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCommandAndDataObjects:
|
||||
"""命令对象和数据对象"""
|
||||
|
||||
def test_create_command_defaults(self):
|
||||
cmd = CreateGenerationTaskCommand()
|
||||
assert cmd.project_id == ""
|
||||
assert cmd.asset_library_id == ""
|
||||
assert cmd.asset_ids == []
|
||||
assert cmd.title_ids == []
|
||||
assert cmd.voice_ids == []
|
||||
assert cmd.auto_retry_enabled is False
|
||||
assert cmd.auto_retry_max == 0
|
||||
|
||||
def test_list_filter_defaults(self):
|
||||
f = ListTasksFilter()
|
||||
assert f.status is None
|
||||
|
||||
def test_list_result(self):
|
||||
task = make_task()
|
||||
r = ListGenerationTasksResult(items=[task], total=1)
|
||||
assert len(r.items) == 1
|
||||
assert r.total == 1
|
||||
Executable
+576
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
Job 应用层用例单元测试(第十八波)
|
||||
|
||||
覆盖:
|
||||
- CreateJobUseCase
|
||||
- SubmitJobUseCase
|
||||
- UpdateJobProgressUseCase
|
||||
- CompleteJobUseCase
|
||||
- FailJobUseCase
|
||||
- RetryJobUseCase
|
||||
- CancelJobUseCase
|
||||
- GetJobUseCase
|
||||
- ListJobsUseCase
|
||||
- GetJobStatisticsUseCase
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.jobs import (
|
||||
CancelJobUseCase,
|
||||
CompleteJobCommand,
|
||||
CompleteJobUseCase,
|
||||
CreateJobCommand,
|
||||
CreateJobUseCase,
|
||||
FailJobCommand,
|
||||
FailJobUseCase,
|
||||
GetJobStatisticsUseCase,
|
||||
GetJobUseCase,
|
||||
ListJobsUseCase,
|
||||
RetryJobUseCase,
|
||||
SubmitJobUseCase,
|
||||
UpdateJobProgressCommand,
|
||||
UpdateJobProgressUseCase,
|
||||
)
|
||||
from packages.domain.job import Job, JobStatus, JobType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def make_job(
|
||||
status=JobStatus.PENDING,
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
project_id="proj-1",
|
||||
**kwargs,
|
||||
):
|
||||
job = Job.create(
|
||||
project_id=project_id,
|
||||
job_type=job_type,
|
||||
**kwargs,
|
||||
)
|
||||
# 绕过状态机直接设置状态(测试构造用)
|
||||
if status != JobStatus.PENDING:
|
||||
object.__setattr__(job, "status", status)
|
||||
return job
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CreateJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCreateJobUseCase:
|
||||
"""CreateJobUseCase 创建任务"""
|
||||
|
||||
def test_create_success(self, mock_repo):
|
||||
"""正常创建任务"""
|
||||
mock_repo.create.side_effect = lambda j: j
|
||||
|
||||
cmd = CreateJobCommand(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
payload={"key": "val"},
|
||||
source_id="src-1",
|
||||
created_by_user_id="user-1",
|
||||
max_retries=5,
|
||||
)
|
||||
uc = CreateJobUseCase(mock_repo)
|
||||
job = uc.execute(cmd)
|
||||
|
||||
assert job.project_id == "proj-1"
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
assert job.payload == {"key": "val"}
|
||||
assert job.source_id == "src-1"
|
||||
assert job.created_by_user_id == "user-1"
|
||||
assert job.max_retries == 5
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.progress == 0.0
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
def test_create_default_values(self, mock_repo):
|
||||
"""默认参数"""
|
||||
mock_repo.create.side_effect = lambda j: j
|
||||
|
||||
cmd = CreateJobCommand(project_id="proj-1", job_type="video_compose")
|
||||
uc = CreateJobUseCase(mock_repo)
|
||||
job = uc.execute(cmd)
|
||||
|
||||
assert job.payload == {}
|
||||
assert job.source_id == ""
|
||||
assert job.created_by_user_id == ""
|
||||
assert job.max_retries == 3
|
||||
|
||||
def test_create_string_job_type(self, mock_repo):
|
||||
"""字符串类型的 job_type 也支持"""
|
||||
mock_repo.create.side_effect = lambda j: j
|
||||
|
||||
cmd = CreateJobCommand(project_id="proj-1", job_type="asset_ingest")
|
||||
uc = CreateJobUseCase(mock_repo)
|
||||
job = uc.execute(cmd)
|
||||
|
||||
assert job.job_type == JobType.ASSET_INGEST
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SubmitJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestSubmitJobUseCase:
|
||||
"""SubmitJobUseCase 提交任务"""
|
||||
|
||||
def test_submit_success(self, mock_repo):
|
||||
"""正常提交 pending 任务"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
uc = SubmitJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id, celery_task_id="celery-123")
|
||||
|
||||
assert result.status == JobStatus.RUNNING
|
||||
assert result.celery_task_id == "celery-123"
|
||||
assert result.current_stage == "已提交,等待执行"
|
||||
assert result.started_at is not None
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_submit_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = SubmitJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute("nonexistent")
|
||||
|
||||
def test_submit_already_running(self, mock_repo):
|
||||
"""已经是 running 状态不能再提交"""
|
||||
job = make_job(status=JobStatus.RUNNING)
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
uc = SubmitJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="只有 pending 状态"):
|
||||
uc.execute(job.id)
|
||||
|
||||
def test_submit_without_celery_id(self, mock_repo):
|
||||
"""不传 celery_task_id 也可以"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
uc = SubmitJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id)
|
||||
|
||||
assert result.status == JobStatus.RUNNING
|
||||
assert result.celery_task_id == ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# UpdateJobProgressUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestUpdateJobProgressUseCase:
|
||||
"""UpdateJobProgressUseCase 更新进度"""
|
||||
|
||||
def test_update_progress_success(self, mock_repo):
|
||||
"""正常更新进度"""
|
||||
job = make_job(status=JobStatus.RUNNING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0, current_stage="处理中")
|
||||
uc = UpdateJobProgressUseCase(mock_repo)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.progress == 50.0
|
||||
assert result.current_stage == "处理中"
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_update_progress_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
cmd = UpdateJobProgressCommand(job_id="nope", progress=50.0)
|
||||
uc = UpdateJobProgressUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute(cmd)
|
||||
|
||||
def test_update_progress_not_running(self, mock_repo):
|
||||
"""非 running 状态不能更新进度"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0)
|
||||
uc = UpdateJobProgressUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="只有 running 状态"):
|
||||
uc.execute(cmd)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CompleteJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCompleteJobUseCase:
|
||||
"""CompleteJobUseCase 完成任务"""
|
||||
|
||||
def test_complete_from_running(self, mock_repo):
|
||||
"""从 running 状态完成"""
|
||||
job = make_job(status=JobStatus.RUNNING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
cmd = CompleteJobCommand(job_id=job.id, result={"output": "ok"})
|
||||
uc = CompleteJobUseCase(mock_repo)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.status == JobStatus.SUCCESS
|
||||
assert result.progress == 100.0
|
||||
assert result.result == {"output": "ok"}
|
||||
assert result.completed_at is not None
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_complete_from_pending(self, mock_repo):
|
||||
"""从 pending 状态也可以直接完成"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
cmd = CompleteJobCommand(job_id=job.id)
|
||||
uc = CompleteJobUseCase(mock_repo)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.status == JobStatus.SUCCESS
|
||||
assert result.progress == 100.0
|
||||
|
||||
def test_complete_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
cmd = CompleteJobCommand(job_id="nope")
|
||||
uc = CompleteJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute(cmd)
|
||||
|
||||
def test_complete_already_failed(self, mock_repo):
|
||||
"""已失败的任务不能直接标记完成"""
|
||||
job = make_job(status=JobStatus.FAILED)
|
||||
job.error_message = "some error"
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
cmd = CompleteJobCommand(job_id=job.id)
|
||||
uc = CompleteJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="只有 running/pending"):
|
||||
uc.execute(cmd)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# FailJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestFailJobUseCase:
|
||||
"""FailJobUseCase 失败任务"""
|
||||
|
||||
def test_fail_from_running(self, mock_repo):
|
||||
"""从 running 状态失败"""
|
||||
job = make_job(status=JobStatus.RUNNING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
cmd = FailJobCommand(job_id=job.id, error_message="网络超时")
|
||||
uc = FailJobUseCase(mock_repo)
|
||||
result = uc.execute(cmd)
|
||||
|
||||
assert result.status == JobStatus.FAILED
|
||||
assert result.error_message == "网络超时"
|
||||
assert result.current_stage == "失败"
|
||||
assert result.completed_at is not None
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_fail_pending_rejected_by_domain(self, mock_repo):
|
||||
"""pending 状态不能直接失败(领域状态机约束)"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
cmd = FailJobCommand(job_id=job.id, error_message="资源不足")
|
||||
uc = FailJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
uc.execute(cmd)
|
||||
|
||||
def test_fail_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
cmd = FailJobCommand(job_id="nope", error_message="err")
|
||||
uc = FailJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute(cmd)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RetryJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestRetryJobUseCase:
|
||||
"""RetryJobUseCase 重试任务"""
|
||||
|
||||
def test_retry_success(self, mock_repo):
|
||||
"""失败任务重试成功"""
|
||||
job = make_job(status=JobStatus.FAILED, max_retries=3)
|
||||
job.retry_count = 0
|
||||
job.error_message = "timeout"
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
uc = RetryJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id)
|
||||
|
||||
assert result.status == JobStatus.PENDING
|
||||
assert result.retry_count == 1
|
||||
assert result.progress == 0.0
|
||||
assert result.error_message == ""
|
||||
assert result.started_at is None
|
||||
assert result.completed_at is None
|
||||
assert result.celery_task_id == ""
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_retry_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = RetryJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute("nope")
|
||||
|
||||
def test_retry_exceeds_max_retries(self, mock_repo):
|
||||
"""超过最大重试次数不可重试"""
|
||||
job = make_job(status=JobStatus.FAILED, max_retries=3)
|
||||
job.retry_count = 3
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
uc = RetryJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
uc.execute(job.id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CancelJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCancelJobUseCase:
|
||||
"""CancelJobUseCase 取消任务"""
|
||||
|
||||
def test_cancel_pending(self, mock_repo):
|
||||
"""取消 pending 任务"""
|
||||
job = make_job(status=JobStatus.PENDING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
uc = CancelJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id)
|
||||
|
||||
assert result.status == JobStatus.CANCELLED
|
||||
assert result.current_stage == "已取消"
|
||||
mock_repo.update.assert_called_once()
|
||||
|
||||
def test_cancel_running(self, mock_repo):
|
||||
"""取消 running 任务"""
|
||||
job = make_job(status=JobStatus.RUNNING)
|
||||
mock_repo.get.return_value = job
|
||||
mock_repo.update.side_effect = lambda j: j
|
||||
|
||||
uc = CancelJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id)
|
||||
|
||||
assert result.status == JobStatus.CANCELLED
|
||||
|
||||
def test_cancel_job_not_found(self, mock_repo):
|
||||
"""任务不存在"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = CancelJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="任务不存在"):
|
||||
uc.execute("nope")
|
||||
|
||||
def test_cancel_already_success(self, mock_repo):
|
||||
"""已成功的任务不能取消"""
|
||||
job = make_job(status=JobStatus.SUCCESS)
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
uc = CancelJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="终态"):
|
||||
uc.execute(job.id)
|
||||
|
||||
def test_cancel_already_failed(self, mock_repo):
|
||||
"""已失败的任务不能取消(走重试)"""
|
||||
job = make_job(status=JobStatus.FAILED)
|
||||
job.error_message = "err"
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
uc = CancelJobUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="终态"):
|
||||
uc.execute(job.id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GetJobUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGetJobUseCase:
|
||||
"""GetJobUseCase 获取任务"""
|
||||
|
||||
def test_get_existing(self, mock_repo):
|
||||
"""获取存在的任务"""
|
||||
job = make_job()
|
||||
mock_repo.get.return_value = job
|
||||
|
||||
uc = GetJobUseCase(mock_repo)
|
||||
result = uc.execute(job.id)
|
||||
|
||||
assert result is job
|
||||
mock_repo.get.assert_called_once_with(job.id)
|
||||
|
||||
def test_get_not_found(self, mock_repo):
|
||||
"""获取不存在的任务返回 None"""
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
uc = GetJobUseCase(mock_repo)
|
||||
result = uc.execute("nope")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ListJobsUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestListJobsUseCase:
|
||||
"""ListJobsUseCase 列出任务"""
|
||||
|
||||
def test_list_by_project(self, mock_repo):
|
||||
"""按项目列出"""
|
||||
jobs = [make_job(), make_job()]
|
||||
mock_repo.list_by_project.return_value = jobs
|
||||
|
||||
uc = ListJobsUseCase(mock_repo)
|
||||
result = uc.execute(project_id="proj-1")
|
||||
|
||||
assert len(result) == 2
|
||||
mock_repo.list_by_project.assert_called_once()
|
||||
|
||||
def test_list_by_project_with_filters(self, mock_repo):
|
||||
"""按项目 + 类型 + 状态过滤"""
|
||||
mock_repo.list_by_project.return_value = []
|
||||
|
||||
uc = ListJobsUseCase(mock_repo)
|
||||
uc.execute(
|
||||
project_id="proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
status=JobStatus.RUNNING,
|
||||
limit=20,
|
||||
offset=10,
|
||||
)
|
||||
|
||||
mock_repo.list_by_project.assert_called_once_with(
|
||||
"proj-1",
|
||||
job_type=JobType.VIDEO_COMPOSE,
|
||||
status=JobStatus.RUNNING,
|
||||
limit=20,
|
||||
offset=10,
|
||||
)
|
||||
|
||||
def test_list_by_user(self, mock_repo):
|
||||
"""按用户列出"""
|
||||
jobs = [make_job()]
|
||||
mock_repo.list_by_user.return_value = jobs
|
||||
|
||||
uc = ListJobsUseCase(mock_repo)
|
||||
result = uc.execute(user_id="user-1")
|
||||
|
||||
assert len(result) == 1
|
||||
mock_repo.list_by_user.assert_called_once()
|
||||
|
||||
def test_list_no_filter_raises(self, mock_repo):
|
||||
"""不指定 project_id 或 user_id 报错"""
|
||||
uc = ListJobsUseCase(mock_repo)
|
||||
with pytest.raises(ValueError, match="必须指定"):
|
||||
uc.execute()
|
||||
|
||||
def test_list_project_takes_precedence(self, mock_repo):
|
||||
"""同时传 project_id 和 user_id,优先按项目查"""
|
||||
mock_repo.list_by_project.return_value = []
|
||||
|
||||
uc = ListJobsUseCase(mock_repo)
|
||||
uc.execute(project_id="proj-1", user_id="user-1")
|
||||
|
||||
mock_repo.list_by_project.assert_called_once()
|
||||
mock_repo.list_by_user.assert_not_called()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GetJobStatisticsUseCase
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGetJobStatisticsUseCase:
|
||||
"""GetJobStatisticsUseCase 任务统计"""
|
||||
|
||||
def test_stats_counts(self, mock_repo):
|
||||
"""统计各状态数量"""
|
||||
mock_repo.count_by_project.side_effect = lambda pid, status=None: {
|
||||
None: 10, # total
|
||||
JobStatus.PENDING: 2,
|
||||
JobStatus.RUNNING: 3,
|
||||
JobStatus.SUCCESS: 4,
|
||||
JobStatus.FAILED: 1,
|
||||
}[status]
|
||||
|
||||
uc = GetJobStatisticsUseCase(mock_repo)
|
||||
stats = uc.execute("proj-1")
|
||||
|
||||
assert stats["project_id"] == "proj-1"
|
||||
assert stats["total"] == 10
|
||||
assert stats["pending"] == 2
|
||||
assert stats["running"] == 3
|
||||
assert stats["success"] == 4
|
||||
assert stats["failed"] == 1
|
||||
# 总共调用 5 次 count_by_project
|
||||
assert mock_repo.count_by_project.call_count == 5
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Command 对象
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCommandObjects:
|
||||
"""命令对象基本属性"""
|
||||
|
||||
def test_create_job_command_defaults(self):
|
||||
cmd = CreateJobCommand(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert cmd.payload == {}
|
||||
assert cmd.source_id == ""
|
||||
assert cmd.created_by_user_id == ""
|
||||
assert cmd.max_retries == 3
|
||||
|
||||
def test_update_progress_command_defaults(self):
|
||||
cmd = UpdateJobProgressCommand(job_id="j1", progress=50.0)
|
||||
assert cmd.current_stage == ""
|
||||
|
||||
def test_complete_job_command_defaults(self):
|
||||
cmd = CompleteJobCommand(job_id="j1")
|
||||
assert cmd.result == {}
|
||||
|
||||
def test_fail_job_command(self):
|
||||
cmd = FailJobCommand(job_id="j1", error_message="err")
|
||||
assert cmd.error_message == "err"
|
||||
@@ -309,6 +309,8 @@ class TestTemplateClipEffectMapping:
|
||||
asset_id: str = f"asset_{idx}"
|
||||
duration: float = 5.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
return FakeClip(config=config or {})
|
||||
@@ -396,6 +398,54 @@ class TestTemplateClipEffectMapping:
|
||||
# 模板是 cut 时,保留原有值(避免无意义覆盖)
|
||||
assert clips[0].transition_effect == "fade"
|
||||
|
||||
def test_transition_duration_mapped(self):
|
||||
"""转场时长(transition_duration)从模板 config 正确映射到 clip."""
|
||||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||||
|
||||
clips = [self._make_virtual_clip(i) for i in range(3)]
|
||||
clip_configs = [
|
||||
self._make_template_clip_config("main", transition="fade", config={"transition_duration": 0.8}),
|
||||
self._make_template_clip_config("main", transition="dissolve", config={"transition_duration": 1.2}),
|
||||
]
|
||||
|
||||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||||
|
||||
# 前两个按顺序映射,第三个复用最后一个
|
||||
assert clips[0].transition_effect == "fade"
|
||||
assert clips[0].transition_duration == 0.8
|
||||
assert clips[1].transition_effect == "dissolve"
|
||||
assert clips[1].transition_duration == 1.2
|
||||
assert clips[2].transition_effect == "dissolve"
|
||||
assert clips[2].transition_duration == 1.2
|
||||
|
||||
def test_transition_duration_ignored_for_cut(self):
|
||||
"""模板转场为 cut 时,transition_duration 不生效(保持默认0)."""
|
||||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||||
|
||||
clips = [self._make_virtual_clip(0)]
|
||||
clip_configs = [
|
||||
self._make_template_clip_config("main", transition="cut", config={"transition_duration": 0.5}),
|
||||
]
|
||||
|
||||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||||
|
||||
# cut 转场不映射,transition_duration 也不应用
|
||||
assert clips[0].transition_duration == 0.0
|
||||
|
||||
def test_transition_duration_invalid_value_skipped(self):
|
||||
"""transition_duration 为无效值时安全跳过."""
|
||||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||||
|
||||
clips = [self._make_virtual_clip(0)]
|
||||
clip_configs = [
|
||||
self._make_template_clip_config("main", transition="fade", config={"transition_duration": "abc"}),
|
||||
]
|
||||
|
||||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||||
|
||||
assert clips[0].transition_effect == "fade"
|
||||
assert clips[0].transition_duration == 0.0 # 无效值保持默认
|
||||
|
||||
def test_intro_outro_extracted(self):
|
||||
"""intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置."""
|
||||
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
|
||||
|
||||
Executable
+413
@@ -0,0 +1,413 @@
|
||||
"""SmartAssetSelector 智能素材选择服务单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.services.smart_asset_selector import (
|
||||
SmartAssetSelector,
|
||||
_MEDIUM_BUCKET_MAX,
|
||||
_SHORT_BUCKET_MAX,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockAsset:
|
||||
"""模拟 Asset 实体."""
|
||||
|
||||
id: str
|
||||
quality_score: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
duration: float | None = None
|
||||
file_size: int = 0
|
||||
mime_type: str = "video/mp4"
|
||||
status: str = "ready"
|
||||
|
||||
@property
|
||||
def status_value(self) -> str:
|
||||
return self.status
|
||||
|
||||
|
||||
class TestSmartAssetSelectorScoring(unittest.TestCase):
|
||||
"""评分维度测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.selector = SmartAssetSelector()
|
||||
|
||||
def test_quality_score_normalization(self):
|
||||
"""质量分正确归一化到 0-1."""
|
||||
asset_high = MockAsset(id="1", quality_score=90.0)
|
||||
asset_low = MockAsset(id="2", quality_score=30.0)
|
||||
asset_none = MockAsset(id="3", quality_score=None)
|
||||
|
||||
detail_high = self.selector._score_asset(asset_high)
|
||||
detail_low = self.selector._score_asset(asset_low)
|
||||
detail_none = self.selector._score_asset(asset_none)
|
||||
|
||||
# 90分 → 0.9 × 0.5权重 = 0.45 基础贡献
|
||||
self.assertAlmostEqual(detail_high.quality_score, 0.9, delta=0.01)
|
||||
# 30分 → 0.3 × 0.5权重 = 0.15 基础贡献
|
||||
self.assertAlmostEqual(detail_low.quality_score, 0.3, delta=0.01)
|
||||
# 无质量分给默认 0.5
|
||||
self.assertAlmostEqual(detail_none.quality_score, 0.5, delta=0.01)
|
||||
|
||||
def test_resolution_score_1080p_full(self):
|
||||
"""1080p 分辨率得满分."""
|
||||
asset = MockAsset(id="1", width=1920, height=1080)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.resolution_score, 1.0, delta=0.01)
|
||||
|
||||
def test_resolution_score_4k_full(self):
|
||||
"""4K 也得满分(高于目标分辨率不扣分)."""
|
||||
asset = MockAsset(id="1", width=3840, height=2160)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.resolution_score, 1.0, delta=0.01)
|
||||
|
||||
def test_resolution_score_720p_lower(self):
|
||||
"""720p 低于 1080p,得分低于 1."""
|
||||
asset = MockAsset(id="1", width=1280, height=720)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertLess(detail.resolution_score, 1.0)
|
||||
self.assertGreater(detail.resolution_score, 0.3)
|
||||
|
||||
def test_resolution_score_none(self):
|
||||
"""分辨率未知给中评分."""
|
||||
asset = MockAsset(id="1", width=None, height=None)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.resolution_score, 0.5, delta=0.01)
|
||||
|
||||
def test_duration_score_optimal(self):
|
||||
"""最佳时长区间内得满分."""
|
||||
asset = MockAsset(id="1", duration=10.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.duration_score, 1.0, delta=0.01)
|
||||
|
||||
def test_duration_score_too_short(self):
|
||||
"""时长过短扣分."""
|
||||
asset = MockAsset(id="1", duration=1.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertLess(detail.duration_score, 1.0)
|
||||
|
||||
def test_duration_score_too_long(self):
|
||||
"""时长过长扣分."""
|
||||
asset = MockAsset(id="1", duration=120.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertLess(detail.duration_score, 1.0)
|
||||
|
||||
def test_duration_score_none(self):
|
||||
"""时长未知给中评分."""
|
||||
asset = MockAsset(id="1", duration=None)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.duration_score, 0.5, delta=0.01)
|
||||
|
||||
def test_total_score_weighted_sum(self):
|
||||
"""总分是各维度的加权和."""
|
||||
asset = MockAsset(
|
||||
id="1",
|
||||
quality_score=100.0, # 1.0 × 0.5 = 0.5
|
||||
width=1920, # 1.0 × 0.2 = 0.2
|
||||
height=1080,
|
||||
duration=10.0, # 1.0 × 0.2 = 0.2
|
||||
file_size=10_000_000, # ~8Mbps,10秒 → 约 1.0 × 0.1 = 0.1
|
||||
)
|
||||
detail = self.selector._score_asset(asset)
|
||||
# 理论上接近 1.0
|
||||
self.assertGreater(detail.total_score, 0.85)
|
||||
self.assertLessEqual(detail.total_score, 1.0)
|
||||
|
||||
|
||||
class TestSmartAssetSelectorSelection(unittest.TestCase):
|
||||
"""选择逻辑测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.selector = SmartAssetSelector(min_quality_score=0) # 测试时关闭质量门槛
|
||||
|
||||
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
|
||||
assets = []
|
||||
for i in range(count):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"asset_{i}",
|
||||
quality_score=base_quality - i * 5, # 质量递减
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0 + i,
|
||||
file_size=5_000_000 + i * 100_000,
|
||||
)
|
||||
)
|
||||
return assets
|
||||
|
||||
def test_select_all_when_count_zero(self):
|
||||
"""count=0 时返回全部符合条件的."""
|
||||
assets = self._make_assets(10)
|
||||
result = self.selector.select(assets, count=0)
|
||||
self.assertEqual(len(result.selected_ids), 10)
|
||||
self.assertEqual(result.total_candidates, 10)
|
||||
|
||||
def test_select_top_n(self):
|
||||
"""返回指定数量的 top N."""
|
||||
assets = self._make_assets(10)
|
||||
result = self.selector.select(assets, count=3)
|
||||
self.assertEqual(len(result.selected_ids), 3)
|
||||
# 最高分的应该是 asset_0(质量分最高)
|
||||
self.assertEqual(result.selected_ids[0], "asset_0")
|
||||
|
||||
def test_select_more_than_available(self):
|
||||
"""请求数量超过候选数量时返回全部."""
|
||||
assets = self._make_assets(5)
|
||||
result = self.selector.select(assets, count=10)
|
||||
self.assertEqual(len(result.selected_ids), 5)
|
||||
|
||||
def test_filter_non_ready(self):
|
||||
"""非 ready 状态的素材被过滤."""
|
||||
assets = [
|
||||
MockAsset(id="1", quality_score=90.0, status="ready"),
|
||||
MockAsset(id="2", quality_score=80.0, status="processing"),
|
||||
MockAsset(id="3", quality_score=70.0, status="ready"),
|
||||
]
|
||||
result = self.selector.select(assets, count=0)
|
||||
self.assertEqual(len(result.selected_ids), 2)
|
||||
self.assertIn("1", result.selected_ids)
|
||||
self.assertIn("3", result.selected_ids)
|
||||
self.assertNotIn("2", result.selected_ids)
|
||||
|
||||
def test_filter_non_video(self):
|
||||
"""非视频素材被过滤."""
|
||||
assets = [
|
||||
MockAsset(id="1", quality_score=90.0, mime_type="video/mp4"),
|
||||
MockAsset(id="2", quality_score=80.0, mime_type="image/jpeg"),
|
||||
MockAsset(id="3", quality_score=70.0, mime_type="video/quicktime"),
|
||||
]
|
||||
result = self.selector.select(assets, count=0)
|
||||
self.assertEqual(len(result.selected_ids), 2)
|
||||
|
||||
def test_min_quality_filter(self):
|
||||
"""最低质量分门槛过滤."""
|
||||
selector = SmartAssetSelector(min_quality_score=60.0)
|
||||
assets = [
|
||||
MockAsset(id="1", quality_score=90.0),
|
||||
MockAsset(id="2", quality_score=50.0), # 低于门槛
|
||||
MockAsset(id="3", quality_score=70.0),
|
||||
MockAsset(id="4", quality_score=30.0), # 低于门槛
|
||||
]
|
||||
result = selector.select(assets, count=0)
|
||||
self.assertEqual(len(result.selected_ids), 2)
|
||||
self.assertEqual(result.filtered_out, 2)
|
||||
self.assertIn("1", result.selected_ids)
|
||||
self.assertIn("3", result.selected_ids)
|
||||
|
||||
def test_empty_input(self):
|
||||
"""空输入返回空结果."""
|
||||
result = self.selector.select([], count=5)
|
||||
self.assertEqual(result.selected_ids, [])
|
||||
self.assertEqual(result.total_candidates, 0)
|
||||
self.assertEqual(result.avg_score, 0.0)
|
||||
|
||||
def test_sorted_by_score_descending(self):
|
||||
"""结果按总分降序排列."""
|
||||
assets = self._make_assets(5)
|
||||
result = self.selector.select(assets, count=0, ensure_diversity=False)
|
||||
scores = [d.total_score for d in result.details]
|
||||
# 应该是降序
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
|
||||
|
||||
class TestSmartAssetSelectorDiversity(unittest.TestCase):
|
||||
"""多样性选择测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.selector = SmartAssetSelector(min_quality_score=0)
|
||||
|
||||
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
|
||||
assets = []
|
||||
for i in range(count):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"asset_{i}",
|
||||
quality_score=base_quality - i * 5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0 + i,
|
||||
file_size=5_000_000 + i * 100_000,
|
||||
)
|
||||
)
|
||||
return assets
|
||||
|
||||
def test_diversity_all_short(self):
|
||||
"""全是短素材时不报错,正常返回."""
|
||||
assets = []
|
||||
for i in range(10):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"short_{i}",
|
||||
quality_score=80.0 + i,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=2.0 + i * 0.1, # 都 < 5s
|
||||
file_size=1_000_000,
|
||||
)
|
||||
)
|
||||
result = self.selector.select(assets, count=5, ensure_diversity=True)
|
||||
self.assertEqual(len(result.selected_ids), 5)
|
||||
|
||||
def test_diversity_mixed_buckets(self):
|
||||
"""混合时长素材时,各桶都有代表."""
|
||||
assets = []
|
||||
# 短素材(质量分高)
|
||||
for i in range(5):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"short_{i}",
|
||||
quality_score=95.0 - i,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=3.0,
|
||||
file_size=2_000_000,
|
||||
)
|
||||
)
|
||||
# 中素材(质量分中等)
|
||||
for i in range(5):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"medium_{i}",
|
||||
quality_score=85.0 - i,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
)
|
||||
# 长素材(质量分低)
|
||||
for i in range(5):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"long_{i}",
|
||||
quality_score=75.0 - i,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=60.0,
|
||||
file_size=20_000_000,
|
||||
)
|
||||
)
|
||||
|
||||
result = self.selector.select(assets, count=6, ensure_diversity=True)
|
||||
selected = result.selected_ids
|
||||
|
||||
# 6个素材,每个桶至少有1个(基础配额 max(1, 6//3)=2)
|
||||
short_count = sum(1 for sid in selected if sid.startswith("short_"))
|
||||
medium_count = sum(1 for sid in selected if sid.startswith("medium_"))
|
||||
long_count = sum(1 for sid in selected if sid.startswith("long_"))
|
||||
|
||||
# 每个桶至少1个
|
||||
self.assertGreaterEqual(short_count, 1)
|
||||
self.assertGreaterEqual(medium_count, 1)
|
||||
self.assertGreaterEqual(long_count, 1)
|
||||
self.assertEqual(len(selected), 6)
|
||||
|
||||
def test_diversity_disabled_returns_top(self):
|
||||
"""关闭多样性时,直接返回 top N(可能全是短素材)."""
|
||||
assets = []
|
||||
# 短素材(质量分最高)
|
||||
for i in range(10):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"short_{i}",
|
||||
quality_score=95.0 - i,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=3.0,
|
||||
file_size=2_000_000,
|
||||
)
|
||||
)
|
||||
# 长素材(质量分低)
|
||||
for i in range(5):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"long_{i}",
|
||||
quality_score=70.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=60.0,
|
||||
file_size=20_000_000,
|
||||
)
|
||||
)
|
||||
|
||||
result = self.selector.select(assets, count=5, ensure_diversity=False)
|
||||
selected = result.selected_ids
|
||||
# 全是短素材(因为质量分高)
|
||||
self.assertTrue(all(s.startswith("short_") for s in selected))
|
||||
|
||||
def test_avg_score_calculated(self):
|
||||
"""平均分正确计算."""
|
||||
assets = self._make_assets(3)
|
||||
result = self.selector.select(assets, count=3, ensure_diversity=False)
|
||||
expected_avg = sum(d.total_score for d in result.details) / 3
|
||||
self.assertAlmostEqual(result.avg_score, expected_avg, delta=0.001)
|
||||
|
||||
|
||||
class TestSmartAssetSelectorEdgeCases(unittest.TestCase):
|
||||
"""边界情况测试."""
|
||||
|
||||
def setUp(self):
|
||||
self.selector = SmartAssetSelector(min_quality_score=0)
|
||||
|
||||
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
|
||||
assets = []
|
||||
for i in range(count):
|
||||
assets.append(
|
||||
MockAsset(
|
||||
id=f"asset_{i}",
|
||||
quality_score=base_quality - i * 5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0 + i,
|
||||
file_size=5_000_000 + i * 100_000,
|
||||
)
|
||||
)
|
||||
return assets
|
||||
|
||||
def test_single_asset(self):
|
||||
"""单个素材正常返回."""
|
||||
assets = [MockAsset(id="1", quality_score=80.0, width=1920, height=1080, duration=10.0)]
|
||||
result = self.selector.select(assets, count=1)
|
||||
self.assertEqual(len(result.selected_ids), 1)
|
||||
self.assertEqual(result.selected_ids[0], "1")
|
||||
|
||||
def test_zero_width_height(self):
|
||||
"""宽高为0时按未知处理."""
|
||||
asset = MockAsset(id="1", width=0, height=0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.resolution_score, 0.5, delta=0.01)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长按未知处理."""
|
||||
asset = MockAsset(id="1", duration=-5.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.duration_score, 0.5, delta=0.01)
|
||||
|
||||
def test_zero_file_size_with_duration(self):
|
||||
"""文件大小为0时码率评分中等."""
|
||||
asset = MockAsset(id="1", file_size=0, duration=10.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.bitrate_score, 0.5, delta=0.01)
|
||||
|
||||
def test_bitrate_score_optimal(self):
|
||||
"""最佳码率范围得满分."""
|
||||
# 5 Mbps × 10秒 = 6.25 MB → file_size = 6,250,000 bytes
|
||||
asset = MockAsset(id="1", file_size=6_250_000, duration=10.0)
|
||||
detail = self.selector._score_asset(asset)
|
||||
self.assertAlmostEqual(detail.bitrate_score, 1.0, delta=0.01)
|
||||
|
||||
def test_details_match_selected_ids(self):
|
||||
"""details 列表和 selected_ids 顺序一致."""
|
||||
assets = self._make_assets(5)
|
||||
result = self.selector.select(assets, count=3, ensure_diversity=False)
|
||||
self.assertEqual(len(result.details), 3)
|
||||
for i, aid in enumerate(result.selected_ids):
|
||||
self.assertEqual(result.details[i].asset_id, aid)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+313
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
TTS 相关单元测试(第二十二波)
|
||||
|
||||
覆盖:
|
||||
- AudioMerger (空列表/单文件/多文件合并/格式/异常)
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.tts_job.audio_merger import (
|
||||
AudioMergeError,
|
||||
AudioMerger,
|
||||
)
|
||||
|
||||
|
||||
def _make_silence(duration: float = 0.5, sample_rate: int = 22050, fmt: str = "mp3") -> str:
|
||||
"""生成一段静音音频文件,返回路径。"""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False)
|
||||
tmp.close()
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"anullsrc=r={sample_rate}:cl=mono",
|
||||
"-t",
|
||||
str(duration),
|
||||
"-q:a",
|
||||
"9",
|
||||
tmp.name,
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return tmp.name
|
||||
|
||||
|
||||
class TestAudioMerger:
|
||||
"""AudioMerger 音频合并器"""
|
||||
|
||||
def test_empty_list_raises(self):
|
||||
"""空列表抛错"""
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError, match="没有可合并"):
|
||||
merger.merge([])
|
||||
|
||||
def test_single_file_returns_content(self):
|
||||
"""单个文件直接返回内容"""
|
||||
path = _make_silence(duration=0.3)
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([path])
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 100 # 应该有有效数据
|
||||
# 应该和文件本身一致
|
||||
with open(path, "rb") as f:
|
||||
original = f.read()
|
||||
assert result == original
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_two_files_merged(self):
|
||||
"""两个文件合并"""
|
||||
p1 = _make_silence(duration=0.3)
|
||||
p2 = _make_silence(duration=0.4)
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([p1, p2])
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 200 # 合并后应该有数据
|
||||
# 写出来用 ffprobe 验证时长
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
tmp.write(result)
|
||||
tmp.close()
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
tmp.name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
duration = float(probe.stdout.strip())
|
||||
# 0.3 + 0.4 = 0.7 秒左右,允许一定误差
|
||||
assert 0.5 < duration < 1.0
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
def test_three_files_merged(self):
|
||||
"""三个文件合并"""
|
||||
paths = [_make_silence(duration=0.2) for _ in range(3)]
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge(paths)
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 200
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
def test_wav_format(self):
|
||||
"""wav 格式合并"""
|
||||
p1 = _make_silence(duration=0.2, fmt="wav")
|
||||
p2 = _make_silence(duration=0.2, fmt="wav")
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([p1, p2], output_format="wav")
|
||||
assert isinstance(result, bytes)
|
||||
# WAV 头部以 RIFF 开头
|
||||
assert result[:4] == b"RIFF"
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
def test_nonexistent_file_raises(self):
|
||||
"""不存在的文件会抛错"""
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError):
|
||||
merger.merge(["/nonexistent/path/a.mp3", "/nonexistent/path/b.mp3"])
|
||||
|
||||
def test_cleanup_temp_dir(self):
|
||||
"""临时目录会被清理"""
|
||||
p1 = _make_silence(duration=0.2)
|
||||
p2 = _make_silence(duration=0.2)
|
||||
try:
|
||||
import tempfile as _tf
|
||||
|
||||
before = set(os.listdir(_tf.gettempdir()))
|
||||
merger = AudioMerger()
|
||||
merger.merge([p1, p2])
|
||||
after = set(os.listdir(_tf.gettempdir()))
|
||||
# 不应该残留 tts_merge_ 前缀的目录
|
||||
new_items = after - before
|
||||
tts_items = [i for i in new_items if i.startswith("tts_merge_")]
|
||||
assert len(tts_items) == 0, f"残留临时目录: {tts_items}"
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSStreamingService - 入口路由与边界
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSStreamingServiceRouting:
|
||||
"""TTSStreamingService 入口路由与边界条件"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_returns_error(self):
|
||||
"""空文本返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": ""})
|
||||
|
||||
ws.send_json.assert_called_once()
|
||||
call_args = ws.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "error"
|
||||
assert "不能为空" in call_args["message"]
|
||||
# 不应该调用 cosyvoice
|
||||
mock_cosy.submit_synthesize_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_too_long_returns_error(self):
|
||||
"""文本过长返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
_MAX_TEXT_LENGTH,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
long_text = "a" * (_MAX_TEXT_LENGTH + 1)
|
||||
await svc.synthesize_and_stream(ws, {"text": long_text})
|
||||
|
||||
ws.send_json.assert_called_once()
|
||||
call_args = ws.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "error"
|
||||
assert "过长" in call_args["message"]
|
||||
mock_cosy.submit_synthesize_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_routes_to_short_path(self):
|
||||
"""短文本走短文本路径(单段合成)"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.return_value = {
|
||||
"audio_url": "https://example.com/audio.mp3",
|
||||
"duration": 3.5,
|
||||
}
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
# mock 掉音频下载
|
||||
fake_audio = b"fake_audio_data" * 100
|
||||
with patch.object(svc, "_download_audio", return_value=fake_audio):
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好世界", "voice_id": "v1"})
|
||||
|
||||
# 应该调用了 cosy
|
||||
mock_cosy.submit_synthesize_task.assert_called_once()
|
||||
# 应该有 started 和 done 消息
|
||||
msg_types = [c[0][0]["type"] for c in ws.send_json.call_args_list]
|
||||
assert "started" in msg_types
|
||||
assert "done" in msg_types
|
||||
# 应该有音频分块发送
|
||||
assert ws.send_bytes.call_count > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_cosy_error(self):
|
||||
"""短文本合成失败返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.side_effect = CosyVoiceError("音色不存在")
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好", "voice_id": "v-bad"})
|
||||
|
||||
# 最后一条消息应该是 error
|
||||
last_msg = ws.send_json.call_args_list[-1][0][0]
|
||||
assert last_msg["type"] == "error"
|
||||
assert "音色不存在" in last_msg["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_no_audio_url(self):
|
||||
"""合成结果没有 audio_url 返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.return_value = {"duration": 1.0} # 没有 audio_url
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好"})
|
||||
|
||||
last_msg = ws.send_json.call_args_list[-1][0][0]
|
||||
assert last_msg["type"] == "error"
|
||||
assert "音频 URL" in last_msg["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_audio_chunks_returns_total(self):
|
||||
"""_stream_audio_chunks 返回正确字节数,分块正确"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
_AUDIO_CHUNK_SIZE,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
# 生成 10000 字节的假音频
|
||||
audio_data = b"x" * 10000
|
||||
total = await svc._stream_audio_chunks(ws, audio_data)
|
||||
|
||||
assert total == 10000
|
||||
# 应该分 ceil(10000/4096) = 3 块
|
||||
expected_chunks = (10000 + _AUDIO_CHUNK_SIZE - 1) // _AUDIO_CHUNK_SIZE
|
||||
assert ws.send_bytes.call_count == expected_chunks
|
||||
# 验证所有块拼接起来等于原数据
|
||||
all_bytes = b"".join(c[0][0] for c in ws.send_bytes.call_args_list)
|
||||
assert all_bytes == audio_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_json_handles_error(self):
|
||||
"""_send_json 发送失败不抛出异常"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
ws.send_json.side_effect = Exception("连接已断开")
|
||||
|
||||
# 不应该抛异常
|
||||
await svc._send_json(ws, {"type": "done"})
|
||||
ws.send_json.assert_called_once()
|
||||
Executable
+549
@@ -0,0 +1,549 @@
|
||||
"""TTS Workflow service unit tests.
|
||||
|
||||
Covers TTSWorkflowService - start_synthesis, poll_and_process_synthesis,
|
||||
process_synthesis_result, process_synthesis_failure, segment synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError
|
||||
from packages.application.tts_job.workflow import (
|
||||
TTSJobNotFoundError,
|
||||
TTSWorkflowError,
|
||||
TTSWorkflowService,
|
||||
)
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_job(**kwargs):
|
||||
defaults = dict(
|
||||
id="job-123",
|
||||
user_id="user-1",
|
||||
input_text="Hello world",
|
||||
voice_id="voice-1",
|
||||
sample_rate=22050,
|
||||
format="mp3",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return TTSJob(**defaults)
|
||||
|
||||
|
||||
class FakeTTSJobRepository:
|
||||
def __init__(self, job=None):
|
||||
self._job = job
|
||||
self.updated_jobs = []
|
||||
self.get_called = 0
|
||||
|
||||
def get(self, job_id):
|
||||
self.get_called += 1
|
||||
if self._job and self._job.id == job_id:
|
||||
return self._job
|
||||
return None
|
||||
|
||||
def update(self, job):
|
||||
self.updated_jobs.append(job)
|
||||
self._job = job
|
||||
return job
|
||||
|
||||
|
||||
class FakeCosyVoiceService:
|
||||
def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None):
|
||||
self._submit_result = submit_result or {
|
||||
"audio_url": "https://temp.example.com/audio.mp3",
|
||||
"task_id": "",
|
||||
"request_id": "req-1",
|
||||
"duration": 5.0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
self._submit_error = submit_error
|
||||
self._poll_result = poll_result
|
||||
self._poll_error = poll_error
|
||||
self.submit_calls = []
|
||||
self.poll_calls = []
|
||||
|
||||
def submit_synthesize_task(self, **kwargs):
|
||||
self.submit_calls.append(kwargs)
|
||||
if self._submit_error:
|
||||
raise self._submit_error
|
||||
return self._submit_result
|
||||
|
||||
def poll_synthesize_task(self, task_id, timeout=120.0):
|
||||
self.poll_calls.append({"task_id": task_id, "timeout": timeout})
|
||||
if self._poll_error:
|
||||
raise self._poll_error
|
||||
return self._poll_result or {
|
||||
"audio_url": "https://temp.example.com/audio.mp3",
|
||||
"duration": 5.0,
|
||||
"file_size": 1024,
|
||||
}
|
||||
|
||||
|
||||
class FakeStorageService:
|
||||
def __init__(self, upload_url="https://oss.example.com/tts-outputs/user-1/job-123.mp3", upload_error=None):
|
||||
self._upload_url = upload_url
|
||||
self._upload_error = upload_error
|
||||
self.uploads = []
|
||||
|
||||
def upload_file(self, file_obj, storage_key, content_type=None):
|
||||
self.uploads.append({"storage_key": storage_key, "content_type": content_type})
|
||||
if self._upload_error:
|
||||
raise self._upload_error
|
||||
return self._upload_url
|
||||
|
||||
|
||||
# ── start_synthesis tests ───────────────────────────────
|
||||
|
||||
|
||||
class TestStartSynthesis:
|
||||
def test_successful_sync_completion(self):
|
||||
"""start_synthesis with sync audio_url → job marked completed."""
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "https://temp.example.com/audio.mp3",
|
||||
"request_id": "req-abc",
|
||||
"task_id": "",
|
||||
"duration": 3.5,
|
||||
"file_size": 5000,
|
||||
}
|
||||
)
|
||||
storage = FakeStorageService(upload_url="https://oss.example.com/final.mp3")
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake audio data"):
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.id == "job-123"
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert result.output_audio_url == "https://oss.example.com/final.mp3"
|
||||
assert result.output_audio_key == "tts-outputs/user-1/job-123.mp3"
|
||||
assert result.duration == 3.5
|
||||
assert result.file_size == 5000
|
||||
assert result.metadata["cosyvoice_request_id"] == "req-abc"
|
||||
|
||||
# verify cosyvoice was called
|
||||
assert len(cosy.submit_calls) == 1
|
||||
assert cosy.submit_calls[0]["text"] == "Hello world"
|
||||
assert cosy.submit_calls[0]["voice_id"] == "voice-1"
|
||||
|
||||
# verify storage upload
|
||||
assert len(storage.uploads) == 1
|
||||
assert storage.uploads[0]["storage_key"] == "tts-outputs/user-1/job-123.mp3"
|
||||
|
||||
def test_job_not_found_raises(self):
|
||||
repo = FakeTTSJobRepository() # no job
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with pytest.raises(TTSJobNotFoundError):
|
||||
svc.start_synthesis("nonexistent")
|
||||
|
||||
def test_job_marked_processing_before_submit(self):
|
||||
job = make_job()
|
||||
assert job.status == TTSJobStatus.PENDING.value
|
||||
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "",
|
||||
"task_id": "task-abc",
|
||||
"request_id": "req-1",
|
||||
}
|
||||
)
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
|
||||
svc.start_synthesis("job-123")
|
||||
|
||||
# first update should mark processing
|
||||
first_update = repo.updated_jobs[0]
|
||||
assert first_update.status == TTSJobStatus.PROCESSING.value
|
||||
|
||||
def test_cosyvoice_error_marks_failed(self):
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "API rate limit" in result.error_message
|
||||
|
||||
def test_cosyvoice_auth_error_marks_failed(self):
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid key"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "Invalid key" in result.error_message
|
||||
|
||||
def test_value_error_marks_failed(self):
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(submit_error=ValueError("text is empty"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "text is empty" in result.error_message
|
||||
|
||||
def test_oss_transfer_failure_falls_back_to_temp_url(self):
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "https://temp.example.com/original.mp3",
|
||||
"request_id": "req-1",
|
||||
"task_id": "",
|
||||
"duration": 2.0,
|
||||
"file_size": 1000,
|
||||
}
|
||||
)
|
||||
storage = FakeStorageService(upload_error=RuntimeError("OSS down"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake"):
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
# falls back to temp URL
|
||||
assert result.output_audio_url == "https://temp.example.com/original.mp3"
|
||||
assert result.output_audio_key == ""
|
||||
|
||||
def test_no_audio_url_task_id_saved(self):
|
||||
"""Async path: no audio_url, save task_id to metadata."""
|
||||
job = make_job()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "",
|
||||
"task_id": "async-task-123",
|
||||
"request_id": "req-async",
|
||||
}
|
||||
)
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.PROCESSING.value
|
||||
assert result.metadata["cosyvoice_task_id"] == "async-task-123"
|
||||
assert result.metadata["cosyvoice_request_id"] == "req-async"
|
||||
|
||||
|
||||
# ── poll_and_process_synthesis tests ────────────────────
|
||||
|
||||
|
||||
class TestPollAndProcessSynthesis:
|
||||
def test_already_completed_returns_immediately(self):
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://oss.example.com/done.mp3", output_audio_key="key")
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
result = svc.poll_and_process_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
# no new submit calls
|
||||
assert len(cosy.submit_calls) == 0
|
||||
assert len(cosy.poll_calls) == 0
|
||||
|
||||
def test_job_not_found_raises(self):
|
||||
repo = FakeTTSJobRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with pytest.raises(TTSJobNotFoundError):
|
||||
svc.poll_and_process_synthesis("nonexistent")
|
||||
|
||||
def test_no_task_id_resynthesizes(self):
|
||||
"""No task_id in metadata → re-sync synthesize."""
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
job.metadata = {} # no task_id
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "https://temp.example.com/retry.mp3",
|
||||
"request_id": "req-retry",
|
||||
"task_id": "",
|
||||
"duration": 3.0,
|
||||
"file_size": 2048,
|
||||
}
|
||||
)
|
||||
storage = FakeStorageService(upload_url="https://oss.example.com/retry-final.mp3")
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
|
||||
result = svc.poll_and_process_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert result.output_audio_url == "https://oss.example.com/retry-final.mp3"
|
||||
assert len(cosy.submit_calls) == 1
|
||||
|
||||
def test_old_task_id_poll_fails_resynthesizes(self):
|
||||
"""Old task_id poll fails → fallback to re-synthesize."""
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
job.metadata = {"cosyvoice_task_id": "old-task-id"}
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
poll_error=CosyVoiceError("task not found"),
|
||||
submit_result={
|
||||
"audio_url": "https://temp.example.com/retry2.mp3",
|
||||
"request_id": "req-retry2",
|
||||
"task_id": "",
|
||||
"duration": 3.0,
|
||||
"file_size": 1000,
|
||||
},
|
||||
)
|
||||
storage = FakeStorageService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
|
||||
result = svc.poll_and_process_synthesis("job-123")
|
||||
|
||||
assert len(cosy.poll_calls) == 1
|
||||
assert cosy.poll_calls[0]["task_id"] == "old-task-id"
|
||||
assert len(cosy.submit_calls) == 1 # resynthesize
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
|
||||
|
||||
# ── process_synthesis_result tests ──────────────────────
|
||||
|
||||
|
||||
class TestProcessSynthesisResult:
|
||||
def test_success_marks_completed(self):
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService()
|
||||
storage = FakeStorageService(upload_url="https://oss.example.com/final.mp3")
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake audio"):
|
||||
result = svc.process_synthesis_result(
|
||||
"job-123",
|
||||
audio_url="https://temp.example.com/audio.mp3",
|
||||
duration=10.5,
|
||||
file_size=50000,
|
||||
)
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert result.output_audio_url == "https://oss.example.com/final.mp3"
|
||||
assert result.output_audio_key == "tts-outputs/user-1/job-123.mp3"
|
||||
assert result.duration == 10.5
|
||||
assert result.file_size == 50000
|
||||
|
||||
def test_job_not_found_raises(self):
|
||||
repo = FakeTTSJobRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with pytest.raises(TTSJobNotFoundError):
|
||||
svc.process_synthesis_result("nonexistent", audio_url="https://x.mp3")
|
||||
|
||||
def test_oss_upload_failure_uses_temp_url(self):
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
storage = FakeStorageService(upload_error=RuntimeError("network error"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService(), storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"data"):
|
||||
result = svc.process_synthesis_result("job-123", audio_url="https://temp.example.com/x.mp3")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert result.output_audio_url == "https://temp.example.com/x.mp3"
|
||||
assert result.output_audio_key == ""
|
||||
|
||||
|
||||
# ── process_synthesis_failure tests ─────────────────────
|
||||
|
||||
|
||||
class TestProcessSynthesisFailure:
|
||||
def test_marks_job_failed(self):
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
result = svc.process_synthesis_failure("job-123", "CosyVoice 429 rate limited")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "429 rate limited" in result.error_message
|
||||
|
||||
def test_job_not_found_raises(self):
|
||||
repo = FakeTTSJobRepository()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
with pytest.raises(TTSJobNotFoundError):
|
||||
svc.process_synthesis_failure("nonexistent", "error")
|
||||
|
||||
|
||||
# ── Segment synthesis tests ─────────────────────────────
|
||||
|
||||
|
||||
class TestSegmentSynthesis:
|
||||
def test_long_text_triggers_segment_synthesis(self):
|
||||
"""Text over 500 chars → segment synthesis path."""
|
||||
long_text = "你好" * 300 # 600 chars, over 500 threshold
|
||||
job = make_job(input_text=long_text)
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
|
||||
# Each segment returns audio_url (sync path)
|
||||
def mock_submit(**kwargs):
|
||||
return {
|
||||
"audio_url": f"https://seg.example.com/{kwargs.get('text', '')[:10]}.mp3",
|
||||
"task_id": "",
|
||||
"request_id": "r",
|
||||
"duration": 1.0,
|
||||
"file_size": 100,
|
||||
}
|
||||
|
||||
cosy = FakeCosyVoiceService()
|
||||
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
|
||||
storage = FakeStorageService()
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with (
|
||||
patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"),
|
||||
patch("packages.application.tts_job.workflow.safe_download_file") as mock_download,
|
||||
patch("packages.application.tts_job.workflow.AudioMerger") as mock_merger_class,
|
||||
):
|
||||
mock_merger = MagicMock()
|
||||
mock_merger.merge.return_value = b"merged audio data"
|
||||
mock_merger_class.return_value = mock_merger
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert result.file_size == len(b"merged audio data")
|
||||
# Multiple segments submitted
|
||||
assert cosy.submit_synthesize_task.call_count >= 2
|
||||
|
||||
def test_segment_failure_marks_job_failed(self):
|
||||
"""One segment fails → whole job fails."""
|
||||
long_text = "A" * 600
|
||||
job = make_job(input_text=long_text)
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def mock_submit(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
raise CosyVoiceError("segment 2 failed")
|
||||
return {
|
||||
"audio_url": "https://seg.example.com/s.mp3",
|
||||
"task_id": "",
|
||||
"request_id": "r",
|
||||
"duration": 1.0,
|
||||
"file_size": 100,
|
||||
}
|
||||
|
||||
cosy = FakeCosyVoiceService()
|
||||
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "分段" in result.error_message
|
||||
|
||||
def test_segment_task_ids_saved_for_async(self):
|
||||
"""Async segment results → task_ids saved to metadata."""
|
||||
long_text = "B" * 600
|
||||
job = make_job(input_text=long_text)
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
|
||||
def mock_submit(**kwargs):
|
||||
return {"audio_url": "", "task_id": f"task-{kwargs.get('text', '')[:5]}", "request_id": "r"}
|
||||
|
||||
cosy = FakeCosyVoiceService()
|
||||
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
|
||||
|
||||
result = svc.start_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.PROCESSING.value
|
||||
assert "segment_task_ids" in result.metadata
|
||||
assert len(result.metadata["segment_task_ids"]) >= 2
|
||||
assert result.metadata["segment_count"] >= 2
|
||||
|
||||
|
||||
# ── _resynthesize_and_complete tests ────────────────────
|
||||
|
||||
|
||||
class TestResynthesizeAndComplete:
|
||||
def test_resynthesize_success(self):
|
||||
job = make_job(input_text="Retry me")
|
||||
job.mark_processing()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"audio_url": "https://temp.example.com/retry.mp3",
|
||||
"request_id": "req-r",
|
||||
"task_id": "",
|
||||
"duration": 2.5,
|
||||
"file_size": 1500,
|
||||
}
|
||||
)
|
||||
storage = FakeStorageService(upload_url="https://oss.example.com/retry.mp3")
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
|
||||
|
||||
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
|
||||
# call via poll_and_process_synthesis which uses _resynthesize_and_complete
|
||||
result = svc.poll_and_process_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.COMPLETED.value
|
||||
assert len(cosy.submit_calls) == 1
|
||||
|
||||
def test_resynthesize_failure_marks_failed(self):
|
||||
job = make_job()
|
||||
job.mark_processing()
|
||||
repo = FakeTTSJobRepository(job=job)
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("permanent failure"))
|
||||
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
result = svc.poll_and_process_synthesis("job-123")
|
||||
|
||||
assert result.status == TTSJobStatus.FAILED.value
|
||||
assert "permanent failure" in result.error_message
|
||||
|
||||
|
||||
# ── Error classes tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorClasses:
|
||||
def test_workflow_error_inherits_from_exception(self):
|
||||
err = TTSWorkflowError("test error")
|
||||
assert isinstance(err, Exception)
|
||||
assert str(err) == "test error"
|
||||
|
||||
def test_not_found_error_inherits_from_exception(self):
|
||||
err = TTSJobNotFoundError("not found")
|
||||
assert isinstance(err, Exception)
|
||||
assert "not found" in str(err)
|
||||
|
||||
def test_storage_property_lazy_init(self):
|
||||
"""_storage property lazily initializes storage service."""
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
# With provided storage_service
|
||||
storage = FakeStorageService()
|
||||
svc = TTSWorkflowService(repository=MagicMock(), cosyvoice_service=MagicMock(), storage_service=storage)
|
||||
assert svc._storage is storage
|
||||
Executable
+449
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
验证码服务单元测试(第十七波)
|
||||
|
||||
覆盖:
|
||||
- VerificationCodeService.generate
|
||||
- VerificationCodeService.verify
|
||||
- 频控逻辑(冷却 + 每日上限)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.verification_code_service import (
|
||||
CODE_TYPE_EMAIL_BIND,
|
||||
CODE_TYPE_EMAIL_LOGIN,
|
||||
CODE_TYPE_PHONE_BIND,
|
||||
DAILY_LIMIT,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
MAX_ATTEMPTS,
|
||||
RESEND_COOLDOWN_SECONDS,
|
||||
VerificationCodeService,
|
||||
)
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
"""mock 验证码仓储"""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_repo):
|
||||
"""验证码服务实例"""
|
||||
return VerificationCodeService(repo=mock_repo)
|
||||
|
||||
|
||||
def make_code(
|
||||
recipient="test@example.com",
|
||||
code_type=CODE_TYPE_EMAIL_BIND,
|
||||
code="123456",
|
||||
ttl=300,
|
||||
used=False,
|
||||
attempts=0,
|
||||
created_at=None,
|
||||
):
|
||||
"""构造一个验证码实体"""
|
||||
now = created_at or datetime.now(timezone.utc)
|
||||
return VerificationCode(
|
||||
id="test-code-id",
|
||||
recipient=recipient,
|
||||
code=code,
|
||||
code_type=code_type,
|
||||
expires_at=now + timedelta(seconds=ttl),
|
||||
used_at=now if used else None,
|
||||
attempts=attempts,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate - 参数校验
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateParamValidation:
|
||||
"""generate 参数校验"""
|
||||
|
||||
def test_empty_recipient(self, service):
|
||||
"""空接收方"""
|
||||
code, err = service.generate("", CODE_TYPE_EMAIL_BIND)
|
||||
assert code is None
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_whitespace_recipient_stripped(self, service, mock_repo):
|
||||
"""前后空格会被 strip 掉,正常生成"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
code, err = service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
|
||||
assert err is None
|
||||
assert code is not None
|
||||
assert code.recipient == "test@example.com"
|
||||
|
||||
def test_invalid_code_type(self, service):
|
||||
"""无效验证码类型"""
|
||||
code, err = service.generate("test@example.com", "invalid_type")
|
||||
assert code is None
|
||||
assert "无效的验证码类型" in err
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate - 正常生成
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateNormal:
|
||||
"""generate 正常生成场景"""
|
||||
|
||||
def test_generate_success(self, service, mock_repo):
|
||||
"""正常生成验证码"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert err is None
|
||||
assert code is not None
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.code_type == CODE_TYPE_EMAIL_BIND
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
assert not code.is_used
|
||||
assert not code.is_expired
|
||||
mock_repo.save.assert_called_once()
|
||||
|
||||
def test_custom_code(self, service, mock_repo):
|
||||
"""自定义验证码"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="888888")
|
||||
|
||||
assert err is None
|
||||
assert code.code == "888888"
|
||||
|
||||
def test_custom_ttl(self, service, mock_repo):
|
||||
"""自定义有效期"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=60)
|
||||
|
||||
assert err is None
|
||||
# 过期时间 - 创建时间 ≈ 60 秒
|
||||
delta = (code.expires_at - code.created_at).total_seconds()
|
||||
assert delta == 60
|
||||
|
||||
def test_default_ttl_used_when_not_specified(self, service, mock_repo):
|
||||
"""未指定 ttl 时使用默认值"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert err is None
|
||||
delta = (code.expires_at - code.created_at).total_seconds()
|
||||
assert delta == DEFAULT_TTL_SECONDS
|
||||
|
||||
def test_phone_bind_type(self, service, mock_repo):
|
||||
"""手机号绑定类型也支持"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
|
||||
|
||||
assert err is None
|
||||
assert code.code_type == CODE_TYPE_PHONE_BIND
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate - 频控
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateRateLimit:
|
||||
"""generate 频控逻辑"""
|
||||
|
||||
def test_resend_cooldown_blocked(self, service, mock_repo):
|
||||
"""冷却期内发送被拒绝"""
|
||||
# 10 秒前刚发过一条
|
||||
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
|
||||
mock_repo.find_latest.return_value = recent
|
||||
mock_repo.count_today.return_value = 1
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert code is None
|
||||
assert "发送太频繁" in err
|
||||
assert "秒后再试" in err
|
||||
# 等待时间应接近 50 秒(60-10)
|
||||
# 提取数字验证范围
|
||||
import re
|
||||
|
||||
match = re.search(r"(\d+)\s*秒", err)
|
||||
assert match
|
||||
wait = int(match.group(1))
|
||||
assert 45 <= wait <= 55
|
||||
|
||||
def test_resend_after_cooldown_ok(self, service, mock_repo):
|
||||
"""超过冷却期可以重发"""
|
||||
# 2 分钟前发的,已过冷却
|
||||
old = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
|
||||
mock_repo.find_latest.return_value = old
|
||||
mock_repo.count_today.return_value = 1
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert err is None
|
||||
assert code is not None
|
||||
|
||||
def test_daily_limit_reached(self, service, mock_repo):
|
||||
"""达到每日上限"""
|
||||
# 没有最近的(过了冷却),但今日已达上限
|
||||
old = make_code(created_at=datetime.now(timezone.utc) - timedelta(hours=2))
|
||||
mock_repo.find_latest.return_value = old
|
||||
mock_repo.count_today.return_value = DAILY_LIMIT
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert code is None
|
||||
assert "今日发送次数已达上限" in err
|
||||
|
||||
def test_daily_limit_not_reached(self, service, mock_repo):
|
||||
"""未达每日上限可以发"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = DAILY_LIMIT - 1
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert err is None
|
||||
assert code is not None
|
||||
|
||||
def test_no_history_first_time_ok(self, service, mock_repo):
|
||||
"""首次发送,无历史记录"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert err is None
|
||||
assert code is not None
|
||||
mock_repo.save.assert_called_once()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# generate - 自定义频控参数
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateCustomRateLimitParams:
|
||||
"""自定义频控参数"""
|
||||
|
||||
def test_custom_cooldown(self, mock_repo):
|
||||
"""自定义冷却时间"""
|
||||
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=300, daily_limit=5)
|
||||
# 60 秒前发的,默认冷却 60 秒就够了,但这里设了 300 秒
|
||||
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=60))
|
||||
mock_repo.find_latest.return_value = recent
|
||||
mock_repo.count_today.return_value = 1
|
||||
|
||||
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert code is None
|
||||
assert "发送太频繁" in err
|
||||
|
||||
def test_custom_daily_limit(self, mock_repo):
|
||||
"""自定义每日上限"""
|
||||
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=60, daily_limit=3)
|
||||
mock_repo.find_latest.return_value = None
|
||||
mock_repo.count_today.return_value = 3
|
||||
|
||||
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
|
||||
|
||||
assert code is None
|
||||
assert "今日发送次数已达上限" in err
|
||||
|
||||
|
||||
# ============================================================
|
||||
# verify - 参数校验
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestVerifyParamValidation:
|
||||
"""verify 参数校验"""
|
||||
|
||||
def test_empty_recipient(self, service):
|
||||
"""空接收方"""
|
||||
ok, err = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert not ok
|
||||
assert "参数不完整" in err
|
||||
|
||||
def test_empty_code(self, service):
|
||||
"""空验证码"""
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
|
||||
assert not ok
|
||||
assert "参数不完整" in err
|
||||
|
||||
def test_whitespace_stripped(self, service, mock_repo):
|
||||
"""前后空格会被 strip"""
|
||||
code = make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
mock_repo.count_today.return_value = 0
|
||||
|
||||
ok, err = service.verify(" test@example.com ", CODE_TYPE_EMAIL_BIND, " 123456 ")
|
||||
|
||||
assert ok
|
||||
assert err is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# verify - 正常验证
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestVerifyNormal:
|
||||
"""verify 正常验证场景"""
|
||||
|
||||
def test_verify_success_consume(self, service, mock_repo):
|
||||
"""验证成功并消耗"""
|
||||
code = make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=True)
|
||||
|
||||
assert ok
|
||||
assert err is None
|
||||
assert code.is_used # 被标记为已使用
|
||||
# save 被调用了两次:一次 increment_attempts 后,一次 mark_used 后
|
||||
assert mock_repo.save.call_count >= 2
|
||||
|
||||
def test_verify_success_no_consume(self, service, mock_repo):
|
||||
"""验证成功但不消耗"""
|
||||
code = make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=False)
|
||||
|
||||
assert ok
|
||||
assert err is None
|
||||
assert not code.is_used # 未被标记
|
||||
|
||||
def test_verify_code_not_found(self, service, mock_repo):
|
||||
"""验证码不存在"""
|
||||
mock_repo.find_latest.return_value = None
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert not ok
|
||||
assert "不存在或已过期" in err
|
||||
|
||||
def test_verify_wrong_code(self, service, mock_repo):
|
||||
"""验证码错误"""
|
||||
code = make_code(code="123456")
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "999999")
|
||||
|
||||
assert not ok
|
||||
assert "验证码错误" in err
|
||||
# 尝试次数增加了
|
||||
assert code.attempts == 1
|
||||
|
||||
def test_verify_already_used(self, service, mock_repo):
|
||||
"""验证码已使用"""
|
||||
code = make_code(code="123456", used=True)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert not ok
|
||||
assert "已使用" in err
|
||||
|
||||
def test_verify_expired(self, service, mock_repo):
|
||||
"""验证码已过期"""
|
||||
code = make_code(code="123456", ttl=-60) # 已过期 60 秒
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert not ok
|
||||
assert "已过期" in err
|
||||
|
||||
def test_verify_attempts_exceeded(self, service, mock_repo):
|
||||
"""超过最大尝试次数"""
|
||||
code = make_code(code="123456", attempts=MAX_ATTEMPTS)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
|
||||
assert not ok
|
||||
assert "验证次数过多" in err
|
||||
# verify 里先 increment_attempts 再判断,所以这里 attempts 应该是 MAX_ATTEMPTS + 1
|
||||
assert code.attempts == MAX_ATTEMPTS + 1
|
||||
|
||||
def test_attempts_increment_on_wrong_code(self, service, mock_repo):
|
||||
"""错误验证码会增加尝试次数"""
|
||||
code = make_code(code="123456", attempts=0)
|
||||
mock_repo.find_latest.return_value = code
|
||||
|
||||
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000000")
|
||||
assert code.attempts == 1
|
||||
|
||||
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000001")
|
||||
assert code.attempts == 2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# verify - 不同 code_type 互不干扰
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestVerifyCodeTypeIsolation:
|
||||
"""不同验证码类型互不干扰"""
|
||||
|
||||
def test_email_bind_vs_email_login(self, service, mock_repo):
|
||||
"""用 email_login 类型的验证码去验证 email_bind 应该失败"""
|
||||
code = make_code(code_type=CODE_TYPE_EMAIL_LOGIN, code="123456")
|
||||
mock_repo.find_latest.return_value = None # 按 email_bind 查不到
|
||||
|
||||
# find_latest 按 code_type 查询,传 email_bind 返回 None
|
||||
def side_effect(recipient, ct):
|
||||
if ct == CODE_TYPE_EMAIL_LOGIN:
|
||||
return code
|
||||
return None
|
||||
|
||||
mock_repo.find_latest.side_effect = side_effect
|
||||
|
||||
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
|
||||
assert not ok
|
||||
assert "不存在或已过期" in err
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 常量值检查
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""常量默认值校验"""
|
||||
|
||||
def test_default_cooldown_60(self):
|
||||
assert RESEND_COOLDOWN_SECONDS == 60
|
||||
|
||||
def test_default_daily_limit_10(self):
|
||||
assert DAILY_LIMIT == 10
|
||||
|
||||
def test_default_max_attempts_5(self):
|
||||
assert MAX_ATTEMPTS == 5
|
||||
|
||||
def test_default_ttl_300(self):
|
||||
assert DEFAULT_TTL_SECONDS == 300
|
||||
|
||||
def test_valid_code_types_count(self):
|
||||
"""5 种验证码类型"""
|
||||
from packages.application.auth.verification_code_service import VALID_CODE_TYPES
|
||||
|
||||
assert len(VALID_CODE_TYPES) == 5
|
||||
Executable
+603
@@ -0,0 +1,603 @@
|
||||
"""视频分享 - 领域实体 + Use cases 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.video_share.commands import (
|
||||
CreateShareCommand,
|
||||
UpdateShareCommand,
|
||||
)
|
||||
from packages.application.video_share.use_cases import (
|
||||
AccessShareUseCase,
|
||||
CreateShareUseCase,
|
||||
GetShareByTokenUseCase,
|
||||
InvalidPasswordError,
|
||||
ListSharesByUserUseCase,
|
||||
ListSharesByVideoUseCase,
|
||||
NotFoundError,
|
||||
PasswordRequiredError,
|
||||
RecordShareDownloadUseCase,
|
||||
RevokeShareUseCase,
|
||||
ShareAccessResult,
|
||||
ShareExpiredError,
|
||||
UpdateShareUseCase,
|
||||
VideoNotFoundError,
|
||||
)
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
from packages.domain.video_share import (
|
||||
VideoShare,
|
||||
_hash_password,
|
||||
generate_share_token,
|
||||
)
|
||||
|
||||
|
||||
def _make_share(
|
||||
share_id: str = "share_001",
|
||||
video_id: str = "vid_001",
|
||||
user_id: str = "user_001",
|
||||
token: str = "abc123xyz",
|
||||
password: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
is_active: bool = True,
|
||||
) -> VideoShare:
|
||||
return VideoShare(
|
||||
id=share_id,
|
||||
video_id=video_id,
|
||||
user_id=user_id,
|
||||
share_token=token,
|
||||
password_hash=_hash_password(password) if password else None,
|
||||
expires_at=expires_at,
|
||||
view_count=0,
|
||||
download_count=0,
|
||||
is_active=is_active,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_video(video_id: str = "vid_001", user_id: str = "user_001") -> GeneratedVideo:
|
||||
return GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id="proj_001",
|
||||
generation_task_id="task_001",
|
||||
name="测试视频",
|
||||
file_url="oss://bucket/video.mp4",
|
||||
file_size=1024000,
|
||||
duration=30.5,
|
||||
width=1080,
|
||||
height=1920,
|
||||
fps=30.0,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
class TestVideoShareDomain:
|
||||
def test_generate_token_length(self) -> None:
|
||||
token = generate_share_token(12)
|
||||
assert len(token) == 12
|
||||
|
||||
def test_generate_token_url_safe(self) -> None:
|
||||
token = generate_share_token(16)
|
||||
# 只包含字母数字,没有特殊字符
|
||||
assert token.isalnum()
|
||||
|
||||
def test_hash_password_consistent(self) -> None:
|
||||
h1 = _hash_password("mypassword")
|
||||
h2 = _hash_password("mypassword")
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # sha256 hex
|
||||
|
||||
def test_hash_password_different_for_different_passwords(self) -> None:
|
||||
h1 = _hash_password("password1")
|
||||
h2 = _hash_password("password2")
|
||||
assert h1 != h2
|
||||
|
||||
def test_hash_empty_password(self) -> None:
|
||||
assert _hash_password("") == ""
|
||||
|
||||
def test_create_share_success(self) -> None:
|
||||
share = VideoShare.create(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
)
|
||||
assert share.video_id == "vid_001"
|
||||
assert share.user_id == "user_001"
|
||||
assert len(share.id) == 32
|
||||
assert len(share.share_token) == 12
|
||||
assert share.password_hash is None
|
||||
assert share.expires_at is None
|
||||
assert share.is_active is True
|
||||
assert share.view_count == 0
|
||||
assert share.download_count == 0
|
||||
|
||||
def test_create_share_with_password(self) -> None:
|
||||
share = VideoShare.create(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
password="secret123",
|
||||
)
|
||||
assert share.has_password is True
|
||||
assert share.verify_password("secret123") is True
|
||||
assert share.verify_password("wrong") is False
|
||||
|
||||
def test_create_share_with_expiry(self) -> None:
|
||||
future = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
share = VideoShare.create(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
expires_at=future,
|
||||
)
|
||||
assert share.expires_at == future
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_create_share_past_expiry_raises(self) -> None:
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
with pytest.raises(ValueError, match="past"):
|
||||
VideoShare.create(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
expires_at=past,
|
||||
)
|
||||
|
||||
def test_create_share_empty_video_id_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="video_id"):
|
||||
VideoShare.create(video_id="", user_id="user_001")
|
||||
|
||||
def test_create_share_empty_user_id_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
VideoShare.create(video_id="vid_001", user_id=" ")
|
||||
|
||||
def test_is_expired_false_when_no_expiry(self) -> None:
|
||||
share = _make_share()
|
||||
assert share.is_expired is False
|
||||
|
||||
def test_is_expired_true_when_past(self) -> None:
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
share = _make_share(expires_at=past)
|
||||
assert share.is_expired is True
|
||||
|
||||
def test_is_accessible_active_not_expired(self) -> None:
|
||||
share = _make_share()
|
||||
assert share.is_accessible is True
|
||||
|
||||
def test_is_accessible_inactive(self) -> None:
|
||||
share = _make_share(is_active=False)
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_is_accessible_expired(self) -> None:
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
share = _make_share(expires_at=past)
|
||||
assert share.is_accessible is False
|
||||
|
||||
def test_has_password_false_when_no_password(self) -> None:
|
||||
share = _make_share()
|
||||
assert share.has_password is False
|
||||
|
||||
def test_has_password_true_when_password_set(self) -> None:
|
||||
share = _make_share(password="pass123")
|
||||
assert share.has_password is True
|
||||
|
||||
def test_verify_no_password_always_true(self) -> None:
|
||||
share = _make_share() # 没有密码
|
||||
assert share.verify_password("") is True
|
||||
assert share.verify_password("anything") is True
|
||||
|
||||
def test_verify_correct_password(self) -> None:
|
||||
share = _make_share(password="mysecret")
|
||||
assert share.verify_password("mysecret") is True
|
||||
|
||||
def test_verify_wrong_password(self) -> None:
|
||||
share = _make_share(password="mysecret")
|
||||
assert share.verify_password("wrong") is False
|
||||
|
||||
def test_verify_empty_password_with_password_set(self) -> None:
|
||||
share = _make_share(password="mysecret")
|
||||
assert share.verify_password("") is False
|
||||
|
||||
def test_increment_view_count(self) -> None:
|
||||
share = _make_share()
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 1
|
||||
share.increment_view_count()
|
||||
assert share.view_count == 2
|
||||
|
||||
def test_increment_download_count(self) -> None:
|
||||
share = _make_share()
|
||||
share.increment_download_count()
|
||||
assert share.download_count == 1
|
||||
|
||||
def test_revoke_sets_inactive(self) -> None:
|
||||
share = _make_share()
|
||||
share.revoke()
|
||||
assert share.is_active is False
|
||||
assert share.is_accessible is False
|
||||
|
||||
|
||||
class TestCreateShareUseCase:
|
||||
def test_create_success(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
video_repo.get.return_value = _make_video()
|
||||
share_repo.create.side_effect = lambda s: s
|
||||
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.video_id == "vid_001"
|
||||
assert result.user_id == "user_001"
|
||||
share_repo.create.assert_called_once()
|
||||
|
||||
def test_create_with_password(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
video_repo.get.return_value = _make_video()
|
||||
share_repo.create.side_effect = lambda s: s
|
||||
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
cmd = CreateShareCommand(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
password="secret",
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.has_password is True
|
||||
|
||||
def test_create_with_expiry(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
video_repo.get.return_value = _make_video()
|
||||
share_repo.create.side_effect = lambda s: s
|
||||
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
cmd = CreateShareCommand(
|
||||
video_id="vid_001",
|
||||
user_id="user_001",
|
||||
expires_at=future,
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.expires_at == future
|
||||
|
||||
def test_video_not_found_raises(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
video_repo.get.return_value = None
|
||||
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
cmd = CreateShareCommand(video_id="nonexistent", user_id="user_001")
|
||||
|
||||
with pytest.raises(VideoNotFoundError):
|
||||
use_case.execute(cmd)
|
||||
|
||||
def test_wrong_user_cannot_share(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
video_repo.get.return_value = _make_video(user_id="other_user")
|
||||
|
||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
||||
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
|
||||
|
||||
with pytest.raises(VideoNotFoundError):
|
||||
use_case.execute(cmd)
|
||||
|
||||
|
||||
class TestGetShareByTokenUseCase:
|
||||
def test_found_active_share(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = _make_share()
|
||||
|
||||
use_case = GetShareByTokenUseCase(repo)
|
||||
result = use_case.execute("abc123xyz")
|
||||
assert result.share_token == "abc123xyz"
|
||||
|
||||
def test_not_found_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
use_case = GetShareByTokenUseCase(repo)
|
||||
with pytest.raises(NotFoundError):
|
||||
use_case.execute("nonexistent")
|
||||
|
||||
def test_inactive_share_raises_expired(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = _make_share(is_active=False)
|
||||
|
||||
use_case = GetShareByTokenUseCase(repo)
|
||||
with pytest.raises(ShareExpiredError):
|
||||
use_case.execute("token")
|
||||
|
||||
def test_expired_share_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
repo.get_by_token.return_value = _make_share(expires_at=past)
|
||||
|
||||
use_case = GetShareByTokenUseCase(repo)
|
||||
with pytest.raises(ShareExpiredError):
|
||||
use_case.execute("token")
|
||||
|
||||
|
||||
class TestAccessShareUseCase:
|
||||
def test_access_no_password(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = _make_share()
|
||||
video_repo.get.return_value = _make_video()
|
||||
share_repo.increment_view.return_value = None
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
result = use_case.execute("abc123xyz")
|
||||
|
||||
assert isinstance(result, ShareAccessResult)
|
||||
assert result.video.id == "vid_001"
|
||||
assert result.password_verified is True
|
||||
assert result.share.view_count == 1 # 浏览量+1
|
||||
share_repo.increment_view.assert_called_once()
|
||||
|
||||
def test_access_with_correct_password(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = _make_share(password="mypass")
|
||||
video_repo.get.return_value = _make_video()
|
||||
share_repo.increment_view.return_value = None
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
result = use_case.execute("token", password="mypass")
|
||||
|
||||
assert result.password_verified is True
|
||||
|
||||
def test_access_password_required_but_not_provided(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = _make_share(password="secret")
|
||||
video_repo.get.return_value = _make_video()
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
|
||||
with pytest.raises(PasswordRequiredError):
|
||||
use_case.execute("token", password=None)
|
||||
|
||||
def test_access_wrong_password(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = _make_share(password="correct")
|
||||
video_repo.get.return_value = _make_video()
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
|
||||
with pytest.raises(InvalidPasswordError):
|
||||
use_case.execute("token", password="wrong")
|
||||
|
||||
def test_access_share_not_found(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = None
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
use_case.execute("nonexistent")
|
||||
|
||||
def test_access_share_expired(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
share_repo.get_by_token.return_value = _make_share(expires_at=past)
|
||||
video_repo.get.return_value = _make_video()
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
|
||||
with pytest.raises(ShareExpiredError):
|
||||
use_case.execute("token")
|
||||
|
||||
def test_access_video_not_found(self) -> None:
|
||||
share_repo = MagicMock()
|
||||
video_repo = MagicMock()
|
||||
share_repo.get_by_token.return_value = _make_share()
|
||||
video_repo.get.return_value = None
|
||||
|
||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
||||
|
||||
with pytest.raises(VideoNotFoundError):
|
||||
use_case.execute("token")
|
||||
|
||||
|
||||
class TestListSharesByVideoUseCase:
|
||||
def test_lists_shares(self) -> None:
|
||||
repo = MagicMock()
|
||||
expected = [_make_share(), _make_share(share_id="share_002", token="tok2")]
|
||||
repo.list_by_video.return_value = expected
|
||||
|
||||
use_case = ListSharesByVideoUseCase(repo)
|
||||
result = use_case.execute("vid_001", "user_001")
|
||||
|
||||
assert len(result) == 2
|
||||
repo.list_by_video.assert_called_once_with("vid_001", "user_001")
|
||||
|
||||
|
||||
class TestListSharesByUserUseCase:
|
||||
def test_lists_with_total(self) -> None:
|
||||
repo = MagicMock()
|
||||
items = [_make_share(), _make_share(share_id="s2", token="t2")]
|
||||
repo.list_by_user.return_value = items
|
||||
repo.count_by_user.return_value = 10
|
||||
|
||||
use_case = ListSharesByUserUseCase(repo)
|
||||
result_items, total = use_case.execute("user_001", skip=0, limit=2)
|
||||
|
||||
assert len(result_items) == 2
|
||||
assert total == 10
|
||||
repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=2)
|
||||
|
||||
|
||||
class TestUpdateShareUseCase:
|
||||
def test_update_password(self) -> None:
|
||||
repo = MagicMock()
|
||||
share = _make_share()
|
||||
repo.get_by_id.return_value = share
|
||||
repo.update.side_effect = lambda s: s
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
cmd = UpdateShareCommand(
|
||||
share_id="share_001",
|
||||
user_id="user_001",
|
||||
password="newpass",
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.has_password is True
|
||||
assert result.verify_password("newpass") is True
|
||||
repo.update.assert_called_once()
|
||||
|
||||
def test_clear_password(self) -> None:
|
||||
repo = MagicMock()
|
||||
share = _make_share(password="oldpass")
|
||||
repo.get_by_id.return_value = share
|
||||
repo.update.side_effect = lambda s: s
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
cmd = UpdateShareCommand(
|
||||
share_id="share_001",
|
||||
user_id="user_001",
|
||||
password="", # 空字符串=清除密码
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.has_password is False
|
||||
assert result.password_hash is None
|
||||
|
||||
def test_password_none_does_not_change(self) -> None:
|
||||
repo = MagicMock()
|
||||
share = _make_share(password="existing")
|
||||
repo.get_by_id.return_value = share
|
||||
repo.update.side_effect = lambda s: s
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
cmd = UpdateShareCommand(
|
||||
share_id="share_001",
|
||||
user_id="user_001",
|
||||
password=None, # None=不修改
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.verify_password("existing") is True
|
||||
|
||||
def test_update_expires_at(self) -> None:
|
||||
repo = MagicMock()
|
||||
share = _make_share()
|
||||
repo.get_by_id.return_value = share
|
||||
repo.update.side_effect = lambda s: s
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=3)
|
||||
cmd = UpdateShareCommand(
|
||||
share_id="share_001",
|
||||
user_id="user_001",
|
||||
expires_at=future,
|
||||
)
|
||||
result = use_case.execute(cmd)
|
||||
|
||||
assert result.expires_at == future
|
||||
|
||||
def test_not_found_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_id.return_value = None
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
cmd = UpdateShareCommand(share_id="no", user_id="u1", password="x")
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
use_case.execute(cmd)
|
||||
|
||||
def test_past_expiry_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_id.return_value = _make_share()
|
||||
|
||||
use_case = UpdateShareUseCase(repo)
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
cmd = UpdateShareCommand(
|
||||
share_id="share_001",
|
||||
user_id="user_001",
|
||||
expires_at=past,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="past"):
|
||||
use_case.execute(cmd)
|
||||
|
||||
|
||||
class TestRevokeShareUseCase:
|
||||
def test_revoke_success(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_id.return_value = _make_share()
|
||||
repo.delete.return_value = True
|
||||
|
||||
use_case = RevokeShareUseCase(repo)
|
||||
result = use_case.execute("share_001", "user_001")
|
||||
|
||||
assert result is True
|
||||
repo.delete.assert_called_once_with("share_001", "user_001")
|
||||
|
||||
def test_revoke_not_found_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_id.return_value = None
|
||||
|
||||
use_case = RevokeShareUseCase(repo)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
use_case.execute("nonexistent", "user_001")
|
||||
|
||||
|
||||
class TestRecordShareDownloadUseCase:
|
||||
def test_record_success(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = _make_share()
|
||||
repo.increment_download.return_value = None
|
||||
|
||||
use_case = RecordShareDownloadUseCase(repo)
|
||||
use_case.execute("token")
|
||||
|
||||
repo.increment_download.assert_called_once()
|
||||
|
||||
def test_record_with_password(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = _make_share(password="pass")
|
||||
repo.increment_download.return_value = None
|
||||
|
||||
use_case = RecordShareDownloadUseCase(repo)
|
||||
use_case.execute("token", password="pass")
|
||||
|
||||
repo.increment_download.assert_called_once()
|
||||
|
||||
def test_record_wrong_password_raises(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = _make_share(password="correct")
|
||||
|
||||
use_case = RecordShareDownloadUseCase(repo)
|
||||
|
||||
with pytest.raises(InvalidPasswordError):
|
||||
use_case.execute("token", password="wrong")
|
||||
|
||||
def test_record_share_not_found(self) -> None:
|
||||
repo = MagicMock()
|
||||
repo.get_by_token.return_value = None
|
||||
|
||||
use_case = RecordShareDownloadUseCase(repo)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
use_case.execute("nonexistent")
|
||||
|
||||
def test_record_expired_share(self) -> None:
|
||||
repo = MagicMock()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
repo.get_by_token.return_value = _make_share(expires_at=past)
|
||||
|
||||
use_case = RecordShareDownloadUseCase(repo)
|
||||
|
||||
with pytest.raises(ShareExpiredError):
|
||||
use_case.execute("token")
|
||||
@@ -1,466 +1,481 @@
|
||||
"""VoiceCloneWorkflowService 单元测试。"""
|
||||
"""Voice clone workflow service unit tests.
|
||||
|
||||
Covers VoiceCloneWorkflowService - start_clone, poll_and_process_clone,
|
||||
process_clone_result, process_clone_failure, retry_clone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.cosyvoice_service import (
|
||||
CosyVoiceAuthError,
|
||||
CosyVoiceError,
|
||||
CosyVoiceService,
|
||||
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowError,
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
from packages.application.voice_clone.use_cases import (
|
||||
VoiceCloneNotFoundError,
|
||||
VoiceCloneNotRetryableError,
|
||||
)
|
||||
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
from packages.shared.url_security import UrlSecurityError
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_profile(
|
||||
*,
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
|
||||
source_audio_url: str = "https://example.com/audio.wav",
|
||||
retry_count: int = 0,
|
||||
max_retries: int = 3,
|
||||
metadata: dict | None = None,
|
||||
) -> VoiceCloneProfile:
|
||||
"""创建测试用 VoiceCloneProfile。"""
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url=source_audio_url,
|
||||
max_retries=max_retries,
|
||||
metadata=metadata,
|
||||
def make_profile(**kwargs):
|
||||
defaults = dict(
|
||||
id="profile-123",
|
||||
user_id="user-1",
|
||||
name="我的音色",
|
||||
description="",
|
||||
source_audio_url="",
|
||||
voice_model="",
|
||||
language="zh-CN",
|
||||
gender="unknown",
|
||||
max_retries=3,
|
||||
)
|
||||
profile.status = status
|
||||
profile.retry_count = retry_count
|
||||
return profile
|
||||
defaults.update(kwargs)
|
||||
return VoiceCloneProfile(**defaults)
|
||||
|
||||
|
||||
def _make_service(
|
||||
*,
|
||||
repo: MagicMock | None = None,
|
||||
cosyvoice: MagicMock | None = None,
|
||||
) -> VoiceCloneWorkflowService:
|
||||
"""创建测试用 VoiceCloneWorkflowService。"""
|
||||
mock_repo = repo or MagicMock()
|
||||
mock_cosyvoice = cosyvoice or MagicMock(spec=CosyVoiceService)
|
||||
return VoiceCloneWorkflowService(repository=mock_repo, cosyvoice_service=mock_cosyvoice)
|
||||
class FakeProfileRepository:
|
||||
def __init__(self, profile=None):
|
||||
self._profile = profile
|
||||
self.updated = []
|
||||
self.created = []
|
||||
self.get_called = 0
|
||||
|
||||
def create(self, profile):
|
||||
self.created.append(profile)
|
||||
self._profile = profile
|
||||
return profile
|
||||
|
||||
def get(self, profile_id):
|
||||
self.get_called += 1
|
||||
if self._profile and self._profile.id == profile_id:
|
||||
return self._profile
|
||||
return None
|
||||
|
||||
def update(self, profile):
|
||||
self.updated.append(profile)
|
||||
self._profile = profile
|
||||
return profile
|
||||
|
||||
def find_by_user(self, user_id):
|
||||
if self._profile and self._profile.user_id == user_id:
|
||||
return [self._profile]
|
||||
return []
|
||||
|
||||
|
||||
# ── start_clone ──────────────────────────────────────────
|
||||
class FakeCosyVoiceService:
|
||||
def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None):
|
||||
self._submit_result = submit_result or {
|
||||
"voice_id": "voice_abc123",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-abc",
|
||||
}
|
||||
self._submit_error = submit_error
|
||||
self._poll_result = poll_result or {"voice_id": "voice_abc123"}
|
||||
self._poll_error = poll_error
|
||||
self.submit_calls = []
|
||||
self.poll_calls = []
|
||||
|
||||
def submit_clone_task(self, **kwargs):
|
||||
self.submit_calls.append(kwargs)
|
||||
if self._submit_error:
|
||||
raise self._submit_error
|
||||
return self._submit_result
|
||||
|
||||
def poll_clone_task(self, voice_id, timeout=300.0):
|
||||
self.poll_calls.append({"voice_id": voice_id, "timeout": timeout})
|
||||
if self._poll_error:
|
||||
raise self._poll_error
|
||||
return self._poll_result
|
||||
|
||||
|
||||
# ── start_clone tests ───────────────────────────────────
|
||||
|
||||
|
||||
class TestStartClone:
|
||||
"""测试 start_clone 方法。"""
|
||||
|
||||
def test_start_clone_with_deploying(self) -> None:
|
||||
"""提交克隆后返回 DEPLOYING 状态,profile 保持 processing。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
# CosyVoice 返回 voice_id + DEPLOYING 状态(需轮询)
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-abc",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-123",
|
||||
}
|
||||
|
||||
# repo.create 和 repo.update 返回传入的 profile
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
def test_with_audio_url_submits_and_returns_processing(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"voice_id": "voice_new123",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-xyz",
|
||||
}
|
||||
)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
assert profile.metadata["cosyvoice_task_id"] == "voice-abc"
|
||||
assert profile.metadata["cosyvoice_request_id"] == "req-123"
|
||||
mock_cosyvoice.submit_clone_task.assert_called_once()
|
||||
assert mock_repo.create.call_count == 1
|
||||
# update 至少调用 2 次:mark_processing + 保存 voice_id
|
||||
assert mock_repo.update.call_count >= 2
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/audio.mp3",
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.mp3",
|
||||
)
|
||||
|
||||
def test_start_clone_with_ok_status(self) -> None:
|
||||
"""CosyVoice 直接返回 OK 状态,profile 变为 ready。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
assert result.user_id == "user-1"
|
||||
assert result.name == "测试音色"
|
||||
assert result.status == VoiceCloneStatus.PROCESSING.value
|
||||
assert result.metadata["cosyvoice_task_id"] == "voice_new123"
|
||||
assert result.metadata["cosyvoice_request_id"] == "req-xyz"
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-sync-123",
|
||||
"status": "OK",
|
||||
"request_id": "req-456",
|
||||
}
|
||||
# CosyVoice was called
|
||||
assert len(cosy.submit_calls) == 1
|
||||
assert cosy.submit_calls[0]["audio_url"] == "https://safe.example.com/audio.mp3"
|
||||
assert cosy.submit_calls[0]["voice_name"] == "测试音色"
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
def test_without_audio_url_returns_pending(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.READY
|
||||
assert profile.voice_id == "voice-sync-123"
|
||||
|
||||
def test_start_clone_cosyvoice_error(self) -> None:
|
||||
"""CosyVoice 提交失败,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("API 调用失败")
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "API 调用失败" in profile.error_message
|
||||
|
||||
def test_start_clone_auth_error(self) -> None:
|
||||
"""CosyVoice 认证失败,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceAuthError("认证失败")
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "认证失败" in profile.error_message
|
||||
|
||||
def test_start_clone_without_audio_url(self) -> None:
|
||||
"""没有音频 URL 时,profile 保持 pending 状态(P2-1 修复后)。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="待上传音色",
|
||||
source_audio_url="",
|
||||
)
|
||||
|
||||
# P2-1: 没有音频 URL 时不标记 processing,保持 pending
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
assert result.status == VoiceCloneStatus.PENDING.value
|
||||
# No CosyVoice call
|
||||
assert len(cosy.submit_calls) == 0
|
||||
|
||||
def test_start_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://127.0.0.1/audio.wav",
|
||||
def test_sync_completion_status_ok(self):
|
||||
"""CosyVoice returns OK status directly → mark ready."""
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"voice_id": "voice_ready",
|
||||
"status": "OK",
|
||||
"request_id": "req-ok",
|
||||
}
|
||||
)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
# 内网 IP 应该被拒绝,标记为 failed
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="秒成音色",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
)
|
||||
|
||||
def test_start_clone_ssrf_private_ip_rejected(self) -> None:
|
||||
"""SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
assert result.status == VoiceCloneStatus.READY.value
|
||||
assert result.voice_id == "voice_ready"
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
def test_url_security_failure_marks_failed(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://192.168.1.100/audio.wav",
|
||||
)
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
side_effect=UrlSecurityError("SSRF detected: internal IP"),
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="危险音色",
|
||||
source_audio_url="https://10.0.0.1/internal.mp3",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_public_url_passes(self) -> None:
|
||||
"""SSRF 防护:正常公网 URL 应该通过校验。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-ssrf-test",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-ssrf",
|
||||
}
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
# 公网 URL 应该正常通过
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
mock_cosyvoice.submit_clone_task.assert_called_once()
|
||||
|
||||
|
||||
# ── process_clone_result ─────────────────────────────────
|
||||
|
||||
|
||||
class TestProcessCloneResult:
|
||||
"""测试 process_clone_result 方法。"""
|
||||
|
||||
def test_process_clone_result_success(self) -> None:
|
||||
"""克隆成功,profile 标记为 ready。"""
|
||||
mock_repo = MagicMock()
|
||||
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
result = service.process_clone_result(profile.id, "voice-xyz")
|
||||
|
||||
assert result.status == VoiceCloneStatus.READY
|
||||
assert result.voice_id == "voice-xyz"
|
||||
|
||||
def test_process_clone_result_not_found(self) -> None:
|
||||
"""profile 不存在时抛出异常。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
service.process_clone_result("nonexistent", "voice-xyz")
|
||||
|
||||
|
||||
# ── process_clone_failure ────────────────────────────────
|
||||
|
||||
|
||||
class TestProcessCloneFailure:
|
||||
"""测试 process_clone_failure 方法。"""
|
||||
|
||||
def test_process_clone_failure(self) -> None:
|
||||
"""克隆失败,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
result = service.process_clone_failure(profile.id, "超时错误")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert result.error_message == "超时错误"
|
||||
|
||||
def test_process_clone_failure_not_found(self) -> None:
|
||||
"""profile 不存在时抛出异常。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
service.process_clone_failure("nonexistent", "错误")
|
||||
|
||||
|
||||
# ── retry_clone ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRetryClone:
|
||||
"""测试 retry_clone 方法。"""
|
||||
|
||||
def test_retry_clone_with_async_task(self) -> None:
|
||||
"""重试成功,异步模式。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-retry",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-retry",
|
||||
}
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.PROCESSING
|
||||
assert result.metadata["cosyvoice_task_id"] == "voice-retry"
|
||||
assert result.retry_count == 2 # prepare_retry 增加了一次
|
||||
|
||||
def test_retry_clone_with_ok_status(self) -> None:
|
||||
"""重试成功,直接返回 OK 状态。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-retry-sync",
|
||||
"status": "OK",
|
||||
"request_id": "req-retry",
|
||||
}
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.READY
|
||||
assert result.voice_id == "voice-retry-sync"
|
||||
|
||||
def test_retry_clone_not_found(self) -> None:
|
||||
"""profile 不存在时抛出异常。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
service.retry_clone("nonexistent", "user-123")
|
||||
|
||||
def test_retry_clone_not_retryable(self) -> None:
|
||||
"""不可重试时抛出异常。"""
|
||||
mock_repo = MagicMock()
|
||||
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
|
||||
mock_repo.get.return_value = profile
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
with pytest.raises(VoiceCloneNotRetryableError):
|
||||
service.retry_clone(profile.id, "user-123")
|
||||
|
||||
def test_retry_clone_cosyvoice_error(self) -> None:
|
||||
"""重试时 CosyVoice 失败,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("重试失败")
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert "重试失败" in result.error_message
|
||||
|
||||
def test_retry_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(
|
||||
status=VoiceCloneStatus.FAILED,
|
||||
source_audio_url="http://10.0.0.1/secret.wav",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "安全校验失败" in result.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
# No CosyVoice call
|
||||
assert len(cosy.submit_calls) == 0
|
||||
|
||||
def test_cosyvoice_error_marks_failed(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit exceeded"))
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="失败音色",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
)
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "API rate limit" in result.error_message
|
||||
|
||||
def test_cosyvoice_auth_error_marks_failed(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid API key"))
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="鉴权失败",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
)
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "Invalid API key" in result.error_message
|
||||
|
||||
def test_value_error_marks_failed(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService(submit_error=ValueError("audio_url is empty"))
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="参数错误",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
)
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "audio_url is empty" in result.error_message
|
||||
|
||||
def test_custom_language_and_model(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="English Voice",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
language="en-US",
|
||||
voice_model="cosyvoice-v3",
|
||||
)
|
||||
|
||||
assert cosy.submit_calls[0]["language"] == "en-US"
|
||||
assert result.language == "en-US"
|
||||
assert result.voice_model == "cosyvoice-v3"
|
||||
|
||||
def test_metadata_passed_through(self):
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
|
||||
):
|
||||
result = svc.start_clone(
|
||||
user_id="user-1",
|
||||
name="带元数据",
|
||||
source_audio_url="https://example.com/a.mp3",
|
||||
metadata={"source": "upload", "format": "wav"},
|
||||
)
|
||||
|
||||
assert result.metadata.get("source") == "upload"
|
||||
assert result.metadata.get("format") == "wav"
|
||||
# cosyvoice keys also present
|
||||
assert "cosyvoice_task_id" in result.metadata
|
||||
|
||||
|
||||
# ── poll_and_process_clone ───────────────────────────────
|
||||
# ── poll_and_process_clone tests ────────────────────────
|
||||
|
||||
|
||||
class TestPollAndProcessClone:
|
||||
"""测试 poll_and_process_clone 方法(P2-2 修复)。"""
|
||||
def test_poll_success_returns_ready(self):
|
||||
profile = make_profile()
|
||||
profile.mark_processing()
|
||||
profile.metadata = {"cosyvoice_task_id": "voice_pending"}
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService(poll_result={"voice_id": "voice_done"})
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
def test_poll_and_process_clone_success(self) -> None:
|
||||
"""轮询成功:调用 poll_clone_task → process_clone_result。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
result = svc.poll_and_process_clone("profile-123", timeout=60.0)
|
||||
|
||||
profile = _make_profile(
|
||||
status=VoiceCloneStatus.PROCESSING,
|
||||
metadata={"cosyvoice_task_id": "task-abc"},
|
||||
)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
assert result.status == VoiceCloneStatus.READY.value
|
||||
assert result.voice_id == "voice_done"
|
||||
assert len(cosy.poll_calls) == 1
|
||||
assert cosy.poll_calls[0]["voice_id"] == "voice_pending"
|
||||
assert cosy.poll_calls[0]["timeout"] == 60.0
|
||||
|
||||
mock_cosyvoice.poll_clone_task.return_value = {"voice_id": "voice-poll-xyz"}
|
||||
def test_profile_not_found_raises(self):
|
||||
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.poll_and_process_clone(profile.id)
|
||||
repo = FakeProfileRepository()
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
assert result.status == VoiceCloneStatus.READY
|
||||
assert result.voice_id == "voice-poll-xyz"
|
||||
mock_cosyvoice.poll_clone_task.assert_called_once_with("task-abc", timeout=300)
|
||||
|
||||
def test_poll_and_process_clone_no_task_id(self) -> None:
|
||||
"""metadata 中没有 task_id 时抛出 ValueError。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(status=VoiceCloneStatus.PROCESSING, metadata={})
|
||||
mock_repo.get.return_value = profile
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
with pytest.raises(ValueError, match="cosyvoice_task_id"):
|
||||
service.poll_and_process_clone(profile.id)
|
||||
|
||||
def test_poll_and_process_clone_not_found(self) -> None:
|
||||
"""profile 不存在时抛出 VoiceCloneNotFoundError。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = None
|
||||
|
||||
service = _make_service(repo=mock_repo)
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
service.poll_and_process_clone("nonexistent")
|
||||
svc.poll_and_process_clone("nonexistent")
|
||||
|
||||
def test_poll_and_process_clone_timeout(self) -> None:
|
||||
"""超时时透传 CosyVoiceTimeoutError(由 Celery task 捕获重试)。"""
|
||||
def test_no_task_id_raises_value_error(self):
|
||||
profile = make_profile()
|
||||
profile.mark_processing()
|
||||
profile.metadata = {} # no task_id
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with pytest.raises(ValueError, match="no cosyvoice_task_id"):
|
||||
svc.poll_and_process_clone("profile-123")
|
||||
|
||||
def test_poll_timeout_propagates(self):
|
||||
from packages.application.cosyvoice_service import CosyVoiceTimeoutError
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
profile = make_profile()
|
||||
profile.mark_processing()
|
||||
profile.metadata = {"cosyvoice_task_id": "voice_slow"}
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService(poll_error=CosyVoiceTimeoutError("timed out"))
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
profile = _make_profile(
|
||||
status=VoiceCloneStatus.PROCESSING,
|
||||
metadata={"cosyvoice_task_id": "task-abc"},
|
||||
)
|
||||
mock_repo.get.return_value = profile
|
||||
|
||||
mock_cosyvoice.poll_clone_task.side_effect = CosyVoiceTimeoutError("超时")
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
with pytest.raises(CosyVoiceTimeoutError):
|
||||
service.poll_and_process_clone(profile.id)
|
||||
svc.poll_and_process_clone("profile-123")
|
||||
|
||||
|
||||
# ── process_clone_result tests ──────────────────────────
|
||||
|
||||
|
||||
class TestProcessCloneResult:
|
||||
def test_marks_profile_ready(self):
|
||||
profile = make_profile()
|
||||
profile.mark_processing()
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
result = svc.process_clone_result("profile-123", "voice_final123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.READY.value
|
||||
assert result.voice_id == "voice_final123"
|
||||
|
||||
def test_profile_not_found_raises(self):
|
||||
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
||||
|
||||
repo = FakeProfileRepository()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
svc.process_clone_result("nonexistent", "voice_x")
|
||||
|
||||
|
||||
# ── process_clone_failure tests ─────────────────────────
|
||||
|
||||
|
||||
class TestProcessCloneFailure:
|
||||
def test_marks_profile_failed(self):
|
||||
profile = make_profile()
|
||||
profile.mark_processing()
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
result = svc.process_clone_failure("profile-123", "审核未通过")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "审核未通过" in result.error_message
|
||||
|
||||
def test_profile_not_found_raises(self):
|
||||
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
||||
|
||||
repo = FakeProfileRepository()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
|
||||
|
||||
with pytest.raises(VoiceCloneNotFoundError):
|
||||
svc.process_clone_failure("nonexistent", "error")
|
||||
|
||||
|
||||
# ── retry_clone tests ───────────────────────────────────
|
||||
|
||||
|
||||
class TestRetryClone:
|
||||
def test_retry_success_submits_again(self):
|
||||
profile = make_profile(source_audio_url="https://example.com/orig.mp3")
|
||||
profile.mark_failed("previous error")
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"voice_id": "voice_retry",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-retry",
|
||||
}
|
||||
)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/orig.mp3",
|
||||
):
|
||||
result = svc.retry_clone("profile-123", "user-1")
|
||||
|
||||
# Should be processing again
|
||||
assert result.status == VoiceCloneStatus.PROCESSING.value
|
||||
assert result.metadata["cosyvoice_task_id"] == "voice_retry"
|
||||
assert len(cosy.submit_calls) == 1
|
||||
|
||||
def test_retry_url_security_failure(self):
|
||||
profile = make_profile(source_audio_url="https://10.0.0.1/audio.mp3")
|
||||
profile.mark_failed("old error")
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService()
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety", side_effect=UrlSecurityError("internal IP")
|
||||
):
|
||||
result = svc.retry_clone("profile-123", "user-1")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "安全校验失败" in result.error_message
|
||||
assert len(cosy.submit_calls) == 0
|
||||
|
||||
def test_retry_cosyvoice_error(self):
|
||||
profile = make_profile(source_audio_url="https://example.com/a.mp3")
|
||||
profile.mark_failed("old error")
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("still failing"))
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.retry_clone("profile-123", "user-1")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED.value
|
||||
assert "still failing" in result.error_message
|
||||
|
||||
def test_retry_sync_ok(self):
|
||||
profile = make_profile(source_audio_url="https://example.com/a.mp3")
|
||||
profile.mark_failed("old error")
|
||||
repo = FakeProfileRepository(profile=profile)
|
||||
cosy = FakeCosyVoiceService(
|
||||
submit_result={
|
||||
"voice_id": "voice_instant",
|
||||
"status": "OK",
|
||||
"request_id": "req-instant",
|
||||
}
|
||||
)
|
||||
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
|
||||
|
||||
with patch(
|
||||
"packages.application.voice_clone.workflow.validate_url_safety",
|
||||
return_value="https://safe.example.com/a.mp3",
|
||||
):
|
||||
result = svc.retry_clone("profile-123", "user-1")
|
||||
|
||||
assert result.status == VoiceCloneStatus.READY.value
|
||||
assert result.voice_id == "voice_instant"
|
||||
|
||||
|
||||
# ── Error class tests ───────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorClasses:
|
||||
def test_workflow_error_inherits_exception(self):
|
||||
err = VoiceCloneWorkflowError("test error")
|
||||
assert isinstance(err, Exception)
|
||||
assert str(err) == "test error"
|
||||
|
||||
@@ -28,12 +28,14 @@ class TestWatermarkConfig(unittest.TestCase):
|
||||
|
||||
def test_from_dict_text_mode(self):
|
||||
"""文字水印模式."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "hello world",
|
||||
"position": "top_left",
|
||||
})
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
"text": "hello world",
|
||||
"position": "top_left",
|
||||
}
|
||||
)
|
||||
self.assertIsNotNone(cfg)
|
||||
self.assertEqual(cfg.mode, "text")
|
||||
self.assertEqual(cfg.text, "hello world")
|
||||
@@ -41,18 +43,22 @@ class TestWatermarkConfig(unittest.TestCase):
|
||||
|
||||
def test_from_dict_image_missing_path(self):
|
||||
"""图片水印缺路径 → None."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
})
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "image",
|
||||
}
|
||||
)
|
||||
self.assertIsNone(cfg)
|
||||
|
||||
def test_from_dict_text_missing_text(self):
|
||||
"""文字水印缺文字 → None."""
|
||||
cfg = WatermarkConfig.from_dict({
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
})
|
||||
cfg = WatermarkConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"mode": "text",
|
||||
}
|
||||
)
|
||||
self.assertIsNone(cfg)
|
||||
|
||||
def test_validate_text_valid(self):
|
||||
@@ -98,9 +104,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
|
||||
self.my = 20
|
||||
|
||||
def test_top_left(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
|
||||
self.assertEqual(x, 20)
|
||||
self.assertEqual(y, 20)
|
||||
|
||||
@@ -126,9 +130,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
|
||||
self.assertEqual(y, (1080 - 100) // 2)
|
||||
|
||||
def test_center(self):
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
|
||||
self.assertEqual(x, (1920 - 200) // 2)
|
||||
self.assertEqual(y, (1080 - 100) // 2)
|
||||
|
||||
@@ -162,9 +164,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
|
||||
|
||||
def test_default_fallback(self):
|
||||
"""非法位置默认右下角."""
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
"unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
|
||||
)
|
||||
x, y = WatermarkEngine.calc_position("unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
|
||||
self.assertEqual(x, 1920 - 200 - 20)
|
||||
self.assertEqual(y, 1080 - 100 - 20)
|
||||
|
||||
@@ -188,9 +188,7 @@ class TestWatermarkEngineFilters(unittest.TestCase):
|
||||
margin_x=10,
|
||||
margin_y=10,
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter(
|
||||
"[in]", "[out]", cfg, 1920, 1080
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter("[in]", "[out]", cfg, 1920, 1080)
|
||||
self.assertTrue(result.startswith("[in]drawtext="))
|
||||
self.assertIn("text='hello'", result)
|
||||
self.assertIn("fontsize=24", result)
|
||||
@@ -206,9 +204,7 @@ class TestWatermarkEngineFilters(unittest.TestCase):
|
||||
scroll=True,
|
||||
scroll_speed=60,
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter(
|
||||
"[in]", "[out]", cfg, 1920, 1080
|
||||
)
|
||||
result = WatermarkEngine.build_text_watermark_filter("[in]", "[out]", cfg, 1920, 1080)
|
||||
self.assertIn("mod(60*t", result)
|
||||
|
||||
|
||||
@@ -224,16 +220,18 @@ class TestIntroOutroConfig(unittest.TestCase):
|
||||
|
||||
def test_from_dict_intro_text(self):
|
||||
"""文字片头配置."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "欢迎观看",
|
||||
"subtitle": "精彩内容马上开始",
|
||||
"duration": 3.0,
|
||||
"background": "#1a1a2e",
|
||||
},
|
||||
})
|
||||
cfg = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"intro": {
|
||||
"type": "text",
|
||||
"title": "欢迎观看",
|
||||
"subtitle": "精彩内容马上开始",
|
||||
"duration": 3.0,
|
||||
"background": "#1a1a2e",
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertTrue(cfg.has_intro)
|
||||
self.assertFalse(cfg.has_outro)
|
||||
@@ -243,14 +241,16 @@ class TestIntroOutroConfig(unittest.TestCase):
|
||||
|
||||
def test_from_dict_outro_video(self):
|
||||
"""视频片尾配置."""
|
||||
cfg = IntroOutroConfig.from_dict({
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
})
|
||||
cfg = IntroOutroConfig.from_dict(
|
||||
{
|
||||
"enabled": True,
|
||||
"outro": {
|
||||
"type": "video",
|
||||
"video_path": "/tmp/outro.mp4",
|
||||
"duration": 5.0,
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertTrue(cfg.has_outro)
|
||||
self.assertEqual(cfg.outro_type, "video")
|
||||
self.assertEqual(cfg.outro_video_path, "/tmp/outro.mp4")
|
||||
@@ -314,9 +314,7 @@ class TestIntroOutroEngineConcat(unittest.TestCase):
|
||||
# 创建空文件模拟
|
||||
main_video.write_bytes(b"fake video data")
|
||||
|
||||
result = IntroOutroEngine.concat_with_intro_outro(
|
||||
main_video, None, None, output
|
||||
)
|
||||
result = IntroOutroEngine.concat_with_intro_outro(main_video, None, None, output)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(output.exists())
|
||||
self.assertEqual(main_video.read_bytes(), output.read_bytes())
|
||||
@@ -333,12 +331,91 @@ class TestIntroOutroEngineConcat(unittest.TestCase):
|
||||
# intro 路径不存在
|
||||
intro = Path(tmpdir) / "nonexistent.mp4"
|
||||
|
||||
result = IntroOutroEngine.concat_with_intro_outro(
|
||||
main_video, intro, None, output
|
||||
)
|
||||
result = IntroOutroEngine.concat_with_intro_outro(main_video, intro, None, output)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(output.exists())
|
||||
|
||||
|
||||
class TestResolveWatermarkConfig(unittest.TestCase):
|
||||
"""UnifiedRenderService._resolve_watermark_config 兼容性测试."""
|
||||
|
||||
def _resolve(self, plan_config):
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
return UnifiedRenderService._resolve_watermark_config(plan_config)
|
||||
|
||||
def test_none_or_empty_config(self):
|
||||
"""空配置 → None."""
|
||||
self.assertIsNone(self._resolve(None))
|
||||
self.assertIsNone(self._resolve({}))
|
||||
self.assertIsNone(self._resolve([])) # 非dict安全处理
|
||||
|
||||
def test_nested_format_enabled(self):
|
||||
"""嵌套格式 config.watermark 正常解析."""
|
||||
cfg = {"watermark": {"enabled": True, "mode": "text", "text": "测试水印"}}
|
||||
result = self._resolve(cfg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.mode, "text")
|
||||
self.assertEqual(result.text, "测试水印")
|
||||
|
||||
def test_nested_format_disabled(self):
|
||||
"""嵌套格式未启用 → None."""
|
||||
cfg = {"watermark": {"enabled": False, "mode": "text", "text": "测试"}}
|
||||
self.assertIsNone(self._resolve(cfg))
|
||||
|
||||
def test_flat_export_format_enabled(self):
|
||||
"""扁平格式 config.export.watermark_enabled + text 正常解析."""
|
||||
cfg = {"export": {"watermark_enabled": True, "watermark_text": "我的水印"}}
|
||||
result = self._resolve(cfg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.mode, "text")
|
||||
self.assertEqual(result.text, "我的水印")
|
||||
self.assertEqual(result.position, "bottom_right")
|
||||
|
||||
def test_flat_export_format_disabled(self):
|
||||
"""扁平格式未启用 → None."""
|
||||
cfg = {"export": {"watermark_enabled": False, "watermark_text": "测试"}}
|
||||
self.assertIsNone(self._resolve(cfg))
|
||||
|
||||
def test_flat_export_format_no_text(self):
|
||||
"""扁平格式启用但无文字 → None."""
|
||||
cfg = {"export": {"watermark_enabled": True, "watermark_text": ""}}
|
||||
self.assertIsNone(self._resolve(cfg))
|
||||
|
||||
def test_nested_takes_priority(self):
|
||||
"""嵌套格式存在时优先使用嵌套格式(忽略扁平格式)."""
|
||||
cfg = {
|
||||
"watermark": {"enabled": True, "mode": "text", "text": "嵌套水印"},
|
||||
"export": {"watermark_enabled": True, "watermark_text": "扁平水印"},
|
||||
}
|
||||
result = self._resolve(cfg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.text, "嵌套水印")
|
||||
|
||||
def test_flat_with_custom_position(self):
|
||||
"""扁平格式支持自定义位置、透明度等参数."""
|
||||
cfg = {
|
||||
"export": {
|
||||
"watermark_enabled": True,
|
||||
"watermark_text": "自定义水印",
|
||||
"watermark_position": "top_left",
|
||||
"watermark_opacity": 0.5,
|
||||
"watermark_font_size": 32,
|
||||
"watermark_font_color": "red",
|
||||
}
|
||||
}
|
||||
result = self._resolve(cfg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.position, "top_left")
|
||||
self.assertAlmostEqual(result.opacity, 0.5)
|
||||
self.assertEqual(result.font_size, 32)
|
||||
self.assertEqual(result.font_color, "red")
|
||||
|
||||
def test_no_export_key(self):
|
||||
"""没有 export 字段时不报错."""
|
||||
cfg = {"other": "value"}
|
||||
self.assertIsNone(self._resolve(cfg))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+354
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
微信 OAuth 服务单元测试(第二十波)
|
||||
|
||||
覆盖:
|
||||
- MemoryStateStore (put / verify_and_consume / 过期清理)
|
||||
- WechatOAuthService.is_configured
|
||||
- WechatOAuthService.generate_auth_url (正常模式 + mock模式)
|
||||
- WechatOAuthService.handle_callback (正常 / 缺code / state无效 / mock模式 / access_token失败 / userinfo失败 / 网络异常)
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.wechat_oauth_service import (
|
||||
STATE_TTL_SECONDS,
|
||||
MemoryStateStore,
|
||||
WechatOAuthService,
|
||||
WechatUserInfo,
|
||||
get_wechat_oauth_service,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# MemoryStateStore
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestMemoryStateStore:
|
||||
"""MemoryStateStore 内存 state 存储"""
|
||||
|
||||
def test_put_and_verify(self):
|
||||
"""放入并验证成功"""
|
||||
store = MemoryStateStore()
|
||||
store.put("state-1")
|
||||
assert store.verify_and_consume("state-1") is True
|
||||
|
||||
def test_verify_consumes_once(self):
|
||||
"""state 是一次性的,验证后即消费"""
|
||||
store = MemoryStateStore()
|
||||
store.put("state-1")
|
||||
assert store.verify_and_consume("state-1") is True
|
||||
assert store.verify_and_consume("state-1") is False
|
||||
|
||||
def test_verify_nonexistent(self):
|
||||
"""验证不存在的 state"""
|
||||
store = MemoryStateStore()
|
||||
assert store.verify_and_consume("nonexistent") is False
|
||||
|
||||
def test_expired_state_is_cleaned(self):
|
||||
"""过期的 state 会被清理"""
|
||||
store = MemoryStateStore(ttl_seconds=1) # 1秒过期
|
||||
store.put("state-1")
|
||||
time.sleep(1.1)
|
||||
assert store.verify_and_consume("state-1") is False
|
||||
|
||||
def test_put_cleans_expired(self):
|
||||
"""put 时会清理过期的"""
|
||||
store = MemoryStateStore(ttl_seconds=1)
|
||||
store.put("state-1")
|
||||
time.sleep(1.1)
|
||||
store.put("state-2")
|
||||
# state-1 应该被清理掉了
|
||||
assert len(store._states) == 1
|
||||
assert "state-2" in store._states
|
||||
|
||||
def test_default_ttl(self):
|
||||
"""默认 TTL 是 10 分钟"""
|
||||
store = MemoryStateStore()
|
||||
assert store._ttl == STATE_TTL_SECONDS
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WechatOAuthService - is_configured
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestIsConfigured:
|
||||
"""is_configured 配置检查"""
|
||||
|
||||
def test_fully_configured(self):
|
||||
"""三项都配置了"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
assert svc.is_configured() is True
|
||||
|
||||
def test_missing_app_id(self):
|
||||
"""缺 app_id"""
|
||||
svc = WechatOAuthService(app_id="", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
assert svc.is_configured() is False
|
||||
|
||||
def test_missing_app_secret(self):
|
||||
"""缺 app_secret"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="", redirect_uri="https://example.com/cb")
|
||||
assert svc.is_configured() is False
|
||||
|
||||
def test_missing_redirect_uri(self):
|
||||
"""缺 redirect_uri"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="")
|
||||
assert svc.is_configured() is False
|
||||
|
||||
def test_none_configured(self):
|
||||
"""全没配置"""
|
||||
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
assert svc.is_configured() is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WechatOAuthService - generate_auth_url
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGenerateAuthUrl:
|
||||
"""generate_auth_url 生成授权链接"""
|
||||
|
||||
def test_configured_mode(self):
|
||||
"""配置完整时生成正式微信授权链接"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
url, state = svc.generate_auth_url()
|
||||
|
||||
assert "open.weixin.qq.com" in url
|
||||
assert "appid=wx123" in url
|
||||
assert "redirect_uri=" in url
|
||||
assert "response_type=code" in url
|
||||
assert "scope=snsapi_login" in url
|
||||
assert f"state={state}" in url
|
||||
assert "#wechat_redirect" in url
|
||||
assert state # state 非空
|
||||
|
||||
def test_mock_mode(self):
|
||||
"""未配置时返回 mock URL"""
|
||||
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
url, state = svc.generate_auth_url()
|
||||
|
||||
assert "/mock/wechat/auth" in url
|
||||
assert "app_id=mock" in url
|
||||
assert f"state={state}" in url
|
||||
assert state
|
||||
|
||||
def test_custom_scope(self):
|
||||
"""自定义 scope"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
url, _ = svc.generate_auth_url(scope="snsapi_userinfo")
|
||||
assert "scope=snsapi_userinfo" in url
|
||||
|
||||
def test_state_is_unique(self):
|
||||
"""每次生成的 state 不同"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
_, state1 = svc.generate_auth_url()
|
||||
_, state2 = svc.generate_auth_url()
|
||||
assert state1 != state2
|
||||
|
||||
def test_state_stored_in_store(self):
|
||||
"""生成的 state 会存入 store,可被 callback 验证"""
|
||||
store = MemoryStateStore()
|
||||
svc = WechatOAuthService(
|
||||
app_id="wx123",
|
||||
app_secret="secret",
|
||||
redirect_uri="https://example.com/cb",
|
||||
state_store=store,
|
||||
)
|
||||
_, state = svc.generate_auth_url()
|
||||
assert store.verify_and_consume(state) is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WechatOAuthService - handle_callback
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestHandleCallback:
|
||||
"""handle_callback 处理微信回调"""
|
||||
|
||||
def test_missing_code(self):
|
||||
"""缺少授权码"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
user_info, err = svc.handle_callback("", "some-state")
|
||||
assert user_info is None
|
||||
assert "缺少授权码" in err
|
||||
|
||||
def test_invalid_state(self):
|
||||
"""state 无效或已过期"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
user_info, err = svc.handle_callback("code123", "invalid-state")
|
||||
assert user_info is None
|
||||
assert "state" in err
|
||||
|
||||
def test_empty_state(self):
|
||||
"""空 state"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
user_info, err = svc.handle_callback("code123", "")
|
||||
assert user_info is None
|
||||
assert "state" in err
|
||||
|
||||
def test_mock_mode_success(self):
|
||||
"""mock 模式下返回模拟用户信息"""
|
||||
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
# 先生成一个有效的 state
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
user_info, err = svc.handle_callback("mock_code_123456", state)
|
||||
|
||||
assert err is None
|
||||
assert user_info is not None
|
||||
assert user_info.openid.startswith("mock_")
|
||||
assert user_info.unionid.startswith("mock_union_")
|
||||
assert user_info.nickname == "微信测试用户"
|
||||
|
||||
def test_configured_mode_success(self):
|
||||
"""配置完整时正常调用微信 API"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
|
||||
# access_token 响应
|
||||
token_resp = MagicMock()
|
||||
token_resp.json.return_value = {
|
||||
"access_token": "at_123",
|
||||
"openid": "openid_abc",
|
||||
"unionid": "unionid_xyz",
|
||||
"expires_in": 7200,
|
||||
}
|
||||
# userinfo 响应
|
||||
user_resp = MagicMock()
|
||||
user_resp.json.return_value = {
|
||||
"openid": "openid_abc",
|
||||
"nickname": "测试用户",
|
||||
"headimgurl": "https://wx.qq.com/avatar.jpg",
|
||||
"sex": 1,
|
||||
}
|
||||
mock_get.side_effect = [token_resp, user_resp]
|
||||
|
||||
user_info, err = svc.handle_callback("code_abc", state)
|
||||
|
||||
assert err is None
|
||||
assert user_info is not None
|
||||
assert user_info.openid == "openid_abc"
|
||||
assert user_info.unionid == "unionid_xyz"
|
||||
assert user_info.nickname == "测试用户"
|
||||
assert user_info.avatar_url == "https://wx.qq.com/avatar.jpg"
|
||||
# 应该调用了两次 get
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_access_token_failed(self):
|
||||
"""access_token 接口返回错误"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
|
||||
err_resp = MagicMock()
|
||||
err_resp.json.return_value = {
|
||||
"errcode": 40029,
|
||||
"errmsg": "invalid code",
|
||||
}
|
||||
mock_get.return_value = err_resp
|
||||
|
||||
user_info, err = svc.handle_callback("bad_code", state)
|
||||
|
||||
assert user_info is None
|
||||
assert "微信授权失败" in err
|
||||
assert "invalid code" in err
|
||||
|
||||
def test_userinfo_failed(self):
|
||||
"""userinfo 接口返回错误"""
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
|
||||
token_resp = MagicMock()
|
||||
token_resp.json.return_value = {
|
||||
"access_token": "at_123",
|
||||
"openid": "openid_abc",
|
||||
}
|
||||
err_resp = MagicMock()
|
||||
err_resp.json.return_value = {
|
||||
"errcode": 40001,
|
||||
"errmsg": "invalid credential",
|
||||
}
|
||||
mock_get.side_effect = [token_resp, err_resp]
|
||||
|
||||
user_info, err = svc.handle_callback("code_abc", state)
|
||||
|
||||
assert user_info is None
|
||||
assert "获取用户信息失败" in err
|
||||
|
||||
def test_network_error(self):
|
||||
"""网络异常"""
|
||||
import requests
|
||||
|
||||
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
|
||||
mock_get.side_effect = requests.ConnectionError("timeout")
|
||||
|
||||
user_info, err = svc.handle_callback("code_abc", state)
|
||||
|
||||
assert user_info is None
|
||||
assert "微信服务暂不可用" in err
|
||||
|
||||
def test_state_one_time_use(self):
|
||||
"""state 一次性使用,重复使用会失败"""
|
||||
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
_, state = svc.generate_auth_url()
|
||||
|
||||
# 第一次成功
|
||||
user_info1, err1 = svc.handle_callback("code1", state)
|
||||
assert err1 is None
|
||||
assert user_info1 is not None
|
||||
|
||||
# 第二次用同一个 state 失败
|
||||
user_info2, err2 = svc.handle_callback("code2", state)
|
||||
assert user_info2 is None
|
||||
assert "state" in err2
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WechatUserInfo
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestWechatUserInfo:
|
||||
"""WechatUserInfo 数据类"""
|
||||
|
||||
def test_minimal_fields(self):
|
||||
info = WechatUserInfo(openid="abc")
|
||||
assert info.openid == "abc"
|
||||
assert info.unionid == ""
|
||||
assert info.nickname == ""
|
||||
assert info.avatar_url == ""
|
||||
|
||||
def test_full_fields(self):
|
||||
info = WechatUserInfo(
|
||||
openid="abc",
|
||||
unionid="def",
|
||||
nickname="测试",
|
||||
avatar_url="https://example.com/avatar.jpg",
|
||||
)
|
||||
assert info.openid == "abc"
|
||||
assert info.unionid == "def"
|
||||
assert info.nickname == "测试"
|
||||
assert info.avatar_url == "https://example.com/avatar.jpg"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_wechat_oauth_service
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestGetWechatOAuthService:
|
||||
"""工厂函数"""
|
||||
|
||||
def test_returns_service_instance(self):
|
||||
svc = get_wechat_oauth_service()
|
||||
assert isinstance(svc, WechatOAuthService)
|
||||
Reference in New Issue
Block a user