昨天(08-20),一位买家告诉我:支付成功了,却没拿到 API key。这是站点上线后第一次有人付了钱却没收到东西。排查后补了邮件交付,记一下丢 key 的过程和接 Brevo 时遇到的问题。
先说背景:明文只存在一次
outcometick 的 API key 格式是 <key_id>.<secret>,库里只存 secret 的 sha256。就算库被读了也拿不到能用的 key,但明文只在创建时存在,返回给浏览器后只有买家保留的那份。
当时的流程是:Stripe 付款成功后跳回 /success?session_id=...,页面拿 session id 去调 claim-key,服务端签发一把 key、返回明文、页面显示出来。一次性由数据库唯一索引保证,同一个 session 第二次来领,回 409。
查的过程
第一步看钱和权限。subscription_stripe_events 里那笔 invoice.paid 是 applied,订阅加了 30 天;subscription_api_keys 里 key 已经签发了,last_used_ms 是 null,也就是从来没被用过。说明支付、webhook、开通、签发全都正常,key 也确实发到了浏览器,问题出在那之后。
生产机上没装 pg 客户端,查库的脚本得放进 API 目录里借它的 node_modules 跑,跑完删掉。
第二步看应用日志,没用。pm2 日志里只有 webhook 那一行,claim-key 成功不记日志。
最后是 nginx 的 access.log 还原了过程。按 claim-key 和 /success grep,时间线很清楚:claim-key 在 02:49:28Z 返回 200,key 已经到了页面;6 秒后,同一个 UA 请求了不带任何参数的 /zh/success,页面报「缺少结账凭据」;之后再去 claim,全是 409。
Stripe 的 success_url 被我写死成英文 /success。这位买家从中文页下单,付款后到了英文页,点了页头的「中文」。语言切换链接又写死成 /zh/success,没有带上 session id。页面刷新后,明文 key 消失,也不能再次领取。
库里只有哈希,我没有任何办法把原来那把找回来,只能人工补发一把新的。
回跳、缓存和语言切换
我改了三处:
- 回跳带上语言。
success_url、cancel_url和 Stripe 托管结账页本身的locale,都按下单时的语言走(api/lib/checkout-locale.mjs,前端下单时把 locale 带上)。中文买家付完款回到中文页,也就没有理由去点「中文」了。传进来的 locale 不合法就回落英文,绝不因此拒单。 - 拿到 key 立刻缓存。页面领到 key 马上写进 sessionStorage(
lib/claimed-key.mjs),渲染前先从里面 recall,刷新、返回、切语言都还看得到。URL 里没有 session id 时,允许回退到这个标签页最近一次领到的 key,但只开 30 分钟。它是用来救「刚刚把参数点掉了」的人的,不是密钥保险箱;常开的话,谁在这个标签页里打开/success都能看到上一位买家的 key。 - 带参数的页面把参数传进语言切换。站点所有页面共用一个
Shell组件,语言切换链接是localePath(l, path)算出来的,/success?session_id=...和稳定币付款页/pay/<id>都要把完整路径传进去。付款页原来更糟,切语言直接跳回首页。
稳定币那条通道也是一次性交付,一起修了。它的订单号在 URL 路径里,不会丢,所以它不占那个「最近一次」的缓存位,免得 /success 显示出另一条通道的 key。
最后一个缺口:关掉标签页
这三层能挡住几个「离开页面就丢 key」的入口,却挡不住最直接的情况:买家付完款就关掉标签页。明文只有页面请求时才创建,从没请求过的人永远拿不到 key。
所以下午把签发从页面挪到了 webhook,页面改成只读:
1
2
3
invoice.paid ─┬─ 签发 key ── Brevo 发信(耐久通道)
└─ 明文放进进程内存 stash(15 分钟,一次性)
/success ─────── take stash ── 显示同一把 key
stash 用 take,读走即删。success URL 是一个 bearer 引用,如果能反复读,重放这个 URL 就能再拿一次明文。页面刷新时从前端 sessionStorage 读取,不再请求服务端明文。
加了一列 delivered_ms,记录 key 是否已交付。webhook 路径先写未送达,Brevo 接受邮件之后才标记送达。判断客户是否已有 key 时只认送达过的;否则写库后进程崩溃、明文没有交付的记录也会被当成「已经有 key」,阻止后续签发。
未送达的 key 只计数告警,不自动作废。它的明文已不存在,没人能用;自动作废却可能在「同一个人连着付两笔」或「已送达但状态没写回去」时,作废客户正在用的 key。加列时还要把存量行回填成 delivered_ms = created_ms:存量 key 都是页面签发、当场交付的,不回填会在部署后被当成「从未送达」。
交付没完成就回 503。授权已经提交,却什么都没送出去时,回 200 就等于把事件用掉,Stripe 不会再重投。交付抛错、邮件被临时拒绝(5xx、429、超时),或者另一次交付还在路上,这三种都回 503,等 Stripe 重投补救。地址本身无效则不重投,重多少次都一样,只会浪费 Stripe 有限的重投次数。
BREVO_API_KEY 和 MAIL_FROM 缺一个就当作没配,这时 webhook 不接管,页面照旧自己签发。如果只配 key 就让 webhook 接手,而发件人没在 Brevo 验证过,邮件会发不出去,离开页面的买家仍然拿不到 key。
接 Brevo 踩的坑
发件人用的是 support@outcometick.com,收信走 Cloudflare Email Routing 转发到我自己的邮箱,买家回复交付邮件就能联系售后。这个地址也放到了页脚和 llms.txt 上。在这之前全站唯一的联系方式是 Telegram 群,不用 Telegram 的买家没有售后入口。
配置过程中踩到的几条:
启用 Email Routing 会把 SPF 覆盖掉。它自动写成 v=spf1 include:_spf.mx.cloudflare.net ~all,我手工加的 include:spf.brevo.com 被顶掉了,面板上显示的和权威 NS 返回的还不一致。不过影响不大,SPF 校验的是 Brevo 的 return-path 域,不是 From 域,邮件通过校验靠的是 DKIM(两条 _domainkey 的 CNAME)。
Brevo 的 API key 和 SMTP key 是两种东西,xkeysib- 开头的只能走 HTTP API,想在 Gmail 里用这个地址发信要另一种 xsmtpkey-。IP 白名单也是 API 和 SMTP 两个独立开关,SMTP 那边要保持关闭,Gmail 的出口 IP 会轮换,开了的话回信功能会随机失效。
给不存在或收不了信的地址发信会 hard bounce,随后这个地址会被 Brevo 永久拉黑。我在转发规则建好前测了一封,结果把 support@ 自己拉黑了,用 DELETE /v3/smtp/blockedContacts/{email} 才解开。给被拉黑地址发信,API 仍然返回 201,实际没有投递。按目前的逻辑,它会被标成已送达。我已经把这个盲区写进项目文档,后面要在发信前查询 Brevo 的拦截名单,这份名单可以查到。
验证收信时还被 Gmail 的去重骗了一次:从我的 Gmail 发到 support@,而它又转发回同一个 Gmail,信不会出现在收件箱里,Cloudflare 还会专门发一封信解释。验证要用第三方邮箱。最后实测外部 Gmail 收信进收件箱,不进垃圾箱,回复能正常回到售后信箱。
还有一条改不了的:Brevo 会给所有邮件强制加 List-Unsubscribe 头,一封只装着付费凭据的事务邮件下面挂个退订链接,看着很怪。开了工单,答复是换成 List-Help 只对 Enterprise 套餐开放,我的套餐做不到,就先这样。
顺便:微信支付为什么不显示
同一周还处理了一个支付相关的反馈:Stripe 后台里微信支付明明已经启用,中文页的结账页却没有这个选项。我一开始以为是配置问题,其实不是。那次结账是订阅(mode=subscription),而微信支付和支付宝在 Stripe 里都是单次使用的支付方式,订阅模式下一律不会列出,跟后台开没开无关。要看到微信支付,得同时满足一次性买断(mode=payment)和币种是人民币或港币。
于是先把 /zh 的默认币种从美元改成人民币,但这只是必要条件,中文访客默认停在「订阅」上,还是看不到。我没有为了露出一个支付方式去改默认卖什么(买断更贵、不续费),只在中文页停在订阅时加了一句提示:想用微信支付请选一次性买断。这类「显示哪条提示」的判断放进了 lib/pricing.mjs 的纯函数 paymentNotes(),这样能用 node --test 覆盖,组件只管渲染。
支付宝的结局是 08-20 申请被 Stripe 拒了。站点上有 9 处文案提到它,全部删掉,留着就是虚假陈述。这次它没造成任何故障,是因为我创建 Checkout 时从来没设 payment_method_types,支付方式交给 Stripe 按后台配置和币种自己挑,被拒的方式只是安静地不再出现。如果当初把方法名写死在代码里,同一件事会让整个结账页直接报错,全站都下不了单。
这次语言切换前后的 6 秒是从 nginx 日志里查到的,应用日志没有记录。回跳和页面缓存已修,邮件通道也补上了。
outcometick 工程笔记(8/21)