# 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`(读，指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md)

一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 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_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_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](./invite_relation.md) 的 `bind()`，写一行 `channel='fingerprint'` 并发币；本表本身不参与发奖与幂等。
- **迁移**：`invite_fingerprint_table`（`down_revision=0cf18d590b1d`），晚于 `invite_code_and_relation`（#24 的表先建）。
