邀请关系是一本账,不是一条埋点
分享解决「人有没有来」,邀请解决「来了算谁的」。这两件事经常被混在一个模块里,后果是:点击一次就写绑定,用户还没注册,邀请人已经出现在他的账户里;或者反过来,注册时没绑,首单时才补,结果被另一个渠道截胡。
邀请关系的本质是一本账:一个被邀请人,在某一时刻,只能欠一个邀请人一次奖励。 账本一旦写错,纠正成本很高——奖励可能已经发放,用户可能已经完成首单,客服无法用一句「系统问题」解释。
所以绑定模块要回答的不是「什么时候调用接口」,而是四个判定:
- 什么时候允许写这条关系(绑定时机)。
- 已经有一条关系时,新来的请求是幂等、拒绝还是改绑(唯一性)。
- 什么条件下允许撤销,撤销后历史怎么留(可撤销)。
- 哪些请求看起来像邀请,其实是在自己邀请自己(风控)。
三个绑定时机:早、中、晚
| 时机 | 触发点 | 优点 | 风险 | 适用 |
|---|---|---|---|---|
| 点击 | 打开分享链接 | 转化路径最短 | 未注册即可写关系,容易被批量刷 | 强熟人场景、线下地推 |
| 注册 | 账号创建成功 | 身份已确立,关系可追 | 注册前的多渠道点击需要去重 | 大多数产品 |
| 首单 | 首次支付成功 | 只奖励真实交易 | 注册到首单之间的窗口会被截胡 | 高客单、奖励金额大 |
没有普适的最优解,但有一条稳定的判断:绑定时机越晚,越需要解释「为什么不是别人」。 首单绑定意味着注册后到首单前的所有分享触点都可能竞争,端上必须能展示「当前绑定人是谁、什么时候绑的」,否则用户会认为系统随机分配。
本节的模型把时机做成策略参数 allowedTimings,由服务端下发。端上不自行决定时机,只负责在正确的时点发起请求、展示结果。这是交易端贯穿始终的分工:规则归服务端,状态展示归端上。
唯一性:幂等、拒绝与改绑
唯一性不是「数据库加个唯一索引」就结束了。客户端要能区分三种结果:
- 同一邀请人重复提交:幂等成功,界面不重复提示,也不重复发奖励。
- 已有其他邀请人:拒绝,并且不提供「改绑」按钮。改绑是增长侧最常见的投诉来源,一旦开放,用户会反复比较两边的奖励。
- 本地与服务端不一致:以服务端为准,本地乐观状态回滚。
decideBinding 的判定顺序是有意安排的:先排除身份重合(自己邀请自己),再处理幂等,最后才看时机与设备风险。顺序反了会出现「已经是同一邀请人,却因为点击过期被拒绝」这种自相矛盾的结果。
可撤销,但不能抹掉历史
绑定允许撤销,条件是三条同时成立:
- 奖励尚未结算。
- 被邀请人没有产生订单。
- 仍在撤销窗口内。
撤销不是删除。服务端要保留「某账号曾在某时刻绑定某邀请人、又于某时刻撤销」的记录,否则同一对账号可以反复绑定、撤销、再绑定,把新人奖励领成周期性收入。端上展示的是当前关系,复盘看的是完整历史。
自己邀请自己的四种伪装
| 伪装方式 | 判定依据 | 处理 |
|---|---|---|
| 同一账号 | inviterId === inviteeId | 直接拒绝 |
| 同一手机号 | 手机号哈希相同 | 直接拒绝 |
| 同一设备 | 设备 id 出现在邀请人的设备列表 | 直接拒绝 |
| 同设备多账号 | 设备账号数超过阈值 | 挂起人工审核,不直接拒绝 |
前三种是明确的重合,可以直接拒绝。第四种必须挂起而不是拒绝:一台手机换账号是真实场景,家庭成员共用设备也很常见。直接拒绝会误伤,直接放行又会被工作室批量注册。挂起意味着「先不发奖励,等人工或延迟策略复核」,这是增长风控里成本最低的折中。
需要强调:手机号哈希、设备列表、账号数都必须由服务端提供并裁决。端上能做的只是把设备标识传上去,不能自己判断「这个手机号是不是邀请人的」——那等于把风控规则暴露给攻击者。
失败重试:哪些错误值得再试一次
| 错误 | 可重试 | 原因 |
|---|---|---|
| 网络、超时、5xx | 是 | 请求可能根本没到达服务端 |
| 限流 | 是 | 退避后可以恢复 |
| 冲突(已有其他绑定) | 否 | 重试一百次也是同一结果 |
| 邀请人不存在 | 否 | 参数错误,重试无意义 |
| 风控拒绝 | 否 | 需要人工或申诉通道 |
重试采用指数退避,上限四次。更重要的是冲突不能重试:很多客户端把 409 当成网络抖动,重试到第四次才提示失败,用户在此期间看到的是「绑定中」,体验比直接失败更差。
把判定写成纯函数
下面的模块属于 L1:绑定判定、撤销判定、重试计划与乐观状态对账,全部是给定输入就有确定输出的纯函数,不读时钟、不发请求。
// file: src/growth/invite.ts
// tier: L1
// verify: node --test test/growth/invite.test.ts
export type BindTiming = "click" | "register" | "first_order";
export interface InvitePolicy {
allowedTimings: BindTiming[]; clickToBindMs: number;
maxAccountsPerDevice: number; minInviterAgeMs: number; revokeWindowMs: number;
}
export interface BindRequest {
inviterId: string; inviteeId: string; deviceId: string;
phoneHash: string | null; timing: BindTiming; clickedAt: number | null; now: number;
}
export interface InviteContext {
inviterPhoneHash: string | null; inviterDeviceIds: string[];
inviterCreatedAt: number; existingInviterId: string | null;
deviceAccountCount: number; rewardSettled: boolean;
}
export type BindReason =
| "ok"
| "invalid_request"
| "self_invite"
| "same_phone"
| "inviter_device"
| "already_bound"
| "timing_not_allowed"
| "click_missing"
| "click_expired"
| "inviter_too_new"
| "device_multi_account";
export interface BindDecision {
action: "bind" | "noop" | "hold" | "reject";
reason: BindReason;
}
function requestProblem(request: BindRequest): boolean {
return (
request.inviterId.trim() === "" ||
request.inviteeId.trim() === "" ||
request.deviceId.trim() === "" ||
!Number.isFinite(request.now)
);
}
// 判定顺序即风控顺序:先排除身份重合,再处理幂等,最后才看时机与设备风险。
// 设备多账号只挂起不拒绝——同一台手机换号是真实场景,直接拒绝会误伤。
export function decideBinding(
request: BindRequest,
context: InviteContext,
policy: InvitePolicy,
): BindDecision {
if (requestProblem(request)) {
return { action: "reject", reason: "invalid_request" };
}
if (request.inviterId === request.inviteeId) {
return { action: "reject", reason: "self_invite" };
}
if (
request.phoneHash !== null &&
context.inviterPhoneHash !== null &&
request.phoneHash === context.inviterPhoneHash
) {
return { action: "reject", reason: "same_phone" };
}
if (context.inviterDeviceIds.includes(request.deviceId)) {
return { action: "reject", reason: "inviter_device" };
}
if (context.existingInviterId !== null) {
return context.existingInviterId === request.inviterId
? { action: "noop", reason: "already_bound" }
: { action: "reject", reason: "already_bound" };
}
if (!policy.allowedTimings.includes(request.timing)) {
return { action: "reject", reason: "timing_not_allowed" };
}
if (request.timing !== "click") {
if (request.clickedAt === null) {
return { action: "reject", reason: "click_missing" };
}
if (request.now - request.clickedAt > policy.clickToBindMs) {
return { action: "reject", reason: "click_expired" };
}
}
if (request.now - context.inviterCreatedAt < policy.minInviterAgeMs) {
return { action: "hold", reason: "inviter_too_new" };
}
if (context.deviceAccountCount > policy.maxAccountsPerDevice) {
return { action: "hold", reason: "device_multi_account" };
}
return { action: "bind", reason: "ok" };
}
export interface Binding {
inviterId: string; inviteeId: string; boundAt: number; timing: BindTiming;
}
export interface RevokeContext {
currentInviterId: string | null;
rewardSettled: boolean;
orderCount: number;
}
export type RevokeReason =
| "within_window"
| "stale_binding"
| "reward_settled"
| "has_orders"
| "window_expired";
export interface RevokeDecision {
action: "revoke" | "reject";
reason: RevokeReason;
}
// 绑定可撤销,但只在奖励未结算且没有产生订单时;撤销不是删除历史,
// 服务端保留撤销记录,否则同一对账号可以反复领取新人奖励。
export function decideRevoke(
binding: Binding,
context: RevokeContext,
policy: InvitePolicy,
now: number,
): RevokeDecision {
if (context.currentInviterId !== binding.inviterId) {
return { action: "reject", reason: "stale_binding" };
}
if (context.rewardSettled) {
return { action: "reject", reason: "reward_settled" };
}
if (context.orderCount > 0) {
return { action: "reject", reason: "has_orders" };
}
if (now - binding.boundAt > policy.revokeWindowMs) {
return { action: "reject", reason: "window_expired" };
}
return { action: "revoke", reason: "within_window" };
}
export type BindErrorKind =
| "network"
| "timeout"
| "server"
| "rate_limit"
| "conflict"
| "invalid_inviter"
| "already_bound"
| "risk_rejected";
export interface RetryPlan {
retryable: boolean;
delayMs: number;
maxAttempts: number;
}
const RETRY_BASE_MS = 500;
const RETRY_CAP_MS = 8_000;
// 冲突不是重试理由:服务端已有另一条绑定,重试一百次也是同一结果。
export function retryPlan(kind: BindErrorKind, attempt: number): RetryPlan {
const maxAttempts = 4;
if (!Number.isInteger(attempt) || attempt < 1) {
throw new Error("重试次数必须是正整数");
}
const retryable =
kind === "network" || kind === "timeout" || kind === "server" || kind === "rate_limit";
if (!retryable || attempt >= maxAttempts) {
return { retryable: false, delayMs: 0, maxAttempts };
}
const delayMs = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_CAP_MS);
return { retryable: true, delayMs, maxAttempts };
}
export interface BindingView {
inviterId: string; inviteeId: string; status: "pending" | "confirmed";
}
export interface ReconcileResult {
binding: BindingView | null;
rolledBack: boolean;
note: "confirmed" | "server_wins" | "server_cleared" | "rolled_back" | "empty";
}
// 端上可以先展示「绑定中」,但最终以服务端为准;本地乐观状态失败时必须回滚,
// 不能让用户看到一个服务端并不存在的邀请人。
export function reconcileBinding(
local: BindingView | null,
server: BindingView | null,
): ReconcileResult {
if (server === null) {
if (local === null) {
return { binding: null, rolledBack: false, note: "empty" };
}
return {
binding: null,
rolledBack: local.status === "pending",
note: local.status === "pending" ? "rolled_back" : "server_cleared",
};
}
if (local !== null && local.inviterId === server.inviterId) {
return { binding: server, rolledBack: false, note: "confirmed" };
}
return {
binding: server,
rolledBack: local !== null,
note: "server_wins",
};
}
两个设计点值得单独说。
判定返回四种动作,而不是布尔值。 bind、noop、hold、reject 对应四种界面:成功、无需提示、审核中、失败。如果只返回 true/false,「挂起」就只能被塞进失败里,用户会看到「邀请失败」而不是「正在审核」。
对账函数把「本地乐观」当成待确认状态。 reconcileBinding 在服务端没有记录时回滚 pending 状态,在服务端有不同邀请人时以服务端为准。它不负责发请求,只负责回答「现在该显示谁」。把这件事从 UI 里拿出来,才能保证每个入口用的是同一套规则。
测试与真实输出
本机运行结果如下:
$ node --test test/growth/invite.test.ts
✔ 注册时机允许绑定时直接通过 (1.021708ms)
✔ 邀请人与被邀请人是同一账号时拒绝 (0.077417ms)
✔ 手机号哈希相同视为自己邀请自己 (0.061ms)
✔ 设备已被邀请人使用过时拒绝 (0.058084ms)
✔ 重复提交同一邀请人是幂等成功 (0.082833ms)
✔ 已绑定其他邀请人时不允许改绑 (0.073666ms)
✔ 政策未开放的时机被拒绝 (0.068292ms)
✔ 非点击时机缺少点击时间时拒绝 (0.059875ms)
✔ 点击到绑定超过窗口时拒绝 (0.069334ms)
✔ 新注册的邀请人先挂起而不是直接拒绝 (0.121625ms)
✔ 同设备账号数超过阈值时挂起 (0.070334ms)
✔ 同设备账号数恰好等于阈值时仍然放行 (0.06825ms)
✔ 空邀请人或空设备号被判为非法请求 (0.055625ms)
✔ 未结算且无订单时允许在窗口内撤销 (0.0865ms)
✔ 奖励已结算后不允许撤销 (0.042ms)
✔ 已经产生订单的绑定不允许撤销 (0.034667ms)
✔ 超过撤销窗口后拒绝 (0.044042ms)
✔ 本地持有的绑定已不是当前邀请人时拒绝撤销 (0.047208ms)
✔ 网络错误按指数退避重试,达到上限后停止 (0.092625ms)
✔ 冲突与风控拒绝是终态,不重试 (0.052875ms)
✔ 非法重试次数被拒绝 (0.214958ms)
✔ 服务端没有绑定时回滚乐观状态 (0.05575ms)
✔ 服务端已有不同邀请人时以服务端为准 (0.036083ms)
✔ 本地与服务端一致时确认绑定 (0.031084ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 153.511209
二十四条用例覆盖绑定判定、撤销、重试与对账。最值得关注的是判定顺序:自己邀请自己优先于幂等,幂等优先于时机,时机优先于设备风险。任何一条顺序调整,都应该有对应的用例先失败。
一次「乐观绑定」引发的奖励投诉
某交易客户端的邀请页在点击「接受邀请」后立即把按钮改成「已绑定」,并在本地写入邀请人,等待后台请求返回。这个交互在弱网下体验很好,几乎感觉不到等待。
问题出在服务端的风控挂起:新注册的邀请人需要人工复核,接口返回的是「审核中」,不是成功。端上把非 2xx 一律当成失败,但本地状态已经写成「已绑定」,于是出现了两种界面并存的情况——邀请页显示已绑定,奖励页显示未绑定。
更糟的是重试逻辑:端上把服务端的 409(已有其他绑定)当成网络错误,退避重试了四次。用户在十几秒里反复看到「绑定中」,最后收到「绑定失败」,但不知道失败的是哪一次、当前到底绑了谁。
复盘结论有三条:
- 本地只能写
pending,不能写confirmed;确认必须来自服务端返回的关系。 - 「审核中」是独立状态,不能映射成失败,也不能映射成成功。
- 409 是终态,重试计划必须按错误类型分支,而不是按 HTTP 状态码是否小于 500 分支。
修复后端上只保留一个 BindingView,所有入口读同一份状态;reconcileBinding 在每次页面恢复时用服务端结果覆盖本地;重试表按错误类型而不是状态码分类。回归后,同类投诉消失。
小结
邀请绑定的核心是把「谁欠谁奖励」写成可判定的账:时机由策略决定,唯一性区分幂等与拒绝,撤销保留历史,自己邀请自己按四类重合分别处理。
端上不裁决风控,只维护一个待确认的视图,并在服务端返回后对账。bind、noop、hold、reject 四种动作对应四种界面,冲突与风控拒绝是终态,不进入重试。
下一节讨论活动本身出问题时的降级:配置拉不到、活动没开始、名额已耗尽,页面应该退到哪里。
阅读导航:上一节:9.1 分享卡片的参数与归因 · 下一节:9.3 拉新活动页的降级 。
常见错误与边界
- 点击分享链接就写绑定,用户尚未注册就出现在别人的邀请列表里。
- 允许用户改绑,奖励在两个邀请人之间反复摇摆。
- 撤销时直接删除记录,同一对账号可以循环领取新人奖励。
- 把同设备多账号直接判为作弊,误伤家庭共用设备的真实用户。
- 端上自行判断手机号是否相同,把风控规则暴露给攻击者。
- 把「审核中」映射成失败,用户以为邀请无效而放弃。
- 对 409 冲突做指数退避重试,用户在「绑定中」停留十几秒。
- 本地乐观写入
confirmed,服务端拒绝后界面与账本长期不一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。