Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 774845bf91 | |||
| 7a63905a1c | |||
| 4a4b8f4a05 | |||
| d8e995afff |
@@ -29,8 +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__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -44,7 +42,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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,9 +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__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -61,37 +56,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 +65,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 +84,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 +101,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 +119,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 +138,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 +152,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}",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -231,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:
|
||||
@@ -239,18 +209,10 @@ def synthesize(
|
||||
# 但 DB 异常、网络异常等意外错误可能逃逸。
|
||||
# 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。
|
||||
logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True)
|
||||
synthesis_error = e
|
||||
try:
|
||||
job = workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}")
|
||||
# 合成失败且已扣积分 → 退费
|
||||
if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None:
|
||||
try:
|
||||
_points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id)
|
||||
except Exception as refund_err:
|
||||
logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}")
|
||||
|
||||
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
|
||||
if job.status.value == "processing":
|
||||
# 分段合成任务 vs 普通单段任务
|
||||
@@ -269,13 +231,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,
|
||||
@@ -610,31 +565,6 @@ def preview_tts(
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
# ── 积分扣点(#1895 P2) ──
|
||||
_points_deducted = 0
|
||||
_points_scene = "ai_voice"
|
||||
_points_svc = PointsService() if settings.points_enabled else None
|
||||
if _points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(request.text) / 240))
|
||||
_points_deducted = calculate_points_cost(
|
||||
_points_scene,
|
||||
is_member=getattr(authenticated_user.user, "is_member", False),
|
||||
duration_minutes=est_minutes,
|
||||
member_type=getattr(authenticated_user.user, "member_type", None),
|
||||
)
|
||||
_deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db)
|
||||
if not _deduct_res["success"]:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}",
|
||||
"required": _points_deducted,
|
||||
"balance": _deduct_res["balance"],
|
||||
},
|
||||
)
|
||||
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
@@ -664,12 +594,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2036,7 +2036,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<SoundOutlined style={{ marginRight: 4 }} />
|
||||
预计消耗
|
||||
</span>
|
||||
<strong>{task.duration >= 45 ? 80 : task.duration >= 30 ? 60 : 40} 积分</strong>
|
||||
<strong>50 积分</strong>
|
||||
</div>
|
||||
|
||||
{/* 视频预览区 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,22 @@ 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 = {}
|
||||
|
||||
@@ -1,48 +1,25 @@
|
||||
"""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):
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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,24 @@ 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)。"""
|
||||
import inspect
|
||||
|
||||
from app.api.routes import lipsync as lipsync_mod
|
||||
|
||||
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_has_no_points_logic(self):
|
||||
"""tts 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。"""
|
||||
import inspect
|
||||
|
||||
def test_tts_route_skips_points(self):
|
||||
from app.api.routes import tts as tts_mod
|
||||
|
||||
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
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"""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):
|
||||
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
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
"""视频预览生成 积分扣点单元测试 (#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):
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
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_no_points_gate_decorator(self):
|
||||
"""预览生成路由已移除 @points_gate。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
assert hasattr(create_preview_generation_task, "__wrapped__"), "missing @points_gate"
|
||||
# 移除装饰器后 __wrapped__ 不再存在
|
||||
assert not hasattr(create_preview_generation_task, "__wrapped__")
|
||||
|
||||
def test_endpoint_does_not_deduct_points(self):
|
||||
"""端点不再实例化 PointsService / 调用 deduct_points(直接走业务逻辑)。"""
|
||||
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
|
||||
assert "calculate_points_cost" not in src
|
||||
|
||||
@@ -1,63 +1,28 @@
|
||||
"""视频生成 积分扣点单元测试 (#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 仍保留以兼容调用方。"""
|
||||
import inspect
|
||||
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
sig = inspect.signature(create_generation_task)
|
||||
# 函数存在
|
||||
assert callable(create_generation_task)
|
||||
|
||||
@@ -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,76 @@ 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
|
||||
|
||||
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):
|
||||
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
|
||||
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 丢失)。"""
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
class TestLipsyncEndpointPoints:
|
||||
def test_insufficient_raises_402(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
|
||||
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):
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
from app.api.routes.lipsync import preview_tts
|
||||
|
||||
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
|
||||
|
||||
@@ -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,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}, "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 +122,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 +146,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 +154,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 +179,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)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,40 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,76 +1,31 @@
|
||||
"""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_module_no_points_imports(self):
|
||||
import inspect
|
||||
|
||||
def test_disabled_passthrough_no_user_error(self, monkeypatch):
|
||||
"""关闭时不需要 user/db 也能被装饰器透传(验证 gate 关闭零副作用)。"""
|
||||
from app.api.routes import scripts_ai
|
||||
from app.schemas.scripts_ai import AiRewriteRequest
|
||||
|
||||
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
|
||||
|
||||
@@ -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,27 @@ 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):
|
||||
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
|
||||
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 +96,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 +195,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
|
||||
|
||||
Reference in New Issue
Block a user