Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f621713c3 | ||
|
|
abff49222b | ||
|
|
1cb82d8fd7 | ||
|
|
d6973256a7 | ||
|
|
8b10bc3bd5 |
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_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
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),
|
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';
|
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||||
|
|
||||||
describe('AlipayProvider', () => {
|
describe('AlipayProvider', () => {
|
||||||
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createPayment', () => {
|
describe('createPayment', () => {
|
||||||
it('should call pageExecute and return payUrl', async () => {
|
it('should return service short link as desktop qrCode', async () => {
|
||||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
|
||||||
|
|
||||||
const request: CreatePaymentRequest = {
|
const request: CreatePaymentRequest = {
|
||||||
orderId: 'order-001',
|
orderId: 'order-001',
|
||||||
amount: 100,
|
amount: 100,
|
||||||
paymentType: 'alipay',
|
paymentType: 'alipay_direct',
|
||||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||||
clientIp: '127.0.0.1',
|
clientIp: '127.0.0.1',
|
||||||
};
|
};
|
||||||
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
|
|||||||
const result = await provider.createPayment(request);
|
const result = await provider.createPayment(request);
|
||||||
|
|
||||||
expect(result.tradeNo).toBe('order-001');
|
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(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
out_trade_no: 'order-001',
|
out_trade_no: 'order-002',
|
||||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
product_code: 'QUICK_WAP_WAY',
|
||||||
total_amount: '100.00',
|
total_amount: '50.00',
|
||||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
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');
|
const result = await provider.queryOrder('order-004');
|
||||||
expect(result.status).toBe('failed');
|
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', () => {
|
describe('verifyNotification', () => {
|
||||||
@@ -188,7 +222,7 @@ describe('AlipayProvider', () => {
|
|||||||
trade_no: '2026030500003',
|
trade_no: '2026030500003',
|
||||||
out_trade_no: 'order-003',
|
out_trade_no: 'order-003',
|
||||||
trade_status: 'TRADE_CLOSED',
|
trade_status: 'TRADE_CLOSED',
|
||||||
total_amount: '30.00',
|
total_amount: '20.00',
|
||||||
sign: 'test_sign',
|
sign: 'test_sign',
|
||||||
sign_type: 'RSA2',
|
sign_type: 'RSA2',
|
||||||
app_id: '2021000000000000',
|
app_id: '2021000000000000',
|
||||||
@@ -198,80 +232,98 @@ describe('AlipayProvider', () => {
|
|||||||
expect(result.status).toBe('failed');
|
expect(result.status).toBe('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on invalid signature', async () => {
|
it('should reject unsupported sign_type', async () => {
|
||||||
mockVerifySign.mockReturnValue(false);
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
trade_no: '2026030500004',
|
trade_no: '2026030500004',
|
||||||
out_trade_no: 'order-004',
|
out_trade_no: 'order-004',
|
||||||
trade_status: 'TRADE_SUCCESS',
|
trade_status: 'TRADE_SUCCESS',
|
||||||
total_amount: '20.00',
|
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: 'bad_sign',
|
||||||
sign_type: 'RSA2',
|
sign_type: 'RSA2',
|
||||||
|
app_id: '2021000000000000',
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||||
'Alipay notification signature verification failed',
|
'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', () => {
|
describe('refund', () => {
|
||||||
it('should call alipay.trade.refund and return success', async () => {
|
it('should request refund and map success status', async () => {
|
||||||
mockExecute.mockResolvedValue({
|
mockExecute.mockResolvedValue({
|
||||||
code: '10000',
|
code: '10000',
|
||||||
msg: 'Success',
|
msg: 'Success',
|
||||||
trade_no: '2026030500001',
|
trade_no: 'refund-trade-no',
|
||||||
fund_change: 'Y',
|
fund_change: 'Y',
|
||||||
});
|
});
|
||||||
|
|
||||||
const request: RefundRequest = {
|
const request: RefundRequest = {
|
||||||
tradeNo: '2026030500001',
|
tradeNo: 'trade-no',
|
||||||
orderId: 'order-001',
|
orderId: 'order-refund',
|
||||||
amount: 100,
|
amount: 12.34,
|
||||||
reason: 'customer request',
|
reason: 'test refund',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await provider.refund(request);
|
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', {
|
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||||
out_trade_no: 'order-001',
|
out_trade_no: 'order-refund',
|
||||||
refund_amount: '100.00',
|
refund_amount: '12.34',
|
||||||
refund_reason: 'customer request',
|
refund_reason: 'test refund',
|
||||||
out_request_no: 'order-001-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', () => {
|
describe('cancelPayment', () => {
|
||||||
it('should call alipay.trade.close', async () => {
|
it('should close payment by out_trade_no', async () => {
|
||||||
mockExecute.mockResolvedValue({
|
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
|
||||||
code: '10000',
|
|
||||||
msg: 'Success',
|
await provider.cancelPayment('order-close');
|
||||||
trade_no: '2026030500001',
|
|
||||||
});
|
|
||||||
|
|
||||||
await provider.cancelPayment('order-001');
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
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);
|
const sign = generateSign(testParams, privateKey);
|
||||||
expect(sign).toBeTruthy();
|
expect(sign).toBeTruthy();
|
||||||
expect(typeof sign).toBe('string');
|
expect(typeof sign).toBe('string');
|
||||||
// base64 格式
|
|
||||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,16 +43,15 @@ describe('Alipay RSA2 Sign', () => {
|
|||||||
expect(sign1).toBe(sign2);
|
expect(sign1).toBe(sign2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out sign and sign_type fields', () => {
|
it('should filter out sign field but keep sign_type in request signing', () => {
|
||||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||||
const sign1 = generateSign(testParams, privateKey);
|
const sign1 = generateSign(testParams, privateKey);
|
||||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||||
expect(sign1).toBe(sign2);
|
expect(sign1).toBe(sign2);
|
||||||
|
|
||||||
// sign_type should also be excluded from signing (per Alipay spec)
|
|
||||||
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
||||||
const sign3 = generateSign(paramsWithSignType, privateKey);
|
const sign3 = generateSign(paramsWithSignType, privateKey);
|
||||||
expect(sign3).toBe(sign1);
|
expect(sign3).not.toBe(sign1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out empty values', () => {
|
it('should filter out empty values', () => {
|
||||||
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
|
|||||||
const valid = verifySign(testParams, barePublicKey, sign);
|
const valid = verifySign(testParams, barePublicKey, sign);
|
||||||
expect(valid).toBe(true);
|
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({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: mockUser }),
|
json: () => Promise.resolve({ data: mockUser }),
|
||||||
});
|
}) as typeof fetch;
|
||||||
|
|
||||||
const user = await getUser(1);
|
const user = await getUser(1);
|
||||||
expect(user.username).toBe('testuser');
|
expect(user.username).toBe('testuser');
|
||||||
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
|
|||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
});
|
}) as typeof fetch;
|
||||||
|
|
||||||
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||||
});
|
});
|
||||||
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
|
|||||||
used_by: 1,
|
used_by: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
}) as typeof fetch;
|
||||||
|
|
||||||
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||||
expect(result.code).toBe('s2p_test123');
|
expect(result.code).toBe('s2p_test123');
|
||||||
|
|
||||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
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');
|
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 () => {
|
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');
|
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||||
|
|
||||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
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.operation).toBe('subtract');
|
||||||
expect(body.amount).toBe(50);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,12 +101,7 @@ function shouldAutoRedirect(opts: {
|
|||||||
qrCode?: string | null;
|
qrCode?: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return !opts.expired && !isStripeType(opts.paymentType) && !!opts.payUrl && (opts.isMobile || !opts.qrCode);
|
||||||
!opts.expired &&
|
|
||||||
!isStripeType(opts.paymentType) &&
|
|
||||||
!!opts.payUrl &&
|
|
||||||
(opts.isMobile || !opts.qrCode)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -241,11 +236,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
provider = new AlipayProvider();
|
provider = new AlipayProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PC: uses alipay.trade.page.pay, returns payUrl only (no qrCode)', async () => {
|
it('PC: returns service short-link payUrl and qrCode', async () => {
|
||||||
mockAlipayPageExecute.mockReturnValue(
|
|
||||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=xxx',
|
|
||||||
);
|
|
||||||
|
|
||||||
const request: CreatePaymentRequest = {
|
const request: CreatePaymentRequest = {
|
||||||
orderId: 'order-ali-001',
|
orderId: 'order-ali-001',
|
||||||
amount: 100,
|
amount: 100,
|
||||||
@@ -257,20 +248,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
const result = await provider.createPayment(request);
|
const result = await provider.createPayment(request);
|
||||||
|
|
||||||
expect(result.tradeNo).toBe('order-ali-001');
|
expect(result.tradeNo).toBe('order-ali-001');
|
||||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
expect(result.payUrl).toBe('https://pay.example.com/pay/order-ali-001');
|
||||||
expect(result.qrCode).toBeUndefined();
|
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
|
||||||
|
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Verify pageExecute was called with PC method
|
|
||||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'alipay.trade.page.pay',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// PC + payUrl only (no qrCode) => shouldAutoRedirect = true (redirect to Alipay cashier page)
|
|
||||||
expect(
|
expect(
|
||||||
shouldAutoRedirect({
|
shouldAutoRedirect({
|
||||||
expired: false,
|
expired: false,
|
||||||
@@ -279,7 +260,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
qrCode: result.qrCode,
|
qrCode: result.qrCode,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
|
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
|
||||||
@@ -323,15 +304,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Mobile: falls back to PC page.pay when wap.pay throws', async () => {
|
it('Mobile: surfaces wap.pay creation errors', async () => {
|
||||||
// First call (wap.pay) throws, second call (page.pay) succeeds
|
mockAlipayPageExecute.mockImplementationOnce(() => {
|
||||||
mockAlipayPageExecute
|
throw new Error('WAP pay not available');
|
||||||
.mockImplementationOnce(() => {
|
});
|
||||||
throw new Error('WAP pay not available');
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce(
|
|
||||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=fallback',
|
|
||||||
);
|
|
||||||
|
|
||||||
const request: CreatePaymentRequest = {
|
const request: CreatePaymentRequest = {
|
||||||
orderId: 'order-ali-003',
|
orderId: 'order-ali-003',
|
||||||
@@ -341,21 +317,12 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
isMobile: true,
|
isMobile: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await provider.createPayment(request);
|
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
|
||||||
|
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
|
||||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||||
// pageExecute was called twice: first wap.pay (failed), then page.pay
|
|
||||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
|
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
|
||||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||||
);
|
);
|
||||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({ product_code: 'FAST_INSTANT_TRADE_PAY' }),
|
|
||||||
expect.objectContaining({ method: 'alipay.trade.page.pay' }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
|
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
|
||||||
@@ -409,9 +376,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
|
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
|
||||||
mockWxpayCreateH5Order.mockResolvedValue(
|
mockWxpayCreateH5Order.mockResolvedValue('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123');
|
||||||
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123',
|
|
||||||
);
|
|
||||||
|
|
||||||
const request: CreatePaymentRequest = {
|
const request: CreatePaymentRequest = {
|
||||||
orderId: 'order-wx-002',
|
orderId: 'order-wx-002',
|
||||||
|
|||||||
@@ -39,33 +39,34 @@ function DashboardContent() {
|
|||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const isEmbedded = uiMode === 'embedded';
|
const isEmbedded = uiMode === 'embedded';
|
||||||
|
|
||||||
const text = locale === 'en'
|
const text =
|
||||||
? {
|
locale === 'en'
|
||||||
missingToken: 'Missing admin token',
|
? {
|
||||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
missingToken: 'Missing admin token',
|
||||||
invalidToken: 'Invalid admin token',
|
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||||
requestFailed: 'Request failed',
|
invalidToken: 'Invalid admin token',
|
||||||
loadFailed: 'Failed to load data',
|
requestFailed: 'Request failed',
|
||||||
title: 'Dashboard',
|
loadFailed: 'Failed to load data',
|
||||||
subtitle: 'Recharge order analytics and insights',
|
title: 'Dashboard',
|
||||||
daySuffix: 'd',
|
subtitle: 'Recharge order analytics and insights',
|
||||||
orders: 'Order Management',
|
daySuffix: 'd',
|
||||||
refresh: 'Refresh',
|
orders: 'Order Management',
|
||||||
loading: 'Loading...',
|
refresh: 'Refresh',
|
||||||
}
|
loading: 'Loading...',
|
||||||
: {
|
}
|
||||||
missingToken: '缺少管理员凭证',
|
: {
|
||||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
missingToken: '缺少管理员凭证',
|
||||||
invalidToken: '管理员凭证无效',
|
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||||
requestFailed: '请求失败',
|
invalidToken: '管理员凭证无效',
|
||||||
loadFailed: '加载数据失败',
|
requestFailed: '请求失败',
|
||||||
title: '数据概览',
|
loadFailed: '加载数据失败',
|
||||||
subtitle: '充值订单统计与分析',
|
title: '数据概览',
|
||||||
daySuffix: '天',
|
subtitle: '充值订单统计与分析',
|
||||||
orders: '订单管理',
|
daySuffix: '天',
|
||||||
refresh: '刷新',
|
orders: '订单管理',
|
||||||
loading: '加载中...',
|
refresh: '刷新',
|
||||||
};
|
loading: '加载中...',
|
||||||
|
};
|
||||||
|
|
||||||
const [days, setDays] = useState<number>(30);
|
const [days, setDays] = useState<number>(30);
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
@@ -138,7 +139,8 @@ function DashboardContent() {
|
|||||||
<>
|
<>
|
||||||
{DAYS_OPTIONS.map((d) => (
|
{DAYS_OPTIONS.map((d) => (
|
||||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||||
{d}{text.daySuffix}
|
{d}
|
||||||
|
{text.daySuffix}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<a href={`/admin?${navParams}`} className={btnBase}>
|
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||||
@@ -162,7 +164,7 @@ function DashboardContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
|
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
|
||||||
@@ -190,9 +192,7 @@ function DashboardPageFallback() {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<DashboardPageFallback />}>
|
||||||
fallback={<DashboardPageFallback />}
|
|
||||||
>
|
|
||||||
<DashboardContent />
|
<DashboardContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,67 +52,68 @@ function AdminContent() {
|
|||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const isEmbedded = uiMode === 'embedded';
|
const isEmbedded = uiMode === 'embedded';
|
||||||
|
|
||||||
const text = locale === 'en'
|
const text =
|
||||||
? {
|
locale === 'en'
|
||||||
missingToken: 'Missing admin token',
|
? {
|
||||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
missingToken: 'Missing admin token',
|
||||||
invalidToken: 'Invalid admin token',
|
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||||
requestFailed: 'Request failed',
|
invalidToken: 'Invalid admin token',
|
||||||
loadOrdersFailed: 'Failed to load orders',
|
requestFailed: 'Request failed',
|
||||||
retryConfirm: 'Retry recharge for this order?',
|
loadOrdersFailed: 'Failed to load orders',
|
||||||
retryFailed: 'Retry failed',
|
retryConfirm: 'Retry recharge for this order?',
|
||||||
retryRequestFailed: 'Retry request failed',
|
retryFailed: 'Retry failed',
|
||||||
cancelConfirm: 'Cancel this order?',
|
retryRequestFailed: 'Retry request failed',
|
||||||
cancelFailed: 'Cancel failed',
|
cancelConfirm: 'Cancel this order?',
|
||||||
cancelRequestFailed: 'Cancel request failed',
|
cancelFailed: 'Cancel failed',
|
||||||
loadDetailFailed: 'Failed to load order details',
|
cancelRequestFailed: 'Cancel request failed',
|
||||||
title: 'Order Management',
|
loadDetailFailed: 'Failed to load order details',
|
||||||
subtitle: 'View and manage all recharge orders',
|
title: 'Order Management',
|
||||||
dashboard: 'Dashboard',
|
subtitle: 'View and manage all recharge orders',
|
||||||
refresh: 'Refresh',
|
dashboard: 'Dashboard',
|
||||||
loading: 'Loading...',
|
refresh: 'Refresh',
|
||||||
statuses: {
|
loading: 'Loading...',
|
||||||
'': 'All',
|
statuses: {
|
||||||
PENDING: 'Pending',
|
'': 'All',
|
||||||
PAID: 'Paid',
|
PENDING: 'Pending',
|
||||||
RECHARGING: 'Recharging',
|
PAID: 'Paid',
|
||||||
COMPLETED: 'Completed',
|
RECHARGING: 'Recharging',
|
||||||
EXPIRED: 'Expired',
|
COMPLETED: 'Completed',
|
||||||
CANCELLED: 'Cancelled',
|
EXPIRED: 'Expired',
|
||||||
FAILED: 'Recharge failed',
|
CANCELLED: 'Cancelled',
|
||||||
REFUNDED: 'Refunded',
|
FAILED: 'Recharge failed',
|
||||||
},
|
REFUNDED: 'Refunded',
|
||||||
}
|
},
|
||||||
: {
|
}
|
||||||
missingToken: '缺少管理员凭证',
|
: {
|
||||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
missingToken: '缺少管理员凭证',
|
||||||
invalidToken: '管理员凭证无效',
|
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||||
requestFailed: '请求失败',
|
invalidToken: '管理员凭证无效',
|
||||||
loadOrdersFailed: '加载订单列表失败',
|
requestFailed: '请求失败',
|
||||||
retryConfirm: '确认重试充值?',
|
loadOrdersFailed: '加载订单列表失败',
|
||||||
retryFailed: '重试失败',
|
retryConfirm: '确认重试充值?',
|
||||||
retryRequestFailed: '重试请求失败',
|
retryFailed: '重试失败',
|
||||||
cancelConfirm: '确认取消该订单?',
|
retryRequestFailed: '重试请求失败',
|
||||||
cancelFailed: '取消失败',
|
cancelConfirm: '确认取消该订单?',
|
||||||
cancelRequestFailed: '取消请求失败',
|
cancelFailed: '取消失败',
|
||||||
loadDetailFailed: '加载订单详情失败',
|
cancelRequestFailed: '取消请求失败',
|
||||||
title: '订单管理',
|
loadDetailFailed: '加载订单详情失败',
|
||||||
subtitle: '查看和管理所有充值订单',
|
title: '订单管理',
|
||||||
dashboard: '数据概览',
|
subtitle: '查看和管理所有充值订单',
|
||||||
refresh: '刷新',
|
dashboard: '数据概览',
|
||||||
loading: '加载中...',
|
refresh: '刷新',
|
||||||
statuses: {
|
loading: '加载中...',
|
||||||
'': '全部',
|
statuses: {
|
||||||
PENDING: '待支付',
|
'': '全部',
|
||||||
PAID: '已支付',
|
PENDING: '待支付',
|
||||||
RECHARGING: '充值中',
|
PAID: '已支付',
|
||||||
COMPLETED: '已完成',
|
RECHARGING: '充值中',
|
||||||
EXPIRED: '已超时',
|
COMPLETED: '已完成',
|
||||||
CANCELLED: '已取消',
|
EXPIRED: '已超时',
|
||||||
FAILED: '充值失败',
|
CANCELLED: '已取消',
|
||||||
REFUNDED: '已退款',
|
FAILED: '充值失败',
|
||||||
},
|
REFUNDED: '已退款',
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -321,7 +322,9 @@ function AdminContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Order Detail */}
|
{/* Order Detail */}
|
||||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />}
|
{detailOrder && (
|
||||||
|
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
|
||||||
|
)}
|
||||||
</PayPageLayout>
|
</PayPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,9 +342,7 @@ function AdminPageFallback() {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<AdminPageFallback />}>
|
||||||
fallback={<AdminPageFallback />}
|
|
||||||
>
|
|
||||||
<AdminContent />
|
<AdminContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,23 +3,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
import { OrderStatus } from '@prisma/client';
|
import { OrderStatus } from '@prisma/client';
|
||||||
|
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||||
/** 业务时区偏移(东八区,+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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { queryMethodLimits } from '@/lib/order/limits';
|
import { queryMethodLimits } from '@/lib/order/limits';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
|
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/limits
|
* GET /api/limits
|
||||||
@@ -13,19 +14,14 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
|||||||
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
|
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
|
||||||
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
|
* 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() {
|
export async function GET() {
|
||||||
initPaymentProviders();
|
initPaymentProviders();
|
||||||
const types = paymentRegistry.getSupportedTypes();
|
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 methods = await queryMethodLimits(types);
|
||||||
|
const resetAt = getNextBizDayStartUTC();
|
||||||
|
|
||||||
return NextResponse.json({ methods, resetAt });
|
return NextResponse.json({ methods, resetAt });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
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 两个字段。
|
* 订单状态轮询接口。
|
||||||
*
|
*
|
||||||
* 安全考虑:
|
* 返回最小必要信息供前端判断:
|
||||||
* - 订单 ID 使用 CUID(25 位随机字符),具有足够的不可预测性,
|
* - 原始订单状态(status / expiresAt)
|
||||||
* 暴力猜测的成本远高于信息价值。
|
* - 支付是否成功(paymentSuccess)
|
||||||
* - 仅暴露 status 和 expiresAt,不涉及用户隐私或金额信息。
|
* - 充值是否成功 / 当前充值阶段(rechargeSuccess / rechargeStatus)
|
||||||
* - 前端 PaymentQRCode 组件每 2 秒轮询此接口以更新支付状态,
|
|
||||||
* 添加认证会增加不必要的复杂度且影响轮询性能。
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
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({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -20,6 +27,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
expiresAt: true,
|
expiresAt: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,9 +36,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const derived = deriveOrderState(order);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: order.id,
|
id: order.id,
|
||||||
status: order.status,
|
status: order.status,
|
||||||
expiresAt: order.expiresAt,
|
expiresAt: order.expiresAt,
|
||||||
|
paymentSuccess: derived.paymentSuccess,
|
||||||
|
rechargeSuccess: derived.rechargeSuccess,
|
||||||
|
rechargeStatus: derived.rechargeStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -28,7 +31,10 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tokenUser.id !== userId) {
|
if (tokenUser.id !== userId) {
|
||||||
return NextResponse.json({ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' }, { status: 403 });
|
return NextResponse.json(
|
||||||
|
{ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
@@ -77,9 +83,7 @@ export async function GET(request: NextRequest) {
|
|||||||
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||||
helpText: env.PAY_HELP_TEXT ?? null,
|
helpText: env.PAY_HELP_TEXT ?? null,
|
||||||
stripePublishableKey:
|
stripePublishableKey:
|
||||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
|
||||||
? env.STRIPE_PUBLISHABLE_KEY
|
|
||||||
: null,
|
|
||||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -89,6 +93,9 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
|
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
console.error('Get user error:', error);
|
console.error('Get user error:', error);
|
||||||
return NextResponse.json({ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||||
}
|
}
|
||||||
const success = await handlePaymentNotify(notification, provider.name);
|
const success = await handlePaymentNotify(notification, provider.name);
|
||||||
return Response.json(
|
return Response.json(success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' }, {
|
||||||
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
|
status: success ? 200 : 500,
|
||||||
{ status: success ? 200 : 500 },
|
});
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Wxpay notify error:', error);
|
console.error('Wxpay notify error:', error);
|
||||||
return Response.json(
|
return Response.json({ code: 'FAIL', message: '处理失败' }, { status: 500 });
|
||||||
{ code: 'FAIL', message: '处理失败' },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,11 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'PingFang SC',
|
||||||
|
'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -30,8 +30,16 @@ function OrdersContent() {
|
|||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
|
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
|
||||||
visitOrders: pickLocaleText(locale, '请从 Sub2API 平台正确访问订单页面', 'Please open the orders page from Sub2API'),
|
visitOrders: pickLocaleText(
|
||||||
sessionExpired: pickLocaleText(locale, '登录态已失效,请从 Sub2API 重新进入支付页。', 'Session expired. Please re-enter from Sub2API.'),
|
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.'),
|
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
|
||||||
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
|
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
|
||||||
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
|
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
|
||||||
@@ -40,7 +48,11 @@ function OrdersContent() {
|
|||||||
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
|
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
|
||||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
||||||
userPrefix: pickLocaleText(locale, '用户', 'User'),
|
userPrefix: pickLocaleText(locale, '用户', 'User'),
|
||||||
authError: pickLocaleText(locale, '缺少认证信息,请从 Sub2API 平台正确访问订单页面', 'Missing authentication information. Please open the orders page from Sub2API.'),
|
authError: pickLocaleText(
|
||||||
|
locale,
|
||||||
|
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
|
||||||
|
'Missing authentication information. Please open the orders page from Sub2API.',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import PayPageLayout from '@/components/PayPageLayout';
|
|||||||
import MobileOrderList from '@/components/MobileOrderList';
|
import MobileOrderList from '@/components/MobileOrderList';
|
||||||
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
||||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||||
|
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||||
|
|
||||||
interface OrderResult {
|
interface OrderResult {
|
||||||
@@ -21,6 +22,7 @@ interface OrderResult {
|
|||||||
qrCode?: string | null;
|
qrCode?: string | null;
|
||||||
clientSecret?: string | null;
|
clientSecret?: string | null;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
statusAccessToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
@@ -51,7 +53,7 @@ function PayContent() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
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 [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||||
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||||
@@ -154,8 +156,7 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreOrders = async () => {
|
const loadMoreOrders = async () => {
|
||||||
@@ -184,16 +185,16 @@ function PayContent() {
|
|||||||
}, [token, locale]);
|
}, [token, locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
||||||
loadUserAndOrders();
|
loadUserAndOrders();
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setStep('form');
|
setStep('form');
|
||||||
setOrderResult(null);
|
setOrderResult(null);
|
||||||
setFinalStatus('');
|
setFinalOrderState(null);
|
||||||
setError('');
|
setError('');
|
||||||
}, 2200);
|
}, 2200);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [step, finalStatus]);
|
}, [step, finalOrderState]);
|
||||||
|
|
||||||
if (!hasToken) {
|
if (!hasToken) {
|
||||||
return (
|
return (
|
||||||
@@ -201,7 +202,11 @@ function PayContent() {
|
|||||||
<div className="text-center text-red-500">
|
<div className="text-center text-red-500">
|
||||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
{pickLocaleText(
|
||||||
|
locale,
|
||||||
|
'请从 Sub2API 平台正确访问充值页面',
|
||||||
|
'Please open the recharge page from the Sub2API platform',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +219,11 @@ function PayContent() {
|
|||||||
<div className="text-center text-red-500">
|
<div className="text-center text-red-500">
|
||||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
{pickLocaleText(
|
||||||
|
locale,
|
||||||
|
'请检查链接是否正确,或联系管理员',
|
||||||
|
'Please check whether the link is correct or contact the administrator',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,15 +279,33 @@ function PayContent() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const codeMessages: Record<string, string> = {
|
const codeMessages: Record<string, string> = {
|
||||||
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired. Please re-enter the recharge page from the platform'),
|
INVALID_TOKEN: pickLocaleText(
|
||||||
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值,请联系管理员', 'This account is disabled and cannot be recharged. Please contact the administrator'),
|
locale,
|
||||||
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'),
|
'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,
|
DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||||
PAYMENT_GATEWAY_ERROR: data.error,
|
PAYMENT_GATEWAY_ERROR: data.error,
|
||||||
};
|
};
|
||||||
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
|
setError(
|
||||||
|
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +319,7 @@ function PayContent() {
|
|||||||
qrCode: data.qrCode,
|
qrCode: data.qrCode,
|
||||||
clientSecret: data.clientSecret,
|
clientSecret: data.clientSecret,
|
||||||
expiresAt: data.expiresAt,
|
expiresAt: data.expiresAt,
|
||||||
|
statusAccessToken: data.statusAccessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
setStep('paying');
|
setStep('paying');
|
||||||
@@ -302,8 +330,8 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = (status: string) => {
|
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||||
setFinalStatus(status);
|
setFinalOrderState(order);
|
||||||
setStep('result');
|
setStep('result');
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setActiveMobileTab('orders');
|
setActiveMobileTab('orders');
|
||||||
@@ -313,7 +341,7 @@ function PayContent() {
|
|||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setStep('form');
|
setStep('form');
|
||||||
setOrderResult(null);
|
setOrderResult(null);
|
||||||
setFinalStatus('');
|
setFinalOrderState(null);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -478,11 +506,24 @@ function PayContent() {
|
|||||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||||
</div>
|
</div>
|
||||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically after the order completes')}</li>
|
<li>
|
||||||
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for payment history')}</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 && (
|
{config.maxDailyAmount > 0 && (
|
||||||
<li>
|
<li>
|
||||||
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥{config.maxDailyAmount.toFixed(2)}
|
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
|
||||||
|
{config.maxDailyAmount.toFixed(2)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -538,6 +579,7 @@ function PayContent() {
|
|||||||
amount={orderResult.amount}
|
amount={orderResult.amount}
|
||||||
payAmount={orderResult.payAmount}
|
payAmount={orderResult.payAmount}
|
||||||
expiresAt={orderResult.expiresAt}
|
expiresAt={orderResult.expiresAt}
|
||||||
|
statusAccessToken={orderResult.statusAccessToken}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
@@ -547,7 +589,17 @@ function PayContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} locale={locale} />}
|
{step === 'result' && orderResult && finalOrderState && (
|
||||||
|
<OrderStatus
|
||||||
|
orderId={orderResult.orderId}
|
||||||
|
order={finalOrderState}
|
||||||
|
statusAccessToken={orderResult.statusAccessToken}
|
||||||
|
onStateChange={setFinalOrderState}
|
||||||
|
onBack={handleBack}
|
||||||
|
dark={isDark}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{helpImageOpen && helpImageUrl && (
|
{helpImageOpen && helpImageUrl && (
|
||||||
<div
|
<div
|
||||||
@@ -579,9 +631,7 @@ function PayPageFallback() {
|
|||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense fallback={<PayPageFallback />}>
|
||||||
fallback={<PayPageFallback />}
|
|
||||||
>
|
|
||||||
<PayContent />
|
<PayContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,150 @@
|
|||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState, Suspense } from 'react';
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
|
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() {
|
function ResultContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
|
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
|
||||||
|
const accessToken = searchParams.get('access_token');
|
||||||
const isPopup = searchParams.get('popup') === '1';
|
const isPopup = searchParams.get('popup') === '1';
|
||||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
const locale = resolveLocale(searchParams.get('lang'));
|
||||||
@@ -14,30 +153,16 @@ function ResultContent() {
|
|||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
|
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
|
||||||
success: pickLocaleText(locale, '充值成功', 'Top-up successful'),
|
|
||||||
processing: pickLocaleText(locale, '充值处理中', 'Top-up processing'),
|
|
||||||
successMessage: pickLocaleText(locale, '余额已成功到账!', 'Balance has been credited successfully!'),
|
|
||||||
processingMessage: pickLocaleText(locale, '支付成功,余额正在充值中...', 'Payment succeeded, balance is being credited...'),
|
|
||||||
returning: pickLocaleText(locale, '正在返回...', 'Returning...'),
|
|
||||||
returnNow: pickLocaleText(locale, '立即返回', 'Return now'),
|
|
||||||
pending: pickLocaleText(locale, '等待支付', 'Awaiting payment'),
|
|
||||||
pendingMessage: pickLocaleText(locale, '订单尚未完成支付', 'The order has not been paid yet'),
|
|
||||||
expired: pickLocaleText(locale, '订单已超时', 'Order expired'),
|
|
||||||
cancelled: pickLocaleText(locale, '订单已取消', 'Order cancelled'),
|
|
||||||
abnormal: pickLocaleText(locale, '支付异常', 'Payment error'),
|
|
||||||
expiredMessage: pickLocaleText(locale, '订单已超时,请重新充值', 'This order has expired. Please create a new one.'),
|
|
||||||
cancelledMessage: pickLocaleText(locale, '订单已被取消', 'This order has been cancelled.'),
|
|
||||||
abnormalMessage: pickLocaleText(locale, '请联系管理员处理', 'Please contact the administrator.'),
|
|
||||||
back: pickLocaleText(locale, '返回', 'Back'),
|
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'),
|
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||||
unknown: pickLocaleText(locale, '未知', 'Unknown'),
|
unknown: pickLocaleText(locale, '未知', 'Unknown'),
|
||||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isInPopup, setIsInPopup] = useState(false);
|
const [isInPopup, setIsInPopup] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(5);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPopup || window.opener) {
|
if (isPopup || window.opener) {
|
||||||
@@ -46,17 +171,17 @@ function ResultContent() {
|
|||||||
}, [isPopup]);
|
}, [isPopup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!outTradeNo) {
|
if (!outTradeNo || !accessToken || accessToken.length < 10) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkOrder = async () => {
|
const checkOrder = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/orders/${outTradeNo}`);
|
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||||
setStatus(data.status);
|
setOrderState(data);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,13 +196,13 @@ function ResultContent() {
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [outTradeNo]);
|
}, [outTradeNo, accessToken]);
|
||||||
|
|
||||||
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
|
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (isInPopup) {
|
if (isInPopup) {
|
||||||
window.close();
|
closeCurrentWindow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,20 +218,12 @@ function ResultContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSuccess) return;
|
if (!isInPopup || !shouldAutoClose) return;
|
||||||
setCountdown(5);
|
const timer = setTimeout(() => {
|
||||||
const timer = setInterval(() => {
|
closeCurrentWindow();
|
||||||
setCountdown((prev) => {
|
}, 3000);
|
||||||
if (prev <= 1) {
|
return () => clearTimeout(timer);
|
||||||
clearInterval(timer);
|
}, [isInPopup, shouldAutoClose]);
|
||||||
goBack();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [isSuccess, isInPopup]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -116,8 +233,7 @@ function ResultContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = status === 'PENDING';
|
const display = getStatusConfig(orderState, locale, Boolean(accessToken));
|
||||||
const countdownText = countdown > 0 ? pickLocaleText(locale, `${countdown} 秒后自动返回`, `${countdown} seconds before returning`) : text.returning;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
@@ -127,58 +243,27 @@ function ResultContent() {
|
|||||||
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
|
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{isSuccess ? (
|
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
|
||||||
<>
|
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
|
||||||
<div className="text-6xl text-green-500">✓</div>
|
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
|
||||||
<h1 className="mt-4 text-xl font-bold text-green-600">{status === 'COMPLETED' ? text.success : text.processing}</h1>
|
|
||||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
{isInPopup ? (
|
||||||
{status === 'COMPLETED' ? text.successMessage : text.processingMessage}
|
shouldAutoClose && (
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{countdownText}</p>
|
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goBack}
|
onClick={closeCurrentWindow}
|
||||||
className="text-sm text-blue-600 underline hover:text-blue-700"
|
className="text-sm text-blue-600 underline hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{text.returnNow}
|
{text.closeNow}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)
|
||||||
) : isPending ? (
|
|
||||||
<>
|
|
||||||
<div className="text-6xl text-yellow-500">⏳</div>
|
|
||||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">{text.pending}</h1>
|
|
||||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{text.pendingMessage}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goBack}
|
|
||||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
|
||||||
>
|
|
||||||
{text.back}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<button type="button" onClick={goBack} className="mt-4 text-sm text-blue-600 underline hover:text-blue-700">
|
||||||
<div className="text-6xl text-red-500">✗</div>
|
{text.back}
|
||||||
<h1 className="mt-4 text-xl font-bold text-red-600">
|
</button>
|
||||||
{status === 'EXPIRED' ? text.expired : status === 'CANCELLED' ? text.cancelled : text.abnormal}
|
|
||||||
</h1>
|
|
||||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
|
||||||
{status === 'EXPIRED'
|
|
||||||
? text.expiredMessage
|
|
||||||
: status === 'CANCELLED'
|
|
||||||
? text.cancelledMessage
|
|
||||||
: text.abnormalMessage}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goBack}
|
|
||||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
|
||||||
>
|
|
||||||
{text.back}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
|
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function StripePopupContent() {
|
|||||||
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
|
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
|
||||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
const method = searchParams.get('method') || '';
|
const method = searchParams.get('method') || '';
|
||||||
|
const accessToken = searchParams.get('access_token');
|
||||||
const locale = resolveLocale(searchParams.get('lang'));
|
const locale = resolveLocale(searchParams.get('lang'));
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const isAlipay = method === 'alipay';
|
const isAlipay = method === 'alipay';
|
||||||
@@ -18,12 +19,20 @@ function StripePopupContent() {
|
|||||||
const text = {
|
const text = {
|
||||||
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
|
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
|
||||||
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||||
loadFailed: pickLocaleText(locale, '支付组件加载失败,请关闭窗口重试', 'Failed to load payment component. Please close the window and try again.'),
|
loadFailed: pickLocaleText(
|
||||||
|
locale,
|
||||||
|
'支付组件加载失败,请关闭窗口重试',
|
||||||
|
'Failed to load payment component. Please close the window and try again.',
|
||||||
|
),
|
||||||
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
|
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
|
||||||
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
|
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
|
||||||
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
|
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
|
||||||
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
|
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
|
||||||
successClosing: pickLocaleText(locale, '支付成功,窗口即将自动关闭...', 'Payment successful. This window will close automatically...'),
|
successClosing: pickLocaleText(
|
||||||
|
locale,
|
||||||
|
'支付成功,窗口即将自动关闭...',
|
||||||
|
'Payment successful. This window will close automatically...',
|
||||||
|
),
|
||||||
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
|
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
|
||||||
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
|
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
|
||||||
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
|
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
|
||||||
@@ -50,9 +59,12 @@ function StripePopupContent() {
|
|||||||
returnUrl.searchParams.set('status', 'success');
|
returnUrl.searchParams.set('status', 'success');
|
||||||
returnUrl.searchParams.set('popup', '1');
|
returnUrl.searchParams.set('popup', '1');
|
||||||
returnUrl.searchParams.set('theme', theme);
|
returnUrl.searchParams.set('theme', theme);
|
||||||
|
if (accessToken) {
|
||||||
|
returnUrl.searchParams.set('access_token', accessToken);
|
||||||
|
}
|
||||||
applyLocaleToSearchParams(returnUrl.searchParams, locale);
|
applyLocaleToSearchParams(returnUrl.searchParams, locale);
|
||||||
return returnUrl.toString();
|
return returnUrl.toString();
|
||||||
}, [orderId, theme, locale]);
|
}, [orderId, theme, locale, accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (event: MessageEvent) => {
|
const handler = (event: MessageEvent) => {
|
||||||
@@ -187,11 +199,17 @@ function StripePopupContent() {
|
|||||||
{'¥'}
|
{'¥'}
|
||||||
{amount.toFixed(2)}
|
{amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||||
|
{text.orderId}: {orderId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{stripeError ? (
|
{stripeError ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<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
|
||||||
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.close()}
|
onClick={() => window.close()}
|
||||||
@@ -203,9 +221,7 @@ function StripePopupContent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center py-8">
|
<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" />
|
<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 className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.redirecting}</span>
|
||||||
{text.redirecting}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +239,9 @@ function StripePopupContent() {
|
|||||||
{'¥'}
|
{'¥'}
|
||||||
{amount.toFixed(2)}
|
{amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||||
|
{text.orderId}: {orderId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!stripeLoaded ? (
|
{!stripeLoaded ? (
|
||||||
@@ -234,9 +252,7 @@ function StripePopupContent() {
|
|||||||
) : stripeSuccess ? (
|
) : stripeSuccess ? (
|
||||||
<div className="py-6 text-center">
|
<div className="py-6 text-center">
|
||||||
<div className="text-5xl text-green-600">{'✓'}</div>
|
<div className="text-5xl text-green-600">{'✓'}</div>
|
||||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.successClosing}</p>
|
||||||
{text.successClosing}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.close()}
|
onClick={() => window.close()}
|
||||||
@@ -248,7 +264,11 @@ function StripePopupContent() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{stripeError && (
|
{stripeError && (
|
||||||
<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
|
||||||
|
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
|
<div
|
||||||
ref={stripeContainerRef}
|
ref={stripeContainerRef}
|
||||||
@@ -260,9 +280,7 @@ function StripePopupContent() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className={[
|
className={[
|
||||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||||
stripeSubmitting
|
stripeSubmitting ? 'bg-gray-400 cursor-not-allowed' : getPaymentMeta('stripe').buttonClass,
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: getPaymentMeta('stripe').buttonClass,
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{stripeSubmitting ? (
|
{stripeSubmitting ? (
|
||||||
|
|||||||
@@ -1,100 +1,164 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { Locale } from '@/lib/locale';
|
import type { Locale } from '@/lib/locale';
|
||||||
|
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||||
|
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||||
|
|
||||||
interface OrderStatusProps {
|
interface OrderStatusProps {
|
||||||
status: string;
|
orderId: string;
|
||||||
|
order: PublicOrderStatusSnapshot;
|
||||||
|
statusAccessToken?: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onStateChange?: (order: PublicOrderStatusSnapshot) => void;
|
||||||
dark?: boolean;
|
dark?: boolean;
|
||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<Locale, Record<string, { label: string; color: string; icon: string; message: string }>> = {
|
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
|
||||||
zh: {
|
if (order.rechargeSuccess) {
|
||||||
COMPLETED: {
|
return locale === 'en'
|
||||||
label: '充值成功',
|
? {
|
||||||
color: 'text-green-600',
|
label: 'Recharge Successful',
|
||||||
icon: '✓',
|
color: 'text-green-600',
|
||||||
message: '余额已到账,感谢您的充值!',
|
icon: '✓',
|
||||||
},
|
message: 'Your balance has been credited. Thank you for your payment.',
|
||||||
PAID: {
|
}
|
||||||
label: '充值中',
|
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已到账,感谢您的充值!' };
|
||||||
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: '订单已取消。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
COMPLETED: {
|
|
||||||
label: 'Recharge Successful',
|
|
||||||
color: 'text-green-600',
|
|
||||||
icon: '✓',
|
|
||||||
message: 'Your balance has been credited. Thank you for your payment.',
|
|
||||||
},
|
|
||||||
PAID: {
|
|
||||||
label: 'Recharging',
|
|
||||||
color: 'text-blue-600',
|
|
||||||
icon: '⟳',
|
|
||||||
message: 'Payment received. Recharging your balance...',
|
|
||||||
},
|
|
||||||
RECHARGING: {
|
|
||||||
label: 'Recharging',
|
|
||||||
color: 'text-blue-600',
|
|
||||||
icon: '⟳',
|
|
||||||
message: 'Recharging your balance. Please wait...',
|
|
||||||
},
|
|
||||||
FAILED: {
|
|
||||||
label: 'Recharge Failed',
|
|
||||||
color: 'text-red-600',
|
|
||||||
icon: '✗',
|
|
||||||
message: 'Recharge failed. Please contact the administrator.',
|
|
||||||
},
|
|
||||||
EXPIRED: {
|
|
||||||
label: 'Order Expired',
|
|
||||||
color: 'text-gray-500',
|
|
||||||
icon: '⏰',
|
|
||||||
message: 'This order has expired. Please create a new order.',
|
|
||||||
},
|
|
||||||
CANCELLED: {
|
|
||||||
label: 'Cancelled',
|
|
||||||
color: 'text-gray-500',
|
|
||||||
icon: '✗',
|
|
||||||
message: 'The order has been cancelled.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrderStatus({ status, onBack, dark = false, locale = 'zh' }: OrderStatusProps) {
|
if (order.paymentSuccess) {
|
||||||
const config = STATUS_CONFIG[locale][status] || {
|
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||||
label: status,
|
return locale === 'en'
|
||||||
color: 'text-gray-600',
|
? {
|
||||||
icon: '?',
|
label: 'Recharging',
|
||||||
message: locale === 'en' ? 'Unknown status' : '未知状态',
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4 py-8">
|
<div className="flex flex-col items-center space-y-4 py-8">
|
||||||
@@ -108,7 +172,7 @@ export default function OrderStatus({ status, onBack, dark = false, locale = 'zh
|
|||||||
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{status === 'COMPLETED' ? (locale === 'en' ? 'Done' : '完成') : locale === 'en' ? 'Back to Recharge' : '返回充值'}
|
{currentOrder.rechargeSuccess ? doneLabel : backLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Locale } from '@/lib/locale';
|
import type { Locale } from '@/lib/locale';
|
||||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
import {
|
||||||
|
formatStatus,
|
||||||
|
formatCreatedAt,
|
||||||
|
getStatusBadgeClass,
|
||||||
|
getPaymentDisplayInfo,
|
||||||
|
type MyOrder,
|
||||||
|
} from '@/lib/pay-utils';
|
||||||
|
|
||||||
interface OrderTableProps {
|
interface OrderTableProps {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
@@ -98,7 +104,9 @@ export default function OrderTable({ isDark, locale, loading, error, orders }: O
|
|||||||
{formatStatus(order.status, locale)}
|
{formatStatus(order.status, locale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt, locale)}</div>
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||||
|
{formatCreatedAt(order.createdAt, locale)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,12 +223,15 @@ export default function PaymentForm({
|
|||||||
!isValid &&
|
!isValid &&
|
||||||
(() => {
|
(() => {
|
||||||
const num = parseFloat(customAmount);
|
const num = parseFloat(customAmount);
|
||||||
let msg = locale === 'en'
|
let msg =
|
||||||
? 'Amount must be within range and support up to 2 decimal places'
|
locale === 'en'
|
||||||
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
? 'Amount must be within range and support up to 2 decimal places'
|
||||||
|
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||||
if (!isNaN(num)) {
|
if (!isNaN(num)) {
|
||||||
if (num < minAmount) msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
|
if (num < minAmount)
|
||||||
else if (num > effectiveMax) msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
|
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>;
|
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||||
})()}
|
})()}
|
||||||
@@ -252,7 +255,13 @@ export default function PaymentForm({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={isUnavailable}
|
disabled={isUnavailable}
|
||||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||||
title={isUnavailable ? (locale === 'en' ? 'Daily limit reached, please use another payment method' : '今日充值额度已满,请使用其他支付方式') : undefined}
|
title={
|
||||||
|
isUnavailable
|
||||||
|
? locale === 'en'
|
||||||
|
? 'Daily limit reached, please use another payment method'
|
||||||
|
: '今日充值额度已满,请使用其他支付方式'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={[
|
className={[
|
||||||
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||||
isUnavailable
|
isUnavailable
|
||||||
@@ -260,7 +269,7 @@ export default function PaymentForm({
|
|||||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||||
: isSelected
|
: isSelected
|
||||||
? `${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`
|
? `${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
|
: dark
|
||||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
? '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',
|
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||||
@@ -271,7 +280,9 @@ export default function PaymentForm({
|
|||||||
<span className="flex flex-col items-start leading-none">
|
<span className="flex flex-col items-start leading-none">
|
||||||
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
||||||
{isUnavailable ? (
|
{isUnavailable ? (
|
||||||
<span className="text-[10px] tracking-wide text-red-400">{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}</span>
|
<span className="text-[10px] tracking-wide text-red-400">
|
||||||
|
{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}
|
||||||
|
</span>
|
||||||
) : displayInfo.sublabel ? (
|
) : displayInfo.sublabel ? (
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
||||||
@@ -292,7 +303,7 @@ export default function PaymentForm({
|
|||||||
return (
|
return (
|
||||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||||
{locale === 'en'
|
{locale === 'en'
|
||||||
? 'The selected payment method has reached today\'s limit. Please switch to another method.'
|
? "The selected payment method has reached today's limit. Please switch to another method."
|
||||||
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -331,9 +342,7 @@ export default function PaymentForm({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'rounded-lg border p-3 text-sm',
|
'rounded-lg border p-3 text-sm',
|
||||||
dark
|
dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
? 'border-amber-700 bg-amber-900/30 text-amber-300'
|
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{locale === 'en'
|
{locale === 'en'
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import type { Locale } from '@/lib/locale';
|
import type { Locale } from '@/lib/locale';
|
||||||
import {
|
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||||
isStripeType,
|
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
|
||||||
getPaymentMeta,
|
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||||
getPaymentIconSrc,
|
|
||||||
getPaymentChannelLabel,
|
|
||||||
} from '@/lib/pay-utils';
|
|
||||||
import { TERMINAL_STATUSES } from '@/lib/constants';
|
import { TERMINAL_STATUSES } from '@/lib/constants';
|
||||||
|
|
||||||
interface PaymentQRCodeProps {
|
interface PaymentQRCodeProps {
|
||||||
@@ -22,7 +19,8 @@ interface PaymentQRCodeProps {
|
|||||||
amount: number;
|
amount: number;
|
||||||
payAmount?: number;
|
payAmount?: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
onStatusChange: (status: string) => void;
|
statusAccessToken?: string;
|
||||||
|
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
dark?: boolean;
|
dark?: boolean;
|
||||||
isEmbedded?: boolean;
|
isEmbedded?: boolean;
|
||||||
@@ -30,6 +28,10 @@ interface PaymentQRCodeProps {
|
|||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
|
||||||
|
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
|
||||||
|
}
|
||||||
|
|
||||||
export default function PaymentQRCode({
|
export default function PaymentQRCode({
|
||||||
orderId,
|
orderId,
|
||||||
token,
|
token,
|
||||||
@@ -41,6 +43,7 @@ export default function PaymentQRCode({
|
|||||||
amount,
|
amount,
|
||||||
payAmount: payAmountProp,
|
payAmount: payAmountProp,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
statusAccessToken,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onBack,
|
onBack,
|
||||||
dark = false,
|
dark = false,
|
||||||
@@ -76,23 +79,38 @@ export default function PaymentQRCode({
|
|||||||
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
||||||
back: locale === 'en' ? 'Back' : '返回',
|
back: locale === 'en' ? 'Back' : '返回',
|
||||||
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
||||||
h5Hint: locale === 'en' ? 'After payment, please return to this page. The system will confirm automatically.' : '支付完成后请返回此页面,系统将自动确认',
|
h5Hint:
|
||||||
|
locale === 'en'
|
||||||
|
? 'After payment, please return to this page. The system will confirm automatically.'
|
||||||
|
: '支付完成后请返回此页面,系统将自动确认',
|
||||||
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
||||||
paidCancelBlocked:
|
paidCancelBlocked:
|
||||||
locale === 'en' ? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.' : '该订单已支付完成,无法取消。充值将自动到账。',
|
locale === 'en'
|
||||||
|
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
|
||||||
|
: '该订单已支付完成,无法取消。充值将自动到账。',
|
||||||
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
||||||
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
||||||
stripeLoadFailed: locale === 'en' ? 'Failed to load payment component. Please refresh and try again.' : '支付组件加载失败,请刷新页面重试',
|
stripeLoadFailed:
|
||||||
initFailed: locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
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...' : '正在加载支付表单...',
|
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
|
||||||
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
||||||
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
||||||
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
||||||
payNow: locale === 'en' ? 'Pay' : '支付',
|
payNow: locale === 'en' ? 'Pay' : '支付',
|
||||||
popupBlocked:
|
popupBlocked:
|
||||||
locale === 'en' ? 'Popup was blocked by your browser. Please allow popups for this site and try again.' : '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
locale === 'en'
|
||||||
|
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
|
||||||
|
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
||||||
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
||||||
redirectingSuffix: locale === 'en' ? '...' : '...',
|
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 ' : '未跳转?点击前往',
|
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
|
||||||
goPaySuffix: locale === 'en' ? '' : '',
|
goPaySuffix: locale === 'en' ? '' : '',
|
||||||
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
||||||
@@ -109,7 +127,7 @@ export default function PaymentQRCode({
|
|||||||
if (isEmbedded) {
|
if (isEmbedded) {
|
||||||
window.open(payUrl!, '_blank');
|
window.open(payUrl!, '_blank');
|
||||||
} else {
|
} else {
|
||||||
window.location.href = payUrl!;
|
window.location.replace(payUrl!);
|
||||||
}
|
}
|
||||||
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
||||||
|
|
||||||
@@ -223,6 +241,9 @@ export default function PaymentQRCode({
|
|||||||
returnUrl.search = '';
|
returnUrl.search = '';
|
||||||
returnUrl.searchParams.set('order_id', orderId);
|
returnUrl.searchParams.set('order_id', orderId);
|
||||||
returnUrl.searchParams.set('status', 'success');
|
returnUrl.searchParams.set('status', 'success');
|
||||||
|
if (statusAccessToken) {
|
||||||
|
returnUrl.searchParams.set('access_token', statusAccessToken);
|
||||||
|
}
|
||||||
if (locale === 'en') {
|
if (locale === 'en') {
|
||||||
returnUrl.searchParams.set('lang', 'en');
|
returnUrl.searchParams.set('lang', 'en');
|
||||||
}
|
}
|
||||||
@@ -254,6 +275,9 @@ export default function PaymentQRCode({
|
|||||||
popupUrl.searchParams.set('amount', String(amount));
|
popupUrl.searchParams.set('amount', String(amount));
|
||||||
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
||||||
popupUrl.searchParams.set('method', stripePaymentMethod);
|
popupUrl.searchParams.set('method', stripePaymentMethod);
|
||||||
|
if (statusAccessToken) {
|
||||||
|
popupUrl.searchParams.set('access_token', statusAccessToken);
|
||||||
|
}
|
||||||
if (locale === 'en') {
|
if (locale === 'en') {
|
||||||
popupUrl.searchParams.set('lang', 'en');
|
popupUrl.searchParams.set('lang', 'en');
|
||||||
}
|
}
|
||||||
@@ -305,16 +329,15 @@ export default function PaymentQRCode({
|
|||||||
|
|
||||||
const pollStatus = useCallback(async () => {
|
const pollStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/orders/${orderId}`);
|
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||||
if (TERMINAL_STATUSES.has(data.status)) {
|
if (isVisibleOrderOutcome(data)) {
|
||||||
onStatusChange(data.status);
|
onStatusChange(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
}, [orderId, onStatusChange, statusAccessToken]);
|
||||||
}, [orderId, onStatusChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expired) return;
|
if (expired) return;
|
||||||
@@ -326,12 +349,12 @@ export default function PaymentQRCode({
|
|||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/orders/${orderId}`);
|
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||||
|
|
||||||
if (TERMINAL_STATUSES.has(data.status)) {
|
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
|
||||||
onStatusChange(data.status);
|
onStatusChange(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,12 +369,18 @@ export default function PaymentQRCode({
|
|||||||
setCancelBlocked(true);
|
setCancelBlocked(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onStatusChange('CANCELLED');
|
onStatusChange({
|
||||||
|
id: orderId,
|
||||||
|
status: 'CANCELLED',
|
||||||
|
expiresAt,
|
||||||
|
paymentSuccess: false,
|
||||||
|
rechargeSuccess: false,
|
||||||
|
rechargeStatus: 'closed',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = getPaymentMeta(paymentType || 'alipay');
|
const meta = getPaymentMeta(paymentType || 'alipay');
|
||||||
@@ -390,7 +419,9 @@ export default function PaymentQRCode({
|
|||||||
{amount.toFixed(2)}
|
{amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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'}`}>
|
<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}`}
|
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,9 +437,7 @@ export default function PaymentQRCode({
|
|||||||
dark ? 'border-slate-700' : 'border-gray-300',
|
dark ? 'border-slate-700' : 'border-gray-300',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</p>
|
||||||
{t.initFailed}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : !stripeLoaded ? (
|
) : !stripeLoaded ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
@@ -418,10 +447,14 @@ export default function PaymentQRCode({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : stripeError && !stripeLib ? (
|
) : stripeError && !stripeLib ? (
|
||||||
<div className={[
|
<div
|
||||||
'rounded-lg border p-3 text-sm',
|
className={[
|
||||||
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
'rounded-lg border p-3 text-sm',
|
||||||
].join(' ')}>{stripeError}</div>
|
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{stripeError}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -450,9 +483,7 @@ export default function PaymentQRCode({
|
|||||||
onClick={handleStripeSubmit}
|
onClick={handleStripeSubmit}
|
||||||
className={[
|
className={[
|
||||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||||
stripeSubmitting
|
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
|
||||||
? 'cursor-not-allowed bg-gray-400'
|
|
||||||
: meta.buttonClass,
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{stripeSubmitting ? (
|
{stripeSubmitting ? (
|
||||||
@@ -483,7 +514,10 @@ export default function PaymentQRCode({
|
|||||||
) : shouldAutoRedirect ? (
|
) : shouldAutoRedirect ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-center py-6">
|
<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(' ')}>
|
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
||||||
</span>
|
</span>
|
||||||
@@ -495,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}`}
|
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" />}
|
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||||
{redirected ? `${t.notRedirectedPrefix}${channelLabel}` : `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
{redirected
|
||||||
|
? `${t.notRedirectedPrefix}${channelLabel}`
|
||||||
|
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||||
</a>
|
</a>
|
||||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</p>
|
||||||
{t.h5Hint}
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -562,9 +596,7 @@ export default function PaymentQRCode({
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className={[
|
className={[
|
||||||
'flex-1 rounded-lg border py-2 text-sm',
|
'flex-1 rounded-lg border py-2 text-sm',
|
||||||
dark
|
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
|
||||||
? 'border-red-700 text-red-400 hover:bg-red-900/30'
|
|
||||||
: 'border-red-300 text-red-600 hover:bg-red-50',
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{t.cancelOrder}
|
{t.cancelOrder}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProp
|
|||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||||
{chartTitle}
|
{chartTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
|
||||||
|
{emptyText}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,11 @@ export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProp
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={60}
|
width={60}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />} />
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="amount"
|
dataKey="amount"
|
||||||
|
|||||||
@@ -18,9 +18,20 @@ interface DashboardStatsProps {
|
|||||||
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
||||||
const currency = locale === 'en' ? '$' : '¥';
|
const currency = locale === 'en' ? '$' : '¥';
|
||||||
const cards = [
|
const cards = [
|
||||||
{ 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' ? 'Today Recharge' : '今日充值',
|
||||||
{ label: locale === 'en' ? 'Total Recharge' : '累计充值', value: `${currency}${summary.total.amount.toLocaleString()}`, accent: true },
|
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' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
|
||||||
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
||||||
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardPr
|
|||||||
<td
|
<td
|
||||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||||
>
|
>
|
||||||
{currency}{entry.totalAmount.toLocaleString()}
|
{currency}
|
||||||
|
{entry.totalAmount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className={tdMuted}>{entry.orderCount}</td>
|
<td className={tdMuted}>{entry.orderCount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -49,77 +49,78 @@ interface OrderDetailProps {
|
|||||||
|
|
||||||
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
|
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
|
||||||
const currency = locale === 'en' ? '$' : '¥';
|
const currency = locale === 'en' ? '$' : '¥';
|
||||||
const text = locale === 'en'
|
const text =
|
||||||
? {
|
locale === 'en'
|
||||||
title: 'Order Details',
|
? {
|
||||||
auditLogs: 'Audit Logs',
|
title: 'Order Details',
|
||||||
operator: 'Operator',
|
auditLogs: 'Audit Logs',
|
||||||
emptyLogs: 'No logs',
|
operator: 'Operator',
|
||||||
close: 'Close',
|
emptyLogs: 'No logs',
|
||||||
yes: 'Yes',
|
close: 'Close',
|
||||||
no: 'No',
|
yes: 'Yes',
|
||||||
orderId: 'Order ID',
|
no: 'No',
|
||||||
userId: 'User ID',
|
orderId: 'Order ID',
|
||||||
userName: 'Username',
|
userId: 'User ID',
|
||||||
email: 'Email',
|
userName: 'Username',
|
||||||
amount: 'Amount',
|
email: 'Email',
|
||||||
status: 'Status',
|
amount: 'Amount',
|
||||||
paymentSuccess: 'Payment Success',
|
status: 'Status',
|
||||||
rechargeSuccess: 'Recharge Success',
|
paymentSuccess: 'Payment Success',
|
||||||
rechargeStatus: 'Recharge Status',
|
rechargeSuccess: 'Recharge Success',
|
||||||
paymentChannel: 'Payment Channel',
|
rechargeStatus: 'Recharge Status',
|
||||||
provider: 'Provider',
|
paymentChannel: 'Payment Channel',
|
||||||
rechargeCode: 'Recharge Code',
|
provider: 'Provider',
|
||||||
paymentTradeNo: 'Payment Trade No.',
|
rechargeCode: 'Recharge Code',
|
||||||
clientIp: 'Client IP',
|
paymentTradeNo: 'Payment Trade No.',
|
||||||
sourceHost: 'Source Host',
|
clientIp: 'Client IP',
|
||||||
sourcePage: 'Source Page',
|
sourceHost: 'Source Host',
|
||||||
createdAt: 'Created At',
|
sourcePage: 'Source Page',
|
||||||
expiresAt: 'Expires At',
|
createdAt: 'Created At',
|
||||||
paidAt: 'Paid At',
|
expiresAt: 'Expires At',
|
||||||
completedAt: 'Completed At',
|
paidAt: 'Paid At',
|
||||||
failedAt: 'Failed At',
|
completedAt: 'Completed At',
|
||||||
failedReason: 'Failure Reason',
|
failedAt: 'Failed At',
|
||||||
refundAmount: 'Refund Amount',
|
failedReason: 'Failure Reason',
|
||||||
refundReason: 'Refund Reason',
|
refundAmount: 'Refund Amount',
|
||||||
refundAt: 'Refunded At',
|
refundReason: 'Refund Reason',
|
||||||
forceRefund: 'Force Refund',
|
refundAt: 'Refunded At',
|
||||||
}
|
forceRefund: 'Force Refund',
|
||||||
: {
|
}
|
||||||
title: '订单详情',
|
: {
|
||||||
auditLogs: '审计日志',
|
title: '订单详情',
|
||||||
operator: '操作者',
|
auditLogs: '审计日志',
|
||||||
emptyLogs: '暂无日志',
|
operator: '操作者',
|
||||||
close: '关闭',
|
emptyLogs: '暂无日志',
|
||||||
yes: '是',
|
close: '关闭',
|
||||||
no: '否',
|
yes: '是',
|
||||||
orderId: '订单号',
|
no: '否',
|
||||||
userId: '用户ID',
|
orderId: '订单号',
|
||||||
userName: '用户名',
|
userId: '用户ID',
|
||||||
email: '邮箱',
|
userName: '用户名',
|
||||||
amount: '金额',
|
email: '邮箱',
|
||||||
status: '状态',
|
amount: '金额',
|
||||||
paymentSuccess: '支付成功',
|
status: '状态',
|
||||||
rechargeSuccess: '充值成功',
|
paymentSuccess: '支付成功',
|
||||||
rechargeStatus: '充值状态',
|
rechargeSuccess: '充值成功',
|
||||||
paymentChannel: '支付渠道',
|
rechargeStatus: '充值状态',
|
||||||
provider: '提供商',
|
paymentChannel: '支付渠道',
|
||||||
rechargeCode: '充值码',
|
provider: '提供商',
|
||||||
paymentTradeNo: '支付单号',
|
rechargeCode: '充值码',
|
||||||
clientIp: '客户端IP',
|
paymentTradeNo: '支付单号',
|
||||||
sourceHost: '来源域名',
|
clientIp: '客户端IP',
|
||||||
sourcePage: '来源页面',
|
sourceHost: '来源域名',
|
||||||
createdAt: '创建时间',
|
sourcePage: '来源页面',
|
||||||
expiresAt: '过期时间',
|
createdAt: '创建时间',
|
||||||
paidAt: '支付时间',
|
expiresAt: '过期时间',
|
||||||
completedAt: '完成时间',
|
paidAt: '支付时间',
|
||||||
failedAt: '失败时间',
|
completedAt: '完成时间',
|
||||||
failedReason: '失败原因',
|
failedAt: '失败时间',
|
||||||
refundAmount: '退款金额',
|
failedReason: '失败原因',
|
||||||
refundReason: '退款原因',
|
refundAmount: '退款金额',
|
||||||
refundAt: '退款时间',
|
refundReason: '退款原因',
|
||||||
forceRefund: '强制退款',
|
refundAt: '退款时间',
|
||||||
};
|
forceRefund: '强制退款',
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -32,37 +32,38 @@ interface OrderTableProps {
|
|||||||
|
|
||||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
|
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
|
||||||
const currency = locale === 'en' ? '$' : '¥';
|
const currency = locale === 'en' ? '$' : '¥';
|
||||||
const text = locale === 'en'
|
const text =
|
||||||
? {
|
locale === 'en'
|
||||||
orderId: 'Order ID',
|
? {
|
||||||
userName: 'Username',
|
orderId: 'Order ID',
|
||||||
email: 'Email',
|
userName: 'Username',
|
||||||
notes: 'Notes',
|
email: 'Email',
|
||||||
amount: 'Amount',
|
notes: 'Notes',
|
||||||
status: 'Status',
|
amount: 'Amount',
|
||||||
paymentMethod: 'Payment',
|
status: 'Status',
|
||||||
source: 'Source',
|
paymentMethod: 'Payment',
|
||||||
createdAt: 'Created At',
|
source: 'Source',
|
||||||
actions: 'Actions',
|
createdAt: 'Created At',
|
||||||
retry: 'Retry',
|
actions: 'Actions',
|
||||||
cancel: 'Cancel',
|
retry: 'Retry',
|
||||||
empty: 'No orders',
|
cancel: 'Cancel',
|
||||||
}
|
empty: 'No orders',
|
||||||
: {
|
}
|
||||||
orderId: '订单号',
|
: {
|
||||||
userName: '用户名',
|
orderId: '订单号',
|
||||||
email: '邮箱',
|
userName: '用户名',
|
||||||
notes: '备注',
|
email: '邮箱',
|
||||||
amount: '金额',
|
notes: '备注',
|
||||||
status: '状态',
|
amount: '金额',
|
||||||
paymentMethod: '支付方式',
|
status: '状态',
|
||||||
source: '来源',
|
paymentMethod: '支付方式',
|
||||||
createdAt: '创建时间',
|
source: '来源',
|
||||||
actions: '操作',
|
createdAt: '创建时间',
|
||||||
retry: '重试',
|
actions: '操作',
|
||||||
cancel: '取消',
|
retry: '重试',
|
||||||
empty: '暂无订单',
|
cancel: '取消',
|
||||||
};
|
empty: '暂无订单',
|
||||||
|
};
|
||||||
|
|
||||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
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'}`;
|
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||||
@@ -133,7 +134,8 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
|||||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||||
{currency}{order.amount.toFixed(2)}
|
{currency}
|
||||||
|
{order.amount.toFixed(2)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
|||||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>{title}</h3>
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.map((method) => {
|
{data.map((method) => {
|
||||||
const meta = getPaymentMeta(method.paymentType);
|
const meta = getPaymentMeta(method.paymentType);
|
||||||
@@ -56,7 +54,8 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
|||||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
<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-300' : 'text-slate-700'}>{label}</span>
|
||||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||||
{currency}{method.amount.toLocaleString()} · {method.percentage}%
|
{currency}
|
||||||
|
{method.amount.toLocaleString()} · {method.percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -65,7 +64,10 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${method.percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,10 +74,7 @@ export default function RefundDialog({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}
|
||||||
'w-full max-w-md rounded-xl p-6 shadow-xl',
|
|
||||||
dark ? 'bg-slate-900' : 'bg-white',
|
|
||||||
].join(' ')}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||||
@@ -90,7 +87,10 @@ export default function RefundDialog({
|
|||||||
|
|
||||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
<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-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 className="text-lg font-bold text-red-600">
|
||||||
|
{currency}
|
||||||
|
{amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { generateSign } from './sign';
|
import { generateSign } from './sign';
|
||||||
import type { AlipayResponse } from './types';
|
import type { AlipayResponse } from './types';
|
||||||
|
import { parseAlipayJsonResponse } from './codec';
|
||||||
|
|
||||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
|||||||
*/
|
*/
|
||||||
export function pageExecute(
|
export function pageExecute(
|
||||||
bizContent: Record<string, unknown>,
|
bizContent: Record<string, unknown>,
|
||||||
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
|
options?: { notifyUrl?: string; returnUrl?: string | null; method?: string },
|
||||||
): string {
|
): string {
|
||||||
const env = assertAlipayEnv(getEnv());
|
const env = assertAlipayEnv(getEnv());
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export function pageExecute(
|
|||||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||||
params.notify_url = (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)!;
|
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export function pageExecute(
|
|||||||
export async function execute<T extends AlipayResponse>(
|
export async function execute<T extends AlipayResponse>(
|
||||||
method: string,
|
method: string,
|
||||||
bizContent: Record<string, unknown>,
|
bizContent: Record<string, unknown>,
|
||||||
|
options?: { notifyUrl?: string; returnUrl?: string },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const env = assertAlipayEnv(getEnv());
|
const env = assertAlipayEnv(getEnv());
|
||||||
|
|
||||||
@@ -71,6 +73,13 @@ export async function execute<T extends AlipayResponse>(
|
|||||||
biz_content: JSON.stringify(bizContent),
|
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);
|
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||||
|
|
||||||
const response = await fetch(GATEWAY, {
|
const response = await fetch(GATEWAY, {
|
||||||
@@ -80,11 +89,11 @@ export async function execute<T extends AlipayResponse>(
|
|||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
|
||||||
|
|
||||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||||
const result = data[responseKey] as T;
|
const result = data[responseKey] as T | undefined;
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
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 { verifySign } from './sign';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
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 {
|
export class AlipayProvider implements PaymentProvider {
|
||||||
readonly name = 'alipay-direct';
|
readonly name = 'alipay-direct';
|
||||||
@@ -22,42 +69,43 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||||
const buildPayUrl = (mobile: boolean) => {
|
if (!request.isMobile) {
|
||||||
const method = mobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
const entryUrl = buildAlipayEntryUrl(request.orderId);
|
||||||
const productCode = mobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
return {
|
||||||
return pageExecute(
|
tradeNo: request.orderId,
|
||||||
{
|
payUrl: entryUrl,
|
||||||
out_trade_no: request.orderId,
|
qrCode: entryUrl,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
let result: AlipayTradeQueryResponse;
|
||||||
out_trade_no: tradeNo,
|
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';
|
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||||
switch (result.trade_status) {
|
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 env = getEnv();
|
||||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
const params = parseAlipayNotificationParams(rawBody, headers);
|
||||||
const searchParams = new URLSearchParams(body);
|
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
if (params.sign_type && params.sign_type.toUpperCase() !== 'RSA2') {
|
||||||
for (const [key, value] of searchParams.entries()) {
|
|
||||||
params[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sign_type 过滤:仅接受 RSA2
|
|
||||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
|
||||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
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)) {
|
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 校验
|
const tradeNo = getRequiredParam(params, 'trade_no');
|
||||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
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');
|
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 {
|
return {
|
||||||
tradeNo: params.trade_no || '',
|
tradeNo,
|
||||||
orderId: params.out_trade_no || '',
|
orderId,
|
||||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
amount: Math.round(amount * 100) / 100,
|
||||||
status:
|
status: tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
|
||||||
rawData: params,
|
rawData: params,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -130,8 +181,15 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cancelPayment(tradeNo: string): Promise<void> {
|
async cancelPayment(tradeNo: string): Promise<void> {
|
||||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
try {
|
||||||
out_trade_no: tradeNo,
|
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||||
});
|
out_trade_no: tradeNo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isTradeNotExistError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import crypto from 'crypto';
|
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) */
|
/** 自动补全 PEM 格式(PKCS8) */
|
||||||
function formatPrivateKey(key: string): string {
|
function formatPrivateKey(key: string): string {
|
||||||
if (key.includes('-----BEGIN')) return key;
|
const normalized = normalizePemLikeValue(key);
|
||||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
if (normalized.includes('-----BEGIN')) return normalized;
|
||||||
|
return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(normalized)}\n-----END PRIVATE KEY-----`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPublicKey(key: string): string {
|
function formatPublicKey(key: string): string {
|
||||||
if (key.includes('-----BEGIN')) return key;
|
const normalized = normalizePemLikeValue(key);
|
||||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC 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 {
|
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||||
const filtered = Object.entries(params)
|
const filtered = Object.entries(params)
|
||||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||||
@@ -24,15 +43,38 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
|||||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用支付宝公钥验证签名 */
|
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||||
const filtered = Object.entries(params)
|
const filtered = Object.entries(params)
|
||||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
.filter(
|
||||||
|
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||||
|
)
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||||
|
|
||||||
const verifier = crypto.createVerify('RSA-SHA256');
|
const pem = formatPublicKey(alipayPublicKey);
|
||||||
verifier.update(signStr);
|
try {
|
||||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getEnv } from '@/lib/config';
|
|||||||
import { ORDER_STATUS } from '@/lib/constants';
|
import { ORDER_STATUS } from '@/lib/constants';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import { getMethodFeeRate } from './fee';
|
import { getMethodFeeRate } from './fee';
|
||||||
|
import { getBizDayStartUTC } from '@/lib/time/biz-day';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||||
@@ -12,20 +13,18 @@ export function getMethodDailyLimit(paymentType: string): number {
|
|||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
|
||||||
const val = env[key];
|
const val = env[key];
|
||||||
if (typeof val === 'number') return val; // 明确配置(含 0)
|
if (typeof val === 'number') return val;
|
||||||
|
|
||||||
// 尝试从已注册的 provider 取默认值
|
|
||||||
initPaymentProviders();
|
initPaymentProviders();
|
||||||
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||||
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
|
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
|
||||||
|
|
||||||
// 兜底:process.env(支持未在 schema 中声明的动态渠道)
|
|
||||||
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
|
||||||
if (raw !== undefined) {
|
if (raw !== undefined) {
|
||||||
const num = Number(raw);
|
const num = Number(raw);
|
||||||
return Number.isFinite(num) && num >= 0 ? num : 0;
|
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);
|
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
|
||||||
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
|
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
|
||||||
|
|
||||||
return 0; // 使用全局 MAX_RECHARGE_AMOUNT
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MethodLimitStatus {
|
export interface MethodLimitStatus {
|
||||||
/** 每日限额,0 = 不限 */
|
|
||||||
dailyLimit: number;
|
dailyLimit: number;
|
||||||
/** 今日已使用金额 */
|
|
||||||
used: number;
|
used: number;
|
||||||
/** 剩余每日额度,null = 不限 */
|
|
||||||
remaining: number | null;
|
remaining: number | null;
|
||||||
/** 是否还可使用(false = 今日额度已满) */
|
|
||||||
available: boolean;
|
available: boolean;
|
||||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
|
||||||
singleMax: number;
|
singleMax: number;
|
||||||
/** 手续费率百分比,0 = 无手续费 */
|
|
||||||
feeRate: number;
|
feeRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +59,7 @@ export interface MethodLimitStatus {
|
|||||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||||
*/
|
*/
|
||||||
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
||||||
const todayStart = new Date();
|
const todayStart = getBizDayStartUTC();
|
||||||
todayStart.setUTCHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const usageRows = await prisma.order.groupBy({
|
const usageRows = await prisma.order.groupBy({
|
||||||
by: ['paymentType'],
|
by: ['paymentType'],
|
||||||
@@ -79,7 +71,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<
|
|||||||
_sum: { amount: true },
|
_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> = {};
|
const result: Record<string, MethodLimitStatus> = {};
|
||||||
for (const type of paymentTypes) {
|
for (const type of paymentTypes) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { deriveOrderState, isRefundStatus } from './status';
|
import { deriveOrderState, isRefundStatus } from './status';
|
||||||
import { pickLocaleText, type Locale } from '@/lib/locale';
|
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;
|
const MAX_PENDING_ORDERS = 3;
|
||||||
|
|
||||||
@@ -41,11 +43,13 @@ export interface CreateOrderResult {
|
|||||||
qrCode?: string | null;
|
qrCode?: string | null;
|
||||||
clientSecret?: string | null;
|
clientSecret?: string | null;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
statusAccessToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const locale = input.locale ?? 'zh';
|
const locale = input.locale ?? 'zh';
|
||||||
|
const todayStart = getBizDayStartUTC();
|
||||||
|
|
||||||
const user = await getUser(input.userId);
|
const user = await getUser(input.userId);
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
@@ -58,15 +62,17 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||||
throw new OrderError(
|
throw new OrderError(
|
||||||
'TOO_MANY_PENDING',
|
'TOO_MANY_PENDING',
|
||||||
message(locale, `待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`, `Too many pending orders (${MAX_PENDING_ORDERS})`),
|
message(
|
||||||
|
locale,
|
||||||
|
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
|
||||||
|
`Too many pending orders (${MAX_PENDING_ORDERS})`,
|
||||||
|
),
|
||||||
429,
|
429,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每日累计充值限额校验(0 = 不限制)
|
// 每日累计充值限额校验(0 = 不限制)
|
||||||
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
|
||||||
const todayStart = new Date();
|
|
||||||
todayStart.setUTCHours(0, 0, 0, 0);
|
|
||||||
const dailyAgg = await prisma.order.aggregate({
|
const dailyAgg = await prisma.order.aggregate({
|
||||||
where: {
|
where: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -93,8 +99,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
// 渠道每日全平台限额校验(0 = 不限)
|
// 渠道每日全平台限额校验(0 = 不限)
|
||||||
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
|
||||||
if (methodDailyLimit > 0) {
|
if (methodDailyLimit > 0) {
|
||||||
const todayStart = new Date();
|
|
||||||
todayStart.setUTCHours(0, 0, 0, 0);
|
|
||||||
const methodAgg = await prisma.order.aggregate({
|
const methodAgg = await prisma.order.aggregate({
|
||||||
where: {
|
where: {
|
||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
@@ -161,12 +165,15 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
initPaymentProviders();
|
initPaymentProviders();
|
||||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
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 notifyUrl: string | undefined;
|
||||||
let returnUrl: string | undefined;
|
let returnUrl: string | undefined = orderResultUrl;
|
||||||
if (provider.providerKey === 'easypay') {
|
if (provider.providerKey === 'easypay') {
|
||||||
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
||||||
returnUrl = env.EASY_PAY_RETURN_URL || '';
|
returnUrl = orderResultUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentResult = await provider.createPayment({
|
const paymentResult = await provider.createPayment({
|
||||||
@@ -211,6 +218,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
qrCode: paymentResult.qrCode,
|
qrCode: paymentResult.qrCode,
|
||||||
clientSecret: paymentResult.clientSecret,
|
clientSecret: paymentResult.clientSecret,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
statusAccessToken,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prisma.order.delete({ where: { id: order.id } });
|
await prisma.order.delete({ where: { id: order.id } });
|
||||||
@@ -224,13 +232,21 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
||||||
throw new OrderError(
|
throw new OrderError(
|
||||||
'PAYMENT_GATEWAY_ERROR',
|
'PAYMENT_GATEWAY_ERROR',
|
||||||
message(locale, `支付渠道(${input.paymentType})暂未配置,请联系管理员`, `Payment method (${input.paymentType}) is not configured. Please contact the administrator`),
|
message(
|
||||||
|
locale,
|
||||||
|
`支付渠道(${input.paymentType})暂未配置,请联系管理员`,
|
||||||
|
`Payment method (${input.paymentType}) is not configured. Please contact the administrator`,
|
||||||
|
),
|
||||||
503,
|
503,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new OrderError(
|
throw new OrderError(
|
||||||
'PAYMENT_GATEWAY_ERROR',
|
'PAYMENT_GATEWAY_ERROR',
|
||||||
message(locale, '支付渠道暂时不可用,请稍后重试或更换支付方式', 'Payment method is temporarily unavailable. Please try again later or use another payment method'),
|
message(
|
||||||
|
locale,
|
||||||
|
'支付渠道暂时不可用,请稍后重试或更换支付方式',
|
||||||
|
'Payment method is temporarily unavailable. Please try again later or use another payment method',
|
||||||
|
),
|
||||||
502,
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -409,10 +425,7 @@ export async function confirmPayment(input: {
|
|||||||
const result = await prisma.order.updateMany({
|
const result = await prisma.order.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: order.id,
|
id: order.id,
|
||||||
OR: [
|
OR: [{ status: ORDER_STATUS.PENDING }, { status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } }],
|
||||||
{ status: ORDER_STATUS.PENDING },
|
|
||||||
{ status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: ORDER_STATUS.PAID,
|
status: ORDER_STATUS.PAID,
|
||||||
@@ -570,11 +583,19 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
|||||||
|
|
||||||
function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void {
|
function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void {
|
||||||
if (!order.paidAt) {
|
if (!order.paidAt) {
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '订单未支付,不允许重试', '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)) {
|
if (isRefundStatus(order.status)) {
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', '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) {
|
if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) {
|
||||||
@@ -582,14 +603,22 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }, loca
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||||
throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409);
|
throw new OrderError(
|
||||||
|
'CONFLICT',
|
||||||
|
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '仅已支付和失败订单允许重试', '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, locale: Locale = 'zh'): Promise<void> {
|
export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise<void> {
|
||||||
@@ -634,7 +663,11 @@ export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Pro
|
|||||||
|
|
||||||
const derived = deriveOrderState(latest);
|
const derived = deriveOrderState(latest);
|
||||||
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
||||||
throw new OrderError('CONFLICT', message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'), 409);
|
throw new OrderError(
|
||||||
|
'CONFLICT',
|
||||||
|
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (derived.rechargeStatus === 'success') {
|
if (derived.rechargeStatus === 'success') {
|
||||||
@@ -642,10 +675,18 @@ export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRefundStatus(latest.status)) {
|
if (isRefundStatus(latest.status)) {
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'), 400);
|
throw new OrderError(
|
||||||
|
'INVALID_STATUS',
|
||||||
|
message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'),
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409);
|
throw new OrderError(
|
||||||
|
'CONFLICT',
|
||||||
|
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
@@ -678,7 +719,11 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||||
if (order.status !== ORDER_STATUS.COMPLETED) {
|
if (order.status !== ORDER_STATUS.COMPLETED) {
|
||||||
throw new OrderError('INVALID_STATUS', message(locale, '仅已完成订单允许退款', '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);
|
const rechargeAmount = Number(order.amount);
|
||||||
@@ -712,7 +757,11 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
data: { status: ORDER_STATUS.REFUNDING },
|
data: { status: ORDER_STATUS.REFUNDING },
|
||||||
});
|
});
|
||||||
if (lockResult.count === 0) {
|
if (lockResult.count === 0) {
|
||||||
throw new OrderError('CONFLICT', message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'), 409);
|
throw new OrderError(
|
||||||
|
'CONFLICT',
|
||||||
|
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
|
||||||
|
409,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
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;
|
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>([
|
const CLOSED_STATUSES = new Set<string>([
|
||||||
ORDER_STATUS.EXPIRED,
|
ORDER_STATUS.EXPIRED,
|
||||||
ORDER_STATUS.CANCELLED,
|
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);
|
return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveOrderState(order: OrderStatusLike): {
|
export function deriveOrderState(order: OrderStatusLike): DerivedOrderState {
|
||||||
paymentSuccess: boolean;
|
|
||||||
rechargeSuccess: boolean;
|
|
||||||
rechargeStatus: RechargeStatus;
|
|
||||||
} {
|
|
||||||
const paymentSuccess = hasDate(order.paidAt);
|
const paymentSuccess = hasDate(order.paidAt);
|
||||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED;
|
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' };
|
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,4 @@
|
|||||||
import {
|
import { ORDER_STATUS, PAYMENT_TYPE, PAYMENT_PREFIX, REDIRECT_PAYMENT_TYPES } from './constants';
|
||||||
ORDER_STATUS,
|
|
||||||
PAYMENT_TYPE,
|
|
||||||
PAYMENT_PREFIX,
|
|
||||||
REDIRECT_PAYMENT_TYPES,
|
|
||||||
} from './constants';
|
|
||||||
import type { Locale } from './locale';
|
import type { Locale } from './locale';
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
@@ -211,7 +206,10 @@ export function getPaymentTypeLabel(type: string, locale: Locale = 'zh'): string
|
|||||||
return locale === 'en' ? `${meta.label} (${meta.provider})` : `${meta.label}(${meta.provider})`;
|
return locale === 'en' ? `${meta.label} (${meta.provider})` : `${meta.label}(${meta.provider})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPaymentDisplayInfo(type: string, locale: Locale = 'zh'): { channel: string; provider: string; sublabel?: string } {
|
export function getPaymentDisplayInfo(
|
||||||
|
type: string,
|
||||||
|
locale: Locale = 'zh',
|
||||||
|
): { channel: string; provider: string; sublabel?: string } {
|
||||||
const meta = getPaymentText(type, locale);
|
const meta = getPaymentText(type, locale);
|
||||||
return { channel: meta.label, provider: meta.provider, sublabel: meta.sublabel };
|
return { channel: meta.label, provider: meta.provider, sublabel: meta.sublabel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
|
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> {
|
function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -13,13 +17,18 @@ function getHeaders(idempotencyKey?: string): Record<string, string> {
|
|||||||
return headers;
|
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> {
|
export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser> {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -34,7 +43,7 @@ export async function getUser(userId: number): Promise<Sub2ApiUser> {
|
|||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -53,26 +62,43 @@ export async function createAndRedeem(
|
|||||||
notes: string,
|
notes: string,
|
||||||
): Promise<Sub2ApiRedeemCode> {
|
): Promise<Sub2ApiRedeemCode> {
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
|
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
|
||||||
method: 'POST',
|
const body = JSON.stringify({
|
||||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
code,
|
||||||
body: JSON.stringify({
|
type: 'balance',
|
||||||
code,
|
value,
|
||||||
type: 'balance',
|
user_id: userId,
|
||||||
value,
|
notes,
|
||||||
user_id: userId,
|
|
||||||
notes,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
let lastError: unknown;
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
|
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();
|
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
|
||||||
return data.redeem_code as Sub2ApiRedeemCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subtractBalance(
|
export async function subtractBalance(
|
||||||
@@ -90,7 +116,7 @@ export async function subtractBalance(
|
|||||||
amount,
|
amount,
|
||||||
notes,
|
notes,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -99,12 +125,7 @@ export async function subtractBalance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addBalance(
|
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
|
||||||
userId: number,
|
|
||||||
amount: number,
|
|
||||||
notes: string,
|
|
||||||
idempotencyKey: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -114,7 +135,7 @@ export async function addBalance(
|
|||||||
amount,
|
amount,
|
||||||
notes,
|
notes,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user