这是 DEV 的 Summer Bug Smash: Smash Stories 的投稿,由 Sentry 赞助。
支付 webhook 听起来很简单,直到成功支付实际上并未带来客户所支付的服务。
那是我在构建 The Listening Ear(一个预约和在线咨询平台)时遇到的更有趣的 bug 之一。
需求很简单:
客户为会话付款 → 应用确认付款 → 客户预约被预订 → 创建 Zoom 会议。
现实要复杂得多。
项目概览
The Listening Ear 将在线支付与预约安排和基于 Zoom 的咨询连接起来。
该应用使用 Next.js 14、TypeScript、Supabase、Prisma、PostgreSQL、Zoom 和支付提供商 API 等技术构建。
支付工作流尤为重要,因为支付确认实际上是后续预约体验的守门人。
预期流程如下:
Customer
│
▼
Payment Provider
│
│ webhook
▼
Next.js Webhook
│
├── Verify / interpret payment
│
├── Create Zoom meeting
│
└── Create appointment record
│
▼
Customer receives access to their scheduled session
Enter fullscreen mode Exit fullscreen mode
问题是 webhook 直接处于所有这些操作的中间。
Bug 修复或性能改进
当我实现将解锁 Zoom 调度工作流的支付 webhook 时,bug 出现了。
我最初的实现监听支付事件并检查事件是否为:
if (event === 'charge.success') {
一旦满足该条件,webhook 立即继续进入预约工作流。
该工作流包括:
- 从支付事件中读取预约元数据。
- 处理特殊的紧急预约。
- 构建 Zoom 会议负载。
- 调用 Zoom 会议 API。
- 在数据库中创建预约记录。
- 向支付提供商返回成功响应。
问题是所有这些操作实际上都与 webhook 请求耦合在一起。
支付 webhook 是一个异步的外部事件。它可能被重试、多次送达,或在系统其他部分仍在处理时到达。
我的实现最初将其过多地视为普通的同步 API 请求。
这造成了瓶颈:
支付确认 → webhook → Zoom API → 预约 API → 响应
如果其中一个下游操作缓慢或失败,webhook 本身可能会失败。
这意味着支付提供商可能会重试 webhook。
然后我可能会再次处理同一个成功的支付。
这才是我需要解决的真正 bug。
调试历程
第一个症状很容易被误解。
用户已成功完成支付,但预期的 Zoom 调度体验并不总是正确完成。
我的第一直觉自然是查看 Zoom 集成。
但沿着请求向后追踪,暴露了一个更有趣的依赖:
Zoom scheduling
▲
│
Appointment creation
▲
│
Payment webhook
▲
│
Payment provider
Enter fullscreen mode Exit fullscreen mode
Zoom API 不一定是最初的问题。
支付 webhook 承担了太多责任。
我开始从传入的支付事件追踪 webhook,历经每个下游操作。
原始实现的相关部分本质上是:
if (event === 'charge.success') {
// ...
const res = await axios.post(
base_url + '/api/create-zoom-meeting',
zoomPayload
);
if (res) {
const bookedSlotRes = await axios.post(
base_url + '/api/appointments',
{
date: bookingInfo?.appointmentDay,
time: `${bookingInfo.start}-${bookingInfo.end}`,
transaction_ref:
jsonData?.data?.tx_ref ||
jsonData?.data?.reference,
duration: bookingInfo?.sessionType
}
);
return NextResponse.json({
message: "Zoom meeting created with booked time slot",
bookedSlot: bookedSlotRes.data,
meeting: res.data?.meeting,
success: true
});
}
}
Enter fullscreen mode Exit fullscreen mode
那是我调查的转折点。
webhook 不仅仅是在确认支付。
它还在充当两个额外服务的编排层。
重试问题
问题在这里变得特别有趣。
webhook 端点仅在 Zoom 请求和预约请求完成后才返回成功响应。
因此生命周期实际上是:
Payment Provider
│
│ charge.success
▼
Webhook
│
├──────► Zoom API
│ │
│ ▼
│ Success?
│
├──────► Appointments API
│ │
│ ▼
│ Success?
│
▼
HTTP 200
Enter fullscreen mode Exit fullscreen mode
如果在最终响应之前出现问题,处理程序将进入 catch 块:
catch (error: any) {
console.log("Webhook Error:", error);
return NextResponse.json({
message: error.message || error || "Error occured handling webhook",
success: false
}, { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode
从应用的角度来看,这是一个合理的错误响应。
但是,从交付 webhook 的支付提供商的角度来看,500 可能意味着:
“接收系统未成功处理此事件。请重试。”
而这正是重试变得危险的地方。
重试可能导致应用再次尝试创建 Zoom 会议。
这引入了从单一支付事件产生重复下游副作用的可能性。
还有另一个问题
在调试 webhook 的同时,我也重新审视了实现中的签名验证部分。
原始代码包含:
const stringifiedBody = request.body?.toString() as string;
const hash = crypto
.createHmac('sha512', secret_key)
.update(stringifiedBody, 'utf-8')
.digest('hex');
Enter fullscreen mode Exit fullscreen mode
然后将生成的哈希与 Paystack 签名进行比较:
request.headers.get('x-paystack-signature')
然而,请求体已在此处被消费:
const jsonData = await request.json();
这使得原始请求体处理变得有问题。
更重要的是,实际的签名检查已被注释掉:
// if (hash === request.headers.get('x-paystack-signature')) {
// }
Enter fullscreen mode Exit fullscreen mode
这是一个重要的提醒,即 webhook 可靠性不仅关乎成功处理事件。
有两个独立的问题:
- 此事件是否真实?
- 我的应用能否安全可靠地处理它?
两者都很重要。
修复
我围绕更清晰的职责分离重新设计了 webhook 流程。
第一个原则是:
支付 webhook 应在下游服务对其采取行动之前可靠地建立支付状态。
我没有将 charge.success 视为盲目执行整个预约工作流的许可,而是将其视为需要仔细解释和处理的状态转换。
预约元数据继续提供会话所需的信息:
const bookingInfo: any = jsonData?.data?.metadata;
实现还必须考虑特殊的预约类型。
例如,紧急预约要求应用计算新的时间范围:
if (bookingInfo.appointmentOption === "emergency") {
const { start, end } =
computeTimeRange(bookingInfo.sessionType, 5);
startTime = start;
bookingInfo.start = start;
bookingInfo.end = end;
}
Enter fullscreen mode Exit fullscreen mode
该信息随后成为 Zoom 会议负载的一部分。
重要的变化不仅仅是修改一个 if 语句。
而是使 payment → booking → Zoom 关系明确且可预测。
使 Webhook 处理更安全
最大的教训是 webhook 处理程序从一开始就应该在设计时考虑重试。
我必须思考诸如以下的问题:
- 如果同一个成功的支付事件到达两次会怎样?
- 如果 Zoom 成功但预约创建失败会怎样?
- 如果预约创建成功但 webhook 响应失败会怎样?
- 如果提供商在超时后重试事件会怎样?
- 如何防止重复会议?
- 如何防止重复预约记录?
- 何时真正安全地告知支付提供商处理成功?
这些对于 webhook 驱动的系统来说不是不寻常的边缘情况。
它们是正常运行环境的一部分。
因此,最终的方法在支付状态、下游操作和重试行为方面要审慎得多,而不是将 webhook 视为一次性函数。
Zoom 集成只是故事的一半
最有用的调试教训之一是将可见症状与问题的实际来源分开。
用户体验看起来像是 Zoom 问题:
“我付款了,但我无法获得我的会议。”
But the actual chain was:
Payment
↓
Webhook
↓
Payment state
↓
Booking
↓
Zoom meeting
Enter fullscreen mode Exit fullscreen mode
这意味着我无法仅通过盯着 Zoom 集成来可靠地修复问题。
支付 webhook 是外部支付提供商的状态变为内部应用状态的系统边界。
一旦该状态被正确处理,工作流的其余部分就更容易推理了。
也让我注意到的一个小细节
webhook 还处理 Zoom 会议的可选受邀者:
if (bookingInfo.invites && bookingInfo.invites !== "") {
const parsedInvites = bookingInfo.invites.split(",");
parsedInvites.map((invitee: string) => {
zoomPayload.settings.meeting_invitees.push({
email: invitee
});
zoomPayload.settings.authentication_exception.push({
name: invitee.split('@')?.[0],
email: invitee
});
});
}
Enter fullscreen mode Exit fullscreen mode
这是另一个例子,说明为什么 webhook 处理程序会变得欺骗性地复杂。
单个支付事件携带的元数据会影响:
- 预约时间;
- 紧急会话行为;
- Zoom 会议配置;
- 受邀者;
- 身份验证例外;
- 事务引用;
- 以及数据库预约信息。
webhook 实际上已成为应用几个独立部分之间的桥梁。
这使得保持工作流可预测性变得更加重要。
结果
最终目标很简单:
Successful payment
↓
Reliable payment confirmation
↓
Correct appointment state
↓
Zoom meeting creation
↓
Booked session
Enter fullscreen mode Exit fullscreen mode
我没有将支付 webhook 视为简单的通知端点,而是学会了将其视为可靠性边界。
这种视角转变是最大的改进。
我学到的
这个 bug 教会了我一些在后续后端工作中一直牢记的东西:
永远不要将外部事件视为普通的同步函数调用。
webhook 存在于与你的应用不同的世界中。
发送方和接收方不共享相同的执行上下文。网络请求可能失败。响应可能超时。事件可能被重试。下游 API 可能成功,而你的响应可能失败。并且同一事件可能被发送多次。
这意味着健壮的 webhook 处理需要考虑:
- 身份验证和签名验证;
- 明确的支付状态;
- 异步交付;
- 重试;
- 幂等性;
- 重复事件;
- 部分失败;
- 下游副作用;
- 以及事务引用。
最重要的教训不是如何让支付 webhook 工作。
而是学习如何让 webhook 周围的系统在事情不完美时表现得可预测。
而那是我最乐于砸掉的 bug。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.