代码 四家支付回调收敛到一个入口:/pay/notify/{channel} 的实现笔记

2026-10-21 09:02:28

四家支付回调收敛到一个入口:/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算法待签串 / 密钥时间窗
微信支付 v3Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-SerialRSA-SHA256(平台证书/公钥)timestamp\nnonce\nbody\n;APIv3 密钥用于解密回调中的 resource 密文(按官方文档口径)建议 5 分钟,防重放
支付宝body 中的 sign、sign_typeRSA2(SHA256withRSA),支付宝公钥去掉 sign、sign_type 后按 key 排序并 urlencode 的串未实测,靠 notify_id 去重和重试机制
StripeStripe-Signature,含 t= 和可能多个 v1=HMAC-SHA256,endpoint 的 whsec_"{t}.{raw_body}"默认容忍 300 秒
PayPalpaypal-transmission-id、paypal-transmission-time、paypal-transmission-sig、paypal-cert-url、paypal-auth-algoRSA-SHA256,cert_url 证书公钥transmissionId|timeStamp|webhookId|crc32(crc32 为十进制)未实测

微信 v3 的 header 名大小写不敏感,但取的时候要按实际发送的名字拿全;Wechatpay-Serial 用来定位平台证书。支付宝的待签串构造是最容易写错的一环:排序、urlencode、空值处理都要和官方示例对齐,建议直接用官方 SDK 的验签函数,不要手写。

Stripe 的 v1 可能有多个,验签时任一匹配即通过;t 与当前时间的差值超过容忍窗口直接拒绝。PayPal 的 webhookId 是配置在开发者后台的固定值,不是从请求里取的,验签前要先把证书下载并缓存,cert_url 只用于定位证书,不能无条件信任其内容。

应答语义表

渠道成功应答失败应答应答错误的后果
微信支付 v3HTTP 200,body 为 {"code":"SUCCESS","message":"成功"} 或空体非 2xx,body 为 {"code":"FAIL","message":"..."}触发重试
支付宝纯文本 success(大小写敏感)任意非 success 内容25 小时内 8 次重试,间隔约 4m / 10m / 10m / 1h / 2h / 6h / 15h
Stripe任意 2xx非 2xx触发重试(具体节奏按官方文档)
PayPal2xx非 2xx3 天内最多重试 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 和支付宝的回调细节以各自官方文档为准,本文表格中标「未实测」的条目在接入时需要用真实回调验证一遍。

复制全文 生成海报 支付接口 回调 Go Stripe 微信支付

推荐文章

程序员茄子在线接单