freehire 计费架构设计:基于 RevenueCat 的 Pro 订阅、Webhook 信号与幂等对账

原创2026-10-22 23:08:101,656 阅读
文章标签:后端前端搜索引擎网页爬虫数据工程AI 应用可观测性

freehire 计费架构设计:基于 RevenueCat 的 Pro 订阅、Webhook 信号与幂等对账

本文以 freehire 开源仓库中的 add-pro-subscription 设计文档为骨架,拆解这套“只持有一个派生时间戳”的订阅系统:为什么计费代码可以公开、users.pro_until 的写入边界如何收窄到两个调用方、为什么 webhook 只能当作信号而不能当作事实,以及 billing_events + cmd/billing-sync 如何把“投递不保证顺序、重试五次后彻底放弃”的第三方通道,补成一条不会静默丢单的链路。读完后,你应当能判断这套设计在什么前提下成立、每个配置项真正影响什么,并能对着源码定位到具体实现位置。

说明:设计文档记录的是变更当时的决策。仓库后续演进(如 add-store-purchases、迁移 0135)对部分细节做了修订,本文在相应位置会明确标注“设计原文”与“当前实现”的差异,避免把过期结论当成事实。

一、问题起点:一个能升级的 402,却没有升级入口

设计文档开篇指出的现实是:add-plan-limits 已经交付了付费墙的几乎全部内容——users.pro_until 决定产品里的每一项配额,未付费的请求会拿到 402 并被告知去升级——但升级本身无处可去,唯一能设置该字段的方式是在 psql 里手工 UPDATE。

缺失部分的形状其实在加这一列时就已经定死:

  • 订阅关系由支付提供商持有;
  • freehire 只持有一个派生出来的时间戳;
  • 计量请求路径读数据库列,而不是调第三方 API。

这套约束的直接收益写在源码注释里:提供商变慢或不可达,永远不能延迟用户的下一次提问(见 config.go)。于是这次变更要交付的东西就被压缩成三件小东西:一个 webhook、一个对账器、一条指向托管结账页的链接。

二、为什么计费代码留在公开仓库

这是设计文档里最完整的一段论证,也是最容易被“直觉”推翻的一段,值得完整继承。

两个常见担忧并不由闭源解决。 密钥从来不在源码里,无论开源还是闭源——它们在 /opt/freehire/.env,而 gitleaks 同时看守暂存索引与整个提交历史。法律责任(谁是卖方、谁承担增值税、谁负责退款)由法律实体与支付提供商决定,而不是由某个 webhook handler 的可见性决定。

那些把计费闭源的项目,回答的是另一个问题。 GitLab、Sentry、Cal.com 出售的是“可被竞争对手商业自托管的软件”,因此需要许可证边界,计费只是恰好落在边界另一侧;Ghost 和 Plausible 出售的是托管服务,它们的 Stripe/Paddle 集成是公开的。freehire 属于后一类,而且是极端形式:护城河是 800 万条职位、爬取集群和生产主机,读这个包并不能复制其中任何一项。边界其实早已让出——仓库是 MIT,已有 105 个 fork,事后划一个 ee/ 式的保留区并不能追溯限制既有代码。

唯一真实的成本是噪音,而它被廉价地买断了。 公开计费会招来自托管者的 issue 和 PR。缓解手段是一个单独的包、一份声明“这是 freehire.me 的托管计费、不支持自托管”的 AGENTS.md,加上下文会讲的“未配置即不可见”规则。这买到了别的项目用第二个仓库换来的安静,却不必付出“两个仓库共管一个数据库”的代价。

被否决的方案:独立私有计费服务。 它需要写 users.pro_until,于是两个仓库共管一份 schema,而 migrations/、make sqlc 和 internal/platform/arch/layering/blocks.go 全都留在这里。为了一个 MIT 早已放弃的保护,换来每次部署都横着一道永久接缝,不划算。

这段结论在当前仓库里可以直接验证:billing/AGENTS.md 与包注释 config.go 都重复了“不支持自托管、未配置即惰性”的声明。

三、users.pro_until 的所有权:两个写入者,一个派生值

