// 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 是最常见通道) } // 哪吒协议要求 return_url 必填(支付后浏览器跳回地址);业务方(pangolin)未显式传时, // 兜底用 notify_url 的 origin(即 pay 对外基址,如 https://pay.yanmeiai.com),保证合法非空。 returnURL := req.ReturnURL if returnURL == "" { if u, e := url.Parse(p.notifyURL); e == nil && u.Host != "" { returnURL = u.Scheme + "://" + u.Host } } params := map[string]string{ "pid": p.pid, "type": payType, "out_trade_no": req.OutTradeNo, "notify_url": p.notifyURL, "return_url": 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 是否表示成功:真实哪吒 create 成功返回 // code=0 msg=success(与 query 一致),而非 code=1(经生产联调实测更正)。JSON 数字解出来是 // float64,兼容个别实现把 code 当字符串返回。 func isCreateSuccess(raw map[string]any) bool { switch v := raw["code"].(type) { case float64: return v == 0 case string: return v == "0" 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 }