feat(payment): add complete payment system with multi-provider support

Add a full payment and subscription system supporting EasyPay (Alipay/WeChat),
Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
erio
2026-04-10 21:08:51 +08:00
parent 00c08c574e
commit 63d1860dc0
166 changed files with 42743 additions and 220 deletions

View File

@@ -0,0 +1,279 @@
package provider
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
)
// Alipay product codes.
const (
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
alipayProductCodeWapPay = "QUICK_WAP_WAY"
)
// Alipay response constants.
const (
alipayFundChangeYes = "Y"
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundSuffix = "-refund"
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
type Alipay struct {
instanceID string
config map[string]string // appId, privateKey, publicKey (or alipayPublicKey), notifyUrl, returnUrl
mu sync.Mutex
client *alipay.Client
}
// NewAlipay creates a new Alipay provider instance.
func NewAlipay(instanceID string, config map[string]string) (*Alipay, error) {
required := []string{"appId", "privateKey"}
for _, k := range required {
if config[k] == "" {
return nil, fmt.Errorf("alipay config missing required key: %s", k)
}
}
return &Alipay{
instanceID: instanceID,
config: config,
}, nil
}
func (a *Alipay) getClient() (*alipay.Client, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.client != nil {
return a.client, nil
}
client, err := alipay.New(a.config["appId"], a.config["privateKey"], true)
if err != nil {
return nil, fmt.Errorf("alipay init client: %w", err)
}
pubKey := a.config["publicKey"]
if pubKey == "" {
pubKey = a.config["alipayPublicKey"]
}
if pubKey == "" {
return nil, fmt.Errorf("alipay config missing required key: publicKey (or alipayPublicKey)")
}
if err := client.LoadAliPayPublicKey(pubKey); err != nil {
return nil, fmt.Errorf("alipay load public key: %w", err)
}
a.client = client
return a.client, nil
}
func (a *Alipay) Name() string { return "Alipay" }
func (a *Alipay) ProviderKey() string { return payment.TypeAlipay }
func (a *Alipay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipayDirect}
}
// CreatePayment creates an Alipay payment page URL.
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
notifyURL := a.config["notifyUrl"]
if req.NotifyURL != "" {
notifyURL = req.NotifyURL
}
returnURL := a.config["returnUrl"]
if req.ReturnURL != "" {
returnURL = req.ReturnURL
}
if req.IsMobile {
return a.createTrade(client, req, notifyURL, returnURL, true)
}
return a.createTrade(client, req, notifyURL, returnURL, false)
}
func (a *Alipay) createTrade(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 := client.TradeWapPay(param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, nil
}
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 := client.TradePagePay(param)
if err != nil {
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
QRCode: payURL.String(),
}, nil
}
// QueryOrder queries the trade status via Alipay.
func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: tradeNo})
if err != nil {
if isTradeNotExist(err) {
return &payment.QueryOrderResponse{
TradeNo: tradeNo,
Status: payment.ProviderStatusPending,
}, nil
}
return nil, fmt.Errorf("alipay TradeQuery: %w", err)
}
status := payment.ProviderStatusPending
switch result.TradeStatus {
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
status = payment.ProviderStatusPaid
case alipay.TradeStatusClosed:
status = payment.ProviderStatusFailed
}
amount, err := strconv.ParseFloat(result.TotalAmount, 64)
if err != nil {
return nil, fmt.Errorf("alipay parse amount %q: %w", result.TotalAmount, err)
}
return &payment.QueryOrderResponse{
TradeNo: result.TradeNo,
Status: status,
Amount: amount,
PaidAt: result.SendPayDate,
}, nil
}
// VerifyNotification decodes and verifies an Alipay async notification.
func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
values, err := url.ParseQuery(rawBody)
if err != nil {
return nil, fmt.Errorf("alipay parse notification: %w", err)
}
notification, err := client.DecodeNotification(ctx, values)
if err != nil {
return nil, fmt.Errorf("alipay verify notification: %w", err)
}
status := payment.ProviderStatusFailed
if notification.TradeStatus == alipay.TradeStatusSuccess || notification.TradeStatus == alipay.TradeStatusFinished {
status = payment.ProviderStatusSuccess
}
amount, err := strconv.ParseFloat(notification.TotalAmount, 64)
if err != nil {
return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err)
}
return &payment.PaymentNotification{
TradeNo: notification.TradeNo,
OrderID: notification.OutTradeNo,
Amount: amount,
Status: status,
RawData: rawBody,
}, nil
}
// Refund requests a refund through Alipay.
func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
OutTradeNo: req.OrderID,
RefundAmount: req.Amount,
RefundReason: req.Reason,
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
})
if err != nil {
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
}
refundStatus := payment.ProviderStatusPending
if result.FundChange == alipayFundChangeYes {
refundStatus = payment.ProviderStatusSuccess
}
refundID := result.TradeNo
if refundID == "" {
refundID = req.OrderID + alipayRefundSuffix
}
return &payment.RefundResponse{
RefundID: refundID,
Status: refundStatus,
}, nil
}
// CancelPayment closes a pending trade on Alipay.
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
client, err := a.getClient()
if err != nil {
return err
}
_, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: tradeNo})
if err != nil {
if isTradeNotExist(err) {
return nil
}
return fmt.Errorf("alipay TradeClose: %w", err)
}
return nil
}
func isTradeNotExist(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), alipayErrTradeNotExist)
}
// Ensure interface compliance.
var (
_ payment.Provider = (*Alipay)(nil)
_ payment.CancelableProvider = (*Alipay)(nil)
)

