a6b068b070
照 alipay adapter 的 RSA + redirect/qr + 表单请求模式实现哪吒(nzzf.org)聚合支付: SHA256WithRSA + Base64 签名(商户私钥出站签名/平台公钥验响应及回调),Create 走 POST /api/pay/create,VerifyCallback 处理 GET 异步通知(handler 层新增 Query 透传 + nezha 专属纯文本 success/fail 回复),Query 走 POST /api/pay/query。BuildRegistry 按凭证齐备与否决策注册,缺凭证只 log 跳过不 fatal。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
288 lines
11 KiB
Go
288 lines
11 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/nezha render=qr 选当面付/扫码)、type(nezha 选聚合渠道内的具体
|
|
// 支付方式,如 alipay/wxpay;未传时 nezha adapter 默认 alipay)。未在此列的键一律
|
|
// 丢弃,不放过管线。
|
|
var allowedMetadataKeys = map[string]bool{
|
|
"is_mobile": true,
|
|
"render": true,
|
|
"type": 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)
|
|
}
|
|
|
|
// CreateSubscription POST /api/v2/subscriptions —— 建订阅,返回 {sub_id, order_no, session:{render_type, payload}}。
|
|
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign,与 CreateOrder 同惯例)。
|
|
func (h *GatewayHandler) CreateSubscription(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.CreateSubscription(c.Request.Context(), gateway.CreateSubscriptionInput{
|
|
SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL,
|
|
})
|
|
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)
|
|
}
|
|
|
|
// GetSubscription GET /api/v2/subscriptions/:sub_id —— 查订阅状态 + 续费锚点。
|
|
func (h *GatewayHandler) GetSubscription(c *gin.Context) {
|
|
v, err := h.g.GetSubscription(c.Param("sub_id"))
|
|
if err != nil {
|
|
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
|
|
return
|
|
}
|
|
util.RespondSuccess(c, v)
|
|
}
|
|
|
|
// CancelSubscription POST /api/v2/subscriptions/:sub_id/cancel —— 主动取消:查订阅 → 渠道
|
|
// 取消 → 本地翻 canceled + 入队 subscription.canceled(幂等,重复调用 no-op)。
|
|
func (h *GatewayHandler) CancelSubscription(c *gin.Context) {
|
|
if err := h.g.CancelSubscription(c.Request.Context(), c.Param("sub_id")); err != nil {
|
|
if errors.Is(err, store.ErrSubNotFound) {
|
|
util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
|
|
return
|
|
}
|
|
log.Printf("[v2 subscription] 取消失败 sub_id=%s: %v", c.Param("sub_id"), err)
|
|
util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败,请稍后重试")
|
|
return
|
|
}
|
|
util.RespondSuccess(c, gin.H{"canceled": true})
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// plainTextCallbackMethods 是需要回*纯文本*(而非 JSON)确认的渠道集合:目前仅
|
|
// nezha——其异步通知协议要求商户明确回 "success" 字符串,否则渠道按策略重投最长 24h
|
|
// (与 v1 遗留的 alipay 文本通知同一套约定,见 internal/handler/order.go AlipayNotify)。
|
|
// 其余 v2 渠道(alipay/stripe/crypto/fake)统一回 JSON {"result":...},不受影响。
|
|
var plainTextCallbackMethods = map[string]bool{"nezha": true}
|
|
|
|
// Callback POST/GET /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback →
|
|
// Settle。同时注册 GET 是因为部分渠道(如 nezha)异步通知走 GET query string,不是 POST
|
|
// body(见 router.SetupV2)。
|
|
// 按 SettleResult 分终态/可重试:not_found/duplicate/ignored/amount_mismatch 都是终态
|
|
// (含 amount_mismatch —— Settle 对其返回非 nil err,但仍是已受理的终态),一律回 200 停投;
|
|
// SettleFailed(暂时性失败)与验签/解析失败(err!=nil 且非 amount_mismatch)是可重试态,回 400 让渠道重投
|
|
// ——但 plainTextCallbackMethods 里的渠道只认响应体是否等于 "success",没有"改状态码促重投"这层
|
|
// 协议,故对它们统一 200 + 纯文本("success"/"fail"),不改用 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)
|
|
}
|
|
query := map[string]string{}
|
|
for k := range c.Request.URL.Query() {
|
|
query[k] = c.Request.URL.Query().Get(k)
|
|
}
|
|
plainText := plainTextCallbackMethods[method]
|
|
res, err := h.g.HandleCallback(c.Request.Context(), method, provider.CallbackInput{
|
|
Raw: raw, Headers: headers, Query: query,
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
if plainText {
|
|
c.String(http.StatusOK, "success")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"result": string(res)})
|
|
case res == gateway.SettleAmountMismatch:
|
|
// 金额/币种不符是终态,不是可重试的暂时性失败:回 200 受理,让渠道停止重投。
|
|
log.Printf("[v2 callback] method=%s 金额/币种不符(终态,已受理停投): %v", method, err)
|
|
if plainText {
|
|
// 纯文本协议无法比 JSON 更细分 amount_mismatch;已受理的终态同样回 success 停投。
|
|
c.String(http.StatusOK, "success")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"result": string(res)})
|
|
default:
|
|
// SettleFailed/验签失败等:可重试态。
|
|
log.Printf("[v2 callback] method=%s result=%s err=%v", method, res, err)
|
|
if plainText {
|
|
c.String(http.StatusOK, "fail") // 200+"fail"(非 success)促渠道按其策略重投;此类渠道不看 HTTP 状态码
|
|
return
|
|
}
|
|
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", "该支付方式配置不完整,暂不可用")
|
|
case errors.Is(err, provider.ErrNotSupported):
|
|
util.RespondError(c, http.StatusBadRequest, "method_not_recurring", "该支付方式不支持订阅")
|
|
default:
|
|
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
|
|
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
|
|
}
|
|
}
|