From 79192cf65bc090687bdba4474201eb37eafa0aca Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 20 Apr 2026 19:49:45 +0800 Subject: [PATCH 1/2] feat(payment): harden wxpay config validation with structured errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/internal/payment/provider/wxpay.go | 56 ++++++++++------- .../internal/payment/provider/wxpay_test.go | 53 ++++++++++++++-- .../service/payment_config_providers.go | 61 ++++++++++++++++--- backend/internal/service/payment_order.go | 30 +++++++-- 4 files changed, 159 insertions(+), 41 deletions(-) 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 { From 40d4e167cd8093bc3f21d4dbe205946df9c0aead Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 20 Apr 2026 20:06:53 +0800 Subject: [PATCH 2/2] feat(payment): i18n payment error codes and label localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the backend structured payment errors (reason + metadata). The frontend now maps reason codes to localized messages with metadata as interpolation variables, and automatically localizes raw config-field names (e.g. "certSerial" → "证书序列号") using the existing UI-label i18n namespace. - frontend/src/utils/apiError.ts - extractApiErrorCode now prefers the string `reason` over the numeric HTTP `code`; reason is granular enough to drive i18n lookup, HTTP code is not. - New extractApiErrorMetadata to pull interpolation params off the error. - New extractI18nErrorMessage(err, t, namespace, fallback): looks up `.` in i18n and substitutes metadata. Before substitution, `metadata.key` and `metadata.keys` (slash-joined) are re-translated through `admin.settings.payment.field_` so users see "缺少必填项:证书序列号" instead of "缺少必填项:certSerial". - frontend/src/i18n/locales/{zh,en}.ts - Add payment.errors entries for every structured reason code returned by the backend (PAYMENT_DISABLED, INVALID_AMOUNT, TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED, NO_AVAILABLE_INSTANCE, PAYMENT_PROVIDER_MISCONFIGURED, WXPAY_CONFIG_MISSING_KEY / INVALID_KEY_LENGTH / INVALID_KEY, NOT_FOUND, FORBIDDEN, CONFLICT, INVALID_ORDER_TYPE, INVALID_STATUS, BALANCE_NOT_ENOUGH, REFUND_AMOUNT_EXCEEDED, REFUND_FAILED, and more), with placeholders for template variables. - 13 payment-related Vue files - Migrate catch-block error reporting from extractApiErrorMessage to extractI18nErrorMessage(err, t, 'payment.errors', fallback). - Remove the ad-hoc paymentErrorMap computed in SettingsView.vue, which the new helper supersedes (it reads i18n directly via t). - frontend/src/components/payment/providerConfig.ts - wxpay: publicKey and publicKeyId are now required (was optional), matching the pubkey-only verifier direction; certSerial is already required. This PR is drop-in safe: reason-preferring extractApiErrorCode is backward compatible with callers that pass their own i18nMap, and error codes missing from i18n fall back to the existing message-based path. --- .../components/payment/PaymentQRDialog.vue | 4 +- .../components/payment/PaymentStatusPanel.vue | 4 +- .../payment/StripePaymentInline.vue | 8 +- .../src/components/payment/providerConfig.ts | 4 +- frontend/src/i18n/locales/en.ts | 26 ++++++ frontend/src/i18n/locales/zh.ts | 26 ++++++ frontend/src/utils/apiError.ts | 84 ++++++++++++++++++- frontend/src/views/admin/SettingsView.vue | 18 ++-- .../views/admin/orders/AdminOrdersView.vue | 10 +-- .../orders/AdminPaymentDashboardView.vue | 4 +- .../admin/orders/AdminPaymentPlansView.vue | 8 +- frontend/src/views/user/PaymentQRCodeView.vue | 4 +- frontend/src/views/user/PaymentView.vue | 6 +- frontend/src/views/user/StripePaymentView.vue | 6 +- frontend/src/views/user/StripePopupView.vue | 4 +- frontend/src/views/user/UserOrdersView.vue | 8 +- 16 files changed, 177 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index db90c3b6..09d273cc 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -78,7 +78,7 @@ import Icon from '@/components/icons/Icon.vue' import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' import type { PaymentOrder } from '@/types/payment' import QRCode from 'qrcode' @@ -222,7 +222,7 @@ async function handleCancel() { cleanup() emit('close') } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { cancelling.value = false } diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 17541e59..53989dee 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -124,7 +124,7 @@ import { useI18n } from 'vue-i18n' import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' import type { PaymentOrder } from '@/types/payment' import Icon from '@/components/icons/Icon.vue' @@ -242,7 +242,7 @@ async function handleCancel() { cleanup() outcome.value = 'cancelled' } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { cancelling.value = false } diff --git a/frontend/src/components/payment/StripePaymentInline.vue b/frontend/src/components/payment/StripePaymentInline.vue index 3ddff8c8..bdb0dd6b 100644 --- a/frontend/src/components/payment/StripePaymentInline.vue +++ b/frontend/src/components/payment/StripePaymentInline.vue @@ -67,7 +67,7 @@ import { ref, onMounted, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { paymentAPI } from '@/api/payment' import { useAppStore } from '@/stores' import { getPaymentPopupFeatures } from '@/components/payment/providerConfig' @@ -132,7 +132,7 @@ onMounted(async () => { selectedType.value = event.value.type }) } catch (err: unknown) { - initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) + initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed')) } finally { loading.value = false } @@ -186,7 +186,7 @@ async function handlePay() { emit('success') } } catch (err: unknown) { - error.value = extractApiErrorMessage(err, t('payment.result.failed')) + error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed')) } finally { submitting.value = false } @@ -199,7 +199,7 @@ async function handleCancel() { await paymentAPI.cancelOrder(props.orderId) emit('back') } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { cancelling.value = false } diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index bf2d4177..f4f5acdc 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -99,9 +99,9 @@ export const PROVIDER_CONFIG_FIELDS: Record = { { key: 'mchId', label: '', sensitive: false }, { key: 'privateKey', label: '', sensitive: true }, { key: 'apiV3Key', label: '', sensitive: true }, + { key: 'certSerial', label: '', sensitive: false }, { key: 'publicKey', label: '', sensitive: true }, - { key: 'publicKeyId', label: '', sensitive: false, optional: true }, - { key: 'certSerial', label: '', sensitive: false, optional: true }, + { key: 'publicKeyId', label: '', sensitive: false }, ], stripe: [ { key: 'secretKey', label: '', sensitive: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c0a17d96..8213cb0f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5432,7 +5432,33 @@ export default { errors: { tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', cancelRateLimited: 'Too many cancellations. Please try again later.', + // Structured error codes (reason strings from backend ApplicationError) + PAYMENT_DISABLED: 'Payment system is disabled.', + USER_INACTIVE: 'Your account is disabled.', + BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.', + INVALID_AMOUNT: 'Invalid amount.', + INVALID_INPUT: 'Invalid request.', + PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.', + GROUP_NOT_FOUND: 'Subscription group is no longer available.', + GROUP_TYPE_MISMATCH: 'Group is not a subscription type.', + TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', + DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.', + PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.', + NO_AVAILABLE_INSTANCE: 'No payment channel available right now.', + 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_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.', + FORBIDDEN: 'No permission for this order.', + CONFLICT: 'Order status has changed. Please refresh.', + INVALID_ORDER_TYPE: 'Only balance orders can request a refund.', + INVALID_STATUS: 'The current order status does not allow this operation.', + BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.', + REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.', + REFUND_FAILED: 'Refund failed.', }, stripePay: 'Pay Now', stripeSuccessProcessing: 'Payment successful, processing your order...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ba9edd7f..5f936965 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5620,7 +5620,33 @@ export default { errors: { tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', cancelRateLimited: '取消订单过于频繁,请稍后再试', + // Structured error codes (reason strings from backend ApplicationError) + PAYMENT_DISABLED: '支付系统已关闭', + USER_INACTIVE: '账号已被禁用', + BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭', + INVALID_AMOUNT: '金额无效', + INVALID_INPUT: '参数有误', + PLAN_NOT_AVAILABLE: '套餐不存在或已下架', + GROUP_NOT_FOUND: '订阅分组不可用', + GROUP_TYPE_MISMATCH: '分组类型不是订阅类型', + TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', + DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}', + PAYMENT_GATEWAY_ERROR: '支付方式不可用', + NO_AVAILABLE_INSTANCE: '暂无可用的支付通道', + PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员', + WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}', + WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})', + WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容', PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作', + CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试', + NOT_FOUND: '订单不存在', + FORBIDDEN: '无权限操作此订单', + CONFLICT: '订单状态已变更,请刷新', + INVALID_ORDER_TYPE: '仅余额订单可申请退款', + INVALID_STATUS: '当前订单状态不允许此操作', + BALANCE_NOT_ENOUGH: '退款金额超过余额', + REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额', + REFUND_FAILED: '退款失败', }, stripePay: '立即支付', stripeSuccessProcessing: '支付成功,正在处理订单...', diff --git a/frontend/src/utils/apiError.ts b/frontend/src/utils/apiError.ts index e1fe0c30..07a17aca 100644 --- a/frontend/src/utils/apiError.ts +++ b/frontend/src/utils/apiError.ts @@ -23,14 +23,96 @@ interface ApiErrorLike { /** * Extract the error code from an API error object. + * + * Prefers the string `reason` (e.g. "PAYMENT_PROVIDER_MISCONFIGURED") over the + * numeric HTTP `code`, because reason is granular enough to drive i18n lookup + * while HTTP code is not. */ export function extractApiErrorCode(err: unknown): string | undefined { if (!err || typeof err !== 'object') return undefined const e = err as ApiErrorLike - const code = e.code ?? e.reason ?? e.response?.data?.code + const code = e.reason ?? e.code ?? e.response?.data?.code return code != null ? String(code) : undefined } +/** + * Extract metadata (interpolation params) from an API error object. + * Backend errors carry `metadata` with template variables that fill i18n placeholders. + */ +export function extractApiErrorMetadata(err: unknown): Record | undefined { + if (!err || typeof err !== 'object') return undefined + const e = err as ApiErrorLike + return e.metadata +} + +type TranslateFn = (key: string, params?: Record) => string +type TranslateWithExistsFn = TranslateFn & { te?: (key: string) => boolean } + +/** + * Translate a value via i18n if a matching key exists, otherwise return the original. + * Example: "certSerial" → t('admin.settings.payment.field_certSerial') → "证书序列号". + */ +function tryTranslate(t: TranslateFn, key: string, fallback: string): string { + const translated = t(key) + if (translated === key) return fallback + const te = (t as TranslateWithExistsFn).te + if (te && !te(key)) return fallback + return translated +} + +/** + * Replace raw config field names in metadata (e.g. "certSerial") with their + * localized UI labels (e.g. "证书序列号"), using the provider-config field i18n namespace. + * Handles both single `key` and `/`-joined `keys` patterns used by wxpay errors. + */ +function localizeMetadata(metadata: Record, t: TranslateFn): Record { + const out: Record = { ...metadata } + if (typeof out.key === 'string') { + out.key = tryTranslate(t, `admin.settings.payment.field_${out.key}`, out.key) + } + if (typeof out.keys === 'string') { + out.keys = out.keys + .split('/') + .map(k => tryTranslate(t, `admin.settings.payment.field_${k}`, k)) + .join(' / ') + } + return out +} + +/** + * Extract a localized error message from an API error by looking up + * `.` in i18n and substituting metadata as placeholders. + * + * Config-field names in metadata (`key` / `keys`) are automatically translated + * to their UI labels before substitution, so error messages read like + * "缺少必填项:证书序列号" instead of "缺少必填项:certSerial". + * + * @param err - The caught error + * @param t - Vue i18n translate function + * @param namespace- i18n key prefix, e.g. "payment.errors" + * @param fallback - Fallback key or plain string if no localized mapping exists + */ +export function extractI18nErrorMessage( + err: unknown, + t: TranslateFn, + namespace: string, + fallback: string, +): string { + const code = extractApiErrorCode(err) + if (code) { + const key = `${namespace}.${code}` + const rawMetadata = extractApiErrorMetadata(err) ?? {} + const metadata = localizeMetadata(rawMetadata, t) + const translated = t(key, metadata) + // Vue i18n returns the key itself when missing; detect that and fall back. + if (translated !== key) return translated + // If the framework exposes `te`, use it to double-check. + const te = (t as TranslateWithExistsFn).te + if (te && te(key)) return translated + } + return extractApiErrorMessage(err, fallback) +} + /** * Extract a displayable error message from an API error. * diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index ee6a4c6d..27cb1f0c 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2850,7 +2850,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue' import ImageUpload from '@/components/common/ImageUpload.vue' import BackupSettings from '@/views/admin/BackupView.vue' import { useClipboard } from '@/composables/useClipboard' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError' import { useAppStore } from '@/stores' import { useAdminSettingsStore } from '@/stores/adminSettings' import { @@ -4085,14 +4085,10 @@ const cancelRateLimitModeOptions = computed(() => [ { value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') }, ]) -const paymentErrorMap = computed(() => ({ - PENDING_ORDERS: t('payment.errors.PENDING_ORDERS'), -})) - async function loadProviders() { providersLoading.value = true try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] } - catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { providersLoading.value = false } } @@ -4122,7 +4118,7 @@ async function handleSaveProvider(payload: Partial) { // Auto-save settings so provider changes take effect immediately await saveSettings() } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { providerSaving.value = false } @@ -4148,7 +4144,7 @@ async function handleToggleField(provider: ProviderInstance, field: 'enabled' | } else { provider.allow_user_refund = newValue } - } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) } + } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } async function handleToggleType(provider: ProviderInstance, type: string) { @@ -4158,7 +4154,7 @@ async function handleToggleType(provider: ProviderInstance, type: string) { try { await adminAPI.payment.updateProvider(provider.id, { supported_types: updated } as any) provider.supported_types = updated - } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) } + } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } function confirmDeleteProvider(provider: ProviderInstance) { @@ -4177,7 +4173,7 @@ async function handleReorderProviders(updates: { id: number; sort_order: number if (p) p.sort_order = u.sort_order } } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) loadProviders() } } @@ -4189,7 +4185,7 @@ async function handleDeleteProvider() { appStore.showSuccess(t('common.deleted')) showDeleteProviderDialog.value = false loadProviders() - } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) } + } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } onMounted(() => { diff --git a/frontend/src/views/admin/orders/AdminOrdersView.vue b/frontend/src/views/admin/orders/AdminOrdersView.vue index 027c8e5e..dd9fa7e6 100644 --- a/frontend/src/views/admin/orders/AdminOrdersView.vue +++ b/frontend/src/views/admin/orders/AdminOrdersView.vue @@ -116,7 +116,7 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminPaymentAPI } from '@/api/admin/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { formatOrderDateTime } from '@/components/payment/orderUtils' import type { PaymentOrder } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' @@ -167,7 +167,7 @@ async function loadOrders() { orders.value = res.data.items || [] orderPagination.total = res.data.total || 0 } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { ordersLoading.value = false } } @@ -214,12 +214,12 @@ async function showOrderDetail(order: PaymentOrder) { async function handleCancelOrder(order: PaymentOrder) { try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() } - catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } async function handleRetryOrder(order: PaymentOrder) { try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() } - catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true } @@ -230,7 +230,7 @@ async function handleRefund(data: { amount: number; reason: string; deduct_balan try { await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force }) appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders() - } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { refundSubmitting.value = false } } diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue index 06bc9218..5a80db44 100644 --- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue @@ -72,7 +72,7 @@ import { ref, watch, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminPaymentAPI } from '@/api/admin/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import type { DashboardStats } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' @@ -110,7 +110,7 @@ async function loadDashboard() { const res = await adminPaymentAPI.getDashboard(days.value) stats.value = res.data } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { loading.value = false } diff --git a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue index 876b2aa1..c2fc26fe 100644 --- a/frontend/src/views/admin/orders/AdminPaymentPlansView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentPlansView.vue @@ -78,7 +78,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminPaymentAPI } from '@/api/admin/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import adminAPI from '@/api/admin' import type { SubscriptionPlan } from '@/types/payment' import type { AdminGroup } from '@/types' @@ -150,7 +150,7 @@ async function loadPlans() { : (p.features || []), })) } - catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { plansLoading.value = false } } @@ -166,7 +166,7 @@ async function toggleForSale(plan: SubscriptionPlan) { await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale }) plan.for_sale = !plan.for_sale } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } @@ -174,7 +174,7 @@ function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan async function handleDeletePlan() { if (!deletingPlanId.value) return try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() } - catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } } // ==================== Lifecycle ==================== diff --git a/frontend/src/views/user/PaymentQRCodeView.vue b/frontend/src/views/user/PaymentQRCodeView.vue index 0965947a..f844858d 100644 --- a/frontend/src/views/user/PaymentQRCodeView.vue +++ b/frontend/src/views/user/PaymentQRCodeView.vue @@ -39,7 +39,7 @@ import { useRoute, useRouter } from 'vue-router' import AppLayout from '@/components/layout/AppLayout.vue' import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { useAppStore } from '@/stores' import QRCode from 'qrcode' import alipayIcon from '@/assets/icons/alipay.svg' @@ -167,7 +167,7 @@ async function handleCancel() { cleanup() router.push('/purchase') } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { cancelling.value = false } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 3f1401b3..e2885c80 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -271,7 +271,7 @@ import { usePaymentStore } from '@/stores/payment' import { useSubscriptionStore } from '@/stores/subscriptions' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { isMobileDevice } from '@/utils/device' import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' @@ -610,7 +610,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n } else if (apiErr.reason === 'CANCEL_RATE_LIMITED') { errorMessage.value = t('payment.errors.cancelRateLimited') } else { - errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed')) + errorMessage.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed')) } appStore.showError(errorMessage.value) } finally { @@ -648,7 +648,7 @@ onMounted(async () => { } } } - } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } + } catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { loading.value = false } // Fetch active subscriptions (uses cache, non-blocking) subscriptionStore.fetchActiveSubscriptions().catch(() => {}) diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 20a4a408..3f73d4d5 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -99,7 +99,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { isMobileDevice } from '@/utils/device' import type { PaymentOrder } from '@/types/payment' import type { Stripe, StripeElements } from '@stripe/stripe-js' @@ -167,7 +167,7 @@ onMounted(async () => { mountPaymentElement(stripe, clientSecret) } } catch (err: unknown) { - initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) + initError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed')) } finally { loading.value = false } @@ -248,7 +248,7 @@ async function handleGenericPay() { scheduleClose() } } catch (err: unknown) { - stripeError.value = extractApiErrorMessage(err, t('payment.result.failed')) + stripeError.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.result.failed')) } finally { stripeSubmitting.value = false } diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 2704c62d..688ad644 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -56,7 +56,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import { isMobileDevice } from '@/utils/device' interface StripeWithWechatPay { @@ -143,7 +143,7 @@ async function initStripe(clientSecret: string, publishableKey: string) { } } } catch (err: unknown) { - error.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed')) + error.value = extractI18nErrorMessage(err, t, 'payment.errors', t('payment.stripeLoadFailed')) } } diff --git a/frontend/src/views/user/UserOrdersView.vue b/frontend/src/views/user/UserOrdersView.vue index ea888eb7..c3ed80eb 100644 --- a/frontend/src/views/user/UserOrdersView.vue +++ b/frontend/src/views/user/UserOrdersView.vue @@ -86,7 +86,7 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' -import { extractApiErrorMessage } from '@/utils/apiError' +import { extractI18nErrorMessage } from '@/utils/apiError' import type { PaymentOrder } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import Pagination from '@/components/common/Pagination.vue' @@ -128,7 +128,7 @@ async function fetchOrders() { orders.value = res.data.items || [] pagination.total = res.data.total || 0 } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { loading.value = false } @@ -148,7 +148,7 @@ async function confirmCancel() { cancelTargetId.value = null await fetchOrders() } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { actionLoading.value = false } @@ -166,7 +166,7 @@ async function confirmRefund() { refundReason.value = '' await fetchOrders() } catch (err: unknown) { - appStore.showError(extractApiErrorMessage(err, t('common.error'))) + appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) } finally { actionLoading.value = false }