From 2a4be0117dafb47c68e18e14cf267d58065b62b4 Mon Sep 17 00:00:00 2001
From: wangjia <809946525@qq.com>
Date: Fri, 10 Jul 2026 18:59:03 +0800
Subject: [PATCH] =?UTF-8?q?feat(pay-v2):=20P8=20Task7=20=E4=B8=9A=E5=8A=A1?=
=?UTF-8?q?=E6=96=B9=E4=BA=8B=E4=BB=B6=E9=9B=86=E5=A3=B0=E6=98=8E=20+=20?=
=?UTF-8?q?=E8=A3=85=E9=85=8D=E6=94=B6=E5=B0=BE=20+=20=E5=A5=91=E7=BA=A6?=
=?UTF-8?q?=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
config/config.go | 5 ++
docs/pay-v2-unified-gateway-design.html | 36 ++++++++++---
internal/webhook/notifier.go | 19 +++++++
internal/webhook/notifier_test.go | 70 +++++++++++++++++++++++++
4 files changed, 122 insertions(+), 8 deletions(-)
diff --git a/config/config.go b/config/config.go
index 0079b29..08ffb28 100644
--- a/config/config.go
+++ b/config/config.go
@@ -80,6 +80,11 @@ type WechatConfig struct {
type BizSystemConfig struct {
CallbackURL string `mapstructure:"callback_url"` // 支付成功回调业务方接收器地址
Secret string `mapstructure:"secret"` // HMAC 共享密钥
+ // SupportedEvents 接入方显式声明支持的 webhook event_type 集合(设计 §「接入方显式
+ // 声明支持事件集」)。空 = 只收 payment.succeeded(向后兼容 v1/P2 接入方,不会突然
+ // 收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确
+ // 匹配,Notifier 投递前过滤(见 internal/webhook/notifier.go::eventSupported)。
+ SupportedEvents []string `mapstructure:"supported_events"`
}
// BizByName 按业务系统名取配置(供下单鉴权 / webhook 回调用)。未声明或无密钥则视为未接入。
diff --git a/docs/pay-v2-unified-gateway-design.html b/docs/pay-v2-unified-gateway-design.html
index 2ebb620..dc68dcf 100644
--- a/docs/pay-v2-unified-gateway-design.html
+++ b/docs/pay-v2-unified-gateway-design.html
@@ -276,24 +276,44 @@
payment.succeeded | 先做 | 收款成功 → 接入方开通 |
refund.succeeded / refund.failed | 卡类要真做 | 退款结果 → 接入方冲正权益 |
-chargeback.created | 设计进·later | 信用卡拒付(平台/发卡行发起) |
-subscription.renewed|failed|canceled | 设计进·later | 订阅事件(§见 recurring) |
+subscription.created | ✅ P8 | 订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions) |
+subscription.renewed | ✅ P8 | 续费成功(invoice.paid,下一期扣款到账) |
+subscription.past_due | ✅ P8 | 续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡) |
+subscription.canceled | ✅ P8 | 订阅取消(业务方发起或渠道侧取消) |
+chargeback.received | ✅ P8 | 信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流) |
签名沿用双向 HMAC(system\ntimestamp\nnonce\nrawBody,±5min,nonce 防重放);payload 幂等键 out_trade_no。接入方收 payment.succeeded → 验签 → 幂等 → 金额核对 → max(现到期,now)+时长 叠加(此算法抽成共享工具);非 200/不含 SUCCESS → 60s 重试 24h。
-5.1 自动续订(recurring):设计进模型、暂不实现
+5.1 自动续订(recurring):gateway_scheduled(Stripe)P8 已实现,其余仍设计进模型
本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":
| recurring_kind | 谁驱动 | 代表 | pay 如何推进 |
-token_offsession | 我方 | Stripe自建/支付宝周期扣/微信papay | cron 到期主动 charge |
-gateway_scheduled | 网关 | Stripe Subscriptions / PayPal | 监听 invoice.paid webhook |
-store_managed | 平台 | Apple IAP / Google Play | 被动接 Server Notification/RTDN |
+token_offsession | 我方 | Stripe自建/支付宝周期扣/微信papay | cron 到期主动 charge(能力位,未实现) |
+gateway_scheduled | 网关 | Stripe Subscriptions / PayPal | 监听 invoice.paid webhook(✅ P8 已实现,Stripe;PayPal 未接) |
+store_managed | 平台 | Apple IAP / Google Play | 被动接 Server Notification/RTDN(能力位,未实现) |
none | 无 | 所有 crypto / 单笔 | 到期提醒用户手动再买(伪续订) |
-统一 subscription 实体 + 状态机(active→grace→expired);单笔层与订阅层解耦。crypto 天生无 recurring、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。本轮把 event_type/subscription 实体设计进契约、避免第三次 breaking,但只建一次性付款。
+统一 subscription 实体 + 状态机(active→past_due→canceled,internal/model/subscription.go);单笔层与订阅层解耦。crypto 天生无 recurring(SupportsRecurring=false,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → subscription.created;invoice.paid → 续费单 + subscription.renewed;invoice.payment_failed → subscription.past_due;取消(业务方 API 或渠道侧)→ subscription.canceled;charge.dispute.created → Chargeback 记录 + 原单打 disputed 标 + chargeback.received。token_offsession/store_managed/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。
+
+5.2 接入方显式声明支持事件集
+BizSystemConfig.SupportedEvents(config.yaml 的 biz.<name>.supported_events):接入方在配置里显式声明自己能处理哪些 event_type。空 = 只收 payment.succeeded(v1/P2 老接入方向后兼容,不会突然收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:event_type 不在声明集 → 不 POST、直接标 delivered(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。
+门禁旁路:subscription.past_due/subscription.canceled 挂在已 paid 的首购单(out_trade_no)上,既有的 orderPaid 投递门禁天然放行,无需额外旁路。
+
+5.3 webhook payload 契约(P8 新增事件)
+
+| event_type | payload 关键字段 |
+
+subscription.created | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/created_at |
+subscription.renewed | out_trade_no(续费单号,与首购单号不同)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/paid_at |
+subscription.past_due | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/failed_at |
+subscription.canceled | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/canceled_at |
+chargeback.received | out_trade_no/dispute_ref/biz_system/biz_ref/product_biz_code/amount_minor/currency/reason/received_at |
+
+
+所有事件的 event_type 字段本身也回显在 payload 顶层(与 X-Pay-Event 头一致),便于业务方单点分发。subscription.renewed 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + event_type(同订阅只会 created 一次、canceled 一次;past_due 当前实现下同订阅多次失败只保证首次必达,见 internal/gateway/subscription.go::markSubscriptionPastDue 注释)。
6. 退款设计
退款政策归业务,退款机制归 pay(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。
@@ -408,7 +428,7 @@
13. 待确认 / 后续
- 本设计聚焦 pay 深设计 + codes 定架构;entitlement 各产品自留(不在本设计)。
-- 实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付。
+- 实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付 —— P8 已落地:
gateway_scheduled(Stripe)订阅状态机 + chargeback 拒付记录 + 业务方事件集声明/过滤;token_offsession/store_managed 仍留能力位、未实现。
- 补测试(现 pay 核心资金链路几乎无单测,托多产品钱必须补)。
- pangolin / jiu 收口改造:pay 定稿后再动(见 brain todo)。
diff --git a/internal/webhook/notifier.go b/internal/webhook/notifier.go
index 3b948fa..4955d8f 100644
--- a/internal/webhook/notifier.go
+++ b/internal/webhook/notifier.go
@@ -83,6 +83,10 @@ func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
_ = n.deliveries.MarkFailed(d.ID, "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)
@@ -115,6 +119,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 {
diff --git a/internal/webhook/notifier_test.go b/internal/webhook/notifier_test.go
index abcdb39..270db3c 100644
--- a/internal/webhook/notifier_test.go
+++ b/internal/webhook/notifier_test.go
@@ -2,6 +2,7 @@ package webhook_test
import (
"encoding/json"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -130,3 +131,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)
+ }
+}