merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u # Conflicts: # internal/model/testdb.go # internal/provider/provider.go # internal/router/router.go # internal/store/order_query_test.go # main.go
This commit is contained in:
@@ -154,6 +154,10 @@ func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
|
||||
n.fail(d, "biz system not configured")
|
||||
return false
|
||||
}
|
||||
if !eventSupported(cfg.SupportedEvents, d.EventType) {
|
||||
_ = n.deliveries.MarkDelivered(d.ID) // 业务方未订阅该事件:视为已受理,不投递、不重试
|
||||
return true
|
||||
}
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := uuid.NewString()
|
||||
sign := util.HMACSign(cfg.Secret, d.BizSystem, ts, nonce, d.Payload)
|
||||
@@ -186,6 +190,21 @@ func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// eventSupported 判断业务方是否声明支持该 event_type(设计 §「接入方显式声明支持事件
|
||||
// 集」):list 为空 → 仅 payment.succeeded 为真(v1/P2 接入方向后兼容,不会突然收到
|
||||
// subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确匹配。
|
||||
func eventSupported(list []string, ev string) bool {
|
||||
if len(list) == 0 {
|
||||
return ev == "payment.succeeded"
|
||||
}
|
||||
for _, e := range list {
|
||||
if e == ev {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncate safely truncates a string to n bytes without splitting UTF-8 runes.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
|
||||
@@ -2,6 +2,7 @@ package webhook_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -188,3 +189,72 @@ func TestNotifierGateSkipsUnpaidOrder(t *testing.T) {
|
||||
t.Fatalf("翻转后应投递, sent=%d hits=%d", sent, hits)
|
||||
}
|
||||
}
|
||||
|
||||
// 事件集过滤(P8 Task7):业务方未声明支持的事件不投递、直接标 delivered(视为已受理,
|
||||
// 不占重试),已声明的事件正常投。SupportedEvents 空 → 只收 payment.succeeded(向后兼容
|
||||
// v1/P2 接入方,不会突然收到新事件把它们搞崩)。
|
||||
func TestNotifierFiltersUnsupportedEvents(t *testing.T) {
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits = append(hits, r.Header.Get("X-Pay-Event"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("SUCCESS"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
// 只声明支持 payment.succeeded(空集等价语义的显式形式,顺带覆盖非空单元素集)。
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x", SupportedEvents: []string{"payment.succeeded"}}, true
|
||||
}, func(string) (bool, error) { return true, nil })
|
||||
|
||||
_ = n.Enqueue("PAY-5", "pangolin", "subscription.renewed", "", map[string]any{"event_type": "subscription.renewed"})
|
||||
_ = n.Enqueue("PAY-6", "pangolin", "payment.succeeded", "", map[string]any{"event_type": "payment.succeeded"})
|
||||
|
||||
// deliverOne 对"未订阅事件"也返回 true(视为已受理、直接标 delivered,见 notifier.go
|
||||
// eventSupported 分支的注释),所以 sent 计两条;真正发出的 POST 只应有 1 次。
|
||||
sent, err := n.DeliverPending(10)
|
||||
if err != nil || sent != 2 {
|
||||
t.Fatalf("DeliverPending = %d, %v, want 2(1 条真投 + 1 条未订阅直接标 delivered)", sent, err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0] != "payment.succeeded" {
|
||||
t.Fatalf("只应收到 payment.succeeded 一次 POST, got %v", hits)
|
||||
}
|
||||
// 未订阅的事件已标 delivered(视为已受理),不占重试队列。
|
||||
pend, _ := ws.ListUndelivered(10)
|
||||
if len(pend) != 0 {
|
||||
t.Fatalf("未订阅事件应标 delivered、不留队, got %+v", pend)
|
||||
}
|
||||
}
|
||||
|
||||
// 声明了全部 P8 事件集的业务方:所有事件都应正常投递(不被过滤)。
|
||||
func TestNotifierDeliversAllDeclaredEvents(t *testing.T) {
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits = append(hits, r.Header.Get("X-Pay-Event"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("SUCCESS"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
all := []string{
|
||||
"payment.succeeded", "subscription.renewed", "subscription.created",
|
||||
"subscription.canceled", "subscription.past_due", "chargeback.received",
|
||||
}
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x", SupportedEvents: all}, true
|
||||
}, func(string) (bool, error) { return true, nil })
|
||||
|
||||
for i, ev := range all {
|
||||
_ = n.Enqueue(fmt.Sprintf("PAY-ALL-%d", i), "pangolin", ev, "", map[string]any{"event_type": ev})
|
||||
}
|
||||
|
||||
sent, err := n.DeliverPending(10)
|
||||
if err != nil || sent != len(all) {
|
||||
t.Fatalf("DeliverPending = %d, %v, want %d(声明集内全投)", sent, err, len(all))
|
||||
}
|
||||
if len(hits) != len(all) {
|
||||
t.Fatalf("应收到 %d 次 POST, got %v", len(all), hits)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user