8051b0fb16
1. CreateOrderInput/RetryOrder 加 Metadata 通道,handler 白名单过滤(is_mobile/render)
后原样传给 provider.CreateRequest;alipay adapter 据 is_mobile 选 wap/page。
2. alipay adapter 移植 v1 当面付(TradePreCreate):Metadata["render"]=="qr" → 二维码
render_type,payload={qr_content,display_amount,currency},默认 2 小时窗口。
3. NewOutTradeNo 随机部分 8→16 hex 防生日碰撞(仍 <=64 字符,合规 DB 列/支付宝上限);
v2 改状态端点(下单/重试/取消)加 per-IP 内存令牌桶限流,默认开 30/min,
config.rate_limit.disabled 可关;callback/GET 查询不限。
顺带修:internal/reconcile/sync_test.go 的 gateway.New 调用漏传 refunds store,
预先存在的编译期回归(与本次改动无关,但挡住 go test ./... 全绿,一并修掉)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
216 lines
7.5 KiB
Go
216 lines
7.5 KiB
Go
// 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
|
|
refunds *store.RefundStore
|
|
providers *provider.Registry
|
|
picker accounts.Picker
|
|
products ProductResolver
|
|
webhook WebhookEnqueuer
|
|
region string
|
|
}
|
|
|
|
func New(orders *store.OrderStore, refunds *store.RefundStore, providers *provider.Registry, picker accounts.Picker,
|
|
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
|
|
return &Gateway{orders: orders, refunds: refunds, providers: providers, picker: picker,
|
|
products: products, webhook: webhook, region: region}
|
|
}
|
|
|
|
type CreateOrderInput struct {
|
|
SKU string
|
|
Method string
|
|
BizSystem string
|
|
BizRef string
|
|
ReturnURL string
|
|
// Metadata 透传给 provider.CreateRequest(如 alipay 用 is_mobile 选 wap/page、
|
|
// render=qr 选当面付)。handler 层已按白名单过滤,这里原样传,nil 安全。
|
|
Metadata map[string]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, Metadata: in.Metadata,
|
|
})
|
|
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)。
|
|
// metadata 同 CreateOrderInput.Metadata,原样透传给新尝试的 CreateRequest(nil 安全)。
|
|
func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string, metadata map[string]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, Metadata: metadata,
|
|
})
|
|
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)
|
|
}
|