merge: 合并 develop 解决 AI数字人冲突(v3新契约 + develop增强 双保留)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m26s
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 7m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
前端7文件(基于 v3 契约为主干融合 develop 增强,#1828口径):
- 对口型模式A: video_url+voice_id+script_text+speed+emotion(英文枚举,禁传video_asset_id)
- title_config 按 build_title_drawtext_filter 真实字段映射(text/font_size/font_color/position默认bottom/enabled/bold/stroke/shadow), 不传 titles[]/fontSize/frame
- 智能封面 POST /ai-avatar/render/smart-cover; 语速情绪全链路透传
- 保留 develop: MediaKit中间状态/非阻塞轮询(running中间态)/标题TextArea实时预览+标题库(TitleLibraryModal)
后端4文件取 backend/ai-avatar-merge-1827(0e994072) 已解版本(双模式/TTS直生/emotion透传/中间状态)
tsc ai-avatar 零错误, prettier 全合规
This commit is contained in:
@@ -20,6 +20,7 @@ on:
|
|||||||
default: "手动触发 - CI漏触发补跑"
|
default: "手动触发 - CI漏触发补跑"
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-pipeline-${{ gitea.ref }}
|
group: ci-pipeline-${{ gitea.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -88,9 +89,22 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
|
||||||
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)]")
|
FILES=$(git diff --name-only origin/develop...HEAD 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ -z "${FILES:-}" ]; then
|
||||||
|
# fallback 到 API
|
||||||
|
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||||
|
FILES=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [ -z "${FILES:-}" ]; then
|
||||||
|
echo "⚠️ 无法获取变更文件列表,保守运行完整 CI"
|
||||||
|
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
|
||||||
|
|
||||||
接口:
|
接口:
|
||||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||||
@@ -13,13 +13,19 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_db_session, get_voice_clone_profile_repository
|
from app.dependencies import (
|
||||||
|
get_cosyvoice_service,
|
||||||
|
get_db_session,
|
||||||
|
get_voice_clone_profile_repository,
|
||||||
|
)
|
||||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
from app.services.mediakit_client import MediaKitError
|
from app.services.mediakit_client import MediaKitError
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -28,8 +34,15 @@ router = APIRouter()
|
|||||||
def _get_service(
|
def _get_service(
|
||||||
db: Session = Depends(get_db_session),
|
db: Session = Depends(get_db_session),
|
||||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||||
|
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||||
) -> LipsyncService:
|
) -> LipsyncService:
|
||||||
return LipsyncService(db, voice_clone_repo=voice_clone_repo)
|
# voice_clone_repo 用于克隆音色 profile 解析;cosyvoice_service 用于 TTS 直生
|
||||||
|
# (TTS 合成、音色解析、错误码归一化都在 LipsyncService 内部完成)
|
||||||
|
return LipsyncService(
|
||||||
|
db,
|
||||||
|
cosyvoice_service=cosyvoice_service,
|
||||||
|
voice_clone_repo=voice_clone_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||||
@@ -43,7 +56,9 @@ def create_lipsync_job(
|
|||||||
):
|
):
|
||||||
"""提交对口型任务.
|
"""提交对口型任务.
|
||||||
|
|
||||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
#1809/#1822: 前端传 {video_url, voice_id, script_text, speed?, emotion?},
|
||||||
|
后端内部解析音色、调 TTS 合成音频、转存 OSS,再提交 MediaKit;
|
||||||
|
也支持直接传 {video_url, audio_url}。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
job = svc.create_job(
|
job = svc.create_job(
|
||||||
@@ -57,6 +72,15 @@ def create_lipsync_job(
|
|||||||
enable_video_loop=body.enable_video_loop,
|
enable_video_loop=body.enable_video_loop,
|
||||||
project_id=body.project_id,
|
project_id=body.project_id,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
# 参数无效(如 voice_id 格式不对、文本过长等)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except CosyVoiceError as exc:
|
||||||
|
# TTS 合成基础设施失败(API/网络/认证)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={"code": "TTSSynthesisFailed", "message": str(exc)},
|
||||||
|
) from exc
|
||||||
except MediaKitError as exc:
|
except MediaKitError as exc:
|
||||||
# TTS 合成失败 / 音色无权访问 → 400/403;MediaKit 提交失败 → 502
|
# TTS 合成失败 / 音色无权访问 → 400/403;MediaKit 提交失败 → 502
|
||||||
status_code = 502
|
status_code = 502
|
||||||
@@ -74,6 +98,13 @@ def create_lipsync_job(
|
|||||||
"request_id": getattr(exc, "request_id", ""),
|
"request_id": getattr(exc, "request_id", ""),
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
# 兜底:任何未预期的错误返回 400 而非 500
|
||||||
|
logger.error("创建对口型任务异常: %s", exc, exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"创建对口型任务失败: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@@ -112,13 +143,22 @@ def list_lipsync_jobs(
|
|||||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||||
def get_lipsync_job(
|
def get_lipsync_job(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
background: BackgroundTasks,
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
svc: LipsyncService = Depends(_get_service),
|
svc: LipsyncService = Depends(_get_service),
|
||||||
):
|
):
|
||||||
"""获取对口型任务详情."""
|
"""获取对口型任务详情.
|
||||||
|
|
||||||
|
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
|
||||||
|
避免 MediaKit 慢响应阻塞前端轮询。
|
||||||
|
"""
|
||||||
job = svc.get_job(job_id, current_user.user.id)
|
job = svc.get_job(job_id, current_user.user.id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
|
||||||
|
if job.status not in ("completed", "failed"):
|
||||||
|
background.add_task(svc.refresh_job_status, job_id, current_user.user.id)
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""对口型 API Schema 定义 — #1796 / #1822.
|
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
|
||||||
|
|
||||||
支持两种输入模式(二选一):
|
支持两种输入模式(二选一):
|
||||||
1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion),
|
1. TTS 直生模式(推荐):传 voice_id + script_text(+ speed/emotion),
|
||||||
@@ -45,9 +45,8 @@ class LipsyncJobResponse(BaseModel):
|
|||||||
class CreateLipsyncJobRequest(BaseModel):
|
class CreateLipsyncJobRequest(BaseModel):
|
||||||
"""创建对口型任务请求.
|
"""创建对口型任务请求.
|
||||||
|
|
||||||
两种模式:
|
两种模式(二选一):
|
||||||
- TTS 直生:voice_id + script_text 必填;video_url 必填(人物视频);
|
- TTS 直生:voice_id + script_text 必填(+ 可选 speed/emotion);audio_url 留空。
|
||||||
audio_url 留空(后端合成)。
|
|
||||||
- 直接音频:video_url + audio_url 必填。
|
- 直接音频:video_url + audio_url 必填。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ class CreateLipsyncJobRequest(BaseModel):
|
|||||||
|
|
||||||
# 模式 1:TTS 直生
|
# 模式 1:TTS 直生
|
||||||
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)")
|
||||||
script_text: str = Field("", description="要合成的文案(直生模式必填)")
|
script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)")
|
||||||
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0")
|
||||||
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
|
||||||
|
|
||||||
@@ -85,6 +84,9 @@ class CreateLipsyncJobRequest(BaseModel):
|
|||||||
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
"要么同时传 voice_id + script_text(TTS 直生模式)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if has_tts and len(self.script_text) > 5000:
|
||||||
|
raise ValueError("script_text 最长 5000 字符")
|
||||||
|
|
||||||
if has_audio:
|
if has_audio:
|
||||||
au = self.audio_url.strip()
|
au = self.audio_url.strip()
|
||||||
if not au.startswith(("http://", "https://")):
|
if not au.startswith(("http://", "https://")):
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
- 创建/查询/取消对口型任务
|
- 创建/查询对口型任务
|
||||||
|
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url)
|
||||||
- 调用 MediaKit 客户端提交异步任务
|
- 调用 MediaKit 客户端提交异步任务
|
||||||
- 轮询更新任务状态
|
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
|
||||||
- 用户隔离(每个用户只能操作自己的任务)
|
- 用户隔离(每个用户只能操作自己的任务)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ class LipsyncService:
|
|||||||
self.db.add(job)
|
self.db.add(job)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
|
|
||||||
# 2. 提交到 MediaKit
|
# 3. 提交到 MediaKit
|
||||||
try:
|
try:
|
||||||
result = self.client.submit_lipsync(
|
result = self.client.submit_lipsync(
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
|
|||||||
@@ -128,9 +128,9 @@ services:
|
|||||||
- xiaoxia-net
|
- xiaoxia-net
|
||||||
|
|
||||||
# 健康检查配置
|
# 健康检查配置
|
||||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
|
test: ["CMD-SHELL", "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ docker run -d \
|
|||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--cpus 2 \
|
--cpus 2 \
|
||||||
--memory 2g \
|
--memory 2g \
|
||||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
|
||||||
--health-interval 30s \
|
--health-interval 30s \
|
||||||
--health-timeout 10s \
|
--health-timeout 10s \
|
||||||
--health-retries 3 \
|
--health-retries 3 \
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ docker run -d \
|
|||||||
-v "$GENERATED_DIR:/app/generated" \
|
-v "$GENERATED_DIR:/app/generated" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--label com.centurylinklabs.watchtower.enable=true \
|
--label com.centurylinklabs.watchtower.enable=true \
|
||||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
--health-cmd "sh -c \"grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1\"" \
|
||||||
--health-interval 30s \
|
--health-interval 30s \
|
||||||
--health-timeout 10s \
|
--health-timeout 10s \
|
||||||
--health-retries 3 \
|
--health-retries 3 \
|
||||||
|
|||||||
@@ -524,6 +524,10 @@ class CosyVoiceService:
|
|||||||
if not audio_url:
|
if not audio_url:
|
||||||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||||||
|
|
||||||
|
# DashScope 返回 http://,统一升级为 https://
|
||||||
|
if audio_url.startswith("http://"):
|
||||||
|
audio_url = audio_url.replace("http://", "https://", 1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||||||
"audio_url": audio_url,
|
"audio_url": audio_url,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ rollback() {
|
|||||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||||
-v "$GENERATED_DIR:/app/generated" \
|
-v "$GENERATED_DIR:/app/generated" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||||
--health-interval 30s \
|
--health-interval 30s \
|
||||||
--health-timeout 10s \
|
--health-timeout 10s \
|
||||||
--health-retries 3 \
|
--health-retries 3 \
|
||||||
@@ -501,7 +501,7 @@ docker run -d \
|
|||||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||||
-v "$GENERATED_DIR:/app/generated" \
|
-v "$GENERATED_DIR:/app/generated" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||||
--health-interval 30s \
|
--health-interval 30s \
|
||||||
--health-timeout 10s \
|
--health-timeout 10s \
|
||||||
--health-retries 3 \
|
--health-retries 3 \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""对口型 API 路由 + Service 单元测试 — #1796.
|
"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整.
|
||||||
|
|
||||||
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
|
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
|
||||||
"""
|
"""
|
||||||
@@ -33,6 +33,19 @@ def mock_mediakit():
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_cosyvoice():
|
||||||
|
"""Mock CosyVoice 服务."""
|
||||||
|
service = MagicMock()
|
||||||
|
service.synthesize_speech.return_value = MagicMock(
|
||||||
|
audio_url="https://oss.example.com/tts-output.mp3",
|
||||||
|
duration=15.0,
|
||||||
|
file_size=12345,
|
||||||
|
request_id="tts-req-789",
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_job(
|
def _make_mock_job(
|
||||||
job_id="job-1",
|
job_id="job-1",
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
@@ -48,7 +61,7 @@ def _make_mock_job(
|
|||||||
m.user_id = user_id
|
m.user_id = user_id
|
||||||
m.project_id = ""
|
m.project_id = ""
|
||||||
m.video_url = "https://example.com/video.mp4"
|
m.video_url = "https://example.com/video.mp4"
|
||||||
m.audio_url = "https://example.com/audio.mp3"
|
m.audio_url = "https://oss.example.com/tts-output.mp3"
|
||||||
m.enable_video_loop = False
|
m.enable_video_loop = False
|
||||||
m.mediakit_task_id = mediakit_task_id
|
m.mediakit_task_id = mediakit_task_id
|
||||||
m.status = status
|
m.status = status
|
||||||
@@ -64,16 +77,19 @@ def _make_mock_job(
|
|||||||
|
|
||||||
|
|
||||||
class TestSchemaValidation:
|
class TestSchemaValidation:
|
||||||
"""Schema 验证测试."""
|
"""Schema 验证测试 — #1809 新参数结构."""
|
||||||
|
|
||||||
def test_valid_video_url(self):
|
def test_valid_request(self):
|
||||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
|
|
||||||
req = CreateLipsyncJobRequest(
|
req = CreateLipsyncJobRequest(
|
||||||
video_url="https://example.com/video.mp4",
|
video_url="https://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="大家好,欢迎来到直播间",
|
||||||
)
|
)
|
||||||
assert req.video_url == "https://example.com/video.mp4"
|
assert req.video_url == "https://example.com/video.mp4"
|
||||||
|
assert req.voice_id == "longxiaochun_v3"
|
||||||
|
assert req.script_text == "大家好,欢迎来到直播间"
|
||||||
|
|
||||||
def test_invalid_video_url_not_mp4(self):
|
def test_invalid_video_url_not_mp4(self):
|
||||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
@@ -81,7 +97,8 @@ class TestSchemaValidation:
|
|||||||
with pytest.raises(ValueError, match="MP4"):
|
with pytest.raises(ValueError, match="MP4"):
|
||||||
CreateLipsyncJobRequest(
|
CreateLipsyncJobRequest(
|
||||||
video_url="https://example.com/video.mov",
|
video_url="https://example.com/video.mov",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_invalid_video_url_empty(self):
|
def test_invalid_video_url_empty(self):
|
||||||
@@ -90,7 +107,8 @@ class TestSchemaValidation:
|
|||||||
with pytest.raises(ValueError, match="不能为空"):
|
with pytest.raises(ValueError, match="不能为空"):
|
||||||
CreateLipsyncJobRequest(
|
CreateLipsyncJobRequest(
|
||||||
video_url=" ",
|
video_url=" ",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_invalid_video_url_not_http(self):
|
def test_invalid_video_url_not_http(self):
|
||||||
@@ -99,26 +117,38 @@ class TestSchemaValidation:
|
|||||||
with pytest.raises(ValueError, match="HTTP"):
|
with pytest.raises(ValueError, match="HTTP"):
|
||||||
CreateLipsyncJobRequest(
|
CreateLipsyncJobRequest(
|
||||||
video_url="ftp://example.com/video.mp4",
|
video_url="ftp://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_valid_audio_formats(self):
|
def test_empty_voice_id_rejected(self):
|
||||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
|
|
||||||
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
|
with pytest.raises(ValueError, match="voice_id"):
|
||||||
req = CreateLipsyncJobRequest(
|
|
||||||
video_url="https://example.com/video.mp4",
|
|
||||||
audio_url=f"https://example.com/audio{ext}",
|
|
||||||
)
|
|
||||||
assert req.audio_url.endswith(ext)
|
|
||||||
|
|
||||||
def test_invalid_audio_format(self):
|
|
||||||
from app.schemas.lipsync import CreateLipsyncJobRequest
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="格式不支持"):
|
|
||||||
CreateLipsyncJobRequest(
|
CreateLipsyncJobRequest(
|
||||||
video_url="https://example.com/video.mp4",
|
video_url="https://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.ogg",
|
voice_id=" ",
|
||||||
|
script_text="测试文本",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_script_text_rejected(self):
|
||||||
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="script_text"):
|
||||||
|
CreateLipsyncJobRequest(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_script_text_too_long(self):
|
||||||
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="5000"):
|
||||||
|
CreateLipsyncJobRequest(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="x" * 5001,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_enable_video_loop_default(self):
|
def test_enable_video_loop_default(self):
|
||||||
@@ -126,7 +156,8 @@ class TestSchemaValidation:
|
|||||||
|
|
||||||
req = CreateLipsyncJobRequest(
|
req = CreateLipsyncJobRequest(
|
||||||
video_url="https://example.com/video.mp4",
|
video_url="https://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
assert req.enable_video_loop is False
|
assert req.enable_video_loop is False
|
||||||
|
|
||||||
@@ -136,53 +167,111 @@ class TestSchemaValidation:
|
|||||||
|
|
||||||
req = CreateLipsyncJobRequest(
|
req = CreateLipsyncJobRequest(
|
||||||
video_url="https://example.com/video.mp4?token=abc",
|
video_url="https://example.com/video.mp4?token=abc",
|
||||||
audio_url="https://example.com/audio.mp3?sign=xyz",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
assert "?token=" in req.video_url
|
assert "?token=" in req.video_url
|
||||||
|
|
||||||
|
def test_no_audio_url_in_request(self):
|
||||||
|
"""#1809: 请求体不应包含 audio_url 字段."""
|
||||||
|
from app.schemas.lipsync import CreateLipsyncJobRequest
|
||||||
|
|
||||||
|
req = CreateLipsyncJobRequest(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
|
)
|
||||||
|
assert not hasattr(req, "audio_url")
|
||||||
|
fields = req.model_fields.keys()
|
||||||
|
assert "audio_url" not in fields
|
||||||
|
assert "voice_id" in fields
|
||||||
|
assert "script_text" in fields
|
||||||
|
|
||||||
|
|
||||||
class TestLipsyncServiceUnit:
|
class TestLipsyncServiceUnit:
|
||||||
"""Service 层单元测试(纯 mock,不依赖数据库)."""
|
"""Service 层单元测试(纯 mock,不依赖数据库)— #1809 更新."""
|
||||||
|
|
||||||
def test_create_job_success(self, mock_mediakit):
|
def test_create_job_success(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_db = MagicMock()
|
mock_db = MagicMock()
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
|
||||||
|
|
||||||
# 模拟 db.add + db.flush 不报错
|
|
||||||
mock_db.add = MagicMock()
|
mock_db.add = MagicMock()
|
||||||
mock_db.flush = MagicMock()
|
mock_db.flush = MagicMock()
|
||||||
mock_db.commit = MagicMock()
|
mock_db.commit = MagicMock()
|
||||||
mock_db.refresh = MagicMock()
|
mock_db.refresh = MagicMock()
|
||||||
|
|
||||||
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
job = svc.create_job(
|
job = svc.create_job(
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
video_url="https://example.com/video.mp4",
|
video_url="https://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="大家好,欢迎来到直播间",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert job.status == "submitted"
|
assert job.status == "submitted"
|
||||||
assert job.mediakit_task_id == "mk-task-123"
|
assert job.mediakit_task_id == "mk-task-123"
|
||||||
|
# TTS 应该被调用
|
||||||
|
mock_cosyvoice.synthesize_speech.assert_called_once_with(
|
||||||
|
text="大家好,欢迎来到直播间",
|
||||||
|
voice_id="longxiaochun_v3",
|
||||||
|
)
|
||||||
|
# MediaKit 应该用 TTS 生成的 audio_url
|
||||||
mock_mediakit.submit_lipsync.assert_called_once()
|
mock_mediakit.submit_lipsync.assert_called_once()
|
||||||
|
call_kwargs = mock_mediakit.submit_lipsync.call_args
|
||||||
|
assert call_kwargs.kwargs["audio_url"] == "https://oss.example.com/tts-output.mp3"
|
||||||
|
|
||||||
def test_create_job_api_failure(self, mock_mediakit):
|
def test_create_job_tts_failure(self, mock_mediakit):
|
||||||
|
"""TTS 合成失败时,应创建 failed 记录并抛出 CosyVoiceError."""
|
||||||
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
|
from packages.application.cosyvoice_service import CosyVoiceError
|
||||||
|
|
||||||
|
mock_cosyvoice = MagicMock()
|
||||||
|
mock_cosyvoice.synthesize_speech.side_effect = CosyVoiceError("TTS 服务不可用")
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.flush = MagicMock()
|
||||||
|
mock_db.commit = MagicMock()
|
||||||
|
mock_db.refresh = MagicMock()
|
||||||
|
|
||||||
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
|
with pytest.raises(CosyVoiceError, match="TTS 服务不可用"):
|
||||||
|
svc.create_job(
|
||||||
|
user_id="user-1",
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 不应提交到 MediaKit
|
||||||
|
mock_mediakit.submit_lipsync.assert_not_called()
|
||||||
|
# 应该记录了失败状态
|
||||||
|
added_job = mock_db.add.call_args[0][0]
|
||||||
|
assert added_job.status == "failed"
|
||||||
|
assert "TTS" in added_job.error_message
|
||||||
|
|
||||||
|
def test_create_job_api_failure(self, mock_mediakit, mock_cosyvoice):
|
||||||
|
"""MediaKit 提交失败."""
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
from app.services.mediakit_client import MediaKitError
|
from app.services.mediakit_client import MediaKitError
|
||||||
|
|
||||||
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
|
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
|
||||||
|
|
||||||
mock_db = MagicMock()
|
mock_db = MagicMock()
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
with pytest.raises(MediaKitError, match="API 调用失败"):
|
with pytest.raises(MediaKitError, match="API 调用失败"):
|
||||||
svc.create_job(
|
svc.create_job(
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
video_url="https://example.com/video.mp4",
|
video_url="https://example.com/video.mp4",
|
||||||
audio_url="https://example.com/audio.mp3",
|
voice_id="longxiaochun_v3",
|
||||||
|
script_text="测试文本",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_job_delegates_to_db(self, mock_mediakit):
|
def test_get_job_delegates_to_db(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_job = _make_mock_job()
|
mock_job = _make_mock_job()
|
||||||
@@ -193,13 +282,13 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.get_job("job-1", "user-1")
|
result = svc.get_job("job-1", "user-1")
|
||||||
|
|
||||||
assert result is mock_job
|
assert result is mock_job
|
||||||
mock_db.query.assert_called_once()
|
mock_db.query.assert_called_once()
|
||||||
|
|
||||||
def test_get_job_not_found(self, mock_mediakit):
|
def test_get_job_not_found(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_db = MagicMock()
|
mock_db = MagicMock()
|
||||||
@@ -209,11 +298,11 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.get_job("nonexistent", "user-1")
|
result = svc.get_job("nonexistent", "user-1")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_refresh_job_completed(self, mock_mediakit):
|
def test_refresh_job_completed(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_job = _make_mock_job(status="submitted")
|
mock_job = _make_mock_job(status="submitted")
|
||||||
@@ -224,14 +313,14 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.refresh_job_status("job-1", "user-1")
|
result = svc.refresh_job_status("job-1", "user-1")
|
||||||
|
|
||||||
assert result.status == "completed"
|
assert result.status == "completed"
|
||||||
assert result.output_video_url == "https://output.mp4"
|
assert result.output_video_url == "https://output.mp4"
|
||||||
assert result.output_duration == 30.0
|
assert result.output_duration == 30.0
|
||||||
|
|
||||||
def test_refresh_job_failed(self, mock_mediakit):
|
def test_refresh_job_failed(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_mediakit.get_task_status.return_value = {
|
mock_mediakit.get_task_status.return_value = {
|
||||||
@@ -251,13 +340,13 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.refresh_job_status("job-1", "user-1")
|
result = svc.refresh_job_status("job-1", "user-1")
|
||||||
|
|
||||||
assert result.status == "failed"
|
assert result.status == "failed"
|
||||||
assert result.error_code == "DownloadFailed"
|
assert result.error_code == "DownloadFailed"
|
||||||
|
|
||||||
def test_refresh_job_already_completed(self, mock_mediakit):
|
def test_refresh_job_already_completed(self, mock_mediakit, mock_cosyvoice):
|
||||||
"""已完成的任务不轮询."""
|
"""已完成的任务不轮询."""
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
@@ -269,14 +358,14 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.refresh_job_status("job-1", "user-1")
|
result = svc.refresh_job_status("job-1", "user-1")
|
||||||
|
|
||||||
# 不应调用 MediaKit
|
# 不应调用 MediaKit
|
||||||
mock_mediakit.get_task_status.assert_not_called()
|
mock_mediakit.get_task_status.assert_not_called()
|
||||||
assert result.status == "completed"
|
assert result.status == "completed"
|
||||||
|
|
||||||
def test_cancel_job_pending(self, mock_mediakit):
|
def test_cancel_job_pending(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_job = _make_mock_job(status="pending")
|
mock_job = _make_mock_job(status="pending")
|
||||||
@@ -287,12 +376,12 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.cancel_job("job-1", "user-1")
|
result = svc.cancel_job("job-1", "user-1")
|
||||||
|
|
||||||
assert result.status == "cancelled"
|
assert result.status == "cancelled"
|
||||||
|
|
||||||
def test_cancel_job_completed_not_allowed(self, mock_mediakit):
|
def test_cancel_job_completed_not_allowed(self, mock_mediakit, mock_cosyvoice):
|
||||||
from app.services.lipsync_service import LipsyncService
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
mock_job = _make_mock_job(status="completed")
|
mock_job = _make_mock_job(status="completed")
|
||||||
@@ -303,8 +392,85 @@ class TestLipsyncServiceUnit:
|
|||||||
mock_query.filter.return_value = mock_filter
|
mock_query.filter.return_value = mock_filter
|
||||||
mock_db.query.return_value = mock_query
|
mock_db.query.return_value = mock_query
|
||||||
|
|
||||||
svc = LipsyncService(mock_db, client=mock_mediakit)
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
result = svc.cancel_job("job-1", "user-1")
|
result = svc.cancel_job("job-1", "user-1")
|
||||||
|
|
||||||
# 已完成不可取消
|
# 已完成不可取消
|
||||||
assert result.status == "completed"
|
assert result.status == "completed"
|
||||||
|
|
||||||
|
def test_create_job_stores_tts_audio_url(self, mock_mediakit, mock_cosyvoice):
|
||||||
|
"""#1809: 验证 job 的 audio_url 来自 TTS 合成结果."""
|
||||||
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.flush = MagicMock()
|
||||||
|
mock_db.commit = MagicMock()
|
||||||
|
mock_db.refresh = MagicMock()
|
||||||
|
|
||||||
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
|
job = svc.create_job(
|
||||||
|
user_id="user-1",
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="my-clone-voice",
|
||||||
|
script_text="这是一段测试文本",
|
||||||
|
)
|
||||||
|
|
||||||
|
# job.audio_url 应该是 TTS 返回的 URL
|
||||||
|
assert job.audio_url == "https://oss.example.com/tts-output.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""#1809 补充:错误返回 400 而非 500."""
|
||||||
|
|
||||||
|
def test_voice_id_resolve_failure_returns_400(self, mock_mediakit, mock_cosyvoice):
|
||||||
|
"""voice_clone_repo 查询异常时返回 400 而非 500."""
|
||||||
|
from app.api.routes.lipsync import _resolve_voice_id
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.get.side_effect = Exception("DB connection error")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_resolve_voice_id("bad-voice-id", "user-1", mock_repo)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "voice_id" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
def test_create_job_value_error_returns_400(self, mock_mediakit):
|
||||||
|
"""ValueError(参数无效)返回 400 而非 500."""
|
||||||
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
|
mock_cosyvoice = MagicMock()
|
||||||
|
mock_cosyvoice.synthesize_speech.side_effect = ValueError("voice_id 为空")
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
|
# Service 层会 catch CosyVoiceError 但 ValueError 会穿透
|
||||||
|
# 路由层 catch ValueError → 400
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
svc.create_job(
|
||||||
|
user_id="user-1",
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="",
|
||||||
|
script_text="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_job_unexpected_exception_returns_400(self, mock_mediakit):
|
||||||
|
"""未预期的异常应被路由层捕获返回 400 而非 500."""
|
||||||
|
from app.services.lipsync_service import LipsyncService
|
||||||
|
|
||||||
|
mock_cosyvoice = MagicMock()
|
||||||
|
mock_cosyvoice.synthesize_speech.side_effect = RuntimeError("unexpected")
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
svc = LipsyncService(mock_db, client=mock_mediakit, cosyvoice_service=mock_cosyvoice)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
svc.create_job(
|
||||||
|
user_id="user-1",
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
voice_id="test-voice",
|
||||||
|
script_text="test",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user