From bbd6418bdd03f3d51fbb70bb44c3abec273e00cf Mon Sep 17 00:00:00 2001 From: xiaoxia-agent Date: Tue, 15 Sep 2026 19:34:59 +0800 Subject: [PATCH] feat(points): P2 step 2.2 - wire lipsync points gate - create_lipsync_job(): deduct ai_digital_human points (15/min), estimated from audio_duration (if provided) or script_text (chars/240), min 1 min; refund on ValueError/MediaKitError/Exception; refund on failed job - preview_tts(): deduct ai_digital_human points by script_text length; refund on MediaKitError/Exception - Both gated by settings.points_enabled (default false) - Raises HTTP 402 INSUFFICIENT_POINTS on insufficient balance - 14 new unit tests; 15468 total passed --- apps/api/app/api/routes/lipsync.py | 99 +++++++++++++++++++++++++++++- tests/unit/test_lipsync_points.py | 96 +++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_lipsync_points.py diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index ff3d84b6b..2d6a98bd2 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -12,9 +12,13 @@ from __future__ import annotations import logging +import math from datetime import UTC from app.auth import AuthenticatedUser, get_current_user +from app.config import settings +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService from app.dependencies import ( get_db_session, get_voice_clone_profile_repository, @@ -53,8 +57,40 @@ def _get_service( def create_lipsync_job( body: CreateLipsyncJobRequest, current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), svc: LipsyncService = Depends(_get_service), ): + user_id = current_user.user.id + + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_digital_human" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + # 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟) + if body.audio_url and body.audio_duration and body.audio_duration > 0: + est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0)) + elif body.script_text: + est_minutes = max(1.0, math.ceil(len(body.script_text) / 240)) + else: + est_minutes = 1.0 + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(current_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(current_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) """提交对口型任务. 三种模式: @@ -66,7 +102,7 @@ def create_lipsync_job( """ try: job = svc.create_job( - user_id=current_user.user.id, + user_id=user_id, video_url=body.video_url, audio_url=body.audio_url, audio_duration=body.audio_duration, @@ -79,8 +115,18 @@ def create_lipsync_job( project_id=body.project_id, ) except ValueError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}") raise HTTPException(status_code=400, detail=str(exc)) from exc except MediaKitError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}") status_code = 502 if exc.code in ("VoiceForbidden",): status_code = 403 @@ -96,11 +142,24 @@ def create_lipsync_job( ) from exc except Exception as exc: logger.error("创建对口型任务异常: %s", exc, exc_info=True) + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型异常退积分异常: err={refund_err}") raise HTTPException( status_code=400, detail=f"创建对口型任务失败: {exc}", ) from exc + # 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等) + # 若任务已创建且状态为 failed,退费 + if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed": + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}") + return job @@ -111,8 +170,34 @@ def create_lipsync_job( def preview_tts( body: AiAvatarTtsPreviewRequest, current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), svc: LipsyncService = Depends(_get_service), ): + user_id = current_user.user.id + + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_digital_human" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0 + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(current_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(current_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) """步骤1「生成配音」同步 TTS 预合成. 同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算, @@ -121,13 +206,18 @@ def preview_tts( """ try: result = svc.preview_tts( - user_id=current_user.user.id, + user_id=user_id, voice_id=body.voice_id, script_text=body.script_text, speed=body.speed, emotion=body.emotion, ) except MediaKitError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}") status_code = 400 if exc.code in ("VoiceForbidden",): status_code = 403 @@ -142,6 +232,11 @@ def preview_tts( ) from exc except Exception as exc: logger.error("TTS 预合成异常: %s", exc, exc_info=True) + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}") raise HTTPException( status_code=400, detail=f"TTS 合成失败: {exc}", diff --git a/tests/unit/test_lipsync_points.py b/tests/unit/test_lipsync_points.py new file mode 100644 index 000000000..bebd34cc4 --- /dev/null +++ b/tests/unit/test_lipsync_points.py @@ -0,0 +1,96 @@ +"""lipsync 积分扣点单元测试 (#1895 P2 step 2.2)""" + +from __future__ import annotations + +import math +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + + +def _make_cu(user_id="user-1", is_member=False, member_type=None): + cu = MagicMock() + cu.user.id = user_id + cu.user.is_member = is_member + cu.user.member_type = member_type + return cu + + +class TestLipsyncDurationEstimate: + @pytest.mark.parametrize( + "text,expected", + [ + ("你好", 1.0), + ("你" * 240, 1.0), + ("你" * 241, 2.0), + ("你" * 1000, 5.0), + ], + ) + def test_text_estimate(self, text, expected): + est = max(1.0, math.ceil(len(text) / 240)) + assert est == expected + + @pytest.mark.parametrize( + "seconds,expected", + [ + (30, 1.0), + (60, 1.0), + (61, 2.0), + (120, 2.0), + (180, 3.0), + ], + ) + def test_audio_duration_estimate(self, seconds, expected): + est = max(1.0, math.ceil(seconds / 60.0)) + assert est == expected + + +class TestLipsyncPointsDeduction: + def _deduct(self, text="你好", audio_duration=None, enabled=True, success=True, balance=100, **cu_kw): + from packages.domain.points_rules import calculate_points_cost + + svc = MagicMock() if enabled else None + cu = _make_cu(**cu_kw) + if svc is None: + return 0, cu + if audio_duration and audio_duration > 0: + est = max(1.0, math.ceil(audio_duration / 60.0)) + elif text: + est = max(1.0, math.ceil(len(text) / 240)) + else: + est = 1.0 + cost = calculate_points_cost( + "ai_digital_human", + is_member=getattr(cu.user, "is_member", False), + duration_minutes=est, + member_type=getattr(cu.user, "member_type", None), + ) + svc.deduct_points.return_value = {"success": success, "balance": balance} + res = svc.deduct_points(cu.user.id, cost, "ai_digital_human", MagicMock()) + if not res["success"]: + raise HTTPException(status_code=402, detail={"code": "INSUFFICIENT_POINTS"}) + return cost, cu + + def test_disabled(self): + cost, _ = self._deduct(enabled=False) + assert cost == 0 + + def test_short_text_min_1min(self): + cost, _ = self._deduct(text="你好") + assert cost >= 15 # 15 base/min for free user × 1.15 + + def test_audio_duration_used(self): + cost_long, _ = self._deduct(audio_duration=180) # 3min + cost_short, _ = self._deduct(audio_duration=30) # 1min + assert cost_long > cost_short + + def test_insufficient_402(self): + with pytest.raises(HTTPException) as ei: + self._deduct(text="你" * 500, success=False, balance=0) + assert ei.value.status_code == 402 + + def test_member_cheaper(self): + cm, _ = self._deduct(text="你" * 500, is_member=True, member_type="yearly") + cf, _ = self._deduct(text="你" * 500, is_member=False) + assert cm < cf