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