《互联网产品的客户端架构·交易、增长与平台化》9.2 邀请关系与绑定时机

邀请关系是一本「谁欠谁奖励」的账,绑定时机早一步会被刷、晚一步会被截胡。本节比较点击、注册、首单三个绑定时机,拆开唯一性、可撤销与自己邀请自己的四类伪装,并给出一个把绑定判定、撤销与失败重试写成纯函数、可在本地用 24 条用例验证的可运行模型。

邀请关系是一本账,不是一条埋点

分享解决「人有没有来」,邀请解决「来了算谁的」。这两件事经常被混在一个模块里,后果是:点击一次就写绑定,用户还没注册,邀请人已经出现在他的账户里;或者反过来,注册时没绑,首单时才补,结果被另一个渠道截胡。

邀请关系的本质是一本账:一个被邀请人,在某一时刻,只能欠一个邀请人一次奖励。 账本一旦写错,纠正成本很高——奖励可能已经发放,用户可能已经完成首单,客服无法用一句「系统问题」解释。

所以绑定模块要回答的不是「什么时候调用接口」,而是四个判定:

  • 什么时候允许写这条关系(绑定时机)。
  • 已经有一条关系时,新来的请求是幂等、拒绝还是改绑(唯一性)。
  • 什么条件下允许撤销,撤销后历史怎么留(可撤销)。
  • 哪些请求看起来像邀请,其实是在自己邀请自己(风控)。

三个绑定时机:早、中、晚

时机触发点优点风险适用
点击打开分享链接转化路径最短未注册即可写关系,容易被批量刷强熟人场景、线下地推
注册账号创建成功身份已确立,关系可追注册前的多渠道点击需要去重大多数产品
首单首次支付成功只奖励真实交易注册到首单之间的窗口会被截胡高客单、奖励金额大

没有普适的最优解,但有一条稳定的判断:绑定时机越晚,越需要解释「为什么不是别人」。 首单绑定意味着注册后到首单前的所有分享触点都可能竞争,端上必须能展示「当前绑定人是谁、什么时候绑的」,否则用户会认为系统随机分配。

本节的模型把时机做成策略参数 allowedTimings,由服务端下发。端上不自行决定时机,只负责在正确的时点发起请求、展示结果。这是交易端贯穿始终的分工:规则归服务端,状态展示归端上。

唯一性:幂等、拒绝与改绑

唯一性不是「数据库加个唯一索引」就结束了。客户端要能区分三种结果:

  • 同一邀请人重复提交:幂等成功,界面不重复提示,也不重复发奖励。
  • 已有其他邀请人:拒绝,并且不提供「改绑」按钮。改绑是增长侧最常见的投诉来源,一旦开放,用户会反复比较两边的奖励。
  • 本地与服务端不一致:以服务端为准,本地乐观状态回滚。

decideBinding 的判定顺序是有意安排的:先排除身份重合(自己邀请自己),再处理幂等,最后才看时机与设备风险。顺序反了会出现「已经是同一邀请人,却因为点击过期被拒绝」这种自相矛盾的结果。

可撤销,但不能抹掉历史

绑定允许撤销,条件是三条同时成立:

  1. 奖励尚未结算。
  2. 被邀请人没有产生订单。
  3. 仍在撤销窗口内。

撤销不是删除。服务端要保留「某账号曾在某时刻绑定某邀请人、又于某时刻撤销」的记录,否则同一对账号可以反复绑定、撤销、再绑定,把新人奖励领成周期性收入。端上展示的是当前关系,复盘看的是完整历史。

自己邀请自己的四种伪装

伪装方式判定依据处理
同一账号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(已有其他绑定)当成网络错误,退避重试了四次。用户在十几秒里反复看到「绑定中」,最后收到「绑定失败」,但不知道失败的是哪一次、当前到底绑了谁。

复盘结论有三条:

  1. 本地只能写 pending,不能写 confirmed;确认必须来自服务端返回的关系。
  2. 「审核中」是独立状态,不能映射成失败,也不能映射成成功。
  3. 409 是终态,重试计划必须按错误类型分支,而不是按 HTTP 状态码是否小于 500 分支。

修复后端上只保留一个 BindingView,所有入口读同一份状态;reconcileBinding 在每次页面恢复时用服务端结果覆盖本地;重试表按错误类型而不是状态码分类。回归后,同类投诉消失。

小结

邀请绑定的核心是把「谁欠谁奖励」写成可判定的账:时机由策略决定,唯一性区分幂等与拒绝,撤销保留历史,自己邀请自己按四类重合分别处理。

端上不裁决风控,只维护一个待确认的视图,并在服务端返回后对账。bind、noop、hold、reject 四种动作对应四种界面,冲突与风控拒绝是终态,不进入重试。

下一节讨论活动本身出问题时的降级:配置拉不到、活动没开始、名额已耗尽,页面应该退到哪里。

阅读导航:上一节:9.1 分享卡片的参数与归因 · 下一节:9.3 拉新活动页的降级 。

常见错误与边界

  • 点击分享链接就写绑定,用户尚未注册就出现在别人的邀请列表里。
  • 允许用户改绑,奖励在两个邀请人之间反复摇摆。
  • 撤销时直接删除记录,同一对账号可以循环领取新人奖励。
  • 把同设备多账号直接判为作弊,误伤家庭共用设备的真实用户。
  • 端上自行判断手机号是否相同,把风控规则暴露给攻击者。
  • 把「审核中」映射成失败,用户以为邀请无效而放弃。
  • 对 409 冲突做指数退避重试,用户在「绑定中」停留十几秒。
  • 本地乐观写入 confirmed,服务端拒绝后界面与账本长期不一致。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查