为什么支付服务商明明只推送了一次“转账完成”通知,你的系统却总会收到两遍,甚至三遍?这个问题几乎困扰过每一家对接过银行或支付网关的金融科技公司。带着这个困惑,我们来看看如果接手一家银行的系统优化,一个NestJS后端要怎么从源头一步步搞定它。
问题出在一种叫“至少一次投递”的业内通用设计。从Stripe到Worldpay,再到银行转账网络,没有哪家服务商会承诺“精确一次”把事件送到。他们只会保证:只要没收到你返回的成功响应,就会一直重试。所以重复推送不是供应商的bug,而是你必须在接收端预期并处理的正常行为。现实里,服务端超时、网络抖动随时可能发生,供应商那边等不到你的200 OK,自然就把同一份事件又发了一次。
但很多开发者在写webhook入口时,第一反应都是“来了就处理”。比如下面这种常见的控制器写法:
@Controller('webhooks')export class WebhooksController {constructor(private readonly transactionsService: TransactionsService) {}@Post('payment-provider')async handleWebhook(@Body() payload: PaymentWebhookDto) {await this.transactionsService.markSettled(payload.transactionId);return { received: true };}第一次收到事件时,一切正常。可一旦同一个事件被再次推送,这个方法会再次执行,把已经结算的交易又标记一次。如果里面触发了放款操作,就可能重复支出;如果绑定了通知服务,客户会收到两封一模一样的确认邮件。更麻烦的是,这类bug平时测试很难抓到,它总在真实重试压力下才会暴露。
在处理任何业务逻辑之前,首先要保证请求来源是可信的。支付服务商通常会用HMAC方式,通过一个共享密钥对整个请求体进行签名,并把签名放进请求头。接收端需要自己重新算一遍签名,再和传过来的值做比对。这一层校验可以用NestJS的守卫来实现,把未经验证的请求直接挡在路由外面:
@Injectable()export class WebhookSignatureGuard implements CanActivate {constructor(private readonly configService: ConfigService) {}canActivate(context: ExecutionContext): boolean {const request = context.switchToHttp().getRequest();const signature = request.headers['x-provider-signature'];const secret = this.configService.get('WEBHOOK_SECRET');const expectedSignature = crypto.createHmac('sha256', secret).update(JSON.stringify(request.body)).digest('hex');if (signature !== expectedSignature) {throw new UnauthorizedException('Invalid webhook signature');return true;}有了签名校验,未经授权的POST请求会被立刻驳回。下一步更关键:让真正的业务处理代码可以在多次重复调用时依然保持数据正确。原文给出的方向是“让处理程序安全地运行多次”,也就是通常所说的去重。一个精心设计的webhook系统,需要根据事件携带的唯一标识来判断该事件是否已经被处理过,这种机制可以有效避免重复放款、重复邮件等连带影响。原文在这个环节的讨论只开了一个头,但足以点明:只要没做好幂等控制,后续所有的可靠保障都无从谈起。
热门跟贴