This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
"""会员服务:状态查询与开通(叠加/互斥规则对齐《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),
}