这周我遇到一个很典型的苹果订阅逻辑问题:用户已经支付成功,页面先解锁,过十几秒却又重新锁上,再点购买,苹果却提示“你已经订阅了”。最开始我也在看会员限制层和弹窗,后来才发现,问题根本不在页面,而在会员状态被多条异步校验结果来回改写。
前段时间我在项目的 Scan 模块里踩到一个 bug。
它不是那种一眼就知道该查哪里的 bug。
恰恰相反,它看起来特别像页面问题,结果越查越发现,真正出问题的地方根本不在页面。
测试给到的现象很具体:
-
用沙盒账号买会员,支付成功。
-
Scan首页锁层先消失了,看起来已经解锁。 -
进入健康指标或者疾病风险的趋势图弹窗,来回切几下
7 days / 30 days / 90 days。 -
过十几秒,会员限制层又回来了。
-
用户再点订阅,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 还在同步,页面又在回流,后台还可能有别的刷新。
所以后来又加了一个很短的保护窗。
在这个窗口里:
-
可以继续做校验
-
可以继续确认更准确的状态
-
但不允许任何路径立刻把用户降级成非会员
这么做的目的只有一个:别让用户刚付完钱,就马上体验到一次“被打回去”。
这次最值得记住的,不是某一段代码,而是一种看问题的方式
这次排查对我来说,真正值钱的不是“某个参数怎么配”,而是后面这套看问题的顺序。
先别急着盯页面,先问自己三件事:
-
这个页面显示的状态,到底是谁生产出来的?
-
这个状态现在有几个地方能改?
-
其中有没有谁,本来就不该拥有这么大的写权限?
这三个问题一问,很多表面上的 UI bug,最后都会变成状态同步问题。
这次就是这样,它表面上像:
-
会员限制层闪回
-
趋势图弹窗有问题
-
订阅页这边的状态没有处理干净
但真正该修的,其实是:会员状态不该被这么多异步结果反复改来改去。
后来又顺手带出了另一个坑:Restore
这条线查到后面,还顺手把 Restore 的问题也带出来了。
恢复购买最开始的问题,不是恢复不了,而是恢复这件事对用户来说不够“说得清”。
最典型的是两种情况:
-
提示已经恢复成功了,但会员限制层没开
-
或者中间先提示一个结果,最后状态又是另一个结果
后来我才把恢复购买这条链也单独收了一下:
恢复订阅这件事,不要看到前面有返回就算成功,而是要等最后真正确认用户已经恢复会员了,才 算成功。
这句话和前面的思路其实是一回事。
不管是订阅成功,还是恢复订阅,用户最终看到的东西,都不该由那些半路上的中间结果来决定。
这次复盘下来,我最想留下 4 句话
1. 会员限制层出问题,先别急着查它
很多时候它只是最后一个把错误状态画出来的地方。
2. 页面静默刷新,不该轻易拥有降级权限
它可以同步状态,但不该轻易改掉最终状态。
3. 旧结果覆盖新结果,是会员系统里最容易被低估的坑
不加并发保护,这类问题迟早还会回来。
4. 从会员降级到非会员,一定要比升级更保守
这不是保守开发,而是对真实用户体验负责。
写在最后
这次问题表面上是“已订阅成功却还是非会员”,但真正让我记住的,不是这个现象本身,而是它特别像一个提醒,
提醒我:
做会员、订阅、会员限制层、权限这类功能时,最“危险”的地方往往不是那个最显眼的页面。
真正“危险”的,通常是页面背后那条你平时不太想第一眼去看的状态更新流程。
如果那个流程没有边界,没有优先级,也没有足够保守的降级条件,问题迟早会出来。
今天它可能表现成“刚买成功又回锁”,明天它也可能变成“恢复购买提示成功但没解锁”,或者“真正过期了却迟迟不回锁”。
所以这次文章最后,我想留给自己,也留给正在做类似功能的人一句话:
你项目里,到底有几个地方,有资格把“用户是不是会员”这件事改掉?
如果这个问题你还没有一个很清楚的答案,最好早点去查。
因为很多会员 bug,最后都不是从页面开始的,但一定会在页面上爆出来。