feat(v2): Provider 渠道抽象接口 + 6 render_type + PaidEvent + 注册表
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Package provider abstracts a payment channel behind a uniform interface:
|
||||
// create → {render_type, payload}, verify_callback / query → normalized PaidEvent.
|
||||
// Channel-specific quirks (RSA/cert/on-chain confirmations/receipt checks) are
|
||||
// sealed inside each Provider's VerifyCallback; the pipeline stays channel-neutral.
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
)
|
||||
|
||||
// RenderType — 客户端只认的 6 种付款意图形态(设计 §4.2),不含任何 UI。
|
||||
type RenderType string
|
||||
|
||||
const (
|
||||
RenderRedirect RenderType = "redirect"
|
||||
RenderQR RenderType = "qr"
|
||||
RenderCryptoAddress RenderType = "crypto_address"
|
||||
RenderNativePay RenderType = "native_pay"
|
||||
RenderSDKHandoff RenderType = "sdk_handoff"
|
||||
RenderIAPReceipt RenderType = "iap_receipt"
|
||||
)
|
||||
|
||||
// PaidStatus — verify_callback / query 归一化后的支付状态。
|
||||
type PaidStatus string
|
||||
|
||||
const (
|
||||
PaidPending PaidStatus = "pending"
|
||||
PaidSucceeded PaidStatus = "succeeded"
|
||||
PaidFailed PaidStatus = "failed"
|
||||
)
|
||||
|
||||
// Capabilities — 渠道能力自述(设计 §4.1 capabilities())。
|
||||
type Capabilities struct {
|
||||
RenderTypes []RenderType
|
||||
SupportsRefund bool
|
||||
SupportsRecurring bool
|
||||
RecurringKind string // token_offsession/gateway_scheduled/store_managed/none
|
||||
SettleCurrencies []string
|
||||
Regions []string
|
||||
}
|
||||
|
||||
// CreateRequest — Provider.Create 入参:pay 已定金额/币种/账户,Provider 拿去渠道建单。
|
||||
type CreateRequest struct {
|
||||
OutTradeNo string
|
||||
Subject string
|
||||
AmountMinor int64
|
||||
Currency string
|
||||
Account config.AccountConfig // 选中的收款账户(含 CredentialEnvPrefix)
|
||||
ReturnURL string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Session — Provider.Create 产出:渠道单号 + 付款意图数据(render_type + payload)。
|
||||
type Session struct {
|
||||
ProviderRef string
|
||||
RenderType RenderType
|
||||
Payload map[string]any
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CallbackInput — 渠道原始回调(HTTP body/头/query),由各 Provider 自行解析验签。
|
||||
type CallbackInput struct {
|
||||
Raw []byte
|
||||
Headers map[string]string
|
||||
Query map[string]string
|
||||
}
|
||||
|
||||
// PaidEvent — verify_callback / query 的统一产出(设计 §4.1 → {order_ref,status,paid_amount})。
|
||||
type PaidEvent struct {
|
||||
ProviderRef string
|
||||
Status PaidStatus
|
||||
PaidAmountMinor int64
|
||||
PaidCurrency string
|
||||
Raw string
|
||||
PaidAt *time.Time // 渠道报的支付时间;nil 则 settle 用收到时间,对账时两边时间才对得上
|
||||
}
|
||||
|
||||
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
|
||||
// crypto 自托管的"查单"= 按地址+期望金额+时间窗扫链核对;裸 ref 会逼渠道 adapter
|
||||
// 自建 ref→(地址/金额/窗口) 映射表,重复 pay 已持有的数据。管线侧(SyncPendingAttempts)
|
||||
// 本就拿着整个 attempt,填这个结构零成本。
|
||||
type QueryRequest struct {
|
||||
ProviderRef string
|
||||
OutTradeNo string
|
||||
AccountID string
|
||||
AmountMinor int64
|
||||
Currency string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUnknownMethod = errors.New("provider: unknown method")
|
||||
ErrNotSupported = errors.New("provider: capability not supported")
|
||||
)
|
||||
|
||||
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
|
||||
type Provider interface {
|
||||
Method() string
|
||||
Capabilities() Capabilities
|
||||
Create(ctx context.Context, req CreateRequest) (*Session, error)
|
||||
VerifyCallback(ctx context.Context, in CallbackInput) (*PaidEvent, error)
|
||||
Query(ctx context.Context, req QueryRequest) (*PaidEvent, error)
|
||||
}
|
||||
|
||||
// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4;不支持则 capabilities=false)。
|
||||
type RefundingProvider interface {
|
||||
Provider
|
||||
Refund(ctx context.Context, providerRef string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error)
|
||||
}
|
||||
|
||||
// RecurringProvider — 可选:支持自动续订(P8,设计 §5.1 4 类 kind)。
|
||||
type RecurringProvider interface {
|
||||
Provider
|
||||
CreateAgreement(ctx context.Context, req CreateRequest) (agreementRef string, err error)
|
||||
Charge(ctx context.Context, agreementRef string, amountMinor int64, currency string) (*PaidEvent, error)
|
||||
CancelAgreement(ctx context.Context, agreementRef string) error
|
||||
}
|
||||
|
||||
// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。
|
||||
type Registry struct{ providers map[string]Provider }
|
||||
|
||||
func NewRegistry() *Registry { return &Registry{providers: map[string]Provider{}} }
|
||||
|
||||
func (r *Registry) Register(p Provider) { r.providers[p.Method()] = p }
|
||||
|
||||
func (r *Registry) Get(method string) (Provider, error) {
|
||||
p, ok := r.providers[method]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, method)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Methods 返回已注册方法名(字典序,供 GET /methods 下发已启用渠道)。
|
||||
func (r *Registry) Methods() []string {
|
||||
out := make([]string, 0, len(r.providers))
|
||||
for m := range r.providers {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package provider_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
)
|
||||
|
||||
// stubProvider 最小实现,驱动 Provider 接口 + Registry 成型。
|
||||
type stubProvider struct{ method string }
|
||||
|
||||
func (s stubProvider) Method() string { return s.method }
|
||||
func (s stubProvider) Capabilities() provider.Capabilities { return provider.Capabilities{RenderTypes: []provider.RenderType{provider.RenderQR}} }
|
||||
func (s stubProvider) Create(context.Context, provider.CreateRequest) (*provider.Session, error) {
|
||||
return &provider.Session{ProviderRef: "R-1", RenderType: provider.RenderQR}, nil
|
||||
}
|
||||
func (s stubProvider) VerifyCallback(context.Context, provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidSucceeded}, nil
|
||||
}
|
||||
func (s stubProvider) Query(context.Context, provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
func TestRegistryRegisterGet(t *testing.T) {
|
||||
r := provider.NewRegistry()
|
||||
r.Register(stubProvider{method: "alipay"})
|
||||
r.Register(stubProvider{method: "crypto"})
|
||||
|
||||
p, err := r.Get("crypto")
|
||||
if err != nil || p.Method() != "crypto" {
|
||||
t.Fatalf("Get crypto = %v, %v", p, err)
|
||||
}
|
||||
if _, err := r.Get("nope"); !errors.Is(err, provider.ErrUnknownMethod) {
|
||||
t.Fatalf("未知 method 应返回 ErrUnknownMethod, got %v", err)
|
||||
}
|
||||
if got := r.Methods(); len(got) != 2 || got[0] != "alipay" || got[1] != "crypto" {
|
||||
t.Fatalf("Methods 应按字典序返回 [alipay crypto], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAndCaps(t *testing.T) {
|
||||
var _ provider.Provider = stubProvider{} // 编译期断言 stub 满足接口
|
||||
caps := stubProvider{}.Capabilities()
|
||||
if len(caps.RenderTypes) != 1 || caps.RenderTypes[0] != provider.RenderQR {
|
||||
t.Fatalf("caps = %+v", caps)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user