支付平台没有在规定时间收到 2xx,会重试同一个 Webhook;有的平台即使收到成功响应,也会在网络不确定时再次投递。重复推送不是异常,无法依赖“平台只发一次”。Magento 2 的回调处理必须保证同一支付事件无论到达一次还是十次,最终只产生一张应有的发票和一次业务副作用。
先验签,再讨论幂等
在读取事件内容后使用平台规定的原始 body、时间戳和签名算法验证,拒绝超出时间窗口或签名错误的请求。不要把解析后重新序列化的 JSON 用于验签,字段顺序和空格可能改变。签名密钥只从安全配置读取,日志中不得输出。
用平台事件 ID 做唯一键
支付流水号、事件 ID 或 payment intent ID 应保存到独立事件表,并建立唯一约束。订单号本身不够,因为同一订单可能有授权、支付、退款等多个合法事件。
CREATE TABLE vendor_payment_event (
event_id varchar(128) NOT NULL,
event_type varchar(64) NOT NULL,
order_id int unsigned DEFAULT NULL,
status varchar(32) NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (event_id)
);
这只是设计示例。Magento 模块应通过 declarative schema 创建表,并考虑事件 ID 的字符集、长度与平台范围。收到事件时先尝试登记;唯一键冲突表示已经处理或处理中,读取状态后返回合适的 2xx,而不是再开票。
“先查有没有发票”仍有竞态
两个请求几乎同时执行,都可能在第一步看到没有发票,然后各自创建。需要数据库事务、唯一事件键和对订单/支付状态的锁定组合。Magento service 层的 canInvoice() 是必要检查,但单独调用不能消除并发窗口。
一种稳妥流程是:登记事件为 processing;在事务内锁定对应订单或业务幂等记录;重新加载订单;确认支付金额、币种、交易 ID 和可开票数量;创建并登记发票;提交后把事件标记为 complete。失败则记录可重试状态和安全错误摘要。
金额和状态必须再次验证
不能因为签名正确就按 Webhook 提供的订单号开票。应从平台元数据和 Magento payment additional information 找到订单,比较 merchant account、currency、authorized/captured amount 与已有 transaction。部分捕获、分批发货和多发票订单不能简单判断“存在发票就跳过”。
外部事件与本地动作分开记录
如果开票后还要发邮件、推 ERP、扣积分,这些副作用也要有各自幂等键。发票保存成功、邮件发送超时后重试 Webhook,不能再创建发票;应从事件状态继续未完成步骤,或投递到内部可靠队列。
快速响应,不在回调里做重活
Webhook 入口完成验签和可靠入队后即可返回 2xx,后续由消费者处理。若必须同步处理,确保平台超时大于最坏执行时间并正确处理数据库锁。无论哪种方式,都不要先返回成功再把事件放进不可靠的内存队列。
重放测试比单次成功更重要
在测试环境保存一份脱敏事件,连续发送十次,并发发送两次,再模拟“发票已保存但事件状态未更新”与“邮件失败”。预期是发票数量和 captured amount 始终正确,事件表能说明每次请求结果。最后检查后台订单历史、payment transaction、invoice grid 和外部平台账单,不能只看接口返回 200。

