From f2c6687cbf33d529b3feec7a7408374dc94b6bee Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 20 Apr 2026 18:41:24 +0800 Subject: [PATCH] feat(payment): structured error codes + frontend i18n mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端返回结构化错误码(reason + metadata 关键词),前端按 reason 查 i18n 文案并用 metadata 填充占位符,用户看到本地化提示而不是英文原始错误。 Backend: - wxpay.go: 校验错误从 fmt.Errorf 改为 infraerrors.BadRequest 结构化,新增 reason 码: - WXPAY_CONFIG_MISSING_KEY (metadata: key) - WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual) - WXPAY_CONFIG_PAIR_VIOLATION (metadata: keys) - payment_order.go: invokeProvider 用 errors.As 识别 ApplicationError 透传(保留 wxpay 的 reason/metadata),只有非结构化 err 才 fallback 到 PAYMENT_PROVIDER_MISCONFIGURED;其他几个用户可见错误(TOO_MANY_PENDING / DAILY_LIMIT_EXCEEDED / PAYMENT_GATEWAY_ERROR / NO_AVAILABLE_INSTANCE)message 简化为关键词,参数放 metadata - wxpay_test.go: 更新断言匹配新的 reason code Frontend: - apiError.ts: extractApiErrorCode 优先返回 reason(字符串错误码)而非 HTTP 数字;新增 extractI18nErrorMessage 按 namespace.reason 查 i18n 并以 metadata 作模板变量 - 12 个支付相关 Vue 文件(用户/管理/组件)统一改用 extractI18nErrorMessage('payment.errors', ...) - i18n zh/en locales 下的 payment.errors 添加完整错误码文案(PAYMENT_DISABLED / INVALID_AMOUNT / TOO_MANY_PENDING / DAILY_LIMIT_EXCEEDED / WXPAY_CONFIG_* 等共 20+ 条,支持 {max}/{remaining}/{key} 等占位符) - 移除 SettingsView 中已失效的 paymentErrorMap computed --- backend/internal/payment/provider/wxpay.go | 23 ++++++--- .../internal/payment/provider/wxpay_test.go | 8 ++-- backend/internal/service/payment_order.go | 28 ++++++++--- .../components/payment/PaymentQRDialog.vue | 4 +- .../components/payment/PaymentStatusPanel.vue | 4 +- .../payment/StripePaymentInline.vue | 8 ++-- frontend/src/i18n/locales/en.ts | 26 ++++++++++ frontend/src/i18n/locales/zh.ts | 26 ++++++++++ frontend/src/utils/apiError.ts | 48 ++++++++++++++++++- 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 ++-- 18 files changed, 181 insertions(+), 62 deletions(-) diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index d6d7b2b413..b06f352720 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -8,11 +8,13 @@ import ( "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" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" @@ -58,22 +60,29 @@ 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", "certSerial"} 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"])), + }) } // publicKey + publicKeyId are a pair used by the new pubkey verifier. // If either is set, both must be set; otherwise fall back to legacy platform certificate mode. - hasPubKey := config["publicKey"] != "" - hasPubKeyID := config["publicKeyId"] != "" - if hasPubKey != hasPubKeyID { - return nil, fmt.Errorf("wxpay publicKey and publicKeyId must be provided together") + if (config["publicKey"] != "") != (config["publicKeyId"] != "") { + return nil, infraerrors.BadRequest("WXPAY_CONFIG_PAIR_VIOLATION", "pair_violation"). + WithMetadata(map[string]string{"keys": "publicKey/publicKeyId"}) } return &Wxpay{instanceID: instanceID, config: config}, nil } diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index f5fa838656..111cc16932 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -221,25 +221,25 @@ func TestNewWxpay(t *testing.T) { name: "publicKey without publicKeyId", config: withOverride(map[string]string{"publicKeyId": ""}), wantErr: true, - errSubstr: "must be provided together", + errSubstr: "WXPAY_CONFIG_PAIR_VIOLATION", }, { name: "publicKeyId without publicKey", config: withOverride(map[string]string{"publicKey": ""}), wantErr: true, - errSubstr: "must be provided together", + errSubstr: "WXPAY_CONFIG_PAIR_VIOLATION", }, { 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_order.go b/backend/internal/service/payment_order.go index e78a105465..a72120257a 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,15 +203,25 @@ 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 { slog.Error("[PaymentService] CreateProvider failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err) - return nil, infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_MISCONFIGURED", fmt.Sprintf("payment provider misconfigured: %s", err.Error())). + // 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) @@ -217,7 +229,11 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen 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 { diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index db90c3b648..09d273ccd6 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 17541e59a7..53989dee21 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 3ddff8c813..bdb0dd6b50 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/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ae9d280f0b..1382c4dfb0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5324,7 +5324,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_PAIR_VIOLATION: 'WeChat Pay {keys} must be provided together.', 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 19e81a335d..9188b50307 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5520,7 +5520,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_PAIR_VIOLATION: '微信支付 {keys} 必须同时配置', 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 e1fe0c30cf..384578acd2 100644 --- a/frontend/src/utils/apiError.ts +++ b/frontend/src/utils/apiError.ts @@ -23,14 +23,60 @@ 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 } + +/** + * Extract a localized error message from an API error by looking up + * `.` in i18n and substituting metadata as placeholders. + * + * @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 metadata = extractApiErrorMetadata(err) ?? {} + 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 dd6fec3376..a5b386fcdd 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2884,7 +2884,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 { @@ -4121,14 +4121,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 } } @@ -4158,7 +4154,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 } @@ -4184,7 +4180,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) { @@ -4194,7 +4190,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) { @@ -4213,7 +4209,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() } } @@ -4225,7 +4221,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 027c8e5e91..dd9fa7e6f0 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 06bc921870..5a80db44f0 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 876b2aa187..c2fc26fe90 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 0965947ab6..f844858daf 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 3f1401b39a..e2885c80fe 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 20a4a40837..3f73d4d520 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 2704c62db1..688ad6444d 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 ea888eb702..c3ed80eb59 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 }