b2f5a53dd8
- GET /invitees: 分页 + 名字降级兜底(昵称→微信昵称→脱敏手机号) - 指纹归因: 落地页采集 + (IP,设备型号)反查撞库 + 时间窗口闸 - test_invite: +5 个列表测试(脱敏/倒序/昵称优先/分页/空), 修指纹测试跨用例串味 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Reviewed-on: #31 Co-authored-by: xiebing <xiebing@wonderable.ai> Co-committed-by: xiebing <xiebing@wonderable.ai>
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
|
||
|
||
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
|
||
1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。
|
||
2. 自邀屏蔽 → inviter == invitee 直接拒。
|
||
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。
|
||
|
||
发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务**
|
||
commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS /
|
||
INVITE_INVITEE_COINS。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import secrets
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core import rewards
|
||
from app.models.invite import InviteRelation
|
||
from app.models.invite_fingerprint import InviteFingerprint
|
||
from app.models.user import User
|
||
from app.repositories import wallet as crud_wallet
|
||
|
||
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
|
||
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
|
||
_CODE_LEN = 6
|
||
|
||
|
||
def _gen_code() -> str:
|
||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||
|
||
|
||
def ensure_code(db: Session, user: User) -> str:
|
||
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||
|
||
⚠️ 本函数会 db.commit() 整个 session(与 grant_coins"只 flush 不 commit"约定相反)。
|
||
当前只在 GET /invite/me 里调(该请求此前无其它写,提交范围干净)。若将来在带其它未提交
|
||
写的请求里复用本函数,会被它提前 commit——届时应改成 flush + 由调用方 commit。
|
||
"""
|
||
if user.invite_code:
|
||
return user.invite_code
|
||
for _ in range(8):
|
||
user.invite_code = _gen_code()
|
||
try:
|
||
db.commit()
|
||
db.refresh(user)
|
||
return user.invite_code
|
||
except IntegrityError:
|
||
db.rollback()
|
||
user = db.get(User, user.id) # 重取(rollback 后实例已过期),继续换码
|
||
raise RuntimeError("生成邀请码连续碰撞,请重试")
|
||
|
||
|
||
def resolve_inviter(db: Session, invite_code: str) -> User | None:
|
||
"""邀请码 → 邀请人(大小写不敏感)。"""
|
||
code = (invite_code or "").strip().upper()
|
||
if not code:
|
||
return None
|
||
return db.execute(
|
||
select(User).where(User.invite_code == code)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
def _relation_of_invitee(db: Session, invitee_id: int) -> InviteRelation | None:
|
||
return db.execute(
|
||
select(InviteRelation).where(InviteRelation.invitee_user_id == invitee_id)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
def _is_new_user(user: User) -> bool:
|
||
"""被邀请人是否为"新注册"(created_at 在 INVITE_NEW_USER_WINDOW_HOURS 窗口内)。
|
||
|
||
"新用户闸":只奖刚注册的人,挡存量老用户互相填码薅羊毛。兼容 PG(tz-aware)与
|
||
SQLite(naive,按 UTC 解释)。
|
||
"""
|
||
created = user.created_at
|
||
if created is None:
|
||
return False
|
||
if created.tzinfo is None:
|
||
created = created.replace(tzinfo=timezone.utc)
|
||
age = datetime.now(timezone.utc) - created
|
||
return age <= timedelta(hours=rewards.INVITE_NEW_USER_WINDOW_HOURS)
|
||
|
||
|
||
@dataclass
|
||
class BindResult:
|
||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||
relation: InviteRelation | None = None
|
||
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
|
||
|
||
|
||
def bind(
|
||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||
) -> BindResult:
|
||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
|
||
|
||
幂等:invitee 已被绑过 → already_bound(不重复发奖)。
|
||
"""
|
||
# 幂等:已绑过直接返回(不重复发奖)
|
||
existing = _relation_of_invitee(db, invitee.id)
|
||
if existing is not None:
|
||
return BindResult("already_bound", existing)
|
||
|
||
inviter = resolve_inviter(db, invite_code)
|
||
if inviter is None or inviter.status != "active":
|
||
return BindResult("invalid_code")
|
||
if inviter.id == invitee.id:
|
||
return BindResult("self_invite")
|
||
# 新用户闸:被邀请人必须是"新注册"(窗口内)才发奖,挡存量老用户互相填码薅羊毛
|
||
if not _is_new_user(invitee):
|
||
return BindResult("not_eligible")
|
||
|
||
inviter_coin = rewards.INVITE_INVITER_COINS
|
||
invitee_coin = rewards.INVITE_INVITEE_COINS
|
||
|
||
rel = InviteRelation(
|
||
inviter_user_id=inviter.id,
|
||
invitee_user_id=invitee.id,
|
||
channel=(channel or "clipboard")[:16],
|
||
status="effective",
|
||
inviter_coin=inviter_coin,
|
||
invitee_coin=invitee_coin,
|
||
)
|
||
db.add(rel)
|
||
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
|
||
crud_wallet.grant_coins(
|
||
db, inviter.id, inviter_coin,
|
||
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
|
||
)
|
||
crud_wallet.grant_coins(
|
||
db, invitee.id, invitee_coin,
|
||
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
|
||
)
|
||
try:
|
||
db.commit()
|
||
except IntegrityError:
|
||
# 并发:同一 invitee 另一个请求先建了关系 → 回滚返回已存在(幂等兜底)
|
||
db.rollback()
|
||
existing = _relation_of_invitee(db, invitee.id)
|
||
if existing is not None:
|
||
return BindResult("already_bound", existing)
|
||
raise
|
||
except Exception:
|
||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
|
||
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||
db.rollback()
|
||
raise
|
||
db.refresh(rel)
|
||
return BindResult("success", rel, invitee_coin)
|
||
|
||
|
||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
|
||
count = db.execute(
|
||
select(func.count())
|
||
.select_from(InviteRelation)
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
).scalar_one()
|
||
coins = db.execute(
|
||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
).scalar_one()
|
||
return int(count), int(coins)
|
||
|
||
|
||
def _mask_phone(phone: str) -> str:
|
||
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||
|
||
11 位标准手机号 → 前 3 + **** + 后 4;非标准(异常/截断)→ 只露后 4 位;太短 → "新用户"。
|
||
"""
|
||
p = (phone or "").strip()
|
||
if len(p) == 11:
|
||
return f"{p[:3]}****{p[-4:]}"
|
||
if len(p) >= 4:
|
||
return f"****{p[-4:]}"
|
||
return "新用户"
|
||
|
||
|
||
def get_invitees(
|
||
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
|
||
) -> tuple[list[dict], int, bool]:
|
||
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
|
||
|
||
每条 item = {display_name, avatar_url, coins, invited_at}。降级兜底:
|
||
名字 = 昵称 → 微信昵称 → 脱敏手机号(很多被邀请人是刚注册新用户、没设资料);
|
||
头像 = 用户头像 → 微信头像 → None(前端画默认色块)。
|
||
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)。
|
||
"""
|
||
total = db.execute(
|
||
select(func.count())
|
||
.select_from(InviteRelation)
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
).scalar_one()
|
||
|
||
rows = db.execute(
|
||
select(InviteRelation, User)
|
||
.join(User, User.id == InviteRelation.invitee_user_id)
|
||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||
.order_by(InviteRelation.created_at.desc())
|
||
.limit(limit)
|
||
.offset(offset)
|
||
).all()
|
||
|
||
items: list[dict] = []
|
||
for rel, u in rows:
|
||
items.append({
|
||
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
|
||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||
"coins": rel.inviter_coin,
|
||
"invited_at": rel.created_at,
|
||
})
|
||
has_more = offset + len(rows) < int(total)
|
||
return items, int(total), has_more
|
||
|
||
|
||
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
|
||
|
||
def record_fingerprint(
|
||
db: Session,
|
||
*,
|
||
inviter_user_id: int,
|
||
ip: str,
|
||
screen: str,
|
||
device_model: str,
|
||
user_agent: str,
|
||
) -> InviteFingerprint:
|
||
"""落地页 dl.html 访问时调用,写一行指纹记录。
|
||
|
||
后续被邀请人登录、剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
|
||
7 天内最近一条匹配 → 拿出 inviter_user_id 撞库。
|
||
|
||
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作。
|
||
"""
|
||
fp = InviteFingerprint(
|
||
inviter_user_id=inviter_user_id,
|
||
ip=ip[:64],
|
||
screen=screen[:32],
|
||
device_model=device_model[:64],
|
||
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
|
||
)
|
||
db.add(fp)
|
||
db.commit()
|
||
db.refresh(fp)
|
||
return fp
|
||
|
||
|
||
def resolve_inviter_by_fingerprint(
|
||
db: Session,
|
||
*,
|
||
ip: str,
|
||
screen: str,
|
||
device_model: str,
|
||
window_days: int,
|
||
) -> User | None:
|
||
"""根据指纹反查邀请人(time window 内最近一条匹配)。
|
||
|
||
匹配规则:(ip, device_model) 相等 + created_at > now - window_days。
|
||
|
||
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径、
|
||
Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
|
||
(DPR 不严格是整数)→ 严格匹配注定撞不上。同 device_model 必同 screen(同型号同硬件)
|
||
→ screen 是冗余字段,删它不损精度。撞错只有"同 IP 下同型号手机同时被邀请"才会
|
||
发生,中国家庭/公司 WiFi 场景概率极低。
|
||
"""
|
||
if not ip:
|
||
return None
|
||
from datetime import datetime, timedelta, timezone
|
||
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
|
||
fp = db.execute(
|
||
select(InviteFingerprint)
|
||
.where(
|
||
InviteFingerprint.ip == ip,
|
||
InviteFingerprint.device_model == device_model,
|
||
InviteFingerprint.created_at > cutoff,
|
||
)
|
||
.order_by(InviteFingerprint.created_at.desc())
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
if fp is None:
|
||
return None
|
||
return db.get(User, fp.inviter_user_id)
|