View File

@@ -0,0 +1,132 @@
//go:build unit
package provider
import (
"errors"
"strings"
"testing"
)
func TestIsTradeNotExist(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{
name: "nil error returns false",
err: nil,
want: false,
},
{
name: "error containing ACQ.TRADE_NOT_EXIST returns true",
err: errors.New("alipay: sub_code=ACQ.TRADE_NOT_EXIST, sub_msg=交易不存在"),
want: true,
},
{
name: "error not containing the code returns false",
err: errors.New("alipay: sub_code=ACQ.SYSTEM_ERROR, sub_msg=系统错误"),
want: false,
},
{
name: "error with only partial match returns false",
err: errors.New("ACQ.TRADE_NOT"),
want: false,
},
{
name: "error with exact constant value returns true",
err: errors.New(alipayErrTradeNotExist),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := isTradeNotExist(tt.err)
if got != tt.want {
t.Errorf("isTradeNotExist(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestNewAlipay(t *testing.T) {
t.Parallel()
validConfig := map[string]string{
"appId": "2021001234567890",
"privateKey": "MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...",
}
// helper to clone and override config fields
withOverride := func(overrides map[string]string) map[string]string {
cfg := make(map[string]string, len(validConfig))
for k, v := range validConfig {
cfg[k] = v
}
for k, v := range overrides {
cfg[k] = v
}
return cfg
}
tests := []struct {
name string
config map[string]string
wantErr bool
errSubstr string
}{
{
name: "valid config succeeds",
config: validConfig,
wantErr: false,
},
{
name: "missing appId",
config: withOverride(map[string]string{"appId": ""}),
wantErr: true,
errSubstr: "appId",
},
{
name: "missing privateKey",
config: withOverride(map[string]string{"privateKey": ""}),
wantErr: true,
errSubstr: "privateKey",
},
{
name: "nil config map returns error for appId",
config: map[string]string{},
wantErr: true,
errSubstr: "appId",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewAlipay("test-instance", tt.config)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil Alipay instance")
}
if got.instanceID != "test-instance" {
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
}
})
}
}

View File

@@ -0,0 +1,278 @@
// Package provider contains concrete payment provider implementations.
package provider
import (
"context"
"crypto/hmac"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// EasyPay constants.
const (
easypayCodeSuccess = 1
easypayStatusPaid = 1
easypayHTTPTimeout = 10 * time.Second
maxEasypayResponseSize = 1 << 20 // 1MB
tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5"
)
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
type EasyPay struct {
instanceID string
config map[string]string
httpClient *http.Client
}
// NewEasyPay creates a new EasyPay provider.
// config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
if config[k] == "" {
return nil, fmt.Errorf("easypay config missing required key: %s", k)
}
}
return &EasyPay{
instanceID: instanceID,
config: config,
httpClient: &http.Client{Timeout: easypayHTTPTimeout},
}, nil
}
func (e *EasyPay) Name() string { return "EasyPay" }
func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
func (e *EasyPay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
}
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
// Payment mode determined by instance config, not payment type.
// "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"]
if mode == "popup" {
return e.createRedirectPayment(req)
}
return e.createAPIPayment(ctx, req)
}
// createRedirectPayment builds a submit.php URL for browser redirect.
// No server-side API call — the user is redirected to EasyPay's hosted page.
// TradeNo is empty; it arrives via the notify callback after payment.
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
params["cid"] = cid
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
base := strings.TrimRight(e.config["apiBase"], "/")
payURL := base + "/submit.php?" + q.Encode()
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
}
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount, "clientip": req.ClientIP,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
params["cid"] = cid
}
if req.IsMobile {
params["device"] = "mobile"
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = signTypeMD5
body, err := e.post(ctx, strings.TrimRight(e.config["apiBase"], "/")+"/mapi.php", params)
if err != nil {
return nil, fmt.Errorf("easypay create: %w", err)
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TradeNo string `json:"trade_no"`
PayURL string `json:"payurl"`
QRCode string `json:"qrcode"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse: %w", err)
}
if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
}
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil
}
// resolveURLs returns (notifyURL, returnURL) preferring request values,
// falling back to instance config.
func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = e.config["notifyUrl"]
}
returnURL := req.ReturnURL
if returnURL == "" {
returnURL = e.config["returnUrl"]
}
return notifyURL, returnURL
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
"key": e.config["pkey"], "out_trade_no": tradeNo,
}
body, err := e.post(ctx, e.config["apiBase"]+"/api.php", params)
if err != nil {
return nil, fmt.Errorf("easypay query: %w", err)
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Status int `json:"status"`
Money string `json:"money"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse query: %w", err)
}
status := payment.ProviderStatusPending
if resp.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid
}
amount, _ := strconv.ParseFloat(resp.Money, 64)
return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status, Amount: amount}, nil
}
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
values, err := url.ParseQuery(rawBody)
if err != nil {
return nil, fmt.Errorf("parse notify: %w", err)
}
// url.ParseQuery already decodes values — no additional decode needed.
params := make(map[string]string)
for k := range values {
params[k] = values.Get(k)
}
sign := params["sign"]
if sign == "" {
return nil, fmt.Errorf("missing sign")
}
if !easyPayVerifySign(params, e.config["pkey"], sign) {
return nil, fmt.Errorf("invalid signature")
}
status := payment.ProviderStatusFailed
if params["trade_status"] == tradeStatusSuccess {
status = payment.ProviderStatusSuccess
}
amount, _ := strconv.ParseFloat(params["money"], 64)
return &payment.PaymentNotification{
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
Amount: amount, Status: status, RawData: rawBody,
}, nil
}
func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
params := map[string]string{
"pid": e.config["pid"], "key": e.config["pkey"],
"trade_no": req.TradeNo, "out_trade_no": req.OrderID, "money": req.Amount,
}
body, err := e.post(ctx, e.config["apiBase"]+"/api.php?act=refund", params)
if err != nil {
return nil, fmt.Errorf("easypay refund: %w", err)
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse refund: %w", err)
}
if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay refund failed: %s", resp.Msg)
}
return &payment.RefundResponse{RefundID: req.TradeNo, Status: payment.ProviderStatusSuccess}, nil
}
func (e *EasyPay) resolveCID(paymentType string) string {
if strings.HasPrefix(paymentType, "alipay") {
if v := e.config["cidAlipay"]; v != "" {
return v
}
return e.config["cid"]
}
if v := e.config["cidWxpay"]; v != "" {
return v
}
return e.config["cid"]
}
func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
return io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize))
}
func easyPaySign(params map[string]string, pkey 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)
var buf strings.Builder
for i, k := range keys {
if i > 0 {
_ = buf.WriteByte('&')
}
_, _ = buf.WriteString(k + "=" + params[k])
}
_, _ = buf.WriteString(pkey)
hash := md5.Sum([]byte(buf.String()))
return hex.EncodeToString(hash[:])
}
func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
}

