diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..5e27b94 --- /dev/null +++ b/internal/provider/provider.go @@ -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 +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..2a79133 --- /dev/null +++ b/internal/provider/provider_test.go @@ -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) + } +}