用户明明已经订阅成功,App 为什么又把他锁回去了?

20 阅读11分钟

这周我遇到一个很典型的苹果订阅逻辑问题:用户已经支付成功,页面先解锁,过十几秒却又重新锁上,再点购买,苹果却提示“你已经订阅了”。最开始我也在看会员限制层和弹窗,后来才发现,问题根本不在页面,而在会员状态被多条异步校验结果来回改写。


前段时间我在项目的 Scan 模块里踩到一个 bug。

它不是那种一眼就知道该查哪里的 bug。

恰恰相反,它看起来特别像页面问题,结果越查越发现,真正出问题的地方根本不在页面。

测试给到的现象很具体:

  1. 用沙盒账号买会员,支付成功。

  2. Scan 首页锁层先消失了,看起来已经解锁。

  3. 进入健康指标或者疾病风险的趋势图弹窗,来回切几下 7 days / 30 days / 90 days

  4. 过十几秒,会员限制层又回来了。

  5. 用户再点订阅,StoreKit 弹窗提示的是:你已经订阅了

这个现象最烦人的地方就在这儿。

如果苹果提示“没订阅”,那问题反而简单,但现在苹果说“你已经订阅了”,App 却说“你不是会员”,这就说明两边已经不一致了。

也正因为这样,这个问题特别容易把人带偏。

你第一眼会觉得像什么?

  • 是不是会员限制层没刷新?

  • 是不是趋势图弹窗里有额外判断?

  • 是不是首页和结果页各自管了一套会员状态?

我一开始也这么想过,后来把代码链路查下来,才发现这些怀疑都不在点上。

最先排除掉的一件事:页面不是根本原因

我先去看了首页、结果页、趋势图弹窗的锁层的显示条件,最后很快发现,这些页面其实都很“老实”。

它们做的事情差不多就是:


!SCAppStore.shared.isVip

也就是说,页面本身并没有一套自己的会员判断,它只是拿当前的 isVip 去决定锁层该不该显示。

这个结论很重要,因为它会直接把排查方向拉正。

如果页面只是照着状态显示,那页面重新上锁,真正该查的就不是“谁把锁层画出来了”,而是:

谁把会员状态又改回去了。

这一步一旦想清楚,很多看起来热闹的现象,其实都只是结果。

真正麻烦的,不是支付成功,而是订阅成功之后还发生了很多事

后来我把订阅成功之后的整条链路重新看了一遍,问题就慢慢露出来了。

表面上看,用户只是点了一次购买,但代码里在这之后,其实至少会发生两类动作。

第一类,是为了体验更顺一点,会先本地解锁。

这个很好理解。

如果用户刚买完,页面还要等一轮校验才能打开,体验会很差。

所以很多 App 都会这么做:

  • 订阅成功后先把本地状态改成已解锁

  • 然后再去做后面的校验

单独看这一步,其实没问题。

第二类,是别的地方又会静默去查一次会员状态。

比如首页回来时查一次,结果页出来时查一次,订阅页也可能再查一次。

这些动作本身也不是错的,因为页面确实想知道用户是不是已经开通了会员。

真正的问题,是这两类动作叠在一起之后,状态开始不稳了。

整条链路大概会变成这样:

1. 用户购买成功,本地先解锁。

2. 过一会儿,某个页面又悄悄发起一次会员校验。

3. 这次校验如果刚好读到旧 receipt,或者读到的本地过期时间不是最新状态,就可能返回“当前不是会员”。

4. 代码如果在这里直接把本地会员状态清掉,isVip 就会重新变成 false

5. 页面又是照着 isVip 画的,于是锁层立刻回来。

最后用户看到的,就成了很诡异的一幕:我明明刚订阅成功,为什么又把我锁了?

沙盒确实更容易出这类事,但锅不能全甩给沙盒

这次还有一个很容易偷懒的解释:“沙盒本来就不稳定。”

这句话不能说完全错误。

沙盒订阅的续期和过期周期,确实会被压得很短。

很多在线上不容易碰到的时序问题,在沙盒里都会被放大。

但如果我把问题简单归到“沙盒环境有坑”,那这次排查基本就白做了。

因为沙盒只能解释“为什么更容易撞到”,解释不了“为什么一撞到就会被重新锁上”。

更准确的说法应该是:

  • 沙盒让这个问题更容易暴露

  • 但真正该修的,还是代码里的会员状态同步逻辑

如果本地这条链足够稳,沙盒再快,也不应该把一个刚买成功的用户立刻打回非会员。

查到后面,问题其实只剩一句话

这次排到后面,我最后把问题收成了一句很朴素的话:

不是订阅成功之后没解锁,而是订阅成功之后,还有太多地方有资格把会员状态改掉。

这句话听起来很简单,但它其实就是整个 bug 的核心。

因为它直接说明,问题不是某一个接口错了,也不是某一个页面漏刷了。

问题在于:

  • 会员状态没有一个足够清晰的更新边界

  • 不同来源的结果都能去改它

  • 而且“把会员改成非会员”这件事,门槛还太低

你只要把这三件事放在一起,这类 bug 就几乎一定会出现。

最后怎么收的:不是重写,而是先把边界收紧

这次我最后没有去重写订阅页,也没有去补更多会员限制层兜底。

原因很简单:

这些都治不到根上。

真正落下来的,是几条看起来很普通,但特别关键的约束。

1. 不同场景的会员校验,不能再一视同仁

后来我把会员校验拆成了三种场景。

第一种,是页面静默刷新。

