Files
video/server/build/lib/app/services/membership_service.py
2026-10-16 13:51:00 +08:00

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