Merge branch 'main' into rebuild/auth-identity-foundation
This commit is contained in:
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// Alipay product codes.
|
||||
const (
|
||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||
)
|
||||
|
||||
// Alipay response constants.
|
||||
@@ -102,8 +102,13 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string {
|
||||
return map[string]string{"app_id": appID}
|
||||
}
|
||||
|
||||
// CreatePayment creates an Alipay payment page URL.
|
||||
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
// CreatePayment creates an Alipay payment using redirect-only flow:
|
||||
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
|
||||
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
|
||||
// new window; Alipay's own page then shows login/QR. We intentionally do
|
||||
// NOT encode the URL into a QR on the client (it isn't a scannable payload
|
||||
// and would produce an invalid scan result).
|
||||
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := a.getClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -119,44 +124,46 @@ func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentReq
|
||||
}
|
||||
|
||||
if req.IsMobile {
|
||||
return a.createTrade(ctx, client, req, notifyURL, returnURL, true)
|
||||
return a.createWapTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
return a.createTrade(ctx, client, req, notifyURL, returnURL, false)
|
||||
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||
}
|
||||
|
||||
func (a *Alipay) createTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
|
||||
if isMobile {
|
||||
param := alipay.TradeWapPay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodeWapPay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradeWapPay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
param := alipay.TradePreCreate{}
|
||||
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradeWapPay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodeWapPay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
resp, err := alipayTradePreCreate(ctx, client, param)
|
||||
payURL, err := client.TradeWapPay(param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
|
||||
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
QRCode: strings.TrimSpace(resp.QRCode),
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||
param := alipay.TradePagePay{}
|
||||
param.OutTradeNo = req.OrderID
|
||||
param.TotalAmount = req.Amount
|
||||
param.Subject = req.Subject
|
||||
param.ProductCode = alipayProductCodePagePay
|
||||
param.NotifyURL = notifyURL
|
||||
param.ReturnURL = returnURL
|
||||
|
||||
payURL, err := alipayTradePagePay(client, param)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
|
||||
}
|
||||
return &payment.CreatePaymentResponse{
|
||||
TradeNo: req.OrderID,
|
||||
PayURL: payURL.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,17 @@ package provider
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
@@ -84,15 +85,35 @@ type Wxpay struct {
|
||||
notifyHandler *notify.Handler
|
||||
}
|
||||
|
||||
const wxpayAPIv3KeyLength = 32
|
||||
|
||||
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "publicKey", "publicKeyId", "certSerial"}
|
||||
// All fields are required. Platform-certificate mode is intentionally unsupported —
|
||||
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
|
||||
// and newly-provisioned merchants cannot download platform certificates at all.
|
||||
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
|
||||
for _, k := range required {
|
||||
if config[k] == "" {
|
||||
return nil, fmt.Errorf("wxpay config missing required key: %s", k)
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
|
||||
WithMetadata(map[string]string{"key": k})
|
||||
}
|
||||
}
|
||||
if len(config["apiV3Key"]) != 32 {
|
||||
return nil, fmt.Errorf("wxpay apiV3Key must be exactly 32 bytes, got %d", len(config["apiV3Key"]))
|
||||
if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
|
||||
WithMetadata(map[string]string{
|
||||
"key": "apiV3Key",
|
||||
"expected": strconv.Itoa(wxpayAPIv3KeyLength),
|
||||
"actual": strconv.Itoa(len(config["apiV3Key"])),
|
||||
})
|
||||
}
|
||||
// Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
|
||||
if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
return &Wxpay{instanceID: instanceID, config: config}, nil
|
||||
}
|
||||
@@ -127,14 +148,19 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
if w.coreClient != nil {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
privateKey, publicKey, err := w.loadKeyPair()
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "privateKey"})
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
|
||||
WithMetadata(map[string]string{"key": "publicKey"})
|
||||
}
|
||||
certSerial := w.config["certSerial"]
|
||||
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
|
||||
client, err := core.NewClient(context.Background(),
|
||||
option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey),
|
||||
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
|
||||
option.WithVerifier(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wxpay init client: %w", err)
|
||||
@@ -148,18 +174,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
|
||||
return w.coreClient, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
|
||||
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("wxpay load private key: %w", err)
|
||||
}
|
||||
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("wxpay load public key: %w", err)
|
||||
}
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||
client, err := w.ensureClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,10 @@ package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -16,6 +20,26 @@ import (
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
)
|
||||
|
||||
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
|
||||
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
|
||||
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key: %v", err)
|
||||
}
|
||||
privDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkcs8: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pkix: %v", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
}
|
||||
|
||||
func TestMapWxState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -183,13 +207,14 @@ func TestFormatPEM(t *testing.T) {
|
||||
func TestNewWxpay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
privPEM, pubPEM := generateTestKeyPair(t)
|
||||
validConfig := map[string]string{
|
||||
"appId": "wx1234567890",
|
||||
"mchId": "1234567890",
|
||||
"privateKey": "fake-private-key",
|
||||
"privateKey": privPEM,
|
||||
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
||||
"publicKey": "fake-public-key",
|
||||
"publicKeyId": "key-id-001",
|
||||
"publicKey": pubPEM,
|
||||
"publicKeyId": "PUB_KEY_ID_TEST",
|
||||
"certSerial": "SERIAL001",
|
||||
}
|
||||
|
||||
@@ -240,6 +265,12 @@ func TestNewWxpay(t *testing.T) {
|
||||
wantErr: true,
|
||||
errSubstr: "apiV3Key",
|
||||
},
|
||||
{
|
||||
name: "missing certSerial",
|
||||
config: withOverride(map[string]string{"certSerial": ""}),
|
||||
wantErr: true,
|
||||
errSubstr: "certSerial",
|
||||
},
|
||||
{
|
||||
name: "missing publicKey",
|
||||
config: withOverride(map[string]string{"publicKey": ""}),
|
||||
@@ -252,17 +283,29 @@ func TestNewWxpay(t *testing.T) {
|
||||
wantErr: true,
|
||||
errSubstr: "publicKeyId",
|
||||
},
|
||||
{
|
||||
name: "malformed privateKey PEM",
|
||||
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "malformed publicKey PEM",
|
||||
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
|
||||
wantErr: true,
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too short",
|
||||
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
||||
wantErr: true,
|
||||
errSubstr: "exactly 32 bytes",
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
{
|
||||
name: "apiV3Key too long",
|
||||
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
||||
wantErr: true,
|
||||
errSubstr: "exactly 32 bytes",
|
||||
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user