Files
2026-06-17 23:32:36 +08:00

5.7 KiB
Raw Permalink Blame History

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_modelAndroid 正则抓 ;...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 "" 设备型号(如 PJF11023046PNC9C)。浏览器侧由 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_iduser.id(硬 FK,多对一):一个邀请人的落地页可被多次访问。
  • 反查组合键 = (ip, device_model) + created_at > cutoff:等值匹配 IP 与型号、再取窗口内最近一条。注意 screen 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。
  • 反查结果不直接 join 本表→user,而是 db.get(User, fp.inviter_user_id) 拿邀请人,再回 invite_relationbind()

索引与约束

  • PK id
  • ix_invite_fp_matchip, screen, device_model, created_at):反查主索引。/bind 兜底分支按 ip + device_model + created_at 范围扫 + 倒序 LIMIT 1SQLite/PG 优化器都能用上该复合索引。
  • ix_invite_fp_inviterinviter_user_id):统计/排查用("这个邀请人的落地页被谁访问过")。

注意

  • screen 只入库不反查(关键设计,resolve_inviter_by_fingerprint docstring:浏览器算物理像素走 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_atscreen 夹在中间会打断"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_relationbind(),写一行 channel='fingerprint' 并发币;本表本身不参与发奖与幂等。
  • 迁移invite_fingerprint_tabledown_revision=0cf18d590b1d),晚于 invite_code_and_relation#24 的表先建)。