feat(v2): webhook v2 outbox + Notifier(event_type + HMAC 双向 + 重试兜底)
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Package webhook delivers pay→business webhooks (v2, with event_type) from an
|
||||
// outbox, signed with the shared HMAC scheme (双向验签,与 v1 notifyBiz 一致),
|
||||
// with a background retry sweep as the backstop for lost/failed deliveries.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
"github.com/wangjia/pay/internal/util"
|
||||
)
|
||||
|
||||
// BizConfigFunc resolves a business system's callback URL + HMAC secret.
|
||||
type BizConfigFunc func(system string) (config.BizSystemConfig, bool)
|
||||
|
||||
// OrderPaidFunc reports whether an order is settled (paid). Delivery gate:
|
||||
// settle 是"先入队后翻转",崩溃窗口里 outbox 可能存在"未付单"的行——投递前必须
|
||||
// 门禁,否则会把 payment.succeeded 发给业务方、白给权益。
|
||||
type OrderPaidFunc func(outTradeNo string) (bool, error)
|
||||
|
||||
type Notifier struct {
|
||||
deliveries *store.WebhookStore
|
||||
bizConfig BizConfigFunc
|
||||
orderPaid OrderPaidFunc
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewNotifier(ws *store.WebhookStore, bizConfig BizConfigFunc, orderPaid OrderPaidFunc) *Notifier {
|
||||
return &Notifier{deliveries: ws, bizConfig: bizConfig, orderPaid: orderPaid,
|
||||
client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Enqueue implements gateway.WebhookEnqueuer: serialize the domain payload and
|
||||
// idempotently persist it to the outbox (delivery happens async).
|
||||
func (n *Notifier) Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook.Enqueue marshal: %w", err)
|
||||
}
|
||||
return n.deliveries.EnqueueDelivery(outTradeNo, bizSystem, eventType, string(body))
|
||||
}
|
||||
|
||||
// DeliverPending flushes undelivered rows; returns how many succeeded this pass.
|
||||
func (n *Notifier) DeliverPending(limit int) (int, error) {
|
||||
rows, err := n.deliveries.ListUndelivered(limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ok := 0
|
||||
for i := range rows {
|
||||
if n.deliverOne(&rows[i]) {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
|
||||
// 门禁:订单未付不投递(不计失败,等 settle 翻转后自然放行)。
|
||||
paid, err := n.orderPaid(d.OutTradeNo)
|
||||
if err != nil || !paid {
|
||||
return false
|
||||
}
|
||||
cfg, found := n.bizConfig(d.BizSystem)
|
||||
if !found || cfg.CallbackURL == "" {
|
||||
_ = n.deliveries.MarkFailed(d.ID, "biz system not configured")
|
||||
return false
|
||||
}
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := uuid.NewString()
|
||||
sign := util.HMACSign(cfg.Secret, d.BizSystem, ts, nonce, d.Payload)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, cfg.CallbackURL, bytes.NewReader([]byte(d.Payload)))
|
||||
if err != nil {
|
||||
_ = n.deliveries.MarkFailed(d.ID, err.Error())
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Pay-System", d.BizSystem)
|
||||
req.Header.Set("X-Pay-Event", d.EventType)
|
||||
req.Header.Set("X-Pay-Timestamp", ts)
|
||||
req.Header.Set("X-Pay-Nonce", nonce)
|
||||
req.Header.Set("X-Pay-Sign", sign)
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
_ = n.deliveries.MarkFailed(d.ID, err.Error())
|
||||
return false
|
||||
}
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
// 约定:业务方返回 HTTP 200 且响应含 SUCCESS 视为受理(与 v1 一致)。
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(strings.ToUpper(string(rb)), "SUCCESS") {
|
||||
_ = n.deliveries.MarkDelivered(d.ID)
|
||||
return true
|
||||
}
|
||||
_ = n.deliveries.MarkFailed(d.ID, fmt.Sprintf("http %d: %s", resp.StatusCode, truncate(string(rb), 120)))
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start runs a background retry sweep (backstop for lost/failed webhooks).
|
||||
func (n *Notifier) Start(interval time.Duration) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[webhook] retry sweep panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
if _, err := n.DeliverPending(50); err != nil {
|
||||
log.Printf("[webhook] DeliverPending: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user