fix(v2): jiu 反馈波——端型透传(wap/page)+ alipay qr(当面付移植)+ 单号增熵/挡板限流
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
This commit is contained in:
@@ -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,使用默认值 + 环境变量")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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", "订单非待支付态,不可重试")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ import (
|
||||
)
|
||||
|
||||
// NewOutTradeNo 生成全局唯一的商户订单号:<code>-<时间>-<随机>,控制在 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user