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
202 lines
6.8 KiB
Go
202 lines
6.8 KiB
Go
package gateway_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/wangjia/pay/config"
|
|
"github.com/wangjia/pay/internal/accounts"
|
|
"github.com/wangjia/pay/internal/gateway"
|
|
"github.com/wangjia/pay/internal/model"
|
|
"github.com/wangjia/pay/internal/provider"
|
|
"github.com/wangjia/pay/internal/provider/fake"
|
|
"github.com/wangjia/pay/internal/store"
|
|
)
|
|
|
|
// --- 测试替身 ---
|
|
|
|
type stubResolver struct{}
|
|
|
|
func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) {
|
|
if sku != "pro_year" {
|
|
return 0, "", "", gateway.ErrProductNotFound
|
|
}
|
|
// 结算币种驱动金额:USDT 6 位, 其余按分。测试只用 fake(USDT)。
|
|
switch currency {
|
|
case "USDT":
|
|
return 29990000, "Pro 年付", "pro_year", nil
|
|
case "CNY":
|
|
return 19900, "Pro 年付", "pro_year", nil
|
|
default:
|
|
return 0, "", "", gateway.ErrProductNotFound
|
|
}
|
|
}
|
|
|
|
type spyEnqueuer struct {
|
|
calls []map[string]any
|
|
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
|
|
}
|
|
|
|
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
|
|
if s.failNext {
|
|
s.failNext = false
|
|
return errors.New("outbox down")
|
|
}
|
|
s.calls = append(s.calls, data)
|
|
return nil
|
|
}
|
|
|
|
func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *store.OrderStore) {
|
|
t.Helper()
|
|
db := model.OpenTestDB(t)
|
|
orders := store.NewOrderStore(db)
|
|
refunds := store.NewRefundStore(db)
|
|
preg := provider.NewRegistry()
|
|
fp := fake.New()
|
|
preg.Register(fp)
|
|
// fake 注册在 method "fake";账户按 channel="fake" region="global" 配。
|
|
areg := accounts.New([]config.AccountConfig{
|
|
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
|
{AccountID: "fake-a2", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
|
|
})
|
|
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
|
|
spy := &spyEnqueuer{}
|
|
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
|
|
return g, fp, spy, orders
|
|
}
|
|
|
|
func TestCreateOrderPipeline(t *testing.T) {
|
|
g, _, _, orders := newGateway(t)
|
|
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
|
|
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateOrder: %v", err)
|
|
}
|
|
if res.OrderNo == "" || res.Session.RenderType != string(provider.RenderCryptoAddress) {
|
|
t.Fatalf("result = %+v", res)
|
|
}
|
|
if res.Session.Payload["currency"] != "USDT" {
|
|
t.Fatalf("payload = %+v", res.Session.Payload)
|
|
}
|
|
// 落库:order pending + attempt pending 带 provider_ref。
|
|
o, err := orders.GetOrder(res.OrderNo)
|
|
if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 29990000 {
|
|
t.Fatalf("order = %+v, %v", o, err)
|
|
}
|
|
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
|
if len(atts) != 1 || atts[0].Channel != "fake" || atts[0].AccountID != "fake-a1" {
|
|
t.Fatalf("attempt = %+v", atts)
|
|
}
|
|
}
|
|
|
|
func TestCreateOrderPersistsBizCode(t *testing.T) {
|
|
g, _, _, orders := newGateway(t)
|
|
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
|
|
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
o, err := orders.GetOrder(res.OrderNo)
|
|
if err != nil {
|
|
t.Fatalf("get order: %v", err)
|
|
}
|
|
if o.BizCode != "pro_year" { // stubResolver 的 bizCode
|
|
t.Fatalf("order.BizCode = %q, want pro_year", o.BizCode)
|
|
}
|
|
}
|
|
|
|
func TestCreateOrderErrors(t *testing.T) {
|
|
g, _, _, _ := newGateway(t)
|
|
ctx := context.Background()
|
|
if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "nope", Method: "fake"}); err != gateway.ErrProductNotFound {
|
|
t.Fatalf("未知 sku 应 ErrProductNotFound, got %v", err)
|
|
}
|
|
if _, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "ghost"}); err == nil {
|
|
t.Fatalf("未知 method 应报错")
|
|
}
|
|
}
|
|
|
|
func TestRetryAndCancel(t *testing.T) {
|
|
g, _, _, orders := newGateway(t)
|
|
ctx := context.Background()
|
|
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
|
|
|
|
// retry:弃旧尝试 + 建新尝试(order 仍 pending)。
|
|
r2, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil)
|
|
if err != nil || r2.OrderNo != res.OrderNo {
|
|
t.Fatalf("retry = %+v, %v", r2, err)
|
|
}
|
|
pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
|
if len(pend) != 1 {
|
|
t.Fatalf("retry 后应恰 1 个 pending 尝试, got %d", len(pend))
|
|
}
|
|
|
|
// cancel pending → true;再 cancel → false。
|
|
ok, err := g.CancelOrder(res.OrderNo)
|
|
if err != nil || !ok {
|
|
t.Fatalf("cancel = %v, %v", ok, err)
|
|
}
|
|
if ok2, _ := g.CancelOrder(res.OrderNo); ok2 {
|
|
t.Fatalf("已取消单再取消应 false")
|
|
}
|
|
// canceled 单不可 retry。
|
|
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != gateway.ErrOrderNotPending {
|
|
t.Fatalf("canceled 单 retry 应 ErrOrderNotPending, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetrySwitchesAccount(t *testing.T) {
|
|
g, _, _, orders := newGateway(t)
|
|
ctx := context.Background()
|
|
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
|
|
|
|
// 首单落 fake-a1(round_robin 计数 0);retry 排除 a1 → 必落 fake-a2。
|
|
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != nil {
|
|
t.Fatalf("retry: %v", err)
|
|
}
|
|
pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
|
if len(pend) != 1 || pend[0].AccountID != "fake-a2" {
|
|
t.Fatalf("retry 应换到 fake-a2, got %+v", pend)
|
|
}
|
|
}
|
|
|
|
// jiu 反馈波 item 1:CreateOrderInput.Metadata / RetryOrder 的 metadata 参数须原样
|
|
// 透传到 provider.CreateRequest.Metadata,不能在 gateway 管线里被丢弃或改写。
|
|
func TestMetadataPassthroughToProvider(t *testing.T) {
|
|
g, fp, _, _ := newGateway(t)
|
|
ctx := context.Background()
|
|
|
|
res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{
|
|
SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1",
|
|
Metadata: map[string]string{"is_mobile": "1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if got := fp.LastMetadata(); got["is_mobile"] != "1" {
|
|
t.Fatalf("CreateOrder 后 provider 收到的 metadata = %+v, want is_mobile=1", got)
|
|
}
|
|
|
|
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", map[string]string{"render": "qr"}); err != nil {
|
|
t.Fatalf("retry: %v", err)
|
|
}
|
|
if got := fp.LastMetadata(); got["render"] != "qr" {
|
|
t.Fatalf("RetryOrder 后 provider 收到的 metadata = %+v, want render=qr", got)
|
|
}
|
|
}
|
|
|
|
func TestCreateOrderCurrencyFromChannelCapability(t *testing.T) {
|
|
g, _, _, orders := newGateway(t) // fake provider, SettleCurrencies=["USDT"]
|
|
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
o, _ := orders.GetOrder(res.OrderNo)
|
|
if o.Currency != "USDT" || o.AmountMinor != 29990000 {
|
|
t.Fatalf("order = %s/%d want USDT/29990000", o.Currency, o.AmountMinor)
|
|
}
|
|
}
|