2026-04-10 21:08:51 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Shared constants and types for payment provider management.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
// --- Types ---
|
|
|
|
|
|
|
|
|
|
|
|
export interface ConfigFieldDef {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
sensitive: boolean
|
|
|
|
|
|
optional?: boolean
|
|
|
|
|
|
defaultValue?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface TypeOption {
|
|
|
|
|
|
value: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Callback URL paths for a provider. */
|
|
|
|
|
|
export interface CallbackPaths {
|
|
|
|
|
|
notifyUrl?: string
|
|
|
|
|
|
returnUrl?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Constants ---
|
|
|
|
|
|
|
|
|
|
|
|
/** Maps provider key → available payment types. */
|
|
|
|
|
|
export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
|
|
|
|
|
|
easypay: ['alipay', 'wxpay'],
|
|
|
|
|
|
alipay: ['alipay'],
|
|
|
|
|
|
wxpay: ['wxpay'],
|
|
|
|
|
|
stripe: ['card', 'alipay', 'wxpay', 'link'],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Available payment modes for EasyPay providers. */
|
|
|
|
|
|
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
|
|
|
|
|
|
|
|
|
|
|
|
/** Fixed display order for user-facing payment methods */
|
|
|
|
|
|
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const
|
|
|
|
|
|
|
|
|
|
|
|
/** Payment mode constants */
|
|
|
|
|
|
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
|
|
|
|
|
export const PAYMENT_MODE_POPUP = 'popup'
|
|
|
|
|
|
|
2026-04-19 01:40:25 +08:00
|
|
|
|
/** Preferred popup size for payment gateways. Alipay's standard checkout
|
|
|
|
|
|
* (QR + account login panel) needs ~1200×900 to render without any scrolling. */
|
|
|
|
|
|
const PAYMENT_POPUP_PREFERRED_WIDTH = 1250
|
|
|
|
|
|
const PAYMENT_POPUP_PREFERRED_HEIGHT = 900
|
2026-04-10 21:08:51 +08:00
|
|
|
|
|
2026-04-19 01:40:25 +08:00
|
|
|
|
/** Build a window.open features string sized to fit within the current screen
|
|
|
|
|
|
* while preferring the above dimensions. Centers the popup on the available
|
|
|
|
|
|
* work area so nothing is clipped on smaller laptop displays. */
|
|
|
|
|
|
export function getPaymentPopupFeatures(): string {
|
|
|
|
|
|
const screen = typeof window !== 'undefined' ? window.screen : null
|
|
|
|
|
|
const availW = screen?.availWidth ?? PAYMENT_POPUP_PREFERRED_WIDTH
|
|
|
|
|
|
const availH = screen?.availHeight ?? PAYMENT_POPUP_PREFERRED_HEIGHT
|
|
|
|
|
|
const width = Math.min(PAYMENT_POPUP_PREFERRED_WIDTH, availW - 40)
|
|
|
|
|
|
const height = Math.min(PAYMENT_POPUP_PREFERRED_HEIGHT, availH - 40)
|
|
|
|
|
|
const left = Math.max(0, Math.floor((availW - width) / 2))
|
|
|
|
|
|
const top = Math.max(0, Math.floor((availH - height) / 2))
|
|
|
|
|
|
return `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
|
|
|
|
|
}
|
2026-04-10 21:08:51 +08:00
|
|
|
|
|
|
|
|
|
|
/** Webhook paths for each provider (relative to origin). */
|
|
|
|
|
|
export const WEBHOOK_PATHS: Record<string, string> = {
|
|
|
|
|
|
easypay: '/api/v1/payment/webhook/easypay',
|
|
|
|
|
|
alipay: '/api/v1/payment/webhook/alipay',
|
|
|
|
|
|
wxpay: '/api/v1/payment/webhook/wxpay',
|
|
|
|
|
|
stripe: '/api/v1/payment/webhook/stripe',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const RETURN_PATH = '/payment/result'
|
|
|
|
|
|
|
|
|
|
|
|
/** Fixed callback paths per provider — displayed as read-only after base URL. */
|
|
|
|
|
|
export const PROVIDER_CALLBACK_PATHS: Record<string, CallbackPaths> = {
|
|
|
|
|
|
easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH },
|
|
|
|
|
|
alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH },
|
|
|
|
|
|
wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay },
|
|
|
|
|
|
// stripe: no callback URL config needed (webhook is separate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
|
|
|
|
|
|
export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
|
|
|
|
|
|
easypay: [
|
|
|
|
|
|
{ key: 'pid', label: 'PID', sensitive: false },
|
|
|
|
|
|
{ key: 'pkey', label: 'PKey', sensitive: true },
|
|
|
|
|
|
{ key: 'apiBase', label: '', sensitive: false },
|
|
|
|
|
|
{ key: 'cidAlipay', label: '', sensitive: false, optional: true },
|
|
|
|
|
|
{ key: 'cidWxpay', label: '', sensitive: false, optional: true },
|
|
|
|
|
|
],
|
|
|
|
|
|
alipay: [
|
|
|
|
|
|
{ key: 'appId', label: 'App ID', sensitive: false },
|
|
|
|
|
|
{ key: 'privateKey', label: '', sensitive: true },
|
|
|
|
|
|
{ key: 'publicKey', label: '', sensitive: true },
|
|
|
|
|
|
],
|
|
|
|
|
|
wxpay: [
|
|
|
|
|
|
{ key: 'appId', label: 'App ID', sensitive: false },
|
|
|
|
|
|
{ key: 'mchId', label: '', sensitive: false },
|
|
|
|
|
|
{ key: 'privateKey', label: '', sensitive: true },
|
|
|
|
|
|
{ key: 'apiV3Key', label: '', sensitive: true },
|
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:06:53 +08:00
|
|
|
|
{ key: 'certSerial', label: '', sensitive: false },
|
2026-04-10 21:08:51 +08:00
|
|
|
|
{ key: 'publicKey', label: '', sensitive: true },
|
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:06:53 +08:00
|
|
|
|
{ key: 'publicKeyId', label: '', sensitive: false },
|
2026-04-10 21:08:51 +08:00
|
|
|
|
],
|
|
|
|
|
|
stripe: [
|
|
|
|
|
|
{ key: 'secretKey', label: '', sensitive: true },
|
|
|
|
|
|
{ key: 'publishableKey', label: '', sensitive: false },
|
|
|
|
|
|
{ key: 'webhookSecret', label: '', sensitive: true },
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
|
|
/** Resolve type label for display. */
|
|
|
|
|
|
export function resolveTypeLabel(
|
|
|
|
|
|
typeVal: string,
|
|
|
|
|
|
_providerKey: string,
|
|
|
|
|
|
allTypes: TypeOption[],
|
|
|
|
|
|
_redirectLabel: string,
|
|
|
|
|
|
): TypeOption {
|
|
|
|
|
|
return allTypes.find(pt => pt.value === typeVal) || { value: typeVal, label: typeVal }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Get available type options for a provider key. */
|
|
|
|
|
|
export function getAvailableTypes(
|
|
|
|
|
|
providerKey: string,
|
|
|
|
|
|
allTypes: TypeOption[],
|
|
|
|
|
|
redirectLabel: string,
|
|
|
|
|
|
): TypeOption[] {
|
|
|
|
|
|
const types = PROVIDER_SUPPORTED_TYPES[providerKey] || []
|
|
|
|
|
|
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Extract base URL from a full callback URL by removing the known path suffix. */
|
|
|
|
|
|
export function extractBaseUrl(fullUrl: string, path: string): string {
|
|
|
|
|
|
if (!fullUrl) return ''
|
|
|
|
|
|
if (fullUrl.endsWith(path)) return fullUrl.slice(0, -path.length)
|
|
|
|
|
|
// Fallback: try to extract origin
|
|
|
|
|
|
try { return new URL(fullUrl).origin } catch { return fullUrl }
|
|
|
|
|
|
}
|