feat(pay-v2): P8 Task7 业务方事件集声明 + 装配收尾 + 契约文档
This commit is contained in:
@@ -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 回调用)。未声明或无密钥则视为未接入。
|
||||
|
||||
@@ -276,24 +276,44 @@
|
||||
<tbody>
|
||||
<tr><td><code>payment.succeeded</code></td><td><span class="tag ok">先做</span></td><td>收款成功 → 接入方开通</td></tr>
|
||||
<tr><td><code>refund.succeeded</code> / <code>refund.failed</code></td><td><span class="tag ok">卡类要真做</span></td><td>退款结果 → 接入方冲正权益</td></tr>
|
||||
<tr><td><code>chargeback.created</code></td><td><span class="tag info">设计进·later</span></td><td>信用卡拒付(平台/发卡行发起)</td></tr>
|
||||
<tr><td><code>subscription.renewed|failed|canceled</code></td><td><span class="tag info">设计进·later</span></td><td>订阅事件(§见 recurring)</td></tr>
|
||||
<tr><td><code>subscription.created</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions)</td></tr>
|
||||
<tr><td><code>subscription.renewed</code></td><td><span class="tag ok">✅ P8</span></td><td>续费成功(invoice.paid,下一期扣款到账)</td></tr>
|
||||
<tr><td><code>subscription.past_due</code></td><td><span class="tag ok">✅ P8</span></td><td>续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡)</td></tr>
|
||||
<tr><td><code>subscription.canceled</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅取消(业务方发起或渠道侧取消)</td></tr>
|
||||
<tr><td><code>chargeback.received</code></td><td><span class="tag ok">✅ P8</span></td><td>信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>签名沿用双向 HMAC(<code>system\ntimestamp\nnonce\nrawBody</code>,±5min,nonce 防重放);payload 幂等键 <code>out_trade_no</code>。接入方收 <code>payment.succeeded</code> → 验签 → 幂等 → 金额核对 → <code>max(现到期,now)+时长</code> 叠加(<b>此算法抽成共享工具</b>);非 200/不含 SUCCESS → 60s 重试 24h。</p>
|
||||
|
||||
<h3>5.1 自动续订(recurring):设计进模型、暂不实现</h3>
|
||||
<h3>5.1 自动续订(recurring):<code>gateway_scheduled</code>(Stripe)P8 已实现,其余仍设计进模型</h3>
|
||||
<p>本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":</p>
|
||||
<table>
|
||||
<thead><tr><th>recurring_kind</th><th>谁驱动</th><th>代表</th><th>pay 如何推进</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>token_offsession</code></td><td>我方</td><td>Stripe自建/支付宝周期扣/微信papay</td><td>cron 到期主动 charge</td></tr>
|
||||
<tr><td><code>gateway_scheduled</code></td><td>网关</td><td>Stripe Subscriptions / PayPal</td><td>监听 invoice.paid webhook</td></tr>
|
||||
<tr><td><code>store_managed</code></td><td>平台</td><td>Apple IAP / Google Play</td><td>被动接 Server Notification/RTDN</td></tr>
|
||||
<tr><td><code>token_offsession</code></td><td>我方</td><td>Stripe自建/支付宝周期扣/微信papay</td><td>cron 到期主动 charge(能力位,<b>未实现</b>)</td></tr>
|
||||
<tr><td><code>gateway_scheduled</code></td><td>网关</td><td>Stripe Subscriptions / PayPal</td><td>监听 invoice.paid webhook(<span class="tag ok">✅ P8 已实现,Stripe</span>;PayPal 未接)</td></tr>
|
||||
<tr><td><code>store_managed</code></td><td>平台</td><td>Apple IAP / Google Play</td><td>被动接 Server Notification/RTDN(能力位,<b>未实现</b>)</td></tr>
|
||||
<tr><td><code>none</code></td><td>无</td><td>所有 crypto / 单笔</td><td>到期提醒用户手动再买(伪续订)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">统一 subscription 实体 + 状态机(active→grace→expired);单笔层与订阅层解耦。crypto 天生无 recurring、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。本轮把 event_type/subscription 实体设计进契约、避免第三次 breaking,但只建一次性付款。</p>
|
||||
<p class="small">统一 subscription 实体 + 状态机(active→past_due→canceled,<code>internal/model/subscription.go</code>);单笔层与订阅层解耦。crypto 天生无 recurring(<code>SupportsRecurring=false</code>,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → <code>subscription.created</code>;<code>invoice.paid</code> → 续费单 + <code>subscription.renewed</code>;<code>invoice.payment_failed</code> → <code>subscription.past_due</code>;取消(业务方 API 或渠道侧)→ <code>subscription.canceled</code>;<code>charge.dispute.created</code> → <code>Chargeback</code> 记录 + 原单打 disputed 标 + <code>chargeback.received</code>。<code>token_offsession</code>/<code>store_managed</code>/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。</p>
|
||||
|
||||
<h3>5.2 接入方显式声明支持事件集</h3>
|
||||
<p><code>BizSystemConfig.SupportedEvents</code>(<code>config.yaml</code> 的 <code>biz.<name>.supported_events</code>):接入方在配置里显式声明自己能处理哪些 <code>event_type</code>。<b>空 = 只收 <code>payment.succeeded</code></b>(v1/P2 老接入方向后兼容,不会突然收到 <code>subscription.*</code>/<code>chargeback.received</code> 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:<code>event_type</code> 不在声明集 → 不 POST、直接标 <code>delivered</code>(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。</p>
|
||||
<p class="small">门禁旁路:<code>subscription.past_due</code>/<code>subscription.canceled</code> 挂在已 paid 的首购单(<code>out_trade_no</code>)上,既有的 <code>orderPaid</code> 投递门禁天然放行,无需额外旁路。</p>
|
||||
|
||||
<h3>5.3 webhook payload 契约(P8 新增事件)</h3>
|
||||
<table>
|
||||
<thead><tr><th>event_type</th><th>payload 关键字段</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>subscription.created</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>created_at</code></td></tr>
|
||||
<tr><td><code>subscription.renewed</code></td><td><code>out_trade_no</code>(<b>续费单号</b>,与首购单号不同)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>paid_at</code></td></tr>
|
||||
<tr><td><code>subscription.past_due</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>failed_at</code></td></tr>
|
||||
<tr><td><code>subscription.canceled</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>canceled_at</code></td></tr>
|
||||
<tr><td><code>chargeback.received</code></td><td><code>out_trade_no</code>/<code>dispute_ref</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>reason</code>/<code>received_at</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">所有事件的 <code>event_type</code> 字段本身也回显在 payload 顶层(与 <code>X-Pay-Event</code> 头一致),便于业务方单点分发。<code>subscription.renewed</code> 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + <code>event_type</code>(同订阅只会 created 一次、canceled 一次;<code>past_due</code> 当前实现下同订阅多次失败只保证首次必达,见 <code>internal/gateway/subscription.go::markSubscriptionPastDue</code> 注释)。</p>
|
||||
|
||||
<h2>6. 退款设计</h2>
|
||||
<p><b>退款政策归业务,退款机制归 pay</b>(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。</p>
|
||||
@@ -408,7 +428,7 @@
|
||||
<h2>13. 待确认 / 后续</h2>
|
||||
<ul>
|
||||
<li>本设计聚焦 <b>pay 深设计 + codes 定架构</b>;entitlement 各产品自留(不在本设计)。</li>
|
||||
<li>实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付。</li>
|
||||
<li>实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付 —— <b>P8 已落地</b>:<code>gateway_scheduled</code>(Stripe)订阅状态机 + <code>chargeback</code> 拒付记录 + 业务方事件集声明/过滤;<code>token_offsession</code>/<code>store_managed</code> 仍留能力位、未实现。</li>
|
||||
<li>补测试(现 pay 核心资金链路几乎无单测,托多产品钱必须补)。</li>
|
||||
<li>pangolin / jiu 收口改造:<b>pay 定稿后</b>再动(见 brain todo)。</li>
|
||||
</ul>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user