refactor(payment): 支付插件 - 宿主仅通过 RPC 包加载并保护订单版本
This commit is contained in:
1 parent
d6bbdbe27b
commit
38e715ea3f
45 files changed
+1584
-203
No files matched your search
@@ -0,0 +1,289 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWeChatNativeCreateOrderSignsRequestAndReturnsQRCode(t *testing.T) {
|
||||
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/v3/pay/transactions/native" {
|
||||
t.Fatalf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(request.Header.Get("Authorization"), `mchid="1900000001"`) {
|
||||
t.Fatalf("authorization = %q", request.Header.Get("Authorization"))
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["out_trade_no"] != "0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("out_trade_no = %v", payload["out_trade_no"])
|
||||
}
|
||||
responseBody := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`)
|
||||
timestamp, nonce := fixedNow.Unix(), "response-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(timestamp, nonce, responseBody)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Header().Set("Wechatpay-Timestamp", "1788321600")
|
||||
writer.Header().Set("Wechatpay-Nonce", nonce)
|
||||
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
||||
writer.Header().Set("Wechatpay-Signature", signature)
|
||||
_, _ = writer.Write(responseBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewWeChatProvider(server.Client(), server.URL)
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
||||
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/notify",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "qr_code" || !strings.HasPrefix(checkout.Value, "weixin://") || !checkout.ExpiresAt.Equal(fixedNow.Add(30*time.Minute)) {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
_ = merchantPublic
|
||||
}
|
||||
|
||||
func TestWeChatNotificationVerifiesAndDecrypts(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
transaction := []byte(`{"appid":"wx-app","mchid":"1900000001","out_trade_no":"0123456789abcdef0123456789abcdef","transaction_id":"420000001","trade_type":"NATIVE","trade_state":"SUCCESS","success_time":"2026-09-02T11:59:00+08:00","amount":{"total":100,"currency":"CNY"}}`)
|
||||
ciphertext := encryptWeChatTestResource(t, config["apiV3Key"], "0123456789ab", "transaction", transaction)
|
||||
envelope, err := json.Marshal(map[string]any{
|
||||
"id": "EV-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource",
|
||||
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "nonce": "0123456789ab"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := "notification-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, envelope)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headers := http.Header{
|
||||
"Wechatpay-Timestamp": {"1788321600"}, "Wechatpay-Nonce": {nonce},
|
||||
"Wechatpay-Serial": {"PUB_KEY_ID_3000000001"}, "Wechatpay-Signature": {signature},
|
||||
}
|
||||
provider := NewWeChatProvider(http.DefaultClient, "https://example.invalid")
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, headers, envelope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notification.EventID != "EV-1" || !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "420000001" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatMissingTradeBillUsesBillSentinel(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
platformPrivate, platformPublic := testRSAKeyPair(t)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
config := testWeChatConfig(merchantPrivate, platformPublic)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body := []byte(`{"code":"NO_STATEMENT_EXIST","message":"not ready"}`)
|
||||
nonce := "bill-response-nonce"
|
||||
signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer.Header().Set("Wechatpay-Timestamp", strconv.FormatInt(fixedNow.Unix(), 10))
|
||||
writer.Header().Set("Wechatpay-Nonce", nonce)
|
||||
writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001")
|
||||
writer.Header().Set("Wechatpay-Signature", signature)
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewWeChatProvider(server.Client(), server.URL)
|
||||
provider.now = func() time.Time { return fixedNow }
|
||||
_, err := provider.DownloadTradeBill(context.Background(), config, fixedNow.AddDate(0, 0, -1))
|
||||
if !errors.Is(err, ErrTradeBillNotFound) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayPagePayBuildsSignedDesktopCheckout(t *testing.T) {
|
||||
merchantPrivate, merchantPublic := testRSAKeyPair(t)
|
||||
_, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
provider := NewAlipayProvider(http.DefaultClient)
|
||||
fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
provider.now = func() time.Time { return fixedNow.Add(time.Second) }
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分",
|
||||
AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute),
|
||||
NotifyURL: "https://merchant.example/api/notify", ReturnURL: "https://merchant.example/api/return",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(checkout.Value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := parsed.Query()
|
||||
signature := params.Get("sign")
|
||||
params.Del("sign")
|
||||
if err := rsaSHA256Verify(merchantPublic, []byte(canonicalAlipayParams(params)), signature); err != nil {
|
||||
t.Fatalf("checkout signature: %v", err)
|
||||
}
|
||||
if params.Get("method") != "alipay.trade.page.pay" || !strings.Contains(params.Get("biz_content"), `"product_code":"FAST_INSTANT_TRADE_PAY"`) || !strings.Contains(params.Get("biz_content"), `"timeout_express":"30m"`) {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayRejectsNonOfficialGateway(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
_, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
config["gateway"] = "https://evil.example/gateway.do"
|
||||
if err := NewAlipayProvider(http.DefaultClient).ValidateConfig(config); err == nil {
|
||||
t.Fatal("expected non-official gateway error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlipayNotificationVerifiesMerchantAndAmount(t *testing.T) {
|
||||
merchantPrivate, _ := testRSAKeyPair(t)
|
||||
alipayPrivate, alipayPublic := testRSAKeyPair(t)
|
||||
config := testAlipayConfig(merchantPrivate, alipayPublic)
|
||||
values := url.Values{
|
||||
"notify_id": {"notify-1"}, "app_id": {"2026000000000001"}, "seller_id": {"2088000000000001"},
|
||||
"out_trade_no": {"0123456789abcdef0123456789abcdef"}, "trade_no": {"2026090200001"},
|
||||
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"1.00"}, "gmt_payment": {"2026-09-02 12:00:00"},
|
||||
"sign_type": {"RSA2"},
|
||||
}
|
||||
unsigned := cloneURLValues(values)
|
||||
unsigned.Del("sign_type")
|
||||
signature, err := rsaSHA256Sign(alipayPrivate, []byte(canonicalAlipayParams(unsigned)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values.Set("sign", signature)
|
||||
provider := NewAlipayProvider(http.DefaultClient)
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(values.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notification.EventID != "notify-1" || !notification.Paid || notification.AmountFen != 100 || notification.Currency != "CNY" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYuanToFenUsesExactDecimalArithmetic(t *testing.T) {
|
||||
for value, expected := range map[string]int64{"0.01": 1, "1": 100, "1.2": 120, "1000000.99": 100000099} {
|
||||
actual, err := parseYuanToFen(value)
|
||||
if err != nil || actual != expected {
|
||||
t.Fatalf("parseYuanToFen(%q) = %d, %v; want %d", value, actual, err, expected)
|
||||
}
|
||||
}
|
||||
if _, err := parseYuanToFen("1.001"); err == nil {
|
||||
t.Fatal("expected precision error")
|
||||
}
|
||||
if _, err := parseYuanToFen("92233720368547758.08"); err == nil {
|
||||
t.Fatal("expected overflow error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSAKeyParsersAcceptRawBase64(t *testing.T) {
|
||||
privateKey, publicKey := testRSAKeyPair(t)
|
||||
privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseRSAPrivateKey(base64.StdEncoding.EncodeToString(privateDER)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseRSAPublicKey(base64.StdEncoding.EncodeToString(publicDER)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return privateKey, &privateKey.PublicKey
|
||||
}
|
||||
|
||||
func testPrivatePEM(key *rsa.PrivateKey) string {
|
||||
encoded, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))
|
||||
}
|
||||
|
||||
func testPublicPEM(key *rsa.PublicKey) string {
|
||||
encoded, _ := x509.MarshalPKIXPublicKey(key)
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: encoded}))
|
||||
}
|
||||
|
||||
func testWeChatConfig(merchantPrivate *rsa.PrivateKey, platformPublic *rsa.PublicKey) Config {
|
||||
return Config{
|
||||
"appId": "wx-app", "mchId": "1900000001", "merchantSerialNo": "MERCHANT-SERIAL",
|
||||
"merchantPrivateKey": testPrivatePEM(merchantPrivate), "apiV3Key": "0123456789abcdef0123456789abcdef",
|
||||
"wechatPayPublicKeyId": "PUB_KEY_ID_3000000001", "wechatPayPublicKey": testPublicPEM(platformPublic),
|
||||
}
|
||||
}
|
||||
|
||||
func testAlipayConfig(merchantPrivate *rsa.PrivateKey, alipayPublic *rsa.PublicKey) Config {
|
||||
return Config{
|
||||
"appId": "2026000000000001", "sellerId": "2088000000000001", "merchantPrivateKey": testPrivatePEM(merchantPrivate),
|
||||
"alipayPublicKey": testPublicPEM(alipayPublic), "gateway": "https://openapi.alipay.com/gateway.do",
|
||||
}
|
||||
}
|
||||
|
||||
func testWeChatMessage(timestamp int64, nonce string, body []byte) string {
|
||||
return strconv.FormatInt(timestamp, 10) + "\n" + nonce + "\n" + string(body) + "\n"
|
||||
}
|
||||
|
||||
func encryptWeChatTestResource(t *testing.T, key, nonce, associatedData string, plaintext []byte) string {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(associatedData)))
|
||||
}
|
||||
Reference in new issue
Block a user