feat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点
This commit is contained in:
@@ -45,6 +45,7 @@ func TestE2ECryptoQuerySettles(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
acctReg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "e2e-1", Channel: "crypto", Enabled: true, Region: "global", CredentialEnvPrefix: "e2e"},
|
||||
})
|
||||
@@ -56,7 +57,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)
|
||||
|
||||
// 下单 → 从 session payload 拿到期望链上金额(base+唯一尾数),喂给假 TronGrid。
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
|
||||
|
||||
@@ -46,12 +46,13 @@ type Gateway struct {
|
||||
products ProductResolver
|
||||
webhook WebhookEnqueuer
|
||||
region string
|
||||
subs *store.SubscriptionStore
|
||||
}
|
||||
|
||||
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) *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}
|
||||
}
|
||||
|
||||
type CreateOrderInput struct {
|
||||
|
||||
@@ -52,6 +52,7 @@ 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)
|
||||
preg := provider.NewRegistry()
|
||||
fp := fake.New()
|
||||
preg.Register(fp)
|
||||
@@ -62,7 +63,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)
|
||||
return g, fp, spy, orders
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -96,7 +97,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 +109,43 @@ 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, ev) // Task 4
|
||||
case provider.EventSubscriptionPastDue:
|
||||
return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
|
||||
case provider.EventSubscriptionCanceled:
|
||||
return g.settleSubscriptionCanceled(ctx, ev) // Task 5
|
||||
case provider.EventChargeback:
|
||||
return g.recordChargeback(ctx, method, ev) // Task 6
|
||||
default: // EventPayment:一次性 / 订阅首期
|
||||
res, serr := g.Settle(ctx, ev)
|
||||
if serr == nil && ev.SubscriptionRef != "" {
|
||||
if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil {
|
||||
return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投)
|
||||
}
|
||||
}
|
||||
return res, serr
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task 4/5/6 处理器占位(本 Task 只需 default 分支可用 + onSubscriptionActivated)。
|
||||
// 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。
|
||||
|
||||
func (g *Gateway) settleRenewal(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
||||
}
|
||||
|
||||
func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
||||
}
|
||||
|
||||
func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
||||
}
|
||||
|
||||
func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
|
||||
}
|
||||
|
||||
// SyncPendingAttempts polls every pending attempt via its Provider.Query and
|
||||
|
||||
@@ -138,6 +138,7 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
fp := fake.New()
|
||||
preg.Register(fp)
|
||||
@@ -146,7 +147,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)
|
||||
|
||||
ctx := context.Background()
|
||||
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
"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)。
|
||||
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
|
||||
}
|
||||
subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
|
||||
created, 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 !created || o.BizSystem == "" {
|
||||
return nil // 已诞生过(重投)/ 独立收款无业务方回调
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package gateway_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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
|
||||
}
|
||||
|
||||
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, _ string) error { return nil }
|
||||
|
||||
// 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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,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"))
|
||||
@@ -158,6 +191,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+"失败,请稍后重试")
|
||||
|
||||
@@ -44,13 +44,14 @@ 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)
|
||||
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)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, orders
|
||||
@@ -177,6 +178,7 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(fake.New()) // method="fake", settles in "USDT"
|
||||
|
||||
@@ -189,7 +191,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)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
|
||||
|
||||
@@ -44,11 +44,12 @@ 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)
|
||||
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)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, g, orders, fp
|
||||
|
||||
@@ -45,6 +45,7 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) {
|
||||
v2 := r.Group("/api/v2")
|
||||
{
|
||||
v2.POST("/orders", h.CreateOrder)
|
||||
v2.POST("/subscriptions", h.CreateSubscription)
|
||||
v2.GET("/orders/:order_no", h.GetStatus)
|
||||
v2.POST("/orders/:order_no/retry", h.Retry)
|
||||
v2.POST("/orders/:order_no/cancel", h.Cancel)
|
||||
|
||||
@@ -41,6 +41,7 @@ func main() {
|
||||
// v2 统一网关装配(P2 骨架,P3 配置驱动注册接线):provider 注册表 + gateway + webhook notifier。
|
||||
orderStore := store.NewOrderStore(db)
|
||||
refundStore := store.NewRefundStore(db)
|
||||
subStore := store.NewSubscriptionStore(db)
|
||||
webhookStore := store.NewWebhookStore(db)
|
||||
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
|
||||
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
|
||||
@@ -56,7 +57,7 @@ func main() {
|
||||
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
|
||||
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
|
||||
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, accounts.NopUsage{})
|
||||
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn")
|
||||
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore)
|
||||
router.SetupV2(r, gw)
|
||||
|
||||
if config.C.QuerySync.Enabled {
|
||||
|
||||
Reference in New Issue
Block a user