类型检查全绿。六十多项单元测试全绿。本地端到端流程全绿。然后把它指向真实域名、换上真实密钥,四件事接连倒下,没有一件是本地测试套件抓得到的。
这个应用是 PrintLatch,给丝网印刷店用的报价与审批工具。它是我们自己的,所以下面这些问题也是我们自己的。技术栈是 Cloudflare Workers、D1、R2 和 Stripe 订阅,但四个发现里有三个跟这套栈没有关系,它们是任何一次上线都可能遇到的形状。
一、开启税号收集后,老客户的结账全部被拒
症状:创建 Checkout 会话每一次都返回 400。接口报错:
Tax ID collection requires updating business name on the customer.
To enable tax ID collection for an existing customer,
please set `customer_update[name]` to `auto`.
修复只是一个字段:
customer_update: { name: "auto", address: "auto" },
automatic_tax: { enabled: false },
tax_id_collection: { enabled: true },
为什么容易漏掉:只有当你把一个已存在的 customer 传给 Checkout 时才会触发。如果让 Checkout 自己创建客户,你永远看不到它。很多应用在注册时就建好了 Stripe 客户,一旦这么做又打开税号收集,每一笔结账都会死。
更值得记的是它出现的位置。我们的测试套件把 Stripe 客户端 mock 掉了,所以它完美验证了我们的逻辑,却对 Stripe 会接受什么一无所知。被 mock 的服务商测的是你的代码,不是你和服务商之间的约定。 能抓到这个的只有一次打到真实账号的真实调用。
二、一个永远不可能成功、却被永远重试的请求
这一条更隐蔽,也是最值得带走的一条。
创建 Checkout 会话是一次网络调用,它可能在 Stripe 已经做完事情之后才失败。所以标准的防御写法是:调用之前先把确切参数和幂等键存下来,之后若发现遗留的 pending 记录,就用同一个键重放,Stripe 会返回原来那个会话而不是再建一个。这个模式是对的,我们也有。
漏洞在于:它假设所有失败都是响应丢失。并不是。当 Stripe 拒绝了请求——参数不合法的 400,或者一个卡错误——用同样的参数和同样的键重放,永远不可能得到不同结果。而因为恢复这一步跑在最前面,这家店铺从此被永久卡死:之后每一次结账都先去恢复那条死记录,死在同一个 400 上,然后返回一个笼统的错误。界面上没有任何出路。
第一个问题就是这样从缺陷变成陷阱的:税号报错留下了一条中毒的记录,即使我们把参数修好了,那条记录仍在用旧参数被反复重放。
修法是对失败分类,而不是一视同仁:
function rejectedOutright(e) {
// 幂等冲突说明原请求还在处理中,属于可恢复。
if (e?.rawType === "idempotency_error") return false;
return (
e?.type === "StripeInvalidRequestError" ||
e?.type === "StripeCardError" ||
e?.rawType === "invalid_request_error" ||
e?.rawType === "card_error"
);
}
被明确拒绝就作废这条记录,让下一次调用用新参数重建。其余情况——超时、5xx、限流、幂等冲突——仍按原逻辑恢复。两条回归测试覆盖了它:一条验证被拒记录会作废,一条验证一条中毒的旧记录不会挡住另一个套餐的新结账。
如果你的系统里有任何重试逻辑,问它一个问题:这里有没有一种失败是重试永远修不好的? 如果有,而你又在之后每一次请求里都先重试它,那你造的就是一个永久卡死。
三、一个安全的默认值,在悄悄跟你的 sitemap 唱反调
我们的根布局设了 robots: { index: false, follow: false }。这是正确的默认:工作台、账单、设置和客户审批页绝不能被收录,而一个安全的默认意味着新加的私有页不会因为忘写一行就泄漏出去。
公开的营销页会覆盖它。但有三个没有:联系、条款、隐私三页没有自己的元数据,于是继承了 noindex——同时却被列在 sitemap 里。
sitemap 是一个断言:这个 URL 值得收录。该 URL 上的 noindex 是相反的断言。Search Console 会标记这个冲突,而这些页面不会被收录。
抓到它的不是读配置,是抓自己的 sitemap 逐条检查实际响应:
curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's|<loc>||' |
while read u; do
echo "$u $(curl -s "$u" | grep -c 'content="noindex')"
done
十九个 URL,三个在说谎。安全的默认值保留,只要把例外显式声明出来,并且对着已部署的站点验证,而不是对着源码。
顺便一件值得知道的事:IndexNow 到不了 Google。 Bing、Yandex、Seznam 和 Naver 参与,Google 不参与。如果你提交了 IndexNow 然后盯着 Google 流量等,你会得出”哪里坏了”的结论,而其实什么都没坏。Google 只有 Search Console 这一条路。
四、一个不幂等的代码改写脚本
我们有一个一次性脚本,遍历 JSX 把未翻译的文本包进 <T> 组件。它会跳过已经导入 T 的文件。守卫是这样写的:
if (source.includes('import { T }')) continue;
它能匹配 import { T } from "@/app/i18n"。但它匹配不了 import { T, LanguageSwitch } from "@/app/i18n",也匹配不了 import { T } from "./i18n"。于是在一个已完成的代码库上重跑,产出了 <T><T>Pricing</T></T> 和重复导入,波及二十三个文件。
由此得到两条规则:
- 跳过守卫必须匹配它要找的东西的每一种写法。对源码做子串匹配几乎总是太窄。这里正确的粒度是对 import 的模块说明符做正则。
- 一个代码改写脚本真正的测试是跑两遍。如果第二遍不是空操作,它就不适合留在仓库里——而且一定会有人再跑一次,因为它就摆在
scripts/里。
相关的坑:这个脚本每次运行还会重写自己的输出清单。守卫修好、所有文件都被正确跳过之后,它写出了一份空清单,因为”没有提取到新东西”和”什么都不存在”长得一模一样。增量工具应该往输出里合并,而不是覆盖。
结论
这四个问题,类型系统、单元测试和本地端到端跑一遍,都没有抓到。抓到它们的是把真东西指向真实世界:一次打到真实 Stripe 账号的真实调用,和一次对自己已部署 sitemap 的抓取。
给这一步留出时间。它不是构建之后的形式流程,它是另一个类别的测试,而且是唯一一个会去检查你自己都不知道自己做了哪些假设的测试。