220 lines
7.7 KiB
Python
220 lines
7.7 KiB
Python
"""会员服务:状态查询与开通(叠加/互斥规则对齐《01-开发文档》2.4.3)"""
|
||
import random
|
||
from datetime import datetime, timedelta
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import biz_config
|
||
from app.core import errors
|
||
from app.core.response import BizException
|
||
from app.models.user import MembershipOrder, UserMembership
|
||
|
||
# 会员开通记录来源
|
||
SOURCE_POINTS = 1 # 积分购买
|
||
SOURCE_GIFT = 2 # 活动赠送
|
||
|
||
PLAN_NAMES = {"trial": "体验会员", "month": "月卡", "year": "年卡", "lifetime": "终身卡"}
|
||
|
||
# 套餐展示元数据(价格以 config.yaml / 后台配置为准,前端不硬编码)
|
||
PLAN_META = {
|
||
"trial": {"name": "体验会员", "desc": "1天体验,活动同享"},
|
||
"month": {"name": "月卡", "desc": "30天无限观看"},
|
||
"year": {"name": "年卡", "desc": "365天无限观看"},
|
||
"lifetime": {"name": "终身卡", "desc": "永久无限观看"},
|
||
}
|
||
|
||
PLAN_BENEFITS = ["无限观看视频", "优先上传(转码/审核优先)"]
|
||
|
||
|
||
def gen_order_no(prefix: str = "M") -> str:
|
||
"""业务单号:前缀 + 时间戳 + 随机"""
|
||
return f"{prefix}{datetime.now():%Y%m%d%H%M%S}{random.randint(100000, 999999)}"
|
||
|
||
|
||
async def get_membership(db: AsyncSession, user_id: int) -> UserMembership:
|
||
ms = await db.get(UserMembership, user_id)
|
||
if ms is None:
|
||
ms = UserMembership(user_id=user_id)
|
||
db.add(ms)
|
||
await db.flush()
|
||
return ms
|
||
|
||
|
||
def membership_active(ms: UserMembership, now: datetime | None = None) -> bool:
|
||
"""会员有效性判定(以 expire_at 为准,终身卡永久有效)"""
|
||
now = now or datetime.now()
|
||
if ms.is_lifetime:
|
||
return True
|
||
return ms.expire_at is not None and ms.expire_at > now
|
||
|
||
|
||
async def is_member(db: AsyncSession, user_id: int) -> bool:
|
||
ms = await db.get(UserMembership, user_id)
|
||
return ms is not None and membership_active(ms)
|
||
|
||
|
||
async def grant_membership(
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
plan_code: str,
|
||
points_cost: int,
|
||
source: int,
|
||
days: int | None = None,
|
||
) -> MembershipOrder:
|
||
"""开通会员并写开通记录,返回订单。
|
||
|
||
- 有效期内重复开通:有效期叠加(max(now, expire_at) + days)
|
||
- 体验会员期间开通付费卡:按付费卡生效,体验剩余时间不累加
|
||
- 终身卡不可再开通其它套餐
|
||
"""
|
||
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
|
||
if not plan_cfg or not plan_cfg.get("enabled", False):
|
||
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
|
||
duration_days = int(days if days is not None else plan_cfg.get("days", 0))
|
||
|
||
now = datetime.now()
|
||
ms = await get_membership(db, user_id)
|
||
|
||
if ms.is_lifetime:
|
||
raise BizException(errors.LIFETIME_MEMBER)
|
||
|
||
if duration_days == 0: # 终身卡
|
||
start_at, end_at = now, None
|
||
else:
|
||
base = now
|
||
# 付费卡叠加:当前为付费会员且未到期 → 在到期时间上叠加
|
||
# 体验会员转付费卡:从当前时间重新计算(体验剩余不累加)
|
||
if membership_active(ms, now) and ms.current_plan != "trial" and plan_code != "trial":
|
||
base = max(now, ms.expire_at) # type: ignore[arg-type]
|
||
start_at = base
|
||
end_at = base + timedelta(days=duration_days)
|
||
|
||
order = MembershipOrder(
|
||
order_no=gen_order_no(),
|
||
user_id=user_id,
|
||
plan_code=plan_code,
|
||
points_cost=points_cost,
|
||
duration_days=duration_days,
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
source=source,
|
||
)
|
||
db.add(order)
|
||
|
||
ms.is_member = 1
|
||
ms.current_plan = plan_code
|
||
ms.is_lifetime = 1 if duration_days == 0 else 0
|
||
ms.expire_at = end_at
|
||
await db.flush()
|
||
return order
|
||
|
||
|
||
async def grant_trial_gift(db: AsyncSession, user_id: int, days: int) -> MembershipOrder:
|
||
"""新注册赠送体验会员(新用户无既有会员状态,直接设置)"""
|
||
now = datetime.now()
|
||
end_at = now + timedelta(days=days)
|
||
order = MembershipOrder(
|
||
order_no=gen_order_no(),
|
||
user_id=user_id,
|
||
plan_code="trial",
|
||
points_cost=0,
|
||
duration_days=days,
|
||
start_at=now,
|
||
end_at=end_at,
|
||
source=SOURCE_GIFT,
|
||
)
|
||
db.add(order)
|
||
ms = await get_membership(db, user_id)
|
||
ms.is_member = 1
|
||
ms.current_plan = "trial"
|
||
ms.is_lifetime = 0
|
||
ms.expire_at = end_at
|
||
await db.flush()
|
||
return order
|
||
|
||
|
||
# ---------- 接口查询(第 7 章) ----------
|
||
|
||
def _fmt(dt: datetime | None) -> str | None:
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||
|
||
|
||
def list_plans() -> dict:
|
||
"""套餐列表(7.1):价格实时读 config.yaml,游客可访问"""
|
||
plans_cfg = biz_config.get("membership") or {}
|
||
plans = []
|
||
for code in ("trial", "month", "year", "lifetime"):
|
||
cfg = plans_cfg.get(code) or {}
|
||
meta = PLAN_META[code]
|
||
plans.append({
|
||
"plan_code": code,
|
||
"name": meta["name"],
|
||
"days": int(cfg.get("days", 0)),
|
||
"points": int(cfg.get("points", 0)),
|
||
"enabled": bool(cfg.get("enabled", False)),
|
||
"desc": meta["desc"],
|
||
})
|
||
return {"plans": plans, "benefits": PLAN_BENEFITS}
|
||
|
||
|
||
async def get_my_membership(db: AsyncSession, user_id: int) -> dict:
|
||
"""我的会员(7.2):当前状态 + 历史开通记录"""
|
||
ms = await get_membership(db, user_id)
|
||
active = membership_active(ms)
|
||
orders = (
|
||
await db.execute(
|
||
select(MembershipOrder).where(MembershipOrder.user_id == user_id)
|
||
.order_by(MembershipOrder.id.desc()).limit(50)
|
||
)
|
||
).scalars().all()
|
||
remaining_days = 0
|
||
if active and not ms.is_lifetime and ms.expire_at:
|
||
remaining_days = max((ms.expire_at - datetime.now()).days, 0)
|
||
return {
|
||
"is_member": active,
|
||
"is_lifetime": bool(ms.is_lifetime),
|
||
"current_plan": ms.current_plan if active else None,
|
||
"plan_name": PLAN_NAMES.get(ms.current_plan) if active else None,
|
||
"expire_at": _fmt(ms.expire_at) if active else None,
|
||
"remaining_days": remaining_days,
|
||
"history": [
|
||
{
|
||
"order_no": o.order_no,
|
||
"plan_name": PLAN_NAMES.get(o.plan_code, o.plan_code),
|
||
"points_cost": o.points_cost,
|
||
"start_at": _fmt(o.start_at),
|
||
"end_at": _fmt(o.end_at),
|
||
"source": o.source,
|
||
}
|
||
for o in orders
|
||
],
|
||
}
|
||
|
||
|
||
async def redeem(db: AsyncSession, user_id: int, plan_code: str) -> dict:
|
||
"""积分开通会员(7.3):扣积分 + 开通同一事务;2004 终身卡 / 2001 积分不足"""
|
||
from app.services import wallet_service # 避免循环引用
|
||
|
||
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
|
||
if not plan_cfg or not plan_cfg.get("enabled", False):
|
||
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
|
||
points_cost = int(plan_cfg.get("points", 0))
|
||
|
||
# 先开通(内部校验终身卡/叠加规则),再扣积分;积分不足抛 2001 整体回滚
|
||
order = await grant_membership(db, user_id, plan_code, points_cost, SOURCE_POINTS)
|
||
remaining = await wallet_service.get_balance(db, user_id)
|
||
if points_cost > 0:
|
||
remaining = await wallet_service.change_points(
|
||
db, user_id, -points_cost, wallet_service.BIZ_MEMBERSHIP,
|
||
biz_id=order.order_no, remark=f"开通{PLAN_NAMES.get(plan_code, plan_code)}",
|
||
)
|
||
await db.commit()
|
||
return {
|
||
"order_no": order.order_no,
|
||
"plan_name": PLAN_NAMES.get(plan_code, plan_code),
|
||
"points_cost": points_cost,
|
||
"remaining_points": remaining,
|
||
"expire_at": _fmt(order.end_at),
|
||
}
|