diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index de0a655b5..c42638cf3 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -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) diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index 0dfa3a05b..c0d989dea 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -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/403;MediaKit 提交失败 → 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 diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index 0836f3fdc..77fe9fa5b 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -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): # 模式 1:TTS 直生 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_text(TTS 直生模式)" ) + 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://")): diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index f8db385aa..36fa4b20c 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -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, diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index b0ce86443..46bfc0e74 100755 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -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 diff --git a/infra/docker/deploy-production-registry.sh b/infra/docker/deploy-production-registry.sh index e9a4a6a27..c0a78f05f 100755 --- a/infra/docker/deploy-production-registry.sh +++ b/infra/docker/deploy-production-registry.sh @@ -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 \ diff --git a/infra/docker/deploy-staging-registry.sh b/infra/docker/deploy-staging-registry.sh index e7494c9b3..c5441d9f9 100755 --- a/infra/docker/deploy-staging-registry.sh +++ b/infra/docker/deploy-staging-registry.sh @@ -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 \ diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index d47c83570..acf6d3755 100755 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -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, diff --git a/scripts/ci_staging_deploy.sh b/scripts/ci_staging_deploy.sh index 19e2d7386..f66e70873 100755 --- a/scripts/ci_staging_deploy.sh +++ b/scripts/ci_staging_deploy.sh @@ -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 \ diff --git a/tests/unit/test_lipsync_routes.py b/tests/unit/test_lipsync_routes.py index b489bc028..5b154194a 100644 --- a/tests/unit/test_lipsync_routes.py +++ b/tests/unit/test_lipsync_routes.py @@ -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", + )