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>
This commit was merged in pull request #31.
This commit is contained in:
xiebing
2026-06-09 21:48:48 +08:00
committed by marco
parent a828b51d9f
commit b2f5a53dd8
11 changed files with 777 additions and 23 deletions
+172 -19
View File
@@ -1,25 +1,37 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码,注册即生效,双方各发金币
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
① clipboard:首启读剪贴板拿邀请码 → 上报码
② manual:用户在邀请页输入邀请码 → 上报码
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
客户端两种调用 /bind:
- 自动:首启读剪贴板拿到邀请码 → 登录后调本接口(channel=clipboard)
- 手动:用户在邀请页输入好友邀请码(channel=manual)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币)在 repositories/invite.py。
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
repositories/invite.py。
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter
from fastapi import APIRouter, Request
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import BindInviteIn, BindInviteOut, InviteInfoOut
from app.schemas.invite import (
BindInviteIn,
BindInviteOut,
InviteeItem,
InviteeListOut,
InviteInfoOut,
LandingTrackIn,
LandingTrackOut,
)
logger = logging.getLogger("shagua.invite")
@@ -31,9 +43,43 @@ _BIND_MESSAGES = {
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
"fp_not_found": "未匹配到邀请关系",
}
def _client_ip(request: Request) -> str:
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
xff = request.headers.get("x-forwarded-for", "")
if xff:
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
def _parse_device_model(ua: str) -> str:
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC''PJF110')。
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
"""
if not ua:
return ""
m = _ANDROID_BUILD_MODEL_RE.search(ua)
if m:
return m.group(1).strip()
try:
from user_agents import parse
return (parse(ua).device.model or "").strip()
except Exception: # noqa: BLE001
return ""
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
@@ -48,17 +94,124 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
)
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(req: BindInviteIn, user: CurrentUser, db: DbSession) -> BindInviteOut:
result = invite_repo.bind(
db, invitee=user, invite_code=req.invite_code, channel=req.channel,
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
def my_invitees(
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
) -> InviteeListOut:
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
limit 夹到 [1,50] 防滥用;offset 不小于 0。
"""
limit = max(1, min(limit, 50))
offset = max(0, offset)
items, total, has_more = invite_repo.get_invitees(
db, user.id, limit=limit, offset=offset,
)
return InviteeListOut(
items=[InviteeItem(**it) for it in items],
total=total,
has_more=has_more,
)
@router.post(
"/landing-track",
response_model=LandingTrackOut,
summary="落地页指纹采集(无需鉴权)",
)
def landing_track(
req: LandingTrackIn, request: Request, db: DbSession
) -> LandingTrackOut:
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
"""
inviter = invite_repo.resolve_inviter(db, req.ref)
if inviter is None or inviter.status != "active":
return LandingTrackOut(status="invalid_code")
ip = _client_ip(request)
if not ip:
return LandingTrackOut(status="no_ip")
ua_str = request.headers.get("user-agent", "")
device_model = _parse_device_model(ua_str)
invite_repo.record_fingerprint(
db,
inviter_user_id=inviter.id,
ip=ip,
screen=req.screen,
device_model=device_model,
user_agent=ua_str,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, req.invite_code, req.channel, result.status,
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
inviter.id, ip, req.screen, device_model,
)
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
code = (req.invite_code or "").strip()
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
if code:
result = invite_repo.bind(
db, invitee=user, invite_code=code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
if req.fingerprint is not None:
ip = _client_ip(request)
inviter = invite_repo.resolve_inviter_by_fingerprint(
db,
ip=ip,
screen=req.fingerprint.screen,
device_model=req.fingerprint.device_model,
window_days=rewards.INVITE_FP_WINDOW_DAYS,
)
if inviter is None:
logger.info(
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
)
return BindInviteOut(
status="fp_not_found",
coins_awarded=0,
message=_BIND_MESSAGES["fp_not_found"],
)
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
result = invite_repo.bind(
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
)
logger.info(
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
user.id, inviter.id, inviter.invite_code, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 3:啥都没传 → 无效请求
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
status="invalid_code",
coins_awarded=0,
message=_BIND_MESSAGES["invalid_code"],
)
+5
View File
@@ -85,6 +85,11 @@ INVITE_INVITEE_COINS: int = 10000
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
+1
View File
@@ -13,6 +13,7 @@ from app.models.coupon_state import ( # noqa: F401
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
+46
View File
@@ -0,0 +1,46 @@
"""邀请指纹归因表(剪贴板归因兜底)。
数据流(对应 [[invite-three-tasks]] 任务 3):
1. B 浏览器打开落地页 dl.html?ref=邀请码 → JS POST /api/v1/invite/landing-track
2. 后端从 HTTP 头拿 IP/UA,解析 UA 得 device_model,跟 JS 上报的 screen 一起入库
3. B 装包首启 → 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
上报 → 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 → 拿出 inviter_user_id
→ 走原 bind 路径(channel=fingerprint)
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteFingerprint(Base):
__tablename__ = "invite_fingerprint"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<InviteFingerprint inviter={self.inviter_user_id} "
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
)
+119
View File
@@ -21,6 +21,7 @@ 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
@@ -164,3 +165,121 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
.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)
+51 -3
View File
@@ -1,6 +1,8 @@
"""邀请相关 API 收发模型。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -11,15 +13,61 @@ class InviteInfoOut(BaseModel):
coins_earned: int # 累计从邀请获得的金币
class LandingTrackIn(BaseModel):
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
class LandingTrackOut(BaseModel):
status: str # ok / invalid_code / no_ip
class FingerprintIn(BaseModel):
"""客户端登录后剪贴板归因失败时上报的设备指纹。
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值。
IP 服务端从 HTTP 头拿,不在这里。
"""
screen: str = Field("", max_length=32)
device_model: str = Field("", max_length=64)
class BindInviteIn(BaseModel):
invite_code: str = Field(..., min_length=4, max_length=16, description="邀请人的邀请码")
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
invite_code: str | None = Field(
None, max_length=16, description="邀请码;走指纹兜底时为空"
)
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(用户手动填)",
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
)
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
fingerprint: FingerprintIn | None = None
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
class InviteeItem(BaseModel):
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
display_name / avatar_url 的"降级兜底"在后端算好(见 repositories/invite.get_invitees):
名字 = 昵称 → 微信昵称 → 脱敏手机号(前端拿不到完整号,必须后端脱敏);
头像 = 用户头像 → 微信头像 → null(前端拿到 null 画默认色块)。
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
class InviteeListOut(BaseModel):
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
items: list[InviteeItem]
total: int # 我邀请的总人数(用于"共 N 人")
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)