设计文档引用迁移 0120 定下的形状:提供商侧记录(产品、状态、周期、支付方式)留在提供商那里,我们只保留一个派生时间戳。这次变更要做的是点名写入者。

变更完成后,users.pro_until 恰好由两个调用方写入,二者都在 internal/identity/billing:webhook handler 与 cmd/billing-sync。别的代码不写它。同时,在 psql 里手工设置的值对客服仍然安全——时间戳会自己过期,忘记删除的成本为零,这正是 0120 当初选择这种字段形态的原因。

写入的值始终派生自 RevenueCat 当前的订阅者状态,绝不从事件累加。这就是“幂等、与顺序无关、可安全重复”的来源:同一份真相应用两次,什么都不会改变。

当前实现的修订(重要): 迁移 0135_pro_until_sources.sql 把这个“一个写入者”的模型拆成了三个来源列,并把 pro_until 改成生成列:

ALTER TABLE users
    ADD COLUMN pro_until_stripe timestamp with time zone,
    ADD COLUMN pro_until_revenuecat timestamp with time zone,
    ADD COLUMN pro_until_granted timestamp with time zone;

ALTER TABLE users ADD COLUMN pro_until timestamp with time zone
    GENERATED ALWAYS AS (GREATEST(pro_until_stripe, pro_until_revenuecat, pro_until_granted)) STORED;

迁移注释给出了原因:应用内购买必须走商店,于是 RevenueCat 成为同一计划的第二个来源;两个来源指向同一列会互相擦除——在 App Store 买了 Pro 的账号没有 Stripe 订阅,下一次 Stripe 同步会派生“没有”并覆盖掉用户已经付过钱的权益,静默地、朝着剥夺用户已购买内容的方向。GREATEST 跳过 NULL,只有全为 NULL 才返回 NULL,语义正好是“没有来源即免费版,一个来源即生效,两个来源取更长”。

GENERATED 而非触发器的关键理由不是“算得对”,而是让错的答案写不进去:UPDATE users SET pro_until = ... 现在会以 428C9 失败,风险由 Postgres 拦截,而不是靠 code review 拦截。

因此,读当前代码时你会看到 billing 包写的是提供商自己的来源列,而不是派生列(service.go 明确解释了这一点:写派生列会让某个提供商“报告无订阅”时撤销另一个来源 confer 的计划)。AGENTS.md 里也把这条规则写成了“派生整体、绝不做增量调整”(AGENTS.md)。

四、Webhook 是信号,不是事实

设计文档点名批评了最“显然”的实现:读 event.type,驱动一个本地状态机——INITIAL_PURCHASE 授予、CANCELLATION 撤销、RENEWAL 延长。它错在两个彼此独立的原因上。

顺序。 RevenueCat 不保证投递顺序。一个 RENEWAL 若在其所取代的 EXPIRATION 之后到达,账号会一直错到有人发现为止。

漂移。 按事件类型分支,等于在本地复制了提供商的状态机;他们每新增或重新解释一种事件类型——账号间转移、扣款重试、宽限期、退款——都是这份副本与原版分叉的机会。这份副本会成为关于钱的第二个真相来源。

所以 handler 的处理方式是:把事件视为“这个用户身上有东西变了”,然后通过重读来回答——GET /v1/subscribers/{app_user_id},在算作 Pro 的权益中取最晚的 expires_date,写入。退款、转移、宽限期都不需要自己的代码,因为它们已经体现在重读的结果里。代价是每个事件一次出站调用;在一个 5 美元订阅能达到的量级上,这不构成考量。

源码把这条规矩写在了两处最显眼的位置:config.go 的包注释,以及 revenuecat.go 的解析函数注释——“事件的 expiration_at_ms、entitlement_ids 被刻意不读。webhook 只说某物变了;变成了什么,从提供商那里读。”

解析器只强制要求一个字段:id。

if fields.ID == "" {
    // 没有 id 就没有幂等键,重投会被二次记录并二次应用。
    // 拒绝会让提供商重试,这是安全的失败方向。
    return Event{}, errors.New("billing: event has no id")
}

