Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f621713c3 | ||
|
|
abff49222b | ||
|
|
1cb82d8fd7 | ||
|
|
d6973256a7 | ||
|
|
8b10bc3bd5 | ||
|
|
2492031e13 | ||
|
|
5cebe85079 | ||
|
|
5fb16f0ccf | ||
|
|
43e116a4f2 | ||
|
|
e1788437c9 | ||
|
|
2df040e9b9 | ||
|
|
8a465ae625 | ||
|
|
1d19fc86ee | ||
|
|
f50a180ec4 | ||
|
|
698df1ee47 | ||
|
|
37096de05c | ||
|
|
d43b04cb5c | ||
|
|
ac0772b0f4 | ||
|
|
4b013370b9 |
@@ -11,7 +11,13 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm prisma generate
|
||||
RUN pnpm build
|
||||
# 构建时注入 dummy 环境变量,避免 Next.js 预渲染 API 路由时 getEnv() 报错
|
||||
RUN DATABASE_URL="postgresql://x:x@localhost/x" \
|
||||
SUB2API_BASE_URL="https://localhost" \
|
||||
SUB2API_ADMIN_API_KEY="build-dummy" \
|
||||
ADMIN_TOKEN="build-dummy" \
|
||||
NEXT_PUBLIC_APP_URL="https://localhost" \
|
||||
pnpm build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -133,7 +133,7 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
|
||||
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API base URL |
|
||||
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay/result` |
|
||||
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
||||
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
|
||||
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay/result` |
|
||||
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
||||
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ model Order {
|
||||
@@index([expiresAt])
|
||||
@@index([createdAt])
|
||||
@@index([paidAt])
|
||||
@@index([paymentType, paidAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
|
||||
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockVerifyAdminToken = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin-auth', () => ({
|
||||
verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/api/orders/[id]/route';
|
||||
import { createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(orderId: string, accessToken?: string) {
|
||||
const url = new URL(`https://pay.example.com/api/orders/${orderId}`);
|
||||
if (accessToken) {
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
}
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/orders/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVerifyAdminToken.mockResolvedValue(false);
|
||||
mockFindUnique.mockResolvedValue({
|
||||
id: 'order-001',
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date('2026-03-10T00:00:00.000Z'),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects requests without access token', async () => {
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns order status with valid access token', async () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
const response = await GET(createRequest('order-001', token), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.id).toBe('order-001');
|
||||
expect(data.paymentSuccess).toBe(false);
|
||||
});
|
||||
|
||||
it('allows admin-authenticated access as fallback', async () => {
|
||||
mockVerifyAdminToken.mockResolvedValue(true);
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
245
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
245
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockBuildAlipayPaymentUrl = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/alipay/provider', () => ({
|
||||
buildAlipayPaymentUrl: (...args: unknown[]) => mockBuildAlipayPaymentUrl(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/pay/[orderId]/route';
|
||||
import { buildOrderResultUrl } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') {
|
||||
return new NextRequest('https://pay.example.com/pay/order-001', {
|
||||
headers: { 'user-agent': userAgent },
|
||||
});
|
||||
}
|
||||
|
||||
function createPendingOrder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'order-001',
|
||||
amount: 88,
|
||||
payAmount: 100.5,
|
||||
paymentType: 'alipay_direct',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GET /pay/[orderId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
||||
});
|
||||
|
||||
it('returns 404 error page when order does not exist', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'missing-order' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(html).toContain('订单不存在');
|
||||
expect(html).toContain('missing-order');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-alipay orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
paymentType: 'wxpay_direct',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(html).toContain('支付方式不匹配');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns success status page for completed orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: new Date('2026-03-09T10:00:03Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('充值成功');
|
||||
expect(html).toContain('余额已到账');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns paid-but-recharge-failed status page for failed paid orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('支付成功');
|
||||
expect(html).toContain('余额充值暂未完成');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expired status page when order is timed out', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('订单超时');
|
||||
expect(html).toContain('订单已超时');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds desktop redirect page with service-generated alipay url and no manual pay button', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder());
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
expect(html).toContain('http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(html).toContain('查看订单结果');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 100.5,
|
||||
subject: 'Sub2API Balance Recharge 100.50 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mobile redirect page with wap alipay url', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
payAmount: null,
|
||||
amount: 88,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
createRequest('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 88,
|
||||
subject: 'Sub2API Balance Recharge 88.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits returnUrl for Alipay app requests to avoid extra close step', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder({ payAmount: 66 }));
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 AlipayClient/10.5.90',
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
expect(html).toContain('window.location.replace(payUrl)');
|
||||
expect(html).toContain('<noscript><meta http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 66,
|
||||
subject: 'Sub2API Balance Recharge 66.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: null,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/__tests__/lib/alipay/client.test.ts
Normal file
97
src/__tests__/lib/alipay/client.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
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',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockGenerateSign } = vi.hoisted(() => ({
|
||||
mockGenerateSign: vi.fn(() => 'signed-value'),
|
||||
}));
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
}));
|
||||
|
||||
import { execute, pageExecute } from '@/lib/alipay/client';
|
||||
|
||||
describe('alipay client helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('pageExecute includes notify_url and return_url by default', () => {
|
||||
const url = new URL(
|
||||
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
|
||||
);
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
|
||||
expect(url.searchParams.get('sign')).toBe('signed-value');
|
||||
});
|
||||
|
||||
it('pageExecute omits return_url when explicitly disabled', () => {
|
||||
const url = new URL(
|
||||
pageExecute(
|
||||
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
|
||||
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
|
||||
expect(url.searchParams.get('return_url')).toBeNull();
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
});
|
||||
|
||||
it('execute posts form data and returns the named response payload', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
},
|
||||
sign: 'server-sign',
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
|
||||
|
||||
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
expect(String(init.body)).toContain('method=alipay.trade.query');
|
||||
});
|
||||
|
||||
it('execute throws when alipay response code is not successful', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '40004',
|
||||
msg: 'Business Failed',
|
||||
sub_code: 'ACQ.TRADE_NOT_EXIST',
|
||||
sub_msg: 'trade not exist',
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
|
||||
'[ACQ.TRADE_NOT_EXIST] trade not exist',
|
||||
);
|
||||
});
|
||||
});
|
||||
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
|
||||
|
||||
describe('Alipay codec', () => {
|
||||
it('should normalize plus signs in notify sign parameter', () => {
|
||||
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(params.sign).toBe('abc+def');
|
||||
expect(params.trade_no).toBe('1');
|
||||
});
|
||||
|
||||
it('should decode payload charset from content-type header', () => {
|
||||
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body, {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
|
||||
});
|
||||
|
||||
it('should fallback to body charset hint when header is missing', () => {
|
||||
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body);
|
||||
|
||||
expect(decoded).toContain('trade_no=202603090001');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ vi.mock('@/lib/config', () => ({
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +26,7 @@ vi.mock('@/lib/alipay/sign', () => ({
|
||||
verifySign: (...args: unknown[]) => mockVerifySign(...args),
|
||||
}));
|
||||
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('AlipayProvider', () => {
|
||||
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should call pageExecute and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
it('should return service short link as desktop qrCode', async () => {
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
paymentType: 'alipay',
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
expect(mockPageExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should build short link from app url', () => {
|
||||
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
|
||||
});
|
||||
|
||||
it('should call pageExecute for mobile and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-002');
|
||||
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
out_trade_no: 'order-001',
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: '100.00',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
out_trade_no: 'order-002',
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
total_amount: '50.00',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
},
|
||||
expect.objectContaining({}),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||
);
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +165,15 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.queryOrder('order-004');
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
const result = await provider.queryOrder('order-005');
|
||||
expect(result.tradeNo).toBe('order-005');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
@@ -174,6 +208,7 @@ describe('AlipayProvider', () => {
|
||||
total_amount: '50.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
@@ -187,88 +222,108 @@ describe('AlipayProvider', () => {
|
||||
trade_no: '2026030500003',
|
||||
out_trade_no: 'order-003',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '30.00',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
it('should reject unsupported sign_type', async () => {
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500004',
|
||||
out_trade_no: 'order-004',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
|
||||
});
|
||||
|
||||
it('should reject invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500005',
|
||||
out_trade_no: 'order-005',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'bad_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||
'Alipay notification signature verification failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject app_id mismatch', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500006',
|
||||
out_trade_no: 'order-006',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000009999',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should call alipay.trade.refund and return success', async () => {
|
||||
it('should request refund and map success status', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
trade_no: 'refund-trade-no',
|
||||
fund_change: 'Y',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: '2026030500001',
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
reason: 'customer request',
|
||||
tradeNo: 'trade-no',
|
||||
orderId: 'order-refund',
|
||||
amount: 12.34,
|
||||
reason: 'test refund',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('2026030500001');
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||
out_trade_no: 'order-001',
|
||||
refund_amount: '100.00',
|
||||
refund_reason: 'customer request',
|
||||
out_trade_no: 'order-refund',
|
||||
refund_amount: '12.34',
|
||||
refund_reason: 'test refund',
|
||||
out_request_no: 'order-refund-refund',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pending when fund_change is N', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
fund_change: 'N',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: '2026030500002',
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
});
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should call alipay.trade.close', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
});
|
||||
it('should close payment by out_trade_no', async () => {
|
||||
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
|
||||
|
||||
await provider.cancelPayment('order-close');
|
||||
|
||||
await provider.cancelPayment('order-001');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-001',
|
||||
out_trade_no: 'order-close',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(typeof sign).toBe('string');
|
||||
// base64 格式
|
||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -44,13 +43,12 @@ describe('Alipay RSA2 Sign', () => {
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign field but keep sign_type', () => {
|
||||
it('should filter out sign field but keep sign_type in request signing', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
|
||||
// sign_type should be included in signing
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
||||
const sign3 = generateSign(paramsWithSignType, privateKey);
|
||||
expect(sign3).not.toBe(sign1);
|
||||
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with private key using literal \\n escapes', () => {
|
||||
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, escapedPrivateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with public key using literal \\n escapes', () => {
|
||||
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, escapedPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with CRLF-formatted PEM keys', () => {
|
||||
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
|
||||
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
|
||||
const sign = generateSign(testParams, crlfPrivateKey);
|
||||
const valid = verifySign(testParams, crlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with literal \\r\\n escapes in PEM keys', () => {
|
||||
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
|
||||
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
|
||||
const sign = generateSign(testParams, escapedCrlfPrivateKey);
|
||||
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
38
src/__tests__/lib/order/status-access.test.ts
Normal file
38
src/__tests__/lib/order/status-access.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
ORDER_STATUS_ACCESS_QUERY_KEY,
|
||||
buildOrderResultUrl,
|
||||
createOrderStatusAccessToken,
|
||||
verifyOrderStatusAccessToken,
|
||||
} from '@/lib/order/status-access';
|
||||
|
||||
describe('order status access token helpers', () => {
|
||||
it('creates and verifies a token bound to the order id', () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-001', token)).toBe(true);
|
||||
expect(verifyOrderStatusAccessToken('order-002', token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing or malformed tokens', () => {
|
||||
expect(verifyOrderStatusAccessToken('order-001', null)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', undefined)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', 'short')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a result url with order id and access token', () => {
|
||||
const url = new URL(buildOrderResultUrl('https://pay.example.com', 'order-009'));
|
||||
expect(url.origin + url.pathname).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('order_id')).toBe('order-009');
|
||||
const token = url.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-009', token)).toBe(true);
|
||||
});
|
||||
});
|
||||
66
src/__tests__/lib/order/status.test.ts
Normal file
66
src/__tests__/lib/order/status.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { deriveOrderState, getOrderDisplayState } from '@/lib/order/status';
|
||||
|
||||
describe('order status helpers', () => {
|
||||
it('derives paid_pending after successful payment but before recharge completion', () => {
|
||||
const state = deriveOrderState({
|
||||
status: ORDER_STATUS.PAID,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'paid_pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps recharge failure after payment to a payment-success display state', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付成功');
|
||||
expect(display.message).toContain('自动重试');
|
||||
});
|
||||
|
||||
it('maps failed order before payment success to failed display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付失败');
|
||||
expect(display.message).toContain('重新发起支付');
|
||||
});
|
||||
|
||||
it('maps completed order to success display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: true,
|
||||
rechargeStatus: 'success',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('充值成功');
|
||||
expect(display.icon).toBe('✓');
|
||||
});
|
||||
|
||||
it('maps pending order to waiting-for-payment display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.PENDING,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'not_paid',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('等待支付');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: mockUser }),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const user = await getUser(1);
|
||||
expect(user.username).toBe('testuser');
|
||||
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||
});
|
||||
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||
expect(result.code).toBe('s2p_test123');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
||||
const headers = fetchCall[1].headers;
|
||||
const headers = fetchCall[1].headers as Record<string, string>;
|
||||
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
|
||||
});
|
||||
|
||||
it('createAndRedeem should retry once on timeout', async () => {
|
||||
const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
redeem_code: {
|
||||
id: 2,
|
||||
code: 's2p_retry',
|
||||
type: 'balance',
|
||||
value: 88,
|
||||
status: 'used',
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_retry', 88, 1, 'retry notes');
|
||||
|
||||
expect(result.code).toBe('s2p_retry');
|
||||
expect((fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('subtractBalance should send subtract request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) as typeof fetch;
|
||||
|
||||
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
const body = JSON.parse(fetchCall[1].body as string);
|
||||
expect(body.operation).toBe('subtract');
|
||||
expect(body.amount).toBe(50);
|
||||
});
|
||||
|
||||
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getBizDayStartUTC, getNextBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||
|
||||
describe('biz-day helpers', () => {
|
||||
it('formats business date in Asia/Shanghai timezone', () => {
|
||||
expect(toBizDateStr(new Date('2026-03-09T15:59:59.000Z'))).toBe('2026-03-09');
|
||||
expect(toBizDateStr(new Date('2026-03-09T16:00:00.000Z'))).toBe('2026-03-10');
|
||||
});
|
||||
|
||||
it('returns business day start in UTC', () => {
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T15:59:59.000Z')).toISOString()).toBe('2026-03-08T16:00:00.000Z');
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T16:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns next business day start in UTC', () => {
|
||||
expect(getNextBizDayStartUTC(new Date('2026-03-09T12:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
});
|
||||
717
src/__tests__/payment-flow.test.ts
Normal file
717
src/__tests__/payment-flow.test.ts
Normal file
@@ -0,0 +1,717 @@
|
||||
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: returns service short-link payUrl and qrCode', async () => {
|
||||
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).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
shouldAutoRedirect({
|
||||
expired: false,
|
||||
paymentType: 'alipay_direct',
|
||||
payUrl: result.payUrl,
|
||||
qrCode: result.qrCode,
|
||||
isMobile: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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: surfaces wap.pay creation errors', async () => {
|
||||
mockAlipayPageExecute.mockImplementationOnce(() => {
|
||||
throw new Error('WAP pay not available');
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-ali-003',
|
||||
amount: 30,
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Test Recharge',
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import DashboardStats from '@/components/admin/DashboardStats';
|
||||
import DailyChart from '@/components/admin/DailyChart';
|
||||
import Leaderboard from '@/components/admin/Leaderboard';
|
||||
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
|
||||
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
@@ -34,9 +35,39 @@ function DashboardContent() {
|
||||
const token = searchParams.get('token');
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
missingToken: 'Missing admin token',
|
||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||
invalidToken: 'Invalid admin token',
|
||||
requestFailed: 'Request failed',
|
||||
loadFailed: 'Failed to load data',
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Recharge order analytics and insights',
|
||||
daySuffix: 'd',
|
||||
orders: 'Order Management',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading...',
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||
invalidToken: '管理员凭证无效',
|
||||
requestFailed: '请求失败',
|
||||
loadFailed: '加载数据失败',
|
||||
title: '数据概览',
|
||||
subtitle: '充值订单统计与分析',
|
||||
daySuffix: '天',
|
||||
orders: '订单管理',
|
||||
refresh: '刷新',
|
||||
loading: '加载中...',
|
||||
};
|
||||
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,14 +81,14 @@ function DashboardContent() {
|
||||
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError('管理员凭证无效');
|
||||
setError(text.invalidToken);
|
||||
return;
|
||||
}
|
||||
throw new Error('请求失败');
|
||||
throw new Error(text.requestFailed);
|
||||
}
|
||||
setData(await res.json());
|
||||
} catch {
|
||||
setError('加载数据失败');
|
||||
setError(text.loadFailed);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -71,8 +102,8 @@ function DashboardContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少管理员凭证</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问管理页面</p>
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -80,6 +111,7 @@ function DashboardContent() {
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
navParams.set('token', token);
|
||||
if (locale === 'en') navParams.set('lang', 'en');
|
||||
if (theme === 'dark') navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
@@ -100,20 +132,22 @@ function DashboardContent() {
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth="full"
|
||||
title="数据概览"
|
||||
subtitle="充值订单统计与分析"
|
||||
title={text.title}
|
||||
subtitle={text.subtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
<>
|
||||
{DAYS_OPTIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||
{d}天
|
||||
{d}
|
||||
{text.daySuffix}
|
||||
</button>
|
||||
))}
|
||||
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||
订单管理
|
||||
{text.orders}
|
||||
</a>
|
||||
<button type="button" onClick={fetchData} className={btnBase}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -130,14 +164,14 @@ function DashboardContent() {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<DashboardStats summary={data.summary} dark={isDark} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} />
|
||||
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} />
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -145,15 +179,20 @@ function DashboardContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<DashboardPageFallback />}>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import OrderTable from '@/components/admin/OrderTable';
|
||||
import OrderDetail from '@/components/admin/OrderDetail';
|
||||
import PaginationBar from '@/components/PaginationBar';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||
|
||||
interface AdminOrder {
|
||||
id: string;
|
||||
@@ -47,9 +48,73 @@ function AdminContent() {
|
||||
const token = searchParams.get('token');
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
missingToken: 'Missing admin token',
|
||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||
invalidToken: 'Invalid admin token',
|
||||
requestFailed: 'Request failed',
|
||||
loadOrdersFailed: 'Failed to load orders',
|
||||
retryConfirm: 'Retry recharge for this order?',
|
||||
retryFailed: 'Retry failed',
|
||||
retryRequestFailed: 'Retry request failed',
|
||||
cancelConfirm: 'Cancel this order?',
|
||||
cancelFailed: 'Cancel failed',
|
||||
cancelRequestFailed: 'Cancel request failed',
|
||||
loadDetailFailed: 'Failed to load order details',
|
||||
title: 'Order Management',
|
||||
subtitle: 'View and manage all recharge orders',
|
||||
dashboard: 'Dashboard',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading...',
|
||||
statuses: {
|
||||
'': 'All',
|
||||
PENDING: 'Pending',
|
||||
PAID: 'Paid',
|
||||
RECHARGING: 'Recharging',
|
||||
COMPLETED: 'Completed',
|
||||
EXPIRED: 'Expired',
|
||||
CANCELLED: 'Cancelled',
|
||||
FAILED: 'Recharge failed',
|
||||
REFUNDED: 'Refunded',
|
||||
},
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||
invalidToken: '管理员凭证无效',
|
||||
requestFailed: '请求失败',
|
||||
loadOrdersFailed: '加载订单列表失败',
|
||||
retryConfirm: '确认重试充值?',
|
||||
retryFailed: '重试失败',
|
||||
retryRequestFailed: '重试请求失败',
|
||||
cancelConfirm: '确认取消该订单?',
|
||||
cancelFailed: '取消失败',
|
||||
cancelRequestFailed: '取消请求失败',
|
||||
loadDetailFailed: '加载订单详情失败',
|
||||
title: '订单管理',
|
||||
subtitle: '查看和管理所有充值订单',
|
||||
dashboard: '数据概览',
|
||||
refresh: '刷新',
|
||||
loading: '加载中...',
|
||||
statuses: {
|
||||
'': '全部',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
},
|
||||
};
|
||||
|
||||
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -71,18 +136,18 @@ function AdminContent() {
|
||||
const res = await fetch(`/api/admin/orders?${params}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError('管理员凭证无效');
|
||||
setError(text.invalidToken);
|
||||
return;
|
||||
}
|
||||
throw new Error('请求失败');
|
||||
throw new Error(text.requestFailed);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setOrders(data.orders);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.total_pages);
|
||||
} catch (e) {
|
||||
setError('加载订单列表失败');
|
||||
} catch {
|
||||
setError(text.loadOrdersFailed);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -96,15 +161,15 @@ function AdminContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少管理员凭证</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问管理页面</p>
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetry = async (orderId: string) => {
|
||||
if (!confirm('确认重试充值?')) return;
|
||||
if (!confirm(text.retryConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
|
||||
method: 'POST',
|
||||
@@ -113,15 +178,15 @@ function AdminContent() {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || '重试失败');
|
||||
setError(data.error || text.retryFailed);
|
||||
}
|
||||
} catch {
|
||||
setError('重试请求失败');
|
||||
setError(text.retryRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (orderId: string) => {
|
||||
if (!confirm('确认取消该订单?')) return;
|
||||
if (!confirm(text.cancelConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
|
||||
method: 'POST',
|
||||
@@ -130,10 +195,10 @@ function AdminContent() {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || '取消失败');
|
||||
setError(data.error || text.cancelFailed);
|
||||
}
|
||||
} catch {
|
||||
setError('取消请求失败');
|
||||
setError(text.cancelRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,25 +210,16 @@ function AdminContent() {
|
||||
setDetailOrder(data);
|
||||
}
|
||||
} catch {
|
||||
setError('加载订单详情失败');
|
||||
setError(text.loadDetailFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
|
||||
const statusLabels: Record<string, string> = {
|
||||
'': '全部',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
const statusLabels: Record<string, string> = text.statuses;
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
if (token) navParams.set('token', token);
|
||||
if (locale === 'en') navParams.set('lang', 'en');
|
||||
if (isDark) navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
@@ -179,15 +235,16 @@ function AdminContent() {
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth="full"
|
||||
title="订单管理"
|
||||
subtitle="查看和管理所有充值订单"
|
||||
title={text.title}
|
||||
subtitle={text.subtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
<>
|
||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||
数据概览
|
||||
{text.dashboard}
|
||||
</a>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -236,7 +293,7 @@ function AdminContent() {
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||
) : (
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
@@ -244,6 +301,7 @@ function AdminContent() {
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,24 +317,32 @@ function AdminContent() {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Order Detail */}
|
||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} />}
|
||||
{detailOrder && (
|
||||
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
|
||||
)}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<AdminPageFallback />}>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -3,23 +3,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/** 业务时区偏移(东八区,+8 小时) */
|
||||
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const BIZ_TZ_NAME = 'Asia/Shanghai';
|
||||
|
||||
/** 获取业务时区下的 YYYY-MM-DD */
|
||||
function toBizDateStr(d: Date): string {
|
||||
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
|
||||
return local.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
|
||||
function getBizDayStartUTC(d: Date): Date {
|
||||
const bizDateStr = toBizDateStr(d);
|
||||
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
|
||||
return new Date(`${bizDateStr}T00:00:00+08:00`);
|
||||
}
|
||||
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
||||
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) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Admin cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
return handleApiError(error, locale === 'en' ? 'Cancel order failed' : '取消订单失败', request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { retryRecharge, OrderError } from '@/lib/order/service';
|
||||
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) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Retry recharge error:', error);
|
||||
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
|
||||
return handleApiError(error, locale === 'en' ? 'Recharge retry failed' : '重试充值失败', request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +19,14 @@ 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({
|
||||
...order,
|
||||
amount: Number(order.amount),
|
||||
payAmount: order.payAmount ? Number(order.payAmount) : null,
|
||||
feeRate: order.feeRate ? Number(order.feeRate) : null,
|
||||
refundAmount: order.refundAmount ? Number(order.refundAmount) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,11 +16,38 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
if (status && status in OrderStatus) where.status = status as OrderStatus;
|
||||
if (userId) where.userId = Number(userId);
|
||||
|
||||
// userId 校验:忽略无效值(NaN)
|
||||
if (userId) {
|
||||
const parsedUserId = Number(userId);
|
||||
if (Number.isFinite(parsedUserId)) {
|
||||
where.userId = parsedUserId;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期校验:忽略无效日期
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {};
|
||||
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
|
||||
if (dateTo) where.createdAt.lte = new Date(dateTo);
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
let hasValidDate = false;
|
||||
|
||||
if (dateFrom) {
|
||||
const d = new Date(dateFrom);
|
||||
if (!isNaN(d.getTime())) {
|
||||
createdAt.gte = d;
|
||||
hasValidDate = true;
|
||||
}
|
||||
}
|
||||
if (dateTo) {
|
||||
const d = new Date(dateTo);
|
||||
if (!isNaN(d.getTime())) {
|
||||
createdAt.lte = d;
|
||||
hasValidDate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidDate) {
|
||||
where.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { processRefund, OrderError } from '@/lib/order/service';
|
||||
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),
|
||||
@@ -10,28 +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) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Refund error:', error);
|
||||
return NextResponse.json({ error: '退款失败' }, { status: 500 });
|
||||
return handleApiError(error, locale === 'en' ? 'Refund failed' : '退款失败', request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType } from '@/lib/payment';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const alipayProvider = new AlipayProvider();
|
||||
import { extractHeaders } from '@/lib/utils/api';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -13,14 +13,15 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
|
||||
const provider = paymentRegistry.getProvider('alipay_direct' as PaymentType);
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const headers = extractHeaders(request);
|
||||
|
||||
const notification = await alipayProvider.verifyNotification(rawBody, headers);
|
||||
const success = await handlePaymentNotify(notification, alipayProvider.name);
|
||||
const notification = await provider.verifyNotification(rawBody, headers);
|
||||
if (!notification) {
|
||||
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
const success = await handlePaymentNotify(notification, provider.name);
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { EasyPayProvider } from '@/lib/easy-pay/provider';
|
||||
|
||||
const easyPayProvider = new EasyPayProvider();
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType } from '@/lib/payment';
|
||||
import { extractHeaders } from '@/lib/utils/api';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// EasyPay 注册为 'alipay' 和 'wxpay' 类型,任一均可获取同一 provider 实例
|
||||
const provider = paymentRegistry.getProvider('alipay' as PaymentType);
|
||||
const rawBody = request.nextUrl.searchParams.toString();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const headers = extractHeaders(request);
|
||||
|
||||
const notification = await easyPayProvider.verifyNotification(rawBody, headers);
|
||||
const success = await handlePaymentNotify(notification, easyPayProvider.name);
|
||||
const notification = await provider.verifyNotification(rawBody, headers);
|
||||
if (!notification) {
|
||||
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
const success = await handlePaymentNotify(notification, provider.name);
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
|
||||
/**
|
||||
* GET /api/limits
|
||||
@@ -13,19 +14,14 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
|
||||
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
|
||||
* },
|
||||
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间)
|
||||
* resetAt: "2026-03-02T16:00:00.000Z" // 业务时区(Asia/Shanghai)次日零点对应的 UTC 时间
|
||||
* }
|
||||
*/
|
||||
export async function GET() {
|
||||
initPaymentProviders();
|
||||
const types = paymentRegistry.getSupportedTypes();
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const resetAt = new Date(todayStart);
|
||||
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
|
||||
|
||||
const methods = await queryMethodLimits(types);
|
||||
const resetAt = getNextBizDayStartUTC();
|
||||
|
||||
return NextResponse.json({ methods, resetAt });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { cancelOrder, OrderError } from '@/lib/order/service';
|
||||
import { cancelOrder } from '@/lib/order/service';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
import { handleApiError } from '@/lib/utils/api';
|
||||
|
||||
const cancelSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -31,10 +32,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
return handleApiError(error, '取消订单失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken } from '@/lib/admin-auth';
|
||||
import { deriveOrderState } from '@/lib/order/status';
|
||||
import { ORDER_STATUS_ACCESS_QUERY_KEY, verifyOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
// 仅返回订单状态相关字段,不暴露任何用户隐私信息
|
||||
/**
|
||||
* 订单状态轮询接口。
|
||||
*
|
||||
* 返回最小必要信息供前端判断:
|
||||
* - 原始订单状态(status / expiresAt)
|
||||
* - 支付是否成功(paymentSuccess)
|
||||
* - 充值是否成功 / 当前充值阶段(rechargeSuccess / rechargeStatus)
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const accessToken = request.nextUrl.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
|
||||
const isAuthorized = verifyOrderStatusAccessToken(id, accessToken) || (await verifyAdminToken(request));
|
||||
|
||||
if (!isAuthorized) {
|
||||
return NextResponse.json({ error: '未授权访问该订单状态' }, { status: 401 });
|
||||
}
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
@@ -11,6 +27,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,9 +36,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const derived = deriveOrderState(order);
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
expiresAt: order.expiresAt,
|
||||
paymentSuccess: derived.paymentSuccess,
|
||||
rechargeSuccess: derived.rechargeSuccess,
|
||||
rechargeStatus: derived.rechargeStatus,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,8 +16,16 @@ export async function GET(request: NextRequest) {
|
||||
const rawPageSize = Number(searchParams.get('page_size') || '20');
|
||||
const pageSize = VALID_PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 20;
|
||||
|
||||
// 单独处理认证,区分认证失败和其他错误
|
||||
let user;
|
||||
try {
|
||||
user = await getCurrentUserByToken(token);
|
||||
} catch (error) {
|
||||
console.error('Auth error in /api/orders/my:', error);
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUserByToken(token);
|
||||
const where = { userId: user.id };
|
||||
|
||||
const [orders, total, statusGroups] = await Promise.all([
|
||||
@@ -76,6 +84,6 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get my orders error:', error);
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: '获取订单失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createOrder, OrderError } from '@/lib/order/service';
|
||||
import { createOrder } from '@/lib/order/service';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
import { handleApiError } from '@/lib/utils/api';
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -17,7 +18,6 @@ const createOrderSchema = z.object({
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const env = getEnv();
|
||||
initPaymentProviders();
|
||||
const body = await request.json();
|
||||
const parsed = createOrderSchema.safeParse(body);
|
||||
|
||||
@@ -66,10 +66,6 @@ export async function POST(request: NextRequest) {
|
||||
const { userName: _u, userBalance: _b, ...safeResult } = result;
|
||||
return NextResponse.json(safeResult);
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Create order error:', error);
|
||||
return NextResponse.json({ error: '创建订单失败,请稍后重试' }, { status: 500 });
|
||||
return handleApiError(error, '创建订单失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType } from '@/lib/payment';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { extractHeaders } from '@/lib/utils/api';
|
||||
|
||||
// Stripe needs raw body - ensure Next.js doesn't parse it
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider('stripe' as PaymentType);
|
||||
|
||||
const rawBody = Buffer.from(await request.arrayBuffer());
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
const headers = extractHeaders(request);
|
||||
|
||||
const notification = await provider.verifyNotification(rawBody, headers);
|
||||
if (!notification) {
|
||||
// Unknown event type — acknowledge receipt
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
await handlePaymentNotify(notification, provider.name);
|
||||
const success = await handlePaymentNotify(notification, provider.name);
|
||||
|
||||
if (!success) {
|
||||
// 处理失败(充值未完成等),返回 500 让 Stripe 重试
|
||||
return NextResponse.json({ error: 'Processing failed, will retry' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('Stripe webhook error:', error);
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUser } from '@/lib/sub2api/client';
|
||||
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: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证 token 并确保请求的 user_id 与 token 对应的用户匹配
|
||||
let tokenUser;
|
||||
try {
|
||||
tokenUser = await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Invalid token' : '无效的 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (tokenUser.id !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
initPaymentProviders();
|
||||
const enabledTypes = paymentRegistry.getSupportedTypes();
|
||||
@@ -23,17 +48,16 @@ export async function GET(request: NextRequest) {
|
||||
// 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabel(provider 名)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,18 +83,19 @@ export async function GET(request: NextRequest) {
|
||||
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||
helpText: env.PAY_HELP_TEXT ?? null,
|
||||
stripePublishableKey:
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||
? env.STRIPE_PUBLISHABLE_KEY
|
||||
: null,
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
} 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { WxpayProvider } from '@/lib/wxpay';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType } from '@/lib/payment';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const wxpayProvider = new WxpayProvider();
|
||||
import { extractHeaders } from '@/lib/utils/api';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -13,26 +13,20 @@ export async function POST(request: NextRequest) {
|
||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
|
||||
const provider = paymentRegistry.getProvider('wxpay_direct' as PaymentType);
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
const headers = extractHeaders(request);
|
||||
|
||||
const notification = await wxpayProvider.verifyNotification(rawBody, headers);
|
||||
const notification = await provider.verifyNotification(rawBody, headers);
|
||||
if (!notification) {
|
||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
const success = await handlePaymentNotify(notification, wxpayProvider.name);
|
||||
return Response.json(
|
||||
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
|
||||
{ status: success ? 200 : 500 },
|
||||
);
|
||||
const success = await handlePaymentNotify(notification, provider.name);
|
||||
return Response.json(success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' }, {
|
||||
status: success ? 200 : 500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Wxpay notify error:', error);
|
||||
return Response.json(
|
||||
{ code: 'FAIL', message: '处理失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
return Response.json({ code: 'FAIL', message: '处理失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,11 @@
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sub2API 充值',
|
||||
description: 'Sub2API 余额充值平台',
|
||||
title: 'Sub2API Recharge',
|
||||
description: 'Sub2API balance recharge platform',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const headerStore = await headers();
|
||||
const pathname = headerStore.get('x-pathname') || '';
|
||||
const search = headerStore.get('x-search') || '';
|
||||
const locale = new URLSearchParams(search).get('lang')?.trim().toLowerCase() === 'en' ? 'en' : 'zh';
|
||||
const htmlLang = locale === 'en' ? 'en' : 'zh-CN';
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<html lang={htmlLang} data-pathname={pathname}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/pay');
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const lang = Array.isArray(params?.lang) ? params?.lang[0] : params?.lang;
|
||||
redirect(lang === 'en' ? '/pay?lang=en' : '/pay');
|
||||
}
|
||||
|
||||
302
src/app/pay/[orderId]/route.ts
Normal file
302
src/app/pay/[orderId]/route.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { buildAlipayPaymentUrl } from '@/lib/alipay/provider';
|
||||
import { deriveOrderState, getOrderDisplayState, type OrderStatusLike } from '@/lib/order/status';
|
||||
import { buildOrderResultUrl } from '@/lib/order/status-access';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MOBILE_UA_PATTERN = /AlipayClient|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i;
|
||||
const ALIPAY_APP_UA_PATTERN = /AlipayClient/i;
|
||||
|
||||
type ShortLinkOrderStatus = OrderStatusLike & { id: string };
|
||||
|
||||
function getUserAgent(request: NextRequest): string {
|
||||
return request.headers.get('user-agent') || '';
|
||||
}
|
||||
|
||||
function isMobileRequest(request: NextRequest): boolean {
|
||||
return MOBILE_UA_PATTERN.test(getUserAgent(request));
|
||||
}
|
||||
|
||||
function isAlipayAppRequest(request: NextRequest): boolean {
|
||||
return ALIPAY_APP_UA_PATTERN.test(getUserAgent(request));
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildAppUrl(pathname = '/'): string {
|
||||
return new URL(pathname, getEnv().NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
function buildResultUrl(orderId: string): string {
|
||||
return buildOrderResultUrl(getEnv().NEXT_PUBLIC_APP_URL, orderId);
|
||||
}
|
||||
|
||||
function serializeScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function getStatusDisplay(order: OrderStatusLike) {
|
||||
return getOrderDisplayState({
|
||||
status: order.status,
|
||||
...deriveOrderState(order),
|
||||
});
|
||||
}
|
||||
|
||||
function renderHtml(title: string, body: string, headExtra = ''): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
${headExtra}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
|
||||
color: #0f172a;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 18px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
line-height: 60px;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.button.secondary {
|
||||
margin-top: 12px;
|
||||
background: #eff6ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 18px auto 0;
|
||||
border-radius: 9999px;
|
||||
border: 3px solid rgba(22, 119, 255, 0.18);
|
||||
border-top-color: #1677ff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.order {
|
||||
margin-top: 18px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
color: #1677ff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.text-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderErrorPage(title: string, message: string, orderId?: string, status = 400): NextResponse {
|
||||
const html = renderHtml(
|
||||
title,
|
||||
`<main class="card">
|
||||
<div class="icon">!</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${orderId ? `<div class="order">订单号:${escapeHtml(orderId)}</div>` : ''}
|
||||
<a class="button secondary" href="${escapeHtml(buildAppUrl('/'))}">返回支付首页</a>
|
||||
</main>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatusPage(order: ShortLinkOrderStatus): NextResponse {
|
||||
const display = getStatusDisplay(order);
|
||||
const html = renderHtml(
|
||||
display.label,
|
||||
`<main class="card">
|
||||
<div class="icon">${escapeHtml(display.icon)}</div>
|
||||
<h1>${escapeHtml(display.label)}</h1>
|
||||
<p>${escapeHtml(display.message)}</p>
|
||||
<div class="order">订单号:${escapeHtml(order.id)}</div>
|
||||
<a class="button secondary" href="${escapeHtml(buildResultUrl(order.id))}">查看订单结果</a>
|
||||
</main>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderRedirectPage(orderId: string, payUrl: string): NextResponse {
|
||||
const html = renderHtml(
|
||||
'正在跳转支付宝',
|
||||
`<main class="card">
|
||||
<div class="icon">支</div>
|
||||
<h1>正在拉起支付宝</h1>
|
||||
<p>请稍候,系统正在自动跳转到支付宝完成支付。</p>
|
||||
<div class="spinner"></div>
|
||||
<div class="order">订单号:${escapeHtml(orderId)}</div>
|
||||
<p class="hint">如未自动拉起支付宝,请返回原充值页后重新发起支付。</p>
|
||||
<a class="text-link" href="${escapeHtml(buildResultUrl(orderId))}">已支付?查看订单结果</a>
|
||||
<script>
|
||||
const payUrl = ${serializeScriptString(payUrl)};
|
||||
window.location.replace(payUrl);
|
||||
setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
window.location.replace(payUrl);
|
||||
}
|
||||
}, 800);
|
||||
</script>
|
||||
</main>`,
|
||||
`<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(payUrl)}" /></noscript>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ orderId: string }> }) {
|
||||
const { orderId } = await params;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
payAmount: true,
|
||||
paymentType: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return renderErrorPage('订单不存在', '未找到对应订单,请确认二维码是否正确', orderId, 404);
|
||||
}
|
||||
|
||||
if (order.paymentType !== 'alipay_direct') {
|
||||
return renderErrorPage('支付方式不匹配', '该订单不是支付宝直连订单,无法通过当前链接支付', orderId, 400);
|
||||
}
|
||||
|
||||
if (order.status !== ORDER_STATUS.PENDING) {
|
||||
return renderStatusPage(order);
|
||||
}
|
||||
|
||||
if (order.expiresAt.getTime() <= Date.now()) {
|
||||
return renderStatusPage({
|
||||
id: order.id,
|
||||
status: ORDER_STATUS.EXPIRED,
|
||||
paidAt: order.paidAt,
|
||||
completedAt: order.completedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const payAmount = Number(order.payAmount ?? order.amount);
|
||||
if (!Number.isFinite(payAmount) || payAmount <= 0) {
|
||||
return renderErrorPage('订单金额异常', '订单金额无效,请返回原页面重新发起支付', order.id, 500);
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const payUrl = buildAlipayPaymentUrl({
|
||||
orderId: order.id,
|
||||
amount: payAmount,
|
||||
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
|
||||
notifyUrl: env.ALIPAY_NOTIFY_URL,
|
||||
returnUrl: isAlipayAppRequest(request) ? null : buildResultUrl(order.id),
|
||||
isMobile: isMobileRequest(request),
|
||||
});
|
||||
|
||||
return renderRedirectPage(order.id, payUrl);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import OrderFilterBar from '@/components/OrderFilterBar';
|
||||
import OrderSummaryCards from '@/components/OrderSummaryCards';
|
||||
import OrderTable from '@/components/OrderTable';
|
||||
import PaginationBar from '@/components/PaginationBar';
|
||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
|
||||
import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
@@ -24,8 +25,36 @@ function OrdersContent() {
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const srcHost = searchParams.get('src_host') || '';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const text = {
|
||||
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
|
||||
visitOrders: pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问订单页面',
|
||||
'Please open the orders page from Sub2API',
|
||||
),
|
||||
sessionExpired: pickLocaleText(
|
||||
locale,
|
||||
'登录态已失效,请从 Sub2API 重新进入支付页。',
|
||||
'Session expired. Please re-enter from Sub2API.',
|
||||
),
|
||||
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
|
||||
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
|
||||
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
|
||||
myOrders: pickLocaleText(locale, '我的订单', 'My Orders'),
|
||||
refresh: pickLocaleText(locale, '刷新', 'Refresh'),
|
||||
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
|
||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
||||
userPrefix: pickLocaleText(locale, '用户', 'User'),
|
||||
authError: pickLocaleText(
|
||||
locale,
|
||||
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
|
||||
'Missing authentication information. Please open the orders page from Sub2API.',
|
||||
),
|
||||
};
|
||||
|
||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
@@ -56,9 +85,9 @@ function OrdersContent() {
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
params.set('tab', 'orders');
|
||||
applyLocaleToSearchParams(params, locale);
|
||||
window.location.replace(`/pay?${params.toString()}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMobile, isEmbedded]);
|
||||
}, [isMobile, isEmbedded, token, theme, uiMode, locale]);
|
||||
|
||||
const loadOrders = async (targetPage = page, targetPageSize = pageSize) => {
|
||||
setLoading(true);
|
||||
@@ -66,7 +95,7 @@ function OrdersContent() {
|
||||
try {
|
||||
if (!hasToken) {
|
||||
setOrders([]);
|
||||
setError('缺少认证信息,请从 Sub2API 平台正确访问订单页面。');
|
||||
setError(text.authError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +106,7 @@ function OrdersContent() {
|
||||
});
|
||||
const res = await fetch(`/api/orders/my?${params}`);
|
||||
if (!res.ok) {
|
||||
setError(res.status === 401 ? '登录态已失效,请从 Sub2API 重新进入支付页。' : '订单加载失败,请稍后重试。');
|
||||
setError(res.status === 401 ? text.sessionExpired : text.loadFailed);
|
||||
setOrders([]);
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +121,7 @@ function OrdersContent() {
|
||||
username:
|
||||
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||
`用户 #${meId}`,
|
||||
`${text.userPrefix} #${meId}`,
|
||||
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
|
||||
});
|
||||
|
||||
@@ -102,7 +131,7 @@ function OrdersContent() {
|
||||
setTotalPages(data.total_pages ?? 1);
|
||||
} catch {
|
||||
setOrders([]);
|
||||
setError('网络错误,请稍后重试。');
|
||||
setError(text.networkError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -111,7 +140,6 @@ function OrdersContent() {
|
||||
useEffect(() => {
|
||||
if (isMobile && !isEmbedded) return;
|
||||
loadOrders(1, pageSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, isMobile, isEmbedded]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
@@ -139,7 +167,7 @@ function OrdersContent() {
|
||||
<div
|
||||
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
|
||||
>
|
||||
正在切换到移动端订单 Tab...
|
||||
{text.switchingMobileTab}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -148,8 +176,8 @@ function OrdersContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问订单页面</p>
|
||||
<p className="text-lg font-medium">{text.missingAuth}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.visitOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -160,6 +188,7 @@ function OrdersContent() {
|
||||
if (token) params.set('token', token);
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
applyLocaleToSearchParams(params, locale);
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -167,28 +196,28 @@ function OrdersContent() {
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
title="我的订单"
|
||||
subtitle={userInfo?.username || '我的订单'}
|
||||
title={text.myOrders}
|
||||
subtitle={userInfo?.username || text.myOrders}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
{!srcHost && (
|
||||
<a href={buildScopedUrl('/pay')} className={btnClass}>
|
||||
返回充值
|
||||
{text.backToPay}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OrderSummaryCards isDark={isDark} summary={summary} />
|
||||
<OrderSummaryCards isDark={isDark} locale={locale} summary={summary} />
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
</div>
|
||||
|
||||
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
|
||||
<OrderTable isDark={isDark} locale={locale} loading={loading} error={error} orders={filteredOrders} />
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
@@ -196,6 +225,7 @@ function OrdersContent() {
|
||||
total={summary.total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
loading={loading}
|
||||
onPageChange={handlePageChange}
|
||||
@@ -205,15 +235,20 @@ function OrdersContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<OrdersPageFallback />}>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,9 @@ import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import MobileOrderList from '@/components/MobileOrderList';
|
||||
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||
|
||||
interface OrderResult {
|
||||
@@ -20,6 +22,7 @@ interface OrderResult {
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: string;
|
||||
statusAccessToken: string;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
@@ -41,6 +44,7 @@ function PayContent() {
|
||||
const tab = searchParams.get('tab');
|
||||
const srcHost = searchParams.get('src_host') || undefined;
|
||||
const srcUrl = searchParams.get('src_url') || undefined;
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||
@@ -49,7 +53,7 @@ function PayContent() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
||||
const [finalStatus, setFinalStatus] = useState('');
|
||||
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||
@@ -97,7 +101,6 @@ function PayContent() {
|
||||
|
||||
setUserNotFound(false);
|
||||
try {
|
||||
// 通过 token 获取用户详情和订单
|
||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||
if (!meRes.ok) {
|
||||
setUserNotFound(true);
|
||||
@@ -120,7 +123,7 @@ function PayContent() {
|
||||
username:
|
||||
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||
`用户 #${meId}`,
|
||||
pickLocaleText(locale, `用户 #${meId}`, `User #${meId}`),
|
||||
balance: typeof meUser.balance === 'number' ? meUser.balance : undefined,
|
||||
});
|
||||
|
||||
@@ -134,8 +137,7 @@ function PayContent() {
|
||||
setOrdersHasMore(false);
|
||||
}
|
||||
|
||||
// 获取服务端支付配置
|
||||
const cfgRes = await fetch(`/api/user?user_id=${meId}`);
|
||||
const cfgRes = await fetch(`/api/user?user_id=${meId}&token=${encodeURIComponent(token)}`);
|
||||
if (cfgRes.ok) {
|
||||
const cfgData = await cfgRes.json();
|
||||
if (cfgData.config) {
|
||||
@@ -154,9 +156,7 @@ function PayContent() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and keep page usable
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadMoreOrders = async () => {
|
||||
@@ -175,7 +175,6 @@ function PayContent() {
|
||||
setOrdersHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setOrdersLoadingMore(false);
|
||||
}
|
||||
@@ -183,29 +182,32 @@ function PayContent() {
|
||||
|
||||
useEffect(() => {
|
||||
loadUserAndOrders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [token, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||
// 立即在后台刷新余额,2.2s 显示结果页后再切回表单(届时余额已更新)
|
||||
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
||||
loadUserAndOrders();
|
||||
const timer = setTimeout(() => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
}, 2200);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, finalStatus]);
|
||||
}, [step, finalOrderState]);
|
||||
|
||||
if (!hasToken) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问充值页面</p>
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问充值页面',
|
||||
'Please open the recharge page from the Sub2API platform',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -215,8 +217,14 @@ function PayContent() {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">用户不存在</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请检查链接是否正确,或联系管理员</p>
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请检查链接是否正确,或联系管理员',
|
||||
'Please check whether the link is correct or contact the administrator',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -228,6 +236,9 @@ function PayContent() {
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
if (forceOrdersTab) params.set('tab', 'orders');
|
||||
if (srcHost) params.set('src_host', srcHost);
|
||||
if (srcUrl) params.set('src_url', srcUrl);
|
||||
applyLocaleToSearchParams(params, locale);
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
@@ -237,7 +248,13 @@ function PayContent() {
|
||||
|
||||
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||
if (pendingBlocked) {
|
||||
setError(`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`);
|
||||
setError(
|
||||
pickLocaleText(
|
||||
locale,
|
||||
`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`,
|
||||
`You have ${pendingCount} pending orders. Please complete or cancel them first (maximum ${MAX_PENDING}).`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,15 +279,33 @@ function PayContent() {
|
||||
|
||||
if (!res.ok) {
|
||||
const codeMessages: Record<string, string> = {
|
||||
INVALID_TOKEN: '认证已失效,请重新从平台进入充值页面',
|
||||
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
|
||||
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
||||
INVALID_TOKEN: pickLocaleText(
|
||||
locale,
|
||||
'认证已失效,请重新从平台进入充值页面',
|
||||
'Authentication expired. Please re-enter the recharge page from the platform',
|
||||
),
|
||||
USER_INACTIVE: pickLocaleText(
|
||||
locale,
|
||||
'账户已被禁用,无法充值,请联系管理员',
|
||||
'This account is disabled and cannot be recharged. Please contact the administrator',
|
||||
),
|
||||
TOO_MANY_PENDING: pickLocaleText(
|
||||
locale,
|
||||
'您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||
'You have too many pending orders. Please complete or cancel existing orders first',
|
||||
),
|
||||
USER_NOT_FOUND: pickLocaleText(
|
||||
locale,
|
||||
'用户不存在,请检查链接是否正确',
|
||||
'User not found. Please check whether the link is correct',
|
||||
),
|
||||
DAILY_LIMIT_EXCEEDED: data.error,
|
||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||
PAYMENT_GATEWAY_ERROR: data.error,
|
||||
};
|
||||
setError(codeMessages[data.code] || data.error || '创建订单失败');
|
||||
setError(
|
||||
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,18 +319,19 @@ function PayContent() {
|
||||
qrCode: data.qrCode,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
|
||||
setStep('paying');
|
||||
} catch {
|
||||
setError('网络错误,请稍后重试');
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
setFinalStatus(status);
|
||||
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||
setFinalOrderState(order);
|
||||
setStep('result');
|
||||
if (isMobile) {
|
||||
setActiveMobileTab('orders');
|
||||
@@ -305,7 +341,7 @@ function PayContent() {
|
||||
const handleBack = () => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
@@ -314,8 +350,9 @@ function PayContent() {
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth={isMobile ? 'sm' : 'lg'}
|
||||
title="Sub2API 余额充值"
|
||||
subtitle="安全支付,自动到账"
|
||||
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
|
||||
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
|
||||
locale={locale}
|
||||
actions={
|
||||
!isMobile ? (
|
||||
<>
|
||||
@@ -329,7 +366,7 @@ function PayContent() {
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
{pickLocaleText(locale, '刷新', 'Refresh')}
|
||||
</button>
|
||||
<a
|
||||
href={ordersUrl}
|
||||
@@ -340,13 +377,22 @@ function PayContent() {
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</a>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
{error && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-lg border p-3 text-sm',
|
||||
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && isMobile && (
|
||||
<div
|
||||
@@ -369,7 +415,7 @@ function PayContent() {
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
充值
|
||||
{pickLocaleText(locale, '充值', 'Recharge')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -385,7 +431,7 @@ function PayContent() {
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
{pickLocaleText(locale, '我的订单', 'My Orders')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -393,7 +439,9 @@ function PayContent() {
|
||||
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>加载中...</span>
|
||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -414,6 +462,7 @@ function PayContent() {
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
@@ -424,6 +473,7 @@ function PayContent() {
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -442,6 +492,7 @@ function PayContent() {
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -451,11 +502,30 @@ function PayContent() {
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>支付说明</div>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看「我的订单」</li>
|
||||
{config.maxDailyAmount > 0 && <li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>}
|
||||
<li>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'订单完成后会自动到账',
|
||||
'Balance will be credited automatically after the order completes',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'如需历史记录请查看「我的订单」',
|
||||
'Check "My Orders" for payment history',
|
||||
)}
|
||||
</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>
|
||||
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
|
||||
{config.maxDailyAmount.toFixed(2)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -466,7 +536,9 @@ function PayContent() {
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
@@ -482,7 +554,7 @@ function PayContent() {
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{helpText.split('\\n').map((line, i) => (
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -507,15 +579,27 @@ function PayContent() {
|
||||
amount={orderResult.amount}
|
||||
payAmount={orderResult.payAmount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
statusAccessToken={orderResult.statusAccessToken}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
isMobile={isMobile}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />}
|
||||
{step === 'result' && orderResult && finalOrderState && (
|
||||
<OrderStatus
|
||||
orderId={orderResult.orderId}
|
||||
order={finalOrderState}
|
||||
statusAccessToken={orderResult.statusAccessToken}
|
||||
onStateChange={setFinalOrderState}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div
|
||||
@@ -534,15 +618,20 @@ function PayContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function PayPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PayPageFallback />}>
|
||||
<PayContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,19 +2,168 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale, type Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
|
||||
type WindowWithAlipayBridge = Window & {
|
||||
AlipayJSBridge?: {
|
||||
call: (name: string, params?: unknown, callback?: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function tryCloseViaAlipayBridge(): boolean {
|
||||
const bridge = (window as WindowWithAlipayBridge).AlipayJSBridge;
|
||||
if (!bridge?.call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
bridge.call('closeWebview');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeCurrentWindow() {
|
||||
if (tryCloseViaAlipayBridge()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const handleBridgeReady = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
|
||||
if (!tryCloseViaAlipayBridge()) {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('AlipayJSBridgeReady', handleBridgeReady, { once: true });
|
||||
window.setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
|
||||
window.close();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale, hasAccessToken: boolean) {
|
||||
if (!order) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: hasAccessToken
|
||||
? 'Unable to load the order status. Please try again later.'
|
||||
: 'Missing order access token. Please go back to the recharge page.',
|
||||
}
|
||||
: {
|
||||
label: '支付异常',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeSuccess) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharge Successful',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited successfully.',
|
||||
}
|
||||
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已成功到账!' };
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Top-up Processing',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment succeeded, and the balance top-up is being processed.',
|
||||
}
|
||||
: { label: '充值处理中', color: 'text-blue-600', icon: '⟳', message: '支付成功,余额正在充值中...' };
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Successful',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'Payment succeeded, but the balance top-up has not completed yet. Please check again later or contact the administrator.',
|
||||
}
|
||||
: {
|
||||
label: '支付成功',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
|
||||
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Order Expired',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
}
|
||||
: { label: '订单已超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新充值。' };
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? { label: 'Order Cancelled', color: 'text-gray-500', icon: '✗', message: 'This order has been cancelled.' }
|
||||
: { label: '订单已取消', color: 'text-gray-500', icon: '✗', message: '订单已被取消。' };
|
||||
}
|
||||
|
||||
return locale === 'en'
|
||||
? { label: 'Payment Error', color: 'text-red-600', icon: '✗', message: 'Please contact the administrator.' }
|
||||
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
|
||||
}
|
||||
|
||||
function ResultContent() {
|
||||
const searchParams = useSearchParams();
|
||||
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
|
||||
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
|
||||
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const isPopup = searchParams.get('popup') === '1';
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const text = {
|
||||
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
|
||||
back: pickLocaleText(locale, '返回', 'Back'),
|
||||
closeSoon: pickLocaleText(locale, '此窗口将在 3 秒后自动关闭', 'This window will close automatically in 3 seconds'),
|
||||
closeNow: pickLocaleText(locale, '立即关闭窗口', 'Close now'),
|
||||
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||
unknown: pickLocaleText(locale, '未知', 'Unknown'),
|
||||
};
|
||||
|
||||
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isInPopup, setIsInPopup] = useState(false);
|
||||
|
||||
// Detect if opened as a popup window (from stripe-popup or via popup=1 param)
|
||||
useEffect(() => {
|
||||
if (isPopup || window.opener) {
|
||||
setIsInPopup(true);
|
||||
@@ -22,136 +171,123 @@ function ResultContent() {
|
||||
}, [isPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outTradeNo) {
|
||||
if (!outTradeNo || !accessToken || accessToken.length < 10) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkOrder = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${outTradeNo}`);
|
||||
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data.status);
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
setOrderState(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOrder();
|
||||
// Poll a few times in case status hasn't updated yet
|
||||
const timer = setInterval(checkOrder, 3000);
|
||||
const timeout = setTimeout(() => clearInterval(timer), 30000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [outTradeNo]);
|
||||
}, [outTradeNo, accessToken]);
|
||||
|
||||
// Auto-close popup window on success
|
||||
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
|
||||
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
|
||||
|
||||
const goBack = () => {
|
||||
if (isInPopup) {
|
||||
closeCurrentWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('theme', theme);
|
||||
applyLocaleToSearchParams(params, locale);
|
||||
window.location.replace(`/pay?${params.toString()}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInPopup || !isSuccess) return;
|
||||
if (!isInPopup || !shouldAutoClose) return;
|
||||
const timer = setTimeout(() => {
|
||||
window.close();
|
||||
closeCurrentWindow();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInPopup, isSuccess]);
|
||||
}, [isInPopup, shouldAutoClose]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">查询支付结果中...</div>
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{text.checking}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = status === 'PENDING';
|
||||
const display = getStatusConfig(orderState, locale, Boolean(accessToken));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<div className="text-6xl text-green-500">✓</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-green-600">
|
||||
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
|
||||
</p>
|
||||
{isInPopup && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm text-gray-400">此窗口将在 3 秒后自动关闭</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
立即关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : isPending ? (
|
||||
<>
|
||||
<div className="text-6xl text-yellow-500">⏳</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">等待支付</h1>
|
||||
<p className="mt-2 text-gray-500">订单尚未完成支付</p>
|
||||
{isInPopup && (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div
|
||||
className={[
|
||||
'w-full max-w-md rounded-xl p-8 text-center shadow-lg',
|
||||
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
|
||||
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
|
||||
|
||||
{isInPopup ? (
|
||||
shouldAutoClose && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
onClick={closeCurrentWindow}
|
||||
className="text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
关闭窗口
|
||||
{text.closeNow}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="text-6xl text-red-500">✗</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-red-600">
|
||||
{status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{status === 'EXPIRED'
|
||||
? '订单已超时,请重新充值'
|
||||
: status === 'CANCELLED'
|
||||
? '订单已被取消'
|
||||
: '请联系管理员处理'}
|
||||
</p>
|
||||
{isInPopup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
关闭窗口
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
<button type="button" onClick={goBack} className="mt-4 text-sm text-blue-600 underline hover:text-blue-700">
|
||||
{text.back}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-gray-400">订单号: {outTradeNo || '未知'}</p>
|
||||
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
|
||||
{text.orderId}: {outTradeNo || text.unknown}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayResultPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<ResultPageFallback />}>
|
||||
<ResultContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
|
||||
import { getPaymentMeta } from '@/lib/pay-utils';
|
||||
|
||||
function StripePopupContent() {
|
||||
@@ -10,10 +11,33 @@ function StripePopupContent() {
|
||||
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const method = searchParams.get('method') || '';
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
const isAlipay = method === 'alipay';
|
||||
|
||||
// Sensitive data received via postMessage from parent, NOT from URL
|
||||
const text = {
|
||||
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
|
||||
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||
loadFailed: pickLocaleText(
|
||||
locale,
|
||||
'支付组件加载失败,请关闭窗口重试',
|
||||
'Failed to load payment component. Please close the window and try again.',
|
||||
),
|
||||
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
|
||||
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
|
||||
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
|
||||
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
|
||||
successClosing: pickLocaleText(
|
||||
locale,
|
||||
'支付成功,窗口即将自动关闭...',
|
||||
'Payment successful. This window will close automatically...',
|
||||
),
|
||||
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
|
||||
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
|
||||
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
|
||||
};
|
||||
|
||||
const [credentials, setCredentials] = useState<{
|
||||
clientSecret: string;
|
||||
publishableKey: string;
|
||||
@@ -34,10 +58,14 @@ function StripePopupContent() {
|
||||
returnUrl.searchParams.set('order_id', orderId);
|
||||
returnUrl.searchParams.set('status', 'success');
|
||||
returnUrl.searchParams.set('popup', '1');
|
||||
returnUrl.searchParams.set('theme', theme);
|
||||
if (accessToken) {
|
||||
returnUrl.searchParams.set('access_token', accessToken);
|
||||
}
|
||||
applyLocaleToSearchParams(returnUrl.searchParams, locale);
|
||||
return returnUrl.toString();
|
||||
}, [orderId]);
|
||||
}, [orderId, theme, locale, accessToken]);
|
||||
|
||||
// Listen for credentials from parent window via postMessage
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
@@ -48,14 +76,12 @@ function StripePopupContent() {
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
// Signal parent that popup is ready to receive data
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
|
||||
}
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, []);
|
||||
|
||||
// Initialize Stripe once credentials are received
|
||||
useEffect(() => {
|
||||
if (!credentials) return;
|
||||
let cancelled = false;
|
||||
@@ -65,14 +91,13 @@ function StripePopupContent() {
|
||||
loadStripe(publishableKey).then((stripe) => {
|
||||
if (cancelled || !stripe) {
|
||||
if (!cancelled) {
|
||||
setStripeError('支付组件加载失败,请关闭窗口重试');
|
||||
setStripeError(text.loadFailed);
|
||||
setStripeLoaded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAlipay) {
|
||||
// Alipay: confirm directly and redirect, no Payment Element needed
|
||||
stripe
|
||||
.confirmAlipayPayment(clientSecret, {
|
||||
return_url: buildReturnUrl(),
|
||||
@@ -80,15 +105,13 @@ function StripePopupContent() {
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.error) {
|
||||
setStripeError(result.error.message || '支付失败,请重试');
|
||||
setStripeError(result.error.message || text.payFailed);
|
||||
setStripeLoaded(true);
|
||||
}
|
||||
// If no error, the page has already been redirected
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: create Elements for Payment Element flow
|
||||
const elements = stripe.elements({
|
||||
clientSecret,
|
||||
appearance: {
|
||||
@@ -103,9 +126,8 @@ function StripePopupContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [credentials, isDark, isAlipay, buildReturnUrl]);
|
||||
}, [credentials, isDark, isAlipay, buildReturnUrl, text.loadFailed, text.payFailed]);
|
||||
|
||||
// Mount Payment Element (only for non-alipay methods)
|
||||
const stripeContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node || !stripeLib) return;
|
||||
@@ -135,7 +157,7 @@ function StripePopupContent() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setStripeError(error.message || '支付失败,请重试');
|
||||
setStripeError(error.message || text.payFailed);
|
||||
setStripeSubmitting(false);
|
||||
} else {
|
||||
setStripeSuccess(true);
|
||||
@@ -143,7 +165,6 @@ function StripePopupContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-close after success
|
||||
useEffect(() => {
|
||||
if (!stripeSuccess) return;
|
||||
const timer = setTimeout(() => {
|
||||
@@ -152,7 +173,6 @@ function StripePopupContent() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [stripeSuccess]);
|
||||
|
||||
// Waiting for credentials from parent
|
||||
if (!credentials) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -161,14 +181,13 @@ function StripePopupContent() {
|
||||
>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在初始化...</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.init}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alipay direct confirm: show loading/redirecting state
|
||||
if (isAlipay) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -177,28 +196,32 @@ function StripePopupContent() {
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{'\u00A5'}
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.orderId}: {orderId}
|
||||
</p>
|
||||
</div>
|
||||
{stripeError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||
<div
|
||||
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
关闭窗口
|
||||
{text.closeWindow}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
正在跳转到支付页面...
|
||||
</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.redirecting}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,35 +236,39 @@ function StripePopupContent() {
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{'\u00A5'}
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>订单号: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.orderId}: {orderId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>正在加载支付表单...</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loadingForm}</span>
|
||||
</div>
|
||||
) : stripeSuccess ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="text-5xl text-green-600">{'\u2713'}</div>
|
||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支付成功,窗口即将自动关闭...
|
||||
</p>
|
||||
<div className="text-5xl text-green-600">{'✓'}</div>
|
||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.successClosing}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
手动关闭窗口
|
||||
{text.closeWindowManually}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{stripeError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||
<div
|
||||
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={stripeContainerRef}
|
||||
@@ -253,18 +280,16 @@ function StripePopupContent() {
|
||||
onClick={handleSubmit}
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: getPaymentMeta('stripe').buttonClass,
|
||||
stripeSubmitting ? 'bg-gray-400 cursor-not-allowed' : getPaymentMeta('stripe').buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
处理中...
|
||||
{text.processing}
|
||||
</span>
|
||||
) : (
|
||||
`支付 ¥${amount.toFixed(2)}`
|
||||
text.payAmount
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -274,15 +299,20 @@ function StripePopupContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function StripePopupFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StripePopupPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<StripePopupFallback />}>
|
||||
<StripePopupContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import OrderFilterBar from '@/components/OrderFilterBar';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
@@ -19,6 +20,7 @@ interface MobileOrderListProps {
|
||||
loadingMore: boolean;
|
||||
onRefresh: () => void;
|
||||
onLoadMore: () => void;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function MobileOrderList({
|
||||
@@ -29,6 +31,7 @@ export default function MobileOrderList({
|
||||
loadingMore,
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
locale = 'zh',
|
||||
}: MobileOrderListProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -59,7 +62,7 @@ export default function MobileOrderList({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
我的订单
|
||||
{locale === 'en' ? 'My Orders' : '我的订单'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,11 +74,11 @@ export default function MobileOrderList({
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
{locale === 'en' ? 'Refresh' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
|
||||
{!hasToken ? (
|
||||
<div
|
||||
@@ -84,7 +87,9 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
当前链接未携带登录 token,无法查询“我的订单”。
|
||||
{locale === 'en'
|
||||
? 'The current link does not include a login token, so "My Orders" is unavailable.'
|
||||
: '当前链接未携带登录 token,无法查询"我的订单"。'}
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div
|
||||
@@ -93,7 +98,7 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -110,29 +115,27 @@ export default function MobileOrderList({
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return provider ? `${channel} · ${provider}` : channel;
|
||||
})()}
|
||||
{getPaymentDisplayInfo(order.paymentType, locale).channel}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 无限滚动哨兵 */}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-3 text-center">
|
||||
{loadingMore ? (
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>加载中...</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{locale === 'en' ? 'Loading...' : '加载中...'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||
上滑加载更多
|
||||
{locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -140,7 +143,7 @@ export default function MobileOrderList({
|
||||
|
||||
{!hasMore && orders.length > 0 && (
|
||||
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||
已显示全部订单
|
||||
{locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { getFilterOptions, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderFilterBarProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
activeFilter: OrderStatusFilter;
|
||||
onChange: (filter: OrderStatusFilter) => void;
|
||||
}
|
||||
|
||||
export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) {
|
||||
export default function OrderFilterBar({ isDark, locale, activeFilter, onChange }: OrderFilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_OPTIONS.map((item) => (
|
||||
{getFilterOptions(locale).map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
|
||||
@@ -1,68 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
|
||||
interface OrderStatusProps {
|
||||
status: string;
|
||||
orderId: string;
|
||||
order: PublicOrderStatusSnapshot;
|
||||
statusAccessToken?: string;
|
||||
onBack: () => void;
|
||||
onStateChange?: (order: PublicOrderStatusSnapshot) => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
},
|
||||
};
|
||||
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
|
||||
if (order.rechargeSuccess) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharge Successful',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited. Thank you for your payment.',
|
||||
}
|
||||
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已到账,感谢您的充值!' };
|
||||
}
|
||||
|
||||
export default function OrderStatus({ status, onBack, dark = false }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: 'text-gray-600',
|
||||
icon: '?',
|
||||
message: '未知状态',
|
||||
};
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment received. Recharging your balance...',
|
||||
}
|
||||
: { label: '充值中', color: 'text-blue-600', icon: '⟳', message: '支付成功,正在充值余额中,请稍候...' };
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Successful',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'Payment completed, but the balance top-up has not finished yet. The system may retry automatically. Please check the order list later or contact the administrator if it remains unresolved.',
|
||||
}
|
||||
: {
|
||||
label: '支付成功',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Failed',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message:
|
||||
'Payment was not completed. Please try again. If funds were deducted but not credited, contact the administrator.',
|
||||
}
|
||||
: {
|
||||
label: '支付失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
|
||||
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Order Expired',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new one.',
|
||||
}
|
||||
: { label: '订单超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新创建订单。' };
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? { label: 'Cancelled', color: 'text-gray-500', icon: '✗', message: 'The order has been cancelled.' }
|
||||
: { label: '已取消', color: 'text-gray-500', icon: '✗', message: '订单已取消。' };
|
||||
}
|
||||
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Payment status is abnormal. Please contact the administrator.',
|
||||
}
|
||||
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '支付状态异常,请联系管理员处理。' };
|
||||
}
|
||||
|
||||
export default function OrderStatus({
|
||||
orderId,
|
||||
order,
|
||||
statusAccessToken,
|
||||
onBack,
|
||||
onStateChange,
|
||||
dark = false,
|
||||
locale = 'zh',
|
||||
}: OrderStatusProps) {
|
||||
const [currentOrder, setCurrentOrder] = useState(order);
|
||||
const onStateChangeRef = useRef(onStateChange);
|
||||
useEffect(() => {
|
||||
onStateChangeRef.current = onStateChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentOrder(order);
|
||||
}, [order]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId || !currentOrder.paymentSuccess || currentOrder.rechargeSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const refreshOrder = async () => {
|
||||
try {
|
||||
const response = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (!response.ok) return;
|
||||
const nextOrder = (await response.json()) as PublicOrderStatusSnapshot;
|
||||
if (cancelled) return;
|
||||
setCurrentOrder(nextOrder);
|
||||
onStateChangeRef.current?.(nextOrder);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
refreshOrder();
|
||||
const timer = setInterval(refreshOrder, 3000);
|
||||
const timeout = setTimeout(() => clearInterval(timer), 30000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [orderId, currentOrder.paymentSuccess, currentOrder.rechargeSuccess, statusAccessToken]);
|
||||
|
||||
const config = getStatusConfig(currentOrder, locale);
|
||||
const doneLabel = locale === 'en' ? 'Done' : '完成';
|
||||
const backLabel = locale === 'en' ? 'Back to Recharge' : '返回充值';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
<div className={`text-6xl ${config.color}`}>{config.icon}</div>
|
||||
<h2 className={`text-xl font-bold ${config.color}`}>{config.label}</h2>
|
||||
<p className="text-center text-gray-500">{config.message}</p>
|
||||
<p className={['text-center', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{config.message}</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||
className={[
|
||||
'mt-4 w-full rounded-lg py-3 font-medium text-white',
|
||||
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{status === 'COMPLETED' ? '完成' : '返回充值'}
|
||||
{currentOrder.rechargeSuccess ? doneLabel : backLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Summary {
|
||||
total: number;
|
||||
pending: number;
|
||||
@@ -7,32 +9,47 @@ interface Summary {
|
||||
|
||||
interface OrderSummaryCardsProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
|
||||
export default function OrderSummaryCards({ isDark, locale, summary }: OrderSummaryCardsProps) {
|
||||
const cardClass = [
|
||||
'rounded-xl border p-3',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ');
|
||||
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
|
||||
const labels =
|
||||
locale === 'en'
|
||||
? {
|
||||
total: 'Total Orders',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
failed: 'Closed/Failed',
|
||||
}
|
||||
: {
|
||||
total: '总订单',
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
failed: '异常/关闭',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>总订单</div>
|
||||
<div className={labelClass}>{labels.total}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>待支付</div>
|
||||
<div className={labelClass}>{labels.pending}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>已完成</div>
|
||||
<div className={labelClass}>{labels.completed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>异常/关闭</div>
|
||||
<div className={labelClass}>{labels.failed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
getStatusBadgeClass,
|
||||
getPaymentDisplayInfo,
|
||||
type MyOrder,
|
||||
} from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
orders: MyOrder[];
|
||||
}
|
||||
|
||||
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
|
||||
export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) {
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
empty: 'No matching orders found',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Amount',
|
||||
payment: 'Payment Method',
|
||||
status: 'Status',
|
||||
createdAt: 'Created At',
|
||||
}
|
||||
: {
|
||||
empty: '暂无符合条件的订单记录',
|
||||
orderId: '订单号',
|
||||
amount: '金额',
|
||||
payment: '支付方式',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -40,7 +67,7 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{text.empty}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -50,11 +77,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>订单号</span>
|
||||
<span>金额</span>
|
||||
<span>支付方式</span>
|
||||
<span>状态</span>
|
||||
<span>创建时间</span>
|
||||
<span>{text.orderId}</span>
|
||||
<span>{text.amount}</span>
|
||||
<span>{text.payment}</span>
|
||||
<span>{text.status}</span>
|
||||
<span>{text.createdAt}</span>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-0">
|
||||
{orders.map((order) => (
|
||||
@@ -67,31 +94,19 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return (
|
||||
<>
|
||||
<span>{channel}</span>
|
||||
{provider && (
|
||||
<span className={['ml-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div>{getPaymentDisplayInfo(order.paymentType, locale).channel}</div>
|
||||
<div>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PaginationBarProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions?: number[];
|
||||
locale?: Locale;
|
||||
isDark?: boolean;
|
||||
loading?: boolean;
|
||||
onPageChange: (newPage: number) => void;
|
||||
@@ -16,6 +19,7 @@ export default function PaginationBar({
|
||||
total,
|
||||
pageSize,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
locale,
|
||||
isDark = false,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
@@ -30,17 +34,29 @@ export default function PaginationBar({
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
total: `Total ${total}${totalPages > 1 ? `, Page ${page} / ${totalPages}` : ''}`,
|
||||
perPage: 'Per page',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
}
|
||||
: {
|
||||
total: `共 ${total} 条${totalPages > 1 ? `,第 ${page} / ${totalPages} 页` : ''}`,
|
||||
perPage: '每页',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
{/* 左侧:统计 + 每页大小 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||
</span>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{text.total}</span>
|
||||
|
||||
{onPageSizeChange && (
|
||||
<>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>每页</span>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{text.perPage}</span>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -68,7 +84,6 @@ export default function PaginationBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:分页导航 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
@@ -85,7 +100,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className={navBtnClass(page <= 1)}
|
||||
>
|
||||
上一页
|
||||
{text.previous}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,7 +108,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className={navBtnClass(page >= totalPages)}
|
||||
>
|
||||
下一页
|
||||
{text.next}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PayPageLayoutProps {
|
||||
isDark: boolean;
|
||||
@@ -8,6 +9,7 @@ interface PayPageLayoutProps {
|
||||
subtitle: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function PayPageLayout({
|
||||
@@ -18,6 +20,7 @@ export default function PayPageLayout({
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
locale = 'zh',
|
||||
}: PayPageLayoutProps) {
|
||||
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function PayPageLayout({
|
||||
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
|
||||
].join(' ')}
|
||||
>
|
||||
Sub2API Secure Pay
|
||||
{locale === 'en' ? 'Sub2API Secure Pay' : 'Sub2API 安全支付'}
|
||||
</div>
|
||||
<h1
|
||||
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta, getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
export interface MethodLimitInfo {
|
||||
available: boolean;
|
||||
@@ -25,6 +26,7 @@ interface PaymentFormProps {
|
||||
dark?: boolean;
|
||||
pendingBlocked?: boolean;
|
||||
pendingCount?: number;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||
@@ -47,12 +49,12 @@ export default function PaymentForm({
|
||||
dark = false,
|
||||
pendingBlocked = false,
|
||||
pendingCount = 0,
|
||||
locale = 'zh',
|
||||
}: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
|
||||
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
|
||||
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
||||
? paymentType
|
||||
: enabledPaymentTypes[0] || 'stripe';
|
||||
@@ -107,7 +109,7 @@ export default function PaymentForm({
|
||||
if (iconType === 'alipay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
{locale === 'en' ? 'A' : '支'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +146,6 @@ export default function PaymentForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
@@ -152,22 +153,22 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
充值账户
|
||||
{locale === 'en' ? 'Recharge Account' : '充值账户'}
|
||||
</div>
|
||||
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
{userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
|
||||
</div>
|
||||
{userBalance !== undefined && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
当前余额: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
{locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '}
|
||||
<span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Selection */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
充值金额
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
@@ -189,10 +190,9 @@ export default function PaymentForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
自定义金额
|
||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
@@ -223,23 +223,28 @@ export default function PaymentForm({
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
let msg =
|
||||
locale === 'en'
|
||||
? 'Amount must be within range and support up to 2 decimal places'
|
||||
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
if (!isNaN(num)) {
|
||||
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
||||
if (num < minAmount)
|
||||
msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax)
|
||||
msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
|
||||
}
|
||||
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||
})()}
|
||||
|
||||
{/* Payment Type — only show when multiple types available */}
|
||||
{enabledPaymentTypes.length > 1 && (
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
{locale === 'en' ? 'Payment Method' : '支付方式'}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const displayInfo = getPaymentDisplayInfo(type, locale);
|
||||
const isSelected = effectivePaymentType === type;
|
||||
const limitInfo = methodLimits?.[type];
|
||||
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
||||
@@ -250,7 +255,13 @@ export default function PaymentForm({
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
title={
|
||||
isUnavailable
|
||||
? locale === 'en'
|
||||
? 'Daily limit reached, please use another payment method'
|
||||
: '今日充值额度已满,请使用其他支付方式'
|
||||
: undefined
|
||||
}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
isUnavailable
|
||||
@@ -258,7 +269,7 @@ export default function PaymentForm({
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||
: isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${dark ? meta?.selectedBgDark || 'bg-blue-950' : meta?.selectedBg || 'bg-blue-50'} ${dark ? 'text-slate-100' : 'text-slate-900'} shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||
@@ -267,14 +278,16 @@ export default function PaymentForm({
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">
|
||||
{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}
|
||||
</span>
|
||||
) : displayInfo.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
{displayInfo.sublabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -284,20 +297,20 @@ export default function PaymentForm({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[effectivePaymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
{locale === 'en'
|
||||
? "The selected payment method has reached today's limit. Please switch to another method."
|
||||
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee Detail */}
|
||||
{feeRate > 0 && selectedAmount > 0 && (
|
||||
<div
|
||||
className={[
|
||||
@@ -306,40 +319,38 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>充值金额</span>
|
||||
<span>{locale === 'en' ? 'Recharge Amount' : '充值金额'}</span>
|
||||
<span>¥{selectedAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span>手续费({feeRate}%)</span>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>{locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%)`}</span>
|
||||
<span>¥{feeAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
|
||||
'mt-1.5 flex items-center justify-between border-t pt-1.5 font-medium',
|
||||
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>实付金额</span>
|
||||
<span>{locale === 'en' ? 'Amount to Pay' : '实付金额'}</span>
|
||||
<span>¥{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending order limit warning */}
|
||||
{pendingBlocked && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark
|
||||
? 'border-amber-700 bg-amber-900/30 text-amber-300'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
您有 {pendingCount} 个待支付订单,请先完成或取消后再充值
|
||||
{locale === 'en'
|
||||
? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.`
|
||||
: `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || loading || pendingBlocked}
|
||||
@@ -352,10 +363,16 @@ export default function PaymentForm({
|
||||
}`}
|
||||
>
|
||||
{loading
|
||||
? '处理中...'
|
||||
? locale === 'en'
|
||||
? 'Processing...'
|
||||
: '处理中...'
|
||||
: pendingBlocked
|
||||
? '待支付订单过多'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
? locale === 'en'
|
||||
? 'Too many pending orders'
|
||||
: '待支付订单过多'
|
||||
: locale === 'en'
|
||||
? `Recharge Now ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
isStripeType,
|
||||
getPaymentMeta,
|
||||
getPaymentIconSrc,
|
||||
getPaymentChannelLabel,
|
||||
} from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
import { TERMINAL_STATUSES } from '@/lib/constants';
|
||||
|
||||
interface PaymentQRCodeProps {
|
||||
@@ -21,21 +19,18 @@ interface PaymentQRCodeProps {
|
||||
amount: number;
|
||||
payAmount?: number;
|
||||
expiresAt: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
statusAccessToken?: string;
|
||||
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
|
||||
onBack: () => void;
|
||||
dark?: boolean;
|
||||
isEmbedded?: boolean;
|
||||
isMobile?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
|
||||
const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4';
|
||||
const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
|
||||
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
|
||||
const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TEXT_H5_HINT =
|
||||
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
||||
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
|
||||
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
|
||||
}
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
@@ -48,22 +43,24 @@ export default function PaymentQRCode({
|
||||
amount,
|
||||
payAmount: payAmountProp,
|
||||
expiresAt,
|
||||
statusAccessToken,
|
||||
onStatusChange,
|
||||
onBack,
|
||||
dark = false,
|
||||
isEmbedded = false,
|
||||
isMobile = false,
|
||||
locale = 'zh',
|
||||
}: PaymentQRCodeProps) {
|
||||
const displayAmount = payAmountProp ?? amount;
|
||||
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
|
||||
const [timeLeft, setTimeLeft] = useState('');
|
||||
const [timeLeftSeconds, setTimeLeftSeconds] = useState(Infinity);
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [cancelBlocked, setCancelBlocked] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Stripe Payment Element state
|
||||
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||
const [stripeSubmitting, setStripeSubmitting] = useState(false);
|
||||
const [stripeError, setStripeError] = useState('');
|
||||
@@ -72,12 +69,56 @@ export default function PaymentQRCode({
|
||||
stripe: import('@stripe/stripe-js').Stripe;
|
||||
elements: import('@stripe/stripe-js').StripeElements;
|
||||
} | null>(null);
|
||||
// Track selected payment method in Payment Element (for embedded popup decision)
|
||||
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
|
||||
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||
const paymentMethodListenerAdded = useRef(false);
|
||||
|
||||
// PC 端有二维码时优先展示二维码;仅移动端或无二维码时才跳转
|
||||
const t = {
|
||||
expired: locale === 'en' ? 'Order Expired' : '订单已超时',
|
||||
remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间',
|
||||
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
||||
back: locale === 'en' ? 'Back' : '返回',
|
||||
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
||||
h5Hint:
|
||||
locale === 'en'
|
||||
? 'After payment, please return to this page. The system will confirm automatically.'
|
||||
: '支付完成后请返回此页面,系统将自动确认',
|
||||
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
||||
paidCancelBlocked:
|
||||
locale === 'en'
|
||||
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
|
||||
: '该订单已支付完成,无法取消。充值将自动到账。',
|
||||
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
||||
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
||||
stripeLoadFailed:
|
||||
locale === 'en'
|
||||
? 'Failed to load payment component. Please refresh and try again.'
|
||||
: '支付组件加载失败,请刷新页面重试',
|
||||
initFailed:
|
||||
locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
||||
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
|
||||
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
||||
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
||||
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
||||
payNow: locale === 'en' ? 'Pay' : '支付',
|
||||
popupBlocked:
|
||||
locale === 'en'
|
||||
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
|
||||
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
||||
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
||||
redirectingSuffix: locale === 'en' ? '...' : '...',
|
||||
redirectRetryHint:
|
||||
locale === 'en'
|
||||
? 'If the payment app does not open automatically, go back and try again.'
|
||||
: '如未自动拉起支付应用,请返回上一页后重新发起支付。',
|
||||
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
|
||||
goPaySuffix: locale === 'en' ? '' : '',
|
||||
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
||||
gotoSuffix: locale === 'en' ? ' to pay' : '支付',
|
||||
openScanPrefix: locale === 'en' ? 'Open ' : '请打开',
|
||||
openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付',
|
||||
};
|
||||
|
||||
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +127,7 @@ export default function PaymentQRCode({
|
||||
if (isEmbedded) {
|
||||
window.open(payUrl!, '_blank');
|
||||
} else {
|
||||
window.location.href = payUrl!;
|
||||
window.location.replace(payUrl!);
|
||||
}
|
||||
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
||||
|
||||
@@ -128,7 +169,6 @@ export default function PaymentQRCode({
|
||||
};
|
||||
}, [qrPayload]);
|
||||
|
||||
// Initialize Stripe Payment Element
|
||||
const isStripe = isStripeType(paymentType);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -139,7 +179,7 @@ export default function PaymentQRCode({
|
||||
loadStripe(stripePublishableKey).then((stripe) => {
|
||||
if (cancelled) return;
|
||||
if (!stripe) {
|
||||
setStripeError('支付组件加载失败,请刷新页面重试');
|
||||
setStripeError(t.stripeLoadFailed);
|
||||
setStripeLoaded(true);
|
||||
return;
|
||||
}
|
||||
@@ -160,9 +200,8 @@ export default function PaymentQRCode({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isStripe, clientSecret, stripePublishableKey, dark]);
|
||||
}, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]);
|
||||
|
||||
// Mount Payment Element when container is available
|
||||
const stripeContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node || !stripeLib) return;
|
||||
@@ -188,7 +227,6 @@ export default function PaymentQRCode({
|
||||
const handleStripeSubmit = async () => {
|
||||
if (!stripeLib || stripeSubmitting) return;
|
||||
|
||||
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
|
||||
if (isEmbedded && stripePaymentMethod === 'alipay') {
|
||||
handleOpenPopup();
|
||||
return;
|
||||
@@ -203,6 +241,12 @@ export default function PaymentQRCode({
|
||||
returnUrl.search = '';
|
||||
returnUrl.searchParams.set('order_id', orderId);
|
||||
returnUrl.searchParams.set('status', 'success');
|
||||
if (statusAccessToken) {
|
||||
returnUrl.searchParams.set('access_token', statusAccessToken);
|
||||
}
|
||||
if (locale === 'en') {
|
||||
returnUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
@@ -213,20 +257,17 @@ export default function PaymentQRCode({
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setStripeError(error.message || '支付失败,请重试');
|
||||
setStripeError(error.message || t.payFailed);
|
||||
setStripeSubmitting(false);
|
||||
} else {
|
||||
// Payment succeeded (or no redirect needed)
|
||||
setStripeSuccess(true);
|
||||
setStripeSubmitting(false);
|
||||
// Polling will pick up the status change
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPopup = () => {
|
||||
if (!clientSecret || !stripePublishableKey) return;
|
||||
setPopupBlocked(false);
|
||||
// Only pass display params in URL — sensitive data sent via postMessage
|
||||
const popupUrl = new URL(window.location.href);
|
||||
popupUrl.pathname = '/pay/stripe-popup';
|
||||
popupUrl.search = '';
|
||||
@@ -234,13 +275,18 @@ export default function PaymentQRCode({
|
||||
popupUrl.searchParams.set('amount', String(amount));
|
||||
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
||||
popupUrl.searchParams.set('method', stripePaymentMethod);
|
||||
if (statusAccessToken) {
|
||||
popupUrl.searchParams.set('access_token', statusAccessToken);
|
||||
}
|
||||
if (locale === 'en') {
|
||||
popupUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
|
||||
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
|
||||
if (!popup || popup.closed) {
|
||||
setPopupBlocked(true);
|
||||
return;
|
||||
}
|
||||
// Send sensitive data via postMessage after popup loads
|
||||
const onReady = (event: MessageEvent) => {
|
||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
|
||||
window.removeEventListener('message', onReady);
|
||||
@@ -263,34 +309,35 @@ export default function PaymentQRCode({
|
||||
const diff = expiry - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeLeft(TEXT_EXPIRED);
|
||||
setTimeLeft(t.expired);
|
||||
setTimeLeftSeconds(0);
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||
setTimeLeftSeconds(totalSeconds);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
const timer = setInterval(updateTimer, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [expiresAt]);
|
||||
}, [expiresAt, t.expired]);
|
||||
|
||||
const pollStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data.status);
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
if (isVisibleOrderOutcome(data)) {
|
||||
onStatusChange(data);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, [orderId, onStatusChange]);
|
||||
} catch {}
|
||||
}, [orderId, onStatusChange, statusAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expired) return;
|
||||
@@ -302,13 +349,12 @@ export default function PaymentQRCode({
|
||||
const handleCancel = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
// 先检查当前订单状态
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
|
||||
if (TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data.status);
|
||||
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,35 +369,38 @@ export default function PaymentQRCode({
|
||||
setCancelBlocked(true);
|
||||
return;
|
||||
}
|
||||
onStatusChange('CANCELLED');
|
||||
onStatusChange({
|
||||
id: orderId,
|
||||
status: 'CANCELLED',
|
||||
expiresAt,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'closed',
|
||||
});
|
||||
} else {
|
||||
await pollStatus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const meta = getPaymentMeta(paymentType || 'alipay');
|
||||
const iconSrc = getPaymentIconSrc(paymentType || 'alipay');
|
||||
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay');
|
||||
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale);
|
||||
const iconBgClass = meta.iconBg;
|
||||
|
||||
if (cancelBlocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
<div className="text-6xl text-green-600">{'\u2713'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
|
||||
<div className="text-6xl text-green-600">{'✓'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{t.paid}</h2>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{
|
||||
'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'
|
||||
}
|
||||
{t.paidCancelBlocked}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{'\u8FD4\u56DE\u5145\u503C'}
|
||||
{t.backToRecharge}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -361,16 +410,19 @@ export default function PaymentQRCode({
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-blue-600">
|
||||
{'\u00A5'}
|
||||
{'¥'}
|
||||
{displayAmount.toFixed(2)}
|
||||
</div>
|
||||
{hasFeeDiff && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
到账 ¥{amount.toFixed(2)}
|
||||
{t.credited}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
||||
<div
|
||||
className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}
|
||||
>
|
||||
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -385,19 +437,24 @@ export default function PaymentQRCode({
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付初始化失败,请返回重试
|
||||
</p>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</p>
|
||||
</div>
|
||||
) : !stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在加载支付表单...
|
||||
{t.loadingForm}
|
||||
</span>
|
||||
</div>
|
||||
) : stripeError && !stripeLib ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -414,9 +471,9 @@ export default function PaymentQRCode({
|
||||
)}
|
||||
{stripeSuccess ? (
|
||||
<div className="text-center">
|
||||
<div className="text-4xl text-green-600">{'\u2713'}</div>
|
||||
<div className="text-4xl text-green-600">{'✓'}</div>
|
||||
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付成功,正在处理订单...
|
||||
{t.successProcessing}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -426,18 +483,16 @@ export default function PaymentQRCode({
|
||||
onClick={handleStripeSubmit}
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: meta.buttonClass,
|
||||
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
处理中...
|
||||
{t.processing}
|
||||
</span>
|
||||
) : (
|
||||
`支付 ¥${amount.toFixed(2)}`
|
||||
`${t.payNow} ¥${amount.toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -450,7 +505,7 @@ export default function PaymentQRCode({
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
|
||||
{t.popupBlocked}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -459,9 +514,12 @@ export default function PaymentQRCode({
|
||||
) : shouldAutoRedirect ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<div
|
||||
className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`}
|
||||
style={{ borderColor: meta.color, borderTopColor: 'transparent' }}
|
||||
/>
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在跳转到{channelLabel}...
|
||||
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -471,11 +529,11 @@ export default function PaymentQRCode({
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
|
||||
{redirected
|
||||
? `${t.notRedirectedPrefix}${channelLabel}`
|
||||
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
</p>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -508,13 +566,13 @@ export default function PaymentQRCode({
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.scanPay}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
|
||||
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -531,14 +589,17 @@ export default function PaymentQRCode({
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_BACK}
|
||||
{t.back}
|
||||
</button>
|
||||
{!expired && token && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm',
|
||||
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_CANCEL_ORDER}
|
||||
{t.cancelOrder}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface DailyData {
|
||||
date: string;
|
||||
@@ -11,6 +12,7 @@ interface DailyData {
|
||||
interface DailyChartProps {
|
||||
data: DailyData[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -34,11 +36,17 @@ function CustomTooltip({
|
||||
payload,
|
||||
label,
|
||||
dark,
|
||||
currency,
|
||||
amountLabel,
|
||||
countLabel,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayload[];
|
||||
label?: string;
|
||||
dark?: boolean;
|
||||
currency: string;
|
||||
amountLabel: string;
|
||||
countLabel: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
@@ -51,16 +59,20 @@ function CustomTooltip({
|
||||
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.dataKey}>
|
||||
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
|
||||
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
|
||||
{p.dataKey === 'amount' ? amountLabel : countLabel}:{' '}
|
||||
{p.dataKey === 'amount' ? `${currency}${p.value.toLocaleString()}` : p.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
// Auto-calculate tick interval: show ~10-15 labels max
|
||||
export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const chartTitle = locale === 'en' ? 'Daily Recharge Trend' : '每日充值趋势';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const amountLabel = locale === 'en' ? 'Amount' : '金额';
|
||||
const countLabel = locale === 'en' ? 'Orders' : '笔数';
|
||||
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -71,9 +83,11 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
|
||||
{emptyText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +103,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
@@ -109,7 +123,11 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip dark={dark} />} />
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Summary {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
@@ -10,16 +12,29 @@ interface Summary {
|
||||
interface DashboardStatsProps {
|
||||
summary: Summary;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
|
||||
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const cards = [
|
||||
{ label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: '成功率', value: `${summary.successRate}%` },
|
||||
{ label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` },
|
||||
{
|
||||
label: locale === 'en' ? 'Today Recharge' : '今日充值',
|
||||
value: `${currency}${summary.today.amount.toLocaleString()}`,
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
label: locale === 'en' ? 'Today Orders' : '今日订单',
|
||||
value: `${summary.today.paidCount}/${summary.today.orderCount}`,
|
||||
},
|
||||
{
|
||||
label: locale === 'en' ? 'Total Recharge' : '累计充值',
|
||||
value: `${currency}${summary.total.amount.toLocaleString()}`,
|
||||
accent: true,
|
||||
},
|
||||
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
||||
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
@@ -11,6 +13,7 @@ interface LeaderboardEntry {
|
||||
interface LeaderboardProps {
|
||||
data: LeaderboardEntry[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
@@ -19,7 +22,13 @@ const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' },
|
||||
};
|
||||
|
||||
export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardProps) {
|
||||
const title = locale === 'en' ? 'Recharge Leaderboard (Top 10)' : '充值排行榜 (Top 10)';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const userLabel = locale === 'en' ? 'User' : '用户';
|
||||
const amountLabel = locale === 'en' ? 'Total Amount' : '累计金额';
|
||||
const orderCountLabel = locale === 'en' ? 'Orders' : '订单数';
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`;
|
||||
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
@@ -33,9 +42,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,16 +57,16 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>#</th>
|
||||
<th className={thCls}>用户</th>
|
||||
<th className={thCls}>累计金额</th>
|
||||
<th className={thCls}>订单数</th>
|
||||
<th className={thCls}>{userLabel}</th>
|
||||
<th className={thCls}>{amountLabel}</th>
|
||||
<th className={thCls}>{orderCountLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200'}`}>
|
||||
@@ -88,7 +97,8 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
¥{entry.totalAmount.toLocaleString()}
|
||||
{currency}
|
||||
{entry.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className={tdMuted}>{entry.orderCount}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { useEffect } from 'react';
|
||||
import { getPaymentDisplayInfo, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
@@ -42,40 +44,125 @@ interface OrderDetailProps {
|
||||
};
|
||||
onClose: () => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) {
|
||||
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
title: 'Order Details',
|
||||
auditLogs: 'Audit Logs',
|
||||
operator: 'Operator',
|
||||
emptyLogs: 'No logs',
|
||||
close: 'Close',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
orderId: 'Order ID',
|
||||
userId: 'User ID',
|
||||
userName: 'Username',
|
||||
email: 'Email',
|
||||
amount: 'Amount',
|
||||
status: 'Status',
|
||||
paymentSuccess: 'Payment Success',
|
||||
rechargeSuccess: 'Recharge Success',
|
||||
rechargeStatus: 'Recharge Status',
|
||||
paymentChannel: 'Payment Channel',
|
||||
provider: 'Provider',
|
||||
rechargeCode: 'Recharge Code',
|
||||
paymentTradeNo: 'Payment Trade No.',
|
||||
clientIp: 'Client IP',
|
||||
sourceHost: 'Source Host',
|
||||
sourcePage: 'Source Page',
|
||||
createdAt: 'Created At',
|
||||
expiresAt: 'Expires At',
|
||||
paidAt: 'Paid At',
|
||||
completedAt: 'Completed At',
|
||||
failedAt: 'Failed At',
|
||||
failedReason: 'Failure Reason',
|
||||
refundAmount: 'Refund Amount',
|
||||
refundReason: 'Refund Reason',
|
||||
refundAt: 'Refunded At',
|
||||
forceRefund: 'Force Refund',
|
||||
}
|
||||
: {
|
||||
title: '订单详情',
|
||||
auditLogs: '审计日志',
|
||||
operator: '操作者',
|
||||
emptyLogs: '暂无日志',
|
||||
close: '关闭',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
orderId: '订单号',
|
||||
userId: '用户ID',
|
||||
userName: '用户名',
|
||||
email: '邮箱',
|
||||
amount: '金额',
|
||||
status: '状态',
|
||||
paymentSuccess: '支付成功',
|
||||
rechargeSuccess: '充值成功',
|
||||
rechargeStatus: '充值状态',
|
||||
paymentChannel: '支付渠道',
|
||||
provider: '提供商',
|
||||
rechargeCode: '充值码',
|
||||
paymentTradeNo: '支付单号',
|
||||
clientIp: '客户端IP',
|
||||
sourceHost: '来源域名',
|
||||
sourcePage: '来源页面',
|
||||
createdAt: '创建时间',
|
||||
expiresAt: '过期时间',
|
||||
paidAt: '支付时间',
|
||||
completedAt: '完成时间',
|
||||
failedAt: '失败时间',
|
||||
failedReason: '失败原因',
|
||||
refundAmount: '退款金额',
|
||||
refundReason: '退款原因',
|
||||
refundAt: '退款时间',
|
||||
forceRefund: '强制退款',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const paymentInfo = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
|
||||
const fields = [
|
||||
{ label: '订单号', value: order.id },
|
||||
{ label: '用户ID', value: order.userId },
|
||||
{ label: '用户名', value: order.userName || '-' },
|
||||
{ label: '邮箱', value: order.userEmail || '-' },
|
||||
{ label: '金额', value: `¥${order.amount.toFixed(2)}` },
|
||||
{ label: '状态', value: order.status },
|
||||
{ label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge Status', value: order.rechargeStatus || '-' },
|
||||
{ label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel },
|
||||
{ label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' },
|
||||
{ label: '充值码', value: order.rechargeCode },
|
||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||
{ label: '来源域名', value: order.srcHost || '-' },
|
||||
{ label: '来源页面', value: order.srcUrl || '-' },
|
||||
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
||||
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '完成时间', value: order.completedAt ? new Date(order.completedAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '失败时间', value: order.failedAt ? new Date(order.failedAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '失败原因', value: order.failedReason || '-' },
|
||||
{ label: text.orderId, value: order.id },
|
||||
{ label: text.userId, value: order.userId },
|
||||
{ label: text.userName, value: order.userName || '-' },
|
||||
{ label: text.email, value: order.userEmail || '-' },
|
||||
{ label: text.amount, value: `${currency}${order.amount.toFixed(2)}` },
|
||||
{ label: text.status, value: order.status },
|
||||
{ label: text.paymentSuccess, value: order.paymentSuccess ? text.yes : text.no },
|
||||
{ label: text.rechargeSuccess, value: order.rechargeSuccess ? text.yes : text.no },
|
||||
{ label: text.rechargeStatus, value: order.rechargeStatus || '-' },
|
||||
{ label: text.paymentChannel, value: paymentInfo.channel },
|
||||
{ label: text.provider, value: paymentInfo.provider || '-' },
|
||||
{ label: text.rechargeCode, value: order.rechargeCode },
|
||||
{ label: text.paymentTradeNo, value: order.paymentTradeNo || '-' },
|
||||
{ label: text.clientIp, value: order.clientIp || '-' },
|
||||
{ label: text.sourceHost, value: order.srcHost || '-' },
|
||||
{ label: text.sourcePage, value: order.srcUrl || '-' },
|
||||
{ label: text.createdAt, value: formatCreatedAt(order.createdAt, locale) },
|
||||
{ label: text.expiresAt, value: formatCreatedAt(order.expiresAt, locale) },
|
||||
{ label: text.paidAt, value: order.paidAt ? formatCreatedAt(order.paidAt, locale) : '-' },
|
||||
{ label: text.completedAt, value: order.completedAt ? formatCreatedAt(order.completedAt, locale) : '-' },
|
||||
{ label: text.failedAt, value: order.failedAt ? formatCreatedAt(order.failedAt, locale) : '-' },
|
||||
{ label: text.failedReason, value: order.failedReason || '-' },
|
||||
];
|
||||
|
||||
if (order.refundAmount) {
|
||||
fields.push(
|
||||
{ label: '退款金额', value: `¥${order.refundAmount.toFixed(2)}` },
|
||||
{ label: '退款原因', value: order.refundReason || '-' },
|
||||
{ label: '退款时间', value: order.refundAt ? new Date(order.refundAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '强制退款', value: order.forceRefund ? '是' : '否' },
|
||||
{ label: text.refundAmount, value: `${currency}${order.refundAmount.toFixed(2)}` },
|
||||
{ label: text.refundReason, value: order.refundReason || '-' },
|
||||
{ label: text.refundAt, value: order.refundAt ? formatCreatedAt(order.refundAt, locale) : '-' },
|
||||
{ label: text.forceRefund, value: order.forceRefund ? text.yes : text.no },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +173,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">订单详情</h3>
|
||||
<h3 className="text-lg font-bold">{text.title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
|
||||
@@ -106,7 +193,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
|
||||
{/* Audit Logs */}
|
||||
<div className="mt-6">
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>{text.auditLogs}</h4>
|
||||
<div className="space-y-2">
|
||||
{order.auditLogs.map((log) => (
|
||||
<div
|
||||
@@ -116,7 +203,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
{formatCreatedAt(log.createdAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
@@ -126,13 +213,13 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
操作者: {log.operator}
|
||||
{text.operator}: {log.operator}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +228,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
onClick={onClose}
|
||||
className={`mt-6 w-full rounded-lg border py-2 text-sm ${dark ? 'border-slate-600 text-slate-300 hover:bg-slate-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
关闭
|
||||
{text.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo, formatStatus, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
@@ -26,22 +27,44 @@ interface OrderTableProps {
|
||||
onCancel: (orderId: string) => void;
|
||||
onViewDetail: (orderId: string) => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; light: string; dark: string }> = {
|
||||
PENDING: { label: '待支付', light: 'bg-yellow-100 text-yellow-800', dark: 'bg-yellow-500/20 text-yellow-300' },
|
||||
PAID: { label: '已支付', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
|
||||
RECHARGING: { label: '充值中', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
|
||||
COMPLETED: { label: '已完成', light: 'bg-green-100 text-green-800', dark: 'bg-green-500/20 text-green-300' },
|
||||
EXPIRED: { label: '已超时', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
|
||||
CANCELLED: { label: '已取消', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
|
||||
FAILED: { label: '充值失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
|
||||
REFUNDING: { label: '退款中', light: 'bg-orange-100 text-orange-800', dark: 'bg-orange-500/20 text-orange-300' },
|
||||
REFUNDED: { label: '已退款', light: 'bg-purple-100 text-purple-800', dark: 'bg-purple-500/20 text-purple-300' },
|
||||
REFUND_FAILED: { label: '退款失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
|
||||
};
|
||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
orderId: 'Order ID',
|
||||
userName: 'Username',
|
||||
email: 'Email',
|
||||
notes: 'Notes',
|
||||
amount: 'Amount',
|
||||
status: 'Status',
|
||||
paymentMethod: 'Payment',
|
||||
source: 'Source',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions',
|
||||
retry: 'Retry',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No orders',
|
||||
}
|
||||
: {
|
||||
orderId: '订单号',
|
||||
userName: '用户名',
|
||||
email: '邮箱',
|
||||
notes: '备注',
|
||||
amount: '金额',
|
||||
status: '状态',
|
||||
paymentMethod: '支付方式',
|
||||
source: '来源',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作',
|
||||
retry: '重试',
|
||||
cancel: '取消',
|
||||
empty: '暂无订单',
|
||||
};
|
||||
|
||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark }: OrderTableProps) {
|
||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
|
||||
@@ -50,24 +73,50 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>订单号</th>
|
||||
<th className={thCls}>用户名</th>
|
||||
<th className={thCls}>邮箱</th>
|
||||
<th className={thCls}>备注</th>
|
||||
<th className={thCls}>金额</th>
|
||||
<th className={thCls}>状态</th>
|
||||
<th className={thCls}>支付方式</th>
|
||||
<th className={thCls}>来源</th>
|
||||
<th className={thCls}>创建时间</th>
|
||||
<th className={thCls}>操作</th>
|
||||
<th className={thCls}>{text.orderId}</th>
|
||||
<th className={thCls}>{text.userName}</th>
|
||||
<th className={thCls}>{text.email}</th>
|
||||
<th className={thCls}>{text.notes}</th>
|
||||
<th className={thCls}>{text.amount}</th>
|
||||
<th className={thCls}>{text.status}</th>
|
||||
<th className={thCls}>{text.paymentMethod}</th>
|
||||
<th className={thCls}>{text.source}</th>
|
||||
<th className={thCls}>{text.createdAt}</th>
|
||||
<th className={thCls}>{text.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
|
||||
{orders.map((order) => {
|
||||
const statusInfo = STATUS_LABELS[order.status] || {
|
||||
label: order.status,
|
||||
light: 'bg-gray-100 text-gray-800',
|
||||
dark: 'bg-slate-600/30 text-slate-400',
|
||||
const statusInfo = {
|
||||
label: formatStatus(order.status, locale),
|
||||
light:
|
||||
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: order.status === 'REFUNDED'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: order.status === 'REFUNDING'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: order.status === 'COMPLETED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: order.status === 'PAID' || order.status === 'RECHARGING'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: order.status === 'PENDING'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800',
|
||||
dark:
|
||||
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
|
||||
? 'bg-red-500/20 text-red-300'
|
||||
: order.status === 'REFUNDED'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: order.status === 'REFUNDING'
|
||||
? 'bg-orange-500/20 text-orange-300'
|
||||
: order.status === 'COMPLETED'
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: order.status === 'PAID' || order.status === 'RECHARGING'
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: order.status === 'PENDING'
|
||||
? 'bg-yellow-500/20 text-yellow-300'
|
||||
: 'bg-slate-600/30 text-slate-400',
|
||||
};
|
||||
return (
|
||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
@@ -85,7 +134,8 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||
¥{order.amount.toFixed(2)}
|
||||
{currency}
|
||||
{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
@@ -96,7 +146,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
@@ -110,7 +160,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className={tdMuted}>{formatCreatedAt(order.createdAt, locale)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<div className="flex gap-1">
|
||||
{order.rechargeRetryable && (
|
||||
@@ -118,7 +168,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
onClick={() => onRetry(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30' : 'bg-blue-100 text-blue-700 hover:bg-blue-200'}`}
|
||||
>
|
||||
重试
|
||||
{text.retry}
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'PENDING' && (
|
||||
@@ -126,7 +176,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
onClick={() => onCancel(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' : 'bg-red-100 text-red-700 hover:bg-red-200'}`}
|
||||
>
|
||||
取消
|
||||
{text.cancel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +187,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>{text.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PaymentMethod {
|
||||
paymentType: string;
|
||||
@@ -12,9 +13,14 @@ interface PaymentMethod {
|
||||
interface PaymentMethodChartProps {
|
||||
data: PaymentMethod[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
||||
export default function PaymentMethodChart({ data, dark, locale = 'zh' }: PaymentMethodChartProps) {
|
||||
const title = locale === 'en' ? 'Payment Method Distribution' : '支付方式分布';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -24,9 +30,9 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,19 +44,18 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
</h3>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>{title}</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const meta = getPaymentMeta(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType, locale);
|
||||
return (
|
||||
<div key={method.paymentType}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||
{currency}
|
||||
{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -59,7 +64,10 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={['h-full rounded-full transition-all', dark ? meta.chartBar.dark : meta.chartBar.light].join(' ')}
|
||||
className={[
|
||||
'h-full rounded-full transition-all',
|
||||
dark ? meta.chartBar.dark : meta.chartBar.light,
|
||||
].join(' ')}
|
||||
style={{ width: `${method.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface RefundDialogProps {
|
||||
orderId: string;
|
||||
@@ -9,6 +10,8 @@ interface RefundDialogProps {
|
||||
onCancel: () => void;
|
||||
warning?: string;
|
||||
requireForce?: boolean;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function RefundDialog({
|
||||
@@ -18,11 +21,47 @@ export default function RefundDialog({
|
||||
onCancel,
|
||||
warning,
|
||||
requireForce,
|
||||
dark = false,
|
||||
locale = 'zh',
|
||||
}: RefundDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [force, setForce] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
title: 'Confirm Refund',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Refund Amount',
|
||||
reason: 'Refund Reason',
|
||||
reasonPlaceholder: 'Enter refund reason (optional)',
|
||||
forceRefund: 'Force refund (balance may become negative)',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm Refund',
|
||||
processing: 'Processing...',
|
||||
}
|
||||
: {
|
||||
title: '确认退款',
|
||||
orderId: '订单号',
|
||||
amount: '退款金额',
|
||||
reason: '退款原因',
|
||||
reasonPlaceholder: '请输入退款原因(可选)',
|
||||
forceRefund: '强制退款(余额可能扣为负数)',
|
||||
cancel: '取消',
|
||||
confirm: '确认退款',
|
||||
processing: '处理中...',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -34,30 +73,50 @@ export default function RefundDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-gray-900">确认退款</h3>
|
||||
<div
|
||||
className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-sm text-gray-500">订单号</div>
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
|
||||
<div className="text-sm font-mono">{orderId}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-sm text-gray-500">退款金额</div>
|
||||
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
|
||||
<div className="text-lg font-bold text-red-600">
|
||||
{currency}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
|
||||
{warning && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg p-3 text-sm',
|
||||
dark ? 'bg-yellow-900/30 text-yellow-300' : 'bg-yellow-50 text-yellow-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">退款原因</label>
|
||||
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
{text.reason}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="请输入退款原因(可选)"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder={text.reasonPlaceholder}
|
||||
className={[
|
||||
'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none',
|
||||
dark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,9 +126,9 @@ export default function RefundDialog({
|
||||
type="checkbox"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')}
|
||||
/>
|
||||
<span className="text-red-600">强制退款(余额可能扣为负数)</span>
|
||||
<span className="text-red-600">{text.forceRefund}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -77,16 +136,21 @@ export default function RefundDialog({
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm',
|
||||
dark
|
||||
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
取消
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{loading ? '处理中...' : '确认退款'}
|
||||
{loading ? text.processing : text.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
@@ -30,7 +31,23 @@ async function isSub2ApiAdmin(token: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function verifyAdminToken(request: NextRequest): Promise<boolean> {
|
||||
const token = request.nextUrl.searchParams.get('token');
|
||||
// 优先从 Authorization: Bearer <token> header 获取
|
||||
let token: string | null = null;
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.slice(7).trim();
|
||||
}
|
||||
|
||||
// Fallback: query parameter(向后兼容,已弃用)
|
||||
if (!token) {
|
||||
token = request.nextUrl.searchParams.get('token');
|
||||
if (token) {
|
||||
console.warn(
|
||||
'[DEPRECATED] Admin token passed via query parameter. Use "Authorization: Bearer <token>" header instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return false;
|
||||
|
||||
// 1. 本地 admin token
|
||||
@@ -40,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 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
import { parseAlipayJsonResponse } from './codec';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
@@ -32,7 +33,7 @@ function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
|
||||
options?: { notifyUrl?: string; returnUrl?: string | null; method?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -45,7 +46,7 @@ export function pageExecute(
|
||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
|
||||
}
|
||||
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
|
||||
if (options?.returnUrl !== null && (options?.returnUrl || env.ALIPAY_RETURN_URL)) {
|
||||
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export function pageExecute(
|
||||
export async function execute<T extends AlipayResponse>(
|
||||
method: string,
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
): Promise<T> {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -71,6 +73,13 @@ export async function execute<T extends AlipayResponse>(
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (options?.notifyUrl) {
|
||||
params.notify_url = options.notifyUrl;
|
||||
}
|
||||
if (options?.returnUrl) {
|
||||
params.return_url = options.returnUrl;
|
||||
}
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
@@ -80,11 +89,11 @@ export async function execute<T extends AlipayResponse>(
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
const result = data[responseKey] as T;
|
||||
const result = data[responseKey] as T | undefined;
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
||||
|
||||
104
src/lib/alipay/codec.ts
Normal file
104
src/lib/alipay/codec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
const HEADER_CHARSET_RE = /charset=([^;]+)/i;
|
||||
const BODY_CHARSET_RE = /(?:^|&)charset=([^&]+)/i;
|
||||
|
||||
function normalizeCharset(charset: string | null | undefined): string | null {
|
||||
if (!charset) return null;
|
||||
|
||||
const normalized = charset
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
switch (normalized) {
|
||||
case 'utf8':
|
||||
return 'utf-8';
|
||||
case 'gb2312':
|
||||
case 'gb_2312-80':
|
||||
return 'gbk';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function detectCharsetFromHeaders(headers: Record<string, string>): string | null {
|
||||
const contentType = headers['content-type'];
|
||||
const match = contentType?.match(HEADER_CHARSET_RE);
|
||||
return normalizeCharset(match?.[1]);
|
||||
}
|
||||
|
||||
function detectCharsetFromBody(rawBody: Buffer): string | null {
|
||||
const latin1Body = rawBody.toString('latin1');
|
||||
const match = latin1Body.match(BODY_CHARSET_RE);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
return normalizeCharset(decodeURIComponent(match[1].replace(/\+/g, ' ')));
|
||||
} catch {
|
||||
return normalizeCharset(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBuffer(rawBody: Buffer, charset: string): string {
|
||||
return new TextDecoder(charset).decode(rawBody);
|
||||
}
|
||||
|
||||
export function decodeAlipayPayload(rawBody: string | Buffer, headers: Record<string, string> = {}): string {
|
||||
if (typeof rawBody === 'string') {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
const primaryCharset = detectCharsetFromHeaders(headers) || detectCharsetFromBody(rawBody) || 'utf-8';
|
||||
const candidates = Array.from(new Set([primaryCharset, 'utf-8', 'gbk', 'gb18030']));
|
||||
|
||||
let fallbackDecoded: string | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const charset of candidates) {
|
||||
try {
|
||||
const decoded = decodeBuffer(rawBody, charset);
|
||||
if (!decoded.includes('\uFFFD')) {
|
||||
return decoded;
|
||||
}
|
||||
fallbackDecoded ??= decoded;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackDecoded) {
|
||||
return fallbackDecoded;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to decode Alipay payload${lastError instanceof Error ? `: ${lastError.message}` : ''}`);
|
||||
}
|
||||
|
||||
export function normalizeAlipaySignature(sign: string): string {
|
||||
return sign.replace(/ /g, '+').trim();
|
||||
}
|
||||
|
||||
export function parseAlipayNotificationParams(
|
||||
rawBody: string | Buffer,
|
||||
headers: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const body = decodeAlipayPayload(rawBody, headers);
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
if (params.sign) {
|
||||
params.sign = normalizeAlipaySignature(params.sign);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function parseAlipayJsonResponse<T>(response: Response): Promise<T> {
|
||||
const rawBody = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
@@ -12,6 +12,53 @@ import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
import { parseAlipayNotificationParams } from './codec';
|
||||
|
||||
export interface BuildAlipayPaymentUrlInput {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
subject: string;
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string | null;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function isTradeNotExistError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message.includes('[ACQ.TRADE_NOT_EXIST]');
|
||||
}
|
||||
|
||||
function getRequiredParam(params: Record<string, string>, key: string): string {
|
||||
const value = params[key]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Alipay notification missing required field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildAlipayPaymentUrl(input: BuildAlipayPaymentUrlInput): string {
|
||||
const method = input.isMobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = input.isMobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: input.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: input.amount.toFixed(2),
|
||||
subject: input.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: input.notifyUrl,
|
||||
returnUrl: input.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAlipayEntryUrl(orderId: string): string {
|
||||
const env = getEnv();
|
||||
return new URL(`/pay/${orderId}`, env.NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay-direct';
|
||||
@@ -22,42 +69,43 @@ export class AlipayProvider implements PaymentProvider {
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const buildPayUrl = (mobile: boolean) => {
|
||||
const method = mobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = mobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let url: string;
|
||||
if (request.isMobile) {
|
||||
try {
|
||||
url = buildPayUrl(true);
|
||||
} catch {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
} else {
|
||||
url = buildPayUrl(false);
|
||||
if (!request.isMobile) {
|
||||
const entryUrl = buildAlipayEntryUrl(request.orderId);
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: entryUrl,
|
||||
qrCode: entryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return { tradeNo: request.orderId, payUrl: url };
|
||||
const payUrl = buildAlipayPaymentUrl({
|
||||
orderId: request.orderId,
|
||||
amount: request.amount,
|
||||
subject: request.subject,
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
isMobile: true,
|
||||
});
|
||||
|
||||
return { tradeNo: request.orderId, payUrl };
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
let result: AlipayTradeQueryResponse;
|
||||
try {
|
||||
result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return {
|
||||
tradeNo,
|
||||
status: 'pending',
|
||||
amount: 0,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_status) {
|
||||
@@ -80,37 +128,40 @@ export class AlipayProvider implements PaymentProvider {
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
const params = parseAlipayNotificationParams(rawBody, headers);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
// sign_type 过滤:仅接受 RSA2
|
||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||
if (params.sign_type && params.sign_type.toUpperCase() !== 'RSA2') {
|
||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const sign = getRequiredParam(params, 'sign');
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
throw new Error(
|
||||
'Alipay notification signature verification failed (check ALIPAY_PUBLIC_KEY uses Alipay public key, not app public key, and rebuild/redeploy the latest image)',
|
||||
);
|
||||
}
|
||||
|
||||
// app_id 校验
|
||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||
const tradeNo = getRequiredParam(params, 'trade_no');
|
||||
const orderId = getRequiredParam(params, 'out_trade_no');
|
||||
const tradeStatus = getRequiredParam(params, 'trade_status');
|
||||
const appId = getRequiredParam(params, 'app_id');
|
||||
|
||||
if (appId !== env.ALIPAY_APP_ID) {
|
||||
throw new Error('Alipay notification app_id mismatch');
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(getRequiredParam(params, 'total_amount'));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error('Alipay notification invalid total_amount');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
tradeNo,
|
||||
orderId,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
status: tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
@@ -130,8 +181,15 @@ export class AlipayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
try {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 将裸 base64 按 64 字符/行折行,符合 PEM 标准(OpenSSL 3.x 严格模式要求) */
|
||||
function wrapBase64(b64: string): string {
|
||||
return b64.replace(/(.{64})/g, '$1\n').trim();
|
||||
}
|
||||
|
||||
function normalizePemLikeValue(key: string): string {
|
||||
return key
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function shouldLogVerifyDebug(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || process.env.DEBUG_ALIPAY_SIGN === '1';
|
||||
}
|
||||
|
||||
/** 自动补全 PEM 格式(PKCS8) */
|
||||
function formatPrivateKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(normalized)}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${wrapBase64(normalized)}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
/** 生成 RSA2 签名 */
|
||||
/** 生成 RSA2 签名(请求签名:仅排除 sign) */
|
||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
@@ -24,15 +43,38 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名 */
|
||||
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
||||
const pem = formatPublicKey(alipayPublicKey);
|
||||
try {
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
const result = verifier.verify(pem, sign, 'base64');
|
||||
if (!result) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] FAILED. signStr:', signStr.substring(0, 200) + '...');
|
||||
console.error('[Alipay verifySign] sign(first 40):', sign.substring(0, 40));
|
||||
console.error('[Alipay verifySign] pubKey(first 80):', pem.substring(0, 80));
|
||||
} else {
|
||||
console.error('[Alipay verifySign] verification failed');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] crypto error:', err);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[Alipay verifySign] crypto error:', message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
function createPrismaClient() {
|
||||
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
|
||||
const connectionString = getEnv().DATABASE_URL;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as EasyPayCreateResponse;
|
||||
@@ -88,7 +89,9 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
||||
@@ -109,6 +112,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as EasyPayRefundResponse;
|
||||
if (data.code !== 1) {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodFeeRate } from './fee';
|
||||
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||
@@ -12,20 +13,18 @@ export function getMethodDailyLimit(paymentType: string): number {
|
||||
const env = getEnv();
|
||||
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
||||
const val = env[key];
|
||||
if (typeof val === 'number') return val; // 明确配置(含 0)
|
||||
if (typeof val === 'number') return val;
|
||||
|
||||
// 尝试从已注册的 provider 取默认值
|
||||
initPaymentProviders();
|
||||
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
|
||||
|
||||
// 兜底:process.env(支持未在 schema 中声明的动态渠道)
|
||||
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
||||
if (raw !== undefined) {
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) && num >= 0 ? num : 0;
|
||||
}
|
||||
return 0; // 默认不限制
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,21 +42,15 @@ export function getMethodSingleLimit(paymentType: string): number {
|
||||
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
|
||||
|
||||
return 0; // 使用全局 MAX_RECHARGE_AMOUNT
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface MethodLimitStatus {
|
||||
/** 每日限额,0 = 不限 */
|
||||
dailyLimit: number;
|
||||
/** 今日已使用金额 */
|
||||
used: number;
|
||||
/** 剩余每日额度,null = 不限 */
|
||||
remaining: number | null;
|
||||
/** 是否还可使用(false = 今日额度已满) */
|
||||
available: boolean;
|
||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||
singleMax: number;
|
||||
/** 手续费率百分比,0 = 无手续费 */
|
||||
feeRate: number;
|
||||
}
|
||||
|
||||
@@ -66,8 +59,7 @@ export interface MethodLimitStatus {
|
||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||
*/
|
||||
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const todayStart = getBizDayStartUTC();
|
||||
|
||||
const usageRows = await prisma.order.groupBy({
|
||||
by: ['paymentType'],
|
||||
@@ -79,7 +71,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<
|
||||
_sum: { amount: true },
|
||||
});
|
||||
|
||||
const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]));
|
||||
const usageMap = Object.fromEntries(usageRows.map((row) => [row.paymentType, Number(row._sum.amount ?? 0)]));
|
||||
|
||||
const result: Record<string, MethodLimitStatus> = {};
|
||||
for (const type of paymentTypes) {
|
||||
|
||||
@@ -6,12 +6,19 @@ import { getMethodDailyLimit } from './limits';
|
||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||
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';
|
||||
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
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 +27,7 @@ export interface CreateOrderInput {
|
||||
isMobile?: boolean;
|
||||
srcHost?: string;
|
||||
srcUrl?: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export interface CreateOrderResult {
|
||||
@@ -35,27 +43,36 @@ export interface CreateOrderResult {
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: Date;
|
||||
statusAccessToken: string;
|
||||
}
|
||||
|
||||
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
||||
const env = getEnv();
|
||||
const locale = input.locale ?? 'zh';
|
||||
const todayStart = getBizDayStartUTC();
|
||||
|
||||
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 = 不限制)
|
||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const dailyAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
@@ -67,15 +84,21 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道每日全平台限额校验(0 = 不限)
|
||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||
if (methodDailyLimit > 0) {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const methodAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
@@ -90,8 +113,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,
|
||||
);
|
||||
}
|
||||
@@ -101,41 +132,48 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const payAmount = calculatePayAmount(input.amount, feeRate);
|
||||
|
||||
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
userEmail: user.email,
|
||||
userName: user.username,
|
||||
userNotes: user.notes || null,
|
||||
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
||||
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
|
||||
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
|
||||
rechargeCode: '',
|
||||
status: 'PENDING',
|
||||
paymentType: input.paymentType,
|
||||
expiresAt,
|
||||
clientIp: input.clientIp,
|
||||
srcHost: input.srcHost || null,
|
||||
srcUrl: input.srcUrl || null,
|
||||
},
|
||||
});
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
userEmail: user.email,
|
||||
userName: user.username,
|
||||
userNotes: user.notes || null,
|
||||
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
||||
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
|
||||
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
|
||||
rechargeCode: '',
|
||||
status: 'PENDING',
|
||||
paymentType: input.paymentType,
|
||||
expiresAt,
|
||||
clientIp: input.clientIp,
|
||||
srcHost: input.srcHost || null,
|
||||
srcUrl: input.srcUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
const rechargeCode = generateRechargeCode(order.id);
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { rechargeCode },
|
||||
const rechargeCode = generateRechargeCode(created.id);
|
||||
await tx.order.update({
|
||||
where: { id: created.id },
|
||||
data: { rechargeCode },
|
||||
});
|
||||
|
||||
return { ...created, rechargeCode };
|
||||
});
|
||||
|
||||
try {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl/returnUrl,其他 provider 内部读取自己的环境变量
|
||||
const statusAccessToken = createOrderStatusAccessToken(order.id);
|
||||
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl,return_url 统一回到带访问令牌的结果页
|
||||
let notifyUrl: string | undefined;
|
||||
let returnUrl: string | undefined;
|
||||
let returnUrl: string | undefined = orderResultUrl;
|
||||
if (provider.providerKey === 'easypay') {
|
||||
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
||||
returnUrl = env.EASY_PAY_RETURN_URL || '';
|
||||
returnUrl = orderResultUrl;
|
||||
}
|
||||
|
||||
const paymentResult = await provider.createPayment({
|
||||
@@ -180,6 +218,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
qrCode: paymentResult.qrCode,
|
||||
clientSecret: paymentResult.clientSecret,
|
||||
expiresAt,
|
||||
statusAccessToken,
|
||||
};
|
||||
} catch (error) {
|
||||
await prisma.order.delete({ where: { id: order.id } });
|
||||
@@ -191,9 +230,25 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,15 +319,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,
|
||||
@@ -280,18 +336,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,
|
||||
@@ -299,7 +356,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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,14 +420,16 @@ export async function confirmPayment(input: {
|
||||
);
|
||||
}
|
||||
|
||||
// 只接受 PENDING 状态,或过期不超过 5 分钟的 EXPIRED 订单(支付在过期边缘完成的宽限窗口)
|
||||
const graceDeadline = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
id: order.id,
|
||||
status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] },
|
||||
OR: [{ status: ORDER_STATUS.PENDING }, { status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } }],
|
||||
},
|
||||
data: {
|
||||
status: ORDER_STATUS.PAID,
|
||||
amount: paidAmount,
|
||||
payAmount: paidAmount,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
failedAt: null,
|
||||
@@ -486,8 +545,8 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
`sub2apipay recharge order:${orderId}`,
|
||||
);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
await prisma.order.updateMany({
|
||||
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
|
||||
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
||||
});
|
||||
|
||||
@@ -522,13 +581,21 @@ 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) {
|
||||
@@ -536,17 +603,25 @@ 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: {
|
||||
@@ -558,10 +633,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: {
|
||||
@@ -583,30 +658,42 @@ 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',
|
||||
},
|
||||
});
|
||||
@@ -618,6 +705,7 @@ export interface RefundInput {
|
||||
orderId: string;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export interface RefundResult {
|
||||
@@ -627,10 +715,15 @@ 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);
|
||||
@@ -642,14 +735,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,
|
||||
};
|
||||
}
|
||||
@@ -660,21 +757,15 @@ 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 {
|
||||
if (order.paymentTradeNo) {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount: refundAmount,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 先扣减用户余额(安全方向:先扣后退)
|
||||
await subtractBalance(
|
||||
order.userId,
|
||||
rechargeAmount,
|
||||
@@ -682,6 +773,45 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
`sub2apipay:refund:${order.id}`,
|
||||
);
|
||||
|
||||
// 2. 调用支付网关退款
|
||||
if (order.paymentTradeNo) {
|
||||
try {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount: refundAmount,
|
||||
reason: input.reason,
|
||||
});
|
||||
} catch (gatewayError) {
|
||||
// 3. 网关退款失败 — 恢复已扣减的余额
|
||||
try {
|
||||
await addBalance(
|
||||
order.userId,
|
||||
rechargeAmount,
|
||||
`sub2apipay refund rollback order:${order.id}`,
|
||||
`sub2apipay:refund-rollback:${order.id}`,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
// 余额恢复也失败,记录审计日志,需人工介入
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
action: 'REFUND_ROLLBACK_FAILED',
|
||||
detail: JSON.stringify({
|
||||
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
|
||||
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
|
||||
rechargeAmount,
|
||||
}),
|
||||
operator: 'admin',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw gatewayError;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
|
||||
37
src/lib/order/status-access.ts
Normal file
37
src/lib/order/status-access.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import crypto from 'crypto';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
|
||||
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
|
||||
|
||||
function buildSignature(orderId: string): string {
|
||||
return crypto
|
||||
.createHmac('sha256', getEnv().ADMIN_TOKEN)
|
||||
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
export function createOrderStatusAccessToken(orderId: string): string {
|
||||
return buildSignature(orderId);
|
||||
}
|
||||
|
||||
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const expected = buildSignature(orderId);
|
||||
const expectedBuffer = Buffer.from(expected, 'utf8');
|
||||
const receivedBuffer = Buffer.from(token, 'utf8');
|
||||
|
||||
if (expectedBuffer.length !== receivedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
|
||||
}
|
||||
|
||||
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
|
||||
const url = new URL('/pay/result', appUrl);
|
||||
url.searchParams.set('order_id', orderId);
|
||||
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
|
||||
return url.toString();
|
||||
}
|
||||
15
src/lib/order/status-url.ts
Normal file
15
src/lib/order/status-url.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Client-safe utility for building order status API URLs.
|
||||
* This module must NOT import any server-only modules (config, fs, crypto, etc.).
|
||||
*/
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
|
||||
export function buildOrderStatusUrl(orderId: string, accessToken?: string | null): string {
|
||||
const query = new URLSearchParams();
|
||||
if (accessToken) {
|
||||
query.set(ACCESS_TOKEN_KEY, accessToken);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
|
||||
}
|
||||
@@ -8,6 +8,25 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface DerivedOrderState {
|
||||
paymentSuccess: boolean;
|
||||
rechargeSuccess: boolean;
|
||||
rechargeStatus: RechargeStatus;
|
||||
}
|
||||
|
||||
export interface PublicOrderStatusSnapshot extends DerivedOrderState {
|
||||
id: string;
|
||||
status: string;
|
||||
expiresAt: Date | string;
|
||||
}
|
||||
|
||||
export interface OrderDisplayState {
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
@@ -28,11 +47,7 @@ export function isRechargeRetryable(order: OrderStatusLike): boolean {
|
||||
return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status);
|
||||
}
|
||||
|
||||
export function deriveOrderState(order: OrderStatusLike): {
|
||||
paymentSuccess: boolean;
|
||||
rechargeSuccess: boolean;
|
||||
rechargeStatus: RechargeStatus;
|
||||
} {
|
||||
export function deriveOrderState(order: OrderStatusLike): DerivedOrderState {
|
||||
const paymentSuccess = hasDate(order.paidAt);
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED;
|
||||
|
||||
@@ -58,3 +73,80 @@ export function deriveOrderState(order: OrderStatusLike): {
|
||||
|
||||
return { paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'not_paid' };
|
||||
}
|
||||
|
||||
export function getOrderDisplayState(
|
||||
order: Pick<PublicOrderStatusSnapshot, 'status' | 'paymentSuccess' | 'rechargeSuccess' | 'rechargeStatus'>,
|
||||
): OrderDisplayState {
|
||||
if (order.rechargeSuccess || order.rechargeStatus === 'success') {
|
||||
return {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中,请稍候...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return {
|
||||
label: '支付成功',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.FAILED) {
|
||||
return {
|
||||
label: '支付失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.PENDING) {
|
||||
return {
|
||||
label: '等待支付',
|
||||
color: 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.EXPIRED) {
|
||||
return {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.CANCELLED) {
|
||||
return {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '支付异常',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付状态异常,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
ORDER_STATUS,
|
||||
PAYMENT_TYPE,
|
||||
PAYMENT_PREFIX,
|
||||
REDIRECT_PAYMENT_TYPES,
|
||||
} from './constants';
|
||||
import { ORDER_STATUS, PAYMENT_TYPE, PAYMENT_PREFIX, REDIRECT_PAYMENT_TYPES } from './constants';
|
||||
import type { Locale } from './locale';
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number;
|
||||
@@ -21,71 +17,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;
|
||||
}
|
||||
|
||||
@@ -96,6 +111,7 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
color: '#00AEEF',
|
||||
selectedBorder: 'border-cyan-400',
|
||||
selectedBg: 'bg-cyan-50',
|
||||
selectedBgDark: 'bg-cyan-950',
|
||||
iconBg: 'bg-[#00AEEF]',
|
||||
iconSrc: '/icons/alipay.svg',
|
||||
chartBar: { light: 'bg-cyan-500', dark: 'bg-cyan-400' },
|
||||
@@ -107,6 +123,7 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
color: '#1677FF',
|
||||
selectedBorder: 'border-blue-500',
|
||||
selectedBg: 'bg-blue-50',
|
||||
selectedBgDark: 'bg-blue-950',
|
||||
iconBg: 'bg-[#1677FF]',
|
||||
iconSrc: '/icons/alipay.svg',
|
||||
chartBar: { light: 'bg-blue-500', dark: 'bg-blue-400' },
|
||||
@@ -118,6 +135,7 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
color: '#2BB741',
|
||||
selectedBorder: 'border-green-500',
|
||||
selectedBg: 'bg-green-50',
|
||||
selectedBgDark: 'bg-green-950',
|
||||
iconBg: 'bg-[#2BB741]',
|
||||
iconSrc: '/icons/wxpay.svg',
|
||||
chartBar: { light: 'bg-green-500', dark: 'bg-green-400' },
|
||||
@@ -129,6 +147,7 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
color: '#07C160',
|
||||
selectedBorder: 'border-green-600',
|
||||
selectedBg: 'bg-green-50',
|
||||
selectedBgDark: 'bg-green-950',
|
||||
iconBg: 'bg-[#07C160]',
|
||||
iconSrc: '/icons/wxpay.svg',
|
||||
chartBar: { light: 'bg-emerald-500', dark: 'bg-emerald-400' },
|
||||
@@ -140,32 +159,61 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
color: '#635bff',
|
||||
selectedBorder: 'border-[#635bff]',
|
||||
selectedBg: 'bg-[#635bff]/10',
|
||||
selectedBgDark: 'bg-[#635bff]/20',
|
||||
iconBg: 'bg-[#635bff]',
|
||||
chartBar: { light: 'bg-purple-500', dark: 'bg-purple-400' },
|
||||
buttonClass: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
},
|
||||
};
|
||||
|
||||
/** 获取支付方式的显示名称(如 '支付宝(易支付)'),用于管理后台等需要区分的场景 */
|
||||
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;
|
||||
@@ -173,23 +221,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);
|
||||
}
|
||||
@@ -202,12 +246,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]) {
|
||||
|
||||
@@ -69,3 +69,6 @@ export function initPaymentProviders(): void {
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
// 注入 lazy init:Registry 方法会自动调用 initPaymentProviders()
|
||||
paymentRegistry.setInitializer(initPaymentProviders);
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types'
|
||||
|
||||
export class PaymentProviderRegistry {
|
||||
private providers = new Map<PaymentType, PaymentProvider>();
|
||||
private _ensureInitialized: (() => void) | null = null;
|
||||
|
||||
/** 设置 lazy init 回调,由 initPaymentProviders 注入 */
|
||||
setInitializer(fn: () => void): void {
|
||||
this._ensureInitialized = fn;
|
||||
}
|
||||
|
||||
private autoInit(): void {
|
||||
if (this._ensureInitialized) {
|
||||
this._ensureInitialized();
|
||||
}
|
||||
}
|
||||
|
||||
register(provider: PaymentProvider): void {
|
||||
for (const type of provider.supportedTypes) {
|
||||
@@ -10,6 +22,7 @@ export class PaymentProviderRegistry {
|
||||
}
|
||||
|
||||
getProvider(type: PaymentType): PaymentProvider {
|
||||
this.autoInit();
|
||||
const provider = this.providers.get(type);
|
||||
if (!provider) {
|
||||
throw new Error(`No payment provider registered for type: ${type}`);
|
||||
@@ -18,21 +31,25 @@ export class PaymentProviderRegistry {
|
||||
}
|
||||
|
||||
hasProvider(type: PaymentType): boolean {
|
||||
this.autoInit();
|
||||
return this.providers.has(type);
|
||||
}
|
||||
|
||||
getSupportedTypes(): PaymentType[] {
|
||||
this.autoInit();
|
||||
return Array.from(this.providers.keys());
|
||||
}
|
||||
|
||||
/** 获取指定渠道的提供商默认限额(未注册时返回 undefined) */
|
||||
getDefaultLimit(type: string): MethodDefaultLimits | undefined {
|
||||
this.autoInit();
|
||||
const provider = this.providers.get(type as PaymentType);
|
||||
return provider?.defaultLimits?.[type];
|
||||
}
|
||||
|
||||
/** 获取指定渠道对应的提供商 key(如 'easypay'、'stripe') */
|
||||
getProviderKey(type: string): string | undefined {
|
||||
this.autoInit();
|
||||
const provider = this.providers.get(type as PaymentType);
|
||||
return provider?.providerKey;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const RECHARGE_TIMEOUT_MS = 30_000;
|
||||
const RECHARGE_MAX_ATTEMPTS = 2;
|
||||
|
||||
function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||
const env = getEnv();
|
||||
const headers: Record<string, string> = {
|
||||
@@ -13,12 +17,18 @@ function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function isRetryableFetchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === 'TimeoutError' || error.name === 'AbortError' || error.name === 'TypeError';
|
||||
}
|
||||
|
||||
export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -33,6 +43,7 @@ export async function getUser(userId: number): Promise<Sub2ApiUser> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
||||
headers: getHeaders(),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -51,25 +62,43 @@ export async function createAndRedeem(
|
||||
notes: string,
|
||||
): Promise<Sub2ApiRedeemCode> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
type: 'balance',
|
||||
value,
|
||||
user_id: userId,
|
||||
notes,
|
||||
}),
|
||||
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
||||
const body = JSON.stringify({
|
||||
code,
|
||||
type: 'balance',
|
||||
value,
|
||||
user_id: userId,
|
||||
notes,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= RECHARGE_MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body,
|
||||
signal: AbortSignal.timeout(RECHARGE_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.redeem_code as Sub2ApiRedeemCode;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= RECHARGE_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`Sub2API createAndRedeem attempt ${attempt} timed out, retrying...`);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.redeem_code as Sub2ApiRedeemCode;
|
||||
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
|
||||
}
|
||||
|
||||
export async function subtractBalance(
|
||||
@@ -87,6 +116,7 @@ export async function subtractBalance(
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -94,3 +124,22 @@ export async function subtractBalance(
|
||||
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
operation: 'add',
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`Add balance failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/lib/time/biz-day.ts
Normal file
16
src/lib/time/biz-day.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const BIZ_TZ_NAME = 'Asia/Shanghai';
|
||||
export const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function toBizDateStr(date: Date): string {
|
||||
const local = new Date(date.getTime() + BIZ_TZ_OFFSET_MS);
|
||||
return local.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function getBizDayStartUTC(date: Date = new Date()): Date {
|
||||
return new Date(`${toBizDateStr(date)}T00:00:00+08:00`);
|
||||
}
|
||||
|
||||
export function getNextBizDayStartUTC(date: Date = new Date()): Date {
|
||||
return new Date(getBizDayStartUTC(date).getTime() + ONE_DAY_MS);
|
||||
}
|
||||
38
src/lib/utils/api.ts
Normal file
38
src/lib/utils/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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, request?: NextRequest): NextResponse {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
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 为普通对象 */
|
||||
export function extractHeaders(request: NextRequest): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import crypto from 'crypto';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { WxpayPcOrderParams, WxpayH5OrderParams, WxpayRefundParams } from './types';
|
||||
|
||||
/** 自动补全 PEM 格式(公钥) */
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://api.mch.weixin.qq.com';
|
||||
|
||||
function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
|
||||
@@ -29,7 +35,7 @@ function getPayInstance(): WxPay {
|
||||
if (!env.WXPAY_PUBLIC_KEY) {
|
||||
throw new Error('WXPAY_PUBLIC_KEY is required');
|
||||
}
|
||||
const publicKey = Buffer.from(env.WXPAY_PUBLIC_KEY);
|
||||
const publicKey = Buffer.from(formatPublicKey(env.WXPAY_PUBLIC_KEY));
|
||||
|
||||
payInstance = new WxPay({
|
||||
appid: env.WXPAY_APP_ID,
|
||||
@@ -166,5 +172,5 @@ export async function verifyNotifySign(params: {
|
||||
const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
|
||||
const verify = crypto.createVerify('RSA-SHA256');
|
||||
verify.update(message);
|
||||
return verify.verify(env.WXPAY_PUBLIC_KEY, params.signature, 'base64');
|
||||
return verify.verify(formatPublicKey(env.WXPAY_PUBLIC_KEY), params.signature, 'base64');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user