feat(payment): structured error codes + frontend i18n mapping

后端返回结构化错误码(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
This commit is contained in:
erio
2026-04-20 18:45:30 +08:00
parent 14bb922a92
commit f2c6687cbf
18 changed files with 181 additions and 62 deletions
+16 -7
View File
@@ -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
}
@@ -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",
},
}
+22 -6
View File
@@ -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 {
@@ -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
}
@@ -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
}
@@ -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
}
+26
View File
@@ -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...',
+26
View File
@@ -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: '支付成功,正在处理订单...',
+47 -1
View File
@@ -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<string, unknown> | undefined {
if (!err || typeof err !== 'object') return undefined
const e = err as ApiErrorLike
return e.metadata
}
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
type TranslateWithExistsFn = TranslateFn & { te?: (key: string) => boolean }
/**
* Extract a localized error message from an API error by looking up
* `<namespace>.<REASON>` 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.
*
+7 -11
View File
@@ -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<ProviderInstance>) {
// 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(() => {
@@ -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 }
}
@@ -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
}
@@ -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 ====================
@@ -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
}
+3 -3
View File
@@ -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(() => {})
@@ -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
}
+2 -2
View File
@@ -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'))
}
}
+4 -4
View File
@@ -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
}