Files
shaguabijia-app-server/docs/guides/邀请功能-实现原理与本地测试.md
2026-06-28 10:32:36 +08:00

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.pymy_invite:settings.INVITE_LANDING_URL + "?ref=" + codeINVITE_LANDING_URLapp/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.pyInviteRelation(inviter_user_id / invitee_user_id / channel / status / inviter_coin / invitee_coin / created_at)+ app/models/user.pyUser.invite_code 列。
迁移 alembic/versions/invite_code_and_relation.py:给 userinvite_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 == inviteeself_invite
  3. 新人闸:_is_new_user(B 的 created_atINVITE_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.ktInviteRepository.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(见本仓 .gitignoredata/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.propertiesdebug.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_tablesinvite_code_and_relation 都挂在 11a1d08c6f55 下、是兄弟迁移。invite 一合 main,main 上就有两个 head,prod alembic upgrade headMultiple 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