669774d0f7
让 pangolin(业务方)能在本地端到端测通:下单(method=fake)→显示假收款→手动
POST /api/v2/dev/orders/:order_no/mark-paid 模拟付款成功→pay 签名 webhook→pangolin
开通订阅。全部改动收在 env MOCK_CHANNEL_ENABLED 门控内,生产默认 false 零影响:
- providerbuild.BuildRegistry:门控下注册 fake provider,SettleCurrencies 覆盖成
["CNY","USDT"](CNY 排首位对齐 pangolin 实际下单币种),fake.go 加 SetSettleCurrencies
测试缝,默认("USDT")不变、不破坏既有测试。
- main.go:门控下注入内存 fake 账户(picker 才选得到 method=fake)+ seedPangolin()
幂等 upsert pro_month/pro_quarter/pro_year 三档商品 + 挂载 dev mark-paid 路由。
- config:新增 MOCK_CHANNEL_ENABLED / WEBHOOK_TICK_SECONDS(默认 60,本地可设 2 近实时
投递)两个 env 开关;config.yaml 补 biz.pangolin 声明(secret 走 BIZ_PANGOLIN_SECRET env)。
- internal/handler/devmock.go:MarkPaid 按 out_trade_no 取 fake 渠道最新 attempt,
原样组回调体走 gateway.HandleCallback("fake",...),与真实回调同一入口。
自测:go build/vet/test 全绿;起服务后签名下单(currency=CNY,与 pangolin 未传
currency 时的实际结算币种一致)→ mark-paid → 订单翻 paid → outbox ~2s 内尝试投递
(因本机未起 pangolin 收 404,预期内,证明 pay 侧链路已通)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
// Package fake is an in-process Provider used to exercise the pay v2 pipeline
|
|
// end-to-end without any real channel. Real adapters (crypto/alipay/stripe) land
|
|
// in P3. provider_ref = "FAKE-"+OutTradeNo+"-"+<nanosecond>; render_type = crypto_address.
|
|
// The nanosecond suffix is fake-only: Attempt has uniqueIndex(channel,provider_ref),
|
|
// and a retry on the same order/method would otherwise reuse the same OutTradeNo and
|
|
// collide. Real channels naturally mint a distinct ref per create call.
|
|
package fake
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wangjia/pay/internal/provider"
|
|
)
|
|
|
|
type Provider struct {
|
|
mu sync.Mutex
|
|
queryResults map[string]provider.PaidEvent
|
|
|
|
supportsRefund bool
|
|
refundRef string
|
|
refundStatus provider.PaidStatus
|
|
refundErr error
|
|
|
|
// settleCurrencies overrides the default ["USDT"] capability (测试缝/dev 缝:
|
|
// providerbuild.BuildRegistry 在 MOCK_CHANNEL_ENABLED 下把它设成 ["CNY","USDT"]
|
|
// 以对齐 pangolin 下单实际用的结算币种;默认 nil 时保持既有测试预期不变)。
|
|
settleCurrencies []string
|
|
|
|
lastMetadata map[string]string // 测试缝:记录最近一次 Create 收到的 Metadata(jiu 反馈波,验证 gateway 透传)
|
|
}
|
|
|
|
func New() *Provider { return &Provider{queryResults: map[string]provider.PaidEvent{}} }
|
|
|
|
func (p *Provider) Method() string { return "fake" }
|
|
|
|
// SetSettleCurrencies overrides Capabilities().SettleCurrencies (测试缝/dev 缝,见字段注释)。
|
|
// SettleCurrencies[0] 驱动 gateway.CreateOrder 实际选用的结算币种(设计 §4.1)。
|
|
func (p *Provider) SetSettleCurrencies(cs []string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.settleCurrencies = cs
|
|
}
|
|
|
|
func (p *Provider) Capabilities() provider.Capabilities {
|
|
p.mu.Lock()
|
|
cs := p.settleCurrencies
|
|
p.mu.Unlock()
|
|
if len(cs) == 0 {
|
|
cs = []string{"USDT"}
|
|
}
|
|
return provider.Capabilities{
|
|
RenderTypes: []provider.RenderType{provider.RenderCryptoAddress},
|
|
SupportsRefund: p.supportsRefund,
|
|
SettleCurrencies: cs,
|
|
Regions: []string{"global"},
|
|
}
|
|
}
|
|
|
|
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
|
|
p.mu.Lock()
|
|
p.lastMetadata = req.Metadata
|
|
p.mu.Unlock()
|
|
exp := time.Now().Add(15 * time.Minute)
|
|
ref := "FAKE-" + req.OutTradeNo + "-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
return &provider.Session{
|
|
ProviderRef: ref,
|
|
RenderType: provider.RenderCryptoAddress,
|
|
Payload: map[string]any{
|
|
"address": "TFake" + req.Account.AccountID + req.OutTradeNo,
|
|
"amount_minor": req.AmountMinor,
|
|
"currency": req.Currency,
|
|
},
|
|
ExpiresAt: &exp,
|
|
}, nil
|
|
}
|
|
|
|
// VerifyCallback 解析测试注入的 JSON 回调体成归一化 PaidEvent。
|
|
func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
|
var b struct {
|
|
ProviderRef string `json:"provider_ref"`
|
|
Status string `json:"status"`
|
|
AmountMinor int64 `json:"amount_minor"`
|
|
Currency string `json:"currency"`
|
|
}
|
|
if err := json.Unmarshal(in.Raw, &b); err != nil {
|
|
return nil, err
|
|
}
|
|
if b.ProviderRef == "" {
|
|
return nil, errors.New("fake: missing provider_ref")
|
|
}
|
|
return &provider.PaidEvent{
|
|
ProviderRef: b.ProviderRef,
|
|
Status: provider.PaidStatus(b.Status),
|
|
PaidAmountMinor: b.AmountMinor,
|
|
PaidCurrency: b.Currency,
|
|
Raw: string(in.Raw),
|
|
}, nil
|
|
}
|
|
|
|
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if ev, ok := p.queryResults[req.ProviderRef]; ok {
|
|
e := ev
|
|
return &e, nil
|
|
}
|
|
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
|
}
|
|
|
|
// LastMetadata returns the Metadata passed to the most recent Create call (test seam).
|
|
func (p *Provider) LastMetadata() map[string]string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.lastMetadata
|
|
}
|
|
|
|
// SetQueryResult primes Query to report a specific event (test seam).
|
|
func (p *Provider) SetQueryResult(providerRef string, ev provider.PaidEvent) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.queryResults[providerRef] = ev
|
|
}
|
|
|
|
// EnableRefund 令 fake 表现为可退渠道并预置一次退款结果(测试缝)。
|
|
func (p *Provider) EnableRefund(refundRef string, status provider.PaidStatus, err error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.supportsRefund = true
|
|
p.refundRef = refundRef
|
|
p.refundStatus = status
|
|
p.refundErr = err
|
|
}
|
|
|
|
// Refund implements provider.RefundingProvider (测试缝:忽略入参,回放预置结果)。
|
|
func (p *Provider) Refund(_ context.Context, _, _ string, _ int64, _ string) (string, provider.PaidStatus, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.refundErr != nil {
|
|
return "", provider.PaidFailed, p.refundErr
|
|
}
|
|
return p.refundRef, p.refundStatus, nil
|
|
}
|