// Package gateway is the channel-neutral payment pipeline: it turns a client // {sku, method} into an authoritative Order + a payment Session (render_type + // payload), and settles callbacks/queries into paid + a business webhook. It // depends only on provider.Registry, store.OrderStore (P1), accounts.Registry // (P1) — never on a concrete channel. package gateway import ( "context" "errors" "time" "github.com/wangjia/pay/internal/accounts" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/provider" "github.com/wangjia/pay/internal/store" "github.com/wangjia/pay/internal/util" ) var ( ErrProductNotFound = errors.New("gateway: product not found") ErrNoAccount = errors.New("gateway: no enabled account for method/region") ErrOrderNotPending = errors.New("gateway: order not pending") ErrNoSettleCurrency = errors.New("gateway: channel has no settle currency") ErrCurrencyMismatch = errors.New("gateway: retry method settles a different currency") ) // ProductResolver maps a client SKU + settlement currency to authoritative amount. // Currency is chosen by the selected channel's SettleCurrencies (设计 §3.1/§4.1), // never sent by the client. type ProductResolver interface { Resolve(sku, currency string) (amountMinor int64, subject, bizCode string, err error) } // WebhookEnqueuer receives a domain payload to deliver to the business system. // refundID 为退款事件的幂等维度(payment 事件传 "")。 type WebhookEnqueuer interface { Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error } type Gateway struct { orders *store.OrderStore providers *provider.Registry picker accounts.Picker products ProductResolver webhook WebhookEnqueuer region string } func New(orders *store.OrderStore, providers *provider.Registry, picker accounts.Picker, products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway { return &Gateway{orders: orders, providers: providers, picker: picker, products: products, webhook: webhook, region: region} } type CreateOrderInput struct { SKU string Method string BizSystem string BizRef string ReturnURL string } type SessionView struct { RenderType string `json:"render_type"` Payload map[string]any `json:"payload"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type OrderResult struct { OrderNo string `json:"order_no"` Session SessionView `json:"session"` } // CreateOrder resolves the product (authoritative amount), picks a provider + // account, persists a pending Order + Attempt (P1 OrderStore), and returns the // payment session {render_type, payload}. 加渠道不改 client(设计 §4.2)。 func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderResult, error) { prov, err := g.providers.Get(in.Method) if err != nil { return nil, err // ErrUnknownMethod } caps := prov.Capabilities() if len(caps.SettleCurrencies) == 0 { return nil, ErrNoSettleCurrency } currency := caps.SettleCurrencies[0] // 结算币种由渠道自述能力驱动(设计 §4.1) amountMinor, subject, bizCode, err := g.products.Resolve(in.SKU, currency) if err != nil { return nil, err // ErrProductNotFound(含"该币种无价") } outNo := util.NewOutTradeNo("pay") acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{ OutTradeNo: outNo, AmountMinor: amountMinor, }) if err != nil { if errors.Is(err, accounts.ErrNoAccount) { return nil, ErrNoAccount } return nil, err } if err := g.orders.CreateOrder(&model.OrderV2{ OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode, Subject: subject, AmountMinor: amountMinor, Currency: currency, Status: model.OrderPendingV2, }); err != nil { return nil, err } sess, err := prov.Create(ctx, provider.CreateRequest{ OutTradeNo: outNo, Subject: subject, AmountMinor: amountMinor, Currency: currency, Account: acct, ReturnURL: in.ReturnURL, }) if err != nil { return nil, err } if err := g.orders.CreateAttempt(&model.Attempt{ OutTradeNo: outNo, Channel: in.Method, AccountID: acct.AccountID, Provider: prov.Method(), ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType), AmountMinor: amountMinor, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt, }); err != nil { return nil, err } return &OrderResult{OrderNo: outNo, Session: SessionView{ RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt, }}, nil } type OrderStatusView struct { OrderNo string `json:"order_no"` Status string `json:"status"` Subject string `json:"subject"` AmountMinor int64 `json:"amount_minor"` Currency string `json:"currency"` PaidAt *time.Time `json:"paid_at,omitempty"` } func (g *Gateway) GetOrder(outTradeNo string) (*OrderStatusView, error) { o, err := g.orders.GetOrder(outTradeNo) if err != nil { return nil, err // ErrOrderNotFound } return &OrderStatusView{ OrderNo: o.OutTradeNo, Status: string(o.Status), Subject: o.Subject, AmountMinor: o.AmountMinor, Currency: o.Currency, PaidAt: o.PaidAt, }, nil } // RetryOrder spawns a fresh attempt (possibly a different method) on a still-pending // order; old pending attempts are expired. attempt 超时 ≠ order 关闭(设计 §3.2)。 func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*OrderResult, error) { o, err := g.orders.GetOrder(outTradeNo) if err != nil { return nil, err } if o.Status != model.OrderPendingV2 { return nil, ErrOrderNotPending } prov, err := g.providers.Get(method) if err != nil { return nil, err } caps := prov.Capabilities() if len(caps.SettleCurrencies) == 0 || caps.SettleCurrencies[0] != o.Currency { // 换到结算币种不同的渠道重试 = 需重定价,超出 P3 范围(P5 多币种路由)。 return nil, ErrCurrencyMismatch } tried, err := g.orders.AttemptAccountIDs(outTradeNo, method) if err != nil { return nil, err } acct, err := g.picker.Pick(method, g.region, accounts.PickHint{ OutTradeNo: outTradeNo, AmountMinor: o.AmountMinor, ExcludeAccounts: tried, }) if err != nil { if errors.Is(err, accounts.ErrNoAccount) { return nil, ErrNoAccount } return nil, err } if _, err := g.orders.ExpirePendingAttempts(outTradeNo); err != nil { return nil, err } sess, err := prov.Create(ctx, provider.CreateRequest{ OutTradeNo: outTradeNo, Subject: o.Subject, AmountMinor: o.AmountMinor, Currency: o.Currency, Account: acct, }) if err != nil { return nil, err } if err := g.orders.CreateAttempt(&model.Attempt{ OutTradeNo: outTradeNo, Channel: method, AccountID: acct.AccountID, Provider: prov.Method(), ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType), AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt, }); err != nil { return nil, err } return &OrderResult{OrderNo: outTradeNo, Session: SessionView{ RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt, }}, nil } func (g *Gateway) CancelOrder(outTradeNo string) (bool, error) { return g.orders.CancelOrder(outTradeNo) }