diff --git a/.gitignore b/.gitignore
index eaf4bc3..e1ee6ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,7 +23,13 @@ dist/
*.db-journal
*.db-shm
*.db-wal
-data/
+# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
+# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
+# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
+data/*
+!data/media/
+data/media/*
+!data/media/dl.html
secrets/*
!secrets/.gitkeep
diff --git a/data/media/dl.html b/data/media/dl.html
new file mode 100644
index 0000000..efb0d19
--- /dev/null
+++ b/data/media/dl.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+傻瓜比价 · 下载
+
+
+
+ 🛒
+ 傻瓜比价
+ 买什么都先比一比
自动帮你找全网最低价
+
+
+
🍔 点外卖前一键比价,美团/京东/淘宝到手价一目了然
+
🎟️ 自动领遍各平台红包券,能省的一分不漏
+
💰 省下的钱看得见,还能赚金币提现
+
+
+
+ Android 安卓版 · 安装包约 24 MB
+
+
+
+
+
+
+
+
点击右上角 ···
选择「在浏览器打开」
+
微信里无法直接下载安装包
需在系统浏览器中完成下载
+
+
1点右上角的 ··· 菜单
+
2选择「在浏览器打开」
+
3在浏览器里点「下载安装包」
+
+
+
我知道了 ✕
+
+
+
+
+
diff --git a/docs/README.md b/docs/README.md
index 0829806..7e9f966 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,6 +10,7 @@
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
+| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
## api/ 目录是怎么组织的(传送门式)
diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/邀请功能-实现原理与本地测试.md
new file mode 100644
index 0000000..1a8b147
--- /dev/null
+++ b/docs/邀请功能-实现原理与本地测试.md
@@ -0,0 +1,139 @@
+# 好友邀请(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 + 给 A、B 各发金币(同事务原子提交)
+```
+
+手动填码这条: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`(已邀人数 + 累计金币)。 |
+| 数据模型 | `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_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`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 = 一个真实手机号账号。
+
+发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
+
+### 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 /data/media/shaguabijia.apk`。
+
+### 4.3 跑流程
+
+1. A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(= `http://: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`。