# 好友邀请(invite-mvp)— 实现原理与本地测试

> 跨 **shaguabijia-app-server**(后端)+ **shaguabijia-app-android**(前端)的好友邀请功能。
> 本文给三件事:**做什么 / 怎么实现 / 怎么在本地内网测**(交接给同事用)。
> 代码引用一律用文件路径 + 符号名(不写行号,重构后行号会失效)。

---

## 一、功能是什么

**注册即生效**:被邀请人(B)用邀请人(A)的邀请码完成绑定 → **A、B 各得 1 万金币(= 1 元,可提现)**。

---

## 二、实现原理(数据流)

归因(B 装的这次算不算 A 邀的)有**两条通道**:

- **剪贴板自动**(deferred deeplink,生产主路径):落地页把邀请码写进剪贴板,APK 首启读回。
- **手动填码**(兜底):B 在邀请页直接输入 A 的邀请码。

剪贴板自动这条的完整跨库链路:

```
A 端(已登录)
  └─ GET /api/v1/invite/me → 拿到 invite_code + share_url(=落地页?ref=<码>)
  └─ 分享 share_url(微信/任意渠道)
        ↓
B 端(系统浏览器打开 share_url = .../media/dl.html?ref=<码>)
  └─ 点「下载安装包」
        ├─ dl.html 把 "SGBJ_INVITE:<码>" 写进剪贴板(execCommand)
        └─ 跳 APK_URL 下载安装包
        ↓
B 安装并首启 App
  └─ InviteCapture 读剪贴板,识别 "SGBJ_INVITE:" 前缀 → 暂存 pending 邀请码
  └─ B 登录成功 → AuthRepository 触发 InviteRepository.bindPendingFromClipboard()
        └─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
        ↓
后端 repositories/invite.py bind()
  └─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```

手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。

---

## 三、具体实现

### 3.1 后端(shaguabijia-app-server)

| 关注点 | 位置 |
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |

**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**

1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。

邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。

### 3.2 前端(shaguabijia-app-android)

| 关注点 | 位置 |
|---|---|
| 网络层 | `data/api/InviteApi.kt` + `InviteDto.kt`:`me()` / `bind(BindInviteRequest)`。 |
| 门面 | `data/invite/InviteRepository.kt`:`myInvite()` / `bindManual(code)` / `bindPendingFromClipboard()`。 |
| 剪贴板捕获 | `util/InviteCapture.kt`:首启读剪贴板找 `SGBJ_INVITE:` 前缀(常量 `CLIP_PREFIX`),命中即暂存到 SP(`pending_invite_code`);**未命中不烧机会**,跨多次启动最多试 `MAX_ATTEMPTS`(5)次,之后靠手动填码兜底。 |
| 自动绑定触发 | 登录成功后由 `data/auth/AuthRepository.kt` 调 `InviteRepository.bindPendingFromClipboard()`(best-effort,不抛异常)。 |
| UI | `ui/invite/InviteScreen.kt` + `InviteViewModel.kt`:展示码 / 二维码(`util/QrCodeGen.kt`)/ 战绩 + 手动填码入口。 |

### 3.3 落地页 dl.html(`data/media/dl.html`,由后端 `/media` 静态托管)

- 读 URL 的 `?ref=<邀请码>`。
- 点「下载安装包」时:`legacyCopy()` 用 `document.execCommand('copy')` **同步**把 `"SGBJ_INVITE:<码>"` 写进剪贴板(execCommand **不要求安全上下文**,内网 HTTP 也能写;`navigator.clipboard` 只是 HTTPS 下锦上添花、失败无妨),再跳 `APK_URL` 下载。
- `APK_URL = location.origin + "/media/shaguabijia.apk"`:**跟随页面 host**——本地测试时页面由笔记本(LAN:8770)托管 → 下载也走 LAN;生产由 `app-api.shaguabijia.com` 托管 → 走生产域名。**无需按机器改 IP**。
- 微信内显示「去浏览器打开」引导(微信内 execCommand / apk 下载都受限,必须跳系统浏览器)。

> dl.html 已纳入 git(见本仓 `.gitignore` 对 `data/media/dl.html` 的例外)。`data/media/shaguabijia.apk`(大二进制)、`avatars/` 等仍忽略。

---

## 四、本地内网测试方法(重点)

**拓扑**:同一 WiFi 下,1 台笔记本(起后端 + 托管落地页/APK)+ 2 台安卓真机(A 邀请人 / B 被邀请人)。落地页和 APK 都由笔记本 app-server 的 `/media` 在局域网托管,一处搞定。

