feat(payment): wxpay pubkey mode only, eager PEM validation

微信从 2024-10 起强推公钥验签,新商户无法下载平台证书,老商户也陆续被迁移。日志里刚确认 beta 商户被微信 404 拒绝平台证书下载。彻底废弃老模式:

- wxpay.go:
  - publicKey / publicKeyId 加入 required 列表
  - NewWxpay 在保存时主动 parse PEM(privateKey 和 publicKey),格式错立即报 WXPAY_CONFIG_INVALID_KEY 带 {key}
  - ensureClient 只走新公钥验签(NewSHA256WithRSAPubkeyVerifier),移除 buildVerifier 和 downloader 分支
  - 清掉 crypto/rsa、auth、downloader 等不再使用的 import

- providerConfig.ts: publicKey / publicKeyId 两个字段去掉 optional:true → UI 加红星必填

- wxpay_test.go:
  - 引入 generateTestKeyPair 在每次用例动态生成有效 RSA 2048 PKCS8/PKIX PEM
  - 替换原先的 "fake-private-key" 字面量,现在 NewWxpay 会真实解析
  - 移除老模式相关用例(legacy/leftover/pair)
  - 新增 malformed PEM 用例覆盖 WXPAY_CONFIG_INVALID_KEY 分支

- i18n locales: 去掉 WXPAY_CONFIG_PAIR_VIOLATION;新增 WXPAY_CONFIG_INVALID_KEY「{key} 格式错误」中英文
This commit is contained in:
erio
2026-04-20 19:35:12 +08:00
parent c289d2da58
commit fa00b9b79e
5 changed files with 69 additions and 53 deletions
+17 -33
View File
@@ -3,7 +3,6 @@ package provider
import (
"bytes"
"context"
"crypto/rsa"
"fmt"
"io"
"log/slog"
@@ -16,9 +15,7 @@ import (
"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"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
@@ -63,7 +60,10 @@ type Wxpay struct {
const wxpayAPIv3KeyLength = 32
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "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, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
@@ -78,13 +78,13 @@ func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
"actual": strconv.Itoa(len(config["apiV3Key"])),
})
}
// Pubkey verifier mode is opt-in via publicKeyId. If publicKeyId is set,
// publicKey must also be set so the verifier can load it.
// A leftover publicKey without publicKeyId is treated as unused legacy data,
// not an error, so admins can switch back to platform-certificate mode without
// having to manually clear the old publicKey field.
if config["publicKeyId"] != "" && config["publicKey"] == "" {
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
// 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
@@ -112,12 +112,15 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
}
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
if err != nil {
return nil, fmt.Errorf("wxpay load private key: %w", err)
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
WithMetadata(map[string]string{"key": "privateKey"})
}
verifier, err := w.buildVerifier(privateKey)
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
if err != nil {
return nil, err
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))
@@ -133,25 +136,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
return w.coreClient, nil
}
// buildVerifier picks the verifier mode based on whether publicKeyId is configured:
// - publicKeyId set → new pubkey verifier using the configured publicKey
// - publicKeyId empty → legacy mode: auto-download platform certs with apiV3Key
func (w *Wxpay) buildVerifier(privateKey *rsa.PrivateKey) (auth.Verifier, error) {
if w.config["publicKeyId"] != "" {
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
if err != nil {
return nil, fmt.Errorf("wxpay load public key: %w", err)
}
return verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey), nil
}
mgr := downloader.MgrInstance()
err := mgr.RegisterDownloaderWithPrivateKey(context.Background(), privateKey, w.config["certSerial"], w.config["mchId"], w.config["apiV3Key"])
if err != nil {
return nil, fmt.Errorf("wxpay register platform cert downloader: %w", err)
}
return verifiers.NewSHA256WithRSAVerifier(mgr.GetCertificateVisitor(w.config["mchId"])), nil
}
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := w.ensureClient()
if err != nil {
+48 -16
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",
}
@@ -213,21 +238,28 @@ func TestNewWxpay(t *testing.T) {
errSubstr: "certSerial",
},
{
name: "legacy mode: publicKey+publicKeyId both empty",
config: withOverride(map[string]string{"publicKey": "", "publicKeyId": ""}),
wantErr: false,
},
{
// Leftover publicKey from a former pubkey-mode setup is ignored; treated as legacy mode.
name: "publicKey leftover without publicKeyId is allowed",
config: withOverride(map[string]string{"publicKeyId": ""}),
wantErr: false,
},
{
name: "publicKeyId without publicKey",
name: "missing publicKey",
config: withOverride(map[string]string{"publicKey": ""}),
wantErr: true,
errSubstr: "WXPAY_CONFIG_MISSING_KEY",
errSubstr: "publicKey",
},
{
name: "missing publicKeyId",
config: withOverride(map[string]string{"publicKeyId": ""}),
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",
@@ -100,8 +100,8 @@ export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
{ key: 'privateKey', label: '', sensitive: true },
{ key: 'apiV3Key', label: '', sensitive: true },
{ key: 'certSerial', label: '', sensitive: false },
{ key: 'publicKey', label: '', sensitive: true, optional: true },
{ key: 'publicKeyId', label: '', sensitive: false, optional: true },
{ key: 'publicKey', label: '', sensitive: true },
{ key: 'publicKeyId', label: '', sensitive: false },
],
stripe: [
{ key: 'secretKey', label: '', sensitive: true },
+1 -1
View File
@@ -5340,7 +5340,7 @@ export default {
PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.',
WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.',
WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).',
WXPAY_CONFIG_PAIR_VIOLATION: 'WeChat Pay {keys} must be provided together.',
WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.',
NOT_FOUND: 'Order not found.',
+1 -1
View File
@@ -5536,7 +5536,7 @@ export default {
PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员',
WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}',
WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})',
WXPAY_CONFIG_PAIR_VIOLATION: '微信支付 {keys} 必须同时配置',
WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试',
NOT_FOUND: '订单不存在',