merge: nezha-provider
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())
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user