add
This commit is contained in:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user