feat(provider): 哪吒支付(nezha)RSA 聚合渠道 provider + 注册 + 测试

照 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
This commit is contained in:
wangjia
2026-07-11 12:19:24 +08:00
parent a04947ad80
commit a6b068b070
9 changed files with 1393 additions and 5 deletions
+36 -5
View File
@@ -34,10 +34,13 @@ type createV2Request struct {
// allowedMetadataKeys 是 createV2Request/retryRequest.Metadata 能透传给
// provider.CreateRequest 的键白名单:is_mobile(alipay 选 wap/page 收银台)、
// render(如 alipay render=qr 选当面付)。未在此列的键一律丢弃,不放过管线。
// 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 的
@@ -203,10 +206,20 @@ func (h *GatewayHandler) Cancel(c *gin.Context) {
util.RespondSuccess(c, gin.H{"canceled": ok})
}
// Callback POST /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。
// 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 让渠道重投
// 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))
@@ -218,19 +231,37 @@ func (h *GatewayHandler) Callback(c *gin.Context) {
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,
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/验签失败等:回 400 让渠道按策略重投(或人工排障)
// 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", "回调处理失败")
}
}