feat: 全站多语言支持 (i18n),lang=en 显示英文,其余默认中文
新增 src/lib/locale.ts 作为统一多语言入口,覆盖前台支付链路、 管理后台、API/服务层错误文案,共 35 个文件。URL 参数 lang 全链路透传, 包括 Stripe return_url、页面跳转、layout html lang 属性等。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import crypto from 'crypto';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
function isLocalAdminToken(token: string): boolean {
|
||||
const env = getEnv();
|
||||
@@ -56,6 +57,7 @@ export async function verifyAdminToken(request: NextRequest): Promise<boolean> {
|
||||
return isSub2ApiAdmin(token);
|
||||
}
|
||||
|
||||
export function unauthorizedResponse() {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
export function unauthorizedResponse(request?: NextRequest) {
|
||||
const locale = resolveLocale(request?.nextUrl.searchParams.get('lang'));
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Unauthorized' : '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
20
src/lib/locale.ts
Normal file
20
src/lib/locale.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type Locale = 'zh' | 'en';
|
||||
|
||||
export function resolveLocale(lang: string | null | undefined): Locale {
|
||||
return lang?.trim().toLowerCase() === 'en' ? 'en' : 'zh';
|
||||
}
|
||||
|
||||
export function isEnglish(locale: Locale): boolean {
|
||||
return locale === 'en';
|
||||
}
|
||||
|
||||
export function pickLocaleText<T>(locale: Locale, zh: T, en: T): T {
|
||||
return locale === 'en' ? en : zh;
|
||||
}
|
||||
|
||||
export function applyLocaleToSearchParams(params: URLSearchParams, locale: Locale): URLSearchParams {
|
||||
if (locale === 'en') {
|
||||
params.set('lang', 'en');
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@@ -9,9 +9,14 @@ import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { deriveOrderState, isRefundStatus } from './status';
|
||||
import { pickLocaleText, type Locale } from '@/lib/locale';
|
||||
|
||||
const MAX_PENDING_ORDERS = 3;
|
||||
|
||||
function message(locale: Locale, zh: string, en: string): string {
|
||||
return pickLocaleText(locale, zh, en);
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
userId: number;
|
||||
amount: number;
|
||||
@@ -20,6 +25,7 @@ export interface CreateOrderInput {
|
||||
isMobile?: boolean;
|
||||
srcHost?: string;
|
||||
srcUrl?: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export interface CreateOrderResult {
|
||||
@@ -39,17 +45,22 @@ export interface CreateOrderResult {
|
||||
|
||||
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
||||
const env = getEnv();
|
||||
const locale = input.locale ?? 'zh';
|
||||
|
||||
const user = await getUser(input.userId);
|
||||
if (user.status !== 'active') {
|
||||
throw new OrderError('USER_INACTIVE', 'User account is disabled', 422);
|
||||
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
|
||||
}
|
||||
|
||||
const pendingCount = await prisma.order.count({
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
||||
throw new OrderError(
|
||||
'TOO_MANY_PENDING',
|
||||
message(locale, `待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`, `Too many pending orders (${MAX_PENDING_ORDERS})`),
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
// 每日累计充值限额校验(0 = 不限制)
|
||||
@@ -67,7 +78,15 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||
throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, 429);
|
||||
throw new OrderError(
|
||||
'DAILY_LIMIT_EXCEEDED',
|
||||
message(
|
||||
locale,
|
||||
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
||||
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +109,16 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
throw new OrderError(
|
||||
'METHOD_DAILY_LIMIT_EXCEEDED',
|
||||
remaining > 0
|
||||
? `${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`
|
||||
: `${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
|
||||
? message(
|
||||
locale,
|
||||
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
|
||||
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
|
||||
)
|
||||
: message(
|
||||
locale,
|
||||
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
|
||||
`${input.paymentType} daily quota is full. Please use another payment method`,
|
||||
),
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -195,9 +222,17 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Payment gateway error (${input.paymentType}):`, error);
|
||||
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
||||
throw new OrderError('PAYMENT_GATEWAY_ERROR', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
|
||||
throw new OrderError(
|
||||
'PAYMENT_GATEWAY_ERROR',
|
||||
message(locale, `支付渠道(${input.paymentType})暂未配置,请联系管理员`, `Payment method (${input.paymentType}) is not configured. Please contact the administrator`),
|
||||
503,
|
||||
);
|
||||
}
|
||||
throw new OrderError('PAYMENT_GATEWAY_ERROR', '支付渠道暂时不可用,请稍后重试或更换支付方式', 502);
|
||||
throw new OrderError(
|
||||
'PAYMENT_GATEWAY_ERROR',
|
||||
message(locale, '支付渠道暂时不可用,请稍后重试或更换支付方式', 'Payment method is temporarily unavailable. Please try again later or use another payment method'),
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,15 +303,16 @@ export async function cancelOrderCore(options: {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
export async function cancelOrder(orderId: string, userId: number): Promise<CancelOutcome> {
|
||||
export async function cancelOrder(orderId: string, userId: number, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { id: true, userId: true, status: true, paymentTradeNo: true, paymentType: true },
|
||||
});
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
if (order.userId !== userId) throw new OrderError('FORBIDDEN', message(locale, '无权操作该订单', 'Forbidden'), 403);
|
||||
if (order.status !== ORDER_STATUS.PENDING)
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
@@ -284,18 +320,19 @@ export async function cancelOrder(orderId: string, userId: number): Promise<Canc
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: `user:${userId}`,
|
||||
auditDetail: 'User cancelled order',
|
||||
auditDetail: message(locale, '用户取消订单', 'User cancelled order'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminCancelOrder(orderId: string): Promise<CancelOutcome> {
|
||||
export async function adminCancelOrder(orderId: string, locale: Locale = 'zh'): Promise<CancelOutcome> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { id: true, status: true, paymentTradeNo: true, paymentType: true },
|
||||
});
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
if (order.status !== ORDER_STATUS.PENDING)
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', 'Order cannot be cancelled'), 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
@@ -303,7 +340,7 @@ export async function adminCancelOrder(orderId: string): Promise<CancelOutcome>
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: 'admin',
|
||||
auditDetail: 'Admin cancelled order',
|
||||
auditDetail: message(locale, '管理员取消订单', 'Admin cancelled order'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -531,13 +568,13 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void {
|
||||
function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void {
|
||||
if (!order.paidAt) {
|
||||
throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单未支付,不允许重试', 'Order is not paid, retry denied'), 400);
|
||||
}
|
||||
|
||||
if (isRefundStatus(order.status)) {
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'), 400);
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) {
|
||||
@@ -545,17 +582,17 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }): voi
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409);
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
||||
}
|
||||
|
||||
throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '仅已支付和失败订单允许重试', 'Only paid and failed orders can retry'), 400);
|
||||
}
|
||||
|
||||
export async function retryRecharge(orderId: string): Promise<void> {
|
||||
export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise<void> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
@@ -567,10 +604,10 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
}
|
||||
|
||||
assertRetryAllowed(order);
|
||||
assertRetryAllowed(order, locale);
|
||||
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
@@ -592,30 +629,30 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
}
|
||||
|
||||
const derived = deriveOrderState(latest);
|
||||
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409);
|
||||
}
|
||||
|
||||
if (derived.rechargeStatus === 'success') {
|
||||
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
||||
}
|
||||
|
||||
if (isRefundStatus(latest.status)) {
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'), 400);
|
||||
}
|
||||
|
||||
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||
throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409);
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
action: 'RECHARGE_RETRY',
|
||||
detail: 'Admin manual retry recharge',
|
||||
detail: message(locale, '管理员手动重试充值', 'Admin manual retry recharge'),
|
||||
operator: 'admin',
|
||||
},
|
||||
});
|
||||
@@ -627,6 +664,7 @@ export interface RefundInput {
|
||||
orderId: string;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export interface RefundResult {
|
||||
@@ -636,10 +674,11 @@ export interface RefundResult {
|
||||
}
|
||||
|
||||
export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
const locale = input.locale ?? 'zh';
|
||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
if (order.status !== ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
|
||||
throw new OrderError('INVALID_STATUS', message(locale, '仅已完成订单允许退款', 'Only completed orders can be refunded'), 400);
|
||||
}
|
||||
|
||||
const rechargeAmount = Number(order.amount);
|
||||
@@ -651,14 +690,18 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
if (user.balance < rechargeAmount) {
|
||||
return {
|
||||
success: false,
|
||||
warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`,
|
||||
warning: message(
|
||||
locale,
|
||||
`用户余额 ${user.balance} 小于需退款的充值金额 ${rechargeAmount}`,
|
||||
`User balance ${user.balance} is lower than refund ${rechargeAmount}`,
|
||||
),
|
||||
requireForce: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
warning: 'Cannot fetch user balance, use force=true',
|
||||
warning: message(locale, '无法获取用户余额,请使用 force=true', 'Cannot fetch user balance, use force=true'),
|
||||
requireForce: true,
|
||||
};
|
||||
}
|
||||
@@ -669,7 +712,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
data: { status: ORDER_STATUS.REFUNDING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||
throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PAYMENT_PREFIX,
|
||||
REDIRECT_PAYMENT_TYPES,
|
||||
} from './constants';
|
||||
import type { Locale } from './locale';
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number;
|
||||
@@ -21,73 +22,90 @@ export interface MyOrder {
|
||||
|
||||
export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
|
||||
|
||||
export const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
[ORDER_STATUS.PENDING]: '待支付',
|
||||
[ORDER_STATUS.PAID]: '已支付',
|
||||
[ORDER_STATUS.RECHARGING]: '充值中',
|
||||
[ORDER_STATUS.COMPLETED]: '已完成',
|
||||
[ORDER_STATUS.EXPIRED]: '已超时',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消',
|
||||
[ORDER_STATUS.FAILED]: '失败',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
const STATUS_TEXT_MAP: Record<Locale, Record<string, string>> = {
|
||||
zh: {
|
||||
[ORDER_STATUS.PENDING]: '待支付',
|
||||
[ORDER_STATUS.PAID]: '已支付',
|
||||
[ORDER_STATUS.RECHARGING]: '充值中',
|
||||
[ORDER_STATUS.COMPLETED]: '已完成',
|
||||
[ORDER_STATUS.EXPIRED]: '已超时',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消',
|
||||
[ORDER_STATUS.FAILED]: '失败',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
},
|
||||
en: {
|
||||
[ORDER_STATUS.PENDING]: 'Pending',
|
||||
[ORDER_STATUS.PAID]: 'Paid',
|
||||
[ORDER_STATUS.RECHARGING]: 'Recharging',
|
||||
[ORDER_STATUS.COMPLETED]: 'Completed',
|
||||
[ORDER_STATUS.EXPIRED]: 'Expired',
|
||||
[ORDER_STATUS.CANCELLED]: 'Cancelled',
|
||||
[ORDER_STATUS.FAILED]: 'Failed',
|
||||
[ORDER_STATUS.REFUNDING]: 'Refunding',
|
||||
[ORDER_STATUS.REFUNDED]: 'Refunded',
|
||||
[ORDER_STATUS.REFUND_FAILED]: 'Refund failed',
|
||||
},
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
{ key: 'EXPIRED', label: '已超时' },
|
||||
];
|
||||
const FILTER_OPTIONS_MAP: Record<Locale, { key: OrderStatusFilter; label: string }[]> = {
|
||||
zh: [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
{ key: 'EXPIRED', label: '已超时' },
|
||||
],
|
||||
en: [
|
||||
{ key: 'ALL', label: 'All' },
|
||||
{ key: 'PENDING', label: 'Pending' },
|
||||
{ key: 'COMPLETED', label: 'Completed' },
|
||||
{ key: 'CANCELLED', label: 'Cancelled' },
|
||||
{ key: 'EXPIRED', label: 'Expired' },
|
||||
],
|
||||
};
|
||||
|
||||
export function getFilterOptions(locale: Locale = 'zh'): { key: OrderStatusFilter; label: string }[] {
|
||||
return FILTER_OPTIONS_MAP[locale];
|
||||
}
|
||||
|
||||
export function detectDeviceIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// 1. 现代 API(Chromium 系浏览器,最准确)
|
||||
const uad = (navigator as Navigator & { userAgentData?: { mobile: boolean } }).userAgentData;
|
||||
if (uad !== undefined) return uad.mobile;
|
||||
|
||||
// 2. UA 正则兜底(Safari / Firefox 等)
|
||||
const ua = navigator.userAgent || '';
|
||||
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
|
||||
if (mobileUA) return true;
|
||||
|
||||
// 3. 触控 + 小屏兜底(新版 iPad UA 伪装成 Mac 的情况)
|
||||
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
|
||||
const touchCapable = navigator.maxTouchPoints > 1;
|
||||
return touchCapable && smallPhysicalScreen;
|
||||
}
|
||||
|
||||
export function formatStatus(status: string): string {
|
||||
return STATUS_TEXT_MAP[status] || status;
|
||||
export function formatStatus(status: string, locale: Locale = 'zh'): string {
|
||||
return STATUS_TEXT_MAP[locale][status] || status;
|
||||
}
|
||||
|
||||
export function formatCreatedAt(value: string): string {
|
||||
export function formatCreatedAt(value: string, locale: Locale = 'zh'): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
return date.toLocaleString(locale === 'en' ? 'en-US' : 'zh-CN');
|
||||
}
|
||||
|
||||
export interface PaymentTypeMeta {
|
||||
/** 支付渠道名(用户看到的:支付宝 / 微信支付 / Stripe) */
|
||||
label: string;
|
||||
/** 选择器中的辅助说明(易支付 / 官方 / 信用卡 / 借记卡) */
|
||||
sublabel?: string;
|
||||
/** 提供商名称(易支付 / 支付宝 / 微信支付 / Stripe) */
|
||||
provider: string;
|
||||
color: string;
|
||||
selectedBorder: string;
|
||||
selectedBg: string;
|
||||
/** 暗色模式选中背景 */
|
||||
selectedBgDark: string;
|
||||
iconBg: string;
|
||||
/** 图标路径(Stripe 不使用外部图标) */
|
||||
iconSrc?: string;
|
||||
/** 图表条形颜色 class */
|
||||
chartBar: { light: string; dark: string };
|
||||
/** 按钮颜色 class(含 hover/active 状态) */
|
||||
buttonClass: string;
|
||||
}
|
||||
|
||||
@@ -153,26 +171,51 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 获取支付方式的显示名称(如 '支付宝(易支付)'),用于管理后台等需要区分的场景 */
|
||||
export function getPaymentTypeLabel(type: string): string {
|
||||
const PAYMENT_TEXT_MAP: Record<Locale, Record<string, { label: string; provider: string; sublabel?: string }>> = {
|
||||
zh: {
|
||||
[PAYMENT_TYPE.ALIPAY]: { label: '支付宝', provider: '易支付' },
|
||||
[PAYMENT_TYPE.ALIPAY_DIRECT]: { label: '支付宝', provider: '支付宝' },
|
||||
[PAYMENT_TYPE.WXPAY]: { label: '微信支付', provider: '易支付' },
|
||||
[PAYMENT_TYPE.WXPAY_DIRECT]: { label: '微信支付', provider: '微信支付' },
|
||||
[PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' },
|
||||
},
|
||||
en: {
|
||||
[PAYMENT_TYPE.ALIPAY]: { label: 'Alipay', provider: 'EasyPay' },
|
||||
[PAYMENT_TYPE.ALIPAY_DIRECT]: { label: 'Alipay', provider: 'Alipay' },
|
||||
[PAYMENT_TYPE.WXPAY]: { label: 'WeChat Pay', provider: 'EasyPay' },
|
||||
[PAYMENT_TYPE.WXPAY_DIRECT]: { label: 'WeChat Pay', provider: 'WeChat Pay' },
|
||||
[PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' },
|
||||
},
|
||||
};
|
||||
|
||||
function getPaymentText(type: string, locale: Locale = 'zh'): { label: string; provider: string; sublabel?: string } {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) return { label: type, provider: '' };
|
||||
const baseText = PAYMENT_TEXT_MAP[locale][type] || { label: meta.label, provider: meta.provider };
|
||||
return {
|
||||
...baseText,
|
||||
sublabel: meta.sublabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaymentTypeLabel(type: string, locale: Locale = 'zh'): string {
|
||||
const meta = getPaymentText(type, locale);
|
||||
if (!meta) return type;
|
||||
if (meta.sublabel) return `${meta.label}(${meta.sublabel})`;
|
||||
// 无 sublabel 时,检查是否有同名渠道需要用 provider 区分
|
||||
const hasDuplicate = Object.entries(PAYMENT_TYPE_META).some(
|
||||
([k, m]) => k !== type && m.label === meta.label,
|
||||
if (meta.sublabel) {
|
||||
return locale === 'en' ? `${meta.label} (${meta.sublabel})` : `${meta.label}(${meta.sublabel})`;
|
||||
}
|
||||
const hasDuplicate = Object.keys(PAYMENT_TYPE_META).some(
|
||||
(key) => key !== type && getPaymentText(key, locale).label === meta.label,
|
||||
);
|
||||
return hasDuplicate ? `${meta.label}(${meta.provider})` : meta.label;
|
||||
if (!hasDuplicate || !meta.provider) return meta.label;
|
||||
return locale === 'en' ? `${meta.label} (${meta.provider})` : `${meta.label}(${meta.provider})`;
|
||||
}
|
||||
|
||||
/** 获取支付渠道和提供商的结构化信息 */
|
||||
export function getPaymentDisplayInfo(type: string): { channel: string; provider: string } {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) return { channel: type, provider: '' };
|
||||
return { channel: meta.label, provider: meta.provider };
|
||||
export function getPaymentDisplayInfo(type: string, locale: Locale = 'zh'): { channel: string; provider: string; sublabel?: string } {
|
||||
const meta = getPaymentText(type, locale);
|
||||
return { channel: meta.label, provider: meta.provider, sublabel: meta.sublabel };
|
||||
}
|
||||
|
||||
/** 获取基础支付方式图标类型(alipay_direct → alipay) */
|
||||
export function getPaymentIconType(type: string): string {
|
||||
if (type.startsWith(PAYMENT_PREFIX.ALIPAY)) return PAYMENT_PREFIX.ALIPAY;
|
||||
if (type.startsWith(PAYMENT_PREFIX.WXPAY)) return PAYMENT_PREFIX.WXPAY;
|
||||
@@ -180,23 +223,19 @@ export function getPaymentIconType(type: string): string {
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 获取支付方式的元数据,带合理的 fallback */
|
||||
export function getPaymentMeta(type: string): PaymentTypeMeta {
|
||||
const base = getPaymentIconType(type);
|
||||
return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META[PAYMENT_TYPE.ALIPAY];
|
||||
}
|
||||
|
||||
/** 获取支付方式图标路径 */
|
||||
export function getPaymentIconSrc(type: string): string {
|
||||
return getPaymentMeta(type).iconSrc || '';
|
||||
}
|
||||
|
||||
/** 获取支付方式简短标签(如 '支付宝'、'微信'、'Stripe') */
|
||||
export function getPaymentChannelLabel(type: string): string {
|
||||
return getPaymentMeta(type).label;
|
||||
export function getPaymentChannelLabel(type: string, locale: Locale = 'zh'): string {
|
||||
return getPaymentDisplayInfo(type, locale).channel;
|
||||
}
|
||||
|
||||
/** 支付类型谓词函数 */
|
||||
export function isStripeType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.STRIPE);
|
||||
}
|
||||
@@ -209,12 +248,10 @@ export function isAlipayType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.ALIPAY);
|
||||
}
|
||||
|
||||
/** 该支付方式需要页面跳转(而非二维码) */
|
||||
export function isRedirectPayment(type: string | undefined | null): boolean {
|
||||
return !!type && REDIRECT_PAYMENT_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** 用自定义 sublabel 覆盖默认值 */
|
||||
export function applySublabelOverrides(overrides: Record<string, string>): void {
|
||||
for (const [type, sublabel] of Object.entries(overrides)) {
|
||||
if (PAYMENT_TYPE_META[type]) {
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { OrderError } from '@/lib/order/service';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
/** 统一处理 OrderError 和未知错误 */
|
||||
export function handleApiError(error: unknown, fallbackMessage: string): NextResponse {
|
||||
export function handleApiError(error: unknown, fallbackMessage: string, request?: NextRequest): NextResponse {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error(`${fallbackMessage}:`, error);
|
||||
return NextResponse.json({ error: fallbackMessage }, { status: 500 });
|
||||
const locale = resolveLocale(request?.nextUrl.searchParams.get('lang'));
|
||||
const resolvedFallback = locale === 'en' ? translateFallbackMessage(fallbackMessage) : fallbackMessage;
|
||||
console.error(`${resolvedFallback}:`, error);
|
||||
return NextResponse.json({ error: resolvedFallback }, { status: 500 });
|
||||
}
|
||||
|
||||
function translateFallbackMessage(message: string): string {
|
||||
switch (message) {
|
||||
case '退款失败':
|
||||
return 'Refund failed';
|
||||
case '重试充值失败':
|
||||
return 'Recharge retry failed';
|
||||
case '取消订单失败':
|
||||
return 'Cancel order failed';
|
||||
case '获取用户信息失败':
|
||||
return 'Failed to fetch user info';
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 NextRequest 提取 headers 为普通对象 */
|
||||
|
||||
Reference in New Issue
Block a user