sub2api/frontend/src/utils/apiError.ts
erio 40d4e167cd feat(payment): i18n payment error codes and label localization
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
    `<namespace>.<REASON>` in i18n and substitutes metadata. Before
    substitution, `metadata.key` and `metadata.keys` (slash-joined) are
    re-translated through `admin.settings.payment.field_<key>` 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.
2026-04-20 20:23:16 +08:00

154 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Centralized API error message extraction
*
* The API client interceptor rejects with a plain object: { status, code, message, error }
* This utility extracts the user-facing message from any error shape.
*/
interface ApiErrorLike {
status?: number
code?: number | string
message?: string
error?: string
reason?: string
metadata?: Record<string, unknown>
response?: {
data?: {
detail?: string
message?: string
code?: number | string
}
}
}
/**
* 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.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 }
/**
* 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<string, unknown>, t: TranslateFn): Record<string, unknown> {
const out: Record<string, unknown> = { ...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
* `<namespace>.<REASON>` 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.
*
* @param err - The caught error (unknown type)
* @param fallback - Fallback message if none can be extracted (use t('common.error') or similar)
* @param i18nMap - Optional map of error codes to i18n translated strings
*/
export function extractApiErrorMessage(
err: unknown,
fallback = 'Unknown error',
i18nMap?: Record<string, string>,
): string {
if (!err) return fallback
// Try i18n mapping by error code first
if (i18nMap) {
const code = extractApiErrorCode(err)
if (code && i18nMap[code]) return i18nMap[code]
}
// Plain object from API client interceptor (most common case)
if (typeof err === 'object' && err !== null) {
const e = err as ApiErrorLike
// Interceptor shape: { message, error }
if (e.message) return e.message
if (e.error) return e.error
// Legacy axios shape: { response.data.detail }
if (e.response?.data?.detail) return e.response.data.detail
if (e.response?.data?.message) return e.response.data.message
}
// Standard Error
if (err instanceof Error) return err.message
// Last resort
const str = String(err)
return str === '[object Object]' ? fallback : str
}