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:
erio
2026-03-09 18:33:57 +08:00
parent 5cebe85079
commit 2492031e13
35 changed files with 1997 additions and 579 deletions

View File

@@ -1,19 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { resolveLocale } from '@/lib/locale';
import { adminCancelOrder } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
const outcome = await adminCancelOrder(id);
const outcome = await adminCancelOrder(id, locale);
if (outcome === 'already_paid') {
return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' });
return NextResponse.json({
success: true,
status: 'PAID',
message: locale === 'en' ? 'Order has already been paid' : '订单已支付完成',
});
}
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, '取消订单失败');
return handleApiError(error, locale === 'en' ? 'Cancel order failed' : '取消订单失败', request);
}
}

View File

@@ -1,16 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { resolveLocale } from '@/lib/locale';
import { retryRecharge } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
await retryRecharge(id);
await retryRecharge(id, locale);
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, '重试充值失败');
return handleApiError(error, locale === 'en' ? 'Recharge retry failed' : '重试充值失败', request);
}
}

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { resolveLocale } from '@/lib/locale';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const { id } = await params;
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
const order = await prisma.order.findUnique({
where: { id },
@@ -17,7 +19,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
});
if (!order) {
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'Order not found' : '订单不存在' }, { status: 404 });
}
return NextResponse.json({

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { processRefund } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
import { resolveLocale } from '@/lib/locale';
const refundSchema = z.object({
order_id: z.string().min(1),
@@ -11,24 +12,30 @@ const refundSchema = z.object({
});
export async function POST(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const body = await request.json();
const parsed = refundSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
return NextResponse.json(
{ error: locale === 'en' ? 'Invalid parameters' : '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
const result = await processRefund({
orderId: parsed.data.order_id,
reason: parsed.data.reason,
force: parsed.data.force,
locale,
});
return NextResponse.json(result);
} catch (error) {
return handleApiError(error, '退款失败');
return handleApiError(error, locale === 'en' ? 'Refund failed' : '退款失败', request);
}
}

View File

@@ -3,17 +3,19 @@ import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
import { resolveLocale } from '@/lib/locale';
export async function GET(request: NextRequest) {
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
const userId = Number(request.nextUrl.searchParams.get('user_id'));
if (!userId || isNaN(userId) || userId <= 0) {
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 });
return NextResponse.json({ error: locale === 'en' ? 'Invalid user ID' : '无效的用户 ID' }, { status: 400 });
}
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json({ error: '缺少 token 参数' }, { status: 401 });
return NextResponse.json({ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' }, { status: 401 });
}
try {
@@ -22,11 +24,11 @@ export async function GET(request: NextRequest) {
try {
tokenUser = await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
return NextResponse.json({ error: locale === 'en' ? 'Invalid token' : '无效的 token' }, { status: 401 });
}
if (tokenUser.id !== userId) {
return NextResponse.json({ error: '无权访问该用户信息' }, { status: 403 });
return NextResponse.json({ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' }, { status: 403 });
}
const env = getEnv();
@@ -40,17 +42,16 @@ export async function GET(request: NextRequest) {
// 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabelprovider 名)
const labelCount = new Map<string, string[]>();
for (const type of enabledTypes) {
const meta = PAYMENT_TYPE_META[type];
if (!meta) continue;
const types = labelCount.get(meta.label) || [];
const { channel } = getPaymentDisplayInfo(type, locale);
const types = labelCount.get(channel) || [];
types.push(type);
labelCount.set(meta.label, types);
labelCount.set(channel, types);
}
for (const [, types] of labelCount) {
if (types.length > 1) {
for (const type of types) {
const meta = PAYMENT_TYPE_META[type];
if (meta) sublabelOverrides[type] = meta.provider;
const { provider } = getPaymentDisplayInfo(type, locale);
if (provider) sublabelOverrides[type] = provider;
}
}
}
@@ -85,9 +86,9 @@ export async function GET(request: NextRequest) {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === 'USER_NOT_FOUND') {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
}
console.error('Get user error:', error);
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 });
return NextResponse.json({ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' }, { status: 500 });
}
}