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