除此之外不设门槛,因为“要求更多字段”会把提供商新增的每一种事件类型都变成一次被拒的投递。注意 CustomerID 与 UserRef 在这里填的是同一个字符串(app_user_id),并且是故意都填:revenuecat.go 的解释是,Stripe 下这两个是不同标识,RevenueCat 下它们是同一个,假装不同只会对读表的人隐藏事实。

重读后如何折算成一个时间点

真正决定钱的函数是 revenuecatReach(revenuecat.go),它的规则比“取 expires_date”要细:

  • 配置的权益不存在 → 返回零值、无错误,表示“本提供商不 confer 任何东西”,不等于账号是免费版(别的来源可能仍然生效)。
  • 权益对象两个日期键都不存在 → 返回错误。这是“读不懂的形状”(字段被改名、未知结构),而不是“永不过期”。
  • 两个日期都显式为 null → 视为不可过期,写为 now + 35 天 的地平线。
  • 宽限期晚于到期时间 → 取宽限期;否则取到期时间。
  • 已过期的时间仍然返回,而不是归零。是否“计划结束”由 plan.TierOf 拿当前时间判断;在这里抹掉,只会毁掉“订阅何时结束”的唯一记录。

测试把这些边界逐条固化在 revenuecat_test.go:活跃、宽限期延长、宽限期短于到期不缩短、已取消但仍到已付周期末、已过期仍返回原日期、无权益、非本产品权益、不可读的到期时间、expires_date: null、{}(两个键都不存在)、字段名变形(expiresDate)、只有宽限期。

其中 {} 与 null 的区分是设计上最精巧的一处:

// 两个键都不存在 = 读不懂的权益,而不是终身权益。
if len(ent.ExpiresDate) == 0 && len(ent.GracePeriodExpiresDate) == 0 {
    return time.Time{}, fmt.Errorf("billing: entitlement %q carries neither an expiry nor a grace period", entitlement)
}

为了让“缺失”和“显式 null”可区分,结构体字段用的是 json.RawMessage 而不是 *string(revenuecat.go)。若用指针,{} 与 {"expires_date":null} 会折叠成同一个值,前者会静默授予一个没人买过的计划。

“永不过期”为什么写成 35 天

revenuecatLifetimeHorizon = 35 * 24 * time.Hour(revenuecat.go)看起来很怪,注释解释了取舍:列是时间戳,说不出“永远”;发明一个表示永远的哨兵值,正是 entitlement.go 已经记录过的危险——Stripe 的第一版草稿就有这么一个,读错字段会让每个订阅者变成永久会员、取消永不生效。于是“永远”被写成“只要我们还在持续确认”。

这段注释同时诚实地点出了脆弱之处:它和对账窗口是耦合的。一个写在 now+35d 的行,大约 34 天后进入 dueSoon 的波段,并在其中停留两倍窗口的时间;如果这个波段里的每一轮都错过(worker 停了两天、pass 报错、每次都撞上 BILLING_SYNC_MAX_PER_RUN),该行会永久离开波段,因为不可过期的权益不会再产生任何 webhook。此时授权会在到达地平线时静默失效。这是朝关闭方向失败,所以可容忍而非 bug,但被两条断言约束(TestTheLifetimeHorizonOutlivesTheSyncWindow):地平线必须大于窗口,且小时级调度必须在其内提供多轮执行。

五、billing_events:一张表承担三个职责

webhook 会先把事件写下来并返回 200,然后才尝试应用。一张 append-only 表因此承担了本需三套机制的三件事(设计文档与迁移注释 0128_billing_events.sql 的表述一致):

职责 机制 代码/约束
幂等 UNIQUE (provider, event_id),重投成为 no-op billing_events_provider_event_id_uniq
重试 应用失败的行保持未处理,由 cmd/billing-sync 拾取 billing_events_unprocessed_idx ... WHERE processed_at IS NULL
审计 “这个账号为什么是 Pro”需要一个书面答案,存原始 payload 让映射 bug 可重放 payload jsonb NOT NULL

返回 200 在应用之前是刻意的:500 会让 RevenueCat 重试一件我们已经持久记录的事,而重试会与对账器争抢同一份工作。事件一旦存储,投递就已经成功。

