Files
sub2api/backend/internal/payment/provider/wxpay.go
erio 79192cf65b feat(payment): harden wxpay config validation with structured errors
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.

Changes:

- backend/internal/payment/provider/wxpay.go
  - Replace fmt.Errorf with structured infraerrors.BadRequest errors:
    - WXPAY_CONFIG_MISSING_KEY    (metadata: key)
    - WXPAY_CONFIG_INVALID_KEY_LENGTH  (metadata: key, expected, actual)
    - WXPAY_CONFIG_INVALID_KEY    (metadata: key) for malformed PEMs
  - Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
    at save time instead of at order creation.
  - Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
    supported verifier; no more loadKeyPair helper.

- backend/internal/service/payment_order.go invokeProvider
  - If CreateProvider or CreatePayment returns a structured ApplicationError,
    pass it through (optionally enriching metadata with provider/instance_id)
    instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
    the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
  - Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
    PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
    metadata for template variables.

- backend/internal/service/payment_config_providers.go
  - New helper validateProviderConfig calls provider.CreateProvider at save
    time. Enabled instances are validated on both Create and Update so admins
    see config errors immediately in the dialog, not later at order creation.
  - Disabled instances are not validated (half-filled drafts are allowed).

- backend/internal/payment/provider/wxpay_test.go
  - Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
    PEMs per test, used by the valid-config baseline (prior fake strings no
    longer pass the eager PEM check).
  - Cover each structured-error branch (missing/invalid-length/malformed PEM).
2026-04-20 19:49:45 +08:00

365 lines
12 KiB
Go

package provider
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"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"
"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
}
const wxpayAPIv3KeyLength = 32
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
// 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, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
WithMetadata(map[string]string{"key": k})
}
}
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
}
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.TypeWxpay}
}
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, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
if err != nil {
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"})
}
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(w.config["mchId"], w.config["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) 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)
)