一个密钥五台 Mac,名额怎么才不会悄悄漏光
Pier 复盘:买断许可证限 5 台设备激活,但「注销」如果只清本地,服务端的名额永远占着,用户换几次机就撞墙来找你人工释放。修法是注销必须服务端释放名额,且失败时宁可不放、也绝不悄悄泄漏。
Pier 2.0 开始收费,一个密钥可以在 5 台 Mac 上激活。这个「5 台」的限制看着简单,做对却有个容易漏的坑:注销时如果只清本地,服务端的名额就永远占着。用户重装几次、换台机器,5 个名额悄悄漏光,然后撞墙——只能来邮件找我人工释放。这篇讲这个名额生命周期怎么才算做对。
之前写过 Pier 无后端的许可证系统:激活、校验都走支付商 Dodo Payments 的公开端点,客户端不搭后端。那篇讲架构,这篇补一块具体的正确性——注销。
名额是服务端的状态,本地清不掉它
先厘清「激活」在服务端是什么。激活一台机器时,客户端调 Dodo 的 activate,服务端为这台机器建一个 activation instance,占掉 5 个名额里的一个,返回 instance id。本地 keychain 存下密钥和这个 instance id。
第一版的注销只做了一半:
// 第一版:只清本地 keychain
func deactivate() {
Keychain.set(nil, for: K.licenseKey)
Keychain.set(nil, for: K.activationID)
}
本地是干净了,界面回到未激活。但服务端那个 instance 还在,名额还占着。用户的心智是「我注销了,应该退了一台的位」,实际那台的名额石沉大海。重装 App = 一次注销 + 一次激活 = 净消耗一个名额。装卸几轮,5 台的额度就在用户毫不知情的情况下见底。
注销必须先服务端释放
修法是让注销真正对称于激活——先调服务端 deactivate 释放名额,成功了再清本地:
func deactivate() async {
guard let key = Keychain.get(K.licenseKey),
let instanceID = Keychain.get(K.activationID) else {
clearLocalActivation(); return
}
isDeactivating = true
defer { isDeactivating = false }
do {
try await Dodo.deactivate(licenseKey: key, instanceID: instanceID)
clearLocalActivation() // 服务端放了名额,才清本地
} catch {
activationError = error.localizedDescription // 失败不清本地
}
}
注意顺序:服务端释放成功,才清本地。反过来(先清本地再调 API)会重新引入漏名额——本地清了、API 万一失败,那个 instance id 就再也没人能引用它去释放了,名额彻底泄漏。
失败语义:宁可不放,绝不悄悄漏
真正的设计在异常分支里。deactivate 的网络请求可能有几种结果,得分开处理:
- 成功(2xx):名额释放,清本地,收工;
- 4xx = 服务端已经不认这个 instance(比如早已被释放):等价于「已经放掉了」,同样清本地——目标已达成;
- 429 / 5xx / 断网:服务端状态未知,保留本地激活态并提示重试。绝不能在这里清本地——一旦清了,本地就失去了 instance id 这个唯一能释放名额的凭据,那个名额就永久漏了。
这条规则一句话概括:要么确认释放了,要么保持原样让用户重试,绝不制造「本地以为退了、服务端还占着」的中间态。宁可让用户多点一次重试,也不让一个名额在无人知晓中蒸发。这和那篇架构文里校验的失败语义是同一套哲学——只在有确定性证据时才改状态,模糊时维持现状——只是方向相反:校验是「没有确凿失效证据就不掉激活」,注销是「没有确凿释放成功就不清本地」。
顺带修正了一处相关逻辑:启动时的静默复验(revalidate)发现许可证失效要回退时,改走纯本地清理,不再误调 deactivate API——那条路径的语义是「服务端已经说这个实例无效了」,再去调释放是多余甚至矛盾的。
界面也得跟上
后端对了,前端的诚实也得跟上。注销按钮加了进行中转圈和失败提示(中英):用户点注销时,如果卡在网络上,他该看到「正在注销」而不是一个假装已完成的静默状态;如果失败了,该看到「没退成功,请重试」而不是界面显示已注销、服务端却还占着。异步的、可能失败的操作,UI 必须如实反映它的三态——进行中、成功、失败——而不是乐观地假装它瞬间成功。
顺带把换机流程讲清楚
代码修对之后,官网 FAQ 也补了换机迁移的说明:一个密钥 5 台 Mac,换新机直接在新机激活即可,不需要先在旧机做任何操作;密钥就是许可证,重装和系统升级后照常有效;万一名额真满了,邮件一封释放退役机器的名额。把「注销释放名额」做对,是让这套自助流程能成立的前提——否则每个换机的用户最终都会变成一封需要人工处理的邮件。
复盘
- 有限名额的许可证,注销必须服务端释放。只清本地是做了一半——服务端 instance 还占着名额,用户装卸几轮就在不知情中漏光额度;
- 释放的顺序是「服务端成功 → 清本地」,不能反。先清本地再调 API,一旦 API 失败就丢了释放名额的唯一凭据,名额永久泄漏;
- 失败分支决定名额会不会漏。4xx 当已释放、5xx/断网保留态重试——绝不制造「本地已退、服务端还占」的中间态。宁可让用户重试,不让名额静默蒸发;
- 可能失败的异步操作,UI 要如实显示三态。进行中转圈、成功清理、失败提示重试,别用乐观 UI 假装瞬时成功——用户需要知道「到底退没退成」。
留言