比如首页、结果页这种地方,用户根本没主动做什么,页面只是顺手查一下当前状态。

这种场景下,它可以确认用户是不是会员,但不应该有资格把一个已经解锁的用户直接打回去。

第二种,是相对更主动一点的同步。

比如用户刚从订阅页回来,或者页面重新进入前台。

这时候可以查得更认真一点,但默认也不该太激进。

第三种,才是真正的权威确认。

比如恢复购买,或者冷启动后的明确校验。

只有这一类,才真正有资格把用户从会员改回非会员。

这个调整的价值就在于:

不是所有“查会员”的动作,都应该拥有同样大的权力。

之前出问题,就是因为连页面里那些很普通的静默刷新,也有能力把最终状态改掉。

2. 旧结果不能覆盖新结果

这次另一个关键点,是把“谁先回来就算谁”这件事彻底收住。

后来我给每次真正发起的校验都加了 token。

思路很简单:

  • 每发一次请求,就拿一个新的 token

  • 回来时只认当前最新的 token

  • 老请求即使晚一点回来,也直接丢掉

这件事看起来很技术,但翻译成人话其实就是:

不能让昨天发出去的判断,回来把今天已经确认的状态又改掉。

很多这类“明明刚成功,怎么又失败了”的问题,本质都是旧结果覆盖了新结果。

3. 把“从会员变成非会员”的门槛抬高

这次最危险的,其实不是“升不上去”,而是“掉得太容易”。

以前有些分支,只要查到一次“当前不是会员”,就会立刻把本地状态清掉。

这在会员体系里是很危险的。

因为这类判断很容易受到下面这些东西影响:

  • receipt 不是最新的

  • 网络抖动

  • 校验先后顺序不对

  • 沙盒状态切换很快

所以后来我把一条规则收得很死:从会员降到非会员,必须比从非会员升到会员更保守。

也就是说,升级可以积极一点,降级一定要谨慎一点。

这其实不只是技术判断,也是产品体验判断。

因为用户最难接受的,不是“我刚买完,多等两秒才开”,而是“我刚买完,你马上又把我锁回去了”。

4. 给刚购买成功的用户留一个很短的保护窗

这一步不是为了糊弄状态,而是为了防止最糟糕的抖动。订阅刚成功的那一小段时间,是整条链最不稳定的时候,receipt 还在同步,页面又在回流,后台还可能有别的刷新。

所以后来又加了一个很短的保护窗。

在这个窗口里:

  • 可以继续做校验

  • 可以继续确认更准确的状态

  • 但不允许任何路径立刻把用户降级成非会员

这么做的目的只有一个:别让用户刚付完钱,就马上体验到一次“被打回去”。

这次最值得记住的,不是某一段代码,而是一种看问题的方式

这次排查对我来说,真正值钱的不是“某个参数怎么配”,而是后面这套看问题的顺序。

先别急着盯页面,先问自己三件事:

  1. 这个页面显示的状态,到底是谁生产出来的?

  2. 这个状态现在有几个地方能改?

  3. 其中有没有谁,本来就不该拥有这么大的写权限?

这三个问题一问,很多表面上的 UI bug,最后都会变成状态同步问题。

这次就是这样,它表面上像:

  • 会员限制层闪回

  • 趋势图弹窗有问题

  • 订阅页这边的状态没有处理干净

但真正该修的,其实是:会员状态不该被这么多异步结果反复改来改去。

后来又顺手带出了另一个坑:Restore

这条线查到后面,还顺手把 Restore 的问题也带出来了。

恢复购买最开始的问题,不是恢复不了,而是恢复这件事对用户来说不够“说得清”。

最典型的是两种情况:

  • 提示已经恢复成功了,但会员限制层没开

  • 或者中间先提示一个结果,最后状态又是另一个结果

后来我才把恢复购买这条链也单独收了一下:

恢复订阅这件事,不要看到前面有返回就算成功,而是要等最后真正确认用户已经恢复会员了,才 算成功。

这句话和前面的思路其实是一回事。

不管是订阅成功,还是恢复订阅,用户最终看到的东西,都不该由那些半路上的中间结果来决定。

这次复盘下来,我最想留下 4 句话

1. 会员限制层出问题,先别急着查它

很多时候它只是最后一个把错误状态画出来的地方。

2. 页面静默刷新,不该轻易拥有降级权限

它可以同步状态,但不该轻易改掉最终状态。

3. 旧结果覆盖新结果,是会员系统里最容易被低估的坑

不加并发保护,这类问题迟早还会回来。

4. 从会员降级到非会员,一定要比升级更保守

这不是保守开发,而是对真实用户体验负责。

写在最后

这次问题表面上是“已订阅成功却还是非会员”,但真正让我记住的,不是这个现象本身,而是它特别像一个提醒,

提醒我:

做会员、订阅、会员限制层、权限这类功能时,最“危险”的地方往往不是那个最显眼的页面。

真正“危险”的,通常是页面背后那条你平时不太想第一眼去看的状态更新流程。

如果那个流程没有边界,没有优先级,也没有足够保守的降级条件,问题迟早会出来。

今天它可能表现成“刚买成功又回锁”,明天它也可能变成“恢复购买提示成功但没解锁”,或者“真正过期了却迟迟不回锁”。

所以这次文章最后,我想留给自己,也留给正在做类似功能的人一句话:

你项目里,到底有几个地方,有资格把“用户是不是会员”这件事改掉?

如果这个问题你还没有一个很清楚的答案,最好早点去查。

因为很多会员 bug,最后都不是从页面开始的,但一定会在页面上爆出来。