feat(pay-v2): P8 Task3 订阅创建 + 首期激活(subscription.created)+ 端点

This commit is contained in:
wangjia
2026-07-10 17:59:35 +08:00
parent 83e3551609
commit 4c242607a7
12 changed files with 411 additions and 11 deletions
+2 -1
View File
@@ -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{
+3 -2
View File
@@ -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 {
+2 -1
View File
@@ -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
}
+41 -2
View File
@@ -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
+2 -1
View File
@@ -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"})
+128
View File
@@ -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),
})
}
+189
View File
@@ -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)
}
}
+35
View File
@@ -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+"失败,请稍后重试")
+4 -2
View File
@@ -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)
+2 -1
View File
@@ -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
+1
View File
@@ -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)
+2 -1
View File
@@ -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 {