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.
This commit is contained in:
erio
2026-04-20 20:06:53 +08:00
parent 79192cf65b
commit 40d4e167cd
16 changed files with 177 additions and 47 deletions

View File

@@ -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<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.
*