From a6b068b0707e38e7a6765658448a2171f05c9a27 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 12:19:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E5=93=AA=E5=90=92=E6=94=AF?= =?UTF-8?q?=E4=BB=98(nezha)RSA=20=E8=81=9A=E5=90=88=E6=B8=A0=E9=81=93=20pr?= =?UTF-8?q?ovider=20+=20=E6=B3=A8=E5=86=8C=20+=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照 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) Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- internal/handler/gateway.go | 41 +- internal/handler/gateway_nezha_test.go | 210 +++++++ internal/provider/nezha/export_test.go | 6 + internal/provider/nezha/nezha.go | 343 ++++++++++ internal/provider/nezha/nezha_test.go | 595 ++++++++++++++++++ internal/provider/nezha/sign.go | 136 ++++ internal/providerbuild/registry.go | 23 + internal/providerbuild/registry_build_test.go | 42 ++ internal/router/router.go | 2 + 9 files changed, 1393 insertions(+), 5 deletions(-) create mode 100644 internal/handler/gateway_nezha_test.go create mode 100644 internal/provider/nezha/export_test.go create mode 100644 internal/provider/nezha/nezha.go create mode 100644 internal/provider/nezha/nezha_test.go create mode 100644 internal/provider/nezha/sign.go diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index 590400f..80ed73a 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -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", "回调处理失败") } } diff --git a/internal/handler/gateway_nezha_test.go b/internal/handler/gateway_nezha_test.go new file mode 100644 index 0000000..c2064f7 --- /dev/null +++ b/internal/handler/gateway_nezha_test.go @@ -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()) + } +} diff --git a/internal/provider/nezha/export_test.go b/internal/provider/nezha/export_test.go new file mode 100644 index 0000000..213aad1 --- /dev/null +++ b/internal/provider/nezha/export_test.go @@ -0,0 +1,6 @@ +package nezha + +// BuildSignSourceForTest 仅测试用,暴露 buildSignSource 供黑盒测试(nezha_test 包)直接 +// 验证 sign_note 的排序/排除规则,不进生产 API 面。标准 export_test.go 手法,同 +// internal/provider/crypto/export_test.go 的既有惯例。 +func BuildSignSourceForTest(params map[string]string) string { return buildSignSource(params) } diff --git a/internal/provider/nezha/nezha.go b/internal/provider/nezha/nezha.go new file mode 100644 index 0000000..4844a51 --- /dev/null +++ b/internal/provider/nezha/nezha.go @@ -0,0 +1,343 @@ +// Package nezha adapts 哪吒支付(nzzf.org)——一个 RSA 签名的第三方聚合支付网关 +// (下单代理到 alipay/wxpay 等具体渠道,结算币种统一 CNY)——到 provider.Provider。 +// 与 alipay adapter(internal/provider/alipay)同为 RSA + redirect/qr + 表单请求的 +// 模式,照其风格实现,但签名协议自成一套(sign_note,见官方文档 https://nzzf.org/doc): +// +// - 算法固定 SHA256WithRSA + Base64;签名串 = 收集所有非空普通参数(排除 +// sign/sign_type/数组),按参数名 ASCII 升序排序拼 "key=value&..."(不拼密钥)。 +// - 出站请求(下单/查单)用商户私钥签;入站(异步通知/响应验签)用平台公钥验。 +// - 异步通知是 GET,商户必须回纯文本 "success" 才算收到,否则渠道按策略重投—— +// 与本仓库其它 v2 渠道统一回 JSON 不同,这层差异由 internal/handler/gateway.go +// 的 Callback 按 method 特判(见该文件 plainTextCallbackMethods)。 +package nezha + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/wangjia/pay/internal/money" + "github.com/wangjia/pay/internal/provider" +) + +const defaultBaseURL = "https://nzzf.org" + +type Provider struct { + pid string + priv *rsa.PrivateKey + platformPub *rsa.PublicKey + notifyURL string + baseURL string + http *http.Client +} + +type Option func(*Provider) + +// WithBaseURL 覆盖渠道 API 根地址(测试用 httptest server 地址注入)。 +func WithBaseURL(u string) Option { return func(p *Provider) { p.baseURL = strings.TrimRight(u, "/") } } + +// WithHTTPClient 覆盖出站 http.Client(测试/自定义超时用)。 +func WithHTTPClient(c *http.Client) Option { return func(p *Provider) { p.http = c } } + +// New 构造哪吒 Provider:商户私钥(出站签名)+ 平台公钥(入站/响应验签)在装配期一次性 +// 解析,凭证不合法直接返回 error(装配方——providerbuild——照 alipay 的既有惯例: +// 缺凭证/解析失败只 log+skip,不 fatal)。notifyURL 由调用方(providerbuild)据 +// config.C.Server.BaseURL 拼好传入——不同于 alipay(notify_url 走支付宝开放平台 +// 应用级配置,per-request 可省略),哪吒协议要求每次下单显式携带 notify_url。 +func New(pid, privateKeyPEM, platformPublicKeyPEM, notifyURL string, opts ...Option) (*Provider, error) { + if pid == "" { + return nil, fmt.Errorf("nezha: pid 不能为空") + } + if notifyURL == "" { + return nil, fmt.Errorf("nezha: notify_url 不能为空") + } + priv, err := parsePrivateKey(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("nezha: 解析商户私钥失败: %w", err) + } + pub, err := parsePublicKey(platformPublicKeyPEM) + if err != nil { + return nil, fmt.Errorf("nezha: 解析平台公钥失败: %w", err) + } + p := &Provider{ + pid: pid, + priv: priv, + platformPub: pub, + notifyURL: notifyURL, + baseURL: defaultBaseURL, + http: &http.Client{Timeout: 15 * time.Second}, + } + for _, o := range opts { + o(p) + } + return p, nil +} + +func (p *Provider) Method() string { return "nezha" } + +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect, provider.RenderQR}, + SupportsRefund: false, // 官方文档未见退款接口,不实现 RefundingProvider + SettleCurrencies: []string{"CNY"}, + Regions: []string{"cn"}, + } +} + +// Create POST /api/pay/create。渲染形态:Metadata["render"]=="qr" 或响应只带 +// qrcode(无 payurl)时走扫码(RenderQR);默认走收银台跳转(RenderRedirect), +// 与 alipay adapter 按 Metadata 选形态的惯例一致。 +func (p *Provider) Create(ctx context.Context, req provider.CreateRequest) (*provider.Session, error) { + if req.Currency != "CNY" { + return nil, fmt.Errorf("nezha: 仅支持 CNY, got %s", req.Currency) + } + amount, err := money.Format(req.AmountMinor, "CNY") + if err != nil { + return nil, err + } + + payType := req.Metadata["type"] + if payType == "" { + payType = "alipay" // 未指定聚合渠道类型时的默认值(alipay 是最常见通道) + } + + params := map[string]string{ + "pid": p.pid, + "type": payType, + "out_trade_no": req.OutTradeNo, + "notify_url": p.notifyURL, + "return_url": req.ReturnURL, + "name": req.Subject, + "money": amount, + "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + } + if v := req.Metadata["param"]; v != "" { + params["param"] = v + } + if v := req.Metadata["clientip"]; v != "" { + params["clientip"] = v + } + + sig, err := signRSA(p.priv, buildSignSource(params)) + if err != nil { + return nil, fmt.Errorf("nezha: 下单签名失败: %w", err) + } + params["sign"] = sig + params["sign_type"] = "RSA" + + raw, err := p.postForm(ctx, "/api/pay/create", params) + if err != nil { + return nil, fmt.Errorf("nezha: 下单请求失败: %w", err) + } + if err := verifyResponseSign(p.platformPub, raw); err != nil { + return nil, fmt.Errorf("nezha: 下单响应验签失败: %w", err) + } + if !isCreateSuccess(raw) { + return nil, fmt.Errorf("nezha: 下单被拒: code=%v msg=%v", raw["code"], raw["msg"]) + } + tradeNo, _ := raw["trade_no"].(string) + if tradeNo == "" { + return nil, fmt.Errorf("nezha: 下单响应缺少 trade_no") + } + payURL, _ := raw["payurl"].(string) + qrCode, _ := raw["qrcode"].(string) + + wantQR := req.Metadata["render"] == "qr" || (payURL == "" && qrCode != "") + if wantQR { + if qrCode == "" { + return nil, fmt.Errorf("nezha: 请求扫码渲染但响应无 qrcode") + } + return &provider.Session{ + ProviderRef: tradeNo, + RenderType: provider.RenderQR, + Payload: map[string]any{ + "qr_content": qrCode, + "display_amount": amount, // 元串(非分),同 alipay createQR 的展示惯例 + "currency": "CNY", + }, + }, nil + } + if payURL == "" { + return nil, fmt.Errorf("nezha: 下单响应缺少 payurl") + } + return &provider.Session{ + ProviderRef: tradeNo, + RenderType: provider.RenderRedirect, + Payload: map[string]any{"url": payURL}, + }, nil +} + +// isCreateSuccess 判定 /api/pay/create 响应的 code 是否表示成功:JSON 数字解出来是 +// float64,兼容个别实现把 code 当字符串返回。 +func isCreateSuccess(raw map[string]any) bool { + switch v := raw["code"].(type) { + case float64: + return v == 1 + case string: + return v == "1" + default: + return false + } +} + +// statusIsPaid 判定 /api/pay/query 响应的 status 是否为"已支付"(规格:1=已支付)。 +func statusIsPaid(v any) bool { + switch t := v.(type) { + case float64: + return t == 1 + case string: + return t == "1" + default: + return false + } +} + +// VerifyCallback 处理哪吒的异步通知——GET,参数在 query string(不在 body)。 +// handler 层(internal/handler/gateway.go Callback)负责把 c.Request.URL.Query() +// 灌进 in.Query;这里兜底一次:若 in.Query 为空但 in.Raw 非空,当作 query string +// 解析(防御未来调用路径变化)。验签用平台公钥,规则与出站签名同一套 sign_note。 +func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { + q := in.Query + if len(q) == 0 && len(in.Raw) > 0 { + if parsed, err := url.ParseQuery(string(in.Raw)); err == nil { + q = map[string]string{} + for k := range parsed { + q[k] = parsed.Get(k) + } + } + } + if len(q) == 0 { + return nil, fmt.Errorf("nezha: 回调无可用参数") + } + sig := q["sign"] + if sig == "" { + return nil, fmt.Errorf("nezha: 回调缺少 sign") + } + source := buildSignSource(q) + if err := verifyRSA(p.platformPub, source, sig); err != nil { + return nil, fmt.Errorf("nezha: 回调验签失败: %w", err) + } + + tradeNo := q["trade_no"] + if tradeNo == "" { + return nil, fmt.Errorf("nezha: 回调缺少 trade_no") + } + var minor int64 + if m := q["money"]; m != "" { + parsed, err := money.Parse(m, "CNY") + if err != nil { + return nil, fmt.Errorf("nezha: 回调金额解析失败 %q: %w", m, err) + } + minor = parsed + } + + ev := &provider.PaidEvent{ + ProviderRef: tradeNo, + PaidAmountMinor: minor, + PaidCurrency: "CNY", + Raw: rawQueryString(in.Raw, q), + } + if q["trade_status"] == "TRADE_SUCCESS" { + ev.Status = provider.PaidSucceeded + // PaidAt 留 nil:通知未带独立付款时间字段,交 settle 按既有惯例(同 alipay + // D4-A3)用收到时间兜底,避免两边对账时间对不上。 + } else { + ev.Status = provider.PaidPending + } + return ev, nil +} + +func rawQueryString(orig []byte, q map[string]string) string { + if len(orig) > 0 { + return string(orig) + } + v := url.Values{} + for k, val := range q { + v.Set(k, val) + } + return v.Encode() +} + +// Query POST /api/pay/query。文档未列查单响应带 sign 字段(与下单响应不同),故只在 +// 响应确有 sign 时顺手验一次,没有则不因此判失败——按文档字面,只认 status 字段。 +func (p *Provider) Query(ctx context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + params := map[string]string{ + "pid": p.pid, + "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + } + if req.ProviderRef != "" { + params["trade_no"] = req.ProviderRef + } else { + params["out_trade_no"] = req.OutTradeNo + } + sig, err := signRSA(p.priv, buildSignSource(params)) + if err != nil { + return nil, fmt.Errorf("nezha: 查单签名失败: %w", err) + } + params["sign"] = sig + params["sign_type"] = "RSA" + + raw, err := p.postForm(ctx, "/api/pay/query", params) + if err != nil { + return nil, fmt.Errorf("nezha: 查单请求失败: %w", err) + } + if _, ok := raw["sign"]; ok { + if err := verifyResponseSign(p.platformPub, raw); err != nil { + return nil, fmt.Errorf("nezha: 查单响应验签失败: %w", err) + } + } + + pending := &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending} + if !statusIsPaid(raw["status"]) { + return pending, nil + } + var minor int64 + if m, _ := raw["money"].(string); m != "" { + parsed, perr := money.Parse(m, "CNY") + if perr != nil { + return nil, fmt.Errorf("nezha: 查单金额解析失败 %q: %w", m, perr) + } + minor = parsed + } + return &provider.PaidEvent{ + ProviderRef: req.ProviderRef, + Status: provider.PaidSucceeded, + PaidAmountMinor: minor, + PaidCurrency: "CNY", + }, nil +} + +// postForm POST application/x-www-form-urlencoded,解析 JSON 响应为 map[string]any +// (数字统一 float64),供上层做签名验证 + 字段提取。 +func (p *Provider) postForm(ctx context.Context, path string, params map[string]string) (map[string]any, error) { + form := url.Values{} + for k, v := range params { + form.Set(k, v) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+path, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := p.http.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("响应解析失败: %w (body=%s)", err, body) + } + return raw, nil +} diff --git a/internal/provider/nezha/nezha_test.go b/internal/provider/nezha/nezha_test.go new file mode 100644 index 0000000..bf8c2fa --- /dev/null +++ b/internal/provider/nezha/nezha_test.go @@ -0,0 +1,595 @@ +package nezha_test + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/wangjia/pay/internal/provider" + nz "github.com/wangjia/pay/internal/provider/nezha" +) + +const testNotifyURL = "https://pay.example.com/api/v2/callback/nezha" + +// --------------------------------------------------------------------------- +// 独立(不复用 nezha 包内部实现)签名/验签工具,照 sign_note 字面重新实现一遍。 +// 与 alipay_test.go 的 signRSA2 同一套惯例:测试自己造签名/验签逻辑去构造 fixture、 +// 校验被测代码产出,避免"拿被测代码自己的实现验自己"的假绿。exclusion/ordering 的 +// 精确规则另有 TestBuildSignSourceOrderingAndExclusion 通过 export_test.go 直接钉死。 +// --------------------------------------------------------------------------- + +func canonicalSource(params map[string]string) string { + 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]) + } + return strings.Join(parts, "&") +} + +func signWith(t *testing.T, priv *rsa.PrivateKey, params map[string]string) string { + t.Helper() + h := sha256.Sum256([]byte(canonicalSource(params))) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return base64.StdEncoding.EncodeToString(sig) +} + +func verifyWith(pub *rsa.PublicKey, params map[string]string, sigB64 string) error { + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return err + } + h := sha256.Sum256([]byte(canonicalSource(params))) + return rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig) +} + +func genKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa key: %v", err) + } + return k +} + +func privPEM(k *rsa.PrivateKey) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})) +} + +func pubPEM(k *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(k) + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) +} + +// formToParams 把 url.Values(单值)拍平成 map[string]string,供签名串重算用。 +func formToParams(v url.Values) map[string]string { + out := make(map[string]string, len(v)) + for k := range v { + out[k] = v.Get(k) + } + return out +} + +// jsonToParams 把 JSON 响应节点(map[string]any,数字统一是 float64)拍平成 +// map[string]string,与 nezha 包内 stringifyParams 的转换规则保持一致 +// (仅标量参与签名;整数不带小数点,以便与商户/平台侧对同一份数值的字符串化结果一致)。 +func jsonToParams(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 +} + +func newProvider(t *testing.T, baseURL, notifyURL string, merchantPriv, platformPriv *rsa.PrivateKey) *nz.Provider { + t.Helper() + p, err := nz.New("test-pid-1", privPEM(merchantPriv), pubPEM(&platformPriv.PublicKey), notifyURL, nz.WithBaseURL(baseURL)) + if err != nil { + t.Fatalf("new: %v", err) + } + return p +} + +// --------------------------------------------------------------------------- +// 签名串构造规则:精确钉死排序/排除(sign/sign_type/空值)。 +// --------------------------------------------------------------------------- + +func TestBuildSignSourceOrderingAndExclusion(t *testing.T) { + got := nz.BuildSignSourceForTest(map[string]string{ + "b": "2", "a": "1", "sign": "should-be-excluded", "sign_type": "RSA", + "empty": "", "c": "3", + }) + want := "a=1&b=2&c=3" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +// --------------------------------------------------------------------------- +// New: 凭证校验。 +// --------------------------------------------------------------------------- + +func TestNewRejectsBadCredentials(t *testing.T) { + merchant := genKey(t) + if _, err := nz.New("", privPEM(merchant), pubPEM(&merchant.PublicKey), testNotifyURL); err == nil { + t.Fatal("空 pid 应报错") + } + if _, err := nz.New("pid", privPEM(merchant), "not-a-key", testNotifyURL); err == nil { + t.Fatal("非法平台公钥应报错") + } + if _, err := nz.New("pid", "not-a-key", pubPEM(&merchant.PublicKey), testNotifyURL); err == nil { + t.Fatal("非法商户私钥应报错") + } + if _, err := nz.New("pid", privPEM(merchant), pubPEM(&merchant.PublicKey), ""); err == nil { + t.Fatal("空 notify_url 应报错") + } +} + +// TestNewAcceptsBareBase64Keys 兼容无 PEM 头的裸 base64 DER(同 alipay 账户凭证 +// "无 PEM 头亦可" 的既有惯例)。 +func TestNewAcceptsBareBase64Keys(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + privDER := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(merchant)) + pubDER, err := x509.MarshalPKIXPublicKey(&platform.PublicKey) + if err != nil { + t.Fatalf("marshal platform pub: %v", err) + } + pubB64 := base64.StdEncoding.EncodeToString(pubDER) + if _, err := nz.New("pid", privDER, pubB64, testNotifyURL); err != nil { + t.Fatalf("裸 base64 密钥应可解析: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +func TestCreateRedirect(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + + var gotForm url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + gotForm = r.Form + if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Fatalf("content-type = %q", got) + } + resp := map[string]any{ + "code": float64(1), "msg": "success", + "trade_no": "NZ2026071100001", "out_trade_no": gotForm.Get("out_trade_no"), + "pay_type": gotForm.Get("type"), "payurl": "https://nzzf.org/pay/checkout/abc", + "qrcode": "", "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + "sign_type": "RSA", + } + resp["sign"] = signWith(t, platform, jsonToParams(resp)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-NZ-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", + ReturnURL: "https://x/return", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "NZ2026071100001" { + t.Fatalf("session = %+v", sess) + } + if u, _ := sess.Payload["url"].(string); u != "https://nzzf.org/pay/checkout/abc" { + t.Fatalf("payload.url = %v", sess.Payload["url"]) + } + + // 下单请求本身的签名须逐字节对:用"平台侧"验签工具、商户公钥重验一遍。 + if err := verifyWith(&merchant.PublicKey, formToParams(gotForm), gotForm.Get("sign")); err != nil { + t.Fatalf("请求签名验证失败(逐字节不对): %v", err) + } + if gotForm.Get("sign_type") != "RSA" { + t.Fatalf("sign_type = %q, want RSA", gotForm.Get("sign_type")) + } + if gotForm.Get("pid") != "test-pid-1" { + t.Fatalf("pid = %q", gotForm.Get("pid")) + } + if gotForm.Get("type") != "alipay" { + t.Fatalf("未传 Metadata[type] 时默认应为 alipay, got %q", gotForm.Get("type")) + } + if gotForm.Get("notify_url") != testNotifyURL { + t.Fatalf("notify_url = %q, want %q", gotForm.Get("notify_url"), testNotifyURL) + } + if gotForm.Get("return_url") != "https://x/return" { + t.Fatalf("return_url = %q", gotForm.Get("return_url")) + } + if gotForm.Get("money") != "199" { + t.Fatalf("money = %q, want 199(money.Format 去尾零)", gotForm.Get("money")) + } +} + +func TestCreateQRWithExplicitType(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + + var gotForm url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotForm = r.Form + resp := map[string]any{ + "code": float64(1), "msg": "success", + "trade_no": "NZ-QR-1", "out_trade_no": gotForm.Get("out_trade_no"), + "pay_type": gotForm.Get("type"), "payurl": "", + "qrcode": "weixin://wxpay/bizpayurl?pr=abc123", "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + "sign_type": "RSA", + } + resp["sign"] = signWith(t, platform, jsonToParams(resp)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-NZ-QR", Subject: "Pro 月付", AmountMinor: 2990, Currency: "CNY", + Metadata: map[string]string{"render": "qr", "type": "wxpay"}, + }) + if err != nil { + t.Fatalf("create qr: %v", err) + } + if sess.RenderType != provider.RenderQR || sess.ProviderRef != "NZ-QR-1" { + t.Fatalf("session = %+v", sess) + } + if sess.Payload["qr_content"] != "weixin://wxpay/bizpayurl?pr=abc123" { + t.Fatalf("qr_content = %v", sess.Payload["qr_content"]) + } + if sess.Payload["currency"] != "CNY" { + t.Fatalf("currency = %v", sess.Payload["currency"]) + } + if sess.Payload["display_amount"] != "29.9" { + t.Fatalf("display_amount = %v, want 29.9", sess.Payload["display_amount"]) + } + if gotForm.Get("type") != "wxpay" { + t.Fatalf("Metadata[type]=wxpay 应透传, got %q", gotForm.Get("type")) + } +} + +// TestCreateQRAutoSelectedWhenNoPayURL: 未显式要求 render=qr,但渠道响应只带 qrcode +// 不带 payurl 时,也应自动落 RenderQR(不能因缺 url 报错)。 +func TestCreateQRAutoSelectedWhenNoPayURL(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + 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-AUTO-QR", + "payurl": "", "qrcode": "https://qr.example/abc", + "timestamp": strconv.FormatInt(time.Now().Unix(), 10), "sign_type": "RSA", + } + resp["sign"] = signWith(t, platform, jsonToParams(resp)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-AUTO-QR", Subject: "x", AmountMinor: 100, Currency: "CNY", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderQR { + t.Fatalf("render_type = %v, want qr(响应只带 qrcode)", sess.RenderType) + } +} + +func TestCreateChannelRejected(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "code": float64(-1), "msg": "商户不存在", + "timestamp": strconv.FormatInt(time.Now().Unix(), 10), "sign_type": "RSA", + } + resp["sign"] = signWith(t, platform, jsonToParams(resp)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-BAD", Subject: "x", AmountMinor: 100, Currency: "CNY", + }) + if err == nil { + t.Fatalf("want 渠道拒绝返回 error, got session=%+v", sess) + } + if sess != nil { + t.Fatalf("渠道拒绝时不应返回 session, got %+v", sess) + } +} + +// TestCreateResponseBadSignRejected: 攻击者/中间人用不相干私钥冒充平台响应签名, +// Create 必须报错、绝不能把伪造的 payurl/trade_no 当真返回。 +func TestCreateResponseBadSignRejected(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + wrongPlatform := genKey(t) // 冒充平台侧的另一把毫不相干的私钥 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "code": float64(1), "msg": "success", "trade_no": "NZ-EVIL", + "payurl": "https://evil.example/pay", "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + "sign_type": "RSA", + } + resp["sign"] = signWith(t, wrongPlatform, jsonToParams(resp)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + sess, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-EVIL", Subject: "x", AmountMinor: 100, Currency: "CNY", + }) + if err == nil { + t.Fatalf("want 验签失败 error, got session=%+v", sess) + } + if sess != nil { + t.Fatalf("验签失败时不应返回 session(哪怕字段看起来正常), got %+v", sess) + } +} + +func TestCreateRejectsNonCNY(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + if _, err := p.Create(context.Background(), provider.CreateRequest{ + OutTradeNo: "PAY-USD", Subject: "x", AmountMinor: 100, Currency: "USD", + }); err == nil { + t.Fatal("want 非 CNY 报错") + } +} + +// --------------------------------------------------------------------------- +// VerifyCallback(GET query,平台公钥验签) +// --------------------------------------------------------------------------- + +func buildCallbackQuery(t *testing.T, priv *rsa.PrivateKey, overrides map[string]string) map[string]string { + q := map[string]string{ + "pid": "test-pid-1", "trade_no": "NZ-CB-1", "out_trade_no": "PAY-CB-1", + "type": "alipay", "name": "Pro 年付", "money": "199.00", + "trade_status": "TRADE_SUCCESS", "timestamp": strconv.FormatInt(time.Now().Unix(), 10), + "sign_type": "RSA", + } + for k, v := range overrides { + q[k] = v + } + q["sign"] = signWith(t, priv, q) + return q +} + +func TestVerifyCallbackSuccess(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + q := buildCallbackQuery(t, platform, nil) + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Query: q}) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.ProviderRef != "NZ-CB-1" || ev.Status != provider.PaidSucceeded { + t.Fatalf("event = %+v", ev) + } + if ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" { + t.Fatalf("amount = %d %s", ev.PaidAmountMinor, ev.PaidCurrency) + } + if ev.PaidAt != nil { + t.Fatalf("回调未带独立付款时间字段,PaidAt 应为 nil(交 settle 用收到时间兜底), got %v", ev.PaidAt) + } +} + +// TestVerifyCallbackTamperedAmountRejected: 签名后篡改金额,验签必须失败——逐字节对 +// 是本任务的硬要求,篡改任何一个参与签名的字段都不能蒙混过关。 +func TestVerifyCallbackTamperedAmountRejected(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + q := buildCallbackQuery(t, platform, nil) + q["money"] = "999999.00" // 签名之后篡改 + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Query: q}) + if err == nil { + t.Fatalf("want 验签失败, got event=%+v", ev) + } + if ev != nil { + t.Fatalf("验签失败不应返回 event, got %+v", ev) + } +} + +func TestVerifyCallbackWrongKeyRejected(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + wrongPlatform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + q := buildCallbackQuery(t, wrongPlatform, nil) // 攻击者用不相干私钥签名,冒充平台 + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Query: q}) + if err == nil { + t.Fatalf("want 验签失败(签名私钥与装配平台公钥不匹配), got event=%+v", ev) + } + if ev != nil { + t.Fatalf("验签失败不应返回 event, got %+v", ev) + } +} + +func TestVerifyCallbackNonSuccessStatusIsPending(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + q := buildCallbackQuery(t, platform, map[string]string{"trade_status": "TRADE_CLOSED"}) + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Query: q}) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.Status != provider.PaidPending { + t.Fatalf("status = %v, want pending", ev.Status) + } +} + +func TestVerifyCallbackMissingSignRejected(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + q := map[string]string{"trade_no": "NZ-CB-2", "trade_status": "TRADE_SUCCESS"} + if _, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Query: q}); err == nil { + t.Fatal("want 缺 sign 报错") + } +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +func TestQuerySucceeded(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + var gotForm url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotForm = r.Form + resp := map[string]any{ + "code": float64(0), "trade_no": "NZ-Q-1", "out_trade_no": "PAY-Q-1", + "status": float64(1), "money": "199.00", "type": "alipay", + "addtime": "2026-07-11 10:00:00", "endtime": "2026-07-11 10:00:05", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + ev, err := p.Query(context.Background(), provider.QueryRequest{ProviderRef: "NZ-Q-1", OutTradeNo: "PAY-Q-1"}) + if err != nil { + t.Fatalf("query: %v", err) + } + if ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" { + t.Fatalf("event = %+v", ev) + } + if gotForm.Get("trade_no") != "NZ-Q-1" { + t.Fatalf("查单请求应优先带 trade_no(ProviderRef), got form=%v", gotForm) + } + if err := verifyWith(&merchant.PublicKey, formToParams(gotForm), gotForm.Get("sign")); err != nil { + t.Fatalf("查单请求签名验证失败: %v", err) + } +} + +func TestQueryPending(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{"code": float64(0), "trade_no": "NZ-Q-2", "status": float64(0)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + ev, err := p.Query(context.Background(), provider.QueryRequest{ProviderRef: "NZ-Q-2"}) + if err != nil { + t.Fatalf("query: %v", err) + } + if ev.Status != provider.PaidPending { + t.Fatalf("status = %v, want pending", ev.Status) + } +} + +func TestQueryPrefersOutTradeNoWhenNoProviderRef(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + var gotForm url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotForm = r.Form + resp := map[string]any{"code": float64(0), "status": float64(0)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + p := newProvider(t, ts.URL, testNotifyURL, merchant, platform) + + if _, err := p.Query(context.Background(), provider.QueryRequest{OutTradeNo: "PAY-NOREF"}); err != nil { + t.Fatalf("query: %v", err) + } + if gotForm.Get("out_trade_no") != "PAY-NOREF" || gotForm.Get("trade_no") != "" { + t.Fatalf("无 ProviderRef 时应用 out_trade_no 而非 trade_no, form=%v", gotForm) + } +} + +// --------------------------------------------------------------------------- +// Method / Capabilities +// --------------------------------------------------------------------------- + +func TestMethodAndCapabilities(t *testing.T) { + merchant := genKey(t) + platform := genKey(t) + p := newProvider(t, "https://unused.invalid", testNotifyURL, merchant, platform) + + if p.Method() != "nezha" { + t.Fatalf("Method() = %q, want nezha", p.Method()) + } + caps := p.Capabilities() + if caps.SupportsRefund { + t.Fatal("SupportsRefund 应为 false(官方文档未见退款接口)") + } + if len(caps.SettleCurrencies) != 1 || caps.SettleCurrencies[0] != "CNY" { + t.Fatalf("SettleCurrencies = %v", caps.SettleCurrencies) + } + if len(caps.Regions) != 1 || caps.Regions[0] != "cn" { + t.Fatalf("Regions = %v", caps.Regions) + } + wantRender := map[provider.RenderType]bool{provider.RenderRedirect: true, provider.RenderQR: true} + if len(caps.RenderTypes) != len(wantRender) { + t.Fatalf("RenderTypes = %v", caps.RenderTypes) + } + for _, rt := range caps.RenderTypes { + if !wantRender[rt] { + t.Fatalf("意外的 render_type: %v", rt) + } + } +} diff --git a/internal/provider/nezha/sign.go b/internal/provider/nezha/sign.go new file mode 100644 index 0000000..067828d --- /dev/null +++ b/internal/provider/nezha/sign.go @@ -0,0 +1,136 @@ +package nezha + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "sort" + "strconv" + "strings" +) + +// buildSignSource 实现哪吒 sign_note 的签名串规则:收集所有*非空*参数,排除 +// sign/sign_type/数组参数(调用方在构造 params 时就不应放入非标量值),按参数名 +// ASCII 升序排序,拼成 "key=value&key2=value2"(不拼商户密钥、不做任何 URL 编码)。 +// sort.Strings 是逐字节比较,对本协议里全 ASCII 的参数名等价于 ASCII 排序。 +func buildSignSource(params map[string]string) string { + 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]) + } + return strings.Join(parts, "&") +} + +// signRSA 对签名串做 SHA256WithRSA + Base64(sign_note 规定算法)。 +func signRSA(priv *rsa.PrivateKey, source string) (string, error) { + h := sha256.Sum256([]byte(source)) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// verifyRSA 验 SHA256WithRSA + Base64 签名。签名不合法/验签失败均返回 error。 +func verifyRSA(pub *rsa.PublicKey, source, sigB64 string) error { + sig, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + return fmt.Errorf("nezha: sign 不是合法 base64: %w", err) + } + h := sha256.Sum256([]byte(source)) + return rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig) +} + +// parsePrivateKey 解析商户 RSA 私钥,兼容 PKCS1/PKCS8、PEM 头或裸 base64 DER +// (与 alipay 账户凭证"无 PEM 头亦可"的既有惯例对齐,详见 config.AlipaySandboxConfig +// 字段注释)。 +func parsePrivateKey(s string) (*rsa.PrivateKey, error) { + der, err := pemOrBase64ToDER(s) + if err != nil { + return nil, err + } + if key, perr := x509.ParsePKCS8PrivateKey(der); perr == nil { + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("nezha: 私钥不是 RSA 类型(PKCS8)") + } + return rsaKey, nil + } + if key, perr := x509.ParsePKCS1PrivateKey(der); perr == nil { + return key, nil + } + return nil, fmt.Errorf("nezha: 无法解析商户私钥(需 PKCS1/PKCS8,PEM 或裸 base64 DER)") +} + +// parsePublicKey 解析平台 RSA 公钥,兼容 PKIX/PKCS1、PEM 头或裸 base64 DER。 +func parsePublicKey(s string) (*rsa.PublicKey, error) { + der, err := pemOrBase64ToDER(s) + if err != nil { + return nil, err + } + if key, perr := x509.ParsePKIXPublicKey(der); perr == nil { + rsaKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("nezha: 公钥不是 RSA 类型(PKIX)") + } + return rsaKey, nil + } + if key, perr := x509.ParsePKCS1PublicKey(der); perr == nil { + return key, nil + } + return nil, fmt.Errorf("nezha: 无法解析平台公钥(需 PKIX/PKCS1,PEM 或裸 base64 DER)") +} + +func pemOrBase64ToDER(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if block, _ := pem.Decode([]byte(s)); block != nil { + return block.Bytes, nil + } + clean := strings.Join(strings.Fields(s), "") + der, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + return nil, fmt.Errorf("nezha: 密钥既非 PEM 也非合法 base64: %w", err) + } + return der, nil +} + +// stringifyParams 把渠道 JSON 响应(map[string]any,数字统一是 float64)拍平成 +// map[string]string 供 buildSignSource 用;数组/对象/nil 一律丢弃(sign_note 排除 +// 数组参数,响应里也不应出现需要参与签名的复合结构)。 +func stringifyParams(raw map[string]any) map[string]string { + out := make(map[string]string, len(raw)) + for k, v := range raw { + switch t := v.(type) { + case string: + out[k] = t + case float64: + out[k] = strconv.FormatFloat(t, 'f', -1, 64) + case bool: + out[k] = strconv.FormatBool(t) + } + } + return out +} + +// verifyResponseSign 用平台公钥验渠道 JSON 响应(下单/查单)自带的 sign。 +func verifyResponseSign(pub *rsa.PublicKey, raw map[string]any) error { + sig, _ := raw["sign"].(string) + if sig == "" { + return fmt.Errorf("响应缺少 sign 字段") + } + source := buildSignSource(stringifyParams(raw)) + return verifyRSA(pub, source, sig) +} diff --git a/internal/providerbuild/registry.go b/internal/providerbuild/registry.go index 19f1046..a8c1b78 100644 --- a/internal/providerbuild/registry.go +++ b/internal/providerbuild/registry.go @@ -13,6 +13,7 @@ package providerbuild import ( "log" + "strings" sw "github.com/smartwalle/alipay/v3" stripeclient "github.com/stripe/stripe-go/v79/client" @@ -23,6 +24,7 @@ import ( "github.com/wangjia/pay/internal/provider/alipay" "github.com/wangjia/pay/internal/provider/crypto" "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/provider/nezha" "github.com/wangjia/pay/internal/provider/stripe" ) @@ -68,6 +70,27 @@ func BuildRegistry(accts *accounts.Registry) *provider.Registry { } } + // nezha:RSA 聚合支付,首个 enabled 账户的 env 凭证 → *nezha.Provider。notify_url + // 与 alipay 不同——alipay notify_url 走支付宝开放平台应用级配置(per-request 可省略), + // 哪吒协议要求每次下单显式携带,这里据 config.C.Server.BaseURL 拼好传入。 + if ns := accts.EnabledFor("nezha", ""); len(ns) > 0 { + n := ns[0] + pid := accts.Credential(n.AccountID, "PID") + priv := accts.Credential(n.AccountID, "PRIVATE_KEY") + pub := accts.Credential(n.AccountID, "PLATFORM_PUBLIC_KEY") + if pid == "" || priv == "" || pub == "" { + log.Printf("[providers] nezha 账户 %s 凭证不全,跳过", n.AccountID) + } else { + notifyURL := strings.TrimRight(config.C.Server.BaseURL, "/") + "/api/v2/callback/nezha" + if np, err := nezha.New(pid, priv, pub, notifyURL); err != nil { + log.Printf("[providers] nezha client 构建失败: %v", err) + } else { + reg.Register(np) + log.Println("[providers] nezha 已注册") + } + } + } + // stripe:首个 enabled 账户的 env 凭证 → *client.API。 if ss := accts.EnabledFor("stripe", ""); len(ss) > 0 { s := ss[0] diff --git a/internal/providerbuild/registry_build_test.go b/internal/providerbuild/registry_build_test.go index ef15886..f1a2591 100644 --- a/internal/providerbuild/registry_build_test.go +++ b/internal/providerbuild/registry_build_test.go @@ -1,6 +1,10 @@ package providerbuild_test import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" "reflect" "sort" "testing" @@ -21,6 +25,24 @@ import ( // 的"缺凭证→跳过、不 fatal、注册表里没有它"分支,以及它在混合场景里不会误伤 // 同批次里凭证齐备的其它渠道——这正是 BuildRegistry 装配决策本身要保证的行为。 func TestBuildRegistry(t *testing.T) { + // nezha 凭证解析(export_test.go 的 base64 DER 兜底路径,见 nezha 包)不像 alipay + // 依赖 smartwalle SDK 的特定加载方式,构造一对测试用 RSA 密钥成本很低,故这里同时 + // 覆盖"凭证齐备→真注册"分支(alipay 因构造成本高特意跳过,见上方注释)。 + nezhaMerchant, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen nezha merchant key: %v", err) + } + nezhaPlatform, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen nezha platform key: %v", err) + } + nezhaPrivB64 := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(nezhaMerchant)) + nezhaPubDER, err := x509.MarshalPKIXPublicKey(&nezhaPlatform.PublicKey) + if err != nil { + t.Fatalf("marshal nezha platform pub: %v", err) + } + nezhaPubB64 := base64.StdEncoding.EncodeToString(nezhaPubDER) + cases := []struct { name string accounts []config.AccountConfig @@ -52,6 +74,26 @@ func TestBuildRegistry(t *testing.T) { // 若 BuildRegistry 对此 fatal/panic,本测试直接挂掉,已是断言的一部分。 want: nil, }, + { + name: "nezha_enabled_但凭证不全_跳过不fatal_注册表无nezha", + accounts: []config.AccountConfig{ + {AccountID: "nz-1", Channel: "nezha", Enabled: true, CredentialEnvPrefix: "t_nz"}, + }, + // 故意不设 T_NZ_PID/_PRIVATE_KEY/_PLATFORM_PUBLIC_KEY。 + want: nil, + }, + { + name: "nezha_enabled_且凭证齐备_注册nezha", + accounts: []config.AccountConfig{ + {AccountID: "nz-2", Channel: "nezha", Enabled: true, CredentialEnvPrefix: "t_nz2"}, + }, + env: map[string]string{ + "T_NZ2_PID": "test-pid", + "T_NZ2_PRIVATE_KEY": nezhaPrivB64, + "T_NZ2_PLATFORM_PUBLIC_KEY": nezhaPubB64, + }, + want: []string{"nezha"}, + }, { name: "stripe_enabled_且凭证齐备_注册stripe", accounts: []config.AccountConfig{ diff --git a/internal/router/router.go b/internal/router/router.go index e380892..0c9d309 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -59,6 +59,8 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) { v2.POST("/orders/:order_no/retry", stateLimit, h.Retry) v2.POST("/orders/:order_no/cancel", stateLimit, h.Cancel) v2.POST("/callback/:method", h.Callback) + // GET 同一 handler:部分渠道(如 nezha)异步通知走 GET query string,不是 POST body。 + v2.GET("/callback/:method", h.Callback) v2.POST("/refunds", h.CreateRefund) v2.GET("/refunds/:refund_id", h.GetRefund) }