e1060f1f6e
哪吒 pay/create 要求 return_url 必填;业务方未显式传时下单被拒(缺少参数 return_url)。 兜底用 notify_url 的 origin(pay 对外基址)保证合法非空。
353 lines
11 KiB
Go
353 lines
11 KiB
Go
// 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 是否表示成功: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
|
|
}
|