feat(1895): 暂停积分系统 ENABLE_CREDIT_SYSTEM=false(保留全部代码/表/接口) #1996

Merged
auto-approve-bot merged 1 commits from feat/1895-disable-credit-system into develop 2026-09-20 01:39:03 +08:00
5 changed files with 322 additions and 27 deletions
+7 -4
View File
@@ -198,10 +198,13 @@ DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# ==================== 积分/会员系统 (#1895) ====================
# 积分扣点总开关:默认 false(对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
# 积分系统总开关:默认 false(暂停积分系统)。
# - false:生成视频/口型同步/数字人/AI标题/TTS/克隆音色等所有功能对登录
# 用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员状态查询接口
# 保留可用,但数据不再变动。积分相关的表、代码、接口均保留不删除。
# - 恢复积分:设置 ENABLE_CREDIT_SYSTEM=true 即可,无需改代码。
ENABLE_CREDIT_SYSTEM=false
# 旧开关名(兼容别名):与 ENABLE_CREDIT_SYSTEM 任一为 true 即启用。
POINTS_ENABLED=false
# ==================== 抖音解析多源轮询 (#1963) ====================
+33 -6
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import get_db_session
from app.schemas.points import (
DailyUsageResponse,
@@ -44,6 +45,12 @@ from packages.domain.points_service import PointsService
logger = logging.getLogger(__name__)
def _credits_enabled() -> bool:
"""积分系统总开关(ENABLE_CREDIT_SYSTEM),关闭时全部功能免费放行。"""
return bool(getattr(settings, "points_enabled", False))
# ── 两个 router ──
points_router = APIRouter()
usage_router = APIRouter()
@@ -172,6 +179,19 @@ def check_points(
"valid_scenes": sorted(POINTS_SCENES.keys()),
},
)
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
if not _credits_enabled():
svc = _get_service()
account = svc.get_or_create_account(current_user.user.id, db)
return PointsCheckResponse(
allowed=True,
required_points=0,
current_balance=account["balance"],
remaining_after=account["balance"],
is_free_quota=False,
)
is_mem = _is_member(current_user)
mt = _member_type(current_user)
@@ -209,8 +229,19 @@ def deduct_points(
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""积分扣减(内部服务调用)。"""
"""积分扣减(内部服务调用)。
积分系统暂停(ENABLE_CREDIT_SYSTEM=false)时为 no-op:不扣分、余额不变,
直接返回成功,保证内部调用方拿到 success=True 继续业务流程。
"""
svc = _get_service()
if not _credits_enabled():
account = svc.get_or_create_account(current_user.user.id, db)
return SimpleMessageResponse(
success=True,
message="积分系统已暂停,未扣减积分",
data={"transaction_id": "", "balance": account["balance"]},
)
result = svc.deduct_points(
user_id=current_user.user.id,
amount=body.amount,
@@ -243,11 +274,7 @@ def refund_points(
"""积分退还(内部服务调用)。"""
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
txn = (
db.query(PointsTransactionModel)
.filter(PointsTransactionModel.id == body.transaction_id)
.first()
)
txn = db.query(PointsTransactionModel).filter(PointsTransactionModel.id == body.transaction_id).first()
if txn is None:
raise HTTPException(status_code=404, detail="交易记录不存在")
if txn.user_id != current_user.user.id:
+28 -3
View File
@@ -8,6 +8,7 @@ API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段
import os
from typing import Optional, TypeVar
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
T = TypeVar("T", bound=BaseSettings)
@@ -76,9 +77,33 @@ class SharedSettings(BaseSettings):
mediakit_timeout: int = 60
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
# 总开关:默认 false(对所有用户零影响),P2 路由逐个接入时用
# `if settings.points_enabled:` 包裹,防止未完善的扣点逻辑影响现有用户。
points_enabled: bool = False
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
# - false(默认):所有 AI 功能(生成视频/口型/数字人/AI标题/TTS/克隆音色…)
# 对全部登录用户免费放行,不扣积分、不做余额拦截;积分余额/流水/会员
# 状态等查询接口保持可用,但数据不再变动。
# - 未来恢复:只需设置环境变量 ENABLE_CREDIT_SYSTEM=true。
# 旧开关 POINTS_ENABLED 仍保留作为兼容别名(两者任一为 true 即启用)。
# 主开关(推荐环境变量名 ENABLE_CREDIT_SYSTEM)
credits_enabled: bool = Field(
default=False,
validation_alias=AliasChoices("ENABLE_CREDIT_SYSTEM", "credits_enabled"),
)
# 旧开关兼容(POINTS_ENABLED);两者任一为 true 即启用
points_enabled_compat: bool = Field(
default=False,
validation_alias=AliasChoices("POINTS_ENABLED", "points_enabled_compat"),
)
@property
def points_enabled(self) -> bool:
"""旧代码/测试使用的属性名,等价于积分系统总开关(兼容别名)。"""
return bool(self.credits_enabled or self.points_enabled_compat)
@points_enabled.setter
def points_enabled(self, value: bool) -> None:
# 支持旧测试/代码 ``settings.points_enabled = True`` 的写法
self.credits_enabled = bool(value)
self.points_enabled_compat = False
# ── GPU MuseTalk 反向轮询 Worker ────────────────────────────────────
# Worker 用这个长期 Token 鉴权(不是用户 JWT)。多 Worker 共用同一个 Token;
+229
View File
@@ -0,0 +1,229 @@
"""积分系统暂停开关测试 (#1895, ENABLE_CREDIT_SYSTEM)。
产品要求:暂停积分系统但保留全部代码/表/接口。
- 默认 false:所有 AI 功能免费放行,不扣积分、不做余额拦截;
- /points/check 恒返回 allowed=True、required_points=0;
- /points/deduct 为 no-op,余额不变;
- 查询接口(balance/transactions/rules/packages/membership/usage)照常可用;
- 旧环境变量 POINTS_ENABLED 作为兼容别名仍可开启。
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def _make_cu(user_id="user-1", is_member=False, member_type=None):
cu = MagicMock()
cu.user.id = user_id
cu.user.is_member = is_member
cu.user.member_type = member_type
cu.user.member_expires_at = None
return cu
# ── 配置层 ────────────────────────────────────────────────────────────────
class TestCreditSystemConfig:
def test_default_disabled(self):
import os
from packages.config.base import SharedSettings
assert os.environ.get("ENABLE_CREDIT_SYSTEM") is None
assert os.environ.get("POINTS_ENABLED") is None
s = SharedSettings(_env_file=None)
assert s.credits_enabled is False
# 旧属性名仍可用(业务代码大量引用 settings.points_enabled)
assert s.points_enabled is False
def test_enable_credit_system_env(self, monkeypatch):
from packages.config import base as base_mod
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "true")
s = base_mod.SharedSettings(_env_file=None)
assert s.points_enabled is True
assert s.credits_enabled is True
def test_legacy_points_enabled_env_alias(self, monkeypatch):
from packages.config import base as base_mod
monkeypatch.setenv("ENABLE_CREDIT_SYSTEM", "false")
monkeypatch.setenv("POINTS_ENABLED", "true")
s = base_mod.SharedSettings(_env_file=None)
assert s.points_enabled is True
assert s.credits_enabled is False
assert s.points_enabled_compat is True
def test_legacy_setter_back_compat(self):
from packages.config.base import SharedSettings
s = SharedSettings(_env_file=None)
s.points_enabled = True
assert s.credits_enabled is True
assert s.points_enabled is True
s.points_enabled = False
assert s.points_enabled is False
# ── /points/check:关闭时恒放行、需 0 积分 ────────────────────────────────
class TestCheckEndpointWhenDisabled:
def test_check_allowed_zero_required(self):
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_voice", quantity=1, duration_minutes=5)
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=body, current_user=cu, db=db)
assert resp.allowed is True
assert resp.required_points == 0
assert resp.remaining_after == 0
# 不再走免费额度判定
svc.check_daily_free_clip.assert_not_called()
def test_unknown_scene_still_400_when_disabled(self):
"""未知 scene 即使系统关闭也返回 400(参数校验先于开关)。"""
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
def test_check_enabled_calculates_cost(self):
"""开关开启时保持原有计费校验。"""
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)
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = check_points(body=body, current_user=_make_cu(), db=MagicMock())
assert resp.required_points == 2 # 免费用户 ceil(1*1.15)=2
# ── /points/deduct:关闭时 no-op,余额不变 ────────────────────────────────
class TestDeductEndpointWhenDisabled:
def test_deduct_is_noop(self):
from app.api.routes.points import deduct_points
from app.schemas.points import PointsDeductRequest
svc = MagicMock()
svc.get_or_create_account.return_value = {"balance": 7}
body = PointsDeductRequest(scene_key="ai_voice", amount=999)
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
svc.deduct_points.assert_not_called()
assert resp.success is True
assert resp.data["balance"] == 7
assert resp.data["transaction_id"] == ""
def test_deduct_enabled_works_as_before(self):
from app.api.routes.points import deduct_points
from app.schemas.points import PointsDeductRequest
svc = MagicMock()
svc.deduct_points.return_value = {"success": True, "balance": 8, "transaction_id": "tx-1"}
body = PointsDeductRequest(scene_key="ai_title", amount=2)
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = deduct_points(body=body, current_user=_make_cu(), db=MagicMock())
svc.deduct_points.assert_called_once()
assert resp.data["balance"] == 8
assert resp.data["transaction_id"] == "tx-1"
# ── 查询接口:系统关闭时仍全部可用 ────────────────────────────────────────
class TestQueryEndpointsRemainAvailable:
def test_balance_route_works_when_disabled(self):
from app.api.routes.points import get_balance
svc = MagicMock()
svc.get_or_create_account.return_value = {"balance": 0, "total_earned": 0, "total_spent": 0}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_balance(current_user=_make_cu(), db=MagicMock())
assert resp.balance == 0
assert resp.is_member is False
def test_transactions_route_works_when_disabled(self):
from app.api.routes.points import get_transactions
svc = MagicMock()
svc.get_transactions.return_value = {"items": [], "total": 0, "page": 1, "page_size": 20}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_transactions(current_user=_make_cu(), db=MagicMock())
assert resp.total == 0
def test_daily_usage_route_works_when_disabled(self):
from app.api.routes.points import get_daily_usage
svc = MagicMock()
svc.get_daily_usage.return_value = {
"free_clips_used": 0,
"free_clips_limit": 2,
"free_clips_remaining": 2,
"reset_at": "2026-09-20T00:00:00Z",
}
with (
patch("app.api.routes.points._credits_enabled", return_value=False),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = get_daily_usage(current_user=_make_cu(), db=MagicMock())
assert resp.free_clips_limit == 2
# ── 业务路由:开关关闭时 PointsService 不实例化、不扣分 ───────────────────
class TestBusinessRoutesBypassWhenDisabled:
def test_lipsync_route_skips_points(self):
"""lipsync 创建任务路由:settings.points_enabled=False 时不构造 PointsService。"""
from app.api.routes import lipsync as lipsync_mod
assert bool(getattr(lipsync_mod.settings, "points_enabled", False)) is False
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
+25 -14
View File
@@ -71,9 +71,7 @@ class TestRechargeOrderResponse:
cu = _make_cu()
body = PointsRechargeRequest(package_id="nonexistent")
with pytest.raises(HTTPException) as exc, patch(
"app.api.routes.points._get_service", return_value=svc
):
with pytest.raises(HTTPException) as exc, patch("app.api.routes.points._get_service", return_value=svc):
create_recharge_order(body=body, current_user=cu, db=db)
assert exc.value.status_code == 400
@@ -112,7 +110,10 @@ class TestCheckPointsUnknownScene:
cu = _make_cu()
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
with patch("app.api.routes.points._get_service", return_value=svc):
with (
patch("app.api.routes.points._credits_enabled", return_value=True),
patch("app.api.routes.points._get_service", return_value=svc),
):
resp = check_points(body=body, current_user=cu, db=db)
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
assert resp.current_balance == 50
@@ -148,22 +149,30 @@ class TestSubscriptionPlans:
def _import_plans_fn():
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
import importlib.util
_route_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
)
_spec = importlib.util.spec_from_file_location(
"_real_subscription_routes", os.path.abspath(_route_path)
"..",
"..",
"apps",
"api",
"app",
"api",
"routes",
"subscription.py",
)
_spec = importlib.util.spec_from_file_location("_real_subscription_routes", os.path.abspath(_route_path))
_mod = importlib.util.module_from_spec(_spec)
# inject settings before exec
import os as _os
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
_spec.loader.exec_module(_mod)
return _mod.list_membership_plans
def test_plans_endpoint_returns_three_tiers(self):
import os # noqa: F401 (used by _import_plans_fn)
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
@@ -177,6 +186,7 @@ class TestSubscriptionPlans:
def test_longer_plans_cheaper_per_month(self):
import os # noqa: F401
list_membership_plans = self._import_plans_fn()
resp = list_membership_plans(current_user=_make_cu())
plans = resp["plans"]
@@ -211,9 +221,10 @@ class TestMultiplierConsistency:
db = MagicMock()
cu = _make_cu()
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
body = PointsCheckRequest(scene_key=scene, quantity=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)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
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)
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)
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"