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:
wangjia
2026-07-10 19:43:32 +08:00
32 changed files with 2826 additions and 52 deletions
+5
View File
@@ -92,6 +92,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 回调用)。未声明或无密钥则视为未接入。
+28 -8
View File
@@ -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.&lt;name&gt;.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>
+171
View File
@@ -0,0 +1,171 @@
package gateway_test
import (
"context"
"encoding/json"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
)
// newChargebackGateway 装配一套独立的 gateway(渠道 "substripe",复用 subscription_test.go
// 的 fakeSubProvider——它把测试注入的 JSON 原样反序列化成 provider.PaidEvent,包括
// Kind/DisputeRef/OutTradeNo 等 P8 新增字段,fake.Provider 的精简版协议做不到这点),额外
// 暴露 *store.ChargebackStore 供断言落库情况(P8 Task6 专用,不复用 newGateway/newSubGateway
// 避免改动其多处既有调用签名)。
func newChargebackGateway(t *testing.T) (*gateway.Gateway, *store.OrderStore, *store.ChargebackStore, *spyEnqueuer) {
t.Helper()
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(&fakeSubProvider{sessionRef: "cs_cb_1"})
areg := accounts.New([]config.AccountConfig{
{AccountID: "cb-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
return g, orders, chargebacks, spy
}
func seedPaidOrder(t *testing.T, orders *store.OrderStore, no string) {
t.Helper()
if err := orders.CreateOrder(&model.OrderV2{
OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
AmountMinor: 2999, Currency: "USD", Status: model.OrderPaidV2,
}); err != nil {
t.Fatalf("seed paid order: %v", err)
}
}
// TestRecordChargebackHappyPath 覆盖 brief Step1 ①②:charge.dispute.created → 落
// Chargeback 一行 + 原 order Disputed=true + webhook spy 收 chargeback.received,且不
// 自动改订单状态机(仍是 paid,只是 Disputed 打标)。
func TestRecordChargebackHappyPath(t *testing.T) {
g, orders, chargebacks, spy := newChargebackGateway(t)
seedPaidOrder(t, orders, "PAY-1")
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_1", OutTradeNo: "PAY-1",
ProviderPaymentRef: "pi_1", PaidAmountMinor: 2999, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed", result, err)
}
o, err := orders.GetOrder("PAY-1")
if err != nil {
t.Fatalf("GetOrder: %v", err)
}
if !o.Disputed {
t.Fatalf("order.Disputed = false, want true")
}
if o.Status != model.OrderPaidV2 {
t.Fatalf("order.Status = %s, want unchanged paid(打标不改状态机)", o.Status)
}
_ = chargebacks // 幂等落库由下面的重投用例断言(created=false)
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
c := spy.calls[0]
if c["event_type"] != gateway.EvtChargebackReceived || c["out_trade_no"] != "PAY-1" || c["dispute_ref"] != "dp_1" {
t.Fatalf("webhook payload = %+v", c)
}
}
// TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued 拒付重投(Stripe 常见重投场景)→
// Chargeback 表不双记(ON CONFLICT dispute_ref)、webhook 不双发(outbox 唯一键)。
func TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued(t *testing.T) {
g, orders, _, spy := newChargebackGateway(t)
seedPaidOrder(t, orders, "PAY-2")
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_2", OutTradeNo: "PAY-2",
ProviderPaymentRef: "pi_2", PaidAmountMinor: 1999, PaidCurrency: "USD",
Reason: "duplicate", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if _, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}); err != nil {
t.Fatalf("HandleCallback#1: %v", err)
}
result2, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback#2: %v", err)
}
if result2 != gateway.SettleDuplicate {
t.Fatalf("replay result = %v, want duplicate", result2)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
}
}
// TestRecordChargebackEmptyOutTradeNoNotEnqueued 覆盖 brief Step1 ③:out_trade_no 空
// (订阅拒付,PI 无 metadata)→ 仍落 Chargeback 记录,但不入队业务 webhook(无法定位业务单)。
func TestRecordChargebackEmptyOutTradeNoNotEnqueued(t *testing.T) {
g, _, chargebacks, spy := newChargebackGateway(t)
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_sub_1", OutTradeNo: "",
ProviderPaymentRef: "pi_sub_1", PaidAmountMinor: 999, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录未转发)", result, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0(无法定位业务单不转发): %+v", len(spy.calls), spy.calls)
}
// 重投同一空 out_trade_no dispute → Chargeback 仍不双记(created 幂等),同样不入队。
again, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || again != gateway.SettleDuplicate {
t.Fatalf("replay = %v, %v, want duplicate", again, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls after replay = %d, want still 0", len(spy.calls))
}
_ = chargebacks
}
// TestRecordChargebackUnknownOrderNotBlocking out_trade_no 非空但查单失败(极端场景,如脏
// 数据/竞态)→ 已记录 Chargeback,定位失败不阻断、不 panic,同样不转发。
func TestRecordChargebackUnknownOrderNotBlocking(t *testing.T) {
g, _, _, spy := newChargebackGateway(t)
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: "dp_unknown", OutTradeNo: "NOPE",
ProviderPaymentRef: "pi_unknown", PaidAmountMinor: 500, PaidCurrency: "USD",
Reason: "fraudulent", Status: provider.PaidFailed,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录,查单失败不阻断)", result, err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0", len(spy.calls))
}
}
+3 -1
View File
@@ -45,6 +45,8 @@ func TestE2ECryptoQuerySettles(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
acctReg := accounts.New([]config.AccountConfig{
{AccountID: "e2e-1", Channel: "crypto", Enabled: true, Region: "global", CredentialEnvPrefix: "e2e"},
})
@@ -56,7 +58,7 @@ func TestE2ECryptoQuerySettles(t *testing.T) {
picker := accounts.NewRouter(acctReg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, cryptoResolver{}, spy, "global")
g := gateway.New(orders, refunds, preg, picker, cryptoResolver{}, spy, "global", subs, chargebacks)
// 下单 → 从 session payload 拿到期望链上金额(base+唯一尾数),喂给假 TronGrid。
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
+12 -9
View File
@@ -39,19 +39,22 @@ type WebhookEnqueuer interface {
}
type Gateway struct {
orders *store.OrderStore
refunds *store.RefundStore
providers *provider.Registry
picker accounts.Picker
products ProductResolver
webhook WebhookEnqueuer
region string
orders *store.OrderStore
refunds *store.RefundStore
providers *provider.Registry
picker accounts.Picker
products ProductResolver
webhook WebhookEnqueuer
region string
subs *store.SubscriptionStore
chargebacks *store.ChargebackStore
}
func New(orders *store.OrderStore, refunds *store.RefundStore, providers *provider.Registry, picker accounts.Picker,
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
products ProductResolver, webhook WebhookEnqueuer, region string, subs *store.SubscriptionStore,
chargebacks *store.ChargebackStore) *Gateway {
return &Gateway{orders: orders, refunds: refunds, providers: providers, picker: picker,
products: products, webhook: webhook, region: region}
products: products, webhook: webhook, region: region, subs: subs, chargebacks: chargebacks}
}
type CreateOrderInput struct {
+24 -2
View File
@@ -35,7 +35,15 @@ func (stubResolver) Resolve(sku, currency string) (int64, string, string, error)
type spyEnqueuer struct {
calls []map[string]any
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用),命中后消费一次即清零。
// failOnEventType 非空时,下一次遇到该 event_type 的入队调用失败一次(消费后清空)——用于
// 测试"同一 HandleCallback 内先后两次 Enqueue,只让第二次(如 subscription.created)失败,
// 第一次(如 payment.succeeded)先成功"这类场景,failNext 做不到按 event_type 区分。
failOnEventType string
seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type,
// refund_id) 唯一键 ON CONFLICT DO NOTHING:重复 key 静默 no-op(不追加 calls,也不算失败)。
// 修复"重投补入队自愈"后,业务代码会在 duplicate/重投分支也调用 Enqueue,若 spy 仍是无脑
// 计数器就会把 outbox 天然幂等的重复行误判成"多发了一次 webhook",这里镜像真实幂等语义。
}
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
@@ -43,6 +51,18 @@ func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string,
s.failNext = false
return errors.New("outbox down")
}
if s.failOnEventType != "" && eventType == s.failOnEventType {
s.failOnEventType = ""
return errors.New("outbox down")
}
key := outTradeNo + "|" + eventType + "|" + refundID
if s.seen == nil {
s.seen = make(map[string]bool)
}
if s.seen[key] {
return nil // 幂等 no-op,行已存在
}
s.seen[key] = true
s.calls = append(s.calls, data)
return nil
}
@@ -52,6 +72,8 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
@@ -62,7 +84,7 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
})
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders
}
+154 -2
View File
@@ -96,7 +96,9 @@ func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt, paidAt time.Time)
}
// HandleCallback runs a channel's raw callback through its Provider.VerifyCallback
// (验签/解析封死在渠道内) then settles the normalized event.
// (验签/解析封死在渠道内) then dispatches the normalized event by Kind(设计 §5):
// 一次性/首期支付走既有 Settle(零 fork);订阅续费/催收/取消/拒付各有专属处理器
// (Task 4/5/6,本 Task 先占 stub 保证独立可编译)。
func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) {
prov, err := g.providers.Get(method)
if err != nil {
@@ -106,7 +108,157 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider
if err != nil {
return SettleNotFound, err
}
return g.Settle(ctx, ev)
switch ev.Kind {
case provider.EventSubscriptionRenewal:
return g.settleRenewal(ctx, method, ev) // Task 4
case provider.EventSubscriptionPastDue:
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
case provider.EventSubscriptionCanceled:
return g.settleSubscriptionCanceled(ctx, method, ev) // Task 5
case provider.EventChargeback:
return g.recordChargeback(ctx, method, ev) // Task 6
default: // EventPayment:一次性 / 订阅首期
res, serr := g.Settle(ctx, ev)
// 门必须按结算结果开,不能只看 serr==nil:Settle 对非成功状态(如 Stripe
// checkout.session.completed 的 payment_status=unpaid 异步支付)返回
// (SettleIgnored, nil)——serr 为 nil 但订单并未结算,不能诞生订阅。
// Duplicate = 成功结算的重放(已 paid/已终态),安全且保证重投幂等路径仍能补建订阅行。
if (res == SettleProcessed || res == SettleDuplicate) && ev.SubscriptionRef != "" {
if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil {
return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投)
}
}
return res, serr
}
}
// --- Task 6 处理器占位(Task 4 续费/Task 5 past_due+取消已替换实现,见 subscription.go)。
// settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2
// (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。
// 幂等靠 renewal attempt 的 (channel,provider_ref=invoice.ID) 唯一索引 + renewal order 的
// out_trade_no 唯一索引双保险。
//
// 入队纪律(与文件头 Settle 的顺序不变量同根同源,但形态不同——续费建单是"单步已 paid",
// 没有 Settle 那种"翻转严格发生在成功入队之后"的两段式可依赖):**duplicate 分支(created=
// false)也必须尝试入队**,不能像旧实现那样直接 return。理由:首次入队若瞬时失败,订单/attempt
// 已在同一事务内落为 paid(不回滚,幂等键护着),而 outbox 从未有行;Stripe 拿不到 200 会重投
// 同一 invoice.paid,此时 created 必为 false——若 duplicate 分支不入队,该续费通知永久丢失
// (SyncPendingAttempts 也救不了:续费 attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
// outbox 唯一键 (out_trade_no,event_type,refund_id) 上 ON CONFLICT DO NOTHING 天然幂等:行已
// 存在则本次 no-op,行曾丢失则本次补建——重投即自愈,任何一分支入队失败都仍返回 SettleFailed
// 交渠道再重投。
func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
// channel = 触发本次回调的 method(与 markSubscriptionPastDue/recordChargeback 同式);
// 订阅诞生时 Subscription.Channel 落的正是 att.Channel=method,查询须对齐,不能硬编码字面量
// "stripe"(测试固定用 "substripe" 注册子供应商,生产 Stripe 适配器 Method()="stripe")。
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef)
return SettleIgnored, nil
}
return SettleFailed, err
}
paidAt := time.Now()
if ev.PaidAt != nil {
paidAt = *ev.PaidAt
}
renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef
created, err := g.orders.CreateRenewalPaid(
&model.OrderV2{
OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode,
Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency,
Status: model.OrderPaidV2, PaidAt: &paidAt,
},
&model.Attempt{
OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef,
AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt,
})
if err != nil {
return SettleFailed, err
}
// 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。
nextEnd := paidAt.Add(30 * 24 * time.Hour)
if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil {
return SettleFailed, err
}
// 无论 created 与否都尝试入队(见函数注释的入队纪律);独立收款(BizSystem=="")无下游可发,跳过。
if sub.BizSystem != "" {
if err := g.enqueueRenewed(sub, renewalNo, ev, paidAt); err != nil {
return SettleFailed, err // 已建单的事实不回滚(幂等键护着);渠道重投时补入队
}
}
if !created {
return SettleDuplicate, nil // 重投:订单/attempt 已在(幂等 no-op),outbox 已补齐
}
return SettleProcessed, nil // 首过(含无业务方的独立收款,对齐 enqueuePaymentSucceeded 语义)
}
// enqueueRenewed 组 subscription.renewed 领域 payload 并幂等入队(抽出供 settleRenewal 的
// created/duplicate 两分支共用,避免复制两份 payload 构造)。
func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *provider.PaidEvent, paidAt time.Time) error {
return g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{
"event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel,
"paid_at": paidAt.Format(time.RFC3339),
})
}
// recordChargeback 处理入站 charge.dispute.created(P8 Task6,设计 §6「钱到账不可逆」的
// 例外形态)。只 Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款永无
// chargeback——三者均不产出 EventChargeback,本函数只会被 stripe adapter 触发。
//
// 决策记录:不自动回收权益(§6,业务方裁量),只做三件事——①落 Chargeback(幂等 by
// dispute_ref,DisputeRef 重投 no-op,不双记)②给原订单打 Disputed 标(不改状态机)
// ③入队 chargeback.received 给业务方自行冲正。out_trade_no 解析失败(订阅拒付/查单失败)
// 时仍落 Chargeback 留痕 + log 告警,但不阻断、不转发(无法定位业务单)。
//
// 入队不按 created 分叉(与 settleRenewal/finalizeCanceled/markSubscriptionPastDue 同型的
// P8 反纪律修法):Chargeback.Create 与 Enqueue 是两次独立写,不在同一事务——若首次 Create
// 成功但 Enqueue 瞬时失败,调用方(HandleCallback)拿到 err 后 Stripe 会重投同一
// charge.dispute.created,此时 created 必为 false;若像"created=false→直接 return
// SettleDuplicate"那样跳过下面的打标+入队,chargeback.received 通知永久丢失、Disputed
// 标也永远打不上。改为无论 created 与否都走完打标+入队,outbox 唯一键 ON CONFLICT DO
// NOTHING + MarkDisputed 条件 UPDATE 天然双重幂等——重投即自愈,不会双记/双发。
func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
created, err := g.chargebacks.Create(&model.Chargeback{
DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method,
ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor,
Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received",
})
if err != nil {
return SettleFailed, err
}
result := SettleProcessed
if !created {
result = SettleDuplicate // 拒付重投:Chargeback 已记录过,但仍需补齐下面的打标/入队(见函数注释)
}
if ev.OutTradeNo == "" {
log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef)
return result, nil
}
o, err := g.orders.GetOrder(ev.OutTradeNo)
if err != nil {
log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err)
return result, nil // 已记录 chargeback;定位失败不阻断
}
if _, err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机;条件 UPDATE 幂等
return SettleFailed, err
}
if o.BizSystem == "" {
return result, nil
}
if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, "", map[string]any{
"event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef,
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
"amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason,
"received_at": time.Now().Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
return result, nil
}
// SyncPendingAttempts polls every pending attempt via its Provider.Query and
+3 -1
View File
@@ -138,6 +138,8 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
@@ -146,7 +148,7 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
+278
View File
@@ -0,0 +1,278 @@
package gateway
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
"github.com/wangjia/pay/internal/accounts"
)
// webhook event 常量集中在 internal/gateway(Task 7 收敛业务方声明)。
const (
EvtPaymentSucceeded = "payment.succeeded"
EvtSubscriptionCreated = "subscription.created"
EvtSubscriptionRenewed = "subscription.renewed"
EvtSubscriptionPastDue = "subscription.past_due"
EvtSubscriptionCanceled = "subscription.canceled"
EvtChargebackReceived = "chargeback.received"
)
type CreateSubscriptionInput struct {
SKU string
Method string
BizSystem string
BizRef string
ReturnURL string
}
type SubscriptionResult struct {
SubID string `json:"sub_id"`
OrderNo string `json:"order_no"`
Session SessionView `json:"session"`
}
// CreateSubscription 解析套餐权威金额 → 选 stripe 账户(须实现 SubscriptionProvider 且
// SupportsRecurring)→ 落 pending 首购 OrderV2 + Attempt → 返回订阅 Checkout redirect。
// 订阅在首期支付回调时诞生(见 onSubscriptionActivated)。
func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) {
prov, err := g.providers.Get(in.Method)
if err != nil {
return nil, err
}
caps := prov.Capabilities()
subProv, ok := prov.(provider.SubscriptionProvider)
if !ok || !caps.SupportsRecurring {
return nil, provider.ErrNotSupported
}
if len(caps.SettleCurrencies) == 0 {
return nil, ErrNoSettleCurrency
}
currency := caps.SettleCurrencies[0]
amount, subject, bizCode, err := g.products.Resolve(in.SKU, currency)
if err != nil {
return nil, err // ErrProductNotFound(含"该币种无价")
}
outTradeNo := util.NewOutTradeNo("pay")
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{OutTradeNo: outTradeNo, AmountMinor: amount})
if err != nil {
if errors.Is(err, accounts.ErrNoAccount) {
return nil, ErrNoAccount
}
return nil, err
}
// SubID 确定性派生自 out_trade_no(与 onSubscriptionActivated 一致,消除双号)。
subID := "SUB-" + outTradeNo
sess, err := subProv.CreateSubscriptionCheckout(ctx, provider.CreateRequest{
OutTradeNo: outTradeNo, Subject: subject, AmountMinor: amount, Currency: currency,
Account: acct, ReturnURL: in.ReturnURL, Metadata: map[string]string{"pay_sub_id": subID},
})
if err != nil {
return nil, fmt.Errorf("gateway.CreateSubscription: %w", err)
}
order := &model.OrderV2{
OutTradeNo: outTradeNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode,
Subject: subject, AmountMinor: amount, Currency: currency, Status: model.OrderPendingV2,
}
if err := g.orders.CreateOrder(order); err != nil {
return nil, err
}
att := &model.Attempt{
OutTradeNo: outTradeNo, Channel: in.Method, AccountID: acct.AccountID, Provider: in.Method,
ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType),
AmountMinor: amount, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt,
}
if err := g.orders.CreateAttempt(att); err != nil {
return nil, err
}
return &SubscriptionResult{
SubID: subID, OrderNo: outTradeNo,
Session: SessionView{RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt},
}, nil
}
// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。
// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。
// 入队失败原样返回 err,调用方(HandleCallback)映射 SettleFailed,驱动 Stripe 侧重投自愈
// (见下方入队处注释)。
func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error {
att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
if err != nil {
return nil // 首期会话未落库(不该发生);交由 Settle 侧日志,订阅侧静默
}
o, err := g.orders.GetOrder(att.OutTradeNo)
if err != nil {
return err
}
if !o.Status.Settled() {
// 纵深防御:HandleCallback 的门(res==Processed/Duplicate)已挡住未结算订单,
// 这里再核一遍订单状态,防止未来调用点绕过门直接调用本函数。
log.Printf("[subscription] onSubscriptionActivated 订单未结算 out_trade_no=%s status=%s,拒绝激活", o.OutTradeNo, o.Status)
return nil
}
subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
// created(首次诞生 true / 重投幂等 false)不再驱动入队分叉,见下方入队处注释——不必单独取用。
_, err = g.subs.Create(&model.Subscription{
SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode,
Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled,
AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive,
})
if err != nil {
return err
}
if o.BizSystem == "" {
return nil // 独立收款无业务方回调
}
// 入队不按 created 分叉(P8 Task4/5 审计发现的同型反纪律,同 commit 一并修——settleRenewal/
// finalizeCanceled/markSubscriptionPastDue 已是这个写法):若首次 Create 成功但入队瞬时失败,
// 调用方(HandleCallback)拿到 err 后 Stripe 会重投同一 checkout.session.completed;此时
// subs.Create 已幂等、created=false,若像旧实现那样 `!created→return nil` 直接跳过,outbox
// 永远补不上这一行,subscription.created 通知永久丢失。改为无论 created 与否都尝试入队,
// outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:行已存在则 no-op,行曾丢失则本次补建。
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, "", map[string]any{
"event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID,
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
"amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel,
"created_at": time.Now().Format(time.RFC3339),
})
}
// SubscriptionView — GET /api/v2/subscriptions/:sub_id 返回视图(状态 + 续费锚点)。
type SubscriptionView struct {
SubID string `json:"sub_id"`
Status string `json:"status"`
CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"`
CanceledAt *time.Time `json:"canceled_at,omitempty"`
}
func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) {
sub, err := g.subs.GetBySubID(subID)
if err != nil {
return nil, err // store.ErrSubNotFound
}
return &SubscriptionView{
SubID: sub.SubID, Status: string(sub.Status),
CurrentPeriodEnd: sub.CurrentPeriodEnd, CanceledAt: sub.CanceledAt,
}, nil
}
// CancelSubscription 主动取消(POST /api/v2/subscriptions/:sub_id/cancel):查订阅 → 已终态则
// 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队
// subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器
// (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。
//
// 本地 active/past_due 但渠道已先行取消(dashboard 手工取消 / 竞态下未消费的 deleted webhook
// 抢先落地)时,渠道会拒绝二次 Cancel;adapter 将其识别并 wrap 成 provider.ErrSubAlreadyCanceled
// (errors.Is 可判)。这不是"取消失败"而是"取消事实已在渠道成立"——按此哨兵走与入站 webhook
// 相同的本地收敛(finalizeCanceled),而非把渠道拒绝原样透传成 500。与随后可能补投的
// customer.subscription.deleted 双路收敛于同一 finalizeCanceled:MarkCanceled 是状态条件
// UPDATE(只在非 canceled 时翻转)+ outbox 唯一键 ON CONFLICT DO NOTHING,谁先谁后都幂等。
func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
sub, err := g.subs.GetBySubID(subID)
if err != nil {
return err // store.ErrSubNotFound
}
if sub.Status == model.SubCanceled {
return nil // 已取消(重复调用/webhook 已先到):幂等 no-op,不再打渠道
}
prov, err := g.providers.Get(sub.Channel)
if err != nil {
return err
}
if sp, ok := prov.(provider.SubscriptionProvider); ok {
if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil && !errors.Is(err, provider.ErrSubAlreadyCanceled) {
return err
}
}
return g.finalizeCanceled(sub)
}
// settleSubscriptionCanceled 处理入站 customer.subscription.deleted:反查订阅 → finalizeCanceled。
// 未知 provider_sub_ref(订阅未诞生/已清理)忽略,不报错(与 settleRenewal 同惯例)。
func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
return SettleIgnored, nil
}
return SettleFailed, err
}
if err := g.finalizeCanceled(sub); err != nil {
return SettleFailed, err
}
return SettleProcessed, nil
}
// finalizeCanceled 是取消的唯一落地点(主动取消 API + 入站 webhook 两路收敛于此):
// MarkCanceled 幂等翻转 canceled,随后(有业务方时)入队 subscription.canceled。
//
// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):若第一次翻转
// 成功但入队瞬时失败,调用方拿到 err 后重投——此时 MarkCanceled 已是"已取消"故 flipped=
// false,若像旧实现那样 `!flipped→return nil` 直接跳过,outbox 永远补不上这一行,通知永久
// 丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:
// 行已存在则 no-op,行曾丢失则本次补建。canceled 每订阅只发生一次,不存在"同订阅多次 canceled
// 真被吞"的歧义(不同于 past_due 可能有多次不同 invoice 失败),无条件入队是安全的。
//
// outbox 键取真实首购单号 sub.OutTradeNo(不用合成后缀键):该单已 paid,Notifier 投递门禁
// (orderPaid)天然放行;event_type=subscription.canceled 与该单已有的 payment.succeeded /
// subscription.created 行不同 event_type,唯一键 (out_trade_no,event_type,refund_id) 不撞。
func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
if _, err := g.subs.MarkCanceled(sub.SubID); err != nil {
return err
}
if sub.BizSystem == "" {
return nil // 独立收款无业务方回调
}
return g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionCanceled, "", map[string]any{
"event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"canceled_at": time.Now().Format(time.RFC3339),
})
}
// markSubscriptionPastDue 处理入站 invoice.payment_failed:active→past_due(MarkPastDue 只在
// 当前 active 时翻转,已 past_due/已 canceled 不动),入队 subscription.past_due 供业务方提醒
// 用户换卡。下期 invoice.paid 成功由 settleRenewal 的 Activate 自动恢复 active。
//
// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):首次翻转成功但
// 入队瞬时失败时,重投这个同一事件会命中 flipped=false(已是 past_due),旧实现直接 return
// 会让这条 past_due 通知永久丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT
// DO NOTHING 天然幂等——重投即自愈。
//
// outbox 键取真实首购单号 sub.OutTradeNo(理由同 finalizeCanceled):Task5 最小可行接受
// "同订阅多次 past_due(不同 invoice 各自失败)只报首次"这一语义限制不变——第二次真实失败的
// 行同样撞 (out_trade_no,event_type) 唯一键、幂等 no-op,Task 7 视需要再引入合成键 + Notifier
// 门禁旁路;这里修的只是"同一事件重投也补不回入队"这一段自愈缺口,不扩大既有限制的范围。
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
if err != nil {
if errors.Is(err, store.ErrSubNotFound) {
return SettleIgnored, nil
}
return SettleFailed, err
}
flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef)
if err != nil {
return SettleFailed, err
}
if sub.BizSystem != "" {
if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{
"event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
"biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
"failed_at": time.Now().Format(time.RFC3339),
}); err != nil {
return SettleFailed, err
}
}
if !flipped {
return SettleDuplicate, nil
}
return SettleProcessed, nil
}
+947
View File
@@ -0,0 +1,947 @@
package gateway_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
)
// fakeSubProvider 实现 provider.SubscriptionProvider:创建订阅 Checkout 返回固定 session。
// VerifyCallback 不经它——测试直接构造 PaidEvent 走 Settle/HandleCallback 的 default 分支
// 时也走它(HandleCallback 仍需先过 VerifyCallback 才能拿到 ev),这里让它原样透传注入的
// JSON 回调体(与 fake.Provider.VerifyCallback 同构,便于测试直接摆事件)。
type fakeSubProvider struct {
sessionRef string
cancelCalls []string // 记录 CancelSubscription 收到的 providerSubRef,断言调用次数/参数
cancelErr error // 非空时 CancelSubscription 返回该 err(模拟渠道对"已取消订阅"回 400)
}
func (p *fakeSubProvider) Method() string { return "substripe" }
func (p *fakeSubProvider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRecurring: true,
RecurringKind: provider.RecurringKindGatewayScheduled,
SettleCurrencies: []string{"USD"},
Regions: []string{"global"},
}
}
func (p *fakeSubProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
return nil, errors.New("fakeSubProvider: one-time Create not used")
}
func (p *fakeSubProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
return &provider.Session{
ProviderRef: p.sessionRef,
RenderType: provider.RenderRedirect,
Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef, "amount_minor": req.AmountMinor},
}, nil
}
func (p *fakeSubProvider) CancelSubscription(_ context.Context, providerSubRef string) error {
p.cancelCalls = append(p.cancelCalls, providerSubRef)
return p.cancelErr
}
// VerifyCallback 直接把测试构造的 provider.PaidEvent JSON 反序列化透传回放,省去自建协议。
func (p *fakeSubProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
var ev provider.PaidEvent
if err := json.Unmarshal(in.Raw, &ev); err != nil {
return nil, err
}
return &ev, nil
}
func (p *fakeSubProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
}
func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueuer, *store.OrderStore, *store.SubscriptionStore) {
t.Helper()
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := &fakeSubProvider{sessionRef: "cs_test_sess1"}
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{
{AccountID: "sub-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders, subs
}
type stubSubResolver struct{}
func (stubSubResolver) Resolve(sku, currency string) (int64, string, string, error) {
if sku != "pro_monthly" {
return 0, "", "", gateway.ErrProductNotFound
}
if currency != "USD" {
return 0, "", "", gateway.ErrProductNotFound
}
return 2999, "Pro 月付", "pro_monthly", nil
}
func TestCreateSubscriptionPipeline(t *testing.T) {
g, fp, _, orders, _ := newSubGateway(t)
res, err := g.CreateSubscription(context.Background(), gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
if res.SubID == "" || res.OrderNo == "" {
t.Fatalf("result = %+v", res)
}
if res.Session.RenderType != string(provider.RenderRedirect) {
t.Fatalf("session = %+v", res.Session)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 2999 || o.Currency != "USD" {
t.Fatalf("order = %+v, %v", o, err)
}
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(atts) != 1 || atts[0].ProviderRef != fp.sessionRef {
t.Fatalf("attempt = %+v", atts)
}
}
func TestSubscriptionActivationOnFirstPayment(t *testing.T) {
g, fp, spy, orders, subs := newSubGateway(t)
ctx := context.Background()
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal event: %v", err)
}
// 首次:order 翻 paid + 订阅诞生 active + webhook 收到 2 条(payment.succeeded + subscription.created)。
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback = %v, %v", result, err)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPaidV2 {
t.Fatalf("order after settle = %+v, %v", o, err)
}
sub, err := subs.GetByProviderRef("substripe", "sub_new")
if err != nil {
t.Fatalf("subscription not created: %v", err)
}
if sub.Status != model.SubActive || sub.SubID != res.SubID {
t.Fatalf("subscription = %+v, want active/%s", sub, res.SubID)
}
if len(spy.calls) != 2 {
t.Fatalf("webhook calls = %d, want 2: %+v", len(spy.calls), spy.calls)
}
sawPaymentSucceeded, sawSubCreated := false, false
for _, c := range spy.calls {
switch c["event_type"] {
case gateway.EvtPaymentSucceeded:
sawPaymentSucceeded = true
case gateway.EvtSubscriptionCreated:
sawSubCreated = true
if c["sub_id"] != res.SubID {
t.Fatalf("subscription.created sub_id = %v, want %s", c["sub_id"], res.SubID)
}
}
}
if !sawPaymentSucceeded || !sawSubCreated {
t.Fatalf("missing expected events: %+v", spy.calls)
}
// 重投同一 event → 幂等:订单/订阅不重复变动,webhook 不再新增。
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback replay: %v", err)
}
if result2 != gateway.SettleDuplicate {
t.Fatalf("replay result = %v, want duplicate", result2)
}
if len(spy.calls) != 2 {
t.Fatalf("webhook calls after replay = %d, want still 2: %+v", len(spy.calls), spy.calls)
}
subAfter, err := subs.GetByProviderRef("substripe", "sub_new")
if err != nil {
t.Fatalf("subscription after replay: %v", err)
}
if subAfter.ID != sub.ID {
t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub)
}
}
// TestSettleRenewal 先经首期激活诞生订阅(provider_sub_ref=sub_new),再喂
// EventSubscriptionRenewal:①新增 renewal OrderV2(paid,out_trade_no 含 -r-)
// ②webhook 收到 subscription.renewed ③重投幂等(不双铸/不双发) ④订阅刷新 current_period_end、
// 从 past_due 恢复 active。
func TestSettleRenewal(t *testing.T) {
g, fp, spy, orders, subs := newSubGateway(t)
ctx := context.Background()
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
activateRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal activate event: %v", err)
}
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil {
t.Fatalf("activate: %v", err)
}
spy.calls = nil // 只看续费产生的 webhook
// 续费成功前先把订阅打成 past_due,验证续费成功后能恢复 active(路径复用 Activate)。
if ok, err := subs.MarkPastDue("substripe", "sub_new"); err != nil || !ok {
t.Fatalf("MarkPastDue: ok=%v err=%v", ok, err)
}
renewalRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_123",
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
})
if err != nil {
t.Fatalf("marshal renewal event: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback renewal = %v, %v", result, err)
}
allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
if err != nil {
t.Fatalf("ListOrders: %v", err)
}
var renewalOrder *model.OrderV2
for i := range allOrders {
if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
renewalOrder = &allOrders[i]
}
}
if renewalOrder == nil {
t.Fatalf("renewal order not found: %+v", allOrders)
}
if renewalOrder.Status != model.OrderPaidV2 || renewalOrder.AmountMinor != 2999 || renewalOrder.Currency != "USD" {
t.Fatalf("renewal order = %+v", renewalOrder)
}
if !strings.HasSuffix(renewalOrder.OutTradeNo, "-r-in_123") {
t.Fatalf("renewal out_trade_no = %s, want suffix -r-in_123", renewalOrder.OutTradeNo)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
renewedCall := spy.calls[0]
if renewedCall["event_type"] != gateway.EvtSubscriptionRenewed {
t.Fatalf("event_type = %v, want %s", renewedCall["event_type"], gateway.EvtSubscriptionRenewed)
}
if renewedCall["sub_id"] != res.SubID {
t.Fatalf("sub_id = %v, want %s", renewedCall["sub_id"], res.SubID)
}
if renewedCall["out_trade_no"] != renewalOrder.OutTradeNo {
t.Fatalf("out_trade_no = %v, want %s", renewedCall["out_trade_no"], renewalOrder.OutTradeNo)
}
if renewedCall["amount_minor"] != int64(2999) {
t.Fatalf("amount_minor = %v, want 2999", renewedCall["amount_minor"])
}
sub, err := subs.GetByProviderRef("substripe", "sub_new")
if err != nil {
t.Fatalf("GetByProviderRef: %v", err)
}
if sub.Status != model.SubActive {
t.Fatalf("subscription status after renewal = %v, want active(recovered from past_due)", sub.Status)
}
if sub.CurrentPeriodEnd == nil {
t.Fatalf("current_period_end not refreshed")
}
// 重投同一 invoice → 不双铸 renewal order、不双发 webhook。
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err != nil {
t.Fatalf("HandleCallback renewal replay: %v", err)
}
if result2 != gateway.SettleDuplicate {
t.Fatalf("replay result = %v, want duplicate", result2)
}
ordersAfterReplay, err := orders.ListOrders("pangolin", "u-1", 10)
if err != nil {
t.Fatalf("ListOrders after replay: %v", err)
}
renewalCount := 0
for i := range ordersAfterReplay {
if strings.Contains(ordersAfterReplay[i].OutTradeNo, "-r-") {
renewalCount++
}
}
if renewalCount != 1 {
t.Fatalf("renewal order count after replay = %d, want 1", renewalCount)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
}
}
// TestSubscriptionNotActivatedOnUnpaidSession 复现 CRITICAL 发现:Stripe checkout.session.completed
// 在异步支付方式下可能 payment_status=unpaid(映射为 PaidPending)先到达,此时 Settle 返回
// SettleIgnored——订阅诞生钩子必须跟随结算结果关门,不能只看 serr==nil(nil error 但未结算)。
// 断言:无 Subscription 行诞生、无 subscription.created 入队、订单仍 pending。
func TestSubscriptionNotActivatedOnUnpaidSession(t *testing.T) {
g, fp, spy, orders, subs := newSubGateway(t)
ctx := context.Background()
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
// checkout.session.completed(异步支付,payment_status=unpaid)归一化为 PaidPending。
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidPending,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal event: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
if result != gateway.SettleIgnored {
t.Fatalf("result = %v, want ignored", result)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPendingV2 {
t.Fatalf("order after unpaid callback = %+v, %v, want still pending", o, err)
}
if _, err := subs.GetByProviderRef("substripe", "sub_new"); !errors.Is(err, store.ErrSubNotFound) {
t.Fatalf("subscription should not exist for unpaid session, err = %v", err)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0(no premature subscription.created): %+v", len(spy.calls), spy.calls)
}
}
// TestSettleRenewalUnknownSubscription 未知 provider_sub_ref(订阅未诞生/已清理)→ 忽略,不报错。
func TestSettleRenewalUnknownSubscription(t *testing.T) {
g, _, spy, _, _ := newSubGateway(t)
ctx := context.Background()
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost",
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
if result != gateway.SettleIgnored {
t.Fatalf("result = %v, want ignored", result)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
}
}
// activateSub 建订阅 + 喂首期支付事件,把订阅推进到 active,返回 (subID, providerSubRef, orderNo)。
// providerSubRef 固定用 "sub_new"(下面取消/past_due 用例复用同一渠道订阅号反查)。
func activateSub(t *testing.T, g *gateway.Gateway, fp *fakeSubProvider) (subID, providerSubRef, orderNo string) {
t.Helper()
ctx := context.Background()
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal activate event: %v", err)
}
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil {
t.Fatalf("activate: %v", err)
}
return res.SubID, "sub_new", res.OrderNo
}
// TestCancelSubscriptionAPI 覆盖主动取消 API 路径:调渠道 CancelSubscription(带正确
// providerSubRef)→ 本地翻 canceled → 入队 subscription.canceled(out_trade_no 用真实首购
// 单号,supporting Notifier 投递门禁天然放行 —— 见 subscription.go finalizeCanceled 注释)。
func TestCancelSubscriptionAPI(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
subID, providerSubRef, orderNo := activateSub(t, g, fp)
spy.calls = nil // 只看取消产生的 webhook
if err := g.CancelSubscription(context.Background(), subID); err != nil {
t.Fatalf("CancelSubscription: %v", err)
}
if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef {
t.Fatalf("cancelCalls = %+v, want [%s]", fp.cancelCalls, providerSubRef)
}
sub, err := subs.GetBySubID(subID)
if err != nil {
t.Fatalf("GetBySubID: %v", err)
}
if sub.Status != model.SubCanceled || sub.CanceledAt == nil {
t.Fatalf("subscription after cancel = %+v", sub)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
c := spy.calls[0]
if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
c, gateway.EvtSubscriptionCanceled, subID, orderNo)
}
}
// TestCancelSubscriptionIdempotentNoOp 重复调用主动取消 API:第二次调用命中 sub.Status
// 已 canceled 的早退分支,绝不重新打渠道(渠道对已取消订阅二次 Cancel 会回 400 —— 调用层
// 靠"先查本地终态"归一为幂等 no-op,压根不给渠道二次调用的机会),也不重复入队。
func TestCancelSubscriptionIdempotentNoOp(t *testing.T) {
g, fp, spy, _, _ := newSubGateway(t)
subID, _, _ := activateSub(t, g, fp)
spy.calls = nil
if err := g.CancelSubscription(context.Background(), subID); err != nil {
t.Fatalf("cancel#1: %v", err)
}
if err := g.CancelSubscription(context.Background(), subID); err != nil {
t.Fatalf("cancel#2(重复调用应幂等 no-op,不应报错): %v", err)
}
if len(fp.cancelCalls) != 1 {
t.Fatalf("cancelCalls = %+v, want exactly 1(第二次不应打渠道)", fp.cancelCalls)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1(不重复入队)", len(spy.calls))
}
}
// TestCancelSubscriptionChannelAlreadyCanceledConverges 覆盖渠道已先行取消(dashboard 手工 /
// 竞态下未消费的 deleted webhook 抢先落地)的失配场景:本地仍 active,adapter 打渠道拿到
// provider.ErrSubAlreadyCanceled(此处用死脚手架 fp.cancelErr 模拟 stripe adapter 已 wrap 好的
// 哨兵错误)——CancelSubscription 不应再报错(修复前会把这个 err 原样透传,handler 会映成 500
// cancel_failed;修复后按哨兵走本地收敛),本地翻 canceled 且恰好入队一次 subscription.canceled。
func TestCancelSubscriptionChannelAlreadyCanceledConverges(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
subID, providerSubRef, orderNo := activateSub(t, g, fp)
spy.calls = nil
fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
if err := g.CancelSubscription(context.Background(), subID); err != nil {
t.Fatalf("CancelSubscription(渠道已取消场景应本地收敛,不应报错): %v", err)
}
if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef {
t.Fatalf("cancelCalls = %+v, want [%s](渠道确实被打过一次,只是回了'已取消'错误)", fp.cancelCalls, providerSubRef)
}
sub, err := subs.GetBySubID(subID)
if err != nil {
t.Fatalf("GetBySubID: %v", err)
}
if sub.Status != model.SubCanceled || sub.CanceledAt == nil {
t.Fatalf("subscription after cancel(渠道失配场景) = %+v, want locally canceled", sub)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
c := spy.calls[0]
if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
c, gateway.EvtSubscriptionCanceled, subID, orderNo)
}
}
// TestCancelSubscriptionWebhookThenAPIConverge 覆盖反向竞态:入站 customer.subscription.deleted
// 先到(本地先翻 canceled + 发一次 webhook),随后业务方/用户侧发起的主动取消 API 才姗姗来迟。
// 此时本地已是终态,CancelSubscription 应在打渠道之前就早退(sub.Status==canceled 分支),
// 绝不二次调用渠道、也绝不重复入队——即便 fp.cancelErr 被设成"渠道已取消"哨兵(模拟万一实现
// 顺序有误、真打了渠道也不该出错),结果仍应是幂等 no-op。
func TestCancelSubscriptionWebhookThenAPIConverge(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
subID, providerSubRef, _ := activateSub(t, g, fp)
spy.calls = nil
fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback deleted(webhook 先到) = %v, %v", result, err)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after inbound deleted = %d, want 1", len(spy.calls))
}
if err := g.CancelSubscription(ctx, subID); err != nil {
t.Fatalf("CancelSubscription(webhook 已先到,应幂等 no-op 不报错): %v", err)
}
if len(fp.cancelCalls) != 0 {
t.Fatalf("cancelCalls = %+v, want 0(本地已终态,不应再打渠道)", fp.cancelCalls)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after API cancel(webhook 先到之后) = %d, want still 1(不重复入队)", len(spy.calls))
}
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubCanceled {
t.Fatalf("subscription = %+v, %v", sub, err)
}
}
// TestInboundSubscriptionDeletedConverges 覆盖入站 customer.subscription.deleted:同订阅
// 幂等标 canceled + 入队一次;重投(渠道 webhook 常见重投)不重复发 webhook。
func TestInboundSubscriptionDeletedConverges(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
subID, providerSubRef, orderNo := activateSub(t, g, fp)
spy.calls = nil
raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback deleted#1 = %v, %v", result, err)
}
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubCanceled {
t.Fatalf("subscription after inbound deleted = %+v, %v", sub, err)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
if spy.calls[0]["out_trade_no"] != orderNo {
t.Fatalf("out_trade_no = %v, want %s", spy.calls[0]["out_trade_no"], orderNo)
}
// 重投同一 customer.subscription.deleted → 幂等,不再新增 webhook。
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback deleted#2: %v", err)
}
if result2 != gateway.SettleProcessed {
t.Fatalf("replay result = %v, want processed(finalizeCanceled 内部幂等,外层结果按 brief 恒 processed)", result2)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
}
}
// TestCancelAPIThenInboundDeletedConverge 两路收敛同一终态:先走主动取消 API(本地先翻
// canceled + 发 webhook),Stripe 随后异步补投 customer.subscription.deleted → 命中同一
// finalizeCanceled 的幂等分支,不重复发 webhook。
func TestCancelAPIThenInboundDeletedConverge(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
subID, providerSubRef, _ := activateSub(t, g, fp)
spy.calls = nil
if err := g.CancelSubscription(ctx, subID); err != nil {
t.Fatalf("CancelSubscription: %v", err)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after API cancel = %d, want 1", len(spy.calls))
}
raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback deleted after API cancel = %v, %v", result, err)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after inbound deleted = %d, want still 1(两路收敛,不重复发)", len(spy.calls))
}
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubCanceled {
t.Fatalf("subscription = %+v, %v", sub, err)
}
}
// TestSettleSubscriptionCanceledUnknownSubscription 未知 provider_sub_ref → 忽略,不报错
// (与 TestSettleRenewalUnknownSubscription 同惯例)。
func TestSettleSubscriptionCanceledUnknownSubscription(t *testing.T) {
g, _, spy, _, _ := newSubGateway(t)
raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: "sub_ghost"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
if result != gateway.SettleIgnored {
t.Fatalf("result = %v, want ignored", result)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
}
}
// TestMarkSubscriptionPastDue 覆盖 invoice.payment_failed → active 翻 past_due + 入队
// subscription.past_due(out_trade_no 用真实首购单号)。
func TestMarkSubscriptionPastDue(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
subID, providerSubRef, orderNo := activateSub(t, g, fp)
spy.calls = nil
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback past_due = %v, %v", result, err)
}
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubPastDue {
t.Fatalf("subscription after past_due = %+v, %v", sub, err)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
}
c := spy.calls[0]
if c["event_type"] != gateway.EvtSubscriptionPastDue || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
t.Fatalf("past_due webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
c, gateway.EvtSubscriptionPastDue, subID, orderNo)
}
}
// TestMarkSubscriptionPastDueUnknownSubscription 未知 provider_sub_ref → 忽略,不报错。
func TestMarkSubscriptionPastDueUnknownSubscription(t *testing.T) {
g, _, spy, _, _ := newSubGateway(t)
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionPastDue, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback: %v", err)
}
if result != gateway.SettleIgnored {
t.Fatalf("result = %v, want ignored", result)
}
if len(spy.calls) != 0 {
t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
}
}
// TestPastDueThenRenewalRecovers 端到端串联 past_due 决策记录的完整路径:invoice.payment_failed
// → past_due(+ 入队 subscription.past_due);随后 invoice.paid(subscription_cycle)→ settleRenewal
// 的 Activate 自动恢复 active,续费锚点刷新。与 T4 的 TestSettleRenewal 不同,这里用真实
// markSubscriptionPastDue 事件路径推进到 past_due(不直接调 subs.MarkPastDue 抄近道)。
func TestPastDueThenRenewalRecovers(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
subID, providerSubRef, _ := activateSub(t, g, fp)
spy.calls = nil
failedRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
})
if err != nil {
t.Fatalf("marshal past_due: %v", err)
}
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: failedRaw}); err != nil {
t.Fatalf("HandleCallback past_due: %v", err)
}
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubPastDue {
t.Fatalf("subscription after past_due = %+v, %v", sub, err)
}
renewalRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: providerSubRef, InvoiceRef: "in_recover_1",
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
})
if err != nil {
t.Fatalf("marshal renewal: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("HandleCallback renewal recovery = %v, %v", result, err)
}
subAfter, err := subs.GetBySubID(subID)
if err != nil || subAfter.Status != model.SubActive {
t.Fatalf("subscription after renewal recovery = %+v, %v, want active", subAfter, err)
}
if subAfter.CurrentPeriodEnd == nil {
t.Fatalf("current_period_end not refreshed on recovery")
}
sawPastDue, sawRenewed := false, false
for _, c := range spy.calls {
switch c["event_type"] {
case gateway.EvtSubscriptionPastDue:
sawPastDue = true
case gateway.EvtSubscriptionRenewed:
sawRenewed = true
}
}
if !sawPastDue || !sawRenewed {
t.Fatalf("missing expected events: %+v", spy.calls)
}
}
// TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued 同一失败 invoice 重投(渠道
// webhook 常见重投场景)→ MarkPastDue 第二次不再翻转(已 past_due),不重复入队。
func TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued(t *testing.T) {
g, fp, spy, _, _ := newSubGateway(t)
ctx := context.Background()
_, providerSubRef, _ := activateSub(t, g, fp)
spy.calls = nil
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil {
t.Fatalf("HandleCallback#1: %v", err)
}
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback#2: %v", err)
}
if result2 != gateway.SettleDuplicate {
t.Fatalf("replay result = %v, want duplicate", result2)
}
if len(spy.calls) != 1 {
t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
}
}
// TestSettleRenewalEnqueueFailureThenRetryRecovers 复现 Important 发现:settleRenewal 先
// CreateRenewalPaid(建单已 paid,单步无 pending 中间态)后 Enqueue——首次入队失败(瞬时)后,
// invoice.paid 重投走 created=false 的 duplicate 分支必须仍尝试入队(outbox 唯一键幂等,
// 行不存在则补建),否则 subscription.renewed 永久丢失(SyncPendingAttempts 救不了:续费
// attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
// 断言:①首次入队失败 → SettleFailed+err,订单已建为 paid(幂等键护着,不回滚)②同一
// invoice.paid 重投 → subscription.renewed 最终恰入队一次 ③renewal order 不因重投双铸。
func TestSettleRenewalEnqueueFailureThenRetryRecovers(t *testing.T) {
g, fp, spy, orders, _ := newSubGateway(t)
ctx := context.Background()
activateSub(t, g, fp)
spy.calls = nil
renewalRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_flaky",
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
})
if err != nil {
t.Fatalf("marshal renewal event: %v", err)
}
spy.failNext = true
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err == nil || result != gateway.SettleFailed {
t.Fatalf("首次入队失败应 SettleFailed+err, got %v, %v", result, err)
}
allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
if err != nil {
t.Fatalf("ListOrders: %v", err)
}
renewalCount := 0
for i := range allOrders {
if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
renewalCount++
if allOrders[i].Status != model.OrderPaidV2 {
t.Fatalf("renewal order 应已建为 paid(幂等键护着,不因入队失败回滚), got %+v", allOrders[i])
}
}
}
if renewalCount != 1 {
t.Fatalf("renewal order count after first(失败) attempt = %d, want 1", renewalCount)
}
if len(spy.calls) != 0 {
t.Fatalf("入队失败不应留下 webhook 记录, got %+v", spy.calls)
}
// 渠道重投同一 invoice.paid(Stripe 拿不到 200 会重投):outbox 补建自愈。
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err != nil {
t.Fatalf("HandleCallback retry: %v", err)
}
_ = result2 // duplicate(created=false,订单已在)——本用例只关心自愈,不断言具体 result 值
if len(spy.calls) != 1 {
t.Fatalf("重投后 subscription.renewed 应恰入队一次(自愈), got %d: %+v", len(spy.calls), spy.calls)
}
if spy.calls[0]["event_type"] != gateway.EvtSubscriptionRenewed {
t.Fatalf("event_type = %v, want %s", spy.calls[0]["event_type"], gateway.EvtSubscriptionRenewed)
}
allOrdersAfter, err := orders.ListOrders("pangolin", "u-1", 10)
if err != nil {
t.Fatalf("ListOrders after retry: %v", err)
}
renewalCountAfter := 0
for i := range allOrdersAfter {
if strings.Contains(allOrdersAfter[i].OutTradeNo, "-r-") {
renewalCountAfter++
}
}
if renewalCountAfter != 1 {
t.Fatalf("renewal order count after retry = %d, want still 1(不双铸)", renewalCountAfter)
}
}
// TestSubscriptionActivatedEnqueueFailureThenRetryRecovers 镜像
// TestSettleRenewalEnqueueFailureThenRetryRecovers,覆盖同型缺口(T5 re-review 发现):
// onSubscriptionActivated 先 subs.Create(先于入队幂等诞生订阅行)后 Enqueue subscription.created
// ——首次入队失败(瞬时)后,checkout.session.completed 重投走 created=false 的分支必须仍尝试
// 入队(outbox 唯一键幂等,行不存在则补建),否则 subscription.created 永久丢失(旧实现
// `!created→return nil` 直接跳过,行永远补不上)。
//
// 用 spy.failOnEventType 只让 subscription.created 这一次入队失败(而非 Settle 内更早的
// payment.succeeded)——同一 HandleCallback 里先后两次 Enqueue,只想复现"第二次失败"这个窗口。
//
// 断言:①首次:payment.succeeded 已入队(Settle 已 processed,订单已 paid),订阅行已幂等
// 诞生 active(Create 先于 Enqueue),但 subscription.created 入队失败 → HandleCallback 整体
// SettleFailed+err ②同一事件重投 → subscription.created 最终恰入队一次(payment.succeeded
// 不因重投重发,outbox 唯一键幂等)③重投不双铸订阅行(SubID 幂等派生)。
func TestSubscriptionActivatedEnqueueFailureThenRetryRecovers(t *testing.T) {
g, fp, spy, orders, subs := newSubGateway(t)
ctx := context.Background()
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal activate event: %v", err)
}
spy.failOnEventType = gateway.EvtSubscriptionCreated
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err == nil || result != gateway.SettleFailed {
t.Fatalf("subscription.created 入队失败应 SettleFailed+err, got %v, %v", result, err)
}
o, err := orders.GetOrder(res.OrderNo)
if err != nil || o.Status != model.OrderPaidV2 {
t.Fatalf("order after first(失败) attempt = %+v, %v, want paid(payment.succeeded 已先成功入队+翻转)", o, err)
}
subID := "SUB-" + res.OrderNo
sub, err := subs.GetBySubID(subID)
if err != nil || sub.Status != model.SubActive {
t.Fatalf("subscription after first(失败) attempt = %+v, %v, want already active(Create 先于 Enqueue,幂等诞生)", sub, err)
}
if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" {
t.Fatalf("首次 calls = %+v, want 仅 payment.succeeded 一条(subscription.created 那次入队失败,未记入)", spy.calls)
}
// Stripe 拿不到 200 会重投同一 checkout.session.completed。
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
if err != nil {
t.Fatalf("HandleCallback retry: %v", err)
}
_ = result2 // duplicate(created=false,订单/订阅已在)——本用例只关心自愈,不断言具体 result 值
if len(spy.calls) != 2 {
t.Fatalf("重投后 calls = %d, want 2(payment.succeeded 幂等 no-op 不重发 + subscription.created 补建恰一次): %+v", len(spy.calls), spy.calls)
}
if spy.calls[1]["event_type"] != gateway.EvtSubscriptionCreated || spy.calls[1]["sub_id"] != subID {
t.Fatalf("payload = %+v, want event_type=%s sub_id=%s", spy.calls[1], gateway.EvtSubscriptionCreated, subID)
}
subAfter, err := subs.GetBySubID(subID)
if err != nil || subAfter.Status != model.SubActive {
t.Fatalf("subscription after retry = %+v, %v", subAfter, err)
}
}
// TestSettleRenewalNoBizSystemStillProcessed 独立收款(无业务方回调,BizSystem=="")的续费首过
// 应与 enqueuePaymentSucceeded 的"无业务方=跳过入队但仍 processed"语义对齐,不能误判 duplicate
// (created=true 是真正的首次成交,只是没有下游 webhook 可发)。
func TestSettleRenewalNoBizSystemStillProcessed(t *testing.T) {
g, fp, spy, _, subs := newSubGateway(t)
ctx := context.Background()
_, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
SKU: "pro_monthly", Method: "substripe", // BizSystem/BizRef 留空 = 独立收款
})
if err != nil {
t.Fatalf("CreateSubscription: %v", err)
}
activateRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
})
if err != nil {
t.Fatalf("marshal activate event: %v", err)
}
if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil {
t.Fatalf("activate: %v", err)
}
sub, err := subs.GetByProviderRef("substripe", "sub_new")
if err != nil || sub.BizSystem != "" {
t.Fatalf("subscription = %+v, %v, want BizSystem empty(独立收款)", sub, err)
}
renewalRaw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_indep_1",
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
})
if err != nil {
t.Fatalf("marshal renewal event: %v", err)
}
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
if err != nil || result != gateway.SettleProcessed {
t.Fatalf("独立收款续费首过应 processed(非 duplicate), got %v, %v", result, err)
}
if len(spy.calls) != 0 {
t.Fatalf("独立收款不应入队 webhook, got %+v", spy.calls)
}
}
+60
View File
@@ -92,6 +92,39 @@ func (h *GatewayHandler) CreateOrder(c *gin.Context) {
util.RespondSuccess(c, res)
}
// CreateSubscription POST /api/v2/subscriptions —— 建订阅,返回 {sub_id, order_no, session:{render_type, payload}}。
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign,与 CreateOrder 同惯例)。
func (h *GatewayHandler) CreateSubscription(c *gin.Context) {
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
return
}
var req createV2Request
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.SKU == "" || req.Method == "" {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method")
return
}
if req.BizSystem != "" {
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
}
res, err := h.g.CreateSubscription(c.Request.Context(), gateway.CreateSubscriptionInput{
SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL,
})
if err != nil {
h.writeCreateErr(c, "建订阅", req.Method, err)
return
}
util.RespondSuccess(c, res)
}
// GetStatus GET /api/v2/orders/:order_no
func (h *GatewayHandler) GetStatus(c *gin.Context) {
v, err := h.g.GetOrder(c.Param("order_no"))
@@ -102,6 +135,31 @@ func (h *GatewayHandler) GetStatus(c *gin.Context) {
util.RespondSuccess(c, v)
}
// GetSubscription GET /api/v2/subscriptions/:sub_id —— 查订阅状态 + 续费锚点。
func (h *GatewayHandler) GetSubscription(c *gin.Context) {
v, err := h.g.GetSubscription(c.Param("sub_id"))
if err != nil {
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
return
}
util.RespondSuccess(c, v)
}
// CancelSubscription POST /api/v2/subscriptions/:sub_id/cancel —— 主动取消:查订阅 → 渠道
// 取消 → 本地翻 canceled + 入队 subscription.canceled(幂等,重复调用 no-op)。
func (h *GatewayHandler) CancelSubscription(c *gin.Context) {
if err := h.g.CancelSubscription(c.Request.Context(), c.Param("sub_id")); err != nil {
if errors.Is(err, store.ErrSubNotFound) {
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
return
}
log.Printf("[v2 subscription] 取消失败 sub_id=%s: %v", c.Param("sub_id"), err)
util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败,请稍后重试")
return
}
util.RespondSuccess(c, gin.H{"canceled": true})
}
type retryRequest struct {
Method string `json:"method"`
Metadata map[string]string `json:"metadata,omitempty"` // 同 createV2Request.Metadata,过同一白名单
@@ -189,6 +247,8 @@ func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, e
util.RespondError(c, http.StatusConflict, "currency_mismatch", "该支付方式结算币种与订单不符,请换一种支付方式")
case errors.Is(err, gateway.ErrNoSettleCurrency):
util.RespondError(c, http.StatusServiceUnavailable, "no_settle_currency", "该支付方式配置不完整,暂不可用")
case errors.Is(err, provider.ErrNotSupported):
util.RespondError(c, http.StatusBadRequest, "method_not_recurring", "该支付方式不支持订阅")
default:
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
+150 -2
View File
@@ -44,13 +44,15 @@ func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(fake.New())
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, orders
@@ -177,6 +179,8 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(fake.New()) // method="fake", settles in "USDT"
@@ -189,7 +193,7 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
{AccountID: "fake-a2", Channel: "fake_eur", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
@@ -246,3 +250,147 @@ func (p *eurFakeProvider) VerifyCallback(_ context.Context, in provider.Callback
func (p *eurFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
return nil, errors.New("not implemented")
}
// subFakeProvider 实现 provider.SubscriptionProvider,供 handler 层 CancelSubscription/
// GetSubscription 端到端测试用(独立于 internal/gateway 包的 fakeSubProvider,同构但不能跨
// 测试包复用未导出类型)。VerifyCallback 原样透传测试构造的 provider.PaidEvent JSON。
type subFakeProvider struct {
sessionRef string
}
func (p *subFakeProvider) Method() string { return "subfake" }
func (p *subFakeProvider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRecurring: true,
RecurringKind: provider.RecurringKindGatewayScheduled,
SettleCurrencies: []string{"USD"},
Regions: []string{"global"},
}
}
func (p *subFakeProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
return nil, errors.New("subFakeProvider: one-time Create not used")
}
func (p *subFakeProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
return &provider.Session{
ProviderRef: p.sessionRef, RenderType: provider.RenderRedirect,
Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef},
}, nil
}
func (p *subFakeProvider) CancelSubscription(_ context.Context, _ string) error { return nil }
func (p *subFakeProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
var ev provider.PaidEvent
if err := json.Unmarshal(in.Raw, &ev); err != nil {
return nil, err
}
return &ev, nil
}
func (p *subFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
}
type subResolver struct{}
func (subResolver) Resolve(sku, currency string) (int64, string, string, error) {
return 2999, "Pro 月付", "pro_monthly", nil
}
// buildSubEngine 装配一套支持订阅的路由(subfake 渠道),供取消/查询端点测试用。
func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) {
t.Helper()
gin.SetMode(gin.TestMode)
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := &subFakeProvider{sessionRef: "cs_sub_1"}
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{
{AccountID: "subfake-a1", Channel: "subfake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, subResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, fp
}
// TestV2SubscriptionNotFound404 —— 未知 sub_id 的取消/查询都应 404,而不是 500。
func TestV2SubscriptionNotFound404(t *testing.T) {
r, _ := buildSubEngine(t)
wGet, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/SUB-GHOST", nil)
if wGet.Code != http.StatusNotFound {
t.Fatalf("get unknown sub code=%d, want 404", wGet.Code)
}
wCancel, _ := do(t, r, http.MethodPost, "/api/v2/subscriptions/SUB-GHOST/cancel", nil)
if wCancel.Code != http.StatusNotFound {
t.Fatalf("cancel unknown sub code=%d, want 404", wCancel.Code)
}
}
// TestV2SubscriptionCancelLifecycle 端到端:建订阅 → 首期支付回调激活 → 查询 active →
// 取消 → 查询 canceled;取消端点重复调用幂等(仍 200,canceled=true)。
func TestV2SubscriptionCancelLifecycle(t *testing.T) {
r, fp := buildSubEngine(t)
w, out := do(t, r, http.MethodPost, "/api/v2/subscriptions", map[string]any{
"sku": "pro_monthly", "method": "subfake", "biz_system": "", "biz_ref": "u-1",
})
if w.Code != http.StatusOK {
t.Fatalf("create subscription code=%d body=%v", w.Code, out)
}
data := out["data"].(map[string]any)
subID := data["sub_id"].(string)
// 激活前:GetSubscription 404(Subscription 行在首期支付回调时才诞生)。
wPre, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
if wPre.Code != http.StatusNotFound {
t.Fatalf("get before activation code=%d, want 404", wPre.Code)
}
// 首期支付回调激活。
raw, err := json.Marshal(provider.PaidEvent{
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_h1",
})
if err != nil {
t.Fatalf("marshal event: %v", err)
}
wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/subfake", json.RawMessage(raw))
if wc.Code != http.StatusOK {
t.Fatalf("activate callback code=%d", wc.Code)
}
wActive, outActive := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
if wActive.Code != http.StatusOK {
t.Fatalf("get after activation code=%d body=%v", wActive.Code, outActive)
}
if outActive["data"].(map[string]any)["status"] != "active" {
t.Fatalf("status = %v, want active", outActive["data"])
}
// 取消。
wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
}
wAfter, outAfter := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
if wAfter.Code != http.StatusOK || outAfter["data"].(map[string]any)["status"] != "canceled" {
t.Fatalf("status after cancel = %d %v", wAfter.Code, outAfter)
}
// 重复取消:幂等 200,不报错。
wCancel2, outCancel2 := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
if wCancel2.Code != http.StatusOK || outCancel2["data"].(map[string]any)["canceled"] != true {
t.Fatalf("repeat cancel = %d %v", wCancel2.Code, outCancel2)
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ func TestV2CreateOrderMetadataWhitelist(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
+2 -2
View File
@@ -34,7 +34,7 @@ func TestV2CreateOrderRateLimited(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
@@ -87,7 +87,7 @@ func TestV2RateLimitDisabled(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
+3 -1
View File
@@ -45,11 +45,13 @@ func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.Orde
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}})
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global")
g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, g, orders, fp
+1 -1
View File
@@ -32,7 +32,7 @@ func OpenTestDB(t *testing.T) *gorm.DB {
t.Fatalf("open test db: %v", err)
}
if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{},
&Product{}, &ProductPrice{}, &OrphanPayment{}); err != nil {
&Product{}, &ProductPrice{}, &OrphanPayment{}, &Subscription{}, &Chargeback{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := UpgradeWebhookDeliveryIndex(db); err != nil {
+53
View File
@@ -72,6 +72,9 @@ type OrderV2 struct {
DiscountReason string `gorm:"size:128"`
PaidAt *time.Time
ExpiresAt *time.Time // 整体购买窗口
// Disputed 打标位(P8 Task6):渠道拒付(charge.dispute.created)命中该单时置 true,
// 仅留痕、不改状态机(§6 决策:不自动回收权益,业务方按 chargeback.received 自行冲正)。
Disputed bool `gorm:"default:false"`
}
// ---- 支付尝试(收款账本)----
@@ -121,3 +124,53 @@ type Refund struct {
InitiatedBy string `gorm:"size:16"` // business/platform
CompletedAt *time.Time
}
// ---- 订阅(recurring)----
type SubStatus string
const (
SubActive SubStatus = "active" // 已激活,正常续费
SubPastDue SubStatus = "past_due" // 某期扣款失败,待恢复(invoice.payment_failed)
SubCanceled SubStatus = "canceled" // 已取消(主动/网关删除),终态
)
// Subscription 是"同一 entitlement 的跨期账本"(设计 §5.1)。单笔层(OrderV2/Attempt)
// 与订阅层解耦:每期扣款仍落一张 renewal OrderV2,Subscription 只维护状态机 + 续费锚点。
type Subscription struct {
Base
SubID string `gorm:"uniqueIndex;size:64;not null"` // pay 生成的逻辑订阅号
OutTradeNo string `gorm:"index;size:64;not null"` // 首购 order(诞生订阅那笔)
MerchantID uint64 `gorm:"index"`
BizSystem string `gorm:"index;size:32"`
BizRef string `gorm:"size:128"`
BizCode string `gorm:"index;size:64"` // 套餐码副本,续费事件带回
Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_sub_provider"`
ProviderSubRef string `gorm:"size:128;not null;uniqueIndex:uq_sub_provider"` // 渠道订阅号(stripe sub id)
RecurringKind string `gorm:"size:24"` // gateway_scheduled/token_offsession/...
AmountMinor int64 `gorm:"not null"`
Currency string `gorm:"size:16;not null"`
Status SubStatus `gorm:"index;size:16;not null"`
CurrentPeriodEnd *time.Time
CanceledAt *time.Time
}
// ---- 拒付(chargeback,P8 Task6)----
// Chargeback 记录渠道拒付(设计 §6「钱到账不可逆」的例外形态:卡组织裁定退单)。只有
// Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款不可逆、永无 chargeback——
// 三者均不实现 Capabilities 里任何 chargeback 相关声明(P8 未新增该 capability 位,
// 靠 EventKind 是否产出 EventChargeback 天然区分)。
// 每次拒付(含重投)按 DisputeRef 幂等落一行留痕;是否成功定位/转发给业务方是另一回事
// (见 gateway.recordChargeback),Chargeback 表本身对"能否定位业务单"保持中立、全记录。
type Chargeback struct {
Base
DisputeRef string `gorm:"uniqueIndex;size:128;not null"` // 渠道拒付号(stripe dp_...)
OutTradeNo string `gorm:"index;size:64"` // 解析出的原单号(订阅拒付可能为空)
Channel string `gorm:"index;size:32;not null"`
ProviderPaymentRef string `gorm:"size:128"` // 关联 PaymentIntent id
AmountMinor int64 `gorm:"not null"`
Currency string `gorm:"size:16;not null"`
Reason string `gorm:"size:64"`
Status string `gorm:"size:24"` // 渠道拒付状态快照(needs_response/...)
}
+42
View File
@@ -71,6 +71,22 @@ type CallbackInput struct {
Query map[string]string
}
// EventKind — PaidEvent 的语义判别器(P8,设计 §5)。零值 EventPayment 是既有一次性/首期
// 支付语义(P3 三渠道全落此),新增枚举全为 additive,不改变任何既有调用点的行为。
type EventKind string
const (
EventPayment EventKind = "" // 默认:一次性/首期支付(向后兼容)
EventSubscriptionRenewal EventKind = "subscription_renewal"
EventSubscriptionPastDue EventKind = "subscription_past_due"
EventSubscriptionCanceled EventKind = "subscription_canceled"
EventChargeback EventKind = "chargeback"
)
// RecurringKindGatewayScheduled — Capabilities.RecurringKind 取值之一:续费由渠道网关自身
// 调度驱动(如 Stripe invoice.paid),pay 不主动发起 Charge(区别于 token_offsession)。
const RecurringKindGatewayScheduled = "gateway_scheduled"
// PaidEvent — verify_callback / query 的统一产出(设计 §4.1 → {order_ref,status,paid_amount})。
type PaidEvent struct {
ProviderRef string
@@ -79,6 +95,15 @@ type PaidEvent struct {
PaidCurrency string
Raw string
PaidAt *time.Time // 渠道报的支付时间;nil 则 settle 用收到时间,对账时两边时间才对得上
// 以下均可选(P8,零值=旧行为):
Kind EventKind // 事件语义判别器,零值=既有一次性支付
SubscriptionRef string // 渠道订阅号(checkout.completed 诞生 / invoice / deleted 反查)
InvoiceRef string // 续费期次唯一号(renewal attempt 的 provider_ref)
DisputeRef string // 拒付号
ProviderPaymentRef string // 拒付关联的 PaymentIntent id
OutTradeNo string // 拒付解析出的原单号(可空)
Reason string // 拒付原因(渠道枚举,如 stripe fraudulent/product_not_received)
}
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
@@ -106,6 +131,13 @@ var (
// "钱确定没退成可以标 failed 释放额度" vs "退没退不确定,必须留 processing 占额度
// 交人工/对账收敛"(P6 RefundStuckAlertTask)。
ErrRefundRejected = errors.New("provider: refund rejected by channel")
// ErrSubAlreadyCanceled — SubscriptionProvider.CancelSubscription 的哨兵:渠道侧订阅
// 已处于取消终态(dashboard 手工取消 / 竞态下未消费的 deleted webhook 抢先落地),本地
// 发起的主动取消打到渠道时渠道拒绝(如 Stripe "already been canceled" / resource_missing)。
// 调用方(gateway.CancelSubscription)须将其视为"取消事实已成立",走本地收敛而非报错——
// 具体渠道 adapter 负责把渠道原生错误 wrap 成本哨兵(参见 stripe.isAlreadyCanceledErr)。
ErrSubAlreadyCanceled = errors.New("provider: subscription already canceled at channel")
)
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
@@ -163,6 +195,16 @@ type OrphanScanner interface {
ScanOrphans(ctx context.Context, req OrphanScanRequest) ([]OrphanTransfer, error)
}
// SubscriptionProvider — 可选:渠道网关自身调度续费(RecurringKindGatewayScheduled,如
// Stripe Checkout mode=subscription)的 Provider 额外实现。与 RecurringProvider(pay 主动
// Charge 的 token_offsession 类)不同:订阅号在用户完成收银台支付后才诞生,续费由渠道
// webhook(invoice.paid)驱动,pay 只负责建单与取消。
type SubscriptionProvider interface {
Provider
CreateSubscriptionCheckout(ctx context.Context, req CreateRequest) (*Session, error)
CancelSubscription(ctx context.Context, providerSubRef string) error
}
// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。
type Registry struct{ providers map[string]Provider }
+178 -16
View File
@@ -42,10 +42,12 @@ func (p *Provider) Method() string { return "stripe" }
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:/v1/refunds
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:/v1/refunds
SupportsRecurring: true, // P8:Checkout mode=subscription,续费由 Stripe 网关调度
RecurringKind: provider.RecurringKindGatewayScheduled,
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
}
}
@@ -108,6 +110,14 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
},
},
}},
// 一次性(mode=payment)Checkout 生成的 PaymentIntent 打 out_trade_no metadata(P8
// Task6):dispute webhook(charge.dispute.created)只带 payment_intent id,须靠此
// metadata 才能反查回原订单——订阅首期/续费的 charge 由 invoice 生成,Stripe 不透传
// SubscriptionData.Metadata 到 PI,那类拒付的 out_trade_no 天然解析为空(honest scope,
// 见 VerifyCallback 的 charge.dispute.created 分支注释)。
PaymentIntentData: &gostripe.CheckoutSessionPaymentIntentDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
},
}
sess, err := p.sc.CheckoutSessions.New(params)
if err != nil {
@@ -120,6 +130,87 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
}, nil
}
// CreateSubscriptionCheckout 建 Stripe 订阅(Checkout mode=subscription):返回 redirect 收银台。
// 订阅号(sub_...)在用户完成支付后才生成 → 经 checkout.session.completed webhook 诞生 pay 订阅。
// 续费由 Stripe 网关驱动(invoice.paid),pay 不主动 Charge(区别于 token_offsession)。
func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
if req.Currency != supportedCurrency {
return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency)
}
params := &gostripe.CheckoutSessionParams{
Mode: gostripe.String(string(gostripe.CheckoutSessionModeSubscription)),
SuccessURL: gostripe.String(req.ReturnURL),
ClientReferenceID: gostripe.String(req.OutTradeNo),
LineItems: []*gostripe.CheckoutSessionLineItemParams{{
Quantity: gostripe.Int64(1),
PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{
Currency: gostripe.String(strings.ToLower(supportedCurrency)),
UnitAmount: gostripe.Int64(req.AmountMinor),
// 最小可行:固定月付。周期(month/year)后续由 product 定价档下发,此处留 month 默认。
Recurring: &gostripe.CheckoutSessionLineItemPriceDataRecurringParams{
Interval: gostripe.String("month"),
},
ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{
Name: gostripe.String(req.Subject),
},
},
}},
// 订阅 metadata 带 out_trade_no,便于人工对账;续费/取消映射实际走 sub id 反查,不依赖它。
SubscriptionData: &gostripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
},
}
if v := req.Metadata["pay_sub_id"]; v != "" {
params.SubscriptionData.Metadata["pay_sub_id"] = v
}
sess, err := p.sc.CheckoutSessions.New(params)
if err != nil {
return nil, fmt.Errorf("stripe: 创建订阅 Checkout 失败: %w", err)
}
return &provider.Session{
ProviderRef: sess.ID,
RenderType: provider.RenderRedirect,
Payload: map[string]any{"url": sess.URL},
}, nil
}
// CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted,
// 入站处理器幂等标 canceled,与本地主动标一致收敛。
//
// 渠道已先行取消(dashboard 手工 / 竞态下未消费的 deleted webhook 抢先落地)时,Stripe 会拒绝
// 二次 Cancel:识别出这类错误后 wrap 成 provider.ErrSubAlreadyCanceled(errors.Is 可判),不是
// "取消失败"而是"取消已成立"——调用方(gateway.CancelSubscription)据此走本地收敛而非报错。
func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error {
if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil {
if isAlreadyCanceledErr(err) {
return fmt.Errorf("%w: %v", provider.ErrSubAlreadyCanceled, err)
}
return fmt.Errorf("stripe: 取消订阅失败: %w", err)
}
return nil
}
// isAlreadyCanceledErr 判定"渠道侧订阅已处于取消终态"这一场景,对应 vendored v79
// (github.com/stripe/stripe-go/v79 error.go)实测/文档记录的两种 *stripe.Error 形态:
//
// - 订阅对象已被彻底删除(引用旧 id 查不到):Type=invalid_request_error,
// Code=resource_missing(有明确机器可读 Code,见 error.go ErrorCodeResourceMissing)。
// - 订阅对象仍在但 status=canceled(二次 Cancel 同一仍存在的订阅):Type=invalid_request_error,
// **无 Code**(Stripe 对这种校验类拒绝不下发机器可读 code,仅给 Msg 文案
// "This subscription has already been canceled."),只能按已知文案兜底、大小写不敏感匹配,
// 避免因标点/大小写细节波动误判。
func isAlreadyCanceledErr(err error) bool {
var stripeErr *gostripe.Error
if !errors.As(err, &stripeErr) {
return false
}
if stripeErr.Code == gostripe.ErrorCodeResourceMissing {
return true
}
return stripeErr.Type == gostripe.ErrorTypeInvalidRequest &&
strings.Contains(strings.ToLower(stripeErr.Msg), "already been canceled")
}
func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
sig := in.Headers["Stripe-Signature"]
// stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量
@@ -134,20 +225,82 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput)
if err != nil {
return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err)
}
if event.Type != "checkout.session.completed" {
switch event.Type {
case "checkout.session.completed":
var sess gostripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
}
ev := sessionToEvent(&sess, in.Raw)
if event.Created > 0 {
paidAt := unixToTime(event.Created)
ev.PaidAt = &paidAt
}
return ev, nil
case "invoice.paid":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
if inv.BillingReason != gostripe.InvoiceBillingReasonSubscriptionCycle {
// 首期(subscription_create)由 checkout.session.completed 入账;其余非续费忽略。
return &provider.PaidEvent{Kind: provider.EventPayment, Status: provider.PaidPending, Raw: string(in.Raw)}, nil
}
ev := &provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, Status: provider.PaidSucceeded,
InvoiceRef: inv.ID, PaidAmountMinor: inv.Total, PaidCurrency: strings.ToUpper(string(inv.Currency)),
Raw: string(in.Raw),
}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
if event.Created > 0 {
t := unixToTime(event.Created)
ev.PaidAt = &t
}
return ev, nil
case "customer.subscription.deleted":
var sub gostripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("stripe: 解析 subscription 失败: %w", err)
}
return &provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: sub.ID, Raw: string(in.Raw)}, nil
case "invoice.payment_failed":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
ev := &provider.PaidEvent{Kind: provider.EventSubscriptionPastDue, InvoiceRef: inv.ID, Raw: string(in.Raw)}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
return ev, nil
case "charge.dispute.created":
// 拒付(设计 §6 决策记录):dispute payload 里 payment_intent 常只是 id 未展开,须
// 反查 PaymentIntents.Get 取其 metadata["out_trade_no"](Create 时已 stamp,见上方
// Create 的 PaymentIntentData 注释)。订阅首期/续费 charge 的 PI 不带该 metadata
// (Stripe 不透传 SubscriptionData.Metadata 到 PI)→ OutTradeNo 天然为空,交
// gateway.recordChargeback 记录但不转发(honest scope,精确定位留后续轮次)。
var d gostripe.Dispute
if err := json.Unmarshal(event.Data.Raw, &d); err != nil {
return nil, fmt.Errorf("stripe: 解析 dispute 失败: %w", err)
}
ev := &provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: d.ID, Status: provider.PaidFailed,
PaidAmountMinor: d.Amount, PaidCurrency: strings.ToUpper(string(d.Currency)),
Reason: string(d.Reason), Raw: string(in.Raw),
}
if d.PaymentIntent != nil && d.PaymentIntent.ID != "" {
ev.ProviderPaymentRef = d.PaymentIntent.ID
if pi, err := p.sc.PaymentIntents.Get(d.PaymentIntent.ID, nil); err == nil && pi.Metadata != nil {
ev.OutTradeNo = pi.Metadata["out_trade_no"] // 一次性单带;订阅 charge 为空
}
}
return ev, nil
default:
// 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。
return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil
}
var sess gostripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
}
ev := sessionToEvent(&sess, in.Raw)
if event.Created > 0 {
paidAt := unixToTime(event.Created)
ev.PaidAt = &paidAt
}
return ev, nil
}
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
@@ -170,11 +323,20 @@ func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEv
case gostripe.CheckoutSessionPaymentStatusUnpaid:
status = provider.PaidPending
}
return &provider.PaidEvent{
ev := &provider.PaidEvent{
ProviderRef: sess.ID,
Status: status,
PaidAmountMinor: sess.AmountTotal, // cent
PaidCurrency: strings.ToUpper(string(sess.Currency)),
Raw: string(raw),
}
// mode=subscription 的 Checkout 完成时,sess.Subscription 必被 Stripe 填充
// (*Subscription;字符串 id 会 unmarshal 成 &Subscription{ID:...})。一次性单
// (mode=payment)天然为 nil,不影响既有语义。Kind 保持零值 EventPayment——本仓
// 分派门(settle.go)靠 SubscriptionRef != "" 而非 Kind 判断是否触发订阅激活,
// 见 internal/gateway/settle.go 的 onSubscriptionActivated 调用点。
if sess.Subscription != nil {
ev.SubscriptionRef = sess.Subscription.ID
}
return ev
}
+321 -2
View File
@@ -5,7 +5,9 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -26,11 +28,39 @@ func fakeStripeAPI(t *testing.T) *httptest.Server {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
// 创建 session
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
// 创建 session。一次性(mode=payment)与订阅(mode=subscription)共用此分支,
// 靠 form body 是否带 "subscription" 区分,回不同 id 供各自用例断言。
b, _ := io.ReadAll(r.Body)
if strings.Contains(string(b), "subscription") {
fmt.Fprint(w, `{"id":"cs_sub_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_sub_123","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
} else {
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
}
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"):
// 查询 session — 已付
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"):
fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_resource_missing"):
// 渠道已彻底删除该订阅对象:invalid_request_error + code=resource_missing(有明确
// 机器可读 Code)。
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription: 'sub_resource_missing'"}}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_already_canceled"):
// 订阅对象仍在但 status=canceled,二次 Cancel:invalid_request_error,**无 code**,
// 只有 Stripe 实测的固定文案。
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"This subscription has already been canceled."}}`)
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_other_error"):
// 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"Something else went wrong."}}`)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_1"):
// 一次性单的 PI:携带 Create 时 stamp 的 out_trade_no metadata。
fmt.Fprint(w, `{"id":"pi_1","object":"payment_intent","metadata":{"out_trade_no":"PAY-1"}}`)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_sub_1"):
// 订阅续费 charge 的 PI:不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)。
fmt.Fprint(w, `{"id":"pi_sub_1","object":"payment_intent","metadata":{}}`)
default:
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
}
@@ -47,6 +77,88 @@ func newStripe(t *testing.T, ts *httptest.Server) *st.Provider {
return st.New(sc, whSecret)
}
func TestCreateSubscriptionCheckout(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
sess, err := p.CreateSubscriptionCheckout(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-S1", Subject: "Pro Monthly", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return", Metadata: map[string]string{"pay_sub_id": "SUB-1"},
})
if err != nil {
t.Fatalf("create sub checkout: %v", err)
}
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_sub_123" {
t.Fatalf("session = %+v", sess)
}
}
func TestCancelSubscription(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
if err := p.CancelSubscription(context.Background(), "sub_cancel_ok"); err != nil {
t.Fatalf("cancel: %v", err)
}
}
// TestCancelSubscriptionResourceMissingWrapsSentinel 覆盖"渠道已彻底删除该订阅对象"这一
// 已取消形态:*stripe.Error{Type:invalid_request_error, Code:resource_missing} → wrap 成
// provider.ErrSubAlreadyCanceled(errors.Is 可判),不是不可判别的裸字符串错误。
func TestCancelSubscriptionResourceMissingWrapsSentinel(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_resource_missing")
if err == nil {
t.Fatalf("cancel resource_missing: want error, got nil")
}
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel resource_missing err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
}
}
// TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel 覆盖"订阅对象仍在但 status=canceled
// 二次 Cancel"这一形态:Stripe 对此场景**不下发机器可读 Code**,只有 invalid_request_error 类型
// + 固定文案"already been canceled"——同样应 wrap 成哨兵。
func TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_already_canceled")
if err == nil {
t.Fatalf("cancel already_canceled: want error, got nil")
}
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel already_canceled err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
}
}
// TestCancelSubscriptionOtherErrorNotWrapped 反例:与"已取消"无关的渠道拒绝不应被误判成
// 哨兵,原样透传成普通错误(不能 errors.Is 命中)。
func TestCancelSubscriptionOtherErrorNotWrapped(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
err := p.CancelSubscription(context.Background(), "sub_cancel_other_error")
if err == nil {
t.Fatalf("cancel other error: want error, got nil")
}
if errors.Is(err, provider.ErrSubAlreadyCanceled) {
t.Fatalf("cancel other error err = %v, 不应误判成 ErrSubAlreadyCanceled", err)
}
}
func TestCapabilitiesRecurring(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
c := newStripe(t, ts).Capabilities()
if !c.SupportsRecurring || c.RecurringKind != provider.RecurringKindGatewayScheduled {
t.Fatalf("caps = %+v", c)
}
}
func TestCreateCheckoutRedirect(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
@@ -101,6 +213,34 @@ func TestVerifyWebhook(t *testing.T) {
}
}
// 订阅 Checkout(mode=subscription)完成时,session payload 携带 subscription 字段
// (stripe-go v79 CheckoutSession.Subscription 为 *Subscription,字符串 id 会 unmarshal
// 成 &Subscription{ID:...})。sessionToEvent 必须回填 PaidEvent.SubscriptionRef——否则
// gateway.settleRenewal 的激活门(ev.SubscriptionRef != "")恒 false,订阅首期激活主链路
// 在生产环境(真实 Stripe webhook)整体不工作,只是被 fake provider 的测试掩盖了。
func TestVerifyWebhookSubscriptionSessionBackfillsSubscriptionRef(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_sub_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_sub_123","object":"checkout.session","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"paid","subscription":"sub_new"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.SubscriptionRef != "sub_new" {
t.Fatalf("SubscriptionRef = %q, want %q (event = %+v)", ev.SubscriptionRef, "sub_new", ev)
}
if ev.ProviderRef != "cs_sub_123" || ev.Status != provider.PaidSucceeded {
t.Fatalf("event = %+v", ev)
}
}
// 用错误的签名密钥(冒充攻击者伪造 webhook)→ ConstructEventWithOptions 内部 HMAC 校验
// 必失败,VerifyCallback 必须返回 error,绝不能返回 PaidEvent(哪怕 payload 里状态是 paid)。
func TestVerifyWebhookWrongSecretFails(t *testing.T) {
@@ -123,6 +263,185 @@ func TestVerifyWebhookWrongSecretFails(t *testing.T) {
}
}
// invoice.paid + billing_reason=subscription_cycle → 归一化为 EventSubscriptionRenewal,
// 携带 invoice/subscription/金额,供 gateway.settleRenewal 铸 renewal order。
func TestVerifyInvoicePaidRenewal(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionRenewal {
t.Fatalf("kind = %v, want EventSubscriptionRenewal", ev.Kind)
}
if ev.InvoiceRef != "in_123" || ev.SubscriptionRef != "sub_new" || ev.PaidAmountMinor != 2999 || ev.Status != provider.PaidSucceeded {
t.Fatalf("event = %+v", ev)
}
if ev.PaidCurrency != "USD" {
t.Fatalf("currency = %s, want USD", ev.PaidCurrency)
}
}
// invoice.paid 但 billing_reason=subscription_create 是首期发票,与 checkout.session.completed
// 是同一笔钱——由后者入账,这里必须跳过(归一化为 EventPayment,不触发续费铸单)。
func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r2","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_first","object":"invoice","billing_reason":"subscription_create","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventPayment {
t.Fatalf("kind = %v, want EventPayment(skip)", ev.Kind)
}
}
// customer.subscription.deleted(Stripe 侧取消,主动取消/欠费催收耗尽后网关删除)→
// 归一化为 EventSubscriptionCanceled,携带渠道订阅号供 gateway 反查 Subscription。
func TestVerifyCustomerSubscriptionDeleted(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_del","object":"event","type":"customer.subscription.deleted","data":{"object":{"id":"sub_new","object":"subscription","status":"canceled"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionCanceled {
t.Fatalf("kind = %v, want EventSubscriptionCanceled", ev.Kind)
}
if ev.SubscriptionRef != "sub_new" {
t.Fatalf("subscription_ref = %s, want sub_new", ev.SubscriptionRef)
}
}
// TestCreateSendsPaymentIntentMetadata 覆盖 P8 Task6:一次性(mode=payment)Checkout 的
// Create 必须给 PaymentIntentData 打 out_trade_no metadata,dispute webhook 反查 PI 才能
// 定位原订单(见 VerifyCallback 的 charge.dispute.created 分支)。
func TestCreateSendsPaymentIntentMetadata(t *testing.T) {
var gotBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
}))
defer ts.Close()
p := newStripe(t, ts)
if _, err := p.Create(context.Background(), provider.CreateRequest{
OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD",
ReturnURL: "https://x/return",
}); err != nil {
t.Fatalf("create: %v", err)
}
if !strings.Contains(gotBody, "payment_intent_data") || !strings.Contains(gotBody, "out_trade_no") || !strings.Contains(gotBody, "PAY-1") {
t.Fatalf("create request body missing payment_intent_data out_trade_no metadata: %s", gotBody)
}
}
// charge.dispute.created(拒付)→ 归一化为 EventChargeback:dispute payload 的
// payment_intent 只是 id("pi_1")未展开,须反查 PaymentIntents.Get 取 metadata
// out_trade_no(一次性单 Create 已 stamp)。
func TestVerifyChargeDisputeCreated(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_dp1","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_1","object":"dispute","amount":2999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_1"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventChargeback {
t.Fatalf("kind = %v, want EventChargeback", ev.Kind)
}
if ev.DisputeRef != "dp_1" || ev.OutTradeNo != "PAY-1" || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" || ev.Reason != "fraudulent" {
t.Fatalf("event = %+v", ev)
}
if ev.ProviderPaymentRef != "pi_1" {
t.Fatalf("provider_payment_ref = %s, want pi_1", ev.ProviderPaymentRef)
}
}
// 订阅续费 charge 的 PI 不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)→
// OutTradeNo 归一化为空,gateway.recordChargeback 据此记录但不转发(honest scope)。
func TestVerifyChargeDisputeCreatedNoOutTradeNo(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_dp2","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_sub_1","object":"dispute","amount":999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_sub_1"}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventChargeback || ev.DisputeRef != "dp_sub_1" {
t.Fatalf("event = %+v", ev)
}
if ev.OutTradeNo != "" {
t.Fatalf("out_trade_no = %q, want empty(订阅拒付无法定位)", ev.OutTradeNo)
}
}
// invoice.payment_failed(某期扣款失败)→ 归一化为 EventSubscriptionPastDue,携带失败
// 发票号(供 gateway 铸 outbox 幂等键)+ 渠道订阅号。
func TestVerifyInvoicePaymentFailed(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_fail","object":"event","type":"invoice.payment_failed","data":{"object":{"id":"in_failed","object":"invoice","subscription":{"id":"sub_new","object":"subscription"}}}}`
sig := signStripe(payload, whSecret, time.Now().Unix())
ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{
Raw: []byte(payload),
Headers: map[string]string{"Stripe-Signature": sig},
})
if err != nil {
t.Fatalf("verify: %v", err)
}
if ev.Kind != provider.EventSubscriptionPastDue {
t.Fatalf("kind = %v, want EventSubscriptionPastDue", ev.Kind)
}
if ev.InvoiceRef != "in_failed" || ev.SubscriptionRef != "sub_new" {
t.Fatalf("event = %+v", ev)
}
}
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
func signStripe(payload, secret string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
+1 -1
View File
@@ -35,7 +35,7 @@ func TestSyncPendingTaskSettlesViaQuery(t *testing.T) {
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true}})
gw := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global")
gw := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
res, _ := gw.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
+5 -2
View File
@@ -45,13 +45,16 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) {
h := handler.NewGatewayHandler(g)
v2 := r.Group("/api/v2")
{
// per-IP 限流(jiu 反馈波,P3 限流纵深):只挂改状态端点(下单/重试/取消)——
// per-IP 限流(jiu 反馈波,P3 限流纵深):只挂改状态端点(下单/重试/取消/订阅创建/订阅取消)——
// - callback 不限:来源是渠道服务器,不是终端用户,限了只会误伤渠道重投。
// - GET 查单/查退款不限:只读、幂等,是结果页轮询的正常用法,风险远低于
// - GET 查单/查退款/查订阅不限:只读、幂等,是结果页轮询的正常用法,风险远低于
// 下单类写操作;真要限也该给比写操作更宽松的档,这里选择直接不限。
// - /refunds(创建退款)/admin 组不在本次改动范围内(见 config.RateLimit 注释)。
stateLimit := stateChangeRateLimit()
v2.POST("/orders", stateLimit, h.CreateOrder)
v2.POST("/subscriptions", stateLimit, h.CreateSubscription)
v2.GET("/subscriptions/:sub_id", h.GetSubscription)
v2.POST("/subscriptions/:sub_id/cancel", stateLimit, h.CancelSubscription)
v2.GET("/orders/:order_no", h.GetStatus)
v2.POST("/orders/:order_no/retry", stateLimit, h.Retry)
v2.POST("/orders/:order_no/cancel", stateLimit, h.Cancel)
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
// ChargebackStore 持久化拒付留痕(P8 Task6)。与 SubscriptionStore/OrderStore 同惯例:
// 幂等靠 DB 唯一约束 + ON CONFLICT DO NOTHING,不在应用层加锁。
type ChargebackStore struct{ db *gorm.DB }
func NewChargebackStore(db *gorm.DB) *ChargebackStore { return &ChargebackStore{db: db} }
// Create 幂等插入:重复 dispute_ref(渠道重投同一拒付)→ no-op(created=false)。
// Chargeback 表对"能否定位业务单"保持中立——OutTradeNo 为空也照常落一行留痕。
func (s *ChargebackStore) Create(cb *model.Chargeback) (bool, error) {
res := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dispute_ref"}},
DoNothing: true,
}).Create(cb)
if res.Error != nil {
return false, fmt.Errorf("store.ChargebackStore.Create: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
+44
View File
@@ -0,0 +1,44 @@
package store_test
import (
"testing"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func TestChargebackCreateIdempotent(t *testing.T) {
s := store.NewChargebackStore(model.OpenTestDB(t))
cb := &model.Chargeback{
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
Reason: "fraudulent", Status: "received",
}
created, err := s.Create(cb)
if err != nil || !created {
t.Fatalf("first create: created=%v err=%v", created, err)
}
// 同 dispute_ref 重投(Stripe 重投 charge.dispute.created)→ 幂等 no-op,不双记。
again, err := s.Create(&model.Chargeback{
DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
Reason: "fraudulent", Status: "received",
})
if err != nil || again {
t.Fatalf("dup create: again=%v err=%v", again, err)
}
}
// 订阅拒付常无法解析出业务单号(PI 不带 out_trade_no metadata)——Chargeback 表对此
// 保持中立,OutTradeNo 空值也照常落一行留痕,不因空值而失败或跳过。
func TestChargebackCreateWithEmptyOutTradeNo(t *testing.T) {
s := store.NewChargebackStore(model.OpenTestDB(t))
created, err := s.Create(&model.Chargeback{
DisputeRef: "dp_sub_1", OutTradeNo: "", Channel: "stripe",
ProviderPaymentRef: "pi_sub_1", AmountMinor: 999, Currency: "USD",
Reason: "fraudulent", Status: "received",
})
if err != nil || !created {
t.Fatalf("create with empty out_trade_no: created=%v err=%v", created, err)
}
}
+30
View File
@@ -8,6 +8,7 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
@@ -65,6 +66,35 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at
return flipped, nil
}
// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。
// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。
func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) {
var created bool
err := s.db.Transaction(func(tx *gorm.DB) error {
ores := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true,
}).Create(order)
if ores.Error != nil {
return ores.Error
}
if ores.RowsAffected == 0 {
return nil // 已建过 → 幂等 no-op
}
ares := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true,
}).Create(att)
if ares.Error != nil {
return ares.Error
}
created = ares.RowsAffected > 0
return nil
})
if err != nil {
return false, fmt.Errorf("store.CreateRenewalPaid: %w", err)
}
return created, nil
}
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
+13
View File
@@ -27,6 +27,19 @@ func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) {
return &o, nil
}
// MarkDisputed 打拒付标(P8 Task6):条件 UPDATE 仅在 disputed=false 时翻转,幂等——
// 重投同一 dispute 命中 rows_affected=0,不报错,调用方(recordChargeback)不看返回值
// 也安全(打标不改状态机,不存在"取消标记"这一操作,单向翻转足够)。
func (s *OrderStore) MarkDisputed(outTradeNo string) (bool, error) {
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND disputed = ?", outTradeNo, false).
Update("disputed", true)
if res.Error != nil {
return false, fmt.Errorf("store.MarkDisputed: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement
// can recover out_trade_no + channel from a callback/query that only carries the ref.
func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) {
+22
View File
@@ -232,3 +232,25 @@ func TestListAttemptsByChannelSince(t *testing.T) {
t.Fatalf("只应含近期 crypto, got %+v", got)
}
}
// TestMarkDisputed 覆盖 P8 Task6 打标:条件 UPDATE 只在 disputed=false 时翻转,重投同一
// dispute(第二次调用)幂等 no-op(rows_affected=0,不报错)。
func TestMarkDisputed(t *testing.T) {
s := store.NewOrderStore(model.OpenTestDB(t))
seedOrder(t, s, "PAY-Q3")
flipped, err := s.MarkDisputed("PAY-Q3")
if err != nil || !flipped {
t.Fatalf("first MarkDisputed: flipped=%v err=%v", flipped, err)
}
o, err := s.GetOrder("PAY-Q3")
if err != nil || !o.Disputed {
t.Fatalf("order after MarkDisputed = %+v, %v", o, err)
}
// 重投同一 dispute → 幂等 no-op,不报错。
again, err := s.MarkDisputed("PAY-Q3")
if err != nil || again {
t.Fatalf("dup MarkDisputed: again=%v err=%v", again, err)
}
}
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
var ErrSubNotFound = errors.New("store: subscription not found")
type SubscriptionStore struct{ db *gorm.DB }
func NewSubscriptionStore(db *gorm.DB) *SubscriptionStore { return &SubscriptionStore{db: db} }
// Create 幂等插入:重复 (channel,provider_sub_ref) → no-op(created=false)。
// Stripe 会重投 checkout.session.completed;诞生订阅必须幂等。
func (s *SubscriptionStore) Create(sub *model.Subscription) (bool, error) {
res := s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "channel"}, {Name: "provider_sub_ref"}},
DoNothing: true,
}).Create(sub)
if res.Error != nil {
return false, fmt.Errorf("store.SubscriptionStore.Create: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) {
var sub model.Subscription
if err := s.db.Where("sub_id = ?", subID).First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubNotFound
}
return nil, fmt.Errorf("store.GetBySubID: %w", err)
}
return &sub, nil
}
func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error) {
var sub model.Subscription
if err := s.db.Where("channel = ? AND provider_sub_ref = ?", channel, ref).First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSubNotFound
}
return nil, fmt.Errorf("store.GetByProviderRef: %w", err)
}
return &sub, nil
}
// Activate 置 active + 刷新续费锚点(past_due 续费成功恢复也走它);canceled 终态不复活。
func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) {
upd := map[string]any{"status": model.SubActive}
if periodEnd != nil {
upd["current_period_end"] = periodEnd
}
res := s.db.Model(&model.Subscription{}).
Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).Updates(upd)
if res.Error != nil {
return false, fmt.Errorf("store.Activate: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) {
res := s.db.Model(&model.Subscription{}).
Where("channel = ? AND provider_sub_ref = ? AND status = ?", channel, providerRef, model.SubActive).
Update("status", model.SubPastDue)
if res.Error != nil {
return false, fmt.Errorf("store.MarkPastDue: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) {
res := s.db.Model(&model.Subscription{}).
Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).
Updates(map[string]any{"status": model.SubCanceled, "canceled_at": time.Now()})
if res.Error != nil {
return false, fmt.Errorf("store.MarkCanceled: %w", res.Error)
}
return res.RowsAffected > 0, nil
}
+65
View File
@@ -0,0 +1,65 @@
package store_test
import (
"testing"
"time"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func newSubStore(t *testing.T) *store.SubscriptionStore {
return store.NewSubscriptionStore(model.OpenTestDB(t))
}
func TestSubscriptionCreateIdempotent(t *testing.T) {
s := newSubStore(t)
sub := &model.Subscription{SubID: "SUB-1", Channel: "stripe", ProviderSubRef: "sub_x",
OutTradeNo: "PAY-1", BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
AmountMinor: 2999, Currency: "USD", Status: model.SubActive}
created, err := s.Create(sub)
if err != nil || !created {
t.Fatalf("first create: created=%v err=%v", created, err)
}
// 同 provider_sub_ref 再建 → 幂等 no-op(Stripe 重投 checkout.completed)
again, err := s.Create(&model.Subscription{SubID: "SUB-2", Channel: "stripe", ProviderSubRef: "sub_x",
OutTradeNo: "PAY-1", AmountMinor: 2999, Currency: "USD", Status: model.SubActive})
if err != nil || again {
t.Fatalf("dup create: again=%v err=%v", again, err)
}
}
func TestSubscriptionStateMachine(t *testing.T) {
s := newSubStore(t)
end := time.Now().Add(30 * 24 * time.Hour)
if _, err := s.Create(&model.Subscription{SubID: "SUB-9", Channel: "stripe", ProviderSubRef: "sub_9",
OutTradeNo: "PAY-9", AmountMinor: 2999, Currency: "USD", Status: model.SubActive}); err != nil {
t.Fatal(err)
}
// active → past_due
ok, _ := s.MarkPastDue("stripe", "sub_9")
if !ok {
t.Fatal("MarkPastDue should flip active→past_due")
}
// past_due → active(续费恢复)
ok, _ = s.Activate("SUB-9", &end)
if !ok {
t.Fatal("Activate should recover past_due→active")
}
// → canceled(终态)
ok, _ = s.MarkCanceled("SUB-9")
if !ok {
t.Fatal("MarkCanceled should flip →canceled")
}
// canceled 后不可复活
if ok, _ := s.Activate("SUB-9", &end); ok {
t.Fatal("canceled sub must not be re-activated")
}
if ok, _ := s.MarkPastDue("stripe", "sub_9"); ok {
t.Fatal("canceled sub must not go past_due")
}
got, err := s.GetBySubID("SUB-9")
if err != nil || got.Status != model.SubCanceled {
t.Fatalf("final status = %v err=%v", got.Status, err)
}
}
+19
View File
@@ -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 {
+70
View File
@@ -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)
}
}
+5 -1
View File
@@ -55,6 +55,8 @@ func main() {
// v2 统一网关装配(P2 骨架,P3 配置驱动注册接线):provider 注册表 + gateway + webhook notifier。
orderStore := store.NewOrderStore(db)
refundStore := store.NewRefundStore(db)
subStore := store.NewSubscriptionStore(db)
chargebackStore := store.NewChargebackStore(db)
webhookStore := store.NewWebhookStore(db)
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
@@ -71,7 +73,7 @@ func main() {
// limit_aware 用量数据源:P6 对账 Runner 周期 Refresh 的真实用量源(替 NopUsage)。
usage := reconcile.NewUsageSource(orderStore, time.Now)
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, usage)
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn")
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore, chargebackStore)
router.SetupV2(r, gw)
// P6 后台守护 / 对账:订单过期清理 + 用量刷新 + 查单对账收敛 + 已付抽查 +
@@ -166,6 +168,8 @@ func autoMigrate(db *gorm.DB) {
&model.NotifyLog{},
&model.BizNotifyLog{},
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, &model.OrphanPayment{}, // v2
&model.Subscription{}, // v2 recurring
&model.Chargeback{}, // v2 recurring — P8 Task6 拒付记录
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}