审计我们自己的支付系统时发现了什么
两个严重问题,而且都不在我们本会去找的地方。一份关于已修复错误的公开报告。

2026 年 8 月,我们从头审查了我们的支付系统。不是因为发生了什么事,而是因为它不久前刚上线,我们想知道自己遗漏了什么。
我们遗漏了不少。本文会把这些发现一一列出。这里描述的所有问题都已修复,而且其中大部分本来就已写在公开的更新日志中。
两个严重问题源自同一个建模错误
当有人向我们购买 Premium 时,会在我们的支付服务商那里创建一条客户记录。我们把这条记录存储在服务器 ID 下,毕竟 Premium 是落在服务器上的。
这恰恰是错误的模型,也是两个最严重问题的根源。
客户记录属于一位买家。它承载着买家的付款方式、发票历史,而一旦有人为两个服务器付费,还会承载多个订阅。用服务器 ID 作为键,就意味着多个人指向同一行。
问题 1:无需付款即可覆盖他人的客户数据
服务器的任何成员都可以购买,而获取或创建客户记录的函数在付款之前就会运行。它按服务器 ID 找到那一行,然后悄悄把它改写成新的潜在买家,包括其在支付服务商那里的电子邮件地址。
实际上这意味着:在一个曾经有人购买过的服务器上点一下就够了。此后,那位原始买家每一个订阅的发票都会发送到这个陌生人的电子邮件地址。无需付款,无需确认。
修复方法:在任何复用之前,先检查该记录是否真的属于这个账户。如果不匹配,就单独创建一条,另一条保持不变。
问题 2:账单门户暴露了他人的订阅
同一个建模错误,更深一层。所有权检查针对的是服务器的订阅,但门户会话是为客户记录打开的,而按设计,客户记录可以承载多个服务器。
因此,任何在一个服务器上合法购买的人,都可以看到并管理同一条记录为其他服务器承载的订阅。
修复方法:从具体的购买中解析客户记录,而不是从服务器那一行。
代码与我们自己的条款相矛盾的两个问题
这是最让我们困扰的一类,因为它不是技术问题。
黑名单拿走了已付费的时间。 我们的条款规定,订阅在当前期限结束时终止。但在将服务器加入黑名单时,代码要求立即取消:不退款,并且从那一刻起,对于已经付费的期间也不再提供服务。代码做的事与我们书面承诺的不一样。现在,已付费的期间会正常走完。
撤回权放弃声明无法证明。 购买前你必须确认服务立即开始。这份确认只存在于浏览器中,从未被发送,也从未被存储在任何地方。举证责任在我们,而我们根本没有证据。现在,同意是结账路由的必填字段,会连同时间戳和带版本的措辞一起存储,而且措辞本身也有版本:现有文本永远不会被编辑,总是创建一个新版本,否则旧记录将无法再现。
第二轮比第一轮更糟
在发现前 21 个问题后,我们几周后又检查了一遍。这是正确的决定,因为最严重的那个问题直到那时才暴露出来。
服务器 B 上的一笔退款删除了服务器 A 已付费的永久许可。 退款和拒付通知只指明一笔付款,而不是服务器。我们通过客户记录来解析它们。而在第一轮中我们已经正确地确定,一条记录属于一位跨多个服务器的买家,于是这种解析方式恰恰不再唯一确定。为两个服务器付过费、并为其中一个获得退款的人,可能会失去另一个服务器的永久许可。
现在的解析分四个精确步骤进行,任何无法唯一确定的情况都会被上报,而不是靠猜。
赢得的拒付争议没有恢复任何东西。 发生拒付时,代码会删除购买记录,而那是证明这个服务器曾拥有永久许可的唯一证据。之后即使赢得争议,也无法撤销。现在它会做标记,而不是删除。
数据保留删除了已付费的服务器。 当机器人离开一个服务器时,其数据会在 30 天后被移除。这项清理从未询问是否还挂着一个正在进行的订阅。
我们从中学到了什么
bug 不在支付代码里。 没有一个严重问题涉及金额、货币或计算。它们全都在背后的模型中:谁拥有客户记录,谁拥有付款,谁拥有服务器。任何通过查看金额来审计支付系统的人,都把目光从危险的部分移开了。
审计需要第二轮。 最严重的问题直接源于第一轮的一个修复。这并非巧合:纠正一个模型,会让其他地方仍然依赖的假设失效。
“我们的条款是这样写的”是一句可以核查的话。 有两个问题是我们书面承诺与代码行为之间的偏差。这是可以读出来的,但没人去做,因为它看起来像法律而不是工程。