freehire Pro 订阅计费规格解析:Checkout、Webhook 与对账器如何共同决定 `users.pro_until`
freehire Pro 订阅计费规格解析:Checkout、Webhook 与对账器如何共同决定 users.pro_until
本文以 freehire 仓库中的 Pro 订阅能力规格 pro-subscription/spec.md 为主线,逐条拆解这份规格定义的行为契约:用户如何进入购买流程、订阅状态如何落到 users.pro_until、Webhook 为什么会丢失或重复、以及未配置计费的部署为什么必须表现得像这段代码不存在。同时结合 internal/identity/billing、internal/api/handler/billing.go 与 cmd/billing-sync/main.go 的源码,给出可直接对照的实现锚点与运维参数。读完后你应当能够:判断一次订阅状态变更是否被正确处理、定位"付了钱但没变 Pro"的故障环节,并在自己的部署中安全地把计费子系统关掉。
一、这份规格要解决的问题:一列时间戳,四种失败模式
规格的出发点非常克制:整套计费只维护一个衍生时间戳。数据库迁移 0120_users_pro_until.sql 已经把设计意图写在文件头——provider 自己保管产品、状态、账期、支付方式,freehire 只保留一个"这个账号现在是不是 Pro"的答案,而答案用一个可空时间戳表达,而不是布尔值:
- 未来时间戳 = Pro;
NULL或过去时间戳 = Free;- 到期不需要任何 sweep、cron 或过期任务,时间戳自己会失效。
这一选择直接决定了后续所有需求:既然 hot path 只读一列,那么 provider 慢、挂、超时都不可能拖慢任何一次计量动作(规格最后一条 Requirement 正是在守这个性质)。
围绕这一列,规格要防的是四种典型故障:
| 故障 | 后果 | 规格中的对应约束 |
|---|---|---|
| 客户端自行组装 checkout 标识 | 付款记到别人/匿名账号上 | 标识只能来自服务端会话 |
| 用事件类型驱动本地状态机 | 乱序、退款、转账导致状态漂移 | 只从 provider 当前状态派生 |
| Webhook 丢包或重复 | 已付费用户变 Free,或重复应用 | 先记录再应用 + 对账器兜底 |
| 未配置计费却启动失败 | 自托管者被可选子系统拖垮 | 未配置即 404 且 worker 空转退出 |
二、购买入口:URL 必须由服务端签发
2.1 规格要求
规格第一条 Requirement 写到:系统 SHALL 提供已认证端点,返回 Pro 套餐在 provider 托管付费墙上的 checkout URL;URL SHALL 携带调用者自己的 users.id 作为 provider 的 app_user_id;系统 SHALL NOT 接收、传输或存储卡号,也 SHALL NOT 渲染自建支付表单。
三个场景分别约束了:
- 已登录用户请求 checkout:返回托管付费墙 URL,携带自身标识,系统不接触卡数据;
- 匿名调用者请求 checkout:以未认证拒绝,不签发任何 URL,理由是"购买永远不能被归因到一个匿名 provider 标识";
- 标识不得由客户端提供:URL 中的标识取自服务端会话,请求中用户自带的标识被忽略。
2.2 实现锚点
请求处理在 billing.go:
api.Get("/billing/checkout", mw.cookie, h.Checkout)
- 路由挂在
mw.cookie之后,也就是只认浏览器 Cookie 会话,不接受 API Key。原因写在注册处注释里:checkout 链接决定"谁被扣款",因此不能由 API Key 代表第三方签发。 - handler 内部调用
requireUserID(c)取会话用户,再把userID交给h.billing.CheckoutURL(ctx, userID, c.Query("price"), discount)。 c.Query("price")是唯一从请求中读取的业务参数,而且它在Service.CheckoutURL里会被cfg.Sells(priceID)校验——浏览器给出的价格 id 必须是我们真实在售的,否则返回ErrNoCheckout(见 service.go)。这条校验防的是"调用者指名一个零元价格"。- 任何失败都被渲染成 404 而非 500:
log.Printf("billing: no checkout for user %d: %v", userID, err)
return fiber.NewError(fiber.StatusNotFound, "checkout is not available")
404 是刻意的:前端据此省略升级入口,而不是渲染一个坏掉的按钮(对应规格第三条 Requirement)。
2.3 与规格写法的差异:托管付费墙的形态已演进
写规格时,checkout 指向 RevenueCat 的 hosted Web Billing paywall,URL 形如 https://pay.rev.cat/<token>/<app_user_id>,其中标识是 path segment 而非 query 参数,且必须 URL 编码(见 design.md)。
从当前源码看,Web 侧的实现在已经改为向 provider 创建 Checkout Session(Service.CheckoutURL 最终调用 createCheckoutSession),而 RevenueCat 侧只保留库存订阅(App Store / Google Play)的入口,RevenueCat 类型明确没有 checkout 对应物:
It has no counterpart to Stripe's checkout, portal, prices or receipts, and that absence is the product rather than a gap —— revenuecat.go
规格约束本身没有变化:标识服务端生成、不碰卡号、不渲染自建表单、身份不来自请求。变化的是"托管页由谁提供"。阅读规格时应把它当作行为契约,而不是对某个 provider API 形态的永久绑定。
2.4 一个容易忽略的细节:已在订阅的人不会开第二个 Checkout
Service.CheckoutURL 对已知 customer 会先读取其现有订阅,然后由纯函数 decideCheckoutTarget 决定:已在该价格上 → 直接返回 ReturnURL(说明什么都没买,不能用 SuccessURL 污染购买转化);订阅含多个 item → 拒绝猜测;否则原地改价而不是新开 Checkout。
这段逻辑的注释记录了一个真实事故:早期"升级到 Ultra"会给一个在订的 Pro 用户开第二个独立订阅,两条账单并行扣款。因此这条路径的失败是"整体失败",不会回退到"新开一个 checkout"——回退等于悄悄重建同一个 bug。
三、计划列只能由 provider 当前状态派生
3.1 规格要求拆解
第二条 Requirement 是整份规格的技术核心:
系统 SHALL 在写入的那一刻,从计费 provider 读取订阅状态并据此设置
users.pro_until,SHALL NOT 由收到的事件序列累加得出。同一 provider 状态应用两次,列值不变。
六个场景把边界情况全部点名:
- 有效权益:未来到期时间 → 写入该时间;
- 多个授予 Pro 的权益:取最晚的那个到期时间;
- 没有任何权益:清空或写成 provider 报告的时间,用户立即解析为 Free,不需要任何 sweep、cron 或过期任务(原因就是时间戳自己会过期);
- 宽限期:支付失败但宽限期晚于到期时间 → 写入宽限期结束时刻,"需要换卡"不应立刻剥夺已付费权益;
- 永不到期:
NULL到期时间不得被读成"过去",用户仍解析为 Pro; - 同一状态应用两次:结果不变;
- provider 从未见过的用户:只为"已有计费事件或
pro_until非 NULL"的用户读取状态,避免读取动作本身在 provider 侧创建订阅者记录。
3.2 实现锚点:纯函数派生
proUntilFrom 是一个完全不碰网络、不碰数据库的纯函数:
func proUntilFrom(sub subscriber, proPrices []string) time.Time {
return bestEntitling(sub, proPrices).CurrentPeriodEnd
}
见 entitlement.go。它依赖两个判定:
entitlingStatuses:active、trialing、past_due都授予权益。把past_due放进来是产品决策——provider 会对失败的卡重试数天,第一次失败就断供会把"需要换卡"变成"流失客户";而订阅自己的账期终点仍然限制上限,所以它不可能凭空多送时间。entitles(prices):status 授予权益且订阅价格出现在配置的价格列表中。空价格列表匹配零个而不是全部——"忘了配置价格"必须让所有人变 Free,而不是让所有人变 Pro。
bestEntitling 则在所有授予权益的订阅里取 CurrentPeriodEnd 最晚的那个,这直接实现了"多个权益取最晚"的要求。
范围更大的 entitlementFrom 用同一次读取回答所有层级的权益(Pro / Ultra / API / Data),这也是包内 AGENTS.md 反复强调的规则:
A provider answers for EVERY tier it could sell, from one read, in one write. 两次读可能落在一次变更的两侧而互相矛盾;两次写则会留下"升级中的订阅者两个层级都不持有"的瞬间。
3.3 宽限期与"永不到期":RevenueCat 专属的两个陷阱
RevenueCat 的 subscriber 响应里,entitlement 同时带 expires_date 与 grace_period_expires_date。revenuecatReach 的处理顺序是:
if grace.After(expires) {
return grace, nil
}
return expires, nil
即"宽限期晚于到期时间就用宽限期",这正是规格第四条场景的实现。
更微妙的是 null。代码用 json.RawMessage 而不是 *string,因为字段缺失与字段存在但为 null 必须区分:
- 两者都存在且为 null → provider 在说"这条权益不过期",写入
now + revenuecatLifetimeHorizon(35 天),由对账器每次靠近到期时再推远; - 两个 key 都不存在 → 这是"读不懂的形状"(字段被改名、未知结构),返回错误并授予零值。绝不能当成终身权益。
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)
}
这段在 revenuecat.go。为什么不用 sentinel 表示"永不过期"?entitlement.go 顶部给了反面案例:Stripe 把 current_period_end 从 subscription 移到了 item 上,旧代码读到零值;如果零值被当成"永不过期",每一个订阅者都会变成永久 Pro,且取消永不生效——静默地,且方向是白送产品。所以这里的规则是"读不到账期 → 谁都不授予",失败朝安全方向。
35 天这个常量还与对账窗口耦合:一条 now+35d 的记录会在约 34 天后进入 dueSoon 的窗口,并在其中停留两倍窗口时长。若整个窗口期内对账器一次都没跑(进程宕机两天、请求持续报错、账号每次都排在 BILLING_SYNC_MAX_PER_RUN 之后),这条记录会永久离开窗口,而"不过期权益"不会再产生任何 webhook。这是有意的 fail-closed 取舍,并通过测试 TestTheLifetimeHorizonOutlivesTheSyncWindow 双向约束:horizon 必须大于窗口,且小时级调度必须在窗口内提供足够多次机会。
3.4 "provider 从未见过的用户":一次 GET 就是一次写入
规格第 7 个场景不是洁癖,而是 provider 的 API 语义决定的:GET /v1/subscribers/{id} 在 id 未知时会创建订阅者记录。一次纯粹的读因此带有写后果。代码把这条约束下沉到查询:
func (p *revenuecatProvider) knows(ctx context.Context, userID int64) (bool, error) {
known, err := p.q.HasRevenueCatFootprint(ctx, userID)
...
}
SyncUser 先问 knows,不认识就直接返回 ErrNoSubscription;而对账器第二遍的候选集是从 billing_events 连到 users 再按 pro_until 过滤,因此"只问交易过的人"变成查询的结构性质,而不是某段代码需要记得的约定。
但规格不能一刀切。首次购买者必然没有记录事件、pro_until 也必然为 NULL——这正是"首次"的含义。如果所有路径都走带守卫的 SyncUser,"你的 webhook 丢了"就会被回答成"你不是订阅者",而且对账器两遍都找不到他。因此实现区分了两条入口:
SyncUser:对账器与批量重放用,带陌生人检查;SyncCaller:一个已认证、被限流的调用者查自己,跳过检查(见 service.go)。
对应路由是 POST /api/v1/billing/revenuecat/sync,限流为每账号每分钟 10 次(billing_store.go)。这条路由专门填补"设备端购买完成、webhook 还没到"之间的空档。
四、升级入口:没有东西可卖时就不该出现
规格第三条 Requirement 要求:只有当 checkout 真正可签发时才显示升级入口;计费未配置或没有付费墙时,界面 SHALL 省略入口,而不是渲染成失败态。
这条约束的实现分两层:
服务端:未配置时路由根本不注册,而不是注册后返回 404——
func (h *billingHandlers) register(api fiber.Router, mw middleware) {
h.registerStore(api, mw)
if !h.billing.Enabled() {
return
}
...
}
注释解释得很清楚:一个"存在但返回 404"的路由,仍然可被探测出形状与时序;从未挂载的路由与"构建里没有这个文件"不可区分,而且没有任何 handler 需要记得做这个检查。
前端:PlanView.svelte 在拿到 manageUrl / 定价信息之后才渲染升级链接;Pro 用户在同一位置显示"订阅持续到某日",数据来自已存储的列,而不是再次调用 provider(对应规格"订阅者打开计划页时不需要请求 provider"的场景)。
五、取消入口必须由 provider 给出
规格第四条 Requirement 说:订阅管理目的地 SHALL 从 provider 获取,而不是本地拼装;并且只在某个界面即将展示它时才去请求。取不到时,界面 SHALL 省略链接,但仍须说明"删除账号不会取消订阅"。
实现上有两个层次:
- RevenueCat 的 subscriber 响应本身带
management_url,规格明确"必须使用 provider 为该订阅者报告的值,而不是由配置推导的 URL"; - Web 侧通过
Service.ManagementURL创建 portal session,注释强调了"按访问现生成、短时效,因此不存储",以及"自建取消流程只会成为又一个可能与真实资金流向不一致的东西"。
删除账号页面的提示文案也被规格固定下来:删除账号不代表取消订阅。这是一条容易被当成 UX 细节、实际却是资金纠纷来源的要求。
六、users.pro_until 的写入者必须可枚举
规格第五条 Requirement 把写入权限收紧到两个调用者:webhook handler 与对账器;其它任何请求路径、worker 或功能都不得写它。同时指出"支持人员在数据库里手动设置的值仍然安全,因为列会自己过期"。
从源码结构看,这条规则在当前实现中被进一步强化了。迁移 0135_pro_until_sources.sql 把单一列拆成三个来源列,每源一个写入者,并让 pro_until 成为生成列:
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;
pro_until_stripe:Web 订阅,由 Stripe sync 写;pro_until_revenuecat:App Store / Play 订阅,由 RevenueCat sync 写;pro_until_granted:人工或未来的邀请赠送,任何 provider 都不得清除;pro_until:无写入者,GREATEST跳过 NULL,全为 NULL 时才是 NULL。
这样做的理由写在迁移注释里:两个 provider 指向同一列会互相擦除——在 App Store 买了 Pro 的账号没有 Stripe 订阅,下一次 Stripe sync 推导出"什么都没有",就会覆盖掉苹果已经收过钱的那份权益,静默地、且方向是剥夺用户已购买的时长。
更关键的是 GENERATED ALWAYS 让错误的写入不可表达:UPDATE users SET pro_until = ... 现在直接失败并返回 428C9,风险由 Postgres 拦截,而不是靠代码评审。Ultra 层级在 0141_ultra_until_sources.sql 复用了同一形状。
读规格时需要注意:规格写的是"两个写入者写 users.pro_until",当前实现是"每个来源列一个写入者,衍生列零个写入者"。两者意图一致(写入者必须可枚举、幂等、可按来源撤销),但实现更严格。plan.TierOf 与 /me/plan 仍然只读一列,hot path 没有因此多一次 provider 调用。
七、删号必须擦除计费记录
规格第六条 Requirement 要求:删除账号时一并擦除记在该账号上的计费事件;系统 SHALL NOT 代用户向 provider 取消或退款,并且删除界面 SHALL 在确认前说明这一点。
Schema 层由外键完成:
ALTER TABLE ONLY public.billing_events
ADD CONSTRAINT billing_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
见 0128_billing_events.sql。注意 user_id 是可空的,这不是疏漏:
app_user_id按收到的原样存储,同时从它解析出user_id,因为两者可能不一致:dashboard 发来的 TEST 事件、已被删除账号的事件、或一个从来不属于我们的标识,都必须被记录而不能丢弃。无法归因的行是证据;被拒绝写入的行什么都不是。
ON DELETE CASCADE 自然不会触及 NULL 行——它们不属于任何人的个人数据,而且是"确实收到过一个无法归因的事件"的唯一证据。
八、先落库,再返回 200
规格第七条 Requirement 是与 provider 打交道的核心契约:
系统 SHALL 将接受的每个 webhook 事件记录进 append-only 存储,以 (provider, provider event id) 唯一;事件持久化之后才以 HTTP 200 确认投递——在任何应用尝试之前。
四个场景分别定义了:
- 事件到达:带 payload 存储并返回 200,无论此刻能否应用;
- 同一事件投递两次:返回 200,不产生第二条记录、不产生第二次应用;
- 应用时 provider 不可达:仍返回 200,事件保持未处理,稍后由对账器应用;
- 事件根本无法记录:不确认,让 provider 重投——"确认"是在声称事件已存储,虚假地声称是事件永久丢失的唯一方式。
实现锚点:顺序就是契约
webhookFor 把这套顺序写成了代码结构:
event, err := p.Accept(c.Request().Body(), c.Get(p.SignatureHeader()), time.Now())
if err != nil { ... } // 验签失败:401 / 400,不写任何东西
rowID, recorded, err := p.Record(c.Context(), event)
if err != nil { return fiber.NewError(500, "could not record the event") }
if !recorded { return c.JSON(status: "duplicate") } // 重投:200,无第二次记录
applyNow(c, p, rowID, event) // 尽力应用,错误不改变响应
return c.JSON(status: "recorded")
applyNow 用 applyTimeout = 10 * time.Second 约束内联应用;失败只是日志,因为它已经落库了。只有两类错误会被"盖戳"为已处理:ErrUnknownSubscriber 与 ErrNoSubscription——这类事件永远无法归因,留在队列里只会每小时失败一次直到有人翻日志。
内联应用的存在理由不是顺序,而是延迟:刚付完钱的候选人应该下一页就看到 Pro,而不是等到整点。规格允许"先 ack 再异步",实现选择"在预算内同步做完",并明确说明"应用失败不算错误路径,而是回退到 worker 的正常情况"。
九、绝不从事件类型推断权益
规格第八条 Requirement:
系统 SHALL 只从 provider 报告的订阅状态派生用户计划,SHALL NOT 依据 webhook 的 event type 分支来授予、延长或撤销 Pro。
两个场景:
- 乱序到达:两个事件的到达顺序与发生顺序不同,最终
pro_until仍与 provider 当前状态一致; - 无法识别的事件类型:仍被记录,并触发与其他事件相同的 state re-read,而不是被丢弃或特殊解释。
设计文档把"读 event.type 驱动本地状态机"为什么错讲得很彻底(design.md):
- 顺序:provider 不保证投递顺序。一个
RENEWAL在它本应覆盖的EXPIRATION之后到达,账号就会一直错到有人发现; - 漂移:按事件类型分支等于在本地复制一份 provider 的状态机,而他们每新增或重新解释一种事件(账号间转移、重试、宽限期、退款),复制品就有一次偏离的机会。这个复制品会成为关于金钱的第二个真相来源。
所以 handler 把事件当作"关于这个用户的某件事变了",用重读来回答:拉取当前订阅状态,取所有"算得上 Pro"的权益中的最晚到期时间,写入。退款、转移、宽限期因此不需要自己的代码——它们已经反映在重读结果里。
代价是每个事件一次出站调用。对于一个低价订阅的量级,这不是问题;这个取舍在 design 中被明确记录。
代码层的证据是 parseRevenueCatEvent:它只强制要求 id,并故意不读 expiration_at_ms 与 entitlement_ids:
事件的
id是幂等唯一依赖的字段,要求更多会把 provider 新增的每个事件类型都变成被拒绝的投递。而事件自身对权益的主张——expiration_at_ms、entitlement_ids——是刻意不读的。webhook 只说明有东西变了;变成了什么,从 provider 读。
十、验签:在记录之前,且在原始字节上
规格第九条 Requirement 的措辞很密:SHALL 在记录事件或修改任何计划之前,用配置的签名密钥验证投递签名;比较 SHALL 以常量时间进行;签名 SHALL 在任何解析之前、对收到的原始请求体计算;时间戳超出有界新鲜窗口的投递 SHALL 被拒绝。
10.1 为什么必须是原始字节
signature.go 的注释把两个"装订点"说清楚了:
- HMAC 覆盖原始字节,因此必须在收到的 body 上计算。解析后重新序列化会改变字节,导致合法投递被拒绝——同一个事件、不同的序列化,就是不同的字节;
t必须校验新鲜度。没有时间界的签名等同于 bearer 凭据,而这正是替换共享Authorization头的理由。
同样重要的是"不要用错 API"这条血泪经验(AGENTS.md):
签名覆盖 RAW body ——
c.Request().Body(),不是 Fiber 的Ctx.Body()。Ctx.Body()看起来是正确调用,其实不是:它遵循Content-Encoding并解压,最多链式三层。在这唯一一个未认证的 POST 上,这是两侧都漏的洞:HMAC 覆盖的内容变成了发送方写的一个头;解压发生在验签之前,而服务器的 8MBBodyLimit只约束压缩后的 body,于是"任何人发来的几 MB brotli"就是一次无界分配。
billing handler 因此显式写 c.Request().Body()。
10.2 常量时间与多签名
比较使用 subtle.ConstantTimeCompare,因为"失败要花多久"是这个端点唯一可能泄露的信息。同时,每一个 v1 都要尝试,而不是只取一个:
当两个 endpoint secret 同时生效时,provider 会用两个密钥各签一次,并在同一个头里发两个
v1,且不承诺顺序。只保留一个的验证器会在整个轮换期内拒绝大约一半的投递——而这恰恰是因为泄露而轮换密钥的时候。
parseSignatureHeader 还刻意忽略除 v1 以外的所有 scheme:provider 会在测试投递里发一个故意伪造的 v0,如果验证器接受任意 scheme,就会接受一个它从未校验过的签名。未知元素(如将来的 v2)被忽略而不是拒绝,但缺失 t 或 v1 仍算格式错误。
10.3 窗口按 provider 分别设定
代码里有两个常量,理由不同:
| Provider | 窗口 | 依据 |
|---|---|---|
| Stripe | 5 分钟 | provider 自己的默认容差;Stripe 的每次重试都会重新签名,所以窗口约束的是"签名年龄",不是"事件年龄" |
| RevenueCat | 90 分钟 | 文档未说明是否重新签名,而最后一次重试在首次之后 80 分钟到达;5 分钟窗口会静默拒绝所有重试,只剩对账器一条路 |
宽窗口的代价很小(重放已按 event id 幂等),窄窗口的代价是丢掉已付费订阅。注释还写明:等到检查过一次真实的重试投递后再收窄。
10.4 401 与 400 的区别
ErrBadSignature 被导出,就是为了让 handler 区分两类永久失败:
- 验签失败 → 401,provider 放弃;
- 验签通过但无法解析 → 400。
如果用一个"可重试"的状态去回应一个永远不可能解析的 body,端点会被重试三天然后被 provider 判定为坏端点并停用,连带把续费一起带走。
十一、对账器:修复 webhook 投递丢掉的东西
规格第十条 Requirement 要求运行一个定时对账器:应用"已记录但未处理"的事件,并对"订阅接近记录到期时间"的用户重读 provider 状态;任何时刻运行都安全,且幂等。
11.1 三遍扫描
cmd/billing-sync/main.go 的头部注释定义了它的定位:
它不是给可靠通道加的安全网。provider 最多重试三天然后永久停止,而一个被判定为坏端点的地址可能更早被停用——在任一之后,这个 worker 就是"已付费订阅变成 Pro"的唯一路径。
三遍分别是:
- 应用 webhook 记录但未能应用的事件(provider 不可达、连接池打满、进程在两步之间崩溃);
- 对计划到期临近的账号重读订阅状态,捕捉"续费发生了但 webhook 从未到达";
- 结算推荐奖励(仅 Stripe,因为商店订阅不产生可读发票)。
两个 provider 各自对账,各自针对自己的事件与自己的来源列,互不阻塞:
passes := []struct {
name string
svc reconcilable
}{
{"stripe", billing.New(stripeCfg, queries)},
{"revenuecat", billing.NewRevenueCat(storeCfg, queries)},
}
理由写在注释里:它们是两家不同的公司、有不同的故障;"Stripe 事故连带让 App Store 续费停摆"会是我们自己发明的故障。
11.2 为什么要"从 billing_events 出发"
design 文档强调:两遍都从 billing_events 开始,从不从 users 开始。第二遍把事件表 join 到 users 并过滤 pro_until,于是候选集是"订阅者基数"而非 800 万行全表,也不需要为支持它而在 users 上建索引。
这同时把"绝不向陌生人询问"变成了结构性保证:provider 的 GET 会为未知 id 创建订阅者,而一个只能触达"交易过的账号"的查询不可能触发它。
11.3 已知边界:从未产生事件的用户对 worker 不可见
design 坦率承认这是一个被接受的限制而非疏漏:购买一定会产生事件,我们先存事件再处理,provider 会对未投递的 webhook 重试数天。如果一笔购买在这个窗口里一个事件都没产生,那是 provider 故障,恢复手段是支持工单,而不是定时全量扫描。遍历 provider 全部客户被考虑过并否决——为了找一个尚未发生的情况,每小时扫一遍整个订阅者基数。
十二、未配置即惰性
规格第十一条 Requirement 要求:provider 凭据缺失时视为计费关闭;每个计费路由表现为"不存在";对账器 SHALL 在不打开数据库连接的情况下成功退出;配置缺失 SHALL NOT 导致应用启动失败。
12.1 路由:不注册,而不是返回 404
前文已引 register 的早退。registerStore 同样独立判断:
func (h *billingHandlers) registerStore(api fiber.Router, mw middleware) {
if h.store == nil || !h.store.Enabled() {
return
}
...
}
两个 provider 独立挂载:只在 Web 卖是合法部署,只在 App 内卖也是。
12.2 Worker:先判断配置,再 Bootstrap
stripeCfg := billing.ConfigFromEnv()
storeCfg := billing.RevenueCatConfigFromEnv()
if !stripeCfg.Enabled() && !storeCfg.Enabled() {
log.Print("billing-sync: no billing provider is configured, nothing to reconcile")
return 0
}
ctx, _, pool, cleanup, err := worker.Bootstrap(context.Background())
顺序是刻意的:Gate 在 Bootstrap 之前,因此未配置的部署连连接池都不会打开。这与 cmd/queue-metrics 在没有 PROM_TEXTFILE_DIR 时的降级形状一致。
12.3 构造函数不能失败
ConfigFromEnv 与 New 都被设计成永不返回错误:凭据缺失意味着"关闭",不是"部署坏了"。Enabled() 是门闸,CanCheckout() 额外要求站点 URL,二者分离是有意的:
已经卖出的订阅会继续续费;因为没人能发起新购买就拒绝记录这些续费,会丢掉我们已经收到的钱。
十三、配置项清单(以当前代码为准)
以下是源码中实际读取的环境变量与默认行为。注意规格/设计写作时的 BILLING_CHECKOUT_URL、REVENUECAT_WEBHOOK_TOKEN 在当前实现中已被 Checkout Session 与 REVENUECAT_WEBHOOK_SECRET 取代。
| 变量 | 作用 | 缺失后果 | 源码 |
|---|---|---|---|
STRIPE_SECRET_KEY |
服务端 sk_… 密钥 |
Stripe 侧关闭 | config.go |
STRIPE_WEBHOOK_SECRET |
whsec_… 签名密钥 |
Stripe 侧关闭 | 同上 |
STRIPE_PRICE_IDS |
授予 Pro 的价格 id,逗号分隔;第一个是默认售出价 | 空列表 → 任何人都不授予 Pro | 同上 |
STRIPE_ULTRA_PRICE_IDS |
授予 Ultra 的价格 id | 不解析出任何 Ultra | 同上 |
STRIPE_API_PRICE_IDS / STRIPE_API_DATA_PRICE_IDS |
API 产品两档 | 不出售 API 访问 | 同上 |
FRONTEND_ORIGIN |
付款后回跳站点(ReturnURL = /my/plan) |
禁用 checkout,但 webhook 仍工作 | 同上 |
REVENUECAT_API_KEY |
RevenueCat 服务端密钥 | 商店侧关闭 | revenuecat.go |
REVENUECAT_WEBHOOK_SECRET |
webhook 签名密钥 | 商店侧关闭 | 同上 |
REVENUECAT_ENTITLEMENT |
代表 Pro 的 entitlement id,默认 pro |
有默认值,但显式置空会导致关闭 | 同上 |
BILLING_SYNC_MAX_PER_RUN |
单次对账的最大处理条数 | 默认 500;非法值回退并记录日志 | cmd/billing-sync |
价格列表没有默认值,这是刻意的"配置错误要失败在安全方向":猜一个默认值要么匹配不到任何东西(同样结果,但不诚实),要么匹配到一个我们并不想卖的价格。
十四、数据模型:一张表做三件事
billing_events 是 append-only 的,字段为 id, provider, event_id, app_user_id, user_id, event_type, payload jsonb, received_at, processed_at,并带两个关键索引:
CREATE UNIQUE INDEX billing_events_provider_event_id_uniq
ON public.billing_events (provider, event_id);
CREATE INDEX billing_events_unprocessed_idx
ON public.billing_events (received_at) WHERE processed_at IS NULL;
这张表承担三个原本需要三套机制才能完成的职责:
- 幂等:
UNIQUE (provider, event_id)让重投变成 no-op。按 provider 分域是必需的——两个 provider 的事件 id 是不透明字符串,来自不同命名空间,"通过静默丢一笔支付才发现它们会重叠"不是好的发现方式。 - 重试:事件留在未处理状态,由
cmd/billing-sync接手。因此在 webhook 里应用失败永远是安全的。 - 审计:"这个账号为什么是 Pro"是一个关于金钱的问题,需要一个书面答案。存储完整 payload(而不是我们当前读取的字段)让映射 bug 可重放,而不是只能考古。
payload 存的是整个 event 对象,provider 与 event_type 上没有 CHECK 约束:provider 的事件词汇表会增长(TEMPORARY_ENTITLEMENT_GRANT、PRICE_INCREASE_CONSENT_REQUIRED 都不在最初版本里),一个约束会把"他们加了新事件类型"变成"拒绝投递 + 改 schema";而代码根本不按类型分支,约束也没有东西可保护。
Apply 的顺序同样有讲究:先 sync,再 stamp processed。中间崩溃只会留下"未处理"的行,对账器重复一次不改变任何东西的同步——无害的方向。反过来的话,工作就掉地上了。
十五、验证与运维:可以照着跑的检查
仓库把计费的正确性主要交给两层验证:纯函数单测 + 带真实 Postgres / stub provider 的集成测试。
# 纯函数与客户端单测(无网络、无数据库)
go test ./internal/identity/billing/
# 带集成标签:webhook 去重、坏签名、过期签名、未知事件类型、
# 未知用户事件、内联应用时 provider 不可达
go test -tags=integration ./internal/identity/billing/ ./internal/api/handler/
# 全仓库静态检查
gofmt -l .
go vet ./...
go vet -tags=integration ./...
proUntilFrom 的测试被明确要求覆盖:active、lapsed、refunded、transferred、多个 entitlement、未知 entitlement、处于宽限期的 entitlement、以及 null expires_date(表示永不过期,不得读成已过期)。这些正是规格里六个场景的一一对应。
运维侧要点来自任务清单的 Ops 章节与 worker 注释:
- 对账器是
Type=oneshot的小时级 systemd timer,不是可选项; - 小时级是针对 provider 重试上限选的:约 2.5 小时后 provider 放弃,此后这是唯一路径;
release.sh只构建 API,不构建cmd/下的每个命令——worker 二进制需要在主机上构建并单独部署;- 手工 dashboard 步骤(注册 webhook URL、开启 HMAC 签名并取回密钥、创建
sk_密钥、创建 entitlement 与付费墙、取回 paywall token)属于人工步骤,需要在部署记录里留痕; - 不要把计费加入
docker-compose的默认服务。
十六、六个最容易踩的坑(以及它们为什么贵)
- 用
Ctx.Body()验签:解压发生在验签之前,且 HMAC 覆盖的变成发送方可控的头。既漏验签,又给未认证 POST 一个无界分配入口。 - 把
expires_date: null读成零值:终身购买者被静默降级为 Free。用json.RawMessage区分"字段缺失"与"显式 null",前者代表"读不懂",必须 fail-closed。 - 零值 sentinel 表示永不过期:Stripe 把
current_period_end移到 item 上时,零值会把每个订阅者变成永久 Pro,且取消永不生效。 - 只保留一个
v1签名:密钥轮换期约一半投递被拒,而那正是密钥泄露的时刻。 - 按
event.type分支授予权益:本地状态机与 provider 漂移,乱序投递会让账号一直错到有人发现。 - 记录失败却返回 200:200 是"已持久化"的声明。虚假声明是事件永久丢失的唯一方式——其余所有失败都还能被对账器修回来。
十七、继续深入
- 规格正文:openspec/changes/add-pro-subscription/specs/pro-subscription/spec.md
- 设计与取舍(为何公开计费代码、为何是对账器、provider 契约核对记录):design.md
- 任务分解与验收项:tasks.md
- 包级约定(本文件是理解实现"为什么这么写"的最佳入口):internal/identity/billing/AGENTS.md
- 派生列迁移:0120_users_pro_until.sql、0135_pro_until_sources.sql、0141_ultra_until_sources.sql
- 事件表迁移:0128_billing_events.sql
- 计划判定与计量(严格不导入 billing):internal/ai/plan/plan.go
- HTTP 面:internal/api/handler/billing.go、billing_store.go、me_plan.go
一句话总结这份规格的设计哲学:把"钱的事实"留在 provider 那里,本地只保存一个会自己过期的衍生时间戳;把"投递"当作不可靠信号,用一张 append-only 表承担幂等、重试与审计;把"是否启用"交给环境变量,让未配置的部署连代码的存在都感知不到。