Files
shaguabijia-app-server/app/repositories/invite.py
T
xiebing b2f5a53dd8 feat(invite): 被邀请人列表接口 + 指纹归因(任务3) (#31)
- 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>
2026-06-09 21:48:48 +08:00

286 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""好友邀请 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)