移动订阅计费从表面上看往往具有欺骗性的简单。标准的推销说辞建议你只需添加一个支付墙 SDK,在各自的应用商店中配置你的产品,监听 Webhook,然后在购买成功时更新你的数据库。不幸的是,这种说法只是一半的事实。这条理想路径一直运行得很好,直到你不可避免地触及系统中最令人不适的边缘:沙盒环境、缺失的身份字段、竞争条件、延迟的 Webhook、恢复的购买、续订、取消,以及令人尴尬的现实:应用商店拥有这笔交易,而你的应用程序拥有用户。
在将 Superwall 与 Google Play 订阅集成时,我一头撞向了这个现实。虽然生产环境的购买看起来很正常,但沙盒测试很快就变得一团糟。测试购买会在应用内成功完成,但我的后端无法可靠地找出究竟哪个用户应该真正获得高级权限。原本简单的支付墙集成突然变成了一个以身份、所有权和对账为中心的分布式系统问题。本文详细介绍了最终的架构设计,以及迫使我走到这一步的痛苦教训。
我曾经相信的架构
我的计费流程的第一个版本自然遵循了标准的 Webhook 驱动模型。用户点击升级按钮,Superwall 呈现支付墙,处理购买,然后向我的后端发送一个 Webhook。后端只需从传入的 Webhook 中读取用户 ID,并升级该特定用户。
在理想的场景下,Webhook 载荷如下所示:
{
"data": {
"originalAppUserId": "user_123",
"transactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly"
}
}该载荷包含了后端开发者想要的一切。它包括产品、交易,最重要的是,应用级别的用户身份。有了这些干净的数据,授予访问权限非常简单。对于真实的生产环境购买,此流程的运行异常顺利。用户购买订阅,Webhook 及时到达,数据库将他们移至高级计划。然后,我开始使用 Google Play 沙盒进行正确的测试。
沙盒打破了假设
Google Play 沙盒测试非常有用,因为订阅时间线被大幅加速。一个月度订阅可能每隔几分钟就会续订一次,这使得你可以彻底测试续订、取消、过期和重新订阅流程,而无需等待整整一个月。但是我最初的沙盒购买以一种极其令人困惑的方式失败了。购买在应用中完成了,但用户并未被升级。
后端日志很快解释了原因:
{
"data": {
"originalAppUserId": null,
"userAttributes": null,
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly",
"environment": "SANDBOX"
}
}身份字段完全缺失。该 Webhook 实际上在声明:“刚刚有人购买了这个订阅,这是交易 ID,但我完全不知道他们是谁。” 这一个缺失打破了整个设计,因为我的后端严重依赖 Webhook 来回答最关键的问题:哪个用户拥有这笔购买?
更深层次的架构问题在于,Google Play 根本不知道我的内部用户 ID。订阅属于 Google 账户,而我的应用用户属于我专有的身份验证系统。Superwall 肯定可以尝试桥接这两个截然不同的身份域,但在每一个环境中都将这座桥梁视为绝对真理是一个错误。一旦我明白了这种脱节,旧的架构就不再显得简单,而是开始显得极其脆弱。
失败的修复方案
在确定正确的模型之前,我自然尝试了一些显而易见的修复,但最终都未能奏效。
这些反复的失败迫使我仔细分离两个截然不同的概念,而我在最初的设计中意外地将它们合并在了一起。
所有权和生命周期是不同的问题
重大突破在于认识到,订阅的所有权和订阅的生命周期根本不是同一个问题。所有权回答的是:谁购买了这个订阅?生命周期回答的是:这个订阅当前的状态是什么?我一直依赖 Webhook 来处理这两者,这是我的根本错误。
所有权只需要建立一次,而且必须严格地通过经过身份验证的用户操作来建立。另一方面,生命周期的变化会随着时间的推移不断发生,包括续订、取消、过期、退款和重新订阅。Webhook 对于异步更新生命周期非常棒,但当关键的身份字段可能缺失时,它们是建立所有权的极其糟糕的主要来源。
最终让这一切生效的关键数据是交易的溯源:
{
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085"
}originalTransactionId 牢牢标识了订阅的根源。在所有续订和生命周期事件中,它都保持完全稳定,这使得它成为将商店订阅绑定到特定应用用户的完美密钥。难题中第二个重要的部分是 purchaseToken,在 Google Play 购买之后,可以在客户端上轻易获取。后端可以使用该令牌直接向 Google Play 询问该购买是否实际上是有效的。
这一认知带来了一个更好的架构:使用经过身份验证的客户端上下文立即绑定所有权,直接与 Google Play 验证购买,然后在未来的生命周期对账中严格依赖 Webhook。
成功的架构
在最终设计中,Webhook 不再是决定谁获得高级访问权限的权威机构。相反,它充当一个后台信使,负责保持整体订阅状态为最新。
现在的直接购买流程始于客户端。当 Superwall 报告交易已完成时,应用会捕获交易溯源和购买令牌。然后,它在用户正常的、安全的身份验证会话下,将这些特定值发送到我的后端。
POST /v1/billing/superwall-binding
Authorization: Bearer <user_token>
Content-Type: application/json
{
"store": "PLAY_STORE",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"transactionId": "GPA.3373-4052-0812-08085",
"purchaseToken": "AOP..."
}至关重要的是,后端不信任隐藏在请求体内的任何用户 ID。它完全依赖 Authorization 头部来识别用户。然后,它创建一个明确声明的绑定:此经过身份验证的用户拥有这个原始交易 ID。该绑定成为永久的所有权记录。
接下来,后端立即直接向 Google Play 验证购买:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}如果 Google 回复说购买令牌有效且订阅处于活跃状态,后端会当场授予权限。用户在完成购买后立即获得高级访问权限,彻底消除了对等待 Webhook 的任何依赖。
后来,当 Superwall Webhook 最终到达时,即使缺失了身份字段,它依然很有用:
{
"data": {
"originalAppUserId": null,
"originalTransactionId": "GPA.3373-4052-0812-08085",
"name": "renewal",
"expirationAt": 1775832567843
}
}后端只需使用 originalTransactionId 查找绑定关系,然后安全地更新现有的订阅记录。Webhook 不再需要知道用户是谁,因为系统已经知道了。
为什么这能更好地处理边缘情况
这种解耦的模型使得所有令人头疼的边缘情况都变得更容易推理。
如果一个 Webhook 碰巧在客户端绑定之前到达,后端只需将其存储为挂起状态。当经过身份验证的客户端稍后提交绑定时,后端可以安全地重放该挂起的生命周期事件。如果用户直接从 Play Store 应用重新订阅,而不是在我的应用内,那么将不会触发即时的客户端事件。然而,Webhook 仍然可以到达并停留在挂起状态,直到用户再次打开应用。一旦 SDK 检测到恢复的购买,客户端就会发送绑定,后端就会无缝跟进。
最重要的是,如果 Google Play 沙盒省略了身份字段,也不会有任何系统损坏。直接验证依赖于购买令牌,Webhook 对账依赖于原始交易 ID。这两个关键步骤都不依赖于 originalAppUserId 的实际存在。这正是那种仅仅希望所有集成都能保留身份信息的计费系统,与强制执行其自身权威所有权模型的系统之间的深刻区别。
心智模型
商店拥有交易。你的应用拥有用户。你的后端必须牢牢拥有它们之间的绑定关系。一旦你真正接受了这种动态,整个架构就会变得清晰得多。
| 关注点 | 真理之源 |
|---|---|
| 用户身份 | 你的身份验证系统 |
| 购买有效性 | Google Play 或 App Store |
| 订阅所有权 | 你的绑定表 |
| 续订和取消 | 商店通过 Webhook 发送的事件,通过绑定表进行对账 |
客户端完全允许携带关联数据,例如 purchaseToken 和 originalTransactionId。然而,从根本上来说,它是不允许为自己授予访问权限的。在创建持久权限之前,后端必须始终与商店验证交易。
教训
可靠的移动计费从根本上要求将 Webhook 视为异步生命周期消息,而不是将其视为完美的身份记录。Webhook 可能会延迟,到达顺序可能完全错乱,并且它们很容易丢失关键的用户字段。沙盒环境的表现可能与生产环境截然不同。所有这些因素都不应决定一个合法买家是否能真正获得他们所购买功能的访问权限。
健壮的、弹性的模式是通过经过身份验证的客户端请求牢牢建立所有权,直接与商店验证购买,并仅将 Webhook 用于在较长时间内保持生命周期的更新。正是这一次架构转变,彻底稳固了我的系统。我不再要求 Webhook 告诉我谁拥有订阅,而是明确了所有权,在源头进行了验证,并让 Webhook 完成它们真正被设计来执行的后台同步工作。
