325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""激励服务:资产钱包 / 推荐绑定 / 提现(对应《03-接口文档》第 9 章、《02-技术文档》6.6)"""
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
import redis.asyncio as aioredis
|
||
from sqlalchemy import func, 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.incentive import AssetTransaction, AssetWallet, Referral, Withdrawal
|
||
from app.models.user import User
|
||
from app.services import config_service, membership_service
|
||
|
||
# 资产流水业务类型(与 asset_transactions.biz_type 注释一致)
|
||
ASSET_BIZ_REBATE = 1 # 充值返利
|
||
ASSET_BIZ_COMPLETE = 2 # 完整观看收益
|
||
ASSET_BIZ_LIKE = 3 # 点赞收益
|
||
ASSET_BIZ_WITHDRAW = 4 # 提现
|
||
ASSET_BIZ_WITHDRAW_BACK = 5 # 提现退回
|
||
ASSET_BIZ_CLAWBACK = 6 # 返利扣回
|
||
ASSET_BIZ_ADJUST = 7 # 后台调整
|
||
|
||
# 提现单在途锁兜底过期(正常由后台审核后释放)
|
||
WD_LOCK_TTL = 7 * 24 * 3600
|
||
# 补绑邀请码时限:注册后 24h 内
|
||
BIND_WINDOW_HOURS = 24
|
||
|
||
REFERRAL_STATUS_TEXT = {0: "待激活", 1: "有效", 2: "无效", 3: "风控冻结"}
|
||
WITHDRAW_STATUS_TEXT = {0: "待审核", 1: "已打款", 2: "已拒绝"}
|
||
|
||
|
||
def _fmt(dt: datetime | None) -> str | None:
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||
|
||
|
||
def _asset_cfg() -> dict:
|
||
return biz_config.get("asset") or {}
|
||
|
||
|
||
def _withdraw_cfg() -> dict:
|
||
return biz_config.get("withdraw") or {}
|
||
|
||
|
||
# ---------- 资产钱包变更(与积分钱包同一套"行锁 + 流水 + 乐观锁"规范) ----------
|
||
|
||
async def get_or_create_asset_wallet(db: AsyncSession, user_id: int, for_update: bool = False) -> AssetWallet:
|
||
stmt = select(AssetWallet).where(AssetWallet.user_id == user_id)
|
||
if for_update:
|
||
stmt = stmt.with_for_update()
|
||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||
if wallet is None:
|
||
wallet = AssetWallet(user_id=user_id)
|
||
db.add(wallet)
|
||
await db.flush()
|
||
if for_update:
|
||
wallet = (await db.execute(stmt)).scalar_one()
|
||
return wallet
|
||
|
||
|
||
async def change_assets(
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
amount: Decimal,
|
||
biz_type: int,
|
||
biz_id: str = "",
|
||
remark: str = "",
|
||
allow_negative: bool = False,
|
||
) -> Decimal:
|
||
"""变更资产余额,返回变动后余额。
|
||
|
||
- amount 正增负减;扣减默认不允许透支(退款/返利扣回场景传 allow_negative=True)
|
||
- 同事务内:行锁 → 校验/更新余额与累计 → 写流水
|
||
"""
|
||
wallet = await get_or_create_asset_wallet(db, user_id, for_update=True)
|
||
new_balance = wallet.assets + amount
|
||
if amount < 0 and new_balance < 0 and not allow_negative:
|
||
raise BizException(errors.ASSET_NOT_ENOUGH)
|
||
|
||
wallet.assets = new_balance
|
||
wallet.version += 1
|
||
if amount > 0:
|
||
wallet.total_earned += amount
|
||
elif biz_type == ASSET_BIZ_WITHDRAW:
|
||
wallet.total_withdrawn += -amount
|
||
|
||
db.add(
|
||
AssetTransaction(
|
||
user_id=user_id,
|
||
change_amount=amount,
|
||
balance_after=new_balance,
|
||
biz_type=biz_type,
|
||
biz_id=biz_id,
|
||
remark=remark,
|
||
)
|
||
)
|
||
await db.flush()
|
||
return new_balance
|
||
|
||
|
||
# ---------- 推荐 ----------
|
||
|
||
async def get_my_referral(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
|
||
"""我的邀请(9.1):邀请码 + 统计 + 当前会员身份(eligible)"""
|
||
user = await db.get(User, user_id)
|
||
if user is None:
|
||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||
|
||
base_url = await config_service.get_config(db, redis, "app.invite_base_url", "https://app.example.com/r/")
|
||
total_invitees = (await db.execute(
|
||
select(func.count(Referral.id)).where(Referral.inviter_id == user_id)
|
||
)).scalar() or 0
|
||
activated_count = (await db.execute(
|
||
select(func.count(Referral.id)).where(
|
||
Referral.inviter_id == user_id, Referral.status == 1
|
||
)
|
||
)).scalar() or 0
|
||
# 邀请积分奖励累计(point_transactions biz_type=8)
|
||
from app.models.billing import PointTransaction
|
||
|
||
reward_points_total = (await db.execute(
|
||
select(func.coalesce(func.sum(PointTransaction.change_amount), 0)).where(
|
||
PointTransaction.user_id == user_id, PointTransaction.biz_type == 8
|
||
)
|
||
)).scalar() or 0
|
||
# 充值返利资产累计(asset_transactions biz_type=1)
|
||
rebate_assets_total = (await db.execute(
|
||
select(func.coalesce(func.sum(AssetTransaction.change_amount), 0)).where(
|
||
AssetTransaction.user_id == user_id, AssetTransaction.biz_type == ASSET_BIZ_REBATE
|
||
)
|
||
)).scalar() or 0
|
||
|
||
return {
|
||
"invite_code": user.invite_code,
|
||
"invite_url": f"{base_url}{user.invite_code}",
|
||
"eligible": await membership_service.is_member(db, user_id),
|
||
"total_invitees": total_invitees,
|
||
"activated_count": activated_count,
|
||
"reward_points_total": int(reward_points_total),
|
||
"rebate_assets_total": float(rebate_assets_total),
|
||
}
|
||
|
||
|
||
async def bind_invite_code(
|
||
db: AsyncSession, redis: aioredis.Redis, user_id: int, invite_code: str, device_id: str | None, ip: str
|
||
) -> dict:
|
||
"""补绑邀请码(9.2):注册 24h 内仅一次;无效码/已绑定/绑自己/同设备均 2101"""
|
||
user = await db.get(User, user_id)
|
||
if user is None:
|
||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||
|
||
bound = (await db.execute(
|
||
select(Referral.id).where(Referral.invitee_id == user_id)
|
||
)).first()
|
||
if bound:
|
||
raise BizException(errors.INVITE_CODE_INVALID, "已绑定过邀请码")
|
||
if user.created_at < datetime.now() - timedelta(hours=BIND_WINDOW_HOURS):
|
||
raise BizException(errors.INVITE_CODE_INVALID, "超过绑定时限(注册后24小时内可绑定)")
|
||
|
||
inviter = (await db.execute(
|
||
select(User).where(User.invite_code == invite_code)
|
||
)).scalar_one_or_none()
|
||
if inviter is None:
|
||
raise BizException(errors.INVITE_CODE_INVALID, "邀请码无效")
|
||
if inviter.id == user_id:
|
||
raise BizException(errors.INVITE_CODE_INVALID, "不能绑定自己的邀请码")
|
||
if device_id and await redis.exists(f"ref:device:{device_id}"):
|
||
old = int(await redis.get(f"ref:device:{device_id}") or 0)
|
||
if old and old != user_id:
|
||
raise BizException(errors.INVITE_CODE_INVALID, "当前设备已注册其他账号")
|
||
|
||
# 风控规则与注册时一致(邀请人单日超限冻结 / 同 IP 批量注册标记)
|
||
status, risk_flag = 0, ""
|
||
risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}"
|
||
bind_count = await redis.incr(risk_key)
|
||
await redis.expire(risk_key, 48 * 3600)
|
||
max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10)
|
||
if bind_count > max_bind:
|
||
status, risk_flag = 3, "inviter_burst"
|
||
else:
|
||
max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5)
|
||
cnt = (await db.execute(
|
||
select(func.count(User.id)).where(
|
||
User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24)
|
||
)
|
||
)).scalar() or 0
|
||
if cnt > max_reg:
|
||
status, risk_flag = 3, "batch_register"
|
||
|
||
db.add(Referral(
|
||
inviter_id=inviter.id,
|
||
invitee_id=user_id,
|
||
invite_code=invite_code,
|
||
status=status,
|
||
risk_flag=risk_flag,
|
||
register_ip=ip,
|
||
))
|
||
await db.commit()
|
||
return {"bound": True, "status": status, "status_text": REFERRAL_STATUS_TEXT[status]}
|
||
|
||
|
||
# ---------- 资产 / 提现 ----------
|
||
|
||
async def get_my_assets(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
|
||
"""资产钱包(9.3)"""
|
||
wallet = await get_or_create_asset_wallet(db, user_id)
|
||
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
|
||
has_pending = bool(await redis.exists(f"wd:pending:{user_id}")) or bool(
|
||
(await db.execute(
|
||
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
|
||
)).first()
|
||
)
|
||
return {
|
||
"assets": float(wallet.assets),
|
||
"assets_value_yuan": float(wallet.assets / per_yuan),
|
||
"total_earned": float(wallet.total_earned),
|
||
"total_withdrawn": float(wallet.total_withdrawn),
|
||
"min_withdraw_assets": int(_withdraw_cfg().get("min_assets", 100)),
|
||
"has_pending_withdraw": has_pending,
|
||
}
|
||
|
||
|
||
async def list_asset_records(
|
||
db: AsyncSession, user_id: int, page: int, page_size: int, biz_type: int | None
|
||
) -> dict:
|
||
"""资产流水(9.4)"""
|
||
stmt = select(AssetTransaction).where(AssetTransaction.user_id == user_id)
|
||
if biz_type is not None:
|
||
stmt = stmt.where(AssetTransaction.biz_type == biz_type)
|
||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||
rows = (
|
||
await db.execute(
|
||
stmt.order_by(AssetTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return {
|
||
"total": total,
|
||
"list": [
|
||
{
|
||
"id": r.id,
|
||
"change_amount": float(r.change_amount),
|
||
"balance_after": float(r.balance_after),
|
||
"biz_type": r.biz_type,
|
||
"remark": r.remark,
|
||
"created_at": _fmt(r.created_at),
|
||
}
|
||
for r in rows
|
||
],
|
||
}
|
||
|
||
|
||
async def apply_withdraw(
|
||
db: AsyncSession, redis: aioredis.Redis, user_id: int, assets: Decimal, channel: int, account: str
|
||
) -> dict:
|
||
"""申请提现(9.5):在途锁防并发 → 事务内冻结扣减 + 建单;审核拒绝由后台退回并释放锁"""
|
||
min_assets = int(_withdraw_cfg().get("min_assets", 100))
|
||
if assets < min_assets:
|
||
raise BizException(errors.WITHDRAW_TOO_LOW)
|
||
|
||
lock_key = f"wd:pending:{user_id}"
|
||
if not await redis.set(lock_key, 1, nx=True, ex=WD_LOCK_TTL):
|
||
raise BizException(errors.WITHDRAW_PENDING)
|
||
try:
|
||
# 双保险:DB 层存在待审核单也拦截(锁丢失场景)
|
||
if (await db.execute(
|
||
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
|
||
)).first():
|
||
raise BizException(errors.WITHDRAW_PENDING)
|
||
|
||
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
|
||
amount_fen = int((assets / per_yuan) * 100) # 10资产=1元 → 1资产=10分
|
||
# 冻结扣减(余额不足抛 2102,事务回滚)
|
||
await change_assets(
|
||
db, user_id, -assets, ASSET_BIZ_WITHDRAW,
|
||
remark=f"申请提现冻结({account})",
|
||
)
|
||
wd = Withdrawal(
|
||
withdraw_no=membership_service.gen_order_no("W"),
|
||
user_id=user_id,
|
||
assets=assets,
|
||
amount_fen=amount_fen,
|
||
channel=channel,
|
||
account=account,
|
||
)
|
||
db.add(wd)
|
||
await db.commit()
|
||
except Exception:
|
||
await redis.delete(lock_key) # 建单失败释放锁
|
||
raise
|
||
|
||
return {
|
||
"withdraw_no": wd.withdraw_no,
|
||
"assets": float(wd.assets),
|
||
"amount_yuan": float(Decimal(wd.amount_fen) / 100),
|
||
"status": 0,
|
||
"status_text": WITHDRAW_STATUS_TEXT[0],
|
||
}
|
||
|
||
|
||
async def list_withdraw_records(db: AsyncSession, user_id: int, page: int, page_size: int) -> dict:
|
||
"""提现记录(9.6)"""
|
||
stmt = select(Withdrawal).where(Withdrawal.user_id == user_id)
|
||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||
rows = (
|
||
await db.execute(
|
||
stmt.order_by(Withdrawal.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||
)
|
||
).scalars().all()
|
||
return {
|
||
"total": total,
|
||
"list": [
|
||
{
|
||
"withdraw_no": w.withdraw_no,
|
||
"assets": float(w.assets),
|
||
"amount_yuan": float(Decimal(w.amount_fen) / 100),
|
||
"channel": w.channel,
|
||
"status": w.status,
|
||
"status_text": WITHDRAW_STATUS_TEXT.get(w.status, ""),
|
||
"created_at": _fmt(w.created_at),
|
||
}
|
||
for w in rows
|
||
],
|
||
}
|