diff --git a/internal/gateway/e2e_crypto_test.go b/internal/gateway/e2e_crypto_test.go index 93b2898..f813cf5 100644 --- a/internal/gateway/e2e_crypto_test.go +++ b/internal/gateway/e2e_crypto_test.go @@ -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{ diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 74ac9b7..3eafa7d 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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 { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 487f324..b32646f 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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 } diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go index 2526af5..a4659b0 100644 --- a/internal/gateway/settle.go +++ b/internal/gateway/settle.go @@ -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 diff --git a/internal/gateway/settle_test.go b/internal/gateway/settle_test.go index 5faf227..5f7673f 100644 --- a/internal/gateway/settle_test.go +++ b/internal/gateway/settle_test.go @@ -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"}) diff --git a/internal/gateway/subscription.go b/internal/gateway/subscription.go new file mode 100644 index 0000000..062f6f9 --- /dev/null +++ b/internal/gateway/subscription.go @@ -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), + }) +} diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go new file mode 100644 index 0000000..55def36 --- /dev/null +++ b/internal/gateway/subscription_test.go @@ -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) + } +} diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index bf1d443..adeb483 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -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+"失败,请稍后重试") diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index 713a000..90c5d3c 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -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) diff --git a/internal/handler/refund_test.go b/internal/handler/refund_test.go index fa1657a..e3297fa 100644 --- a/internal/handler/refund_test.go +++ b/internal/handler/refund_test.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index 974399c..0a09cf5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/main.go b/main.go index 55fcf5d..4cdabf5 100644 --- a/main.go +++ b/main.go @@ -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. 选策略(缺省 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 {