Files
xiaoxia-saas/packages/application/points_service.py
T

605 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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