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()) } }