Files
pay/internal/handler/gateway.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

197 lines
7.2 KiB
Go

package handler
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/provider"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/util"
)
type GatewayHandler struct {
g *gateway.Gateway
}
func NewGatewayHandler(g *gateway.Gateway) *GatewayHandler { return &GatewayHandler{g: g} }
type createV2Request struct {
SKU string `json:"sku"`
Method string `json:"method"`
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}}。
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign)。
func (h *GatewayHandler) CreateOrder(c *gin.Context) {
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
return
}
var req createV2Request
if err := json.Unmarshal(raw, &req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.SKU == "" || req.Method == "" {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method")
return
}
if req.BizSystem != "" {
if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
}
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)
return
}
util.RespondSuccess(c, res)
}
// GetStatus GET /api/v2/orders/:order_no
func (h *GatewayHandler) GetStatus(c *gin.Context) {
v, err := h.g.GetOrder(c.Param("order_no"))
if err != nil {
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
return
}
util.RespondSuccess(c, v)
}
type retryRequest struct {
Method string `json:"method"`
Metadata map[string]string `json:"metadata,omitempty"` // 同 createV2Request.Metadata,过同一白名单
}
// Retry POST /api/v2/orders/:order_no/retry
func (h *GatewayHandler) Retry(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes)
var req retryRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
return
}
if req.Method == "" {
util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 method")
return
}
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", "订单非待支付态,不可重试")
return
}
if errors.Is(err, store.ErrOrderNotFound) {
util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在")
return
}
h.writeCreateErr(c, "重试", req.Method, err)
return
}
util.RespondSuccess(c, res)
}
// Cancel POST /api/v2/orders/:order_no/cancel
func (h *GatewayHandler) Cancel(c *gin.Context) {
ok, err := h.g.CancelOrder(c.Param("order_no"))
if err != nil {
util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败")
return
}
util.RespondSuccess(c, gin.H{"canceled": ok})
}
// Callback POST /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。
// 按 SettleResult 分终态/可重试:not_found/duplicate/ignored/amount_mismatch 都是终态
// (含 amount_mismatch —— Settle 对其返回非 nil err,但仍是已受理的终态),一律回 200 停投;
// SettleFailed(暂时性失败)与验签/解析失败(err!=nil 且非 amount_mismatch)是可重试态,回 400 让渠道重投。
func (h *GatewayHandler) Callback(c *gin.Context) {
method := c.Param("method")
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
if err != nil {
util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大")
return
}
headers := map[string]string{}
for k := range c.Request.Header {
headers[k] = c.GetHeader(k)
}
res, err := h.g.HandleCallback(c.Request.Context(), method, provider.CallbackInput{
Raw: raw, Headers: headers,
})
switch {
case err == nil:
c.JSON(http.StatusOK, gin.H{"result": string(res)})
case res == gateway.SettleAmountMismatch:
// 金额/币种不符是终态,不是可重试的暂时性失败:回 200 受理,让渠道停止重投。
log.Printf("[v2 callback] method=%s 金额/币种不符(终态,已受理停投): %v", method, err)
c.JSON(http.StatusOK, gin.H{"result": string(res)})
default:
// SettleFailed/验签失败等:回 400 让渠道按策略重投(或人工排障)。
log.Printf("[v2 callback] method=%s result=%s err=%v", method, res, err)
util.RespondError(c, http.StatusBadRequest, "callback_failed", "回调处理失败")
}
}
func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, err error) {
switch {
case errors.Is(err, gateway.ErrProductNotFound):
util.RespondError(c, http.StatusNotFound, "product_not_found", "套餐不存在或已下架")
case errors.Is(err, provider.ErrUnknownMethod):
util.RespondError(c, http.StatusBadRequest, "unknown_method", "不支持的支付方式")
case errors.Is(err, gateway.ErrNoAccount):
util.RespondError(c, http.StatusServiceUnavailable, "no_account", "该支付方式暂不可用")
case errors.Is(err, gateway.ErrCurrencyMismatch):
util.RespondError(c, http.StatusConflict, "currency_mismatch", "该支付方式结算币种与订单不符,请换一种支付方式")
case errors.Is(err, gateway.ErrNoSettleCurrency):
util.RespondError(c, http.StatusServiceUnavailable, "no_settle_currency", "该支付方式配置不完整,暂不可用")
default:
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
}
}