"""会员服务:状态查询与开通(叠加/互斥规则对齐《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), }