View File

@@ -0,0 +1,180 @@
package provider
import (
"testing"
)
func TestEasyPaySignConsistentOutput(t *testing.T) {
t.Parallel()
params := map[string]string{
"pid": "1001",
"type": "alipay",
"out_trade_no": "ORDER123",
"name": "Test Product",
"money": "10.00",
}
pkey := "test_secret_key"
sign1 := easyPaySign(params, pkey)
sign2 := easyPaySign(params, pkey)
if sign1 != sign2 {
t.Fatalf("easyPaySign should be deterministic: %q != %q", sign1, sign2)
}
if len(sign1) != 32 {
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign1))
}
}
func TestEasyPaySignExcludesSignAndSignType(t *testing.T) {
t.Parallel()
pkey := "my_key"
base := map[string]string{
"pid": "1001",
"type": "alipay",
}
withSign := map[string]string{
"pid": "1001",
"type": "alipay",
"sign": "should_be_ignored",
"sign_type": "MD5",
}
signBase := easyPaySign(base, pkey)
signWithExtra := easyPaySign(withSign, pkey)
if signBase != signWithExtra {
t.Fatalf("sign and sign_type should be excluded: base=%q, withExtra=%q", signBase, signWithExtra)
}
}
func TestEasyPaySignExcludesEmptyValues(t *testing.T) {
t.Parallel()
pkey := "key123"
base := map[string]string{
"pid": "1001",
"type": "alipay",
}
withEmpty := map[string]string{
"pid": "1001",
"type": "alipay",
"device": "",
"clientip": "",
}
signBase := easyPaySign(base, pkey)
signWithEmpty := easyPaySign(withEmpty, pkey)
if signBase != signWithEmpty {
t.Fatalf("empty values should be excluded: base=%q, withEmpty=%q", signBase, signWithEmpty)
}
}
func TestEasyPayVerifySignValid(t *testing.T) {
t.Parallel()
params := map[string]string{
"pid": "1001",
"type": "alipay",
"out_trade_no": "ORDER456",
"money": "25.00",
}
pkey := "secret"
sign := easyPaySign(params, pkey)
// Add sign to params (as would come in a real callback)
params["sign"] = sign
params["sign_type"] = "MD5"
if !easyPayVerifySign(params, pkey, sign) {
t.Fatal("easyPayVerifySign should return true for a valid signature")
}
}
func TestEasyPayVerifySignTampered(t *testing.T) {
t.Parallel()
params := map[string]string{
"pid": "1001",
"type": "alipay",
"out_trade_no": "ORDER789",
"money": "50.00",
}
pkey := "secret"
sign := easyPaySign(params, pkey)
// Tamper with the amount
params["money"] = "99.99"
if easyPayVerifySign(params, pkey, sign) {
t.Fatal("easyPayVerifySign should return false for tampered params")
}
}
func TestEasyPayVerifySignWrongKey(t *testing.T) {
t.Parallel()
params := map[string]string{
"pid": "1001",
"type": "wxpay",
}
sign := easyPaySign(params, "correct_key")
if easyPayVerifySign(params, "wrong_key", sign) {
t.Fatal("easyPayVerifySign should return false with wrong key")
}
}
func TestEasyPaySignEmptyParams(t *testing.T) {
t.Parallel()
sign := easyPaySign(map[string]string{}, "key123")
if sign == "" {
t.Fatal("easyPaySign with empty params should still produce a hash")
}
if len(sign) != 32 {
t.Fatalf("MD5 hex should be 32 chars, got %d", len(sign))
}
}
func TestEasyPaySignSortOrder(t *testing.T) {
t.Parallel()
pkey := "test_key"
params1 := map[string]string{
"a": "1",
"b": "2",
"c": "3",
}
params2 := map[string]string{
"c": "3",
"a": "1",
"b": "2",
}
sign1 := easyPaySign(params1, pkey)
sign2 := easyPaySign(params2, pkey)
if sign1 != sign2 {
t.Fatalf("easyPaySign should be order-independent: %q != %q", sign1, sign2)
}
}
func TestEasyPayVerifySignWrongSignValue(t *testing.T) {
t.Parallel()
params := map[string]string{
"pid": "1001",
"type": "alipay",
}
pkey := "key"
if easyPayVerifySign(params, pkey, "00000000000000000000000000000000") {
t.Fatal("easyPayVerifySign should return false for an incorrect sign value")
}
}

