Files
pay/internal/router/router.go
T
wangjia 8051b0fb16 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
2026-07-10 18:40:15 +08:00

80 lines
3.2 KiB
Go

package router
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/pay/config"
"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"
)
// Setup 装配路由。返回 OrderService 供 main 启动查单兜底任务。
func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderService {
orderSvc := service.NewOrderService(db, reg, config.C.Server.BaseURL)
orderH := handler.NewOrderHandler(orderSvc)
pageH := handler.NewPageHandler(db, "web")
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
// 页面
r.GET("/result", pageH.ResultPage)
r.GET("/qrcode", pageH.QRCode)
v1 := r.Group("/api/v1")
{
v1.GET("/products", pageH.ListProducts)
v1.POST("/orders", orderH.Create)
v1.POST("/orders/qr", orderH.CreateQR)
v1.GET("/orders/:out_trade_no", orderH.GetStatus)
v1.POST("/notify/alipay", orderH.AlipayNotify)
v1.POST("/notify/wechat", orderH.WechatNotify)
}
return orderSvc
}
// SetupV2 装配 pay v2 统一网关路由(/api/v2,与旧版同一 /api 前缀风格)。
// 旧 /api/v1 仅为存量支付宝当面付部署保留(路径 POST /api/v1/orders 与 v2 新契约
// 同名不同形,无法在同一前缀下并存);P3 支付宝 adapter 迁入 v2 后整组删除,
// 最终对外只剩 /api/v2 一套。
func SetupV2(r *gin.Engine, g *gateway.Gateway) {
h := handler.NewGatewayHandler(g)
v2 := r.Group("/api/v2")
{
// 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", 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)
}
// 人工退款闭环:独立 admin 组(env PAY_ADMIN_TOKEN 保护),路径与 v2/refunds 分离避免路由冲突。
admin := r.Group("/api/v2/admin")
{
admin.GET("/manual-refunds", h.ListManualRefunds)
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()
}