feat(v2): 一次性收款管线 CreateOrder/GetOrder/Retry/Cancel + DBProductResolver
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// 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")
|
||||
)
|
||||
|
||||
// ProductResolver maps a client-facing SKU to the authoritative amount/currency.
|
||||
// Amount authority lives in pay (设计 §3.1); the client never sends raw amounts.
|
||||
type ProductResolver interface {
|
||||
Resolve(sku string) (amountMinor int64, currency, subject, bizCode string, err error)
|
||||
}
|
||||
|
||||
// WebhookEnqueuer receives a domain payload to deliver to the business system.
|
||||
type WebhookEnqueuer interface {
|
||||
Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
orders *store.OrderStore
|
||||
providers *provider.Registry
|
||||
accounts *accounts.Registry
|
||||
products ProductResolver
|
||||
webhook WebhookEnqueuer
|
||||
region string
|
||||
}
|
||||
|
||||
func New(orders *store.OrderStore, providers *provider.Registry, accts *accounts.Registry,
|
||||
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
|
||||
return &Gateway{orders: orders, providers: providers, accounts: accts,
|
||||
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) {
|
||||
amountMinor, currency, subject, _, err := g.products.Resolve(in.SKU)
|
||||
if err != nil {
|
||||
return nil, err // ErrProductNotFound
|
||||
}
|
||||
prov, err := g.providers.Get(in.Method)
|
||||
if err != nil {
|
||||
return nil, err // ErrUnknownMethod
|
||||
}
|
||||
accts := g.accounts.EnabledFor(in.Method, g.region)
|
||||
if len(accts) == 0 {
|
||||
return nil, ErrNoAccount
|
||||
}
|
||||
acct := accts[0] // 路由策略(round_robin/weighted/…)在 P5;P2 取首个 enabled。
|
||||
|
||||
outNo := util.NewOutTradeNo("pay")
|
||||
if err := g.orders.CreateOrder(&model.OrderV2{
|
||||
OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef,
|
||||
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
|
||||
}
|
||||
accts := g.accounts.EnabledFor(method, g.region)
|
||||
if len(accts) == 0 {
|
||||
return nil, ErrNoAccount
|
||||
}
|
||||
acct := accts[0]
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user