From 7c9fe76c54f05e7d35d72864059fda91344dfd24 Mon Sep 17 00:00:00 2001 From: xiaoxia-agent Date: Tue, 15 Sep 2026 19:12:18 +0800 Subject: [PATCH] feat(points): P2 step 2.1 - wire TTS + voice_clone preview points gate - synthesize(): deduct ai_voice points (1/min, est ceil(chars/240)), refund on workflow/Celery failure - preview_tts(): deduct ai_voice points, refund on CosyVoiceError/ValueError - get_voice_clone_preview(): deduct voice_clone_synth points (1/min), refund on CosyVoiceError/ValueError - All points logic gated by settings.points_enabled (default false) - Raises HTTP 402 INSUFFICIENT_POINTS when balance insufficient --- apps/api/app/api/routes/tts.py | 91 ++++++++++++++++++++++--- apps/api/app/api/routes/voice_clones.py | 46 ++++++++++++- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 8a2e83b3a..6782a1d33 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -4,12 +4,14 @@ from __future__ import annotations import json import logging +import math import subprocess import tempfile from pathlib import Path from typing import Any, Optional from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.core.celery_app import celery_app from app.core.storage import get_storage_service from app.dependencies import ( @@ -39,6 +41,8 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.tts_job_repository import ( SQLAlchemyTTSJobRepository, ) +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService from packages.application.tts_job.streaming_service import TTSStreamingService from packages.application.tts_job.use_cases import ( @@ -128,6 +132,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse: def synthesize( request: TTSSynthesizeRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), voice_clone_repo=Depends(get_voice_clone_profile_repository), @@ -139,6 +144,31 @@ def synthesize( """ user_id = authenticated_user.user.id + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_voice" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + # 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分 + est_minutes = max(1.0, math.ceil(len(request.text) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_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"], + }, + ) + # 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id), # 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id actual_voice_id = request.voice_id @@ -198,6 +228,7 @@ def synthesize( cosyvoice_service=cosyvoice_service, ) + synthesis_error: Exception | None = None try: job = workflow.start_synthesis(job.id) except Exception as e: @@ -205,10 +236,17 @@ def synthesize( # 但 DB 异常、网络异常等意外错误可能逃逸。 # 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。 logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True) + synthesis_error = e try: job = workflow.process_synthesis_failure(job.id, str(e)) except Exception as inner_e: logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}") + # 合成失败且已扣积分 → 退费 + if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}") # 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询 if job.status.value == "processing": @@ -223,10 +261,17 @@ def synthesize( celery_app.send_task("worker.process_tts_synthesis", args=[job.id]) except Exception as e: # Celery 调度失败,标记 job 为 failed + # e used below for refund context try: workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}") + # 调度失败退费 + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}") return TTSSynthesizeResponse( job_id=job.id, @@ -553,6 +598,7 @@ def save_tts_job_to_library( def preview_tts( request: TTSPreviewRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), voice_clone_repo=Depends(get_voice_clone_profile_repository), ) -> TTSPreviewResponse: @@ -561,6 +607,31 @@ def preview_tts( 用于前端预览配音效果,限制文本长度 200 字以内。 支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。 """ + user_id = authenticated_user.user.id + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_voice" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(request.text) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_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"], + }, + ) + # 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_id) @@ -586,16 +657,16 @@ def preview_tts( emotion=request.emotion, language=getattr(request, "language", "zh-CN"), ) - except CosyVoiceError as e: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"TTS 合成失败: {e}", - ) from e - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e + except (CosyVoiceError, ValueError) as e: + # 合成失败退费 + 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 预览失败退积分异常: {refund_err}") + if isinstance(e, CosyVoiceError): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e return TTSPreviewResponse( audio_url=result.audio_url, diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 82f8a0b44..fb60e84cc 100755 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -3,14 +3,17 @@ from __future__ import annotations import logging +import math from typing import Optional from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.core.celery_app import celery_app from app.core.storage import get_storage_service from app.dependencies import ( get_asset_repository, get_cosyvoice_service, + get_db_session, get_project_repository, get_voice_clone_profile_repository, ) @@ -38,6 +41,12 @@ from packages.application.voice_clone.use_cases import ( from packages.application.voice_clone.workflow import ( VoiceCloneWorkflowService, ) +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService +from sqlalchemy.orm import Session + +# remove duplicate +_DUMMY_DELETED = () from packages.ports.asset_repository import AssetRepository from packages.ports.project_repository import ProjectRepository from packages.shared.storage import SharedStorageService @@ -339,6 +348,7 @@ def get_voice_clone_preview( description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然", ), authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service), ) -> VoiceClonePreviewResponse: @@ -350,6 +360,31 @@ def get_voice_clone_preview( """ import time + user_id = authenticated_user.user.id + _points_deducted = 0 + _points_scene = "voice_clone_synth" + _points_svc = PointsService() if settings.points_enabled else None + _preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_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"], + }, + ) + if emotion not in _ALLOWED_PREVIEW_EMOTIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -393,9 +428,14 @@ def get_voice_clone_preview( speed=speed, emotion=emotion, ) - except CosyVoiceError as e: - raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e - except ValueError as e: + except (CosyVoiceError, ValueError) as e: + 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"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}") + if isinstance(e, CosyVoiceError): + raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e # 缓存(仅默认参数组合)