支付 webhook 去重:event_id 和 payment_id 是两把锁,混用会吞掉退款
浏览器跳回 /success?status=ok 不能当成支付成功。URL 里的参数由前端控制,用户改成 ?status=failed 也一样能打开。跳回页只是“去查一下”的信号,真正的状态来自服务端主动查单,或验签后的 webhook。
下面这些点来自对接 eSewa、IMEPay、Dodo Payments、Paddle 时踩过的坑,顺序按一次请求从进来到落库。
验签要基于原始字节
不验签的 webhook 等于公开一个 POST /webhook {"type":"payment_succeeded"} 接口。
签名算的是原始 body 的字节,不是反序列化之后的 JSON 对象。很多框架先把 body 解析成 map,再序列化回去,键顺序、空白、数字格式全变了,签名自然对不上。于是有人干脆把验签关掉。
NestJS 要开 rawBody: true,并且确认该路由拿到的是未被改动的 buffer。Paddle 的文档明确要求不能改动 raw body,连空白都不行。SDK 的验签 helper 还会拒绝时间戳超出容忍窗口的请求(文档默认 5 秒),不少厂商走的是 Standard Webhooks 规范,这部分逻辑可以直接复用。
Go 里大概是这个形状:
func webhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body) // 只读一次,后面都用这份
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if err := verify(r.Header, raw); err != nil {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var ev Event
if err := json.Unmarshal(raw, &ev); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
handle(r.Context(), ev, raw)
}
验签只回答“这是谁发的”。它不阻止重复处理。
厂商至少投递一次
慢、超时、返回 500,都会触发重发。就算你每次都 200,也不保证只来一次。所以真正要解决的是重复,不是签名。
去重是两把锁,不是一个键
两个不同的问题:
- 同一条 webhook 投递两次 → 用 event id 建立唯一约束,见过就跳过;
- 同一笔支付被记录两次 → 用 payment id 建立唯一约束。
不能共用同一个键。一笔支付会随时间产生多条事件:paid、refunded、disputed。如果用 payment_id 当唯一键去重,退款事件到达时,你在库里查到“这笔支付已经处理过了”,于是跳过,退款永远不发生,权益继续发放。
create table webhook_events (
event_id text primary key,
type text not null,
occurred_at timestamptz not null,
payload jsonb not null
);
create table payments (
provider_payment_id text primary key,
order_id text not null,
amount_minor bigint not null,
currency char(3) not null,
status text not null,
occurred_at timestamptz not null
);
event_id 管“这条消息见过没”,provider_payment_id 管“这笔钱记过没”。两条约束各管各的。
用唯一约束,不要 select-then-insert
代码里写“先查一下存在吗,不存在就插入”在并发下必然出问题:两份相同的 webhook 同时到达,两个事务都查到“没有”,然后都插入。唯一约束保证只有一个能提交,另一个报冲突(用 PostgreSQL 的 INSERT ... ON CONFLICT DO NOTHING 直接变成静默跳过)。
别把去重放在应用层的 map 或 Redis 里做唯一判断,那是缓存,会过期,也不是权威。
记录支付和发放权益放同一个事务,不要提前 return
分两步写的经典 bug:
- 插入
payments成功; - 调发权益的接口前进程崩了。
重试时看到 payment 已存在,代码 return,用户付了钱,永远拿不到权益。
修复是把两条语句放进同一个事务,两条都写成幂等的,每次投递都跑一遍:
func handle(ctx context.Context, ev Event) error {
return db.WithTx(ctx, func(tx pgx.Tx) error {
// 事件去重:和业务写入在同一事务里,命中说明上次已完整提交
tag, err := tx.Exec(ctx, `
insert into webhook_events (event_id, type, occurred_at, payload)
values ($1,$2,$3,$4) on conflict do nothing`,
ev.ID, ev.Type, ev.OccurredAt, ev.Raw)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return nil
}
if _, err := tx.Exec(ctx, `
insert into payments (provider_payment_id, order_id, amount_minor, currency, status, occurred_at)
values ($1,$2,$3,$4,$5,$6)
on conflict (provider_payment_id) do nothing`,
ev.PaymentID, ev.OrderID, ev.AmountMinor, ev.Currency, ev.Status, ev.OccurredAt); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
insert into entitlements (order_id) values ($1)
on conflict (order_id) do nothing`, ev.OrderID); err != nil {
return err
}
return nil
})
}
注意这里没有“payment 已存在就 return”。重复投递可能是补完上次半成品的机会,让它跑完那两条 on conflict do nothing 的语句,代价几乎为零。
另外,事件记录之所以能“见过就跳过”,前提是它和业务写入在同一个事务里。如果你先单独记一条“收到了”再去处理业务,中间失败,重投时那条记录会让整条事件被跳过,直接丢单。要记“收到”,就得和业务写入一起提交。
外部副作用交给 outbox
事务只覆盖自己的数据库。发邮件、调外部 API 都不在事务里,回滚不掉。用 transactional outbox:事务内往待办表写一行,后台 worker 取走并重试。
create table outbox (
id bigserial primary key,
topic text not null,
payload jsonb not null,
done_at timestamptz
);
_, err = tx.Exec(ctx,
`insert into outbox (topic, payload) values ('payment.receipt', $1)`, ev.OrderID)
worker 用 select ... for update skip locked 取未完成的,做完置 done_at,失败就下次再来。这样“记支付并发放权益”与“发收据”之间不会互相拖累。
webhook 不保证顺序
典型场景:subscription.canceled(10:00:05)先到,subscription.updated(10:00:01)后到,后者把状态改回 active,库里显示用户还在订阅。
两个处理方式:
存事件时间 occurred_at,比库里旧就忽略。 更新语句自带条件:
update payments
set status = $2, occurred_at = $3
where provider_payment_id = $1
and occurred_at < $3;
写“状态改成 canceled”这种 set 操作是幂等的;写“加 10 积分”这种 increment 不是。 乱序到达时两次 increment 会变成 20 分。凡是增量语义,要么按事件 id 记录后再重算,要么干脆改成“根据事件全集推导当前值”。
事件不完整、看着过期、或者引用了本地还不存在的对象,就回查厂商 API,不要信 payload 里那份快照。
也不要用“故意返回错误让厂商重试”来修顺序问题。重试依然可能乱序,你只是把问题推迟了,还额外制造了重复。
金额用最小单位整数,并且和本地订单对一遍
签名有效、状态是 paid,只证明这条消息来自厂商。它不证明金额和你本地订单一致。
必须拿本地订单比对金额、币种、订单号、状态。绝不要用浏览器传来的金额。对不上就标记待审,不开通权益。
存储用最小货币单位的整数加币种:NPR 10.50 存 1050 paisa。别用浮点,0.1 + 0.2 = 0.30000000000000004 这种误差在金额上不可接受。
各厂商的格式还不一样,有的给 "10.50" 字符串,有的给 1050 整数。只在金额进入系统时转换一次,展示的时候再格式化,中间全程用整数:
func toMinor(s, currency string) (int64, error) {
d, err := decimal.NewFromString(s)
if err != nil {
return 0, err
}
return d.Mul(decimal.NewFromInt(100)).IntPart(), nil
}
5 秒内回 200,慢活推后台
Paddle 要求 5 秒内返回 200,否则重试。生产账号最多重试 60 次,跨 3 天。把慢活塞在 webhook 处理链路里,等于超时、重发、慢活做两遍。
顺序是:验签 → 落库(事件、支付、权益、outbox)→ 返回 200,其余交给 worker。
但有个前提:在事件和待办安全落库之前,不能先回 200。否则你回完就崩,厂商认为已投递、永不重发,任务就此消失。宁可这里慢一点、失败让厂商重试。
“不知道”是一种状态,不是失败
请求超时之后,你不知道那笔支付到底创建成功没有。直接重试可能付第二笔。
有幂等键就用幂等键(不是所有厂商都支持)。没有的话,先按订单号查状态,只在确认没创建时才重试。
在本地也一样,给结果未知的支付留一个单独状态(unknown / pending_confirm),由对账任务去回查。把它当失败处理,就变成重复扣款。
剩下的边界
- 客户付完直接关标签页,跳回压根不会发生,只能靠 webhook 或主动查单发现。
- 订单长期
pending是正常的,有些渠道几分钟甚至几小时后才落账。对账任务应该去回查状态,而不是到点直接关单。 - 同一订单被真付两次:双击、开两个标签页。两个 webhook 都去重了,但两笔钱真的都扣了。做法是只履约一次、两笔都记录、退掉多出来的那笔。
- 退款和拒付不显式处理,就会继续发权益。部分退款、全额退款、chargeback 三者的语义要分开定义,别用一个
refunded状态糊过去。 - 测试密钥和生产密钥、测试端点和生产端点混用,是最容易被忽略又最疼的一类事故。
- 收到不认识的商品 ID 或用户 ID 的 webhook,意味着有一笔钱你没记下来。这是告警条件,不是日志条件。原始 payload 要留着,映射修好之后能重放。
可以在工程上固定下来的几件事
每个厂商包一层适配器,把事件归一成自己控制的 payment_succeeded / refunded / subscription_cancelled,业务代码不碰厂商 SDK。
单独维护一张只追加的支付事件日志表(厂商 id、事件类型、金额、币种、原始 payload)。当前状态用于做决策,事件历史用于对账和排障。
每天跑一次与厂商的对账。
异常(不认识商品、金额不符、验签失败)要变成消息发出来,不能躺在日志里等出事再翻。
测试用真实 webhook 样例回放,至少覆盖两次投递和并发投递这两种情况。这两条过了,大部分重复问题就挡住了。