View File

@@ -0,0 +1,23 @@
package provider
import (
"fmt"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// CreateProvider creates a Provider from a provider key, instance ID and decrypted config.
func CreateProvider(providerKey string, instanceID string, config map[string]string) (payment.Provider, error) {
switch providerKey {
case "easypay":
return NewEasyPay(instanceID, config)
case "alipay":
return NewAlipay(instanceID, config)
case "wxpay":
return NewWxpay(instanceID, config)
case "stripe":
return NewStripe(instanceID, config)
default:
return nil, fmt.Errorf("unknown provider key: %s", providerKey)
}
}

View File

@@ -0,0 +1,262 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/Wei-Shaw/sub2api/internal/payment"
stripe "github.com/stripe/stripe-go/v85"
"github.com/stripe/stripe-go/v85/webhook"
)
// Stripe constants.
const (
stripeCurrency = "cny"
stripeEventPaymentSuccess = "payment_intent.succeeded"
stripeEventPaymentFailed = "payment_intent.payment_failed"
)
// Stripe implements the payment.CancelableProvider interface for Stripe payments.
type Stripe struct {
instanceID string
config map[string]string
mu sync.Mutex
initialized bool
sc *stripe.Client
}
// NewStripe creates a new Stripe provider instance.
func NewStripe(instanceID string, config map[string]string) (*Stripe, error) {
if config["secretKey"] == "" {
return nil, fmt.Errorf("stripe config missing required key: secretKey")
}
return &Stripe{
instanceID: instanceID,
config: config,
}, nil
}
func (s *Stripe) ensureInit() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.initialized {
s.sc = stripe.NewClient(s.config["secretKey"])
s.initialized = true
}
}
// GetPublishableKey returns the publishable key for frontend use.
func (s *Stripe) GetPublishableKey() string {
return s.config["publishableKey"]
}
func (s *Stripe) Name() string { return "Stripe" }
func (s *Stripe) ProviderKey() string { return payment.TypeStripe }
func (s *Stripe) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeStripe}
}
// stripePaymentMethodTypes maps our PaymentType to Stripe payment_method_types.
var stripePaymentMethodTypes = map[string][]string{
payment.TypeCard: {"card"},
payment.TypeAlipay: {"alipay"},
payment.TypeWxpay: {"wechat_pay"},
payment.TypeLink: {"link"},
}
// CreatePayment creates a Stripe PaymentIntent.
func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
s.ensureInit()
amountInCents, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("stripe create payment: %w", err)
}
// Collect all Stripe payment_method_types from the instance's configured sub-methods
methods := resolveStripeMethodTypes(req.InstanceSubMethods)
pmTypes := make([]*string, len(methods))
for i, m := range methods {
pmTypes[i] = stripe.String(m)
}
params := &stripe.PaymentIntentCreateParams{
Amount: stripe.Int64(amountInCents),
Currency: stripe.String(stripeCurrency),
PaymentMethodTypes: pmTypes,
Description: stripe.String(req.Subject),
Metadata: map[string]string{"orderId": req.OrderID},
}
// WeChat Pay requires payment_method_options with client type
if hasStripeMethod(methods, "wechat_pay") {
params.PaymentMethodOptions = &stripe.PaymentIntentCreatePaymentMethodOptionsParams{
WeChatPay: &stripe.PaymentIntentCreatePaymentMethodOptionsWeChatPayParams{
Client: stripe.String("web"),
},
}
}
params.SetIdempotencyKey(fmt.Sprintf("pi-%s", req.OrderID))
params.Context = ctx
pi, err := s.sc.V1PaymentIntents.Create(ctx, params)
if err != nil {
return nil, fmt.Errorf("stripe create payment: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: pi.ID,
ClientSecret: pi.ClientSecret,
}, nil
}
// QueryOrder retrieves a PaymentIntent by ID.
func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
s.ensureInit()
pi, err := s.sc.V1PaymentIntents.Retrieve(ctx, tradeNo, nil)
if err != nil {
return nil, fmt.Errorf("stripe query order: %w", err)
}
status := payment.ProviderStatusPending
switch pi.Status {
case stripe.PaymentIntentStatusSucceeded:
status = payment.ProviderStatusPaid
case stripe.PaymentIntentStatusCanceled:
status = payment.ProviderStatusFailed
}
return &payment.QueryOrderResponse{
TradeNo: pi.ID,
Status: status,
Amount: payment.FenToYuan(pi.Amount),
}, nil
}
// VerifyNotification verifies a Stripe webhook event.
func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
s.ensureInit()
webhookSecret := s.config["webhookSecret"]
if webhookSecret == "" {
return nil, fmt.Errorf("stripe webhookSecret not configured")
}
sig := headers["stripe-signature"]
if sig == "" {
return nil, fmt.Errorf("stripe notification missing stripe-signature header")
}
event, err := webhook.ConstructEvent([]byte(rawBody), sig, webhookSecret)
if err != nil {
return nil, fmt.Errorf("stripe verify notification: %w", err)
}
switch event.Type {
case stripeEventPaymentSuccess:
return parseStripePaymentIntent(&event, payment.ProviderStatusSuccess, rawBody)
case stripeEventPaymentFailed:
return parseStripePaymentIntent(&event, payment.ProviderStatusFailed, rawBody)
}
return nil, nil
}
func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string) (*payment.PaymentNotification, error) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
return nil, fmt.Errorf("stripe parse payment_intent: %w", err)
}
return &payment.PaymentNotification{
TradeNo: pi.ID,
OrderID: pi.Metadata["orderId"],
Amount: payment.FenToYuan(pi.Amount),
Status: status,
RawData: rawBody,
}, nil
}
// Refund creates a Stripe refund.
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
s.ensureInit()
amountInCents, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("stripe refund: %w", err)
}
params := &stripe.RefundCreateParams{
PaymentIntent: stripe.String(req.TradeNo),
Amount: stripe.Int64(amountInCents),
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
}
params.Context = ctx
r, err := s.sc.V1Refunds.Create(ctx, params)
if err != nil {
return nil, fmt.Errorf("stripe refund: %w", err)
}
refundStatus := payment.ProviderStatusPending
if r.Status == stripe.RefundStatusSucceeded {
refundStatus = payment.ProviderStatusSuccess
}
return &payment.RefundResponse{
RefundID: r.ID,
Status: refundStatus,
}, nil
}
// resolveStripeMethodTypes converts instance supported_types (comma-separated)
// into Stripe API payment_method_types. Falls back to ["card"] if empty.
func resolveStripeMethodTypes(instanceSubMethods string) []string {
if instanceSubMethods == "" {
return []string{"card"}
}
var methods []string
for _, t := range strings.Split(instanceSubMethods, ",") {
t = strings.TrimSpace(t)
if mapped, ok := stripePaymentMethodTypes[t]; ok {
methods = append(methods, mapped...)
}
}
if len(methods) == 0 {
return []string{"card"}
}
return methods
}
// hasStripeMethod checks if the given Stripe method list contains the target method.
func hasStripeMethod(methods []string, target string) bool {
for _, m := range methods {
if m == target {
return true
}
}
return false
}
// CancelPayment cancels a pending PaymentIntent.
func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error {
s.ensureInit()
_, err := s.sc.V1PaymentIntents.Cancel(ctx, tradeNo, nil)
if err != nil {
return fmt.Errorf("stripe cancel payment: %w", err)
}
return nil
}
// Ensure interface compliance.
var (
_ payment.Provider = (*Stripe)(nil)
_ payment.CancelableProvider = (*Stripe)(nil)
)

