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).
This commit is contained in:
erio
2026-04-20 19:49:45 +08:00
parent 23def40bc5
commit 79192cf65b
4 changed files with 159 additions and 41 deletions

View File

@@ -3,12 +3,36 @@
package provider
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
// 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()
@@ -149,13 +173,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",
}
@@ -206,6 +231,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": ""}),
@@ -218,17 +249,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",
},
}