Merge pull request #7 from daguimu/feat/alipay-shortlink-notify-fixes
fix: harden alipay direct pay flow
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
247
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
247
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/__tests__/lib/alipay/client.test.ts
Normal file
98
src/__tests__/lib/alipay/client.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
39
src/__tests__/lib/order/status-access.test.ts
Normal file
39
src/__tests__/lib/order/status-access.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -241,11 +241,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 +253,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 +265,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 +309,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 +322,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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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[]>([]);
|
||||
@@ -184,16 +186,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 (
|
||||
@@ -292,6 +294,7 @@ function PayContent() {
|
||||
qrCode: data.qrCode,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
|
||||
setStep('paying');
|
||||
@@ -302,8 +305,8 @@ function PayContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
setFinalStatus(status);
|
||||
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||
setFinalOrderState(order);
|
||||
setStep('result');
|
||||
if (isMobile) {
|
||||
setActiveMobileTab('orders');
|
||||
@@ -313,7 +316,7 @@ function PayContent() {
|
||||
const handleBack = () => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
@@ -538,6 +541,7 @@ function PayContent() {
|
||||
amount={orderResult.amount}
|
||||
payAmount={orderResult.payAmount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
statusAccessToken={orderResult.statusAccessToken}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
@@ -547,7 +551,18 @@ 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
|
||||
|
||||
@@ -1,12 +1,122 @@
|
||||
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
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 buildOrderStatusUrl(orderId: string, accessToken?: string | null): string {
|
||||
const query = new URLSearchParams();
|
||||
if (accessToken) {
|
||||
query.set('access_token', accessToken);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
|
||||
}
|
||||
|
||||
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 +124,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 +142,17 @@ function ResultContent() {
|
||||
}, [isPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outTradeNo) {
|
||||
if (!outTradeNo || !accessToken) {
|
||||
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 +167,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 +189,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 +204,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 +214,31 @@ 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';
|
||||
@@ -50,9 +51,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) => {
|
||||
|
||||
@@ -1,100 +1,127 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
|
||||
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 buildOrderStatusUrl(orderId: string, statusAccessToken?: string): string {
|
||||
const query = new URLSearchParams();
|
||||
if (statusAccessToken) {
|
||||
query.set('access_token', statusAccessToken);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
|
||||
}
|
||||
|
||||
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' : '未知状态',
|
||||
};
|
||||
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: '余额已到账,感谢您的充值!' };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
onStateChange?.(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, onStateChange, 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 +135,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>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import {
|
||||
isStripeType,
|
||||
getPaymentMeta,
|
||||
@@ -22,7 +23,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 +32,19 @@ interface PaymentQRCodeProps {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
|
||||
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
|
||||
}
|
||||
|
||||
function buildOrderStatusUrl(orderId: string, statusAccessToken?: string): string {
|
||||
const query = new URLSearchParams();
|
||||
if (statusAccessToken) {
|
||||
query.set('access_token', statusAccessToken);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
|
||||
}
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
token,
|
||||
@@ -41,6 +56,7 @@ export default function PaymentQRCode({
|
||||
amount,
|
||||
payAmount: payAmountProp,
|
||||
expiresAt,
|
||||
statusAccessToken,
|
||||
onStatusChange,
|
||||
onBack,
|
||||
dark = false,
|
||||
@@ -93,6 +109,7 @@ export default function PaymentQRCode({
|
||||
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 +126,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 +240,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 +274,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 +328,16 @@ 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]);
|
||||
}, [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,7 +369,14 @@ export default function PaymentQRCode({
|
||||
setCancelBlocked(true);
|
||||
return;
|
||||
}
|
||||
onStatusChange('CANCELLED');
|
||||
onStatusChange({
|
||||
id: orderId,
|
||||
status: 'CANCELLED',
|
||||
expiresAt,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'closed',
|
||||
});
|
||||
} else {
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
103
src/lib/alipay/codec.ts
Normal file
103
src/lib/alipay/codec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
@@ -11,7 +11,58 @@ import type {
|
||||
import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
import type {
|
||||
AlipayTradeQueryResponse,
|
||||
AlipayTradeRefundResponse,
|
||||
AlipayTradeCloseResponse,
|
||||
} from './types';
|
||||
import { parseAlipayNotificationParams } from './codec';
|
||||
|
||||
export interface BuildAlipayPaymentUrlInput {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
subject: string;
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string | null;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function isTradeNotExistError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message.includes('[ACQ.TRADE_NOT_EXIST]');
|
||||
}
|
||||
|
||||
function getRequiredParam(params: Record<string, string>, key: string): string {
|
||||
const value = params[key]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Alipay notification missing required field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildAlipayPaymentUrl(input: BuildAlipayPaymentUrlInput): string {
|
||||
const method = input.isMobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = input.isMobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: input.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: input.amount.toFixed(2),
|
||||
subject: input.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: input.notifyUrl,
|
||||
returnUrl: input.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAlipayEntryUrl(orderId: string): string {
|
||||
const env = getEnv();
|
||||
return new URL(`/pay/${orderId}`, env.NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay-direct';
|
||||
@@ -22,42 +73,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 +132,41 @@ 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,
|
||||
tradeNo,
|
||||
orderId,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
@@ -130,8 +186,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,35 @@
|
||||
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,7 +39,7 @@ 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)
|
||||
@@ -32,7 +47,28 @@ export function verifySign(params: Record<string, string>, alipayPublicKey: stri
|
||||
|
||||
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') {
|
||||
@@ -65,8 +69,6 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
|
||||
// 每日累计充值限额校验(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 +95,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 +161,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 +214,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 } });
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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,79 @@ 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,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) {
|
||||
@@ -114,7 +140,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