支付是 vibe 项目里第一个「不能 vibe」的地方:一份手写级接入实战
Zephos 团队在一个 Agent 搭起来的 Next.js + Supabase + Stripe 笔记应用里埋了 16 个上线杀手级问题,其中 2 个和支付有关:webhook 没验签、拿客户端传的 plan 直接开 pro。这篇指南把这两个坑拆成一套可落地的实战方法:webhook 签名三件套、服务端唯一真相源、订阅状态机、测试时钟和沙盒到生产 checklist。核心判断:和钱相关的逻辑必须手写或逐行审计,agent 只能写样板。

Zephos 团队在 Indie Hackers 上做过一个很有意思的实验:他们让 Agent 从零搭起一个 Next.js + Supabase + Stripe 的笔记应用 Notely,然后故意在里面埋了 16 个上线杀手级问题,看 Agent 自己能不能找出来、修好。其中 2 个和支付直接相关:第一,Stripe webhook 接受了 Stripe 从未签名的事件——签名验证根本没做;第二,服务端直接拿请求体里「自称」的 client_reference_id 给用户开 pro。这两个问题在演示环境里跑得好好的,页面漂亮、流程顺滑,但凡上线收第一笔钱,就是两个敞开的大门。
这篇指南就从这两个坑出发,把「vibe 项目怎么接支付」拆成一套能直接落地的实战方法。先说结论,也是全文最重要的一句判断:支付是 vibe 项目里第一个「不能 vibe」的地方。页面可以让 Agent 自由发挥,CRUD 可以让它一次写完,但凡是和钱相关的逻辑——签名验证、金额判定、订阅状态——必须手写,或者逐行审计。Agent 可以写样板,但这类代码错一行就是丢钱,而且丢得悄无声息。
杀手问题一:webhook 签名验证,「三件套」缺一不可
Stripe 的 webhook 机制本质上是「Stripe 主动敲你家的门,告诉你钱的事」。问题是,任何人都可以敲你家的门。Agent 生成的代码里,最常见的写法是这样的:
// Agent 最爱写的「看起来能跑」的版本
app.post('/webhook', express.json(), async (req, res) => {
const event = req.body; // 直接当 Stripe 事件用
if (event.type === 'checkout.session.completed') {
await grantPro(event.data.object.client_reference_id);
}
res.json({ received: true });
});
这段代码在本地用 Stripe CLI 触发测试事件时完全正常——因为测试事件本来就是 Stripe 发的。但它有一个致命假设:凡是 POST 到这个地址的 JSON,都默认是 Stripe 发的。攻击者用 curl 发一个伪造的 checkout.session.completed,把 client_reference_id 填成自己的用户 ID,就能白嫖 pro。Notely 里埋的正是这个坑。
正确的姿势是「三件套」:endpoint secret、constructEvent、幂等处理,少一件都不行。
第一件:endpoint secret。每个 webhook endpoint 在 Stripe Dashboard 里都有独立的签名密钥(whsec_...),测试环境和生产环境各不相同。它是签名的根信任,绝不能进前端、绝不能进 git。Agent 有个坏习惯是把密钥写进 .env.example 当示例值,或者干脆 hardcode 在代码里方便调试——review 时看到 whsec_ 开头的字符串出现在仓库里,直接打回。
第二件:constructEvent,用原始请求体验签。Stripe 的签名是对原始字节算的 HMAC-SHA256,这意味着验签必须发生在任何 JSON 解析之前。这是 Agent 最容易写错的地方:它习惯性地先挂 express.json() 中间件,解析完再验签,签名字符串对不上,要么验签永远失败(你被迫关掉验签「先让流程跑通」),要么它干脆跳过验签。正确的顺序长这样:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// 注意:这个路由不能用 express.json(),要用 raw body
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // 原始字节,不能是解析过的对象
sig,
process.env.STRIPE_WEBHOOK_SECRET // whsec_...,按环境区分
);
} catch (err) {
// 签名不对:直接 400,一个字都别多说
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// 只有到这里,event 才可信
await handleEvent(event);
res.json({ received: true });
}
);
一个实操细节:如果你用的是 Next.js App Router,记得在路由里加 export const dynamic = 'force-dynamic',并且用 await req.text() 拿原始文本再传给 constructEvent——Agent 生成的 Next.js webhook 代码十有八九直接 await req.json(),然后把解析后的对象传进去,签名永远对不上。这是 vibe 项目里最高频的支付 bug 之一,review 时先看这一行。
第三件:幂等。Stripe 对同一个事件可能投递多次(重试、网络抖动),你的 handler 必须能安全地跑两遍。做法很直接:用 event.id 做唯一键,落库前先查这条 event 是否处理过。Agent 写的 handler 通常没有这一层——「反正 Stripe 只发一次」是它默认的幻觉。配合数据库唯一约束(stripe_event_id unique),重复投递会被数据库层面挡掉,而不是靠「希望它不发生」。
杀手问题二:绝不信任客户端传来的 plan 和 price
Notely 的第二个坑更隐蔽:服务端用请求体里「自称」的 client_reference_id 开 pro。把这个模式推广一下,就是 vibe 项目支付安全的头号铁律:客户端传来的任何和「买了什么」「花了多少钱」有关的信息,都只能当备注看,不能当依据用。
Agent 特别喜欢写出这种代码,因为它「顺」:前端选了 Pro 套餐,调后端接口时顺手把 { plan: 'pro' } 传过去,后端收到就开权限。整个链条在演示时天衣无缝——直到有人打开浏览器 devtools,把 plan 改成 pro,或者把价格字段改成 1 分钱。前端是用户完全控制的地盘,从前端发过来的 plan,本质上和用户在纸条上写「我是 pro」没有区别。
正确的心智模型是:服务端只有一个真相源——Stripe 返回的对象,或者你自己的数据库里由 webhook 写进去的那一行。价格、套餐、订阅状态,全部以服务端查到的为准。实操上分两层:
第一层,创建支付会话时,price ID 写死在服务端。前端只传一个套餐标识(比如 pro_monthly),服务端用白名单映射到真正的 Stripe Price ID(price_...),再创建 Checkout Session。映射表长这样:
// 服务端:允许的套餐 -> Stripe Price ID 白名单
const PLANS = {
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY,
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY,
};
app.post('/api/checkout', async (req, res) => {
const { plan } = req.body;
const priceId = PLANS[plan];
if (!priceId) return res.status(400).json({ error: 'unknown plan' });
// 前端传什么都不重要,最终以白名单里的 priceId 为准
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: req.user.id, // 绑定用户,但只做关联不用做授权依据
success_url: '...',
cancel_url: '...',
});
res.json({ url: session.url });
});
第二层,开通权限时,只认 webhook 事件里 Stripe 告诉你的订阅状态。收到 checkout.session.completed,用事件里的 subscription ID 再去 Stripe 拉一次订阅对象(或者直接信任验签后的事件 payload),确认 status === 'active' 或 trialing,再写库开权限。「用户说他付了钱」不算数,「Stripe 说他付了钱」才算数。这一层做完,Notely 的第二个坑就被彻底堵死了。
还有一个 Agent 高频犯的错值得单独点名:把 client_reference_id 当成「用户传什么就是什么」。正确用法是服务端在创建 Session 时自己填(上面代码里填的是 req.user.id,来自登录态,不是请求体),webhook 里读出来只做「这笔订阅属于哪个用户」的关联,绝不做「他买了什么」的判断。关联和授权是两件事,混在一起就是漏洞。
订阅生命周期状态机:每个状态都要有明确的「干什么」
一次性买断的逻辑是线性的:付钱→开通,完了。但订阅是一个状态机,Agent 最容易在这里翻车,因为它会把订阅当成一个布尔值——isPro: true/false。真实的 Stripe 订阅有 trialing → active → past_due → canceled(以及 incomplete、unpaid 等)一串状态,每个状态你的系统都要有明确的行为定义。布尔值思维会在这里漏掉整整一类 bug:用户卡过期了、续费失败了,系统该干什么?
先把状态机摊开,每个状态配一个动作:
trialing(试用中):权限等同于付费用户,但必须在产品里明确告诉用户「试用 x 天后开始扣费」,并且试用结束前发提醒。这是很多 vibe 项目漏掉的产品细节——技术上开了权限,产品上没做预期管理,试用到期扣费那一刻就是一次客诉。监听 customer.subscription.trial_will_end 事件,提前 3 天发邮件,这个事件是 Stripe 专门为你准备的,不用自己算日子。
active(生效中):正常提供服务。这是唯一一个「什么都不用干」的状态,也是 Agent 默认以为永远成立的状态。
past_due(逾期未付):这是最需要产品判断的状态。Stripe 的 Smart Retries 会自动重试扣款,第一次失败不代表用户跑了。我的建议:past_due 期间保留访问权限,但开始倒计时和提醒。一刀切的「一失败就降级」会误伤那些只是卡过期、换卡中的真实付费用户;无限期保留则是在给恶意欠费开绿灯。折中方案是按重试窗口给宽限期(比如 Stripe 默认的重试周期走完还没成功再降级),并在宽限期内给用户发 2-3 次「更新付款方式」的提醒。宽限期的长度是产品决策,不是技术决策,但必须在代码里显式实现,不能留空。
canceled(已取消):注意区分两种取消。cancel_at_period_end = true 的用户在当前周期结束前依然是付费用户——他付过钱的时间,一分钟都不能少给,这是底线;真正的 canceled(周期结束或立即取消)才降级。Agent 写的降级逻辑经常是「收到 canceled 事件就降级」,把「到期取消」和「立即取消」混为一谈,导致付费用户提前被踢。review 状态机代码时,先看它有没有处理 cancel_at_period_end 这个字段,没有就直接打回。
把状态机落成代码,最实用的形状是一张「事件→动作」映射表,而不是散落在各处的 if-else:
const handlers = {
'checkout.session.completed': onCheckoutCompleted, // 验签后:确认订阅,写库开权限
'customer.subscription.updated': onSubscriptionUpdated, // 状态机流转:按新 status 更新权限
'customer.subscription.deleted': onSubscriptionDeleted, // 降级,保留数据
'invoice.payment_failed': onPaymentFailed, // past_due:进宽限期,发提醒
'customer.subscription.trial_will_end': onTrialEnding, // 试用结束提醒
};
async function onSubscriptionUpdated(sub) {
// 只认 Stripe 的 status,不认任何客户端传来的值
switch (sub.status) {
case 'trialing':
case 'active':
await setPro(sub.metadata.userId, true); break;
case 'past_due':
await startGracePeriod(sub.metadata.userId); break; // 宽限期 + 提醒
case 'canceled':
case 'unpaid':
await setPro(sub.metadata.userId, false); break; // 降级,数据保留
}
}
注意 metadata.userId 的用法:创建订阅时由服务端写入的用户 ID,随事件带回来做关联——同样是「服务端写、服务端读」,不经过客户端的手。状态机代码建议手写,写完让 Agent 帮你补单元测试(每个状态一个 case),这是人机分工里最划算的一种:人定规则,Agent 穷举验证。
测试时钟:把「下个月续费」变成今天就能测的事
订阅逻辑最难测的部分是时间。你不可能为了验证「续费失败进 past_due」真的等一个月。Stripe 的 Test Clocks(测试时钟)就是为这个准备的:创建一个虚拟时钟,把测试用的 customer 挂上去,然后拨动时间,观察订阅在未来时间点的行为。
典型流程分四步。第一步,建时钟并挂用户:
// 1. 创建测试时钟(frozen_time 设为今天)
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
});
// 2. 建 customer 时绑上这个时钟
const customer = await stripe.customers.create({
email: 'test@example.com',
test_clock: clock.id,
});
// 3. 正常建订阅(trial 7 天,按月扣费)
// 4. 拨动时钟:一次快进 8 天,试用结束,触发扣费
await stripe.testHelpers.testClocks.advance(clock.id, {
frozen_time: Math.floor(Date.now() / 1000) + 8 * 86400,
});
快进之后,去查订阅状态、invoice 状态,断言你的 webhook handler 有没有正确处理 trial_will_end、invoice.payment_failed(配合一张会失败的测试卡,比如 4000000000000341 这张经典的「attached but charge fails」卡)。凡是状态机里的每个分支,都应该对应一个测试时钟的拨动脚本。这是 vibe 项目支付模块从「演示级」到「上线级」的分水岭:没有时间拨动测试的状态机,等于没测过。
两个实操提醒。第一,测试时钟只在测试模式下可用,生产环境没有这个概念——所以时钟相关的代码必须和正式逻辑隔离,别让 testHelpers 的调用有机会出现在生产代码路径里。第二,拨动时钟是异步生效的,Stripe 需要几秒到几十秒推进状态,测试脚本里要轮询等待,别写成「拨完立刻断言」,否则会得到随机失败的 flaky 测试,Agent 写的测试脚本最爱犯这个错。
沙盒到生产 checklist:上线前逐项打勾
支付模块从测试环境切到生产,是事故最高发的时刻。下面这份清单按「一定会出事」的优先级排序,上线前逐项打勾:
1. webhook secret 换成生产的。测试环境的 whsec_... 和生产的是两套,Dashboard 里重新生成,更新环境变量。Agent 迁移环境时最常见的操作是「把 .env 复制一份改个名字」,然后忘了换 secret——结果生产 webhook 签名永远验不过,你被迫在上线当晚关掉验签。把「换 secret」写进清单第一项,就是防这个。
2. webhook endpoint URL 指向生产域名,且用 HTTPS。Stripe 不接受非 HTTPS 的生产 endpoint。同时检查 Dashboard 里 endpoint 订阅的事件类型:测试时你可能只勾了 checkout.session.completed,上线前把状态机需要的那一整套(customer.subscription.updated/deleted、invoice.payment_failed、trial_will_end)都勾上。漏勾事件的表现非常阴险:流程「大部分正常」,只在特定状态流转时悄悄失效。
3. Price ID 映射表切到生产。测试的 price_... 和生产的 price_... 是两套 ID,白名单映射表必须整体切换。建议把映射做成环境变量而不是代码常量,这样测试和生产用同一份代码、不同配置,Agent 也犯不了「改代码时漏改一个」的错。
4. 密钥权限最小化。生产用受限密钥(Restricted Key),只给 webhook 验签和订阅管理需要的权限。Agent 默认用的是万能 secret key,还经常把它打印到日志里做调试——上线前全局搜一遍 sk_live 的出现位置,日志里出现一次就重做一次密钥轮换。
5. 金额用最小货币单位,且服务端二次确认。所有金额以「分」为单位流转,避免浮点。创建 Session 前,服务端拿 priceId 去 Stripe 拉一次确认金额和币种——这是防「测试 price 和生产 price 金额不一致」的最后一道闸。
6. 失败告警先于用户投诉。invoice.payment_failed 除了触发宽限期,还要进你的告警通道(邮件、Slack、Sentry 都行)。支付失败的静默是最贵的 bug:用户流失了你都不知道为什么。Agent 不会主动给你加告警,这一条必须人来加。
7. 做一次真实小额支付端到端验证。用真实卡做一笔最小金额的支付,走完「付钱→webhook→开权限→退款→降级」全链路,再原路退款。这是唯一能验证「生产 secret、生产 price、生产 webhook 地址三者配对正确」的方法。别心疼那点手续费,这是整个清单里性价比最高的一项。
人机分工:Agent 写样板,人守三条红线
回到开篇的判断:支付不是不能用 Agent,而是要划清分工。我的分工建议很具体:
可以交给 Agent 的:Checkout Session 创建的样板代码、前端支付按钮和成功页、测试时钟的拨动脚本、状态机每个分支的单元测试、失败提醒邮件的文案初稿。这些是「错了能发现、发现了好改」的部分。
必须人来做的:webhook 验签那十几行、price 白名单映射、状态机的状态→动作定义、宽限期和降级的产品决策、生产密钥的生成和配置。这几处是「错了悄无声息、发现时已经丢钱」的部分。
如果只能记住一个 review 方法,记住「三问审计法」,拿着它逐行过 Agent 生成的支付代码:第一问,这个值是谁给的?——凡是来自请求体、query 参数、前端的,一律不可信;第二问,验签了吗?——凡是声称来自 Stripe 的,先看 constructEvent 在不在、raw body 对不对;第三问,重复跑会怎样?——每个 handler 都假设会被调两遍,看有没有幂等。三问都通过,这段支付代码才算过关。
最后把 Notely 的两个坑翻译成一句话,贴在你的项目 README 里:「没验签的 webhook 等于没锁的门,客户端传的 plan 等于用户自己填的收据。」支付是 vibe 项目里第一个「不能 vibe」的地方——但只要验签、真相源、状态机这三件事做实了,它也可以是 vibe 项目里最先跑通、最让人安心的模块。毕竟,能放心收钱的产品,才是真正的产品。
相关文章

公网上的每个接口都会在某个深夜被超预期调用。这篇实战为一人团队搭建限流体系:算法选型(滑动窗口 vs 令牌桶)、四层防御、AI 接口烧钱专项防护、配额设计、429 响应规范、误伤排查,最后附上线检查清单。

每个 vibe 项目迟早会遇到同一个时刻:列表页一打开就要查十几次库,并发稍高数据库就被打满。这篇实战从缓存的三问心智模型讲起,逐层拆解 HTTP 缓存头、Next.js 数据缓存、Redis 应用缓存与 AI 结果缓存(语义缓存/prompt 缓存),给出缓存键设计、穿透击穿雪崩的三件套解法和失效策略,最后附一份上线检查清单。

2026 年 9 月 30 日,Bitdefender 宣布 AI Guardian 进入公开 beta:给自主 AI Agent 加的安检门,每一次工具调用、文件访问、密钥使用都要先拿到 allowed / flagged / blocked 裁决。首批 macOS 独占、beta 期免费,支持 Claude Code 与 OpenClaw。为什么这道「Agent 行为防火墙」来得正是时候。