From 8051b0fb169dc62e9bd4c35ed416f1bb666db7b0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 18:40:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20jiu=20=E5=8F=8D=E9=A6=88=E6=B3=A2?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=AB=AF=E5=9E=8B=E9=80=8F=E4=BC=A0(wap/page?= =?UTF-8?q?)+=20alipay=20qr(=E5=BD=93=E9=9D=A2=E4=BB=98=E7=A7=BB=E6=A4=8D)?= =?UTF-8?q?+=20=E5=8D=95=E5=8F=B7=E5=A2=9E=E7=86=B5/=E6=8C=A1=E6=9D=BF?= =?UTF-8?q?=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- config/config.go | 13 ++ internal/gateway/gateway.go | 10 +- internal/gateway/gateway_test.go | 31 ++++- internal/handler/gateway.go | 35 ++++- internal/handler/metadata_test.go | 54 ++++++++ internal/handler/ratelimit_test.go | 101 ++++++++++++++ internal/middleware/ratelimit.go | 98 ++++++++++++++ internal/middleware/ratelimit_test.go | 87 ++++++++++++ internal/provider/alipay/alipay.go | 50 ++++++- internal/provider/alipay/alipay_qr_test.go | 146 +++++++++++++++++++++ internal/provider/fake/fake.go | 12 ++ internal/reconcile/sync_test.go | 3 +- internal/router/router.go | 24 +++- internal/util/idgen.go | 9 +- internal/util/idgen_test.go | 42 ++++++ 15 files changed, 698 insertions(+), 17 deletions(-) create mode 100644 internal/handler/metadata_test.go create mode 100644 internal/handler/ratelimit_test.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/middleware/ratelimit_test.go create mode 100644 internal/provider/alipay/alipay_qr_test.go create mode 100644 internal/util/idgen_test.go diff --git a/config/config.go b/config/config.go index 071c1a9..81e8abd 100644 --- a/config/config.go +++ b/config/config.go @@ -22,6 +22,17 @@ type Config struct { // Routing 路由策略:channel → 策略名(round_robin/weighted/limit_aware)。 // 缺省/未知一律当 round_robin(见 P5 计划 D3)。密钥无关,可写 config.yaml。 Routing map[string]string `mapstructure:"routing"` + // RateLimit v2 改状态端点(下单/重试/取消)的 per-IP 限流(jiu 反馈波)。 + RateLimit RateLimitConfig `mapstructure:"rate_limit"` +} + +// RateLimitConfig v2 网关 per-IP 限流开关与速率。字段故意叫 Disabled 而非 Enabled—— +// 零值(未在 config.yaml 配置该段,或测试里 config.C 只赋值了别的字段)语义须是 +// "默认开",与 Go bool 零值 false 自然对齐;显式 disabled:true 才关闭。 +// RequestsPerMin<=0(含零值)由 middleware.NewIPRateLimiter 兜底成 30/min。 +type RateLimitConfig struct { + Disabled bool `mapstructure:"disabled"` + RequestsPerMin int `mapstructure:"requests_per_min"` } // AccountConfig 单个收款账户的配置(v2 多账户路由用)。凭证严禁写进 config.yaml, @@ -164,6 +175,8 @@ func Load() { viper.SetDefault("reconcile.refund_apply_every_sec", 300) viper.SetDefault("reconcile.refund_stuck_warn_min", 30) viper.SetDefault("reconcile.refund_apply_lookback_min", 48*60) + viper.SetDefault("rate_limit.disabled", false) + viper.SetDefault("rate_limit.requests_per_min", 30) if err := viper.ReadInConfig(); err != nil { log.Println("[config] 未找到 config.yaml,使用默认值 + 环境变量") diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 74ac9b7..f03ba1e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -60,6 +60,9 @@ type CreateOrderInput struct { 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 { @@ -111,7 +114,7 @@ func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderR sess, err := prov.Create(ctx, provider.CreateRequest{ OutTradeNo: outNo, Subject: subject, AmountMinor: amountMinor, - Currency: currency, Account: acct, ReturnURL: in.ReturnURL, + Currency: currency, Account: acct, ReturnURL: in.ReturnURL, Metadata: in.Metadata, }) if err != nil { return nil, err @@ -152,7 +155,8 @@ func (g *Gateway) GetOrder(outTradeNo string) (*OrderStatusView, error) { // RetryOrder spawns a fresh attempt (possibly a different method) on a still-pending // order; old pending attempts are expired. attempt 超时 ≠ order 关闭(设计 §3.2)。 -func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*OrderResult, error) { +// 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 @@ -188,7 +192,7 @@ func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*O } sess, err := prov.Create(ctx, provider.CreateRequest{ OutTradeNo: outTradeNo, Subject: o.Subject, AmountMinor: o.AmountMinor, - Currency: o.Currency, Account: acct, + Currency: o.Currency, Account: acct, Metadata: metadata, }) if err != nil { return nil, err diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 487f324..b2a43a4 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -125,7 +125,7 @@ func TestRetryAndCancel(t *testing.T) { 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") + r2, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil) if err != nil || r2.OrderNo != res.OrderNo { t.Fatalf("retry = %+v, %v", r2, err) } @@ -143,7 +143,7 @@ func TestRetryAndCancel(t *testing.T) { t.Fatalf("已取消单再取消应 false") } // canceled 单不可 retry。 - if _, err := g.RetryOrder(ctx, res.OrderNo, "fake"); err != gateway.ErrOrderNotPending { + if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != gateway.ErrOrderNotPending { t.Fatalf("canceled 单 retry 应 ErrOrderNotPending, got %v", err) } } @@ -154,7 +154,7 @@ func TestRetrySwitchesAccount(t *testing.T) { 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"); err != nil { + if _, err := g.RetryOrder(ctx, res.OrderNo, "fake", nil); err != nil { t.Fatalf("retry: %v", err) } pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) @@ -163,6 +163,31 @@ func TestRetrySwitchesAccount(t *testing.T) { } } +// 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"}) diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index bf1d443..977a94e 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -27,6 +27,35 @@ type createV2Request struct { BizSystem string `json:"biz_system,omitempty"` BizRef string `json:"biz_ref,omitempty"` ReturnURL string `json:"return_url,omitempty"` + // Metadata 端型/渲染意图等透传给 provider(如 alipay is_mobile/render)。 + // 过白名单(allowedMetadataKeys)才放行,防业务方任意塞值注入 provider 内部逻辑。 + Metadata map[string]string `json:"metadata,omitempty"` +} + +// allowedMetadataKeys 是 createV2Request/retryRequest.Metadata 能透传给 +// provider.CreateRequest 的键白名单:is_mobile(alipay 选 wap/page 收银台)、 +// render(如 alipay render=qr 选当面付)。未在此列的键一律丢弃,不放过管线。 +var allowedMetadataKeys = map[string]bool{ + "is_mobile": true, + "render": true, +} + +// filterMetadata 只保留白名单键,空结果返回 nil(与 CreateOrderInput.Metadata 的 +// nil-safe 约定一致)。 +func filterMetadata(raw map[string]string) map[string]string { + if len(raw) == 0 { + return nil + } + out := make(map[string]string, len(raw)) + for k, v := range raw { + if allowedMetadataKeys[k] { + out[k] = v + } + } + if len(out) == 0 { + return nil + } + return out } // CreateOrder POST /api/v2/orders —— 下单,返回 {order_no, session:{render_type, payload}}。 @@ -54,6 +83,7 @@ func (h *GatewayHandler) CreateOrder(c *gin.Context) { } res, err := h.g.CreateOrder(c.Request.Context(), gateway.CreateOrderInput{ SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL, + Metadata: filterMetadata(req.Metadata), }) if err != nil { h.writeCreateErr(c, "下单", req.Method, err) @@ -73,7 +103,8 @@ func (h *GatewayHandler) GetStatus(c *gin.Context) { } type retryRequest struct { - Method string `json:"method"` + Method string `json:"method"` + Metadata map[string]string `json:"metadata,omitempty"` // 同 createV2Request.Metadata,过同一白名单 } // Retry POST /api/v2/orders/:order_no/retry @@ -88,7 +119,7 @@ func (h *GatewayHandler) Retry(c *gin.Context) { util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 method") return } - res, err := h.g.RetryOrder(c.Request.Context(), c.Param("order_no"), req.Method) + res, err := h.g.RetryOrder(c.Request.Context(), c.Param("order_no"), req.Method, filterMetadata(req.Metadata)) if err != nil { if errors.Is(err, gateway.ErrOrderNotPending) { util.RespondError(c, http.StatusConflict, "order_not_pending", "订单非待支付态,不可重试") diff --git a/internal/handler/metadata_test.go b/internal/handler/metadata_test.go new file mode 100644 index 0000000..a38de08 --- /dev/null +++ b/internal/handler/metadata_test.go @@ -0,0 +1,54 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "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/router" + "github.com/wangjia/pay/internal/store" +) + +// jiu 反馈波 item 1:POST /api/v2/orders 的 metadata 字段只应放行白名单键 +// (is_mobile/render),任意其它键(潜在注入 provider 内部逻辑)必须被丢弃。 +func TestV2CreateOrderMetadataWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + fp := fake.New() + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + + w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{ + "sku": "pro_year", "method": "fake", + "metadata": map[string]any{ + "is_mobile": "1", + "evil_key": "inject-me", // 不在白名单,必须被丢弃 + }, + }) + if w.Code != http.StatusOK { + t.Fatalf("create code=%d body=%v", w.Code, out) + } + got := fp.LastMetadata() + if got["is_mobile"] != "1" { + t.Fatalf("白名单键 is_mobile 应透传给 provider, got %+v", got) + } + if _, ok := got["evil_key"]; ok { + t.Fatalf("非白名单键 evil_key 不应透传给 provider, got %+v", got) + } +} diff --git a/internal/handler/ratelimit_test.go b/internal/handler/ratelimit_test.go new file mode 100644 index 0000000..ba80d27 --- /dev/null +++ b/internal/handler/ratelimit_test.go @@ -0,0 +1,101 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "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/router" + "github.com/wangjia/pay/internal/store" +) + +// jiu 反馈波 item 3:v2 改状态端点(POST /orders 等)超过 config.RateLimit 设定的 +// per-IP 速率应回 429。config.C 用完整重赋值 + defer 恢复(与 refund_test.go +// buildRefundEngine 同一套约定),避免污染同包其它测试的全局配置状态。 +func TestV2CreateOrderRateLimited(t *testing.T) { + gin.SetMode(gin.TestMode) + prev := config.C + config.C = config.Config{RateLimit: config.RateLimitConfig{RequestsPerMin: 2}} + defer func() { config.C = prev }() + + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + preg.Register(fake.New()) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + + body := map[string]any{"sku": "pro_year", "method": "fake"} + var codes []int + for i := 0; i < 3; i++ { + w, _ := do(t, r, http.MethodPost, "/api/v2/orders", body) + codes = append(codes, w.Code) + } + if codes[0] != http.StatusOK || codes[1] != http.StatusOK { + t.Fatalf("前 2 次(= burst=RequestsPerMin)应放行, got codes=%v", codes) + } + if codes[2] != http.StatusTooManyRequests { + t.Fatalf("第 3 次(超 burst)应 429, got codes=%v", codes) + } +} + +// callback 端点(渠道来源,不是终端用户)不挂限流:哪怕下单类速率上限压得很低, +// 回调也不该被误伤,否则会把渠道异步通知的正常重投当成攻击拦掉。 +func TestV2CallbackNotRateLimited(t *testing.T) { + gin.SetMode(gin.TestMode) + prev := config.C + config.C = config.Config{RateLimit: config.RateLimitConfig{RequestsPerMin: 1}} + defer func() { config.C = prev }() + + r, _ := buildEngineWithStore(t) + for i := 0; i < 5; i++ { + w, _ := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{ + "provider_ref": "GHOST", "status": "succeeded", "amount_minor": 1, "currency": "USDT", + }) + if w.Code != http.StatusOK { + t.Fatalf("callback 第 %d 次不应被限流, code=%d", i+1, w.Code) + } + } +} + +// disabled:true 应完全放行改状态端点,不管速率配多低。 +func TestV2RateLimitDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + prev := config.C + config.C = config.Config{RateLimit: config.RateLimitConfig{Disabled: true, RequestsPerMin: 1}} + defer func() { config.C = prev }() + + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + preg := provider.NewRegistry() + preg.Register(fake.New()) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global") + r := gin.New() + router.SetupV2(r, g) + + body := map[string]any{"sku": "pro_year", "method": "fake"} + for i := 0; i < 5; i++ { + w, _ := do(t, r, http.MethodPost, "/api/v2/orders", body) + if w.Code != http.StatusOK { + t.Fatalf("disabled=true 时第 %d 次不应被限流, code=%d", i+1, w.Code) + } + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..388eb0d --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,98 @@ +// Package middleware holds gin-agnostic-ish HTTP cross-cutting concerns +// (currently: per-IP rate limiting) shared by the v2 gateway router. +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// bucket 单个 IP 的令牌桶状态。 +type bucket struct { + mu sync.Mutex + tokens float64 + updated time.Time +} + +// IPRateLimiter 简单内存令牌桶,按客户端 IP 分桶限速——单实例部署足够(jiu 反馈波, +// P3 v2 限流纵深)。多实例横向扩展需换外置存储(Redis INCR+EXPIRE 等)统一计数, +// 当前 pay 只跑单进程,内存方案够用且零额外依赖。 +type IPRateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // 每秒补充的令牌数 + burst float64 // 桶容量(=突发上限,取每分钟额度) + sweepCounter uint64 +} + +// NewIPRateLimiter 按每分钟额度构造限流器;perMinute<=0 时按 30 兜底(与 config 里 +// "零值=默认开、默认 30/min" 的约定一致,调用方无需重复判空)。 +func NewIPRateLimiter(perMinute int) *IPRateLimiter { + if perMinute <= 0 { + perMinute = 30 + } + return &IPRateLimiter{ + buckets: make(map[string]*bucket), + rate: float64(perMinute) / 60.0, + burst: float64(perMinute), + } +} + +// allow 令牌桶判定:按经过时间匀速补充令牌(封顶 burst),够 1 个则放行并扣 1。 +func (l *IPRateLimiter) allow(ip string) bool { + now := time.Now() + l.mu.Lock() + b, ok := l.buckets[ip] + if !ok { + b = &bucket{tokens: l.burst, updated: now} + l.buckets[ip] = b + } + l.sweepCounter++ + if l.sweepCounter%1000 == 0 { + l.sweepLocked(now) + } + l.mu.Unlock() + + b.mu.Lock() + defer b.mu.Unlock() + elapsed := now.Sub(b.updated).Seconds() + b.tokens += elapsed * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.updated = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// sweepLocked 惰性回收长期不活跃(>10min 无请求)的 IP 桶,防长期运行的单实例 +// 在"短连接、海量不同 IP"场景下内存无界增长。调用方须已持 l.mu。 +func (l *IPRateLimiter) sweepLocked(now time.Time) { + for ip, b := range l.buckets { + b.mu.Lock() + stale := now.Sub(b.updated) > 10*time.Minute + b.mu.Unlock() + if stale { + delete(l.buckets, ip) + } + } +} + +// Gin 返回 gin 中间件:超速回 429,不中断其它路由。 +func (l *IPRateLimiter) Gin() gin.HandlerFunc { + return func(c *gin.Context) { + if !l.allow(c.ClientIP()) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": "rate_limited", "message": "请求过于频繁,请稍后重试", + }) + return + } + c.Next() + } +} diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go new file mode 100644 index 0000000..eb1ab79 --- /dev/null +++ b/internal/middleware/ratelimit_test.go @@ -0,0 +1,87 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/internal/middleware" +) + +func newTestEngine(rl *middleware.IPRateLimiter) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/x", rl.Gin(), func(c *gin.Context) { c.Status(http.StatusOK) }) + return r +} + +// burst(=RequestsPerMin)个请求应放行,第 burst+1 个应 429。 +func TestIPRateLimiterBurstThenBlocks(t *testing.T) { + rl := middleware.NewIPRateLimiter(3) + r := newTestEngine(rl) + + for i := 0; i < 3; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) + if w.Code != http.StatusOK { + t.Fatalf("第 %d 次(burst 内)应 200, got %d", i+1, w.Code) + } + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("第 4 次(超 burst)应 429, got %d", w.Code) + } +} + +// 不同 IP 各自独立计桶,互不影响。 +func TestIPRateLimiterPerIPIsolated(t *testing.T) { + rl := middleware.NewIPRateLimiter(1) + r := newTestEngine(rl) + + req1 := httptest.NewRequest(http.MethodGet, "/x", nil) + req1.RemoteAddr = "10.0.0.1:1234" + w1 := httptest.NewRecorder() + r.ServeHTTP(w1, req1) + if w1.Code != http.StatusOK { + t.Fatalf("IP1 第 1 次应 200, got %d", w1.Code) + } + + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.RemoteAddr = "10.0.0.2:1234" + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("IP2(独立桶)第 1 次应 200, got %d", w2.Code) + } + + // IP1 burst=1 已耗尽,第 2 次应 429。 + req3 := httptest.NewRequest(http.MethodGet, "/x", nil) + req3.RemoteAddr = "10.0.0.1:1234" + w3 := httptest.NewRecorder() + r.ServeHTTP(w3, req3) + if w3.Code != http.StatusTooManyRequests { + t.Fatalf("IP1 第 2 次(超 burst)应 429, got %d", w3.Code) + } +} + +// perMinute<=0 兜底成默认 30/min(config.RateLimitConfig 零值语义:"零值=默认开")。 +func TestNewIPRateLimiterZeroDefaultsTo30(t *testing.T) { + rl := middleware.NewIPRateLimiter(0) + r := newTestEngine(rl) + + for i := 0; i < 30; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) + if w.Code != http.StatusOK { + t.Fatalf("零值兜底 30/min:第 %d 次应 200, got %d", i+1, w.Code) + } + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil)) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("第 31 次(超默认 30/min)应 429, got %d", w.Code) + } +} diff --git a/internal/provider/alipay/alipay.go b/internal/provider/alipay/alipay.go index 620ebd7..505f87a 100644 --- a/internal/provider/alipay/alipay.go +++ b/internal/provider/alipay/alipay.go @@ -23,6 +23,11 @@ const gmtPaymentLayout = "2006-01-02 15:04:05" var cst = time.FixedZone("CST", 8*3600) +// qrDefaultTTL 当面付(TradePreCreate)未显式设置 timeout_express 时,支付宝按 +// 官方文档默认 2 小时未支付自动关闭交易;这里镜像同一默认值给客户端展示倒计时用, +// 不代表 pay 侧另设了过期逻辑(真实过期仍由支付宝侧判定,pay 靠回调/查单收敛)。 +const qrDefaultTTL = 2 * time.Hour + type Provider struct{ client *sw.Client } func New(client *sw.Client) *Provider { return &Provider{client: client} } @@ -31,7 +36,7 @@ func (p *Provider) Method() string { return "alipay" } func (p *Provider) Capabilities() provider.Capabilities { return provider.Capabilities{ - RenderTypes: []provider.RenderType{provider.RenderRedirect}, + RenderTypes: []provider.RenderType{provider.RenderRedirect, provider.RenderQR}, SupportsRefund: true, // P4:alipay.trade.refund 同步退款 SettleCurrencies: []string{"CNY"}, Regions: []string{"cn"}, @@ -66,7 +71,7 @@ func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amo return refundID, provider.PaidSucceeded, nil } -func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { +func (p *Provider) Create(ctx context.Context, req provider.CreateRequest) (*provider.Session, error) { if req.Currency != "CNY" { return nil, fmt.Errorf("alipay: 仅支持 CNY, got %s", req.Currency) } @@ -74,8 +79,12 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi if err != nil { return nil, err } + // render=qr:当面付(扫码收银台,不跳转)——移植自 v1 internal/channel/alipay.go PreCreate。 + if req.Metadata["render"] == "qr" { + return p.createQR(ctx, req, amount) + } var payURL *url.URL - if req.Metadata["is_mobile"] == "1" { + if isMobileMetadata(req.Metadata) { wp := sw.TradeWapPay{} wp.OutTradeNo = req.OutTradeNo wp.Subject = req.Subject @@ -103,6 +112,41 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi }, nil } +// isMobileMetadata 判端型:白名单值 "1"/"true" 均视为手机端(handler 层 resolveIsMobile +// 目前下发 "1",这里同时兼容 "true" 以防未来调用方改用布尔字面量字符串)。 +func isMobileMetadata(md map[string]string) bool { + v := md["is_mobile"] + return v == "1" || v == "true" +} + +// createQR 当面付(alipay.trade.precreate):返回二维码码串供前端渲染,不跳转、不拉起 App, +// 收银台之外的第三个 render_type(RenderQR)——与 wap/page 共用 VerifyCallback/Query +// (同一套异步通知 + trade_query,渠道侧不区分下单方式)。 +func (p *Provider) createQR(ctx context.Context, req provider.CreateRequest, amount string) (*provider.Session, error) { + pc := sw.TradePreCreate{} + pc.OutTradeNo = req.OutTradeNo + pc.Subject = req.Subject + pc.TotalAmount = amount + rsp, err := p.client.TradePreCreate(ctx, pc) + if err != nil { + return nil, fmt.Errorf("alipay: 预下单调用失败: %w", err) + } + if rsp.IsFailure() { + return nil, fmt.Errorf("alipay: 预下单失败: %s / %s", rsp.Msg, rsp.SubMsg) + } + exp := time.Now().Add(qrDefaultTTL) + return &provider.Session{ + ProviderRef: req.OutTradeNo, // 支付宝以 out_trade_no 归位,与 wap/page 一致 + RenderType: provider.RenderQR, + Payload: map[string]any{ + "qr_content": rsp.QRCode, + "display_amount": amount, // 元串(非分),供前端直接展示金额 + "currency": "CNY", + }, + ExpiresAt: &exp, + }, nil +} + func (p *Provider) VerifyCallback(ctx context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { form, err := url.ParseQuery(string(in.Raw)) if err != nil { diff --git a/internal/provider/alipay/alipay_qr_test.go b/internal/provider/alipay/alipay_qr_test.go new file mode 100644 index 0000000..7ac2f44 --- /dev/null +++ b/internal/provider/alipay/alipay_qr_test.go @@ -0,0 +1,146 @@ +package alipay_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "crypto" + + "github.com/wangjia/pay/internal/provider" + ali "github.com/wangjia/pay/internal/provider/alipay" +) + +// jiu 反馈波 item 1:Metadata["is_mobile"]=="1" 应走 TradeWapPay,产出与 +// page(FAST_INSTANT_TRADE_PAY)不同的收银台跳转(product_code=QUICK_WAP_WAY)。 +// TradeWapPay/TradePagePay 都是纯本地签名构造 URL(不出网),同 alipay_test.go +// TestCreateRedirect 的用法,无需 httptest。 +func TestCreateWapDiffersFromPage(t *testing.T) { + appPriv, _, aliPub := genKeys(t) + p := ali.New(buildClient(t, appPriv, aliPub)) + + page, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-WAP-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + ReturnURL: "https://x/return", + }) + if err != nil { + t.Fatalf("create page: %v", err) + } + wap, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-WAP-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + ReturnURL: "https://x/return", Metadata: map[string]string{"is_mobile": "1"}, + }) + if err != nil { + t.Fatalf("create wap: %v", err) + } + if page.RenderType != provider.RenderRedirect || wap.RenderType != provider.RenderRedirect { + t.Fatalf("render_type: page=%v wap=%v, want both redirect", page.RenderType, wap.RenderType) + } + pageURL, _ := page.Payload["url"].(string) + wapURL, _ := wap.Payload["url"].(string) + if pageURL == wapURL { + t.Fatalf("wap URL 应与 page URL 不同(不同 product_code/签名参数), got 相同: %q", pageURL) + } + if !strings.Contains(wapURL, "QUICK_WAP_WAY") { + t.Fatalf("wap URL 应带 product_code=QUICK_WAP_WAY, got %q", wapURL) + } + if !strings.Contains(pageURL, "FAST_INSTANT_TRADE_PAY") { + t.Fatalf("page URL 应带 product_code=FAST_INSTANT_TRADE_PAY, got %q", pageURL) + } +} + +// fakeAlipayPreCreate 返回一份用"支付宝侧"私钥签名的 alipay.trade.precreate 响应 +// (与 alipay_refund_test.go 的 fakeAlipayRefund 同一套 fixture 手法)。 +func fakeAlipayPreCreate(t *testing.T, aliPriv *rsa.PrivateKey, qrCode string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := map[string]any{ + "code": "10000", "msg": "Success", + "out_trade_no": "PAY-QR-1", "qr_code": qrCode, + } + nodeJSON, _ := json.Marshal(node) + h := sha256.Sum256(nodeJSON) + sig, _ := rsa.SignPKCS1v15(rand.Reader, aliPriv, crypto.SHA256, h[:]) + resp := map[string]any{ + "alipay_trade_precreate_response": json.RawMessage(nodeJSON), + "sign": base64.StdEncoding.EncodeToString(sig), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) +} + +// jiu 反馈波 item 2:Metadata["render"]=="qr" 应走 TradePreCreate(当面付), +// 产出 render_type=qr,payload 三字段 {qr_content, display_amount, currency}。 +func TestCreateQR(t *testing.T) { + aliKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen ali key: %v", err) + } + ts := fakeAlipayPreCreate(t, aliKey, "https://qr.alipay.com/bax12345") + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) // 复用 alipay_refund_test.go 的 sandbox-gateway client builder + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-QR-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + Metadata: map[string]string{"render": "qr"}, + }) + if err != nil { + t.Fatalf("create qr: %v", err) + } + if sess.RenderType != provider.RenderQR { + t.Fatalf("render_type = %v, want qr", sess.RenderType) + } + if sess.ExpiresAt == nil { + t.Fatal("qr session 应带 ExpiresAt(当面付默认 2 小时窗口)") + } + if sess.Payload["qr_content"] != "https://qr.alipay.com/bax12345" { + t.Fatalf("payload.qr_content = %v", sess.Payload["qr_content"]) + } + if sess.Payload["display_amount"] != "199" { // money.Format 去尾零(19900 分 → "199") + t.Fatalf("payload.display_amount = %v, want 199", sess.Payload["display_amount"]) + } + if sess.Payload["currency"] != "CNY" { + t.Fatalf("payload.currency = %v, want CNY", sess.Payload["currency"]) + } +} + +// alipay.trade.precreate 被渠道拒绝(如金额超限)时 Create 应报错、不返回 Session。 +func TestCreateQRChannelRejected(t *testing.T) { + aliKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen ali key: %v", err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := map[string]any{"code": "40004", "msg": "Business Failed", "sub_code": "ACQ.INVALID_PARAMETER", "sub_msg": "参数无效"} + nodeJSON, _ := json.Marshal(node) + h := sha256.Sum256(nodeJSON) + sig, _ := rsa.SignPKCS1v15(rand.Reader, aliKey, crypto.SHA256, h[:]) + resp := map[string]any{ + "alipay_trade_precreate_response": json.RawMessage(nodeJSON), + "sign": base64.StdEncoding.EncodeToString(sig), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := ali.New(buildRefundClient(t, ts.URL, &aliKey.PublicKey)) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-QR-2", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + Metadata: map[string]string{"render": "qr"}, + }) + if err == nil { + t.Fatalf("want 渠道拒绝返回 error, got session=%+v", sess) + } + if sess != nil { + t.Fatalf("渠道拒绝时不应返回 Session, got %+v", sess) + } +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index 6bd28af..07ff5f4 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -25,6 +25,8 @@ type Provider struct { refundRef string refundStatus provider.PaidStatus refundErr error + + lastMetadata map[string]string // 测试缝:记录最近一次 Create 收到的 Metadata(jiu 反馈波,验证 gateway 透传) } func New() *Provider { return &Provider{queryResults: map[string]provider.PaidEvent{}} } @@ -41,6 +43,9 @@ func (p *Provider) Capabilities() provider.Capabilities { } 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{ @@ -88,6 +93,13 @@ func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provide 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() diff --git a/internal/reconcile/sync_test.go b/internal/reconcile/sync_test.go index 010d089..db01a38 100644 --- a/internal/reconcile/sync_test.go +++ b/internal/reconcile/sync_test.go @@ -30,11 +30,12 @@ func (nopEnq) Enqueue(_, _, _, _ string, _ map[string]any) error { return nil } func TestSyncPendingTaskSettlesViaQuery(t *testing.T) { db := model.OpenTestDB(t) orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) preg := provider.NewRegistry() fp := fake.New() preg.Register(fp) areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true}}) - gw := gateway.New(orders, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global") + gw := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global") res, _ := gw.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"}) atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) diff --git a/internal/router/router.go b/internal/router/router.go index 974399c..c8e7fba 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,7 @@ import ( "github.com/wangjia/pay/internal/channel" "github.com/wangjia/pay/internal/gateway" "github.com/wangjia/pay/internal/handler" + "github.com/wangjia/pay/internal/middleware" "github.com/wangjia/pay/internal/service" ) @@ -44,10 +45,16 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) { h := handler.NewGatewayHandler(g) v2 := r.Group("/api/v2") { - v2.POST("/orders", h.CreateOrder) + // per-IP 限流(jiu 反馈波,P3 限流纵深):只挂改状态端点(下单/重试/取消)—— + // - callback 不限:来源是渠道服务器,不是终端用户,限了只会误伤渠道重投。 + // - GET 查单/查退款不限:只读、幂等,是结果页轮询的正常用法,风险远低于 + // 下单类写操作;真要限也该给比写操作更宽松的档,这里选择直接不限。 + // - /refunds(创建退款)/admin 组不在本次改动范围内(见 config.RateLimit 注释)。 + stateLimit := stateChangeRateLimit() + v2.POST("/orders", stateLimit, h.CreateOrder) v2.GET("/orders/:order_no", h.GetStatus) - v2.POST("/orders/:order_no/retry", h.Retry) - v2.POST("/orders/:order_no/cancel", h.Cancel) + v2.POST("/orders/:order_no/retry", stateLimit, h.Retry) + v2.POST("/orders/:order_no/cancel", stateLimit, h.Cancel) v2.POST("/callback/:method", h.Callback) v2.POST("/refunds", h.CreateRefund) v2.GET("/refunds/:refund_id", h.GetRefund) @@ -59,3 +66,14 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) { admin.POST("/refunds/:refund_id/complete", h.CompleteRefund) } } + +// stateChangeRateLimit 按 config.C.RateLimit 装配限流中间件;disabled=true 时放行一切 +// (no-op 中间件,不是跳过注册——保持路由树形状稳定,便于测试/观测)。每次 SetupV2 调用 +// 都构造一个新的 IPRateLimiter 实例(桶状态不跨多次 SetupV2 共享),这也让测试里反复 +// 建引擎彼此互不影响。 +func stateChangeRateLimit() gin.HandlerFunc { + if config.C.RateLimit.Disabled { + return func(c *gin.Context) { c.Next() } + } + return middleware.NewIPRateLimiter(config.C.RateLimit.RequestsPerMin).Gin() +} diff --git a/internal/util/idgen.go b/internal/util/idgen.go index 2f6c11a..99ebba3 100644 --- a/internal/util/idgen.go +++ b/internal/util/idgen.go @@ -9,13 +9,18 @@ import ( ) // NewOutTradeNo 生成全局唯一的商户订单号:-<时间>-<随机>,控制在 64 字符内。 -// 例:yanmei-20260624153012-a1b2c3d4 +// 例:yanmei-20260624153012-a1b2c3d4e5f6a7b8 +// +// 随机部分 16 hex(64 位)——原 8 hex(32 位)在高并发/多实例下生日碰撞概率偏高 +// (jiu 反馈波加固,2026-07)。最长 code(16)+"-"(1)+ts(14)+"-"(1)+suffix(16) = 48 字符, +// 仍在 DB out_trade_no 列 size:64 与支付宝 out_trade_no ≤64 字符上限内。v1/v2 共用本函数, +// 加长向后兼容(旧号仍可查、新号不再变短)。 func NewOutTradeNo(merchantCode string) string { code := merchantCode if len(code) > 16 { code = code[:16] } ts := time.Now().Format("20060102150405") - suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8] + suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:16] return fmt.Sprintf("%s-%s-%s", code, ts, suffix) } diff --git a/internal/util/idgen_test.go b/internal/util/idgen_test.go new file mode 100644 index 0000000..c120098 --- /dev/null +++ b/internal/util/idgen_test.go @@ -0,0 +1,42 @@ +package util_test + +import ( + "strings" + "testing" + + "github.com/wangjia/pay/internal/util" +) + +// jiu 反馈波:随机部分 8→16 hex(64 位)加固防生日碰撞;断言新长度/格式, +// 并确认仍在 DB out_trade_no 列 size:64 与支付宝 out_trade_no ≤64 字符上限内。 +func TestNewOutTradeNoSuffixLength(t *testing.T) { + no := util.NewOutTradeNo("yanmei") + parts := strings.Split(no, "-") + if len(parts) != 3 { + t.Fatalf("out_trade_no = %q, want 3 段(code-ts-suffix)", no) + } + suffix := parts[2] + if len(suffix) != 16 { + t.Fatalf("随机后缀长度 = %d, want 16", len(suffix)) + } + if len(no) > 64 { + t.Fatalf("out_trade_no 长度 = %d, want <= 64", len(no)) + } +} + +// merchantCode 拉满 16 字符时,总长仍须 <= 64(alipay out_trade_no 上限)。 +func TestNewOutTradeNoMaxLength(t *testing.T) { + no := util.NewOutTradeNo("0123456789abcdefEXTRA") // >16 字符,函数内部会截断到 16 + if len(no) > 64 { + t.Fatalf("out_trade_no 长度 = %d, want <= 64: %q", len(no), no) + } +} + +// 两次生成不应重复(随机后缀 + 时间戳兜底)。 +func TestNewOutTradeNoUnique(t *testing.T) { + a := util.NewOutTradeNo("pay") + b := util.NewOutTradeNo("pay") + if a == b { + t.Fatalf("两次生成的 out_trade_no 相同: %q", a) + } +}