### 4.1 起后端

1. 本机 PostgreSQL 起着;`conda activate price`。
2. `.env` 改两项(**本地测试覆盖,测完改回**):
   - `SMS_MOCK=true` —— 任意手机号 + 任意 6 位码秒注册,方便建 A/B 两个号(生产是 `false` 走真极光)。
   - `INVITE_LANDING_URL=http://<笔记本LAN_IP>:8770/media/dl.html` —— 让 `/invite/me` 返回的 share_url 走内网。
3. `alembic upgrade head` 建表。
   - ⚠️ **切分支后若报 `Can't locate revision`**(DB 版本指针停在别分支的迁移,例如先测过 coupon-daily-claim-gate,DB 停在 `coupon_state_tables`,而本分支没这个文件):用 `alembic stamp <共同祖先> --purge` 把指针重置到共同祖先(本分支祖先是 `11a1d08c6f55`)→ 再 `alembic upgrade head` 只跑 invite 这一条建表。**别删多余表**。详见 `docs/数据库迁移.md`。
4. `./run.sh`(监听 `0.0.0.0:8770`)。**笔记本防火墙放行入站 8770**。

### 4.2 落地页 + APK(都由 `/media` 在 LAN 托管)

- **落地页**:`data/media/dl.html` 已在 git、host-agnostic,**无需改**(clone 出来就在)。
- **APK**:编一个 debug 包放到 `data/media/shaguabijia.apk`:
  1. 前端 `local.properties` 填 `debug.api.host=<笔记本LAN_IP>` / `debug.api.port=8770`(debug 包的 `BASE_URL` 据此**编译期**烧入,见 `app/build.gradle.kts`;debug 包还放行明文 HTTP)。
  2. `./gradlew assembleDebug`。
  3. `cp app/build/outputs/apk/debug/app-debug.apk <app-server>/data/media/shaguabijia.apk`。

### 4.3 跑流程

1. A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(= `http://<LAN>:8770/media/dl.html?ref=<码>`)。
2. A 把 share_url 发出去 → B 真机**用系统浏览器**打开 → 点「下载」(写剪贴板 + 下 APK)→ 安装。
3. B 首启 → `InviteCapture` 读剪贴板拿码(Android 12+ 会弹"已粘贴"提示,正常)→ B 用**新手机号**登录 → 自动 `bind(clipboard)` → **A、B 各 +1 万金币,`invite_relation` 入库**。
4. **手动填码路径**:B 在邀请页直接输 A 的码(`channel=manual`),不依赖剪贴板——剪贴板失败时的兜底,值得单独测。

### 4.4 测试硬约束 / 坑(都是机制,不是 bug)

- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。

---

## 五、上线前还差什么(本地测通 ≠ 可上线)

本地这一关证明的是"机制能跑通",离生产可上线还差:

1. ⚠️ **alembic 多 head(不修 100% 翻车)**:`coupon_state_tables` 和 `invite_code_and_relation` 都挂在 `11a1d08c6f55` 下、是**兄弟迁移**。invite 一合 main,main 上就有两个 head,prod `alembic upgrade head` 撞 `Multiple head revisions` → 服务起不来。**合 main 前 / 部署前必须建一个 merge 迁移**把两条链并起来(参考 repo 里已有的 `*_merge_*_heads.py`)。本地单分支单 head 测不出。
2. **release 包 ≠ debug 包**:用户拿到的是 release(签名不同、强制 HTTPS、若开 minify 还有 R8 混淆),本地测的是 debug。上线前用 **release 包真机**验全流程。
3. **风控 / 薅羊毛**:1 万金币可提现,**邀请人无总数上限**,接码平台批量注册新号绑同一个码即可刷。上线前加 **inviter 上限 + 基础风控**。
4. **仅验了 happy path**:错误分支文案(`invalid_code` / `already_bound` / `self_invite` / `not_eligible`)、手动填码兜底、stats 展示更新、并发绑定都没真测。
5. **配置回归**:`SMS_MOCK` / `INVITE_LANDING_URL` 是本地覆盖,生产走真极光 + 默认生产落地页。
6. **(dl.html 纳入 git 后的部署细节)**:生产机上已有未跟踪的 `data/media/dl.html`(之前手动放的)。若走 git 部署,首次 pull 前需先移走生产那份,否则报 `untracked working tree files would be overwritten`。
