605 lines
23 KiB
Python
605 lines
23 KiB
Python
"""PointsService — 会员积分应用服务(#1895 P1+P2).
|
||
|
||
核心能力:账户查询、积分扣减/退还/充值、充值订单管理、每日免费额度(Redis 计数 + DB 兜底)。
|
||
所有 DB 操作通过 repository 层;事务通过 session 上下文保证原子性。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from packages.adapters.sqlalchemy_impl.daily_usage_repository import (
|
||
SQLAlchemyDailyUsageRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.points_account_repository import (
|
||
SQLAlchemyPointsAccountRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.points_order_repository import (
|
||
SQLAlchemyPointsOrderRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.points_transaction_repository import (
|
||
SQLAlchemyPointsTransactionRepository,
|
||
)
|
||
from packages.domain.points import (
|
||
FREE_DAILY_CLIPS,
|
||
ORDER_STATUS_PAID,
|
||
ORDER_STATUS_PENDING,
|
||
POINTS_PACKAGES,
|
||
TX_SOURCE_RECHARGE,
|
||
TX_TYPE_EARN,
|
||
TX_TYPE_REFUND,
|
||
TX_TYPE_SPEND,
|
||
calc_package_price,
|
||
calc_points,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
CST = timezone(timedelta(hours=8))
|
||
DAILY_USAGE_REDIS_PREFIX = "daily_usage"
|
||
DAILY_USAGE_REDIS_TTL_SECONDS = 48 * 3600 # 48h
|
||
|
||
# 会员套餐与权益
|
||
MEMBERSHIP_PLANS: dict[str, dict[str, Any]] = {
|
||
"monthly": {
|
||
"name": "月度会员",
|
||
"days": 31,
|
||
"price_cents": 2900,
|
||
"discount": 0.9,
|
||
"daily_free_clips_limit": -1, # -1 表示不限
|
||
},
|
||
"quarterly": {
|
||
"name": "季度会员",
|
||
"days": 93,
|
||
"price_cents": 7900,
|
||
"discount": 0.87,
|
||
"daily_free_clips_limit": -1,
|
||
},
|
||
"yearly": {
|
||
"name": "年度会员",
|
||
"days": 366,
|
||
"price_cents": 25900,
|
||
"discount": 0.8,
|
||
"daily_free_clips_limit": -1,
|
||
},
|
||
}
|
||
MEMBER_PACKAGE_NAME_PREFIX = "membership_" # 订单 package_name 前缀,用于区分会员订阅单
|
||
POINTS_UNIT_PRICE_YUAN = 0.10 # 积分单价(元/积分),用于展示
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class DeductResult:
|
||
success: bool
|
||
balance: int
|
||
amount: int = 0
|
||
transaction_id: str | None = None
|
||
balance_after: int = 0
|
||
reason: str = ""
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class EarnResult:
|
||
success: bool
|
||
transaction_id: str | None = None
|
||
balance_after: int = 0
|
||
amount: int = 0
|
||
reason: str = ""
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class DailyUsageInfo:
|
||
used: int
|
||
limit: int
|
||
remaining: int
|
||
reset_at: datetime | None = None
|
||
|
||
|
||
class PointsService:
|
||
"""积分业务服务。
|
||
|
||
依赖通过构造函数注入(repositories + 可选 redis 客户端)。
|
||
事务:使用 repo.session.begin() 上下文保证原子性,失败回滚。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
account_repo: SQLAlchemyPointsAccountRepository,
|
||
tx_repo: SQLAlchemyPointsTransactionRepository,
|
||
order_repo: SQLAlchemyPointsOrderRepository,
|
||
daily_usage_repo: SQLAlchemyDailyUsageRepository,
|
||
redis_client: Any | None = None,
|
||
):
|
||
self._accounts = account_repo
|
||
self._txs = tx_repo
|
||
self._orders = order_repo
|
||
self._daily = daily_usage_repo
|
||
self._redis = redis_client
|
||
|
||
# ── 账户 ──────────────────────────────────────────────────────────────
|
||
|
||
def get_account(self, user_id: str):
|
||
"""获取积分账户,不存在则自动创建(余额 0)."""
|
||
return self._accounts.create_if_not_exists(user_id)
|
||
|
||
def get_balance(self, user_id: str) -> int:
|
||
account = self._accounts.get_by_user_id(user_id)
|
||
if account is None:
|
||
account = self._accounts.create_if_not_exists(user_id)
|
||
self._accounts.session.commit()
|
||
return int(account.balance or 0)
|
||
|
||
# ── 纯计算 ────────────────────────────────────────────────────────────
|
||
|
||
def calculate_cost(
|
||
self,
|
||
scene_key: str,
|
||
is_member: bool,
|
||
duration_minutes: float = 1.0,
|
||
extra_segments: int = 0,
|
||
) -> int:
|
||
"""仅计算所需积分(不扣减、不写库),用于前端预估消耗。"""
|
||
return calc_points(
|
||
scene_key,
|
||
is_member,
|
||
duration_minutes=duration_minutes,
|
||
extra_segments=extra_segments,
|
||
)
|
||
|
||
# ── 流水查询 ──────────────────────────────────────────────────────────
|
||
|
||
def list_transactions(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
offset: int = 0,
|
||
limit: int = 20,
|
||
type_: str | None = None,
|
||
source: str | None = None,
|
||
start_date: datetime | None = None,
|
||
end_date: datetime | None = None,
|
||
):
|
||
"""分页查询积分流水,返回 (items, total)。"""
|
||
return self._txs.list_by_user(
|
||
user_id,
|
||
offset=offset,
|
||
limit=limit,
|
||
type_=type_,
|
||
source=source,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
)
|
||
|
||
# ── 会员订阅 ──────────────────────────────────────────────────────────
|
||
|
||
def create_membership_order(
|
||
self,
|
||
user_id: str,
|
||
member_type: str,
|
||
payment_method: str,
|
||
):
|
||
"""创建会员订阅订单(pending 状态,复用 points_orders 表,package_name 前缀区分)。"""
|
||
plan = MEMBERSHIP_PLANS.get(member_type)
|
||
if plan is None:
|
||
raise ValueError(f"未知会员类型: {member_type}")
|
||
expire_at = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||
order = self._orders.create(
|
||
user_id=user_id,
|
||
package_name=f"{MEMBER_PACKAGE_NAME_PREFIX}{member_type}",
|
||
points_amount=0,
|
||
price_cents=int(plan["price_cents"]),
|
||
original_price_cents=int(plan["price_cents"]),
|
||
discount=float(plan["discount"]),
|
||
currency="CNY",
|
||
payment_method=payment_method,
|
||
expire_at=expire_at,
|
||
)
|
||
self._orders.session.commit()
|
||
return order
|
||
|
||
def subscribe_member(self, user_id: str, member_type: str) -> None:
|
||
"""激活/续费会员:更新 users.is_member/member_type/member_expires_at。
|
||
|
||
新会员从当前时间加 days;已在会员期内则在 member_expires_at 基础上顺延。
|
||
调用方需确保在事务中调用。
|
||
"""
|
||
from packages.adapters.sqlalchemy_impl.models import UserModel
|
||
|
||
plan = MEMBERSHIP_PLANS.get(member_type)
|
||
if plan is None:
|
||
raise ValueError(f"未知会员类型: {member_type}")
|
||
session = self._orders.session
|
||
user_model = session.query(UserModel).filter(UserModel.id == user_id).with_for_update().first()
|
||
if user_model is None:
|
||
raise ValueError(f"用户不存在: {user_id}")
|
||
now = datetime.now(timezone.utc)
|
||
base = (
|
||
user_model.member_expires_at
|
||
if (user_model.member_expires_at and user_model.member_expires_at > now)
|
||
else now
|
||
)
|
||
new_expires = base + timedelta(days=int(plan["days"]))
|
||
user_model.is_member = True
|
||
user_model.member_type = member_type
|
||
user_model.member_expires_at = new_expires
|
||
session.flush()
|
||
|
||
# ── 扣减 / 退还 ───────────────────────────────────────────────────────
|
||
|
||
def check_and_deduct(
|
||
self,
|
||
user_id: str,
|
||
scene_key: str,
|
||
duration_minutes: float = 1.0,
|
||
extra_segments: int = 0,
|
||
ref_id: str = "",
|
||
description: str = "",
|
||
is_member: bool = False,
|
||
) -> DeductResult:
|
||
"""事务性扣减积分。
|
||
|
||
1. calc_points 计算消耗量
|
||
2. SELECT FOR UPDATE 锁定账户
|
||
3. 余额不足返回失败(reason=insufficient)
|
||
4. 余额充足:更新 balance/total_spent,插入 spend 流水,提交事务
|
||
"""
|
||
amount = calc_points(
|
||
scene_key,
|
||
is_member,
|
||
duration_minutes=duration_minutes,
|
||
extra_segments=extra_segments,
|
||
)
|
||
if amount <= 0:
|
||
# 免费场景(如 voice_clone_train)直接返回成功
|
||
account = self._accounts.create_if_not_exists(user_id)
|
||
self._accounts.session.commit()
|
||
return DeductResult(
|
||
success=True,
|
||
balance=int(account.balance or 0),
|
||
amount=0,
|
||
transaction_id=None,
|
||
balance_after=int(account.balance or 0),
|
||
reason="free_scene",
|
||
)
|
||
|
||
session = self._accounts.session
|
||
with session.begin_nested() if session.in_transaction() else session.begin():
|
||
account = self._accounts.get_for_update(user_id)
|
||
if account is None:
|
||
account = self._accounts.create_if_not_exists(user_id)
|
||
session.flush()
|
||
current = int(account.balance or 0)
|
||
if current < amount:
|
||
return DeductResult(
|
||
success=False,
|
||
balance=current,
|
||
amount=amount,
|
||
reason="insufficient",
|
||
)
|
||
account.balance = current - amount
|
||
account.total_spent = int(account.total_spent or 0) + amount
|
||
account.updated_at = datetime.now(timezone.utc)
|
||
session.flush()
|
||
tx = self._txs.create(
|
||
user_id=user_id,
|
||
account_id=account.id,
|
||
type_=TX_TYPE_SPEND,
|
||
source=scene_key,
|
||
amount=amount,
|
||
balance_after=int(account.balance or 0),
|
||
description=description,
|
||
ref_id=ref_id,
|
||
)
|
||
return DeductResult(
|
||
success=True,
|
||
balance=int(account.balance or 0),
|
||
amount=amount,
|
||
transaction_id=tx.id,
|
||
balance_after=int(account.balance or 0),
|
||
)
|
||
|
||
def refund(self, user_id: str, transaction_id: str, reason: str = "") -> bool:
|
||
"""根据原 spend 流水退还积分。
|
||
|
||
- 只能退还 type=spend 的流水(防止重复退还 earn)
|
||
- 事务内 balance+amount, total_spent-amount,插入 refund 流水
|
||
"""
|
||
session = self._accounts.session
|
||
with session.begin_nested() if session.in_transaction() else session.begin():
|
||
orig = self._txs.get_by_id(transaction_id)
|
||
if orig is None:
|
||
logger.warning("refund: 流水不存在 tx_id=%s", transaction_id)
|
||
return False
|
||
if orig.type != TX_TYPE_SPEND:
|
||
logger.warning(
|
||
"refund: 非 spend 流水不可退 tx_id=%s type=%s",
|
||
transaction_id,
|
||
orig.type,
|
||
)
|
||
return False
|
||
if self._txs.exists_refund_for(transaction_id):
|
||
logger.warning("refund: 流水已退款 tx_id=%s", transaction_id)
|
||
return False
|
||
account = self._accounts.get_for_update(user_id)
|
||
if account is None or account.id != orig.account_id:
|
||
logger.warning(
|
||
"refund: 账户不匹配 user_id=%s tx_account=%s",
|
||
user_id,
|
||
orig.account_id,
|
||
)
|
||
return False
|
||
amount = int(orig.amount or 0)
|
||
account.balance = int(account.balance or 0) + amount
|
||
account.total_spent = max(0, int(account.total_spent or 0) - amount)
|
||
account.updated_at = datetime.now(timezone.utc)
|
||
session.flush()
|
||
self._txs.create(
|
||
user_id=user_id,
|
||
account_id=account.id,
|
||
type_=TX_TYPE_REFUND,
|
||
source=orig.source,
|
||
amount=amount,
|
||
balance_after=int(account.balance or 0),
|
||
description=reason or f"refund for {transaction_id}",
|
||
ref_id=transaction_id,
|
||
)
|
||
return True
|
||
|
||
# ── 充值 / 奖励 ───────────────────────────────────────────────────────
|
||
|
||
def earn_points(
|
||
self,
|
||
user_id: str,
|
||
amount: int,
|
||
source: str,
|
||
description: str = "",
|
||
ref_id: str = "",
|
||
) -> EarnResult:
|
||
"""获得积分(充值或任务奖励)。
|
||
|
||
source=recharge 累加 total_purchased;source=task_reward 累加 total_gifted。
|
||
"""
|
||
if amount <= 0:
|
||
return EarnResult(success=False, reason="invalid_amount")
|
||
session = self._accounts.session
|
||
with session.begin_nested() if session.in_transaction() else session.begin():
|
||
account = self._accounts.get_for_update(user_id)
|
||
if account is None:
|
||
account = self._accounts.create_if_not_exists(user_id)
|
||
session.flush()
|
||
account.balance = int(account.balance or 0) + amount
|
||
account.total_earned = int(account.total_earned or 0) + amount
|
||
if source == TX_SOURCE_RECHARGE:
|
||
account.total_purchased = int(account.total_purchased or 0) + amount
|
||
else:
|
||
account.total_gifted = int(account.total_gifted or 0) + amount
|
||
account.updated_at = datetime.now(timezone.utc)
|
||
session.flush()
|
||
tx = self._txs.create(
|
||
user_id=user_id,
|
||
account_id=account.id,
|
||
type_=TX_TYPE_EARN,
|
||
source=source,
|
||
amount=amount,
|
||
balance_after=int(account.balance or 0),
|
||
description=description,
|
||
ref_id=ref_id,
|
||
)
|
||
return EarnResult(
|
||
success=True,
|
||
transaction_id=tx.id,
|
||
balance_after=int(account.balance or 0),
|
||
amount=amount,
|
||
)
|
||
|
||
# ── 订单 ──────────────────────────────────────────────────────────────
|
||
|
||
def create_order(
|
||
self,
|
||
user_id: str,
|
||
package_id: str,
|
||
payment_method: str,
|
||
member_type_for_discount: str | None = None,
|
||
):
|
||
"""创建积分充值订单(pending 状态,30 分钟过期)。"""
|
||
discounted_cents, original_cents, discount = calc_package_price(package_id, member_type_for_discount)
|
||
pkg = next((p for p in POINTS_PACKAGES if p["id"] == package_id), None)
|
||
if pkg is None:
|
||
raise ValueError(f"未知积分包: {package_id}")
|
||
expire_at = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||
order = self._orders.create(
|
||
user_id=user_id,
|
||
package_name=pkg["name"],
|
||
points_amount=int(pkg["points"]),
|
||
price_cents=int(discounted_cents),
|
||
original_price_cents=int(original_cents),
|
||
discount=float(discount),
|
||
currency="CNY",
|
||
payment_method=payment_method,
|
||
expire_at=expire_at,
|
||
)
|
||
self._orders.session.commit()
|
||
return order
|
||
|
||
def mark_order_paid(self, order_id: str, payment_id: str):
|
||
"""标记订单已支付:积分包 → 充值积分;会员订阅单 → 激活/续费会员。
|
||
|
||
事务内:改订单状态为 paid -> 分发给对应的履约逻辑。幂等。
|
||
"""
|
||
session = self._orders.session
|
||
with session.begin_nested() if session.in_transaction() else session.begin():
|
||
order = self._orders.get_by_id(order_id)
|
||
if order is None:
|
||
raise ValueError(f"订单不存在: {order_id}")
|
||
if order.status == ORDER_STATUS_PAID:
|
||
return order # 幂等
|
||
if order.status != ORDER_STATUS_PENDING:
|
||
raise ValueError(f"订单状态不可支付: {order.status}")
|
||
paid_at = datetime.now(timezone.utc)
|
||
self._orders.update_status(
|
||
order_id,
|
||
status=ORDER_STATUS_PAID,
|
||
payment_id=payment_id,
|
||
paid_at=paid_at,
|
||
)
|
||
if order.package_name and order.package_name.startswith(MEMBER_PACKAGE_NAME_PREFIX):
|
||
member_type = order.package_name[len(MEMBER_PACKAGE_NAME_PREFIX) :]
|
||
self.subscribe_member(order.user_id, member_type)
|
||
else:
|
||
# earn_points 在同一事务内(使用 orders 的 session 需要重新获取账户 repo
|
||
# 为了保证在同一事务,我们让 earn_points 通过 begin_nested 使用;
|
||
# 注意: account/tx repo 与 order repo 应共享同一 session
|
||
# 此处调用 earn_points 将在 order 事务内做 SAVEPOINT
|
||
self.earn_points(
|
||
user_id=order.user_id,
|
||
amount=int(order.points_amount),
|
||
source=TX_SOURCE_RECHARGE,
|
||
description=f"充值 {order.package_name}",
|
||
ref_id=order_id,
|
||
)
|
||
session.flush()
|
||
# commit 外层事务
|
||
session.commit()
|
||
return self._orders.get_by_id(order_id)
|
||
|
||
# ── 每日免费额度(Redis 主 + DB 兜底) ─────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _today_cst() -> date:
|
||
return datetime.now(CST).date()
|
||
|
||
@classmethod
|
||
def _redis_key(cls, user_id: str, usage_date: date, usage_type: str) -> str:
|
||
return f"{DAILY_USAGE_REDIS_PREFIX}:{user_id}:{usage_date.strftime('%Y%m%d')}:{usage_type}"
|
||
|
||
def _redis_check_and_incr(self, key: str, limit: int) -> tuple[bool, int] | None:
|
||
"""原子 check+incr:若当前值已 >= limit 则不递增(拒绝);否则 +1。
|
||
|
||
使用 Lua 脚本保证原子性,避免超限时仍被计数导致"被占用"额度。
|
||
返回 (allowed: bool, current_count_after: int);Redis 不可用时返回 None。
|
||
- allowed=True 表示本次占用成功,count 为占用后的次数(1..limit)
|
||
- allowed=False 表示已达上限,count 为已占用次数(=limit)
|
||
"""
|
||
if self._redis is None:
|
||
return None
|
||
try:
|
||
# 返回: {0=rejected, 1=allowed}, current_count
|
||
lua = """
|
||
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||
local lim = tonumber(ARGV[1])
|
||
if cur >= lim then
|
||
return {0, tostring(cur)}
|
||
end
|
||
local nv = redis.call('INCR', KEYS[1])
|
||
if tonumber(nv) == 1 then
|
||
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
|
||
end
|
||
return {1, tostring(nv)}
|
||
"""
|
||
allowed_flag, cur_str = self._redis.eval(lua, 1, key, limit, DAILY_USAGE_REDIS_TTL_SECONDS)
|
||
return bool(int(allowed_flag)), int(cur_str)
|
||
except Exception:
|
||
logger.warning("Redis check+incr 失败 key=%s", key, exc_info=True)
|
||
return None
|
||
|
||
def _redis_get(self, key: str) -> int | None:
|
||
if self._redis is None:
|
||
return None
|
||
try:
|
||
v = self._redis.get(key)
|
||
return int(v) if v is not None else 0
|
||
except Exception:
|
||
logger.warning("Redis GET 失败 key=%s", key, exc_info=True)
|
||
return None
|
||
|
||
def check_and_incr_daily_free_clips(
|
||
self,
|
||
user_id: str,
|
||
usage_type: str = "free_clip",
|
||
limit: int = FREE_DAILY_CLIPS,
|
||
) -> bool:
|
||
"""检查并占用一次每日免费额度。
|
||
|
||
- 优先走 Redis INCR(原子 + TTL 48h)
|
||
- Redis 不可用则降级 DB 行锁 + increment_count
|
||
- 超过 limit 返回 False;否则 True
|
||
- 异步将 Redis 计数同步到 DB(同步写入,简单可靠;后续可改为异步任务)
|
||
"""
|
||
today = self._today_cst()
|
||
key = self._redis_key(user_id, today, usage_type)
|
||
result = self._redis_check_and_incr(key, limit)
|
||
if result is not None:
|
||
allowed, current = result
|
||
# 异步同步到 DB(这里同步写,轻量)
|
||
try:
|
||
self._daily.set_count(user_id, today, usage_type, current)
|
||
self._daily.session.commit()
|
||
except Exception:
|
||
logger.warning("daily_usage DB 同步失败 user=%s", user_id, exc_info=True)
|
||
self._daily.session.rollback()
|
||
return allowed
|
||
# Redis 不可用:走 DB(事务内先查后增,防超限)
|
||
session = self._daily.session
|
||
try:
|
||
with session.begin_nested() if session.in_transaction() else session.begin():
|
||
record = self._daily.get_for_today(user_id, today, usage_type)
|
||
current = int(record.count or 0)
|
||
if current >= limit:
|
||
session.rollback()
|
||
# 即便回滚本次嵌套事务,仍保留会话可用,提交前序
|
||
return False
|
||
self._daily.increment_count(user_id, today, usage_type)
|
||
session.commit()
|
||
return True
|
||
except Exception:
|
||
session.rollback()
|
||
raise
|
||
|
||
def get_daily_usage(
|
||
self,
|
||
user_id: str,
|
||
usage_type: str = "free_clip",
|
||
limit: int = FREE_DAILY_CLIPS,
|
||
) -> DailyUsageInfo:
|
||
"""查询今日免费额度使用情况."""
|
||
today = self._today_cst()
|
||
key = self._redis_key(user_id, today, usage_type)
|
||
used = self._redis_get(key)
|
||
if used is None:
|
||
# 从 DB 读
|
||
record = self._daily.get_for_today(user_id, today, usage_type)
|
||
try:
|
||
self._daily.session.commit()
|
||
except Exception:
|
||
self._daily.session.rollback()
|
||
used = int(record.count or 0)
|
||
remaining = max(0, limit - used)
|
||
# reset_at: 次日 00:00 CST
|
||
tomorrow_cst = datetime.combine(today + timedelta(days=1), datetime.min.time(), tzinfo=CST)
|
||
return DailyUsageInfo(
|
||
used=used,
|
||
limit=limit,
|
||
remaining=remaining,
|
||
reset_at=tomorrow_cst,
|
||
)
|
||
|
||
|
||
def list_points_packages(member_type_for_discount: str | None = None) -> list[dict[str, Any]]:
|
||
"""返回积分包列表(含按会员类型计算的折后价)。无状态工具函数。"""
|
||
items: list[dict[str, Any]] = []
|
||
for pkg in POINTS_PACKAGES:
|
||
discounted_cents, original_cents, discount = calc_package_price(pkg["id"], member_type_for_discount)
|
||
items.append(
|
||
{
|
||
"id": pkg["id"],
|
||
"name": pkg["name"],
|
||
"points": pkg["points"],
|
||
"price_cents": original_cents,
|
||
"discounted_price_cents": discounted_cents,
|
||
"discount": discount,
|
||
}
|
||
)
|
||
return items
|