diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 0b41c4fb..4df76452 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -3,16 +3,17 @@ package provider import ( "bytes" "context" - "crypto/rsa" "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" @@ -56,15 +57,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 } @@ -89,14 +110,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) @@ -110,18 +136,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 { diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index b8b99537..707fec18 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -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", }, } diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index 3d1e4dc4..30ff4253 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -12,9 +12,22 @@ import ( "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +// validateProviderConfig runs the provider's constructor to surface config-level +// errors at save time (e.g. wxpay missing certSerial), instead of only failing +// when an order is created. Returns the structured ApplicationError from the +// constructor so the frontend i18n layer can localize it. +// +// Only validates enabled instances — a disabled instance may be a half-filled +// draft the admin will complete later. +func (s *PaymentConfigService) validateProviderConfig(providerKey string, config map[string]string) error { + _, err := provider.CreateProvider(providerKey, "_validate_", config) + return err +} + // --- Provider Instance CRUD --- func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) { @@ -48,9 +61,8 @@ func (s *PaymentConfigService) ListProviderInstancesWithConfig(ctx context.Conte resp := ProviderInstanceResponse{ ID: int64(inst.ID), ProviderKey: inst.ProviderKey, Name: inst.Name, SupportedTypes: splitTypes(inst.SupportedTypes), Limits: inst.Limits, - Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, - AllowUserRefund: inst.AllowUserRefund, - SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode, + Enabled: inst.Enabled, RefundEnabled: inst.RefundEnabled, AllowUserRefund: inst.AllowUserRefund, + SortOrder: inst.SortOrder, PaymentMode: inst.PaymentMode, } resp.Config, err = s.decryptAndMaskConfig(inst.ProviderKey, inst.Config) if err != nil { @@ -138,6 +150,11 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil { return nil, err } + if req.Enabled { + if err := s.validateProviderConfig(req.ProviderKey, req.Config); err != nil { + return nil, err + } + } enc, err := s.encryptConfig(req.Config) if err != nil { return nil, err @@ -211,16 +228,42 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in WithMetadata(map[string]string{"count": strconv.Itoa(count)}) } } + // Validate merged config when the instance will end up enabled. + // This surfaces provider-level errors (e.g. wxpay missing certSerial) at save time, + // so admins see them in the dialog instead of only when an order is created. + inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id) + if err != nil { + return nil, fmt.Errorf("load provider instance: %w", err) + } + finalEnabled := inst.Enabled + if req.Enabled != nil { + finalEnabled = *req.Enabled + } + var mergedConfig map[string]string + if req.Config != nil { + mergedConfig, err = s.mergeConfig(ctx, id, req.Config) + if err != nil { + return nil, err + } + } + if finalEnabled { + configToValidate := mergedConfig + if configToValidate == nil { + configToValidate, err = s.decryptConfig(inst.Config) + if err != nil { + return nil, fmt.Errorf("decrypt existing config: %w", err) + } + } + if err := s.validateProviderConfig(inst.ProviderKey, configToValidate); err != nil { + return nil, err + } + } u := s.entClient.PaymentProviderInstance.UpdateOneID(id) if req.Name != nil { u.SetName(*req.Name) } - if req.Config != nil { - merged, err := s.mergeConfig(ctx, id, req.Config) - if err != nil { - return nil, err - } - enc, err := s.encryptConfig(merged) + if mergedConfig != nil { + enc, err := s.encryptConfig(mergedConfig) if err != nil { return nil, err } diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 128416e4..a7212025 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "log/slog" "math" @@ -167,7 +168,7 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us return fmt.Errorf("count pending orders: %w", err) } if c >= max { - return infraerrors.TooManyRequests("TOO_MANY_PENDING", fmt.Sprintf("too many pending orders (max %d)", max)). + return infraerrors.TooManyRequests("TOO_MANY_PENDING", "too_many_pending"). WithMetadata(map[string]string{"max": strconv.Itoa(max)}) } return nil @@ -191,7 +192,8 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user used += o.Amount } if used+amount > limit { - return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", fmt.Sprintf("daily recharge limit reached, remaining: %.2f", math.Max(0, limit-used))) + return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded"). + WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))}) } return nil } @@ -201,21 +203,37 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen // This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay"). sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount) if err != nil { - return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType)) + return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "method_not_configured"). + WithMetadata(map[string]string{"payment_type": req.PaymentType}) } if sel == nil { - return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance") + return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no_available_instance") } prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config) if err != nil { - return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable") + slog.Error("[PaymentService] CreateProvider failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err) + // If the provider returned a structured ApplicationError (e.g. WXPAY_CONFIG_MISSING_KEY), + // pass it through with provider context added to metadata. Otherwise wrap as PAYMENT_PROVIDER_MISCONFIGURED. + if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) { + md := map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID} + for k, v := range appErr.Metadata { + md[k] = v + } + return nil, appErr.WithMetadata(md) + } + return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", "provider_misconfigured"). + WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}) } subject := s.buildPaymentSubject(plan, limitAmount, cfg) outTradeNo := order.OutTradeNo pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{OrderID: outTradeNo, Amount: payAmountStr, PaymentType: req.PaymentType, Subject: subject, ClientIP: req.ClientIP, IsMobile: req.IsMobile, InstanceSubMethods: sel.SupportedTypes}) if err != nil { slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err) - return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error())) + if appErr := new(infraerrors.ApplicationError); errors.As(err, &appErr) { + return nil, appErr + } + return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment_gateway_error"). + WithMetadata(map[string]string{"provider": sel.ProviderKey, "instance_id": sel.InstanceID}) } _, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx) if err != nil {