498da05995
(工作区既有改动, 非本次会话产出)
5.7 KiB
5.7 KiB
invite_fingerprint — 邀请指纹归因(剪贴板兜底)
模型
app/models/invite_fingerprint.py(InviteFingerprint) · 仓库app/repositories/invite.py· 接口app/api/v1/invite.py:POST /api/v1/invite/landing-track(写,无鉴权)、POST /api/v1/invite/bind(读,指纹反查分支) · ← 索引 · 总览
一行 = 一次落地页访问的设备指纹快照。剪贴板归因的兜底:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 (inviter, ip, screen, device_model, ua) 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 (ip, device_model) 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 bind(channel='fingerprint')。任务来源见 #31 好友邀请任务 3。
用在哪 / 增删改查
- C(插入):
POST /api/v1/invite/landing-track(landing_track,无需鉴权,浏览器没 token)→invite_repo.record_fingerprint()。B 浏览器打开dl.html?ref=<码>时 JS POST,服务端从 HTTP 头拿ip(_client_ip,优先X-Forwarded-For第一段)、解析 UA 拿device_model(_parse_device_model,Android 正则抓;...Build/前 token =Build.MODEL),连同 JS 上报的screen一起入库。record_fingerprint自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(invalid_code/no_ip,不抛 5xx 影响下载流程)。 - R(反查):
POST /api/v1/invite/bind(bind_invite)的指纹分支 →resolve_inviter_by_fingerprint()。仅在请求无邀请码、有fingerprint时触发:WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)ORDER BY created_at DESC LIMIT 1→ 命中则取inviter_user_id对应用户 → 用其invite_code走原bind()(同样过幂等/自邀/新人闸/发币逻辑);未命中返回fp_not_found。 - U / D:无。只增不改不删。
字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
id |
Integer | PK, autoincrement | |
inviter_user_id |
Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 ?ref=<码> 解析得到) |
ip |
String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 X-Forwarded-For)。入库 [:64]。反查参与匹配 |
device_model |
String(64) | NOT NULL, default "" |
设备型号(如 PJF110、23046PNC9C)。浏览器侧由 UA 正则解析,客户端侧 = Build.MODEL,跨端对齐。入库 [:64]。反查参与匹配 |
screen |
String(32) | NOT NULL, default "" |
屏幕分辨率 "1080x2400"(浏览器 screen.w×h,客户端 DisplayMetrics)。入库 [:32]。只入库不反查(见注意) |
user_agent |
Text | NOT NULL, default "" |
完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 [:2000] 防异常长 UA |
created_at |
DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) |
关系 / Join Key
inviter_user_id→user.id(硬 FK,多对一):一个邀请人的落地页可被多次访问。- 反查组合键 =
(ip, device_model)+created_at > cutoff:等值匹配 IP 与型号、再取窗口内最近一条。注意screen虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。 - 反查结果不直接 join 本表→
user,而是db.get(User, fp.inviter_user_id)拿邀请人,再回invite_relation走bind()。
索引与约束
- PK
id。 ix_invite_fp_match(ip, screen, device_model, created_at):反查主索引。/bind兜底分支按ip + device_model + created_at范围扫 + 倒序 LIMIT 1,SQLite/PG 优化器都能用上该复合索引。ix_invite_fp_inviter(inviter_user_id):统计/排查用("这个邀请人的落地页被谁访问过")。
注意
screen只入库不反查(关键设计,resolve_inviter_by_fingerprintdocstring):浏览器算物理像素走CSS × devicePixelRatio、Android 走DisplayMetrics.widthPixels真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而同device_model必同screen(同型号同硬件),故screen是冗余字段,删它不损精度。所以匹配只用(ip, device_model)。- 组合索引为何含
screen:索引列序是(ip, screen, device_model, created_at),但 WHERE 只用ip/device_model/created_at。screen夹在中间会打断"ip之后直接接device_model"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成(ip, device_model, created_at)。 - 7 天窗口(
rewards.INVITE_FP_WINDOW_DAYS)的取舍:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。 - 撞错概率:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。
- 与 invite_relation 的关系:本表只是线索库,不发奖、不是结果。真正落地是反查出邀请人后回到 invite_relation 的
bind(),写一行channel='fingerprint'并发币;本表本身不参与发奖与幂等。 - 迁移:
invite_fingerprint_table(down_revision=0cf18d590b1d),晚于invite_code_and_relation(#24 的表先建)。