subscription billing platform 能否在无数据库下使用?
明确结论
可以不自建客户数据库,但不能让桌面 Java 应用直接调用计费平台完成授权,这样做并不安全。
比较合理的方案是:
- 由计费平台保存客户、付款和订阅数据。
- 提供一个小型授权服务,使用服务器端 API key 查询订阅状态。
- 授权服务向客户端签发有期限的数字签名许可证。
- Java 应用只保存公钥并验证许可证,不接触计费平台的密钥。
授权服务可以是无状态的,不一定需要数据库。但如果完全没有自己的服务和存储,身份关联、Webhook 处理、退款撤销、设备管理和离线授权都会受到限制。
为什么不能由 Java 客户端直接查询
计费平台的管理 API 通常需要 secret API key。Java 桌面程序运行在用户控制的计算机上,程序包、内存和网络请求都可能被分析。即使对 secret API key 进行混淆,也无法真正保密。
一旦 API key 被提取,攻击者可能读取客户信息,甚至修改订阅或执行其他管理操作。调用链应当是:
Java 客户端
│ 用户登录或提交一次性授权码
▼
你的授权服务
│ 使用 secret API key
▼
计费平台 API
客户端只能持有公开信息,例如许可证验证公钥。计费平台的 secret API key 和许可证签名私钥必须保存在服务器端。
无自建数据库的实现方式
可以采用下面这套无状态流程。
1. 使用托管结账页面
让用户通过计费平台提供的 hosted checkout 或 customer portal 完成注册和付款。支付卡信息直接提交给支付或计费平台,不经过你的服务器。
这样可以大幅减少你处理敏感支付数据的范围。至于是否满足特定合规要求,还要根据所选平台和业务所在地确认。
2. 建立客户与软件账号的关联
授权服务必须知道正在请求授权的人对应计费平台中的哪位客户。可以采用以下方式:
- 用户在计费平台完成付款后获得一次性兑换码。
- 使用计费平台支持的 OAuth、登录链接或 customer portal 回调。
- 在 checkout 时写入由服务器生成、无法猜测的引用值。
- 用户登录授权服务,再由服务保存或查询对应的 billing customer ID。
不能只让用户输入邮箱。攻击者可能输入其他订阅者的邮箱来获取授权,而且邮箱本身也可能变更。
如果不建立自己的数据库,这种映射必须保存在计费平台允许使用的 metadata、自定义字段或其他可查询记录中。具体能否实现,取决于平台的 API 能力和限制。
3. 由授权服务查询订阅
授权服务收到经过认证的授权请求后,使用服务器端凭据查询:
- customer ID;
- subscription ID;
- subscription status;
- 当前计费周期结束时间;
- 已购买的 plan、product 或 feature entitlement;
- 是否取消、退款或处于宽限期。
不能只检查订阅是否存在。trialing、active、past_due、canceled 等状态分别对应什么授权结果,需要按业务规则明确处理。
示意代码:
// 服务器端示意代码,不对应某个计费平台的真实 SDK。
Subscription subscription =
billingClient.getSubscription(request.subscriptionId());
if (!request.customerId().equals(subscription.customerId())) {
throw new SecurityException("Subscription does not belong to this customer");
}
boolean entitled =
subscription.status() == SubscriptionStatus.ACTIVE
&& subscription.products().contains("desktop-pro");
if (!entitled) {
throw new SecurityException("No active entitlement");
}
Instant licenseExpiresAt = min(
subscription.currentPeriodEnd(),
Instant.now().plus(Duration.ofHours(24))
);
示例把许可证有效期限制为 24 小时,以便取消、退款或欠费状态能较快反映到客户端。24 小时只是示例,实际时长应根据离线使用需求和风险确定。
4. 签发许可证,而不是生成“加密 key”
许可证通常不需要加密,需要的是数字签名:
{
"licenseId": "random-license-id",
"subject": "internal-or-provider-customer-reference",
"product": "desktop-pro",
"features": ["export", "automation"],
"issuedAt": 1787932800,
"expiresAt": 1788019200
}
服务器使用私钥对规范化后的许可证内容签名,客户端使用内置公钥验证。客户端可以读取许可证内容,但如果修改有效期或功能列表,签名验证就会失败。
加密解决的是别人能否看到内容,数字签名解决的是内容是否由你签发以及是否被篡改。授权场景主要需要数字签名。
Java 客户端验证示例
下面的示例假设许可证内容已经按照双方约定的规范编码,并使用 Ed25519 签名。Ed25519 需要相应的 Java 运行时支持。如果目标运行时不支持,应改用它支持的签名算法,或者使用经过审查的密码库。
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
public final class LicenseVerifier {
private final PublicKey publicKey;
public LicenseVerifier(String publicKeyBase64) throws Exception {
byte[] encodedKey = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
this.publicKey = keyFactory.generatePublic(
new X509EncodedKeySpec(encodedKey)
);
}
public void verify(
String canonicalPayload,
String signatureBase64,
long expiresAt
) throws Exception {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(canonicalPayload.getBytes(StandardCharsets.UTF_8));
byte[] signature = Base64.getUrlDecoder().decode(signatureBase64);
if (!verifier.verify(signature)) {
throw new SecurityException("Invalid license signature");
}
Instant expiration = Instant.ofEpochSecond(expiresAt);
if (!expiration.isAfter(Instant.now())) {
throw new SecurityException("License has expired");
}
}
}
调用时还要检查产品和功能权限:
if (!license.product().equals("desktop-pro")) {
throw new SecurityException("License is for another product");
}
if (!license.features().contains("export")) {
throw new SecurityException("Feature is not licensed");
}
生产环境可以使用成熟的 JWT/JWS、PASETO 或专用许可证格式,但必须固定允许使用的签名算法,并验证 issuer、audience、有效期和产品标识。不要接受令牌自行声明的任意算法,也不要自行设计密码算法。
无数据库方案的实际限制
Webhook 难以可靠处理
订阅状态可能因续费失败、退款、拒付或管理员操作而变化。常见做法是接收计费平台的 Webhook,并将最新状态保存到本地数据库。
如果完全不保存状态,授权服务只能在每次续签许可证时实时查询平台 API。这样更简单,但也会:
- 增加 API 调用量和响应延迟;
- 依赖计费平台实时可用;
- 受到 API 限流影响;
- 难以对 Webhook 进行可靠去重和审计;
- 无法立即撤销已经签发的离线许可证。
离线授权无法即时撤销
如果许可证有效期为 30 天,用户退款后,现有许可证理论上仍能继续使用,直到到期。缩短有效期可以缩小这个窗口,但客户端需要更频繁地联网。
这几种方案各有取舍:
- 短期许可证撤销较快,但更依赖网络。
- 长期许可证的离线体验更好,但撤销延迟更长。
- 每次启动时在线验证可以较快更新状态,但服务不可用时,软件也可能无法使用。
一种常见做法是使用短期许可证,并提供有限的宽限期。例如,许可证定期在线刷新,遇到网络故障时允许继续使用几天。具体时长属于业务策略,没有通用的正确值。
本机时间可能被修改
只比较客户端系统时间,无法彻底阻止用户回拨时钟。可以记录上次可信的服务器时间,发现时间明显倒退时要求重新联网,但本地数据仍然可能被删除或修改。
只要代码运行在用户自己的计算机上,就不可能做到绝对防破解。授权系统通常只是提高绕过成本,同时为正常付费用户提供稳定的使用体验。
仍可能需要少量业务数据
即使不保存姓名、地址和支付信息,也可能需要保存:
- 内部账号 ID 与 billing customer ID 的映射;
- 已兑换的一次性授权码;
- 许可证撤销记录;
- 已激活设备及数量;
- Webhook event ID,用于防止重复处理;
- 审计和客服所需记录。
这些数据不一定包含支付信息,也可以设置较短的保存期限。如果业务需要限制设备数量、支持账号迁移或可靠处理退款撤销,使用一个小型数据库通常比完全无状态的设计更稳妥。
推荐的最小架构
如果需求只是判断订阅是否有效,可以从下面的架构开始:
托管结账页面
│
▼
计费平台保存客户和订阅
│
▼
无状态授权服务实时查询订阅
│
▼
签发短期数字签名许可证
│
▼
Java 客户端使用公钥验证并启用功能
这套架构不需要维护自己的客户数据库,也不接触支付卡数据,但仍然需要一个受控的服务器端授权服务。
只有当计费平台本身提供面向桌面应用的安全授权、entitlement 或 license API,并明确支持不暴露管理密钥的客户端流程时,才有可能省去这层服务。是否具备这种能力,需要查阅最终所选平台的官方文档。
对于正式商业软件,更实际的做法是只保存必要的映射和授权状态,不复制计费平台中的完整客户资料。这样既能减少个人数据存储,也能可靠处理续费、退款、撤销和设备管理。
备注:内容仅供参考。
