· engineering· 约 25 分钟精读
端侧离线教育应用的付费权益裁决工程:一次性买断、本机凭据校验与断网授权的能力边界
一款不注册、不联网、不采集遥测的教学应用,却要在商店里卖出一张一次买断的通行证——付费权益本该由服务端回答,而这里没有服务端。本文以密旋科技 QubitLab 为样本,拆开这台设备上的权益判定链路:本机凭据读取、签名校验、现售与历史商品标识的兼容、已购与未购之外的第三态,以及退款与撤销在离线窗口内无法感知这一必须写进文档的边界。
一、一款把数据留在本机的应用,为什么要谈付费
密旋科技的 QubitLab 是面向 App Store 的移动端量子物理与 STEM 启蒙沙盒:96 关闯关、布洛赫球面态矢进动、双比特逻辑门拼装与测量坍缩演示,全部在本机运行。它不注册、不联网、没有服务端,也不采集任何使用数据。题目、视频、关卡参数与进度都在设备上,关掉网络后它能做的事情一件不少。
但商店里有一条商品:一次买断的大师通行证,标价 14.99 美元,非消耗型,支持家庭共享。这句话落到工程上立刻产生一个不小的矛盾——付费权益本该由一台服务器来回答,而这款应用没有服务器。 服务器回答这个问题的方式很成熟:收据上送、后端验签、账号绑定、状态入库;它的每一环都建立在联网与账号之上,而这两样东西恰好是这款应用在设计上主动放弃的。
于是问题变得很具体:没有账号、没有后端、没有遥测,设备如何知道”这台机器上的使用者是不是已购用户”,以及这个判断能担保到什么程度。
需求侧也在变化:2026 年 10 月 10 日,北京市科学技术委员会与中关村科技园区管理委员会发布关于开展北京地区 2026 年前沿新兴技术科普作品(量子科技方向)推荐工作的通知。一款要卖给孩子的应用,最有说服力的隐私声明不是写了一页政策,而是根本没有可被滥用的数据通道。
二、支付轨与应用数据是物理隔离的两件事
付费这件事有两个层面:钱怎么付,和权益怎么判。
钱的那一层完全不经过应用:购买由系统商店组件完成,卡号、账单地址、账户凭据都在系统与商店之间流转,应用拿不到也不需要拿到。应用能看到的只有一件事:商店告诉它,某个商品标识在这台设备上是否处于已购状态。 这条信息以签名凭据的形式存在系统的商店组件里,不在应用自己的台账文件中,也不进入应用写的任何 JSON。
也就是说,在”应用持有哪些用户数据”这个清单上,付费这一栏的答案几乎是空的:不保存买家身份、交易编号、支付凭据,也不保存购买时的设备或地区信息。前两种模式都必须回答”这笔权益属于哪个账号”,因此必然产生一份与个人身份关联的服务端记录;第三种模式里,权益归属于设备上的商店账户关系,应用只是读了一个结论。
| 权益校验模式 | 关键依赖 | 是否需要账号 | 断网时能否判定 | 应用侧需要保存什么 |
|---|---|---|---|---|
| 服务端校验收据 | 应用后端、收据串、网络往返 | 通常需要,用于绑定权益归属 | 不能,判定依赖服务端应答 | 收据副本、账号标识、权益状态记录 |
| 账号令牌下发 | 账号系统与令牌刷新接口 | 需要 | 不能,令牌过期即失效 | 令牌缓存与用户标识 |
| 本机读取商店凭据 | 系统商店组件的本机缓存 | 不需要 | 可以,判定在本机完成 | 不保存权益记录,只保留运行时结论 |
三、判定链路的四步
判定读起来只有一句”看看有没有已购凭据”,落到代码里是四步,每一步都有自己的失败语义。
第一步,读取本机交易凭据。 读取的是商店交易在本机的缓存集合,一次本地查询,不发起网络请求,飞行模式不会让这一步失败。
第二步,做本地签名校验。 每条凭据都带平台签名,校验在设备内用操作系统内置的根证书完成,同样不需要联网。结果二分为通过或不通过,只有通过的那一类被采纳,其余丢弃。
第三步,匹配商品标识。 应用要把现售标识与历史标识并入同一判定,细节留在下一节。
第四步,收敛成状态。 汇总是”只要有一条匹配且校验通过的凭据,就判定为已购”,输出两个独立布尔量:一个是权益结论,一个是判定是否已完成。
| 步骤 | 输入 | 输出 | 失败时的行为 |
|---|---|---|---|
| 读取本机凭据 | 系统商店组件的本机缓存 | 一份交易凭据序列 | 空集,判定为未购,但不报错 |
| 本地签名校验 | 凭据与其签名、系统根证书 | 通过或不通过 | 不通过的凭据被丢弃,不授予权益 |
| 商品标识匹配 | 现售标识、历史标识 | 是否命中任一标识 | 不命中即视为该条凭据无关 |
| 状态收敛 | 上述两步的逐条结果 | 权益结论与判定完成标记 | 判定未完成时保持”未定”,不前移也不解锁 |
整个链路里没有一处需要”现在几点”:凭据的合法性不与当前时间比较,权益也没有到期字段。这来自商品类型的选择,第四节与第十节说明其两种后果。
四、三态为什么不能压成两个状态
界面通常只想要一个布尔量。但这套实现刻意保留了两个:权益结论与判定完成标记,于是得到三种状态——已购、未购、未定。
未定态的存在理由很具体。应用启动的瞬间异步读取还没回来,此时权益结论的初值是”未购”。界面若直接相信这个初值,已付费用户会在每次冷启动的头几百毫秒里看到升级横幅与锁定关卡,然后它又消失。这不是崩溃,但每一次启动都在向付费用户暗示”你好像没有买过”。
判定未完成与判定为未购,是两件必须分开的事。 前者要求静默等待,后者要求提示。三态的做法是:未定时既不显示升级入口,也不解锁受控内容——受控内容呈现为锁定,但这个锁定不带跳转,也不会弹出付费墙。代价是短暂的不一致:钱已经付了,但入口还没出现,这段时间通常在一次本地异步读取之内结束。
还有一个与权益并列的解锁来源:教师课堂演示模式是设备级偏好,它同样能解锁关卡与实验室,用于课堂大屏互动。判定关卡是否解锁因此有两条输入,代码里以”演示模式或已购”的显式或运算表达,否则日后排查”为什么这台设备能进付费关卡”时会找不到原因。
五、商品目录与权益判定必须分开
升级入口要显示价格,价格来自商品目录请求;而权益判定读的是本机凭据。这两组数据来源不同、可用性也不同。 商品目录请求需要网络,本机凭据读取不需要,于是存在一个完全合法、却常被代码写错的状态:权益可得,而价格不可得。
断网的老用户就处在这个状态里:内容应全部可用,付费墙只是无法显示价格。把”目录没取回”当成”未购”会降级这位用户;把权益判定推迟到目录取回之后,断网用户的付费状态就永远不会被确认。
代码把”购买是否就绪”独立成一个判断:只有目录确实取回过一个商品对象,购买动作才被认为就绪,否则入口给出”当前不可用”,而不是一个必然失败的按钮。就绪度描述按钮能不能用,权益描述内容能不能进,两者互不代替。
| 权益结论 | 商品目录已取回 | 界面处理 | 常见写错的方式 |
|---|---|---|---|
| 已购 | 是 | 解锁全部受控内容,不显示升级入口 | 已购仍显示升级入口,重复引导付费 |
| 已购 | 否 | 解锁全部受控内容,不显示价格也不显示入口 | 把目录失败当未购,降级付费用户 |
| 未购 | 是 | 显示升级入口与价格,购买动作可用 | 只显示入口但购买动作被就绪度挡住 |
| 未购 | 否 | 显示升级入口但购买不可用,并说明需要网络 | 直接放行购买,用户点到一个必然失败的按钮 |
这张表的第二行最值得记住:离线可判定这件事的价值,只有在权益判定不与网络请求耦合时才能兑现。
六、一次买断的长期承诺:历史商品标识
商品标识是应用与商店之间的长期契约。应用改名、商品重命名、工程迁移之后,老用户凭据上的标识不会跟着变。当前实现同时接受两个标识:现售的那个与早期版本用过的那个;购买路径只用前者,判定路径两个都认。
这条规则的紧迫性来自离线产品的结构性约束:没有任何服务端通道可以让应用在事后修复一次误判。 如果某个版本”清理”了历史标识的匹配逻辑,所有在早期版本购买过的用户都会在某次更新后掉出已购状态,能恢复的路径只剩下卸载重装后手动触发一次恢复购买,而这要求用户还记得有这个动作,也要求他还在用同一个商店账户。
所以历史标识的匹配应当被视为只读的兼容常量,而不是可以随版本清理的遗留代码。它看起来像一行多余的或运算,实际上是这款应用对”一次买断”的兑现方式:承诺期限不是开发团队记得它的时间,而是这条判定规则还在包里的时间。
七、事件监听:为什么需要一个长生命周期任务
购买动作只覆盖权益变化的一部分,至少三种变化不发生在购买流程里:家长同意之后才到达的延迟批准、家庭共享关系变化带来的权益增减、以及退款与撤销,它们都不会经过那个购买按钮。
处理方式是一条与界面同寿命的异步监听:启动即订阅交易更新,进程退出才取消;每收到一次与相关商品标识匹配的事件,先确认交易已被交付,再重算权益一次。两个动作都不可省,理由是对称的:只重算不确认,商店会在后续启动中反复投递同一条未被确认的交易;只确认不重算,界面会一直拿着变化之前的旧结论。
还有一个容易被界面写错的结果分支:带家长审批的购买既不是成功也不是失败,而是”待批准”。把它当成失败会让用户以为扣款出了问题,当成已购则会提前放行内容。
八、恢复购买不是常规路径,而是补救动作
恢复购买的语义是与商店重新同步一次事务状态,通常需要联网,也可能触发账户验证。它是补救动作,不是权益判定的前置步骤。
两者的区别在断网时非常直观:如果应用只在用户点击恢复之后才去确认权益,一个长期断网的用户就永远看不到自己已经买下的内容。正确的顺序是先读本机凭据、按结论渲染界面,把恢复购买留在”判定结论与用户预期不一致”的时候使用。
九、滞后窗口:退款、撤销与家庭共享
商店是权益事实的唯一权威,但它只在联网时可达;本机缓存是离线可读的副本,两者之间的时间差就是滞后窗口。窗口内本机知道的只是”自己曾经知道的状态”。
窗口内不可感知的事件有三类,影响面并不相同。
| 事件类型 | 权威来源 | 断网时本机能否感知 | 本机后果 | 影响范围 |
|---|---|---|---|---|
| 退款 | 商店 | 不能,需下次同步 | 已退款用户的权益在本机继续生效 | 一台设备上的离线内容 |
| 撤销 | 商店 | 不能,需下次同步 | 同上,撤销时间戳在同步后才会体现在凭据上 | 一台设备上的离线内容 |
| 家庭共享关系变更 | 商店 | 不能,需下次同步 | 加入方在同步前用不到,移出方在同步后才会失去 | 参与共享的各设备 |
读完这张表要补一句边界声明:本机判定回答的问题是”本机已知的商店状态”,不是”用户的全球权益事实”。 它不构成一张全球账本,也没有任何机制让它变成账本。
这个边界可以接受,是因为代价被产品形态限制住了:应用没有云端服务、没有账号体系、没有按次计费的服务端资源,权益只门控本机离线内容。最坏情况是”某台设备上的部分内容在退款后仍可用一段时间”,而不是”某项云端服务被免费用”。若有服务端资源要消耗,本机凭据就不够了,必须回到服务端判定——这是形态决定的安全边界,不是实现上的疏忽。
十、一次买断这个选择,顺带除掉了时钟依赖
答案是商品类型。非消耗型一次买断没有到期时间,凭据里没有可与当前时间比较的字段,判定也就不需要可信时钟。而订阅式权益必须比较到期时间,于是立刻依赖设备时间的可信性——手动改时、跨时区、夏令时、备份恢复造成的时间倒退都会影响判定;离线台账那套”墙钟不可信、需单调锚与改时留痕”的负担会原样复制过来。
产品上选择一次买断,等于在工程上删掉了整类问题。 代价也要说清楚:一次买断在语义上无法表达”限时”或”续期”,要加这些能力就必须重新引入一条时间判定链路,而它在离线环境下不会比现在更可信。
十一、可测试性:权益必须能被注入
权益来自系统商店组件,自动化环境无法真实完成一次购买,因此判定入口必须能被注入;否则界面测试与录制走查只能在一个”永远未购”的状态下运行,付费界面根本走不到。
实现上有两条注入路径:一条是进程启动参数,命中时直接置为已购且判定完成,并且不再订阅交易更新;另一条在调试构建里直接放行购买动作,用于录制产品走查而不产生真实交易。两条路径的共同点是只认进程启动参数、不读任何外部输入,默认路径仍走真实商店。
这构成一个测试友好的入口,而不是用户可以触发的入口。 应用内没有界面能打开它,它只在进程启动那一刻被读取;把测试能力做成显式开关、默认路径不生效,也比在正式路径里塞条件分支干净。
十二、隐私账:不收集,就不知道
在付费这件事上,应用到底知道什么?只有一个布尔量:本机是否已购。它不落进应用写的任何文件,也不参与任何统计。
应用不知道买家的名字、邮箱、账户、地区、交易编号;也不知道有多少人购买、转化率多高、退款率多低。代价很具体:没有用户分群、没有定价实验、没有转化漏斗,也无法在应用里向未付费用户做定向召回。
换来的是另一件事:付费状态不进入任何用户画像,因此结构上不存在”权益数据泄漏”这一攻击面——应用手里根本没有这份数据。对一款装进未成年人设备的量子启蒙应用,这个交换的取向是清楚的:宁可不知道,也不要为了知道而先收集。
十三、这套设计做不到的五件事
它能稳住的部分不必重复,难的是写清做不到的五条。
第一,它不能阻止已退款用户在下次同步前继续使用本机内容。第二,它不能在没有网络的情况下完成购买本身——离线优先描述的是数据与内容,不是支付通道。第三,它不能支持任何基于时间的权益语义,因为这条链路里没有可信时钟,也没有到期字段。第四,它在技术上无法区分”买给自己”与”通过家庭共享获得”,也不打算区分,因为那需要一份关于家庭成员关系的记录,而应用不持有这类记录。第五,它不能在不发版的前提下修改权益策略——判定逻辑是随包发布的代码,不是服务端配置。
十四、结语
付费与隐私常被摆在天平两端:要收钱就得知道用户是谁,要保护隐私就得放弃收费能力。这款应用给出的第三种答案是——权益判定确实可以不依赖账号,只要接受一个明确的代价:判定反映的是本机已知的商店状态,而不是全球事实;退款与撤销存在一段无法消除的滞后窗口。
这个答案之所以成立,是因为代价被限制在本机离线内容的范围内,并且写清了边界。工程上少见的不是”能做到”,而是”知道自己在哪一步开始做不到,并且把它说出来”。
参考文献
[1] Apple Inc., StoreKit 2: Transaction.currentEntitlements. https://developer.apple.com/documentation/storekit/transaction/currententitlements [2] Apple Inc., StoreKit 2: VerificationResult. https://developer.apple.com/documentation/storekit/verificationresult [3] Apple Inc., StoreKit 2: Transaction.updates. https://developer.apple.com/documentation/storekit/transaction/updates [4] Apple Inc., StoreKit 2: AppStore.sync(). https://developer.apple.com/documentation/storekit/appstore/sync() [5] Apple Inc., StoreKit 2: Transaction.revocationDate. https://developer.apple.com/documentation/storekit/transaction/revocationdate [6] Apple Inc., StoreKit 2: Product.familyShareable. https://developer.apple.com/documentation/storekit/product/isfamilyshareable [7] Apple Inc., App Store Review Guidelines, 3.1.1 In-App Purchase. https://developer.apple.com/app-store/review/guidelines/ [8] 北京市科学技术委员会、中关村科技园区管理委员会, 关于开展北京地区2026年前沿新兴技术科普作品(量子科技方向)推荐工作的通知(2026-10-10). https://kw.beijing.gov.cn/zwgk/zcwj/202610/t20261010_4896412.html