1548406f29
Co-authored-by: lowmaster-chen <1119780489@qq.com> Co-authored-by: chenshuobo <1119780489@qq.com> Reviewed-on: #88 Co-authored-by: chenshuobo <chenshuobo@wonderable.ai> Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
10 KiB
10 KiB
好友邀请(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):
- 被邀请人唯一:
invitee_user_id唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回already_bound,不重复绑定)。 - 自邀屏蔽:
inviter == invitee→self_invite。 - 新人闸:
_is_new_user(B 的created_at在INVITE_NEW_USER_WINDOW_HOURS= 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则not_eligible。 - 手机号唯一(天然限量):每个 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 起后端
- 本机 PostgreSQL 起着;
conda activate price。 .env改两项(本地测试覆盖,测完改回):SMS_MOCK=true—— 任意手机号 + 任意 6 位码秒注册,方便建 A/B 两个号(生产是false走真极光)。INVITE_LANDING_URL=http://<笔记本LAN_IP>:8770/media/dl.html—— 让/invite/me返回的 share_url 走内网。
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。
- ⚠️ 切分支后若报
./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:- 前端
local.properties填debug.api.host=<笔记本LAN_IP>/debug.api.port=8770(debug 包的BASE_URL据此编译期烧入,见app/build.gradle.kts;debug 包还放行明文 HTTP)。 ./gradlew assembleDebug。cp app/build/outputs/apk/debug/app-debug.apk <app-server>/data/media/shaguabijia.apk。
- 前端
4.3 跑流程
- A 真机装 debug 包 → 登录 → 邀请页拿到 6 位码 / share_url(=
http://<LAN>:8770/media/dl.html?ref=<码>)。 - A 把 share_url 发出去 → B 真机用系统浏览器打开 → 点「下载」(写剪贴板 + 下 APK)→ 安装。
- B 首启 →
InviteCapture读剪贴板拿码(Android 12+ 会弹"已粘贴"提示,正常)→ B 用新手机号登录 → 自动bind(clipboard)→ A、B 各 +1 万金币,invite_relation入库。 - 手动填码路径: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。
五、上线前还差什么(本地测通 ≠ 可上线)
本地这一关证明的是"机制能跑通",离生产可上线还差:
- ⚠️ alembic 多 head(不修 100% 翻车):
coupon_state_tables和invite_code_and_relation都挂在11a1d08c6f55下、是兄弟迁移。invite 一合 main,main 上就有两个 head,prodalembic upgrade head撞Multiple head revisions→ 服务起不来。合 main 前 / 部署前必须建一个 merge 迁移把两条链并起来(参考 repo 里已有的*_merge_*_heads.py)。本地单分支单 head 测不出。 - release 包 ≠ debug 包:用户拿到的是 release(签名不同、强制 HTTPS、若开 minify 还有 R8 混淆),本地测的是 debug。上线前用 release 包真机验全流程。
- 风控 / 薅羊毛:1 万金币可提现,邀请人无总数上限,接码平台批量注册新号绑同一个码即可刷。上线前加 inviter 上限 + 基础风控。
- 仅验了 happy path:错误分支文案(
invalid_code/already_bound/self_invite/not_eligible)、手动填码兜底、stats 展示更新、并发绑定都没真测。 - 配置回归:
SMS_MOCK/INVITE_LANDING_URL是本地覆盖,生产走真极光 + 默认生产落地页。 - (dl.html 纳入 git 后的部署细节):生产机上已有未跟踪的
data/media/dl.html(之前手动放的)。若走 git 部署,首次 pull 前需先移走生产那份,否则报untracked working tree files would be overwritten。