写入之后,handler 用较短超时尝试就地应用。这纯粹是为了延迟——刚付完钱的候选人应该立刻看到 Pro,而不是等到下一个整点。失败不是错误路径,而是回退到 worker 的正常情形。

表设计里还有几处值得抄走的细节:

  • user_id 可空,且外键为 ON DELETE CASCADE。app_user_id 按原样存储并且同时解析出 user_id,因为两者可能不一致:dashboard 的 TEST 事件、账号已删除的事件、从来不属于我们的标识,都必须被记录而不是丢弃。“无法归属的行是证据;被拒绝写入的行什么都不是。”删除账号时级联擦除其事件,NULL 行不受影响——它们不是任何人的个人数据。
  • provider 与 event_type 没有 CHECK 约束。提供商的事件词汇表会增长(TEMPORARY_ENTITLEMENT_GRANT、PRICE_INCREASE_CONSENT_REQUIRED 就不在第一版里),加约束会把“他们新增了类型”变成一次被拒的投递加一次 schema 变更;而代码根本不按类型分支,约束没有可保护的东西。

string provider 参与唯一键也是必要设计:两个提供商的事件 id 是来自不相关命名空间的不透明字符串,靠“静默丢一笔付款”来发现它们重叠不是好办法。

六、cmd/billing-sync:两小时半之后唯一剩下的路

设计文档把 cmd/billing-sync 定义为一个 Type=oneshot 的小时级 cron worker,同一时刻只做两件事:

  1. 应用 billing_events 中 processed_at IS NULL 的行;
  2. 对 pro_until 落在“当前时刻附近窗口”内的用户,重读订阅者状态,捕捉 webhook 从未到达的续费。

两趟都从 billing_events 出发,绝不从 users 出发:第二趟把事件表 join 到 users 再按 pro_until 过滤,候选集因此是“订阅者基数”而不是 800 万行全表扫描,也不需要为了支撑它给 users 建索引。它还把“绝不向陌生人询问”变成了结构性质——提供商的 GET 会为未知 id 创建订阅者,而一条只能触达“发生过交易”的账号的查询,不可能踩到这个坑。

从未产生过存储事件的用户对 worker 不可见,设计明确承认这是已接受的限制而非疏忽:购买一定会产生事件,事件先存后处理,且 RevenueCat 会重投未确认的 webhook 数天。在这个窗口内完全不产生事件的购买属于提供商故障,恢复手段是客服操作,不是定时扫描。“列出提供商的全部客户”被考虑并否决——那等于每小时扫描整个订阅者基数,只为找一个尚未发生过的案例。

当前实现的扩展

源码比设计文档多了两处,都可以在 cmd/billing-sync/main.go 读到:

第三趟:结算推荐奖励。 它搭这趟车而不是独占一个二进制,因为需要同一个数据库、同一套 Stripe 凭证、同一种工作性质(修补 webhook 无法告知的东西),而新二进制意味着新的 systemd unit——那只存在于生产主机上,是容易忘记的手工步骤。该趟仅限 Stripe:商店订阅不会产生我们能读的发票,没有可赚取奖励的依据。它跑在 advisory lock 下(rewardLockKey = 0x66687277,即 “fhrw”),因为“先读计数、再据此行动”在 READ COMMITTED 下不是原子的,两个并发 pass 可能各自看到计数低于上限并各自发放;锁是非阻塞的,第二个实例直接放弃而不是排队——排队会让 oneshot unit 看起来像挂起。

两个提供商分别对账。 passes 是两个独立条目,各自针对自己的事件与来源列。设计文档只描述了 RevenueCat,但当前代码把 Stripe 与 RevenueCat 并列,理由写在包注释里:一家不可达不能阻断另一家,两家是不同公司、有不同故障,一场 Stripe 事故连带停掉 App Store 续费,会是我们自己发明的故障。

