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:
@@ -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", "回调处理失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/nezha"
|
||||
"github.com/wangjia/pay/internal/router"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
// 本文件覆盖 gateway.go 的 plainTextCallbackMethods 特判:nezha 的异步通知必须回
|
||||
// 纯文本 "success"/"fail",而不是其它 v2 渠道统一的 JSON {"result":...}。
|
||||
|
||||
type nezhaResolver struct{}
|
||||
|
||||
func (nezhaResolver) Resolve(sku, currency string) (int64, string, string, error) {
|
||||
return 19900, "Pro 年付", "pro_year", nil
|
||||
}
|
||||
|
||||
// nezhaCanonicalSign/nezhaJSONParams 是照哪吒 sign_note 字面独立重实现的签名工具
|
||||
// (同 internal/provider/nezha/nezha_test.go 的 canonicalSource/signWith 惯例),
|
||||
// 用来在本 handler 层测试里构造"渠道下单响应"和"渠道异步通知"两类 fixture。
|
||||
func nezhaCanonicalSign(t *testing.T, priv *rsa.PrivateKey, params map[string]string) string {
|
||||
t.Helper()
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || k == "sign_type" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+params[k])
|
||||
}
|
||||
source := strings.Join(parts, "&")
|
||||
h := sha256.Sum256([]byte(source))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func nezhaJSONParams(m map[string]any) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
out[k] = t
|
||||
case float64:
|
||||
out[k] = strconv.FormatFloat(t, 'f', -1, 64)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNezhaEngine 装配一个带真实(自签密钥对)nezha provider 的 v2 引擎:Create 打向
|
||||
// httptest fixture(模拟渠道下单响应,签名用装配给 provider 的同一把"平台"私钥)。
|
||||
func buildNezhaEngine(t *testing.T) (*gin.Engine, *store.OrderStore, *rsa.PrivateKey) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := model.OpenTestDB(t)
|
||||
orders := store.NewOrderStore(db)
|
||||
refunds := store.NewRefundStore(db)
|
||||
subs := store.NewSubscriptionStore(db)
|
||||
chargebacks := store.NewChargebackStore(db)
|
||||
|
||||
merchant, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen merchant key: %v", err)
|
||||
}
|
||||
platform, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen platform key: %v", err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
resp := map[string]any{
|
||||
"code": float64(1), "msg": "success",
|
||||
"trade_no": "NZ-" + r.Form.Get("out_trade_no"), "out_trade_no": r.Form.Get("out_trade_no"),
|
||||
"pay_type": r.Form.Get("type"), "payurl": "https://nzzf.org/pay/checkout/x",
|
||||
"qrcode": "", "timestamp": strconv.FormatInt(time.Now().Unix(), 10), "sign_type": "RSA",
|
||||
}
|
||||
resp["sign"] = nezhaCanonicalSign(t, platform, nezhaJSONParams(resp))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&platform.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal platform pub: %v", err)
|
||||
}
|
||||
np, err := nezha.New(
|
||||
"test-pid",
|
||||
base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(merchant)),
|
||||
base64.StdEncoding.EncodeToString(pubDER),
|
||||
"https://pay.example.com/api/v2/callback/nezha",
|
||||
nezha.WithBaseURL(ts.URL),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("nezha.New: %v", err)
|
||||
}
|
||||
|
||||
preg := provider.NewRegistry()
|
||||
preg.Register(np)
|
||||
areg := accounts.New([]config.AccountConfig{
|
||||
{AccountID: "nezha-a1", Channel: "nezha", Region: "global", Enabled: true, Weight: 1},
|
||||
})
|
||||
picker := accounts.NewRouter(areg, nil, nil)
|
||||
g := gateway.New(orders, refunds, preg, picker, nezhaResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
|
||||
r := gin.New()
|
||||
router.SetupV2(r, g)
|
||||
return r, orders, platform
|
||||
}
|
||||
|
||||
// TestNezhaCallbackRepliesPlainSuccess: 端到端——下单 → 用平台私钥签一份 GET 异步
|
||||
// 通知 → 回调必须回纯文本 "success"(不是 JSON),且订单真正翻成 paid。
|
||||
func TestNezhaCallbackRepliesPlainSuccess(t *testing.T) {
|
||||
r, orders, platform := buildNezhaEngine(t)
|
||||
|
||||
w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "nezha"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create code=%d body=%v", w.Code, out)
|
||||
}
|
||||
orderNo := out["data"].(map[string]any)["order_no"].(string)
|
||||
|
||||
atts, err := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list attempts: %v", err)
|
||||
}
|
||||
var ref string
|
||||
for _, a := range atts {
|
||||
if a.OutTradeNo == orderNo {
|
||||
ref = a.ProviderRef
|
||||
}
|
||||
}
|
||||
if ref == "" {
|
||||
t.Fatalf("未找到订单 %s 的尝试", orderNo)
|
||||
}
|
||||
|
||||
q := map[string]string{
|
||||
"pid": "test-pid", "trade_no": ref, "out_trade_no": orderNo,
|
||||
"type": "alipay", "name": "Pro 年付", "money": "199.00",
|
||||
"trade_status": "TRADE_SUCCESS", "timestamp": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"sign_type": "RSA",
|
||||
}
|
||||
q["sign"] = nezhaCanonicalSign(t, platform, q)
|
||||
|
||||
v := url.Values{}
|
||||
for k, val := range q {
|
||||
v.Set(k, val)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/callback/nezha?"+v.Encode(), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("callback code = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "success" {
|
||||
t.Fatalf("callback body = %q, want 纯文本 success(不是 JSON)", rec.Body.String())
|
||||
}
|
||||
|
||||
_, outStatus := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil)
|
||||
if outStatus["data"].(map[string]any)["status"] != "paid" {
|
||||
t.Fatalf("order status = %v, want paid", outStatus["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNezhaCallbackBadSignRepliesPlainFail: 验签失败(伪造/篡改)必须回纯文本 "fail",
|
||||
// 200(而非 400)——这类聚合网关的重投只认响应体是否等于 "success",不看状态码。
|
||||
func TestNezhaCallbackBadSignRepliesPlainFail(t *testing.T) {
|
||||
r, _, _ := buildNezhaEngine(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/callback/nezha?trade_no=X&trade_status=TRADE_SUCCESS&sign=bogus&sign_type=RSA", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("callback code = %d, want 200", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != "fail" {
|
||||
t.Fatalf("callback body = %q, want fail", rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user