测试订单是假的,钱是真的
一次 Telegram Stars 支付测试,被 Apple 扣了 1,077 新币真钱。不是 Telegram 或 Apple 出了故障,而是我们没搞清测试 Stars 应该从哪里来。为什么稳定版客户端里的充值仍会扣钱、当前怎样预置零真钱测试余额、钱怎么当天全额要回来,以及把服务端订单路径的重演损失压到 1 XTR 的 fail-closed 防护。
事故经过
7 月 15 日晚上,我们在测 BluffKing 的 Telegram Stars 订阅——下单、付款、到账、开会员、退款,一整条链路。测试跑在 Telegram 官方的专用测试环境(Test Server)里:Bot API 地址加一段 /test,里面的 bot、账号、Stars 余额都和生产完全分开。
bot 这一侧一切正常:那笔 1,000 XTR 的测试订单在 /test 下创建、付款、开通了订阅,最后还被 bot 全额退回,一颗 Star 都不差。
但 Apple 的购买记录里,多了两笔真实扣款,合计 1,077 新币。
| 事件 | 时间(GMT+9) | 真钱 |
|---|---|---|
| 一笔 +1,000 Stars 的充值「via App Store」进入测试账号;几秒之后,这 1,000 Stars 付掉了那笔 1,000 XTR 测试订单 | 7月15日 23:15 | S$29 |
Bot 调用 refundStarPayment,1,000 XTR 退回测试账号——退回的是 Stars,不是现金 | 7月15日 23:55 | — |
| 测试栈关停;此测试栈不再产生任何 BluffKing 订单 | 7月15日 23:56 | — |
| 又一笔 +35,000 Stars 的充值「via App Store」——前后都没有任何对应的订单、webhook 或消费 | 7月16日 00:03 | S$1,048 |
| Apple 批准两笔全额退款 | 7月16日 | +S$1,077 |
我们最先怀疑是生产账号和测试账号串了。查下来没串:Telegram 自己的交易记录显示,两笔充值都是「via App Store」直接进的测试账号。没有任何组件出故障。出错的是我们自己的认知——测试手册里写着这条流程「不会扣真钱」,没有任何人在真实的支付面板前验证过这句话。
根因:只写了测试订单,没写测试 Stars 从哪来
根因不是 Telegram 的 bug,也不是 Apple 的 bug,而是我们的测试手册只配置了 Test Server 订单,却没有定义付款余额的安全来源,反而用一句「Test Server 流程不会扣真钱」把这个空白盖了过去。这句话对订单成立,对充值不成立——我们把钱押在了后半句也会自动成立。
要看清后半句为什么不成立,得把「付一笔 Stars 订单」这件事拆开。它其实要穿过三套各自独立的账本:
- Telegram 官方的测试环境隔离的是 bot 和它的订单:Bot API 地址加
/test,订单、支付回调、refundStarPayment全部与生产隔离。我们的测试订单确实是「假」的——这一环没有任何问题。 - 但我们用来付款的是 App Store 下载的稳定版 Telegram,测试账号只是登录在这个普通客户端里。Telegram 的 Test Server 开关不会把稳定版 App 自动变成 StoreKit 沙盒。付 Stars 订单的前提是账号里有余额,而新开的测试账号还没有。
- 客户端发现余额不够,就做了它平时一直做的事:弹出「购买 Stars」面板。我们当时看到的是正常的 Apple 内购(StoreKit),不是标有 Sandbox 的测试收银台。Apple 只看到「有人要买 1,000 个 Telegram Stars,29 新币」——它不知道这些 Stars 接下来会花在一笔
/test订单上,于是从 Apple ID 绑定的付款方式扣了真钱。
连起来就是:假订单 ← 真 Stars ← 真钱。「不会扣真钱」对订单这一环成立,对充值这一环不成立——测试开关只盖住了第一环。四十八分钟后,测试栈都已经关停,但「测试环境的 Stars 是免费的」这个错误认知还立着:为第二天续期测试囤弹药的 35,000 Stars,又从同一个真收银台买了进来,S$1,048。
排除过的假设:「让本地测试栈唤起 Apple 的 sandbox,不就解决了?」
复盘时我们反复回到同一个念头:测试栈跑在本地,收银台却是生产的——这不就是环境配错了吗?让本地直接唤起 Apple 的 sandbox,问题不就解决了?这个直觉对第一方支付通道完全正确——我们自己的测试环境就是指向 Stripe 的 test mode,我们自己 App 的内购也确实走 StoreKit sandbox。但在这条链路上它不成立,有三个互相独立的理由:
- 唤起收银台的不是我们的任何环境。整条链路里,我们的测试栈只参与 bot 这一侧:通过
/testBot API 开出订单、接收它的支付回调、发起 Stars 退款。「购买 Stars」面板是 Telegram 客户端在余额不足时自己弹出的,卖的是 Telegram 自己的内购商品——这次充值没有一个字节经过我们的服务器,在我们这边没有任何订单、回调或日志。我们的本地/生产开关,根本不在充值这条路径上。 - sandbox 开关焊死在「谁的构建」里,不在任何服务端。Apple 的规则:App Store 正式包永远走生产内购,TestFlight/开发签名包永远走 sandbox——环境由 App 的分发方式决定,sandbox 的入场券只能由拥有这个 App 的开发者团队签发。而这个 App 是 Telegram 的,只有 Telegram 能造出一个 sandbox 版的 Stars 收银台;Bot API 里不存在任何参数、webhook 或配置,能让一个商户 bot 替付款人选择 StoreKit 环境。「让 local 唤起 sandbox」不是我们漏配了一个开关,而是这个开关从来不在我们手里。
- 更大的一笔损失里,测试栈根本不在场。S$1,048 那笔 35,000 Stars 的充值发生在测试栈关停之后,前后没有任何订单、webhook 或消费——它是为第二天续期测试囤弹药、在客户端里手动完成的购买,动机正是「测试环境的 Stars 反正免费」。根因必须同时解释两笔扣款:「本地环境唤起了生产收银台」连第二笔的边都碰不到;「没有安全的测试 Stars 来源,加一句没验证过的免费断言」两笔都解释得通。
这个直觉里可执行的那一半——测试栈绝不该把人带到真收银台面前——正是下文修复的核心:金额锁死 1 XTR、余额必须事先声明。这是一份契约而不是保险丝:按契约进场,充值面板就没有弹出的理由;它一旦弹出,说明这次运行已经越界,唯一正确的动作是取消。不可执行的那一半是「弹出来的收银台应该是 sandbox 的」,因为那个收银台从来不是我们的。
测试 Stars 到底怎么来
Telegram 的支付文档说,bot 连到专用测试环境后可以自由测试 Stars 支付;但它没有承诺稳定版客户端里的「Buy Stars」会免费。过去 Test Server 里的 @izpremiumbot 曾能用测试卡发放免费余额,但 Telegram Desktop 官方仓库 2025 年 10 月的维护者说明称该入口因滥用已被锁。截至 2026-07-17 复核,我们没有验证到一个仍可用、零真钱的官方充值入口。
所以现在的安全流程不是「在测试环境充值」,而是预置非商店测试余额:从另一个已有 Test Server Stars 的账号,在测试网络内部转入,例如 Telegram 协议支持的付费消息收入,或收到后可兑换为 Stars 的Star Gift。转入后先查交易记录,来源不得是 App Store、Google Play、Fragment、@PremiumBot,也不能是正在退款或等待撤回的余额。找不到可信的测试网络余额,就停止真实 provider 的端到端测试;本地模拟照跑,但绝不为了「把测试跑完」去买 Stars。
Apple 自己确实提供StoreKit Sandbox,开发签名和 TestFlight 构建里的交易不会扣真钱;这和 Telegram Test Server 是两套独立开关。除非客户端构建和付款面板都明确显示 Sandbox,否则一律按生产扣款处理。我们事故中的稳定版 Telegram 不满足这个条件。
/test 只隔离订单这一环;本次事故里,付订单的余额来自稳定版 Telegram 打开的 Apple 真收银台。
还有一个不对称,让损失显得格外无力:我们手里确实有退款工具 refundStarPayment,但它只作用于 Stars 余额——能把 1,000 XTR 退回账号,碰不到 Apple 那笔现金扣款。Telegram 退 Telegram 的,Apple 收 Apple 的,两套系统各记各的账。所以 bot 把测试付款全额退了,现金一分没回来。
这件事真正可复用的教训有两条。第一,测试模式是每套账本各自的属性,不会跨账本传染:链路穿过几套账本,「这是假钱」就得在几套账本上分别验证——你配置的测试开关只盖住它所属的那一套,上游下游都不管。第二,文档里任何一句「不会扣钱」,在有人真的按到支付面板前验证过之前,都只是猜测。
把 1,077 新币要回来
Apple 在 7 月 16 日批准了两笔全额退款——就是提交申请的当天,比客服自己说的「等 48 小时」快得多。复盘下来,这个案子能成,靠四件事:
- 第一时间冻结现场。发现扣款后:不花、不送、不再买,测试账号也留着不删。36,000 颗 Stars 原封不动,并明确告诉 Apple:随时可以整体收回。「商品完好」和「已经花掉一半」,在退款申请里是两种完全不同的局面。
- 把两边的记录对齐到分钟。Telegram 的交易记录显示两笔充值「via App Store」进入测试账号,Apple 的购买记录显示对应的 S$29 和 S$1,048。第一笔充值和唯一那次测试付款在时间上分钟级吻合;第二笔则完全没有被使用。两套独立系统的记录讲出同一条时间线,这叫证据,不叫说辞。
- 两笔并成一个工单,申请人工复核。我们这次的情况是:扣款还没完全入账时,自助退款页面显示「此购买没有发票」。真正走通的路径是把两笔交易放进同一个支持工单,申请人工例外审查(manual exception review),用上面那张时间线把事实讲清楚。退不退最终由 Apple 决定,结果因案而异——这是走通过一次的路径,不是保证。
- 绝不谎报盗刷。盗刷通道看起来快,但那是假话,而且一查就穿(充值来自你自己的设备),最坏还会把整个 Apple ID 搭进去。老老实实讲「支付测试中的误购、商品完整保留、可以整体收回」——这个版本就足够了。
修复:规则从文档搬进代码
「测试中绝不充值」这条规则,事故之前其实就写在文档里——而文档正是它失效的地方。现在这条规则住进了服务端,变成一个 fail-closed 的防护函数(enforce_telegram_test_server_money_guard,server/src/handlers/billing.rs)。它头顶的 doc-comment,就是这次事故的三行总结:
/// Telegram's Bot API Test Server isolates the invoice, but does not by itself
/// make a store top-up free. A stable iOS/Android client can still open a live
/// App Store/Google Play purchase sheet when the test account lacks Stars.
它强制做到五件事:
- 不在受限运行时里,一律拒绝。所有非生产环境、走真实 provider 的支付订单,必须由专用 wrapper 建立的运行时发起。wrapper 本身只认两个固定动作——注册测试 webhook、启动仓库自己构建的服务端——不接受任何其他子命令,不给裸的 Bot API token 留侧门。
- 金额锁死 1 XTR(Telegram 的最小面额),其他金额一律拒绝。就算重演,走这条路径最多损失一颗已持有的 Star。
- 余额必须事先声明。操作者要传
--existing-test-stars=N,声明测试账号里已经看得见的余额。余额不足就停——订单还没创建,运行就结束了。 - 来源也必须声明。还要传
--test-stars-provenance=non-store-test-balance,确认这批可用余额来自测试网络内部,不是 App Store、Google Play、Fragment、@PremiumBot,也不是已退款/有争议、等待撤回的余额。单报一个数字不再够。 - 一次运行只创建一笔订单,数据库级强制。wrapper 生成一个 run id,服务端对它加 Postgres advisory lock,订单额度和订单写入在同一个事务里一起消耗。哪怕 Telegram 拒绝了这次调用,额度也已经用掉——想重试,就得重新跑一遍 wrapper、重新声明一次余额,而不是悄悄再冒一次弹出充值面板的险。
billing_telegram_test_server_runtime_required → 不在受限运行时里:拒绝
billing_telegram_test_server_amount_must_be_one → 金额 ≠ 1 XTR:拒绝
billing_telegram_test_server_existing_balance_not_confirmed → 没有声明既有余额:拒绝
billing_telegram_test_server_non_store_balance_not_confirmed → 没有确认非商店来源:拒绝
billing_telegram_test_server_invoice_budget_consumed → 同一次运行的第二笔订单:拒绝
这套防护的出发点,不是「操作者已经吸取教训」,而是假设总会有人再犯——凌晨的人类,或者照着一份过期文档干活的 agent。对任何走这条受防护服务端路径的订单,再犯的代价被压到最多 1 XTR,而且这颗 Star 的非商店来源必须被单独确认。同时也得把边界说清楚:它管的是我们服务端的订单创建;在客户端里手动点充值,根本不经过我们的服务器,任何服务端代码都拦不住——所以「测试中绝不充值」依然是一条写在代码之外的铁纪律。
事故复盘照例给了双倍回报:重放这条链路时,顺带抖出两个真 bug。一是过期订阅既不能取消也不能退款,因为 Telegram 明确返回的 SUBSCRIPTION_NOT_ACTIVE 被当成了硬失败;二是订阅周期被硬编码成 30 天,可重复的续期测试根本没法做——现在改成了仅测试可用的 override,只接受测试环境支持的 60 秒和 300 秒两档周期,在生产强度配置下直接 fail-closed。
如果你也在做 Stars——或任何「先充值、再消费」的内购
- 动手之前,把链路上收钱的环节数一遍。订单、余额、充值来源,一个个写下来,然后问:我的测试开关到底盖住了哪几个?对 Telegram Stars,诚实的答案是:只有订单那一环。
- 客户端里弹出的任何购买面板,都当真钱处理——不管你自认为在哪个环境。测试中途出现 Face ID 弹窗、出现本地货币的标价:先取消,再排查。
- 测试 SKU 用最小面额。我们的测试订单开的是 1,000 XTR,只因为照抄了生产价格;测试本身根本不需要。1 XTR 能证明完全相同的闭环。
- 带着来源已核对的非商店测试余额进场。从测试网络内部转入,再查交易记录;余额不够或来源说不清,就停下测试——别去喂钱包。
- 规则写成 fail-closed 的代码,别写成文档。我们文档里那句「不会扣真钱」没人验证过,而且是错的。防护代码不做断言——它直接拒绝。
- 万一真钱动了:冻结、对账、讲实话。商品原样保留,立刻做出分钟级的双边时间线,所有交易放进同一个工单,绝不把误购包装成盗刷。
这些没有一条只适用于 Telegram。任何「测试扣费从一个可以被真钱充值的余额里划走」的系统——游戏币、平台积分、代币钱包——同一条缝都开在同一个位置。
回顾
- 发生了什么:一次跑在 Telegram
/test测试环境上的 Stars 支付测试,触发了 1,077 新币的真实 Apple 扣款。外部系统没有失灵——测试环境隔离的是订单,而付订单的 Stars 是用真钱充出来的。 - 为什么会发生:我们自己的测试手册断言「不会扣真钱」,没有任何人在支付面板前验证过。这句断言错在哪:测试模式不跨账本传染——链路穿过 Telegram 订单、Stars 余额、Apple 收银台三套账本,测试开关只盖住第一套。「让本地唤起 Apple sandbox」也救不了:那个开关在 Telegram 的构建里,不在任何商户手里。
- 钱回来了:现场冻结、双边分钟级对账、一个讲实话的工单——当天全额退款。
- 修复是结构性的:fail-closed 的服务端防护——受限运行时、只许 1 XTR、余额与非商店来源分别声明、advisory lock 下一次运行一笔订单。走受防护订单路径的最坏重演 ≈ 一颗来源已核对的测试 Star;客户端里的充值面板,则继续由「绝不充值」这条纪律看住。
- 带走一句话:测试开关只在它自己那套账本里有效。先数一数,你的链路上有几本收钱的账——再看看你的「免费」断言,是不是每一个都验证过。