View File

@@ -0,0 +1,350 @@
package provider
import (
"bytes"
"context"
"crypto/rsa"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// WeChat Pay constants.
const (
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
)
// WeChat Pay trade states.
const (
wxpayTradeStateSuccess = "SUCCESS"
wxpayTradeStateRefund = "REFUND"
wxpayTradeStateClosed = "CLOSED"
wxpayTradeStatePayError = "PAYERROR"
)
// WeChat Pay notification event types.
const (
wxpayEventTransactionSuccess = "TRANSACTION.SUCCESS"
)
// WeChat Pay error codes.
const (
wxpayErrNoAuth = "NO_AUTH"
)
type Wxpay struct {
instanceID string
config map[string]string
mu sync.Mutex
coreClient *core.Client
notifyHandler *notify.Handler
}
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "publicKey", "publicKeyId", "certSerial"}
for _, k := range required {
if config[k] == "" {
return nil, fmt.Errorf("wxpay config missing required key: %s", k)
}
}
if len(config["apiV3Key"]) != 32 {
return nil, fmt.Errorf("wxpay apiV3Key must be exactly 32 bytes, got %d", len(config["apiV3Key"]))
}
return &Wxpay{instanceID: instanceID, config: config}, nil
}
func (w *Wxpay) Name() string { return "Wxpay" }
func (w *Wxpay) ProviderKey() string { return payment.TypeWxpay }
func (w *Wxpay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeWxpayDirect}
}
func formatPEM(key, keyType string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "-----BEGIN") {
return key
}
return fmt.Sprintf("-----BEGIN %s-----\n%s\n-----END %s-----", keyType, key, keyType)
}
func (w *Wxpay) ensureClient() (*core.Client, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.coreClient != nil {
return w.coreClient, nil
}
privateKey, publicKey, err := w.loadKeyPair()
if err != nil {
return nil, err
}
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.WithVerifier(verifier))
if err != nil {
return nil, fmt.Errorf("wxpay init client: %w", err)
}
handler, err := notify.NewRSANotifyHandler(w.config["apiV3Key"], verifier)
if err != nil {
return nil, fmt.Errorf("wxpay init notify handler: %w", err)
}
w.notifyHandler = handler
w.coreClient = client
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 {
return nil, err
}
// Request-first, config-fallback (consistent with EasyPay/Alipay)
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = w.config["notifyUrl"]
}
if notifyURL == "" {
return nil, fmt.Errorf("wxpay notifyUrl is required")
}
totalFen, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay create payment: %w", err)
}
if req.IsMobile && req.ClientIP != "" {
resp, err := w.createOrder(ctx, client, req, notifyURL, totalFen, true)
if err == nil {
return resp, nil
}
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, err
}
slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID)
}
return w.createOrder(ctx, client, req, notifyURL, totalFen, false)
}
func (w *Wxpay) createOrder(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64, useH5 bool) (*payment.CreatePaymentResponse, error) {
if useH5 {
return w.prepayH5(ctx, c, req, notifyURL, totalFen)
}
return w.prepayNative(ctx, c, req, notifyURL, totalFen)
}
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := native.NativeApiService{Client: c}
cur := wxpayCurrency
resp, _, err := svc.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &native.Amount{Total: core.Int64(totalFen), Currency: &cur},
})
if err != nil {
return nil, fmt.Errorf("wxpay native prepay: %w", err)
}
codeURL := ""
if resp.CodeUrl != nil {
codeURL = *resp.CodeUrl
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, QRCode: codeURL}, nil
}
func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := h5.H5ApiService{Client: c}
cur := wxpayCurrency
tp := wxpayH5Type
resp, _, err := svc.Prepay(ctx, h5.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &h5.Amount{Total: core.Int64(totalFen), Currency: &cur},
SceneInfo: &h5.SceneInfo{PayerClientIp: core.String(req.ClientIP), H5Info: &h5.H5Info{Type: &tp}},
})
if err != nil {
return nil, fmt.Errorf("wxpay h5 prepay: %w", err)
}
h5URL := ""
if resp.H5Url != nil {
h5URL = *resp.H5Url
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
}
func wxSV(s *string) string {
if s == nil {
return ""
}
return *s
}
func mapWxState(s string) string {
switch s {
case wxpayTradeStateSuccess:
return payment.ProviderStatusPaid
case wxpayTradeStateRefund:
return payment.ProviderStatusRefunded
case wxpayTradeStateClosed, wxpayTradeStatePayError:
return payment.ProviderStatusFailed
default:
return payment.ProviderStatusPending
}
}
func (w *Wxpay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
c, err := w.ensureClient()
if err != nil {
return nil, err
}
svc := native.NativeApiService{Client: c}
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return nil, fmt.Errorf("wxpay query order: %w", err)
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = payment.FenToYuan(*tx.Amount.Total)
}
id := tradeNo
if tx.TransactionId != nil {
id = *tx.TransactionId
}
pa := ""
if tx.SuccessTime != nil {
pa = *tx.SuccessTime
}
return &payment.QueryOrderResponse{TradeNo: id, Status: mapWxState(wxSV(tx.TradeState)), Amount: amt, PaidAt: pa}, nil
}
func (w *Wxpay) VerifyNotification(ctx context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
if _, err := w.ensureClient(); err != nil {
return nil, err
}
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "/", io.NopCloser(bytes.NewBufferString(rawBody)))
if err != nil {
return nil, fmt.Errorf("wxpay construct request: %w", err)
}
for k, v := range headers {
r.Header.Set(k, v)
}
var tx payments.Transaction
nr, err := w.notifyHandler.ParseNotifyRequest(ctx, r, &tx)
if err != nil {
return nil, fmt.Errorf("wxpay verify notification: %w", err)
}
if nr.EventType != wxpayEventTransactionSuccess {
return nil, nil
}
var amt float64
if tx.Amount != nil && tx.Amount.Total != nil {
amt = payment.FenToYuan(*tx.Amount.Total)
}
st := payment.ProviderStatusFailed
if wxSV(tx.TradeState) == wxpayTradeStateSuccess {
st = payment.ProviderStatusSuccess
}
return &payment.PaymentNotification{
TradeNo: wxSV(tx.TransactionId), OrderID: wxSV(tx.OutTradeNo),
Amount: amt, Status: st, RawData: rawBody,
}, nil
}
func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
c, err := w.ensureClient()
if err != nil {
return nil, err
}
rf, err := payment.YuanToFen(req.Amount)
if err != nil {
return nil, fmt.Errorf("wxpay refund amount: %w", err)
}
tf, err := w.queryOrderTotalFen(ctx, c, req.OrderID)
if err != nil {
return nil, err
}
rs := refunddomestic.RefundsApiService{Client: c}
cur := wxpayCurrency
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(req.OrderID),
OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
Reason: core.String(req.Reason),
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
})
if err != nil {
return nil, fmt.Errorf("wxpay refund: %w", err)
}
rid := wxSV(res.RefundId)
if rid == "" {
rid = fmt.Sprintf("%s-refund", req.OrderID)
}
st := payment.ProviderStatusPending
if res.Status != nil && *res.Status == refunddomestic.STATUS_SUCCESS {
st = payment.ProviderStatusSuccess
}
return &payment.RefundResponse{RefundID: rid, Status: st}, nil
}
func (w *Wxpay) queryOrderTotalFen(ctx context.Context, c *core.Client, orderID string) (int64, error) {
svc := native.NativeApiService{Client: c}
tx, _, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(orderID), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return 0, fmt.Errorf("wxpay refund query order: %w", err)
}
var tf int64
if tx.Amount != nil && tx.Amount.Total != nil {
tf = *tx.Amount.Total
}
return tf, nil
}
func (w *Wxpay) CancelPayment(ctx context.Context, tradeNo string) error {
c, err := w.ensureClient()
if err != nil {
return err
}
svc := native.NativeApiService{Client: c}
_, err = svc.CloseOrder(ctx, native.CloseOrderRequest{
OutTradeNo: core.String(tradeNo), Mchid: core.String(w.config["mchId"]),
})
if err != nil {
return fmt.Errorf("wxpay cancel payment: %w", err)
}
return nil
}
var (
_ payment.Provider = (*Wxpay)(nil)
_ payment.CancelableProvider = (*Wxpay)(nil)
)

