不搭一行后端的许可证系统
Pier 复盘:一次性买断、5 台设备、可吊销退款——整套许可证系统客户端不到 200 行 Swift,零服务器、零嵌入密钥。关键在状态存哪、校验失败时信谁。
Pier 2.0 要开始收费,意味着需要一套许可证系统:试用 14 天、密钥激活、限 5 台设备、退款后能吊销。约束条件只有一条——我不想为一个菜单栏工具运维一个 license 服务器。它得 7×24 在线、防刷、备份,出问题时全世界用户的激活一起挂。这个负债和产品体量完全不匹配。
最后整套东西是客户端不到 200 行 Swift + 支付商自带的两个 HTTP 端点。
支付商即后端
选了 Dodo Payments,看中两点。一是 Merchant of Record:跨国销售的税务(VAT/GST)它兜底,这对个人开发者是实打实的合规负担转移。二是自带 License Key API:用户付款自动发密钥,客户端只需要打两个公开端点:
POST /licenses/activate—— 密钥换一个 activation instance id(设备数限制在这里生效);POST /licenses/validate—— 校验密钥 + instance 是否仍有效(退款吊销在这里体现)。
关键性质:这两个端点用 license key 自鉴权——密钥本身就是凭证,请求里不需要任何 API secret。也就是说客户端零嵌入密钥,不存在「从 app 里抠出 secret 就能伪造激活」的问题。
状态存哪:keychain,不是 UserDefaults
本地状态机只有三态:
enum Status: Equatable {
case trial(daysLeft: Int)
case activated
case expired
}
试用起点和激活凭证都存 keychain 而不是 UserDefaults,理由只有一个:defaults delete 一行命令就能无限重置试用期,而 keychain 里的 item 删掉 app 重装仍然在。这不是防破解(后面说),是让「重置试用」的成本从一行命令变成需要专门去 Keychain Access 里翻——对诚实用户的摩擦为零,对顺手薅羊毛的用户刚好够用。
失败语义:对用户宽容,对证据严格
启动时会对已激活的许可证做一次静默复验。这里藏着整个系统最重要的一个决定——校验失败时信谁:
/// 返回 true=有效、false=已确认失效(200 但 valid:false、或 4xx);
/// 网络 / 服务器错误则抛出——调用方忽略,不回退状态。
if let valid = try? await Dodo.validate(licenseKey: key, instanceID: instanceID),
valid == false {
deactivate()
}
规则是:只有拿到确定性的失效证据(服务器明确说 valid: false,或 4xx)才吊销本地激活;网络不通、超时、5xx,一律维持现状。飞机上打开 Mac 不该看到「许可证已过期」,支付商宕机十分钟不该让全体付费用户掉激活。误伤一个付费用户的代价,远大于让一个已退款用户多用两天。
可测试性:三态都得能一键进入
许可证代码最讨厌的地方是状态难复现——「过期」要等 14 天,「已激活」要真付一笔钱。解法是留一个环境变量后门:
// PIER_FORCE_STATUS=expired|trial|activated,完全绕开 keychain。
// 仅当从 shell 带 env 启动时生效,普通用户无感。
switch ProcessInfo.processInfo.environment["PIER_FORCE_STATUS"] {
case "expired": status = .expired; return
...
三种付费墙状态在终端里各一行命令就能看到,UI 调试不用碰真实数据。test/live 两套环境也是一个布尔开关同时切 API host 和付款链接,不存在「改了一处忘了另一处」。
诚实条款
这套东西防不了认真破解——本地状态机,改二进制或者伪造 keychain item 都能绕过。但威胁模型从一开始就不是 DRM:单机工具做服务端强校验,等于为了防 1% 的破解者,让 99% 的付费用户承担联网依赖。锁的作用是让门有个形状,愿意开门的人自然走门。
复盘
- 能买的后端不要自己搭。license 服务器是典型的「一次搭建、终身运维」,支付商的公开端点把这个负债整个转出去了;
- 自鉴权端点是零信任客户端的正解。密钥即凭证,客户端里没有任何值得逆向的东西;
- 失败语义比 happy path 重要。「网络错误维持现状」一行判断,决定了离线用户和宕机时段的全部体验;
- 每个状态都要有直达通道。不能一键复现的状态,等于没测过。
留言