四家支付回调收敛到一个入口:/pay/notify/{channel} 的实现笔记
项目同时接了微信支付 v3、支付宝、Stripe、PayPal。早期是四份独立 handler,半年后集中暴露出几类问题:两家用了全局 JSON body parser,验签拿到的是重新序列化后的 body,偶发失败;支付宝回调没有返回纯文本 success,被持续重试,用户收到多次发货提醒;微信 v3 回调里同步做了发券和推送,超过应答时限被重试;幂等键用了订单号,同一订单的支付成功和退款成功两个事件互相覆盖。
这些问题不是业务逻辑写错,而是四家渠道的应答语义、验签材料、重试节奏本来就不同,复制四份 handler 之后,任何一份的细节都可能在另一份里被误用。下面是把它们收敛到 POST /pay/notify/{channel} 的工程做法。
入口层:先落原始 body,再谈验签
路由固定为 POST /pay/notify/{channel},channel 走白名单:wechat / alipay / stripe / paypal。不在白名单直接 404,不进入后续逻辑。
handler 的第一步是 io.ReadAll(r.Body),限制 1MB。读出来的 raw body 连同全部 header、trace id 一起落日志或对象存储。这一步必须在任何解析之前,因为微信 v3 和 Stripe 的签名都直接覆盖原始字节,body 被 JSON 中间件解析再序列化一次,字段顺序、空白、转义都可能变,验签必挂。
所以这条路由不能挂全局 JSON body parser。如果框架默认全局挂了,需要在这个路由上绕过,或者用中间件把 raw body 读出来塞进 context,业务侧只从 context 取,不再碰 r.Body。
拿到 raw body 之后的顺序是:查 registry 取该渠道的 Verifier → 验签(失败直接 4xx,记录失败原因和 trace id)→ 解析成统一事件 → 幂等插入 → 投递队列 → 立即按渠道语义应答。应答之前的同步工作只保留这些,发券、推送、改订单状态全部放到队列消费侧。
验签策略表
| 渠道 | 关键 header | 算法 | 待签串 / 密钥 | 时间窗 |
|---|---|---|---|---|
| 微信支付 v3 | Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial | RSA-SHA256(平台证书/公钥) | timestamp\nnonce\nbody\n;APIv3 密钥用于解密回调中的 resource 密文(按官方文档口径) | 建议 5 分钟,防重放 |
| 支付宝 | body 中的 sign、sign_type | RSA2(SHA256withRSA),支付宝公钥 | 去掉 sign、sign_type 后按 key 排序并 urlencode 的串 | 未实测,靠 notify_id 去重和重试机制 |
| Stripe | Stripe-Signature,含 t= 和可能多个 v1= | HMAC-SHA256,endpoint 的 whsec_ | "{t}.{raw_body}" | 默认容忍 300 秒 |
| PayPal | paypal-transmission-id、paypal-transmission-time、paypal-transmission-sig、paypal-cert-url、paypal-auth-algo | RSA-SHA256,cert_url 证书公钥 | transmissionId|timeStamp|webhookId|crc32(crc32 为十进制) | 未实测 |
微信 v3 的 header 名大小写不敏感,但取的时候要按实际发送的名字拿全;Wechatpay-Serial 用来定位平台证书。支付宝的待签串构造是最容易写错的一环:排序、urlencode、空值处理都要和官方示例对齐,建议直接用官方 SDK 的验签函数,不要手写。
Stripe 的 v1 可能有多个,验签时任一匹配即通过;t 与当前时间的差值超过容忍窗口直接拒绝。PayPal 的 webhookId 是配置在开发者后台的固定值,不是从请求里取的,验签前要先把证书下载并缓存,cert_url 只用于定位证书,不能无条件信任其内容。
应答语义表
| 渠道 | 成功应答 | 失败应答 | 应答错误的后果 |
|---|---|---|---|
| 微信支付 v3 | HTTP 200,body 为 {"code":"SUCCESS","message":"成功"} 或空体 | 非 2xx,body 为 {"code":"FAIL","message":"..."} | 触发重试 |
| 支付宝 | 纯文本 success(大小写敏感) | 任意非 success 内容 | 25 小时内 8 次重试,间隔约 4m / 10m / 10m / 1h / 2h / 6h / 15h |
| Stripe | 任意 2xx | 非 2xx | 触发重试(具体节奏按官方文档) |
| PayPal | 2xx | 非 2xx | 3 天内最多重试 25 次;只投 443 |
支付宝这一行是最容易被“统一封装”坑到的:如果按 JSON 习惯返回 {"code":"SUCCESS"},或者返回 Success、SUCCESS,都会被当成失败,进入 8 次重试。微信反过来,返回纯文本 success 也不行,必须是非 2xx 加 {"code":"FAIL"} 才表示处理失败。
PayPal 只投 443,本地联调要么起 HTTPS,要么用反向代理把 443 转到本地端口,否则收不到回调。
幂等落库:唯一键用 channel + event_id
唯一键不能选订单号。同一笔订单会先后产生支付成功、退款成功等多个事件,用订单号做唯一键会把它们互相覆盖,退款事件被当成重复投递丢掉。
用渠道自己的事件 ID:
CREATE TABLE pay_event (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
channel VARCHAR(16) NOT NULL,
event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
out_trade_no VARCHAR(64) NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0 received 1 done 2 failed
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_channel_event (channel, event_id),
KEY idx_status_created (status, created_at)
) ENGINE=InnoDB;
channel + event_id 做唯一索引,插入冲突就说明是重复投递,直接按该渠道的成功语义应答,不再重复入队。idx_status_created 是给后面的补偿任务用的:按 status=0 和创建时间扫。
各渠道的事件 ID 来源不同:微信 v3 用回调里的 id,Stripe 用 event 的 id,PayPal 用 PAYPAL-TRANSMISSION-ID 或 webhook event id,支付宝没有严格等价的事件 ID,通常用 notify_id 加 out_trade_no 和 trade_status 组合,或者对 raw body 做摘要。这一层由各渠道的 Parse 负责填,入口层不关心。
业务异步化与主动查单兜底
回调只做触发,资金态以查单结果为准。队列消费侧收到 PayEvent 后,先按 out_trade_no 调渠道的查单接口,用查单结果更新订单,而不是直接相信回调里的金额和状态。这样做的好处是:回调乱序、重复、甚至伪造(如果验签有漏洞)都不会直接改资金态。
补偿任务扫 status=0 且创建超过 2 分钟的事件,直接调渠道查单接口补状态。查单成功就把事件置为 done,查单明确失败置为 failed,查单结果不确定就保留 status=0 等下一轮。
乱序是常态:退款成功先到、支付成功后到。状态机要按状态优先级推进,而不是按事件到达时间。支付成功不能把已经退款的订单改回已支付,退款成功也不能假设支付事件已经处理过。具体做法是在订单状态上定义单向的优先级顺序,低优先级事件到达时只记录、不回退。
Go 侧接口与关键代码
Verifier 接口与 registry
type PayEvent struct {
Channel string
EventID string
EventType string
OutTradeNo string
AmountMinor int64
Currency string
Raw []byte
}
type Verifier interface {
Channel() string
Verify(header http.Header, rawBody []byte) error
Parse(header http.Header, rawBody []byte) (*PayEvent, error)
// Ack 按渠道成功语义写应答;Nack 写该渠道的失败应答
Ack(w http.ResponseWriter)
Nack(w http.ResponseWriter, status int, reason string)
}
var verifiers = map[string]Verifier{}
func Register(v Verifier) {
if _, dup := verifiers[v.Channel()]; dup {
panic("duplicate verifier: " + v.Channel())
}
verifiers[v.Channel()] = v
}
应答语义收在 Ack / Nack 里,handler 不写任何渠道相关的输出。这样新增渠道只需要实现接口、在 init 里注册,入口层不用改。
rawBody 中间件
type rawBodyKey struct{}
func rawBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "body read error", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewReader(b))
ctx := context.WithValue(r.Context(), rawBodyKey{}, b)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
这条路由单独挂,不要挂到全局,避免和其他接口的 JSON 解析中间件叠加。
幂等插入
const insertEvent = `INSERT INTO pay_event
(channel, event_id, event_type, out_trade_no, payload, status, created_at)
VALUES (?, ?, ?, ?, ?, 0, NOW(3))
ON DUPLICATE KEY UPDATE channel = channel`
res, err := db.ExecContext(ctx, insertEvent,
ev.Channel, ev.EventID, ev.EventType, ev.OutTradeNo, ev.Raw)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
inserted := n == 1
ON DUPLICATE KEY UPDATE channel = channel 是空更新,用来拿 RowsAffected 判断是否首次插入。不同 MySQL 驱动对 RowsAffected 的返回值可能不同,接库时先跑一次重放确认,或者改用 INSERT IGNORE 并接受它吞掉非唯一键错误的副作用。
handler 与应答 helper
func notifyHandler(w http.ResponseWriter, r *http.Request) {
ch := r.PathValue("channel")
v, ok := verifiers[ch]
if !ok {
http.NotFound(w, r)
return
}
raw, _ := r.Context().Value(rawBodyKey{}).([]byte)
logRaw(r, ch, raw) // trace_id + header + raw body
if err := v.Verify(r.Header, raw); err != nil {
v.Nack(w, http.StatusUnauthorized, "signature mismatch")
return
}
ev, err := v.Parse(r.Header, raw)
if err != nil {
v.Nack(w, http.StatusBadRequest, "bad payload")
return
}
inserted, err := store.InsertPayEvent(r.Context(), ev)
if err != nil {
v.Nack(w, http.StatusInternalServerError, "store error")
return
}
if inserted {
if err := queue.Publish(r.Context(), ev); err != nil {
// 已落库,发布失败交给补偿任务,不在应答路径上放大重试
log.Warn("publish failed", "channel", ch, "event_id", ev.EventID)
}
}
v.Ack(w)
}
插入冲突时不再入队,但也不假设对方已经处理完:如果 status 还是 0,补偿任务会在 2 分钟后接手,所以这里仍然回成功应答。
四个渠道的应答实现:
func (v *wechatVerifier) Ack(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"code":"SUCCESS","message":"成功"}`))
}
func (v *wechatVerifier) Nack(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"code": "FAIL", "message": msg})
}
func (v *alipayVerifier) Ack(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("success"))
}
func (v *alipayVerifier) Nack(w http.ResponseWriter, _ int, _ string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("fail"))
}
Stripe 和 PayPal 的 Ack 只写 2xx 空体,Nack 写非 2xx。
Stripe 的验签主体大致如下,重点是用 raw body 参与 HMAC,不经过任何解析:
func (v *stripeVerifier) Verify(h http.Header, body []byte) error {
var ts string
var vs []string
for _, part := range strings.Split(h.Get("Stripe-Signature"), ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
vs = append(vs, kv[1])
}
}
if ts == "" || len(vs) == 0 {
return ErrBadSignature
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ErrBadSignature
}
if d := time.Since(time.Unix(t, 0)); d > 300*time.Second || d < -300*time.Second {
return ErrExpired
}
mac := hmac.New(sha256.New, []byte(v.secret))
mac.Write([]byte(ts))
mac.Write([]byte("."))
mac.Write(body)
want := mac.Sum(nil)
for _, s := range vs {
got, err := hex.DecodeString(s)
if err == nil && hmac.Equal(got, want) {
return nil
}
}
return ErrBadSignature
}
灰度与自测
灰度按渠道逐个切:先接 Stripe,跑通后再切微信、支付宝、PayPal。切换期间新入口和老 handler 双写一段时间,比对验签结果、应答码和落库事件,确认没有差异再下线老 handler。
自测至少覆盖这几项:
- 同一事件重放 3 次,
pay_event只落一条,三次都应答成功。 - 篡改 raw body 1 个字节,四家都必须返回 4xx,且日志里能定位到失败原因。
- 把支付宝的应答临时改成 200 空体,观察是否进入重试;确认重试间隔和文档描述一致,再改回
success。 - 把业务处理人为 sleep 10 秒,观察微信 v3 和 Stripe 是否超时重试;按这个结果确定应答路径上能留多少同步逻辑。
- 构造乱序事件:先发退款成功、再发支付成功,确认订单状态不会被回退。
官方文档入口:Stripe webhooks 见 https://docs.stripe.com/webhooks ,PayPal webhooks 见 https://developer.paypal.com/api/rest/webhooks/rest 。微信支付 v3 和支付宝的回调细节以各自官方文档为准,本文表格中标「未实测」的条目在接入时需要用真实回调验证一遍。