View File

@@ -0,0 +1,259 @@
//go:build unit
package provider
import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
func TestMapWxState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{
name: "SUCCESS maps to paid",
input: wxpayTradeStateSuccess,
want: payment.ProviderStatusPaid,
},
{
name: "REFUND maps to refunded",
input: wxpayTradeStateRefund,
want: payment.ProviderStatusRefunded,
},
{
name: "CLOSED maps to failed",
input: wxpayTradeStateClosed,
want: payment.ProviderStatusFailed,
},
{
name: "PAYERROR maps to failed",
input: wxpayTradeStatePayError,
want: payment.ProviderStatusFailed,
},
{
name: "unknown state maps to pending",
input: "NOTPAY",
want: payment.ProviderStatusPending,
},
{
name: "empty string maps to pending",
input: "",
want: payment.ProviderStatusPending,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := mapWxState(tt.input)
if got != tt.want {
t.Errorf("mapWxState(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestWxSV(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input *string
want string
}{
{
name: "nil pointer returns empty string",
input: nil,
want: "",
},
{
name: "non-nil pointer returns value",
input: strPtr("hello"),
want: "hello",
},
{
name: "pointer to empty string returns empty string",
input: strPtr(""),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := wxSV(tt.input)
if got != tt.want {
t.Errorf("wxSV() = %q, want %q", got, tt.want)
}
})
}
}
func strPtr(s string) *string {
return &s
}
func TestFormatPEM(t *testing.T) {
t.Parallel()
tests := []struct {
name string
key string
keyType string
want string
}{
{
name: "raw key gets wrapped with headers",
key: "MIIBIjANBgkqhki...",
keyType: "PUBLIC KEY",
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
},
{
name: "already formatted key is returned as-is",
key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
keyType: "PRIVATE KEY",
want: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
},
{
name: "key with leading/trailing whitespace is trimmed before check",
key: " \n MIIBIjANBgkqhki... \n ",
keyType: "PUBLIC KEY",
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
},
{
name: "already formatted key with whitespace is trimmed and returned",
key: " -----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY----- ",
keyType: "RSA PRIVATE KEY",
want: "-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatPEM(tt.key, tt.keyType)
if got != tt.want {
t.Errorf("formatPEM(%q, %q) =\n%s\nwant:\n%s", tt.key, tt.keyType, got, tt.want)
}
})
}
}
func TestNewWxpay(t *testing.T) {
t.Parallel()
validConfig := map[string]string{
"appId": "wx1234567890",
"mchId": "1234567890",
"privateKey": "fake-private-key",
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
"publicKey": "fake-public-key",
"publicKeyId": "key-id-001",
"certSerial": "SERIAL001",
}
// helper to clone and override config fields
withOverride := func(overrides map[string]string) map[string]string {
cfg := make(map[string]string, len(validConfig))
for k, v := range validConfig {
cfg[k] = v
}
for k, v := range overrides {
cfg[k] = v
}
return cfg
}
tests := []struct {
name string
config map[string]string
wantErr bool
errSubstr string
}{
{
name: "valid config succeeds",
config: validConfig,
wantErr: false,
},
{
name: "missing appId",
config: withOverride(map[string]string{"appId": ""}),
wantErr: true,
errSubstr: "appId",
},
{
name: "missing mchId",
config: withOverride(map[string]string{"mchId": ""}),
wantErr: true,
errSubstr: "mchId",
},
{
name: "missing privateKey",
config: withOverride(map[string]string{"privateKey": ""}),
wantErr: true,
errSubstr: "privateKey",
},
{
name: "missing apiV3Key",
config: withOverride(map[string]string{"apiV3Key": ""}),
wantErr: true,
errSubstr: "apiV3Key",
},
{
name: "missing publicKey",
config: withOverride(map[string]string{"publicKey": ""}),
wantErr: true,
errSubstr: "publicKey",
},
{
name: "missing publicKeyId",
config: withOverride(map[string]string{"publicKeyId": ""}),
wantErr: true,
errSubstr: "publicKeyId",
},
{
name: "apiV3Key too short",
config: withOverride(map[string]string{"apiV3Key": "short"}),
wantErr: true,
errSubstr: "exactly 32 bytes",
},
{
name: "apiV3Key too long",
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
wantErr: true,
errSubstr: "exactly 32 bytes",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewWxpay("test-instance", tt.config)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil Wxpay instance")
}
if got.instanceID != "test-instance" {
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
}
})
}
}