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:
wangjia
2026-07-10 18:40:15 +08:00
parent 7b90c24a46
commit 8051b0fb16
15 changed files with 698 additions and 17 deletions
+13
View File
@@ -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,使用默认值 + 环境变量")
+7 -3
View File
@@ -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
+28 -3
View File
@@ -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"})
+33 -2
View File
@@ -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", "订单非待支付态,不可重试")
+54
View File
@@ -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)
}
}
+101
View File
@@ -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)
}
}
}
+98
View File
@@ -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()
}
}
+87
View File
@@ -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)
}
}
+47 -3
View File
@@ -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 {
+146
View File
@@ -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)
}
}
+12
View File
@@ -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()
+2 -1
View File
@@ -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)
+21 -3
View File
@@ -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()
}
+7 -2
View File
@@ -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)
}
+42
View File
@@ -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)
}
}