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

前端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:
CI Bot
2026-09-09 23:02:15 +08:00
10 changed files with 296 additions and 69 deletions
+16 -2
View File
@@ -20,6 +20,7 @@ on:
default: "手动触发 - CI漏触发补跑"
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-pipeline-${{ gitea.ref }}
cancel-in-progress: true
@@ -88,9 +89,22 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 优先用 git diff 判断 PR 改动范围(比 API 稳定)
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
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)]")
if command -v git >/dev/null 2>&1 && [ -d .git ]; then
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)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
+46 -6
View File
@@ -1,4 +1,4 @@
"""对口型 API 路由 — #1796 MediaKit 对口型.
"""对口型 API 路由 — #1796 MediaKit 对口型, #1809 参数调整.
接口:
POST /api/v1/lipsync/jobs 提交对口型任务
@@ -13,13 +13,19 @@ from __future__ import annotations
import logging
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.services.lipsync_service import LipsyncService
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 packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -28,8 +34,15 @@ router = APIRouter()
def _get_service(
db: Session = Depends(get_db_session),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> 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 — 提交对口型任务 ───────────────────────────────────────────
@@ -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:
job = svc.create_job(
@@ -57,6 +72,15 @@ def create_lipsync_job(
enable_video_loop=body.enable_video_loop,
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:
# TTS 合成失败 / 音色无权访问 → 400/403MediaKit 提交失败 → 502
status_code = 502
@@ -74,6 +98,13 @@ def create_lipsync_job(
"request_id": getattr(exc, "request_id", ""),
},
) 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
@@ -112,13 +143,22 @@ def list_lipsync_jobs(
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
def get_lipsync_job(
job_id: str,
background: BackgroundTasks,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: LipsyncService = Depends(_get_service),
):
"""获取对口型任务详情."""
"""获取对口型任务详情.
非终态任务:先返回 DB 缓存,挂后台刷新(下次轮询拿到新状态),
避免 MediaKit 慢响应阻塞前端轮询。
"""
job = svc.get_job(job_id, current_user.user.id)
if job is None:
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
+7 -5
View File
@@ -1,4 +1,4 @@
"""对口型 API Schema 定义 — #1796 / #1822.
"""对口型 API Schema 定义 — #1796 / #1809 / #1822.
支持两种输入模式(二选一):
1. TTS 直生模式(推荐):传 voice_id + script_text+ speed/emotion),
@@ -45,9 +45,8 @@ class LipsyncJobResponse(BaseModel):
class CreateLipsyncJobRequest(BaseModel):
"""创建对口型任务请求.
两种模式:
- TTS 直生:voice_id + script_text 必填;video_url 必填(人物视频);
audio_url 留空(后端合成)。
两种模式(二选一)
- TTS 直生:voice_id + script_text 必填+ 可选 speed/emotion);audio_url 留空。
- 直接音频:video_url + audio_url 必填。
"""
@@ -58,7 +57,7 @@ class CreateLipsyncJobRequest(BaseModel):
# 模式 1TTS 直生
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")
emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)")
@@ -85,6 +84,9 @@ class CreateLipsyncJobRequest(BaseModel):
"要么同时传 voice_id + script_textTTS 直生模式)"
)
if has_tts and len(self.script_text) > 5000:
raise ValueError("script_text 最长 5000 字符")
if has_audio:
au = self.audio_url.strip()
if not au.startswith(("http://", "https://")):
+5 -4
View File
@@ -1,9 +1,10 @@
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
"""对口型 Service — #1796 MediaKit 对口型业务逻辑, #1809 参数调整.
职责:
- 创建/查询/取消对口型任务
- 创建/查询对口型任务
- 双输入模式:TTS 直生(voice_id + script_text,内部先合成音频转存 OSS)或直接音频(audio_url
- 调用 MediaKit 客户端提交异步任务
- 轮询更新任务状态
- 轮询更新任务状态(中间状态同步 DB,成片转存自家 OSS)
- 用户隔离(每个用户只能操作自己的任务)
"""
@@ -193,7 +194,7 @@ class LipsyncService:
self.db.add(job)
self.db.flush()
# 2. 提交到 MediaKit
# 3. 提交到 MediaKit
try:
result = self.client.submit_lipsync(
video_url=video_url,
+2 -2
View File
@@ -128,9 +128,9 @@ services:
- xiaoxia-net
# 健康检查配置
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
# 注:容器内无 pgrep/ps,扫描 /proc 所有进程的 cmdline 查找 celery 进程
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
timeout: 10s
retries: 3
+1 -1
View File
@@ -155,7 +155,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--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-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -116,7 +116,7 @@ docker run -d \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--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-timeout 10s \
--health-retries 3 \
@@ -524,6 +524,10 @@ class CosyVoiceService:
if not audio_url:
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 {
"task_id": "", # 同步接口无 task_id,兼容旧接口
"audio_url": audio_url,
+2 -2
View File
@@ -192,7 +192,7 @@ rollback() {
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--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-timeout 10s \
--health-retries 3 \
@@ -501,7 +501,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--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-timeout 10s \
--health-retries 3 \
+212 -46
View File
@@ -1,4 +1,4 @@
"""对口型 API 路由 + Service 单元测试 — #1796.
"""对口型 API 路由 + Service 单元测试 — #1796, #1809 参数调整.
CI 增量映射: lipsync.py (route) + lipsync_service.py → test_lipsync_routes.py
"""
@@ -33,6 +33,19 @@ def mock_mediakit():
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(
job_id="job-1",
user_id="user-1",
@@ -48,7 +61,7 @@ def _make_mock_job(
m.user_id = user_id
m.project_id = ""
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.mediakit_task_id = mediakit_task_id
m.status = status
@@ -64,16 +77,19 @@ def _make_mock_job(
class TestSchemaValidation:
"""Schema 验证测试."""
"""Schema 验证测试 — #1809 新参数结构."""
def test_valid_video_url(self):
def test_valid_request(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
req = CreateLipsyncJobRequest(
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.voice_id == "longxiaochun_v3"
assert req.script_text == "大家好,欢迎来到直播间"
def test_invalid_video_url_not_mp4(self):
from app.schemas.lipsync import CreateLipsyncJobRequest
@@ -81,7 +97,8 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="MP4"):
CreateLipsyncJobRequest(
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):
@@ -90,7 +107,8 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="不能为空"):
CreateLipsyncJobRequest(
video_url=" ",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="测试文本",
)
def test_invalid_video_url_not_http(self):
@@ -99,26 +117,38 @@ class TestSchemaValidation:
with pytest.raises(ValueError, match="HTTP"):
CreateLipsyncJobRequest(
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
for ext in [".mp3", ".aac", ".wav", ".m4a", ".flac"]:
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="格式不支持"):
with pytest.raises(ValueError, match="voice_id"):
CreateLipsyncJobRequest(
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):
@@ -126,7 +156,8 @@ class TestSchemaValidation:
req = CreateLipsyncJobRequest(
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
@@ -136,53 +167,111 @@ class TestSchemaValidation:
req = CreateLipsyncJobRequest(
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
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:
"""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
mock_db = MagicMock()
svc = LipsyncService(mock_db, client=mock_mediakit)
# 模拟 db.add + db.flush 不报错
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",
audio_url="https://example.com/audio.mp3",
voice_id="longxiaochun_v3",
script_text="大家好,欢迎来到直播间",
)
assert job.status == "submitted"
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()
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.mediakit_client import MediaKitError
mock_mediakit.submit_lipsync.side_effect = MediaKitError("API 调用失败", code="SubmitFailed")
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 调用失败"):
svc.create_job(
user_id="user-1",
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
mock_job = _make_mock_job()
@@ -193,13 +282,13 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
assert result is mock_job
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
mock_db = MagicMock()
@@ -209,11 +298,11 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
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
mock_job = _make_mock_job(status="submitted")
@@ -224,14 +313,14 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
assert result.status == "completed"
assert result.output_video_url == "https://output.mp4"
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
mock_mediakit.get_task_status.return_value = {
@@ -251,13 +340,13 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
assert result.status == "failed"
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
@@ -269,14 +358,14 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
# 不应调用 MediaKit
mock_mediakit.get_task_status.assert_not_called()
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
mock_job = _make_mock_job(status="pending")
@@ -287,12 +376,12 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
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
mock_job = _make_mock_job(status="completed")
@@ -303,8 +392,85 @@ class TestLipsyncServiceUnit:
mock_query.filter.return_value = mock_filter
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")
# 已完成不可取消
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",
)