From 4a4b8f4a05acc82909102371746771b686aa9354 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Fri, 2 Oct 2026 20:39:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(viral-video):=20=E9=94=99=E5=88=AB?= =?UTF-8?q?=E5=AD=97'=E5=BE=88=E8=BF=91'=E4=B8=89=E4=BF=9D=E9=99=A9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编导prompt加粗强调严禁使用'很近',正确用词'最近' - _try_gen增加hard fix字符串替换:口播/分镜对白含'很近'自动替换 - 三级重试合格校验:口播含'很近'视为不合格触发重试 refactor(points): 移除ai_video/数字人/配音/改写/标题/封面/抖音解析扣点,仅保留声音克隆 - 删除5个路由的@points_gate装饰器(generation_preview/generation_cover/generation_tasks/scripts_ai/ai_avatar_render) - 删除tts.py/lipsync.py/narrative_service.py中ai_voice/ai_digital_human手动扣点+refund块 - points_rules.py精简POINTS_SCENES:仅保留voice_clone_train(0积分)和voice_clone_synth(1积分/分钟) - 删除DAILY_FREE_CLIP_LIMIT免费混剪额度逻辑,get_daily_usage返回unlimited占位 - points_gate.py删除ai_video特殊分支,未知/免费场景calculate_points_cost返回0直接放行 - points.py删除UNKNOWN_SCENE 400校验,任意scene可查 - 更新16个测试文件适配新规则:198个扣点相关单测全绿,viral_video 58个测试无回归 --- apps/api/app/api/routes/ai_avatar_render.py | 2 - apps/api/app/api/routes/generation_cover.py | 2 - apps/api/app/api/routes/generation_preview.py | 2 - apps/api/app/api/routes/generation_tasks.py | 2 - apps/api/app/api/routes/lipsync.py | 97 +------ apps/api/app/api/routes/points.py | 23 +- apps/api/app/api/routes/scripts_ai.py | 4 - apps/api/app/api/routes/tts.py | 73 ------ apps/api/app/services/narrative_service.py | 33 --- apps/worker/worker_app/tasks/viral_video.py | 16 +- packages/domain/points_rules.py | 65 +---- packages/domain/points_service.py | 127 +--------- packages/middleware/points_gate.py | 13 - tests/unit/test_1970_narrative_service.py | 46 ++-- tests/unit/test_ai_avatar_render_points.py | 50 +--- tests/unit/test_credit_system_switch.py | 58 +++-- tests/unit/test_generation_cover_points.py | 34 ++- tests/unit/test_generation_preview_points.py | 57 ++--- tests/unit/test_generation_tasks_points.py | 70 ++---- tests/unit/test_lipsync_points.py | 236 ++++-------------- tests/unit/test_points_gate.py | 27 +- tests/unit/test_points_routes.py | 57 +++-- tests/unit/test_points_rules.py | 96 +++---- tests/unit/test_points_service.py | 32 +-- tests/unit/test_scripts_ai_points.py | 82 ++---- tests/unit/test_tts_voice_clone_points.py | 124 +++------ 26 files changed, 350 insertions(+), 1078 deletions(-) diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index d921d7928..9afe70b1f 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -29,7 +29,6 @@ from app.services.ai_avatar_render_service import ( from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -44,7 +43,6 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService @router.post("", response_model=AiAvatarRenderJobResponse, status_code=201) -@points_gate("ai_digital_human", per_unit=15) def create_render_job( body: CreateAiAvatarRenderRequest, current_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index c147c0f93..6a092ad8e 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -27,7 +27,6 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import ( ) from packages.application import ListGeneratedVideosByTaskUseCase from packages.domain.config_schemas import normalize_plan_config -from packages.middleware.points_gate import points_gate from packages.shared.storage import get_shared_storage_service from .templates_editor.dependencies import get_draft_plan_id, get_editor_services @@ -346,7 +345,6 @@ def _is_trusted_media_url(url: str) -> bool: @router.post("/generate-cover", response_model=GenerateCoverResponse) -@points_gate("ai_cover") def generate_cover( body: GenerateCoverRequest, template_id: str = Query(..., description="模板 ID"), diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index ec828daed..9be59b7a8 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -41,7 +41,6 @@ from packages.application import ( GetGenerationTaskUseCase, ListGeneratedVideosByTaskUseCase, ) -from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -270,7 +269,6 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str: @router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201) -@points_gate("ai_video", quantity_field="preview_count") def create_preview_generation_task( request: CreatePreviewGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 0f0a2daac..792ab424a 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -163,7 +163,6 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None: return matched or None -from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -465,7 +464,6 @@ def _resolve_project_and_library( @router.post("/tasks", response_model=BatchGenerationTaskResponse) -@points_gate("ai_video", quantity_field="count") def create_generation_task( request: CreateGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index 6e10608d3..d3304e0d9 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -12,11 +12,9 @@ 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 app.dependencies import ( get_db_session, get_voice_clone_profile_repository, @@ -32,8 +30,6 @@ from app.services.mediakit_client import MediaKitError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.orm import Session -from packages.domain.points_rules import calculate_points_cost -from packages.domain.points_service import PointsService logger = logging.getLogger(__name__) @@ -61,37 +57,6 @@ def create_lipsync_job( 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"], - }, - ) """提交对口型任务. 三种模式: @@ -101,6 +66,8 @@ def create_lipsync_job( - 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings}, 后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。 """ + user_id = current_user.user.id + try: job = svc.create_job( user_id=user_id, @@ -118,18 +85,8 @@ 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 @@ -145,24 +102,11 @@ 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 @@ -176,37 +120,14 @@ def preview_tts( 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 时长 → 句子时间戳计算, 不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。 耗时约 2-3 秒。 """ + user_id = current_user.user.id + try: result = svc.preview_tts( user_id=user_id, @@ -218,11 +139,6 @@ def preview_tts( 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 @@ -237,11 +153,6 @@ 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/apps/api/app/api/routes/points.py b/apps/api/app/api/routes/points.py index ecf1657d8..531a68cd6 100644 --- a/apps/api/app/api/routes/points.py +++ b/apps/api/app/api/routes/points.py @@ -169,17 +169,7 @@ def check_points( current_user: AuthenticatedUser = Depends(get_current_user), db: Session = Depends(get_db_session), ): - """消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。""" - if body.scene_key not in POINTS_SCENES: - raise HTTPException( - status_code=400, - detail={ - "code": "UNKNOWN_SCENE", - "message": f"未知场景: {body.scene_key}", - "valid_scenes": sorted(POINTS_SCENES.keys()), - }, - ) - + """消费前检查余额是否足够。已下线/未知场景返回 cost=0(免费)。""" # 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分 if not _credits_enabled(): svc = _get_service() @@ -195,13 +185,6 @@ def check_points( is_mem = _is_member(current_user) mt = _member_type(current_user) - # 混剪场景先检查免费额度 - is_free_quota = False - if body.scene_key == "ai_video" and not is_mem: - svc = _get_service() - if svc.check_daily_free_clip(current_user.user.id, db): - is_free_quota = True - required = calculate_points_cost( body.scene_key, is_mem, @@ -215,11 +198,11 @@ def check_points( balance = account["balance"] return PointsCheckResponse( - allowed=is_free_quota or balance >= required, + allowed=balance >= required, required_points=required, current_balance=balance, remaining_after=balance - required, - is_free_quota=is_free_quota, + is_free_quota=False, ) diff --git a/apps/api/app/api/routes/scripts_ai.py b/apps/api/app/api/routes/scripts_ai.py index 77d639271..9ff692ee2 100644 --- a/apps/api/app/api/routes/scripts_ai.py +++ b/apps/api/app/api/routes/scripts_ai.py @@ -44,7 +44,6 @@ from app.services.script_asr_service import ( from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from packages.middleware.points_gate import points_gate from packages.shared.ai_client import get_doubao_client logger = logging.getLogger(__name__) @@ -373,7 +372,6 @@ def douyin_diag(): @router.post("/extract-from-douyin", response_model=ExtractFromDouyinResponse) -@points_gate("douyin_extract") def extract_from_douyin( request: ExtractFromDouyinRequest, current_user: AuthenticatedUser = Depends(get_current_user), @@ -497,7 +495,6 @@ def extract_from_douyin( @router.post("/ai-rewrite", response_model=AiRewriteResponse) -@points_gate("ai_rewrite") def ai_rewrite( request: AiRewriteRequest, current_user: AuthenticatedUser = Depends(get_current_user), @@ -537,7 +534,6 @@ def ai_rewrite( @router.post("/ai-generate-titles", response_model=AiGenerateTitlesResponse) -@points_gate("ai_title") def ai_generate_titles( request: AiGenerateTitlesRequest, current_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 6eca8baea..c2476a263 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -4,14 +4,12 @@ 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 ( @@ -53,8 +51,6 @@ from packages.application.tts_job.use_cases import ( ) from packages.application.tts_job.workflow import TTSWorkflowService from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus -from packages.domain.points_rules import calculate_points_cost -from packages.domain.points_service import PointsService from packages.domain.voice_presets import list_voices from packages.ports.asset_library_repository import AssetLibraryRepository from packages.ports.asset_repository import AssetRepository @@ -144,31 +140,6 @@ 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 @@ -244,13 +215,6 @@ def synthesize( 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": # 分段合成任务 vs 普通单段任务 @@ -269,13 +233,6 @@ def synthesize( 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, status=job.status, @@ -611,30 +568,6 @@ def preview_tts( 支持预设音色和克隆音色:克隆音色传的是 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) @@ -664,12 +597,6 @@ def preview_tts( language=getattr(request, "language", "zh-CN"), ) 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 diff --git a/apps/api/app/services/narrative_service.py b/apps/api/app/services/narrative_service.py index 71ea39e6b..827cdb047 100644 --- a/apps/api/app/services/narrative_service.py +++ b/apps/api/app/services/narrative_service.py @@ -11,14 +11,12 @@ 存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为 audio asset id)消费,渲染链路零改动。 -积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。 """ from __future__ import annotations import json import logging -import math import subprocess import tempfile from dataclasses import dataclass @@ -32,13 +30,10 @@ from packages.application.cosyvoice_service import CosyVoiceService from packages.application.tts_job.use_cases import CreateTTSJobUseCase from packages.application.tts_job.workflow import TTSWorkflowService from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus -from packages.domain.points_rules import calculate_points_cost -from packages.domain.points_service import PointsService from packages.shared.storage import SharedStorageService logger = logging.getLogger(__name__) -_POINTS_SCENE = "ai_voice" _SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量 _CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"} @@ -273,24 +268,6 @@ def prepare_narrative_voice( voice_clone_repository=voice_clone_repository, ) - # 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费 - points_svc = PointsService() if points_enabled else None - points_deducted = 0 - if points_svc is not None: - est_minutes = max(1.0, math.ceil(len(content) / 240)) - points_deducted = calculate_points_cost( - _POINTS_SCENE, - is_member=is_member, - duration_minutes=est_minutes, - member_type=member_type, - ) - deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db) - if not deduct_res["success"]: - raise NarrativeError( - f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}", - status_code=402, - ) - use_case = CreateTTSJobUseCase(tts_repository) job = use_case.execute( user_id=user_id, @@ -311,19 +288,9 @@ def prepare_narrative_voice( workflow.process_synthesis_failure(job.id, str(e)) except Exception: # noqa: BLE001 logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True) - if points_deducted and points_svc is not None: - try: - points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id) - except Exception: # noqa: BLE001 - logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True) raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e if not job.is_completed: - if points_deducted and points_svc is not None: - try: - points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id) - except Exception: # noqa: BLE001 - logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True) raise NarrativeError("配音合成未完成,请稍后重试", status_code=504) asset = _save_tts_job_as_voice_asset( diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 1f71143e5..e5f50315f 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -629,7 +629,7 @@ _SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5( 6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。 7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。 8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。 -9. 输出前自检:口播对白禁止错别字和语病(特别注意"很/最"等常见误用),同音字错误一律修正。 +9. 输出前自检:口播对白禁止错别字和语病,**严禁使用"很近",正确用词是"最近"**(指"最近一段时间/最近在用",绝不能写成"很近");其他同音字、形近字错误一律修正。 10. 必须使用产品信息中真实的品牌、品名和外观特征,不要编造与产品无关的内容。""" @@ -921,8 +921,20 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di voiceover_len = len(voiceover) shots_cnt = len((normalized or {}).get("shots") or []) # 判定是否"退化到兜底质量":口播过短(<20字)或镜头数<1;正常的短口播(如15s视频~40字)不视为兜底 + # v1.6.1 双保险:先做 hard fix 字符串替换("很近" → "最近"),再做不合格判定 + if "很近" in voiceover: + logger.warning("[爆款视频] 编导脚本含错别字'很近',hard fix 替换为'最近' label=%s", label) + voiceover = voiceover.replace("很近", "最近") + normalized["voiceover_script"] = voiceover + # 同时在 shots 对白里替换 + for sh in normalized.get("shots") or []: + if isinstance(sh, dict): + sd = sh.get("scene_and_dialogue") or "" + if "很近" in sd: + sh["scene_and_dialogue"] = sd.replace("很近", "最近") fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串 - is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20 + has_typo_henjin = "很近" in voiceover # v1.6.1: 错别字"很近"视为不合格,触发重试 + is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20 or has_typo_henjin logger.info( "[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s raw_type=%s", label, diff --git a/packages/domain/points_rules.py b/packages/domain/points_rules.py index 5bc6dc12a..13b3df8f5 100644 --- a/packages/domain/points_rules.py +++ b/packages/domain/points_rules.py @@ -1,4 +1,9 @@ -"""积分消耗规则配置 (#1895)""" +"""积分消耗规则配置 (#1895) + +v1.6.1: 按产品决策,智能混剪/AI数字人/AI配音/抖音解析/改写/标题/封面 全部免费, +仅保留声音克隆合成(voice_clone_synth)的扣点逻辑;声音克隆训练保持免费。 +爆款视频(viral_video)后续走动态定价,暂不加入本文件。 +""" from __future__ import annotations @@ -6,27 +11,9 @@ import math # ============ 场景定义 ============ # 每个场景: base_points(基础积分), unit(计费单位), name(显示名称) +# 说明:仅保留需要扣点的场景;免费场景不要写入此字典。 POINTS_SCENES: dict[str, dict] = { - "ai_voice": { - "base_points": 1, - "unit": "分钟", - "name": "AI 配音", - "description": "AI 配音每分钟消耗 1 积分(免费用户上浮 15%,会员 8~9 折)", - }, - "ai_video": { - "base_points": 3, - "unit": "条", - "name": "智能混剪", - "extra_per_30s": 1, - "description": "智能混剪每条 3 积分起,视频超过 30 秒后每 30 秒加 1 积分;免费用户每日 2 条免费额度", - }, - "ai_digital_human": { - "base_points": 15, - "unit": "分钟", - "name": "AI 数字人", - "description": "AI 数字人每分钟消耗 15 积分", - }, "voice_clone_train": { "base_points": 0, "unit": "次", @@ -39,23 +26,9 @@ POINTS_SCENES: dict[str, dict] = { "name": "声音克隆合成", "description": "克隆音色合成每分钟消耗 1 积分", }, - "douyin_extract": { - "base_points": 1, - "unit": "次", - "name": "抖音链接提取", - "description": "抖音文案提取每次 1 积分", - }, - "ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案", "description": "AI 改写文案每次 1 积分"}, - "ai_title": { - "base_points": 1, - "unit": "次", - "name": "AI 标题生成", - "description": "AI 生成标题每次 1 积分(免费用户实际上浮后 2 积分/次)", - }, - "ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成", "description": "AI 封面生成每张 1 积分"}, } -# 免费用户积分消耗上浮系数 +# 免费用户积分消耗上浮系数(仅对 voice_clone_synth 生效) FREE_USER_MULTIPLIER = 1.15 # ============ 积分包定义 ============ @@ -81,9 +54,6 @@ MEMBER_DISCOUNT: dict[str, float] = { "yearly": 0.8, } -# 每日免费混剪次数(免费用户) -DAILY_FREE_CLIP_LIMIT = 2 - def calculate_points_cost( scene_key: str, @@ -95,41 +65,32 @@ def calculate_points_cost( """计算指定场景的积分消耗。 Args: - scene_key: 场景标识,如 "ai_voice"、"ai_video" + scene_key: 场景标识(当前仅支持 voice_clone_train/voice_clone_synth) is_member: 是否付费会员 quantity: 数量(按次计费场景) duration_minutes: 时长分钟数(按时长计费场景) member_type: 会员类型 (monthly/quarterly/yearly),用于折扣 Returns: - 实际消耗积分(已含免费用户 ×1.15 上浮或会员折扣) - - Raises: - ValueError: 未知场景标识 + 实际消耗积分(已含免费用户 ×1.15 上浮或会员折扣);免费/已下线场景统一返回 0。 """ scene = POINTS_SCENES.get(scene_key) if not scene: - raise ValueError(f"Unknown points scene: {scene_key}") + # 已下线/未注册的场景统一返回 0(免费),保持向后兼容 + return 0 base = scene["base_points"] if base == 0: return 0 - # —— 计算基础消耗 —— unit = scene["unit"] if unit == "分钟": total_base = base * max(1, math.ceil(duration_minutes)) - elif unit in ("条", "次", "张"): + elif unit in ("次", "张"): total_base = base * quantity - # 混剪特殊逻辑:视频超过 30s 后每 +30s 额外加 1 积分 - if scene_key == "ai_video" and duration_minutes > 0.5: - extra_segments = math.ceil((duration_minutes * 60 - 30) / 30) - if extra_segments > 0: - total_base += scene.get("extra_per_30s", 1) * extra_segments else: total_base = base - # —— 会员折扣 / 免费用户上浮 —— if is_member and member_type and member_type in MEMBER_DISCOUNT: total_base = max(1, math.floor(total_base * MEMBER_DISCOUNT[member_type])) elif not is_member: diff --git a/packages/domain/points_service.py b/packages/domain/points_service.py index 1a8706d9c..4259f4f13 100644 --- a/packages/domain/points_service.py +++ b/packages/domain/points_service.py @@ -13,7 +13,6 @@ from typing import Any from sqlalchemy.orm import Session from packages.domain.points_rules import ( - DAILY_FREE_CLIP_LIMIT, POINTS_PACKAGES, ) @@ -324,132 +323,16 @@ class PointsService: "page_size": page_size, } - # ──────────────── 每日免费混剪额度 ──────────────── - - def _daily_key(self, user_id: str) -> str: - """生成 Redis 每日额度 key。格式: daily_usage:{user_id}:{YYYYMMDD}:free_clip""" - today = datetime.now(UTC).strftime("%Y%m%d") - return f"daily_usage:{user_id}:{today}:free_clip" - - def check_daily_free_clip(self, user_id: str, db: Session) -> bool: - """检查今日是否还有免费混剪额度。 - - 优先查 Redis,Redis 不可用时降级到 DB。 - """ - redis_client = _get_redis_client() - if redis_client: - try: - key = self._daily_key(user_id) - current = redis_client.get(key) - if current is None: - return True - return int(current) < DAILY_FREE_CLIP_LIMIT - except Exception: - logger.warning("Redis 不可用,降级到 DB 查询每日额度") - - # 降级到 DB - _, _, _, DailyUsageRecordModel, _ = _get_models() - today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - record = ( - db.query(DailyUsageRecordModel) - .filter( - DailyUsageRecordModel.user_id == user_id, - DailyUsageRecordModel.usage_type == "free_clip", - DailyUsageRecordModel.usage_date >= today_start, - ) - .first() - ) - if record is None: - return True - return record.count < DAILY_FREE_CLIP_LIMIT - - def record_daily_free_clip(self, user_id: str, db: Session) -> bool: - """记录使用一次免费混剪。 - - 先 INCR Redis;如果超限回退 Redis。DB 使用 upsert 语义(唯一约束)。 - """ - redis_client = _get_redis_client() - if redis_client: - try: - key = self._daily_key(user_id) - new_count = redis_client.incr(key) - if new_count == 1: - redis_client.expire(key, 48 * 3600) # TTL 48h - if new_count <= DAILY_FREE_CLIP_LIMIT: - return True - # 超限,回退 Redis - redis_client.decr(key) - except Exception: - logger.warning("Redis 不可用,降级到 DB 记录每日额度") - - # 降级/兜底到 DB(upsert 语义) - _, _, _, DailyUsageRecordModel, _ = _get_models() - today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - - record = ( - db.query(DailyUsageRecordModel) - .filter( - DailyUsageRecordModel.user_id == user_id, - DailyUsageRecordModel.usage_type == "free_clip", - DailyUsageRecordModel.usage_date >= today_start, - ) - .first() - ) - - if record is None: - if DAILY_FREE_CLIP_LIMIT <= 0: - return False - record = DailyUsageRecordModel( - id=uuid.uuid4().hex, - user_id=user_id, - usage_type="free_clip", - usage_date=datetime.now(UTC), - count=1, - ) - db.add(record) - else: - if record.count >= DAILY_FREE_CLIP_LIMIT: - return False - record.count += 1 - - db.commit() - return True + # ──────────────── 每日免费混剪额度(已下线:智能混剪全免费) ──────────────── def get_daily_usage(self, user_id: str, db: Session) -> dict[str, Any]: - """查询今日免费额度使用情况。""" - redis_client = _get_redis_client() - used = 0 - - if redis_client: - try: - key = self._daily_key(user_id) - val = redis_client.get(key) - used = int(val) if val else 0 - except Exception: - pass - - if used == 0: - # 从 DB 查 - _, _, _, DailyUsageRecordModel, _ = _get_models() - today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - record = ( - db.query(DailyUsageRecordModel) - .filter( - DailyUsageRecordModel.user_id == user_id, - DailyUsageRecordModel.usage_type == "free_clip", - DailyUsageRecordModel.usage_date >= today_start, - ) - .first() - ) - used = record.count if record else 0 - + """查询今日免费额度使用情况(智能混剪已全免费,返回 unlimited)。""" now = datetime.now(UTC) tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) - return { - "free_clips_used": used, - "free_clips_limit": DAILY_FREE_CLIP_LIMIT, - "free_clips_remaining": max(0, DAILY_FREE_CLIP_LIMIT - used), + "free_clips_used": 0, + "free_clips_limit": -1, # -1 表示 unlimited + "free_clips_remaining": -1, "reset_at": tomorrow.isoformat(), } diff --git a/packages/middleware/points_gate.py b/packages/middleware/points_gate.py index ea8ddb6e2..d3d3cba0a 100644 --- a/packages/middleware/points_gate.py +++ b/packages/middleware/points_gate.py @@ -185,19 +185,6 @@ def _execute_with_gate_impl( is_member = getattr(user, "is_member", False) member_type = getattr(user, "member_type", None) - if scene_key == "ai_video": - from packages.domain.points_service import PointsService - - svc = PointsService() - if not is_member: - if svc.check_daily_free_clip(user.id, db): - svc.record_daily_free_clip(user.id, db) - kwargs["_points_deducted"] = 0 - kwargs["_is_free_quota"] = True - if is_async: - return _run_async_impl(func, args, _filter_kwargs_impl(func, kwargs)) - return func(*args, **_filter_kwargs_impl(func, kwargs)) - if per_unit is not None: total_points = per_unit else: diff --git a/tests/unit/test_1970_narrative_service.py b/tests/unit/test_1970_narrative_service.py index 5a00df0a0..d0a496676 100644 --- a/tests/unit/test_1970_narrative_service.py +++ b/tests/unit/test_1970_narrative_service.py @@ -377,32 +377,12 @@ class TestPrepareNarrativeVoice: assert ei.value.status_code == 502 assert "配音合成失败" in ei.value.message - def test_points_insufficient_402(self, monkeypatch): - class FakePoints: - def deduct_points(self, *a, **k): - return {"success": False, "balance": 0} + def test_no_points_service_invoked(self, monkeypatch): + """v1.6.2: 叙事配音已免费,不再实例化 PointsService / 扣点/退费。""" + # 确认 narrative_service 已不再暴露 PointsService + assert not hasattr(ns, "PointsService"), "narrative_service 不应再导入 PointsService" - monkeypatch.setattr(ns, "PointsService", lambda: FakePoints()) - deps = self._deps(points_enabled=True) - with pytest.raises(NarrativeError) as ei: - prepare_narrative_voice(**deps) - assert ei.value.status_code == 402 - - def test_points_refund_on_failure(self, monkeypatch): - class FakePoints: - def __init__(self): - self.refunded = 0 - - def deduct_points(self, *a, **k): - return {"success": True, "balance": 100} - - def refund_points(self, user_id, amount, source, db, ref_id="", **k): - self.refunded += amount - - points = FakePoints() - monkeypatch.setattr(ns, "PointsService", lambda: points) - - class FailingWorkflow: + class FakeWorkflow: def __init__(self, *, repository, cosyvoice_service): pass @@ -412,11 +392,21 @@ class TestPrepareNarrativeVoice: def process_synthesis_failure(self, job_id, error): return None - monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow) + monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow) deps = self._deps(points_enabled=True) - with pytest.raises(NarrativeError): + with pytest.raises(NarrativeError) as ei: prepare_narrative_voice(**deps) - assert points.refunded > 0 + # 走 502 业务错误路径,不再退费 + assert ei.value.status_code == 502 + + def test_module_has_no_points_imports(self): + """模块源码不再包含扣点相关符号。""" + import inspect + src = inspect.getsource(ns) + assert "PointsService" not in src + assert "calculate_points_cost" not in src + assert "_points_scene" not in src + assert "_POINTS_SCENE" not in src def test_clone_source_resolves_profile(self, monkeypatch): captured = {} diff --git a/tests/unit/test_ai_avatar_render_points.py b/tests/unit/test_ai_avatar_render_points.py index 6ae5eea8d..b1566916c 100644 --- a/tests/unit/test_ai_avatar_render_points.py +++ b/tests/unit/test_ai_avatar_render_points.py @@ -1,48 +1,24 @@ -"""AI数字人渲染 积分扣点单元测试 (#1895 P2 step 2.6)""" +"""AI 数字人渲染 — v1.6.2 起免费,不扣积分""" from __future__ import annotations -from unittest.mock import MagicMock, patch -import pytest - -import packages.middleware.points_gate as _pg_module - - -@pytest.fixture(autouse=True) -def _enable(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - yield - - -class TestAiAvatarRenderPoints: - def test_ai_digital_human_per_unit(self): +class TestAiAvatarRenderFree: + def test_ai_digital_human_returns_zero_cost(self): from packages.domain.points_rules import calculate_points_cost - cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) - assert cost >= 15 + assert calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) == 0 + assert calculate_points_cost("ai_digital_human", is_member=True, duration_minutes=5) == 0 - def test_decorator_attached(self): + def test_no_points_gate_decorator(self): from app.api.routes.ai_avatar_render import create_render_job - assert hasattr(create_render_job, "__wrapped__"), "missing @points_gate" + assert not hasattr(create_render_job, "__wrapped__") - def test_insufficient_raises_402(self): - from app.api.routes.ai_avatar_render import create_render_job - from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest - from fastapi import HTTPException + def test_module_has_no_points_imports(self): + from app.api.routes import ai_avatar_render as mod + import inspect - db = MagicMock() - cu = MagicMock() - cu.user.id = "u1" - cu.user.is_member = False - cu.user.member_type = None - svc = MagicMock() - body = CreateAiAvatarRenderRequest(lipsync_job_id="lip1") - with patch("packages.domain.points_service.PointsService") as MS: - msvc = MagicMock() - msvc.deduct_points.return_value = {"success": False, "balance": 0} - MS.return_value = msvc - with pytest.raises(HTTPException) as ei: - create_render_job(body=body, current_user=cu, svc=svc, db=db) - assert ei.value.status_code == 402 + src = inspect.getsource(mod) + assert "PointsService" not in src + assert "points_gate" not in src diff --git a/tests/unit/test_credit_system_switch.py b/tests/unit/test_credit_system_switch.py index 002a7b82b..216c5475e 100644 --- a/tests/unit/test_credit_system_switch.py +++ b/tests/unit/test_credit_system_switch.py @@ -95,25 +95,29 @@ class TestCheckEndpointWhenDisabled: # 不再走免费额度判定 svc.check_daily_free_clip.assert_not_called() - def test_unknown_scene_still_400_when_disabled(self): - """未知 scene 即使系统关闭也返回 400(参数校验先于开关)。""" + def test_unknown_scene_allowed_when_disabled(self): + """任意 scene_key(含未知/已下线)系统关闭时都返回 allowed=True, cost=0。""" from app.api.routes.points import check_points from app.schemas.points import PointsCheckRequest - from fastapi import HTTPException - with pytest.raises(HTTPException) as exc: - check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock()) - assert exc.value.status_code == 400 + svc = MagicMock() + svc.get_or_create_account.return_value = {"balance": 0} + with ( + patch("app.api.routes.points._credits_enabled", return_value=False), + patch("app.api.routes.points._get_service", return_value=svc), + ): + resp = check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock()) + assert resp.allowed is True + assert resp.required_points == 0 def test_check_enabled_calculates_cost(self): - """开关开启时保持原有计费校验。""" + """开关开启时保持原有计费校验(voice_clone_synth 正常计费)。""" from app.api.routes.points import check_points from app.schemas.points import PointsCheckRequest svc = MagicMock() - svc.check_daily_free_clip.return_value = False svc.get_or_create_account.return_value = {"balance": 100} - body = PointsCheckRequest(scene_key="ai_title", quantity=1) + body = PointsCheckRequest(scene_key="voice_clone_synth", quantity=1, duration_minutes=1) with ( patch("app.api.routes.points._credits_enabled", return_value=True), @@ -123,6 +127,23 @@ class TestCheckEndpointWhenDisabled: assert resp.required_points == 2 # 免费用户 ceil(1*1.15)=2 + def test_retired_scene_free_when_enabled(self): + """开关开启时,已下线场景返回 cost=0,直接放行。""" + from app.api.routes.points import check_points + from app.schemas.points import PointsCheckRequest + + svc = MagicMock() + svc.get_or_create_account.return_value = {"balance": 0} + with ( + patch("app.api.routes.points._credits_enabled", return_value=True), + patch("app.api.routes.points._get_service", return_value=svc), + ): + for scene in ["ai_voice", "ai_title", "ai_video", "ai_digital_human", "nope"]: + body = PointsCheckRequest(scene_key=scene, quantity=1) + resp = check_points(body=body, current_user=_make_cu(), db=MagicMock()) + assert resp.required_points == 0, f"{scene} should be free" + assert resp.allowed is True + # ── /points/deduct:关闭时 no-op,余额不变 ──────────────────────────────── @@ -217,13 +238,22 @@ class TestQueryEndpointsRemainAvailable: class TestBusinessRoutesBypassWhenDisabled: - def test_lipsync_route_skips_points(self): - """lipsync 创建任务路由:settings.points_enabled=False 时不构造 PointsService。""" + def test_lipsync_route_has_no_points_logic(self): + """lipsync 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。""" from app.api.routes import lipsync as lipsync_mod + import inspect - assert bool(getattr(lipsync_mod.settings, "points_enabled", False)) is False + src = inspect.getsource(lipsync_mod) + assert "PointsService" not in src + assert "calculate_points_cost" not in src + assert "_points_deducted" not in src - def test_tts_route_skips_points(self): + def test_tts_route_has_no_points_logic(self): + """tts 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。""" from app.api.routes import tts as tts_mod + import inspect - assert bool(getattr(tts_mod.settings, "points_enabled", False)) is False + src = inspect.getsource(tts_mod) + assert "PointsService" not in src + assert "calculate_points_cost" not in src + assert "_points_deducted" not in src diff --git a/tests/unit/test_generation_cover_points.py b/tests/unit/test_generation_cover_points.py index b5cd49d04..2bcf82a3b 100644 --- a/tests/unit/test_generation_cover_points.py +++ b/tests/unit/test_generation_cover_points.py @@ -1,28 +1,24 @@ -"""AI封面生成 积分扣点单元测试 (#1895 P2 step 2.7)""" +"""封面生成 — v1.6.2 起免费,不扣积分""" from __future__ import annotations -from unittest.mock import MagicMock, patch -import pytest - -import packages.middleware.points_gate as _pg_module - - -@pytest.fixture(autouse=True) -def _enable(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - yield - - -class TestGenerationCoverPoints: - def test_ai_cover_cost(self): +class TestGenerationCoverFree: + def test_ai_cover_returns_zero_cost(self): from packages.domain.points_rules import calculate_points_cost - assert calculate_points_cost("ai_cover", is_member=False) == 2 - assert calculate_points_cost("ai_cover", is_member=True, member_type="yearly") >= 0 + assert calculate_points_cost("ai_cover", is_member=False, quantity=1) == 0 + assert calculate_points_cost("ai_cover", is_member=True, quantity=10) == 0 - def test_decorator_attached(self): + def test_no_points_gate_decorator(self): from app.api.routes.generation_cover import generate_cover - assert hasattr(generate_cover, "__wrapped__"), "missing @points_gate" + assert not hasattr(generate_cover, "__wrapped__") + + def test_endpoint_has_no_points_logic(self): + from app.api.routes import generation_cover as mod + import inspect + + src = inspect.getsource(mod) + assert "PointsService" not in src + assert "deduct_points" not in src diff --git a/tests/unit/test_generation_preview_points.py b/tests/unit/test_generation_preview_points.py index 196800d51..e8037ff0f 100644 --- a/tests/unit/test_generation_preview_points.py +++ b/tests/unit/test_generation_preview_points.py @@ -1,54 +1,29 @@ -"""视频预览生成 积分扣点单元测试 (#1895 P2 step 2.5)""" +"""视频预览生成 — v1.6.2 起免费,不扣积分""" from __future__ import annotations from unittest.mock import MagicMock, patch -import pytest -import packages.middleware.points_gate as _pg_module - - -@pytest.fixture(autouse=True) -def _enable(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - yield - - -class TestGenerationPreviewPoints: - def test_ai_video_cost(self): +class TestGenerationPreviewFree: + def test_ai_video_returns_zero_cost(self): from packages.domain.points_rules import calculate_points_cost - assert calculate_points_cost("ai_video", is_member=False) == 4 - assert calculate_points_cost("ai_video", is_member=True, member_type="monthly") == 2 + assert calculate_points_cost("ai_video", is_member=False) == 0 + assert calculate_points_cost("ai_video", is_member=True, member_type="monthly", duration_minutes=10) == 0 - def test_insufficient_raises_402(self): + def test_no_points_gate_decorator(self): + """预览生成路由已移除 @points_gate。""" from app.api.routes.generation_preview import create_preview_generation_task - from app.schemas.generation_task import CreatePreviewGenerationTaskRequest - from fastapi import HTTPException + # 移除装饰器后 __wrapped__ 不再存在 + assert not hasattr(create_preview_generation_task, "__wrapped__") - db = MagicMock() - cu = MagicMock() - cu.user.id = "u1" - cu.user.is_member = False - cu.user.member_type = None - req = CreatePreviewGenerationTaskRequest(template_id="t1", asset_ids=["a1"], preview_count=1) - with patch("packages.domain.points_service.PointsService") as MS: - svc = MagicMock() - svc.check_daily_free_clip.return_value = False - svc.deduct_points.return_value = {"success": False, "balance": 0} - MS.return_value = svc - with pytest.raises(HTTPException) as ei: - create_preview_generation_task( - request=req, - authenticated_user=cu, - db=db, - generation_task_repository=MagicMock(), - asset_repo=MagicMock(), - ) - assert ei.value.status_code == 402 - - def test_decorator_attached(self): + def test_endpoint_does_not_deduct_points(self): + """端点不再实例化 PointsService / 调用 deduct_points(直接走业务逻辑)。""" from app.api.routes.generation_preview import create_preview_generation_task + import inspect - assert hasattr(create_preview_generation_task, "__wrapped__"), "missing @points_gate" + src = inspect.getsource(create_preview_generation_task) + assert "PointsService" not in src + assert "deduct_points" not in src + assert "calculate_points_cost" not in src diff --git a/tests/unit/test_generation_tasks_points.py b/tests/unit/test_generation_tasks_points.py index 2599398ad..cf685f4ec 100644 --- a/tests/unit/test_generation_tasks_points.py +++ b/tests/unit/test_generation_tasks_points.py @@ -1,63 +1,27 @@ -"""视频生成 积分扣点单元测试 (#1895 P2 step 2.4)""" +"""智能混剪任务 — v1.6.2 起免费,不扣积分""" from __future__ import annotations -from unittest.mock import MagicMock, patch - -import pytest - -import packages.middleware.points_gate as _pg_module +from unittest.mock import MagicMock -@pytest.fixture(autouse=True) -def _enable(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - yield - - -class TestGenerationTasksPoints: - def test_ai_video_base_cost(self): +class TestGenerationTasksFree: + def test_ai_video_returns_zero_cost(self): from packages.domain.points_rules import calculate_points_cost - assert calculate_points_cost("ai_video", is_member=False) == 4 - assert calculate_points_cost("ai_video", is_member=True, member_type="monthly") == 2 + assert calculate_points_cost("ai_video", is_member=False, duration_minutes=5) == 0 + assert calculate_points_cost("ai_video", is_member=True, duration_minutes=10) == 0 - def test_ai_video_quantity_scales(self): - from packages.domain.points_rules import calculate_points_cost - - c1 = calculate_points_cost("ai_video", is_member=False, quantity=1) - c3 = calculate_points_cost("ai_video", is_member=False, quantity=3) - assert c3 > c1 - - def test_insufficient_raises_402(self): - from app.api.routes.generation_tasks import create_generation_task - from app.schemas.generation_task import CreateGenerationTaskRequest - from fastapi import HTTPException - - db = MagicMock() - cu = MagicMock() - cu.user.id = "u1" - cu.user.is_member = False - cu.user.member_type = None - req = CreateGenerationTaskRequest(template_id="t1", asset_ids=["a1"], count=1) - with patch("packages.domain.points_service.PointsService") as MS: - svc = MagicMock() - svc.check_daily_free_clip.return_value = False - svc.deduct_points.return_value = {"success": False, "balance": 0} - MS.return_value = svc - with pytest.raises(HTTPException) as ei: - create_generation_task( - request=req, - authenticated_user=cu, - db=db, - generation_task_repository=MagicMock(), - project_repository=MagicMock(), - asset_library_repository=MagicMock(), - asset_repository=MagicMock(), - ) - assert ei.value.status_code == 402 - - def test_decorator_attached(self): + def test_no_points_gate_decorator(self): from app.api.routes.generation_tasks import create_generation_task - assert hasattr(create_generation_task, "__wrapped__"), "missing @points_gate" + assert not hasattr(create_generation_task, "__wrapped__") + + def test_create_task_accepts_request_without_points_block(self): + """路由函数签名不再做扣点,但参数 points_enabled/is_member/member_type 仍保留以兼容调用方。""" + from app.api.routes.generation_tasks import create_generation_task + import inspect + + sig = inspect.signature(create_generation_task) + # 函数存在 + assert callable(create_generation_task) diff --git a/tests/unit/test_lipsync_points.py b/tests/unit/test_lipsync_points.py index 5acb81789..d784f0b38 100644 --- a/tests/unit/test_lipsync_points.py +++ b/tests/unit/test_lipsync_points.py @@ -1,15 +1,16 @@ -"""lipsync 积分扣点单元测试 (#1895 P2 step 2.2)""" +"""lipsync 口型同步 — v1.6.2 起免费,不扣积分""" from __future__ import annotations import math -from unittest.mock import MagicMock +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import pytest from fastapi import HTTPException -def _make_cu(user_id="user-1", is_member=False, member_type=None): +def _cu(user_id="u1", is_member=False, member_type=None): cu = MagicMock() cu.user.id = user_id cu.user.is_member = is_member @@ -17,99 +18,6 @@ def _make_cu(user_id="user-1", is_member=False, member_type=None): 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 - - -# ── 直接调用 create_lipsync_job 覆盖扣点/402/退费分支 ── -import importlib -from types import SimpleNamespace -from unittest.mock import patch - -import packages.middleware.points_gate as _pg_module - - -# Ensure the enable-gate fixture for lipsync also covers @points_gate (if any) -# (the existing autouse _enable is below; importlib to avoid duplicate) -def _do_enable(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - - def _body(**kw): b = MagicMock() defaults = dict( @@ -130,113 +38,63 @@ def _body(**kw): return b -def _cu(user_id="u1", 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 TestLipsyncFree: + """lipsync 已移除手动扣点,业务异常仍按原状态码抛出。""" + def test_ai_digital_human_returns_zero_cost(self): + from packages.domain.points_rules import calculate_points_cost -class TestLipsyncEndpointPoints: - def test_insufficient_raises_402(self, monkeypatch): - _do_enable(monkeypatch) + assert calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) == 0 + assert calculate_points_cost("ai_digital_human", is_member=True, duration_minutes=10) == 0 + + def test_module_has_no_points_imports(self): + from app.api.routes import lipsync as mod + import inspect + + src = inspect.getsource(mod) + assert "PointsService" not in src + assert "calculate_points_cost" not in src + assert "_points_deducted" not in src + assert "settings" not in src # settings was only used for points_enabled + + def test_docstring_at_top_of_create_lipsync_job(self): + """扣点块删除后,docstring 必须在函数体第一行(防止函数体中段 docstring 丢失)。""" from app.api.routes.lipsync import create_lipsync_job + import ast, inspect - db = MagicMock() - svc = MagicMock() - ps = MagicMock() - ps.deduct_points.return_value = {"success": False, "balance": 0} - fs = MagicMock(points_enabled=True) - with ( - patch("app.api.routes.lipsync.PointsService", return_value=ps), - patch("app.api.routes.lipsync.settings", fs), - ): - with pytest.raises(HTTPException) as ei: - create_lipsync_job(body=_body(script_text="你" * 500), current_user=_cu(), db=db, svc=svc) - assert ei.value.status_code == 402 + src = inspect.getsource(create_lipsync_job) + tree = ast.parse(src) + fn = tree.body[0] + # docstring 应为函数体第一条语句 + assert isinstance(fn.body[0], ast.Expr) and isinstance(fn.body[0].value, ast.Constant) and isinstance(fn.body[0].value.value, str), "create_lipsync_job docstring 不在函数体开头" - def test_value_error_refunds(self, monkeypatch): - _do_enable(monkeypatch) + def test_docstring_at_top_of_preview_tts(self): + from app.api.routes.lipsync import preview_tts + import ast, inspect + + src = inspect.getsource(preview_tts) + tree = ast.parse(src) + fn = tree.body[0] + assert isinstance(fn.body[0], ast.Expr) and isinstance(fn.body[0].value, ast.Constant) and isinstance(fn.body[0].value.value, str), "preview_tts docstring 不在函数体开头" + + def test_value_error_still_raises_400(self): + """业务异常仍抛 400(不再退费)。""" from app.api.routes.lipsync import create_lipsync_job db = MagicMock() svc = MagicMock() svc.create_job.side_effect = ValueError("bad input") - ps = MagicMock() - ps.deduct_points.return_value = {"success": True, "balance": 99} - fs = MagicMock(points_enabled=True) - with ( - patch("app.api.routes.lipsync.PointsService", return_value=ps), - patch("app.api.routes.lipsync.settings", fs), - ): - with pytest.raises(HTTPException) as ei: - create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc) - assert ei.value.status_code == 400 - assert ps.refund_points.called + with pytest.raises(HTTPException) as ei: + create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc) + assert ei.value.status_code == 400 - def test_mediakit_error_refunds(self, monkeypatch): - _do_enable(monkeypatch) + def test_success_returns_job(self): from app.api.routes.lipsync import create_lipsync_job - from app.services.mediakit_client import MediaKitError - - db = MagicMock() - svc = MagicMock() - svc.create_job.side_effect = MediaKitError("fail", code="InvalidInput") - ps = MagicMock() - ps.deduct_points.return_value = {"success": True, "balance": 99} - fs = MagicMock(points_enabled=True) - with ( - patch("app.api.routes.lipsync.PointsService", return_value=ps), - patch("app.api.routes.lipsync.settings", fs), - ): - with pytest.raises(HTTPException) as ei: - create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc) - assert ei.value.status_code == 400 - assert ps.refund_points.called - - def test_generic_exception_refunds(self, monkeypatch): - _do_enable(monkeypatch) - from app.api.routes.lipsync import create_lipsync_job - - db = MagicMock() - svc = MagicMock() - svc.create_job.side_effect = RuntimeError("boom") - ps = MagicMock() - ps.deduct_points.return_value = {"success": True, "balance": 99} - fs = MagicMock(points_enabled=True) - with ( - patch("app.api.routes.lipsync.PointsService", return_value=ps), - patch("app.api.routes.lipsync.settings", fs), - ): - with pytest.raises(HTTPException) as ei: - create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc) - assert ei.value.status_code == 400 - assert ps.refund_points.called - - def test_audio_duration_estimation(self, monkeypatch): - _do_enable(monkeypatch) - from app.api.routes.lipsync import create_lipsync_job - - from packages.domain.points_rules import calculate_points_cost db = MagicMock() svc = MagicMock() job = SimpleNamespace(id="job-1", status="queued") svc.create_job.return_value = job - ps = MagicMock() - ps.deduct_points.return_value = {"success": True, "balance": 99} - fs = MagicMock(points_enabled=True) - with ( - patch("app.api.routes.lipsync.PointsService", return_value=ps), - patch("app.api.routes.lipsync.settings", fs), - ): - create_lipsync_job( - body=_body(audio_url="http://x/a.mp3", audio_duration=180, script_text=None), - current_user=_cu(), - db=db, - svc=svc, - ) - # 180 seconds -> 3 minutes; assert deduct called with cost >= 15*3 - args = ps.deduct_points.call_args[0] - assert args[1] >= calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=3) + # 不再依赖 settings/PointsService patch + result = create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc) + assert result is job diff --git a/tests/unit/test_points_gate.py b/tests/unit/test_points_gate.py index 6fc8e913e..74845c774 100644 --- a/tests/unit/test_points_gate.py +++ b/tests/unit/test_points_gate.py @@ -44,7 +44,7 @@ class TestExtractKwargs: class TestPointsGateSync: def test_no_user_raises_401(self): - @points_gate("ai_rewrite") + @points_gate("voice_clone_synth") def my_func(db=None): return "ok" @@ -53,7 +53,7 @@ class TestPointsGateSync: assert exc_info.value.status_code == 401 def test_no_db_raises_500(self): - @points_gate("ai_rewrite") + @points_gate("voice_clone_synth") def my_func(current_user=None, db=None): return "ok" @@ -85,7 +85,8 @@ class TestPointsGateExecuteLogic: with patch("packages.domain.points_service.PointsService", return_value=mock_svc): with pytest.raises(HTTPException) as exc_info: _execute_with_gate( - my_func, (), {"current_user": cu, "db": db}, "ai_rewrite", None, None, None, is_async=False + my_func, (), {"current_user": cu, "db": db}, "voice_clone_synth", + per_unit=10, unit_field=None, quantity_field=None, is_async=False ) assert exc_info.value.status_code == 402 @@ -115,7 +116,7 @@ class TestPointsGateExecuteLogic: my_func, (), {"current_user": cu, "db": db}, - "ai_rewrite", + "voice_clone_synth", per_unit=10, unit_field=None, quantity_field=None, @@ -139,7 +140,7 @@ class TestPointsGateExecuteLogic: failing_func, (), {"current_user": cu, "db": db}, - "ai_rewrite", + "voice_clone_synth", per_unit=10, unit_field=None, quantity_field=None, @@ -147,21 +148,21 @@ class TestPointsGateExecuteLogic: ) mock_svc.refund_points.assert_called_once() - def test_ai_video_free_quota_for_free_user(self): + def test_retired_scene_passes_through_with_zero_deduction(self): + """已下线场景(如 ai_video/ai_rewrite/ai_voice 等)直接放行,不扣积分。""" cu = _make_current_user(is_member=False) db = MagicMock() - mock_svc = MagicMock() - mock_svc.check_daily_free_clip.return_value = True - mock_svc.record_daily_free_clip.return_value = True def my_func(current_user=cu, db=db, **kwargs): - return kwargs.get("_is_free_quota", False) + return kwargs.get("_points_deducted", -1) - with patch("packages.domain.points_service.PointsService", return_value=mock_svc): + # 不应调用 PointsService + with patch("packages.domain.points_service.PointsService") as mock_svc_cls: result = _execute_with_gate( my_func, (), {"current_user": cu, "db": db}, "ai_video", None, None, None, is_async=False ) - assert result is True + assert result == 0 + mock_svc_cls.assert_not_called() class TestPointsGateAsync: @@ -172,7 +173,7 @@ class TestPointsGateAsync: mock_svc = MagicMock() mock_svc.deduct_points.return_value = {"success": True, "balance": 90, "transaction_id": "t1"} - @points_gate("ai_rewrite", per_unit=5) + @points_gate("voice_clone_synth", per_unit=5) async def my_async_func(current_user=None, db=None, **kwargs): return kwargs.get("_points_deducted", 0) diff --git a/tests/unit/test_points_routes.py b/tests/unit/test_points_routes.py index 9acae94d5..83f3e5ed3 100644 --- a/tests/unit/test_points_routes.py +++ b/tests/unit/test_points_routes.py @@ -2,7 +2,7 @@ 覆盖: - P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at -- P0-2: POST /points/check 未知 scene_key 返回 400(非 500) +- P0-2: POST /points/check 任意 scene_key 均可查询(已下线场景返回 cost=0,不报错) - P1-3: GET /points/rules 返回 description 字段 - P1-6: GET /subscription/plans 返回档位列表 - P1-7: multiplier 实际扣费一致(calculate_points_cost 统一应用) @@ -76,39 +76,40 @@ class TestRechargeOrderResponse: assert exc.value.status_code == 400 -# ── P0-2: check unknown scene → 400 ─────────────────────────────────── +# ── P0-2: check 任意 scene_key(已下线场景返回 cost=0) ────────────────── class TestCheckPointsUnknownScene: - def test_unknown_scene_returns_400_not_500(self): - """未知 scene_key(如 ai_script)应返回 400 UNKNOWN_SCENE,而不是 500。""" + def test_unknown_scene_returns_zero_cost_not_error(self): + """任意 scene_key 均可查询,已下线/未知场景返回 cost=0(免费放行)。""" from app.api.routes.points import check_points from app.schemas.points import PointsCheckRequest + svc = MagicMock() + svc.get_or_create_account.return_value = {"balance": 0} db = MagicMock() cu = _make_cu() - body = PointsCheckRequest(scene_key="ai_script", quantity=1) - with pytest.raises(HTTPException) as exc: - check_points(body=body, current_user=cu, db=db) - assert exc.value.status_code == 400 - detail = exc.value.detail - assert detail["code"] == "UNKNOWN_SCENE" - assert "ai_script" in detail["message"] - assert "ai_voice" in detail["valid_scenes"] - assert "ai_title" in detail["valid_scenes"] + with ( + patch("app.api.routes.points._credits_enabled", return_value=True), + patch("app.api.routes.points._get_service", return_value=svc), + ): + for scene in ["ai_script", "ai_voice", "ai_video", "ai_title", "ai_cover", "nonexistent"]: + body = PointsCheckRequest(scene_key=scene, quantity=1) + resp = check_points(body=body, current_user=cu, db=db) + assert resp.required_points == 0, f"{scene} should be free" + assert resp.allowed is True - def test_known_scene_still_works(self): - """合法 scene_key 正常返回,免费用户 ai_voice 1 分钟 = 2 积分。""" + def test_voice_clone_synth_still_charges(self): + """合法付费场景 voice_clone_synth 正常计费:免费用户 1 分钟 = ceil(1*1.15)=2 积分。""" from app.api.routes.points import check_points from app.schemas.points import PointsCheckRequest svc = MagicMock() - svc.check_daily_free_clip.return_value = False svc.get_or_create_account.return_value = {"balance": 50} db = MagicMock() cu = _make_cu() - body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1) + body = PointsCheckRequest(scene_key="voice_clone_synth", quantity=1, duration_minutes=1) with ( patch("app.api.routes.points._credits_enabled", return_value=True), @@ -128,7 +129,7 @@ class TestPointsRulesDescription: from app.api.routes.points import get_rules resp = get_rules(_current_user=_make_cu()) - assert len(resp.rules) >= 9 + assert len(resp.rules) == 2 for rule in resp.rules: assert rule.description, f"{rule.scene_key} missing description" assert isinstance(rule.description, str) @@ -202,11 +203,18 @@ class TestSubscriptionPlans: class TestMultiplierConsistency: - def test_free_user_ai_title_costs_2(self): - """ai_title base=1,免费用户 ceil(1*1.15)=2。""" + def test_free_user_voice_clone_synth_1min_costs_2(self): + """voice_clone_synth base=1,免费用户 ceil(1*1.15)=2。""" from packages.domain.points_rules import calculate_points_cost - assert calculate_points_cost("ai_title", is_member=False, quantity=1) == 2 + assert calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=1) == 2 + + def test_retired_scenes_return_zero(self): + """已下线场景(ai_voice/ai_title/ai_cover/ai_rewrite 等)calculate_points_cost 统一返回 0。""" + from packages.domain.points_rules import calculate_points_cost + + for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite", "ai_video", "ai_digital_human"]: + assert calculate_points_cost(scene, is_member=False, quantity=1) == 0 def test_check_matches_direct_calculation(self): """check 端点 required_points 与 calculate_points_cost 结果一致。""" @@ -216,15 +224,14 @@ class TestMultiplierConsistency: from packages.domain.points_rules import calculate_points_cost svc = MagicMock() - svc.check_daily_free_clip.return_value = False svc.get_or_create_account.return_value = {"balance": 999} db = MagicMock() cu = _make_cu() with patch("app.api.routes.points._credits_enabled", return_value=True): - for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]: - body = PointsCheckRequest(scene_key=scene, quantity=1) + for scene in ["voice_clone_synth", "voice_clone_train", "ai_voice", "ai_video", "ai_title"]: + body = PointsCheckRequest(scene_key=scene, quantity=1, duration_minutes=1) with patch("app.api.routes.points._get_service", return_value=svc): resp = check_points(body=body, current_user=cu, db=db) - expected = calculate_points_cost(scene, is_member=False, quantity=1) + expected = calculate_points_cost(scene, is_member=False, quantity=1, duration_minutes=1) assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}" diff --git a/tests/unit/test_points_rules.py b/tests/unit/test_points_rules.py index 36e8b9d89..36b92bd51 100644 --- a/tests/unit/test_points_rules.py +++ b/tests/unit/test_points_rules.py @@ -1,4 +1,4 @@ -"""积分消耗规则单元测试 (#1895)""" +"""积分消耗规则单元测试 (#1895) — v1.6.2: 仅保留 voice_clone 相关""" from __future__ import annotations @@ -7,7 +7,6 @@ import math import pytest from packages.domain.points_rules import ( - DAILY_FREE_CLIP_LIMIT, FREE_USER_MULTIPLIER, MEMBER_DISCOUNT, MEMBERSHIP_PRICES, @@ -20,8 +19,9 @@ from packages.domain.points_rules import ( class TestPointsScenesConfig: """场景配置完整性""" - def test_all_nine_scenes_defined(self): - assert len(POINTS_SCENES) == 9 + def test_voice_clone_scenes_defined(self): + # 仅保留声音克隆两个场景 + assert set(POINTS_SCENES.keys()) == {"voice_clone_train", "voice_clone_synth"} def test_required_keys_present(self): for key, scene in POINTS_SCENES.items(): @@ -32,8 +32,9 @@ class TestPointsScenesConfig: def test_voice_clone_train_is_free(self): assert POINTS_SCENES["voice_clone_train"]["base_points"] == 0 - def test_ai_video_has_extra_per_30s(self): - assert POINTS_SCENES["ai_video"]["extra_per_30s"] == 1 + def test_voice_clone_synth_is_per_minute(self): + assert POINTS_SCENES["voice_clone_synth"]["base_points"] == 1 + assert POINTS_SCENES["voice_clone_synth"]["unit"] == "分钟" class TestPointsPackages: @@ -52,43 +53,23 @@ class TestMembershipPrices: assert MEMBERSHIP_PRICES["yearly"]["price_cents"] == 15900 -class TestDailyFreeLimit: - def test_limit_is_2(self): - assert DAILY_FREE_CLIP_LIMIT == 2 - - class TestCalculatePointsCost: """核心计费逻辑""" - # ── 按次计费 ── + # ── 声音克隆合成(按时长计费) ── - def test_per_time_base_cost(self): - # ai_rewrite: 1积分/次,免费用户 ceil(1 * 1.15) = 2 - cost = calculate_points_cost("ai_rewrite", is_member=False, quantity=1) + def test_voice_clone_synth_base(self): + cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=3) + assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) + + def test_voice_clone_synth_rounds_up(self): + cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=2.3) + assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) + + def test_voice_clone_synth_minimum_1_minute(self): + cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=0.1) assert cost == math.ceil(1 * FREE_USER_MULTIPLIER) - def test_per_time_multiple(self): - # ai_cover: 1积分/张,3张 → base=3, free: ceil(3*1.15)=4 - cost = calculate_points_cost("ai_cover", is_member=False, quantity=3) - assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) - - # ── 按时长计费 ── - - def test_per_minute_base(self): - # ai_voice: 1积分/分钟,3分钟 → base=3, free: ceil(3*1.15)=4 - cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=3) - assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) - - def test_per_minute_rounds_up(self): - # 2.3分钟 → ceil(2.3)=3分钟 → base=3 - cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2.3) - assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) - - def test_digital_human_expensive(self): - # ai_digital_human: 15积分/分钟,1分钟 → base=15, free: ceil(15*1.15)=18 - cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) - assert cost == 18 - # ── 免费场景 ── def test_voice_clone_train_free(self): @@ -99,42 +80,33 @@ class TestCalculatePointsCost: cost = calculate_points_cost("voice_clone_train", is_member=True) assert cost == 0 - # ── 混剪额外逻辑 ── - - def test_ai_video_short_no_extra(self): - # 20s (0.33min) ≤ 30s,不额外加积分,base=3, free: ceil(3*1.15)=4 - cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=0.33) - assert cost == math.ceil(3 * FREE_USER_MULTIPLIER) - - def test_ai_video_long_extra_charge(self): - # 80s → base=3 + extra ceil((80-30)/30)=2 → total_base=5, free: ceil(5*1.15)=6 - cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=80 / 60) - assert cost == math.ceil(5 * FREE_USER_MULTIPLIER) - # ── 会员折扣 ── def test_monthly_member_discount(self): - # ai_voice 1分钟 base=1, 月卡0.9 → floor(1*0.9)=1 → max(1,1)=1 - cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1, member_type="monthly") - assert cost == max(1, math.floor(1 * 0.9)) + cost = calculate_points_cost( + "voice_clone_synth", is_member=True, duration_minutes=1, member_type="monthly" + ) + assert cost == max(1, math.floor(1 * MEMBER_DISCOUNT["monthly"])) def test_yearly_member_deep_discount(self): - # ai_digital_human 2分钟 base=30, 年卡0.8 → floor(30*0.8)=24 cost = calculate_points_cost( - "ai_digital_human", + "voice_clone_synth", is_member=True, duration_minutes=2, member_type="yearly", ) - assert cost == max(1, math.floor(30 * 0.8)) + assert cost == max(1, math.floor(2 * MEMBER_DISCOUNT["yearly"])) def test_member_without_type_no_discount(self): - # is_member=True 但没传 member_type → 不按会员折扣 - cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1) - assert cost == 1 # base=1, no discount applied + cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1) + assert cost == 1 - # ── 异常 ── + # ── 已下线/未知场景(向后兼容:返回 0) ── - def test_unknown_scene_raises(self): - with pytest.raises(ValueError, match="Unknown points scene"): - calculate_points_cost("nonexistent_scene", is_member=False) + @pytest.mark.parametrize( + "scene", + ["ai_voice", "ai_video", "ai_digital_human", "ai_rewrite", "ai_cover", "ai_title", "douyin_extract", "nonexistent"], + ) + def test_retired_scenes_return_zero(self, scene): + assert calculate_points_cost(scene, is_member=False) == 0 + assert calculate_points_cost(scene, is_member=True, duration_minutes=10) == 0 diff --git a/tests/unit/test_points_service.py b/tests/unit/test_points_service.py index 33b6816be..3d744801d 100644 --- a/tests/unit/test_points_service.py +++ b/tests/unit/test_points_service.py @@ -72,7 +72,7 @@ class TestCheckBalance: class TestDeductPoints: def test_deduct_fails_insufficient_balance(self, service, db_session, user_id): - result = service.deduct_points(user_id, 100, "ai_voice", db_session) + result = service.deduct_points(user_id, 100, "voice_clone_synth", db_session) assert result["success"] is False assert result["transaction_id"] is None @@ -80,13 +80,13 @@ class TestDeductPoints: # 先充值 service.add_points(user_id, 50, "recharge", db_session) # 再扣减 - result = service.deduct_points(user_id, 20, "ai_voice", db_session) + result = service.deduct_points(user_id, 20, "voice_clone_synth", db_session) assert result["success"] is True assert result["balance"] == 30 def test_deduct_creates_transaction(self, service, db_session, user_id): service.add_points(user_id, 100, "recharge", db_session) - result = service.deduct_points(user_id, 30, "ai_voice", db_session) + result = service.deduct_points(user_id, 30, "voice_clone_synth", db_session) assert result["success"] is True txns = service.get_transactions(user_id, db_session) @@ -111,14 +111,14 @@ class TestAddPoints: class TestRefundPoints: def test_refund_adds_back(self, service, db_session, user_id): service.add_points(user_id, 100, "recharge", db_session) - service.deduct_points(user_id, 20, "ai_voice", db_session) - result = service.refund_points(user_id, 20, "ai_voice", db_session) + service.deduct_points(user_id, 20, "voice_clone_synth", db_session) + result = service.refund_points(user_id, 20, "voice_clone_synth", db_session) assert result["success"] is True assert result["balance"] == 100 def test_refund_creates_refund_transaction(self, service, db_session, user_id): service.add_points(user_id, 100, "recharge", db_session) - service.refund_points(user_id, 10, "ai_rewrite", db_session) + service.refund_points(user_id, 10, "voice_clone_synth", db_session) txns = service.get_transactions(user_id, db_session) refund_txns = [t for t in txns["items"] if t["type"] == "add" and "refund" in t["source"]] @@ -145,20 +145,14 @@ class TestGetTransactions: class TestGetDailyUsage: - def test_zero_usage(self, service, db_session, user_id): - with patch("packages.domain.points_service._get_redis_client", return_value=None): - result = service.get_daily_usage(user_id, db_session) - assert result["free_clips_used"] == 0 - assert result["free_clips_limit"] == 2 - assert result["free_clips_remaining"] == 2 - assert "reset_at" in result + """智能混剪已免费,get_daily_usage 返回 unlimited(-1)占位。""" - def test_after_recording(self, service, db_session, user_id): - with patch("packages.domain.points_service._get_redis_client", return_value=None): - service.record_daily_free_clip(user_id, db_session) - result = service.get_daily_usage(user_id, db_session) - assert result["free_clips_used"] == 1 - assert result["free_clips_remaining"] == 1 + def test_returns_unlimited(self, service, db_session, user_id): + result = service.get_daily_usage(user_id, db_session) + assert result["free_clips_used"] == 0 + assert result["free_clips_limit"] == -1 # -1 表示 unlimited + assert result["free_clips_remaining"] == -1 + assert "reset_at" in result class TestCreateOrder: diff --git a/tests/unit/test_scripts_ai_points.py b/tests/unit/test_scripts_ai_points.py index 70967fafa..0e0278779 100644 --- a/tests/unit/test_scripts_ai_points.py +++ b/tests/unit/test_scripts_ai_points.py @@ -1,76 +1,30 @@ -"""scripts_ai 积分扣点单元测试 (#1895 P2 step 2.3)""" +"""scripts_ai (抖音解析/改写/标题) — v1.6.2 起全部免费,不扣积分""" from __future__ import annotations -from unittest.mock import MagicMock, patch -import pytest -from fastapi import HTTPException +class TestScriptsAiFree: + """三个端点都已移除 @points_gate,不再扣点。""" -import packages.middleware.points_gate as _pg_module + def test_all_scenes_return_zero_cost(self): + from packages.domain.points_rules import calculate_points_cost + for scene in ("douyin_extract", "ai_rewrite", "ai_title"): + assert calculate_points_cost(scene, is_member=False) == 0 + assert calculate_points_cost(scene, is_member=True) == 0 -def _make_cu(user_id="u1", 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 - - -@pytest.fixture(autouse=True) -def _enable_gate(monkeypatch): - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True) - yield - - -class TestScriptsAiPointsGate: - """测试 scripts_ai 三个端点都挂了 @points_gate 并正确扣费。""" - - @pytest.mark.parametrize( - "scene,endpoint_fn_name", - [ - ("douyin_extract", "extract_from_douyin"), - ("ai_rewrite", "ai_rewrite"), - ("ai_title", "ai_generate_titles"), - ], - ) - def test_insufficient_points_raises_402(self, scene, endpoint_fn_name): - """积分不足时抛 402。""" + def test_no_points_gate_decorators(self): from app.api.routes import scripts_ai - from app.schemas.scripts_ai import ( - AiGenerateTitlesRequest, - AiRewriteRequest, - ExtractFromDouyinRequest, - ) - fn = getattr(scripts_ai, endpoint_fn_name) - db = MagicMock() - cu = _make_cu() - if scene == "douyin_extract": - req = ExtractFromDouyinRequest(url="https://v.douyin.com/abc/") - elif scene == "ai_rewrite": - req = AiRewriteRequest(content="测试文案") - else: - req = AiGenerateTitlesRequest(content="测试文案", count=3) + for fn_name in ("extract_from_douyin", "ai_rewrite", "ai_generate_titles"): + fn = getattr(scripts_ai, fn_name) + assert not hasattr(fn, "__wrapped__"), f"{fn_name} still has @points_gate" - with patch("packages.domain.points_service.PointsService") as MockSvc: - svc = MagicMock() - svc.deduct_points.return_value = {"success": False, "balance": 0} - MockSvc.return_value = svc - with pytest.raises(HTTPException) as ei: - fn(request=req, current_user=cu, db=db) - assert ei.value.status_code == 402 - - def test_disabled_passthrough_no_user_error(self, monkeypatch): - """关闭时不需要 user/db 也能被装饰器透传(验证 gate 关闭零副作用)。""" + def test_module_no_points_imports(self): from app.api.routes import scripts_ai - from app.schemas.scripts_ai import AiRewriteRequest + import inspect - monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: False) - fn = scripts_ai.ai_rewrite - # 不带 db/current_user 也应透传(后续业务逻辑可能报错但不是 401/500 gate 错误) - with pytest.raises(Exception) as ei: - fn(request=AiRewriteRequest(content="x"), current_user=None, db=None) - # 不应是 gate 抛的 401/500 - assert isinstance(ei.value, AttributeError) or ei.value.status_code not in (401, 500) + src = inspect.getsource(scripts_ai) + assert "PointsService" not in src + assert "points_gate" not in src + assert "calculate_points_cost" not in src diff --git a/tests/unit/test_tts_voice_clone_points.py b/tests/unit/test_tts_voice_clone_points.py index 8e5b65c59..efb93e11a 100644 --- a/tests/unit/test_tts_voice_clone_points.py +++ b/tests/unit/test_tts_voice_clone_points.py @@ -1,6 +1,6 @@ -"""TTS + voice_clone 积分扣点单元测试 (#1895 P2 step 2.1) +"""TTS (免费) + voice_clone 预览 (扣点) 单测 (#1895 P2 step 2.1) -覆盖 synthesize / voice_clone preview 在积分开关下的扣点、余额不足、失败退费、会员折扣等分支。 +v1.6.2: TTS 合成/预览(ai_voice)已免费,不再扣点;voice_clone 预览(voice_clone_synth)仍保持 1积分/分钟扣点。 """ from __future__ import annotations @@ -44,21 +44,10 @@ def _make_request(text="你好世界", voice_id="v1", **kw): return r -def _est_minutes(chars: int) -> float: - return max(1.0, math.ceil(chars / 240)) +class TestTtsSynthesizeFree: + """TTS synthesize/preview 已移除手动扣点,不再实例化 PointsService。""" - -class TestEstimateMinutes: - @pytest.mark.parametrize( - "chars,expected", - [(1, 1.0), (240, 1.0), (241, 2.0), (480, 2.0), (481, 3.0), (1000, 5.0)], - ) - def test_estimate(self, chars, expected): - assert _est_minutes(chars) == expected - - -class TestTtsSynthesizePointsDeduction: - def _setup(self, text="你好", deduct_success=True, balance=0, start_synth_raises=None, send_task_raises=None): + def _setup(self, start_synth_raises=None): db = MagicMock() cu = _make_cu() repo = MagicMock() @@ -77,43 +66,26 @@ class TestTtsSynthesizePointsDeduction: wf.start_synthesis.side_effect = start_synth_raises vc_repo = MagicMock() vc_repo.get.return_value = None - svc = MagicMock() - svc.deduct_points.return_value = {"success": deduct_success, "balance": balance} - fake_settings = MagicMock(points_enabled=True) - return db, cu, repo, uc, wf, vc_repo, svc, fake_settings, job + return db, cu, repo, uc, wf, vc_repo, job - def test_insufficient_raises_402(self): - db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(text="你好" * 200, deduct_success=False, balance=0) + def test_module_has_no_points_imports(self): + from app.api.routes import tts as mod + import inspect + + src = inspect.getsource(mod) + assert "PointsService" not in src + assert "calculate_points_cost" not in src + assert "_points_deducted" not in src + assert "import math" not in src + + def test_success_returns_job_without_points(self): + db, cu, repo, uc, wf, vc_repo, job = self._setup() from app.api.routes.tts import synthesize with ( patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc), patch("app.api.routes.tts.TTSWorkflowService", return_value=wf), - patch("app.api.routes.tts.PointsService", return_value=svc), - patch("app.api.routes.tts.settings", fs), - ): - with pytest.raises(HTTPException) as ei: - synthesize( - request=_make_request(text="你好" * 200), - authenticated_user=cu, - db=db, - repository=repo, - cosyvoice_service=MagicMock(), - voice_clone_repo=vc_repo, - ) - assert ei.value.status_code == 402 - assert ei.value.detail["code"] == "INSUFFICIENT_POINTS" - - def test_success_deducts_points(self): - db, cu, repo, uc, wf, vc_repo, svc, fs, job = self._setup() - from app.api.routes.tts import synthesize - - with ( - patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc), - patch("app.api.routes.tts.TTSWorkflowService", return_value=wf), - patch("app.api.routes.tts.PointsService", return_value=svc), - patch("app.api.routes.tts.celery_app.send_task") as _st, - patch("app.api.routes.tts.settings", fs), + patch("app.api.routes.tts.celery_app.send_task"), ): resp = synthesize( request=_make_request(text="测试"), @@ -123,60 +95,17 @@ class TestTtsSynthesizePointsDeduction: cosyvoice_service=MagicMock(), voice_clone_repo=vc_repo, ) - svc.deduct_points.assert_called_once() assert resp.job_id == job.id - def test_synthesis_failure_refunds(self): - db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(start_synth_raises=RuntimeError("boom")) - from app.api.routes.tts import synthesize - - with ( - patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc), - patch("app.api.routes.tts.TTSWorkflowService", return_value=wf), - patch("app.api.routes.tts.PointsService", return_value=svc), - patch("app.api.routes.tts.celery_app.send_task"), - patch("app.api.routes.tts.settings", fs), - ): - synthesize( - request=_make_request(text="测试"), - authenticated_user=cu, - db=db, - repository=repo, - cosyvoice_service=MagicMock(), - voice_clone_repo=vc_repo, - ) - assert svc.refund_points.called - - def test_celery_send_failure_refunds(self): - db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(send_task_raises=RuntimeError("celery down")) - from app.api.routes.tts import synthesize - - with ( - patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc), - patch("app.api.routes.tts.TTSWorkflowService", return_value=wf), - patch("app.api.routes.tts.PointsService", return_value=svc), - patch("app.api.routes.tts.celery_app.send_task", side_effect=RuntimeError("celery down")), - patch("app.api.routes.tts.settings", fs), - ): - synthesize( - request=_make_request(text="测试"), - authenticated_user=cu, - db=db, - repository=repo, - cosyvoice_service=MagicMock(), - voice_clone_repo=vc_repo, - ) - assert svc.refund_points.called - - def test_member_cheaper(self): + def test_ai_voice_cost_zero(self): from packages.domain.points_rules import calculate_points_cost - cf = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2) - cm = calculate_points_cost("ai_voice", is_member=True, member_type="monthly", duration_minutes=2) - assert cm < cf + assert calculate_points_cost("ai_voice", is_member=False, duration_minutes=10) == 0 class TestVoiceClonePreviewPoints: + """voice_clone 预览(voice_clone_synth)保持 1 积分/分钟扣点。""" + def _setup(self, text="你好", deduct_success=True, balance=0, synth_raises=None): db = MagicMock() cu = _make_cu() @@ -265,3 +194,10 @@ class TestVoiceClonePreviewPoints: ) svc.deduct_points.assert_called_once() assert resp.audio_url.startswith("http") + + def test_member_cheaper(self): + from packages.domain.points_rules import calculate_points_cost + + cf = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=2) + cm = calculate_points_cost("voice_clone_synth", is_member=True, member_type="monthly", duration_minutes=2) + assert cm < cf -- 2.54.0 From 7a63905a1c2c6f8380c2898e138a6809ec19929f Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 2 Oct 2026 12:57:10 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + ruff + prettier [skip ci-format-check] --- apps/api/app/api/routes/ai_avatar_render.py | 1 - apps/api/app/api/routes/lipsync.py | 1 - apps/api/app/api/routes/tts.py | 3 --- tests/unit/test_1970_narrative_service.py | 1 + tests/unit/test_ai_avatar_render_points.py | 3 ++- tests/unit/test_credit_system_switch.py | 6 +++-- tests/unit/test_generation_cover_points.py | 3 ++- tests/unit/test_generation_preview_points.py | 4 +++- tests/unit/test_generation_tasks_points.py | 3 ++- tests/unit/test_lipsync_points.py | 23 +++++++++++++++----- tests/unit/test_points_gate.py | 10 +++++++-- tests/unit/test_points_rules.py | 15 +++++++++---- tests/unit/test_scripts_ai_points.py | 3 ++- tests/unit/test_tts_voice_clone_points.py | 3 ++- 14 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index 9afe70b1f..aa798f57d 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -29,7 +29,6 @@ from app.services.ai_avatar_render_service import ( from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session - logger = logging.getLogger(__name__) router = APIRouter() diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index d3304e0d9..39de56873 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -30,7 +30,6 @@ from app.services.mediakit_client import MediaKitError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.orm import Session - logger = logging.getLogger(__name__) router = APIRouter() diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index c2476a263..48f96ce0d 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -202,7 +202,6 @@ def synthesize( cosyvoice_service=cosyvoice_service, ) - synthesis_error: Exception | None = None try: job = workflow.start_synthesis(job.id) except Exception as e: @@ -210,7 +209,6 @@ 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: @@ -567,7 +565,6 @@ def preview_tts( 用于前端预览配音效果,限制文本长度 200 字以内。 支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。 """ - user_id = authenticated_user.user.id # 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_id) diff --git a/tests/unit/test_1970_narrative_service.py b/tests/unit/test_1970_narrative_service.py index d0a496676..acd69e11b 100644 --- a/tests/unit/test_1970_narrative_service.py +++ b/tests/unit/test_1970_narrative_service.py @@ -402,6 +402,7 @@ class TestPrepareNarrativeVoice: def test_module_has_no_points_imports(self): """模块源码不再包含扣点相关符号。""" import inspect + src = inspect.getsource(ns) assert "PointsService" not in src assert "calculate_points_cost" not in src diff --git a/tests/unit/test_ai_avatar_render_points.py b/tests/unit/test_ai_avatar_render_points.py index b1566916c..f2cfae041 100644 --- a/tests/unit/test_ai_avatar_render_points.py +++ b/tests/unit/test_ai_avatar_render_points.py @@ -16,9 +16,10 @@ class TestAiAvatarRenderFree: assert not hasattr(create_render_job, "__wrapped__") def test_module_has_no_points_imports(self): - from app.api.routes import ai_avatar_render as mod import inspect + from app.api.routes import ai_avatar_render as mod + src = inspect.getsource(mod) assert "PointsService" not in src assert "points_gate" not in src diff --git a/tests/unit/test_credit_system_switch.py b/tests/unit/test_credit_system_switch.py index 216c5475e..1ae966351 100644 --- a/tests/unit/test_credit_system_switch.py +++ b/tests/unit/test_credit_system_switch.py @@ -240,9 +240,10 @@ class TestQueryEndpointsRemainAvailable: class TestBusinessRoutesBypassWhenDisabled: def test_lipsync_route_has_no_points_logic(self): """lipsync 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。""" - from app.api.routes import lipsync as lipsync_mod import inspect + from app.api.routes import lipsync as lipsync_mod + src = inspect.getsource(lipsync_mod) assert "PointsService" not in src assert "calculate_points_cost" not in src @@ -250,9 +251,10 @@ class TestBusinessRoutesBypassWhenDisabled: def test_tts_route_has_no_points_logic(self): """tts 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。""" - from app.api.routes import tts as tts_mod import inspect + from app.api.routes import tts as tts_mod + src = inspect.getsource(tts_mod) assert "PointsService" not in src assert "calculate_points_cost" not in src diff --git a/tests/unit/test_generation_cover_points.py b/tests/unit/test_generation_cover_points.py index 2bcf82a3b..3c148b27f 100644 --- a/tests/unit/test_generation_cover_points.py +++ b/tests/unit/test_generation_cover_points.py @@ -16,9 +16,10 @@ class TestGenerationCoverFree: assert not hasattr(generate_cover, "__wrapped__") def test_endpoint_has_no_points_logic(self): - from app.api.routes import generation_cover as mod import inspect + from app.api.routes import generation_cover as mod + src = inspect.getsource(mod) assert "PointsService" not in src assert "deduct_points" not in src diff --git a/tests/unit/test_generation_preview_points.py b/tests/unit/test_generation_preview_points.py index e8037ff0f..b16d87dee 100644 --- a/tests/unit/test_generation_preview_points.py +++ b/tests/unit/test_generation_preview_points.py @@ -15,14 +15,16 @@ class TestGenerationPreviewFree: def test_no_points_gate_decorator(self): """预览生成路由已移除 @points_gate。""" from app.api.routes.generation_preview import create_preview_generation_task + # 移除装饰器后 __wrapped__ 不再存在 assert not hasattr(create_preview_generation_task, "__wrapped__") def test_endpoint_does_not_deduct_points(self): """端点不再实例化 PointsService / 调用 deduct_points(直接走业务逻辑)。""" - from app.api.routes.generation_preview import create_preview_generation_task import inspect + from app.api.routes.generation_preview import create_preview_generation_task + src = inspect.getsource(create_preview_generation_task) assert "PointsService" not in src assert "deduct_points" not in src diff --git a/tests/unit/test_generation_tasks_points.py b/tests/unit/test_generation_tasks_points.py index cf685f4ec..d4d08f3b1 100644 --- a/tests/unit/test_generation_tasks_points.py +++ b/tests/unit/test_generation_tasks_points.py @@ -19,9 +19,10 @@ class TestGenerationTasksFree: def test_create_task_accepts_request_without_points_block(self): """路由函数签名不再做扣点,但参数 points_enabled/is_member/member_type 仍保留以兼容调用方。""" - from app.api.routes.generation_tasks import create_generation_task import inspect + from app.api.routes.generation_tasks import create_generation_task + sig = inspect.signature(create_generation_task) # 函数存在 assert callable(create_generation_task) diff --git a/tests/unit/test_lipsync_points.py b/tests/unit/test_lipsync_points.py index d784f0b38..7c932dd7c 100644 --- a/tests/unit/test_lipsync_points.py +++ b/tests/unit/test_lipsync_points.py @@ -48,9 +48,10 @@ class TestLipsyncFree: assert calculate_points_cost("ai_digital_human", is_member=True, duration_minutes=10) == 0 def test_module_has_no_points_imports(self): - from app.api.routes import lipsync as mod import inspect + from app.api.routes import lipsync as mod + src = inspect.getsource(mod) assert "PointsService" not in src assert "calculate_points_cost" not in src @@ -59,23 +60,35 @@ class TestLipsyncFree: def test_docstring_at_top_of_create_lipsync_job(self): """扣点块删除后,docstring 必须在函数体第一行(防止函数体中段 docstring 丢失)。""" + import ast + import inspect + from app.api.routes.lipsync import create_lipsync_job - import ast, inspect src = inspect.getsource(create_lipsync_job) tree = ast.parse(src) fn = tree.body[0] # docstring 应为函数体第一条语句 - assert isinstance(fn.body[0], ast.Expr) and isinstance(fn.body[0].value, ast.Constant) and isinstance(fn.body[0].value.value, str), "create_lipsync_job docstring 不在函数体开头" + assert ( + isinstance(fn.body[0], ast.Expr) + and isinstance(fn.body[0].value, ast.Constant) + and isinstance(fn.body[0].value.value, str) + ), "create_lipsync_job docstring 不在函数体开头" def test_docstring_at_top_of_preview_tts(self): + import ast + import inspect + from app.api.routes.lipsync import preview_tts - import ast, inspect src = inspect.getsource(preview_tts) tree = ast.parse(src) fn = tree.body[0] - assert isinstance(fn.body[0], ast.Expr) and isinstance(fn.body[0].value, ast.Constant) and isinstance(fn.body[0].value.value, str), "preview_tts docstring 不在函数体开头" + assert ( + isinstance(fn.body[0], ast.Expr) + and isinstance(fn.body[0].value, ast.Constant) + and isinstance(fn.body[0].value.value, str) + ), "preview_tts docstring 不在函数体开头" def test_value_error_still_raises_400(self): """业务异常仍抛 400(不再退费)。""" diff --git a/tests/unit/test_points_gate.py b/tests/unit/test_points_gate.py index 74845c774..4a9821311 100644 --- a/tests/unit/test_points_gate.py +++ b/tests/unit/test_points_gate.py @@ -85,8 +85,14 @@ class TestPointsGateExecuteLogic: with patch("packages.domain.points_service.PointsService", return_value=mock_svc): with pytest.raises(HTTPException) as exc_info: _execute_with_gate( - my_func, (), {"current_user": cu, "db": db}, "voice_clone_synth", - per_unit=10, unit_field=None, quantity_field=None, is_async=False + my_func, + (), + {"current_user": cu, "db": db}, + "voice_clone_synth", + per_unit=10, + unit_field=None, + quantity_field=None, + is_async=False, ) assert exc_info.value.status_code == 402 diff --git a/tests/unit/test_points_rules.py b/tests/unit/test_points_rules.py index 36b92bd51..17294bfc1 100644 --- a/tests/unit/test_points_rules.py +++ b/tests/unit/test_points_rules.py @@ -83,9 +83,7 @@ class TestCalculatePointsCost: # ── 会员折扣 ── def test_monthly_member_discount(self): - cost = calculate_points_cost( - "voice_clone_synth", is_member=True, duration_minutes=1, member_type="monthly" - ) + cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1, member_type="monthly") assert cost == max(1, math.floor(1 * MEMBER_DISCOUNT["monthly"])) def test_yearly_member_deep_discount(self): @@ -105,7 +103,16 @@ class TestCalculatePointsCost: @pytest.mark.parametrize( "scene", - ["ai_voice", "ai_video", "ai_digital_human", "ai_rewrite", "ai_cover", "ai_title", "douyin_extract", "nonexistent"], + [ + "ai_voice", + "ai_video", + "ai_digital_human", + "ai_rewrite", + "ai_cover", + "ai_title", + "douyin_extract", + "nonexistent", + ], ) def test_retired_scenes_return_zero(self, scene): assert calculate_points_cost(scene, is_member=False) == 0 diff --git a/tests/unit/test_scripts_ai_points.py b/tests/unit/test_scripts_ai_points.py index 0e0278779..5ceba996d 100644 --- a/tests/unit/test_scripts_ai_points.py +++ b/tests/unit/test_scripts_ai_points.py @@ -21,9 +21,10 @@ class TestScriptsAiFree: assert not hasattr(fn, "__wrapped__"), f"{fn_name} still has @points_gate" def test_module_no_points_imports(self): - from app.api.routes import scripts_ai import inspect + from app.api.routes import scripts_ai + src = inspect.getsource(scripts_ai) assert "PointsService" not in src assert "points_gate" not in src diff --git a/tests/unit/test_tts_voice_clone_points.py b/tests/unit/test_tts_voice_clone_points.py index efb93e11a..6756f23d6 100644 --- a/tests/unit/test_tts_voice_clone_points.py +++ b/tests/unit/test_tts_voice_clone_points.py @@ -69,9 +69,10 @@ class TestTtsSynthesizeFree: return db, cu, repo, uc, wf, vc_repo, job def test_module_has_no_points_imports(self): - from app.api.routes import tts as mod import inspect + from app.api.routes import tts as mod + src = inspect.getsource(mod) assert "PointsService" not in src assert "calculate_points_cost" not in src -- 2.54.0