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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user