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
+324
View File
@@ -0,0 +1,324 @@
"""激励服务:资产钱包 / 推荐绑定 / 提现(对应《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
],
}