每轮上限。 BILLING_SYNC_MAX_PER_RUN 默认 500(main.go),因为无界的运行会让一个 oneshot unit 活得比自己的定时器还长;systemd 在第一个实例活跃时不会启动第二个,于是运行无界就等于静默跳过后续触发。解析用 ParseInt(raw, 10, 32) 而非 Atoi,理由很具体:该值最终作为 int32 的 SQL LIMIT,按平台 int 宽度解析再转换,超界数字会回绕成负数,变成查询错误而不是大批量,而“不是正数”的日志行也就成了谎话。测试覆盖了未设置、正数、0、负数、乱码、超界六种情形(main_test.go)。

单账号失败不中止整轮。 main.go 说明行之间彼此独立,一个提供商拒绝某个标识不应阻止其它订阅被修复,失败行留到下一小时。

重放时有一个容易写错的地方,代码专门做了处理:从存储行重建 Event 时,若 user_id 非空就填入 UserRef。否则重试会比第一次尝试更弱——只有 customer id 时,绑定从未写入的账号会解析成“无人”,随后被标记为不可归属。而已标记的行永远不会再重试,等于把一笔真实付款永久标记为已完成。

七、放置与分层:为什么在 identity 而不是被 ai 挡住

internal/identity/billing 属于 identity 块、第 3 层。理由简单:订阅是账号的属性,而账号属性归这个块持有。

显而易见的担忧正是当年把 internal/ai/plan 放进 ai 的原因:identity 与 ai 同处第 3 层,所以 ai 中的任何东西都不能导入 identity。但这条担忧在这里不适用:

  • plan 通过 platform/db 读 pro_until,不导入 billing;
  • billing 的调用方是 api(第 8 层)里的 webhook handler 和 cmd/ 里的二进制,两者都可以导入任何东西。

规则满足,代码可行。这条边界在当前源码里可以逐条对上:Service 与 RevenueCat 都没有为 plan 暴露任何类型,plan 侧只读列。

八、结账:URL 必须由服务端拼

设计规定的购买路径是在 RevenueCat 托管的 Web Billing 付费墙上完成。我们返回一个以用户标识作为路径段结尾的 URL;我们从不处理卡号、不持有 Stripe 凭证、不渲染支付表单。

两条硬规则:

URL 由服务端 handler 构造,不能在 SPA 里用公开环境变量拼接。URL 里的标识决定谁被扣款、谁变成 Pro,而浏览器拼出来的值,浏览器就能改。

app_user_id 永远是我们的 users.id,且只在认证之后使用。 RevenueCat 会给未识别身份的客户端分配匿名标识,在匿名标识下完成的购买会落在一个我们无法解析的账号上。结账路由要求认证,这让错误不可达,而不只是“被文档提醒”。

设计文档还记录了一个被推迟的选项:自建定价页转化更好,但那是前端工作、有更多地方会坏;托管付费墙是“能上线、且可以被替换而不动别处”的更小选择。

当前实现的分叉

这条路径在本仓库里已经由 Stripe 承担:register 只在 h.billing.Enabled()(Stripe 配置)时挂载 GET /billing/checkout,并且只接受 cookie——billing.go 的注释是“结账链接决定谁被扣款,所以只为浏览器会话签发,绝不为 API key 签发”。provider 侧 RevenueCat 结构体则明确没有 checkout/portal/prices/receipts(revenuecat.go):商店订阅在 App Store / Google Play 内完成购买、变更、取消与退款,我们没有 API,也不该有。

值得注意的是,设计文档里的环境变量名与当前代码不一致:文档写 REVENUECAT_WEBHOOK_TOKEN 与 BILLING_CHECKOUT_URL,实现读的是 REVENUECAT_WEBHOOK_SECRET(revenuecat.go),且 Config 用 FRONTEND_ORIGIN 作为回跳站点(config.go)。以代码为准,并以 tasks.md 作为“当时意图”的存档。

九、未配置即关闭:404,而不是 500

设计原文:缺少 REVENUECAT_* 时,/api/v1/billing 下的每条路由返回 404,cmd/billing-sync 以 0 退出且不打开连接池。不是 500,也不是启动失败——一个未配置的可选子系统在这个集群里安静降级,而从没设过这些变量的自托管者,不应该能看出这段代码存在。

