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

@@ -0,0 +1,752 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ============================================================
// Mock: EasyPay
// ============================================================
const mockEasyPayCreatePayment = vi.fn();
vi.mock('@/lib/easy-pay/client', () => ({
createPayment: (...args: unknown[]) => mockEasyPayCreatePayment(...args),
queryOrder: vi.fn(),
refund: vi.fn(),
}));
vi.mock('@/lib/easy-pay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Alipay
// ============================================================
const mockAlipayPageExecute = vi.fn();
vi.mock('@/lib/alipay/client', () => ({
pageExecute: (...args: unknown[]) => mockAlipayPageExecute(...args),
execute: vi.fn(),
}));
vi.mock('@/lib/alipay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Wxpay
// ============================================================
const mockWxpayCreatePcOrder = vi.fn();
const mockWxpayCreateH5Order = vi.fn();
vi.mock('@/lib/wxpay/client', () => ({
createPcOrder: (...args: unknown[]) => mockWxpayCreatePcOrder(...args),
createH5Order: (...args: unknown[]) => mockWxpayCreateH5Order(...args),
queryOrder: vi.fn(),
closeOrder: vi.fn(),
createRefund: vi.fn(),
decipherNotify: vi.fn(),
verifyNotifySign: vi.fn(),
}));
// ============================================================
// Mock: Config (shared by all providers)
// ============================================================
vi.mock('@/lib/config', () => ({
getEnv: () => ({
// EasyPay
EASY_PAY_PID: 'test-pid',
EASY_PAY_PKEY: 'test-pkey',
EASY_PAY_API_BASE: 'https://easypay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easypay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
// Alipay
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
// Wxpay
WXPAY_APP_ID: 'wx-test-app-id',
WXPAY_MCH_ID: 'wx-test-mch-id',
WXPAY_PRIVATE_KEY: 'test-private-key',
WXPAY_API_V3_KEY: 'test-api-v3-key',
WXPAY_PUBLIC_KEY: 'test-public-key',
WXPAY_PUBLIC_KEY_ID: 'test-public-key-id',
WXPAY_CERT_SERIAL: 'test-cert-serial',
WXPAY_NOTIFY_URL: 'https://pay.example.com/api/wxpay/notify',
// General
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
}),
}));
// ============================================================
// Imports (must come after mocks)
// ============================================================
import { EasyPayProvider } from '@/lib/easy-pay/provider';
import { AlipayProvider } from '@/lib/alipay/provider';
import { WxpayProvider } from '@/lib/wxpay/provider';
import { isStripeType } from '@/lib/pay-utils';
import { REDIRECT_PAYMENT_TYPES } from '@/lib/constants';
import type { CreatePaymentRequest } from '@/lib/payment/types';
// ============================================================
// Helper: simulate shouldAutoRedirect logic from PaymentQRCode
// ============================================================
function shouldAutoRedirect(opts: {
expired: boolean;
paymentType?: string;
payUrl?: string | null;
qrCode?: string | null;
isMobile: boolean;
}): boolean {
return (
!opts.expired &&
!isStripeType(opts.paymentType) &&
!!opts.payUrl &&
(opts.isMobile || !opts.qrCode)
);
}
// ============================================================
// Tests
// ============================================================
describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ----------------------------------------------------------
// EasyPay Provider
// ----------------------------------------------------------
describe('EasyPayProvider', () => {
let provider: EasyPayProvider;
beforeEach(() => {
provider = new EasyPayProvider();
});
it('PC: createPayment returns both payUrl and qrCode', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-001',
payurl: 'https://easypay.example.com/pay/EP-001',
qrcode: 'https://qr.alipay.com/fkx12345',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-001',
amount: 50,
paymentType: 'alipay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-001');
expect(result.qrCode).toBe('https://qr.alipay.com/fkx12345');
expect(result.payUrl).toBe('https://easypay.example.com/pay/EP-001');
// PC + has qrCode + has payUrl => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: createPayment returns payUrl for redirect', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-002',
payurl: 'https://easypay.example.com/pay/EP-002',
qrcode: 'https://qr.alipay.com/fkx67890',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-002',
amount: 100,
paymentType: 'wxpay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-002');
expect(result.payUrl).toBeDefined();
// Mobile + has payUrl => shouldAutoRedirect = true (redirect)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('EasyPay does not use isMobile flag itself (delegates to frontend)', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-003',
payurl: 'https://easypay.example.com/pay/EP-003',
qrcode: 'weixin://wxpay/bizpayurl?pr=xxx',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-003',
amount: 10,
paymentType: 'alipay',
subject: 'Test',
clientIp: '1.2.3.4',
isMobile: true,
};
await provider.createPayment(request);
// EasyPay client is called the same way regardless of isMobile
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
expect.objectContaining({
outTradeNo: 'order-ep-003',
paymentType: 'alipay',
}),
);
// No isMobile parameter forwarded to the underlying client
const callArgs = mockEasyPayCreatePayment.mock.calls[0][0];
expect(callArgs).not.toHaveProperty('isMobile');
});
});
// ----------------------------------------------------------
// Alipay Provider
// ----------------------------------------------------------
describe('AlipayProvider', () => {
let provider: AlipayProvider;
beforeEach(() => {
provider = new AlipayProvider();
});
it('PC: uses alipay.trade.page.pay, returns payUrl only (no qrCode)', async () => {
mockAlipayPageExecute.mockReturnValue(
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=xxx',
);
const request: CreatePaymentRequest = {
orderId: 'order-ali-001',
amount: 100,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-001');
expect(result.payUrl).toContain('alipay.trade.page.pay');
expect(result.qrCode).toBeUndefined();
// Verify pageExecute was called with PC method
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({
product_code: 'FAST_INSTANT_TRADE_PAY',
}),
expect.objectContaining({
method: 'alipay.trade.page.pay',
}),
);
// PC + payUrl only (no qrCode) => shouldAutoRedirect = true (redirect to Alipay cashier page)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(true);
});
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
mockAlipayPageExecute.mockReturnValue(
'https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=yyy',
);
const request: CreatePaymentRequest = {
orderId: 'order-ali-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-002');
expect(result.payUrl).toContain('alipay.trade.wap.pay');
expect(result.qrCode).toBeUndefined();
// Verify pageExecute was called with H5 method
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({
product_code: 'QUICK_WAP_WAY',
}),
expect.objectContaining({
method: 'alipay.trade.wap.pay',
}),
);
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: falls back to PC page.pay when wap.pay throws', async () => {
// First call (wap.pay) throws, second call (page.pay) succeeds
mockAlipayPageExecute
.mockImplementationOnce(() => {
throw new Error('WAP pay not available');
})
.mockReturnValueOnce(
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=fallback',
);
const request: CreatePaymentRequest = {
orderId: 'order-ali-003',
amount: 30,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.payUrl).toContain('alipay.trade.page.pay');
// pageExecute was called twice: first wap.pay (failed), then page.pay
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(2);
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ product_code: 'FAST_INSTANT_TRADE_PAY' }),
expect.objectContaining({ method: 'alipay.trade.page.pay' }),
);
});
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
});
// ----------------------------------------------------------
// Wxpay Provider
// ----------------------------------------------------------
describe('WxpayProvider', () => {
let provider: WxpayProvider;
beforeEach(() => {
provider = new WxpayProvider();
});
it('PC: uses Native order, returns qrCode (no payUrl)', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=abc123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-001',
amount: 100,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-001');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=abc123');
expect(result.payUrl).toBeUndefined();
// createPcOrder was called, not createH5Order
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
// PC + qrCode (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
mockWxpayCreateH5Order.mockResolvedValue(
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123',
);
const request: CreatePaymentRequest = {
orderId: 'order-wx-002',
amount: 50,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '2.3.4.5',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-002');
expect(result.payUrl).toContain('tenpay.com');
expect(result.qrCode).toBeUndefined();
// createH5Order was called, not createPcOrder
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: falls back to Native qrCode when H5 returns NO_AUTH', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [NO_AUTH] not authorized'));
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=fallback123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-003',
amount: 30,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '3.4.5.6',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-003');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=fallback123');
expect(result.payUrl).toBeUndefined();
// Both were called: H5 failed, then Native succeeded
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
// Mobile + qrCode only (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(false);
});
it('Mobile: re-throws non-NO_AUTH errors from H5', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [SYSTEMERROR] system error'));
const request: CreatePaymentRequest = {
orderId: 'order-wx-004',
amount: 20,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '4.5.6.7',
isMobile: true,
};
await expect(provider.createPayment(request)).rejects.toThrow('SYSTEMERROR');
// Should not fall back to PC order
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
});
it('Mobile without clientIp: falls back to Native qrCode directly', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=noip');
const request: CreatePaymentRequest = {
orderId: 'order-wx-005',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
// No clientIp
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=noip');
expect(result.payUrl).toBeUndefined();
// H5 was never attempted since clientIp is missing
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
});
it('uses request.notifyUrl as fallback when WXPAY_NOTIFY_URL is set', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=withnotify');
const request: CreatePaymentRequest = {
orderId: 'order-wx-006',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test',
isMobile: false,
notifyUrl: 'https://pay.example.com/api/wxpay/notify-alt',
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=withnotify');
// WXPAY_NOTIFY_URL from env takes priority over request.notifyUrl
expect(mockWxpayCreatePcOrder).toHaveBeenCalledWith(
expect.objectContaining({
notify_url: 'https://pay.example.com/api/wxpay/notify',
}),
);
});
});
// ----------------------------------------------------------
// shouldAutoRedirect logic (PaymentQRCode component)
// ----------------------------------------------------------
describe('shouldAutoRedirect (PaymentQRCode logic)', () => {
it('PC + qrCode + payUrl => false (show QR code, do not redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: false,
}),
).toBe(false);
});
it('PC + payUrl only (no qrCode) => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: undefined,
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + empty qrCode string => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: '',
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + null qrCode => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: null,
isMobile: false,
}),
).toBe(true);
});
it('Mobile + payUrl => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: 'https://wx.tenpay.com/...',
qrCode: undefined,
isMobile: true,
}),
).toBe(true);
});
it('Mobile + payUrl + qrCode => true (redirect, mobile always prefers payUrl)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://easypay.example.com/pay/xxx',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: true,
}),
).toBe(true);
});
it('Mobile + qrCode only (no payUrl) => false (show QR code)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: undefined,
qrCode: 'weixin://wxpay/bizpayurl?pr=xxx',
isMobile: true,
}),
).toBe(false);
});
it('Stripe => false (never redirect, uses Payment Element)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: false,
}),
).toBe(false);
});
it('Stripe on mobile => false (still no redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Expired order => false (never redirect expired orders)', () => {
expect(
shouldAutoRedirect({
expired: true,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('No payUrl at all => false (nothing to redirect to)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: undefined,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Empty payUrl string => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: '',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Null payUrl => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: null,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
});
// ----------------------------------------------------------
// Utility function tests
// ----------------------------------------------------------
describe('isStripeType', () => {
it('returns true for "stripe"', () => {
expect(isStripeType('stripe')).toBe(true);
});
it('returns true for stripe-prefixed types', () => {
expect(isStripeType('stripe_card')).toBe(true);
});
it('returns false for alipay', () => {
expect(isStripeType('alipay')).toBe(false);
});
it('returns false for wxpay', () => {
expect(isStripeType('wxpay')).toBe(false);
});
it('returns false for undefined', () => {
expect(isStripeType(undefined)).toBe(false);
});
it('returns false for null', () => {
expect(isStripeType(null)).toBe(false);
});
});
describe('REDIRECT_PAYMENT_TYPES', () => {
it('includes alipay_direct', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
it('does not include alipay (easypay version)', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay')).toBe(false);
});
it('does not include wxpay types', () => {
expect(REDIRECT_PAYMENT_TYPES.has('wxpay')).toBe(false);
expect(REDIRECT_PAYMENT_TYPES.has('wxpay_direct')).toBe(false);
});
it('does not include stripe', () => {
expect(REDIRECT_PAYMENT_TYPES.has('stripe')).toBe(false);
});
});
});