当前实现把这件事做得更彻底:不是“挂载后返回 404”,而是根本不挂载。

// 不注册就是“禁用”行为的全部。一条存在但从 handler 内部返回 404 的路由,
// 仍然是攻击者可以探测时序与形状的路由;一条从未挂载的路由,
// 与一个不含这个文件的构建无法区分。
func (h *billingHandlers) register(api fiber.Router, mw middleware) {

两个提供商独立挂载:只做 Web 销售的部署是合法的,只做应用内销售的部署也是;一句 if !stripe { return } 覆盖两者,会让第二种不可能,而且不会有人告诉你。

cmd/billing-sync 的门禁在 Bootstrap 之前:

if !stripeCfg.Enabled() && !storeCfg.Enabled() {
    log.Print("billing-sync: no billing provider is configured, nothing to reconcile")
    return 0
}

main_test.go 用一个端口无法解析的 DATABASE_URL 证明这一点:若 run() 走到了 Bootstrap,就会开池失败返回 1;返回 0 就是“它根本没试过”的证据。另一个测试 TestTheStoreProviderAloneKeepsTheWorkerRunning 则守住门的另一侧:只配置 RevenueCat 时运行必须继续,否则商店订阅永远不会被对账。

签名校验:常量时间,且覆盖原始字节

签名比较用 crypto/subtle(signature.go),因为这是该端点认证的全部——比较失败所花的时间,是它唯一可能泄漏的东西。

方案与 Stripe 共用:t=<unix>,v1=<hex>,对 "<t>.<raw body>" 做 HMAC-SHA256。几条关键约束:

  • raw 必须是收到的字节。HMAC 覆盖这些字节,先解析 JSON 再重新序列化会拒绝合法投递——同一个事件的不同序列化就是不同字节。
  • t 必须检查新鲜度,否则签名就是一个可无限重放的 bearer 凭据,与被它取代的共享 header 没有区别。
  • 每一个 v1 都要试。两个端点密钥同时活跃时,提供商用两者各签一次并在同一个 header 里发两个 v1,且不承诺顺序;只保留一个的校验器会在整个轮换期内拒绝大约一半的投递——而那正是密钥因泄漏而轮换的时候。
  • 除 v1 外的方案一律忽略,这是提供商的指示,也是降级防御:他们在测试投递里发一个故意伪造的 v0。
  • 时间窗口是按提供商的参数:Stripe 5 分钟(其默认容忍度,且每次重试都重新签名,所以窗口约束的是签名年龄而非事件年龄);RevenueCat 90 分钟,因为它是否重签没有文档说明,而最后一次重试落在首次之后 80 分钟(revenuecat.go)。窗口过窄会丢掉付费订阅,过宽只会延长一封被截获投递的寿命,而重放本来就按 event id 幂等——所以不确定时朝安全方向失败。

handler 的状态码语义同样是契约的一部分(billing.go):验签失败 401,验签通过但解析失败 400。两者都是永久性拒绝;用一个“提供商会重试”的状态去回应一个永远解析不了的消息体,正是端点被重试三天后被禁用、并连带带走所有续费的方式。而记录失败必须返回非 200,因为 200 是“事件已存储”的声明,虚假声明它是唯一会永久丢事件的方式。

还有一个极易写错的实现细节被源码专门标注:必须用 c.Request().Body(),不是 Fiber 的 c.Ctx.Body()。后者会读 Content-Encoding 并解压(gzip/deflate/brotli 最多三层),于是 (1) HMAC 覆盖的字节与实际收到的不是同一份;(2) 解压发生在任何认证之前,服务器的 8MB BodyLimit 只约束压缩后的体积,在应用里唯一一个未认证 POST 上,几 MB 的 brotli 就是无界分配。

十、记下但未建的三处接缝

设计文档最后一节刻意区分了“决定不写”与“忘了写”:

赠送的 Pro 天数。 add-invites 会授予若干天 Pro,如果它直接写 pro_until,对账器会用提供商真相覆盖赠礼。解法是给赠送单独一列,pro_until 取两者较晚者——但当时还没有赠礼,现在就建这套拆分属于“需求之前的基础设施”。所有权归 add-invites,这一段是交接说明。(当前实现已由迁移 0135 的 pro_until_granted 落地。)

记录商户(merchant of record)。 RevenueCat 不是。通过 App Store 购买时卖方是 Apple;通过 Web Billing 购买时卖方是我们,随之而来的是客户所在每一个司法辖区的增值税与销售税义务。Web 是 freehire 用户的主要所在地。这不改变本次变更的任何代码,也不是软件决策——记在这里是因为替代方案(Paddle 或 Lemon Squeezy 作为记录商户,约 5% 对约 3%)是在提供商层面选择的,落地时只是替换本包唯一的出站客户端。

提供商端口(port)。 只有一个提供商,所以没有为它定义接口。第二个提供商会引入接口;现在就发明它,等于对着我们还没读过文档的提供商形状做猜测。

十一、已验证的提供商契约(设计文档记录于 2026-09-03)

这一节是设计文档里信息密度最高的部分,实现就是对着它写的。文档建议:如果客户端开始以测试无法复现的方式失败,重读这一节。

Webhook 信封。 {"api_version": "1.0", "event": {…}}。事件携带 id(重试复用同一个 id,这正是 UNIQUE (provider, event_id) 成为正确幂等键的原因)、app_user_id、type、expiration_at_ms、entitlement_ids。

投递。 明确不保证顺序、不严格保证送达,可能重复——文档自己就告诉实现者按 event id 去重。非 200 触发 5 次重试,间隔 5、10、20、40、80 分钟,然后永久停止。响应预算 60 秒,文档建议先确认再延后处理。

这个重试上限是全文支持 cmd/billing-sync 的最强论据:购买后约两个半小时,RevenueCat 就不再尝试,对账器是付费订阅变成 Pro 的唯一剩余路径。

认证——HMAC,而非共享 header。 X-RevenueCat-Webhook-Signature: t=<unix>,v1=<hex>,对 "<t>.<raw body>" 用集成签名密钥做 HMAC-SHA256。它取代了变更最初指定的共享 Authorization 值——那是一个任何见过一次投递的人都能重放的 bearer token。

结账 URL。 https://pay.rev.cat/<token>/<app_user_id>——标识是路径段,不是查询参数,必须 URL 编码。BILLING_CHECKOUT_URL 保存到 token 为止的全部内容,handler 追加路径段。支持自定义购后回跳,package_id 可预选产品。

订阅者状态。 GET https://api.revenuecat.com/v1/subscribers/{app_user_id},带 Authorization: Bearer sk_…——密钥,仅服务端。权益是以权益 id 为键的 map,每项含 expires_date、grace_period_expires_date、product_identifier、purchase_date,ISO 8601。响应还带 management_url,那是订阅者取消订阅的地方——删除账号界面必须指向它,而不是我们自己发明的 URL。

该端点的两个陷阱。 其一,expires_date 可为 null,null 表示“不过期”而非“已过期”;把 null 读成零值会把终身购买者静默降级为免费。freehire 不卖终身产品,这恰恰意味着代码必须正确地处理它,因为没人会注意到它被处理错了。其二,GET 在 id 未知时会创建订阅者——一次带写入后果的读取。所以它只对已有记录事件或 pro_until 非 NULL 的用户调用,我们从不制造订阅者;这个约束现在是对账器必须保持的性质,而不只是习惯。

当前实现把第二个陷阱做成了显式函数,并给出了很好的命名理由(revenuecat.go):

// knows 报告 RevenueCat 是否曾就这个账号与我们有过接触:一条已记录的投递,
// 或一个我们已经持有的商店权益。
// 它存在是因为 v1 subscribers 端点会在标识未知时 CREATE 订阅者——
// 一次读带着写的后果。若一趟遍历用户表而不先问这个,
// 就会把我们拥有的每个账号永久注册到提供商那里。

与之配套的是两个入口的分工:SyncUser 先问 knows(对账器与事件重放会批量触达账号),SyncCaller 故意跳过陌生人检查(一个已认证、有限流的调用者询问自己的 id,不属于“批量登记”——他刚买了东西,提供商的设备 SDK 早已创建了订阅者)。没有这个区分,那条为“补上丢失的 webhook”而建的路由,会拒绝它唯一要服务的场景:首次购买者没有事件记录、来源列为 NULL,守卫会把“你的 webhook 丢了”变成“你不是订阅者”,而对账器的两趟也永远找不到他(service.go、billing_store.go)。

十二、配置与运维速查

以下参数表以当前仓库源码为准,括号内标注与设计原文的差异。

变量 作用 缺省/约束 依据
REVENUECAT_API_KEY sk_ 秘密密钥,仅服务端 缺失即整个商店提供商禁用 revenuecat.go
REVENUECAT_WEBHOOK_SECRET 校验 HMAC 的签名密钥(设计原文写作 …_TOKEN) 缺失即禁用;与 API key 任一缺失都算半配置 同上
REVENUECAT_ENTITLEMENT 表示 Pro 的权益 id 默认 pro;空值会回落默认,但 Enabled() 要求它非空 同上
BILLING_SYNC_MAX_PER_RUN 单轮每趟处理上限 默认 500;非正数或超出 int32 保留默认并打日志 billing-sync/main.go
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET Web 商店的密钥与签名密钥 与 RevenueCat 独立启停 config.go
STRIPE_PRICE_IDS 等 授予 Pro/Ultra/API 的价格 id 列表 空列表不授予任何人,无默认值 同上
FRONTEND_ORIGIN 购后/管理后的回跳站点 为空则 CanCheckout() 为假,但 webhook 仍可记录 config.go

关于“空列表授予零人”这一条,源码给了明确的价值判断(config.go):猜测的默认值要么匹配不到任何东西(结果相同但不诚实),要么匹配到一个我们并不想要的价格。Enabled() 也因此要求价格列表非空——否则每次同步都会派生“没有 Pro”,并静默降级所有订阅者,这种故障看起来和“没有人付过钱”一模一样。

CanCheckout() 与 Enabled() 分开也是刻意的:缺少站点 URL 不能让 webhook 停止记录事件,已经卖出的订阅会继续续费,因为没人能发起新的购买就拒绝记录续费,会损失已经收到的钱。

运维侧需要的手工步骤(设计文档“Ops”一节)包括:在 dashboard 注册 webhook URL、启用 HMAC 签名并取得密钥、铸造 sk_ 密钥、创建 pro 权益与 Web Billing 付费墙、取得 paywall token;主机上新增一个 Type=oneshot 的小时级 freehire-billing-sync timer,并把二进制在主机上构建——release.sh 只构建 API,不构建 cmd/ 下的每个命令。小时级频率是相对提供商重试上限选的:约两个半小时后它彻底放弃,此后这个 timer 是唯一剩下的路径。

验证命令(来自 tasks.md):

gofmt -l .          # 应无输出
go vet ./...
go test ./...
go vet -tags=integration ./...
go test -tags=integration ./internal/identity/billing/ ./internal/api/handler/

十三、这套设计真正的取舍

把设计文档读完,会发现它反复在做同一件事:把“关于钱的第二个真相来源”从系统里删掉。

  • 不分支事件类型,因为那是提供商状态机的本地副本;
  • 不存订阅状态、周期、价格,因为提供商持有它们,且一次 API 调用即可读到;
  • 不自建取消流程,因为提供商的客户门户持有它,而我们写的流程只会与实际发生的资金变动产生分歧;
  • 不用哨兵值表示“永远”,因为读错一个字段就会让所有订阅者变成永久会员;
  • 不用“未找到”表示“已过期”,因为那会静默降级终身购买者。

代价被明确列出并接受:每个事件一次出站调用;约两小时半的 webhook 放弃窗口后依赖小时级对账;从未产生事件的用户不可见;Web Billing 下我们自己成为记录商户。

而收益是一句可以写进 README 的话:users.pro_until 这个派生列,让计量路径读一列而不是调一个 API,于是一个慢或不可达的支付提供商,永远无法延迟用户的下一次提问。这不是营销表述,它是 config.go 的包注释,也是整个设计每一处“少做一点”的最终解释。

登录后查看全文
freehire