Merge pull request #7 from daguimu/feat/alipay-shortlink-notify-fixes

fix: harden alipay direct pay flow
This commit is contained in:
eriol touwa
2026-03-10 14:27:06 +08:00
committed by GitHub
30 changed files with 1893 additions and 437 deletions

View 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);
});
});

View 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,
});
});
});

View 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',
);
});
});

View 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');
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View 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);
});
});

View 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('等待支付');
});
});

View File

@@ -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);
});

View 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');
});
});

View File

@@ -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', () => {

View File

@@ -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();

View File

@@ -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 });
}

View File

@@ -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 使用 CUID25 位随机字符),具有足够的不可预测性,
* 暴力猜测的成本远高于信息价值。
* - 仅暴露 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,
});
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
}

View File

@@ -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

View File

@@ -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'}>

View File

@@ -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) => {

View File

@@ -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>
);

View File

@@ -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();
}

View File

@@ -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
View 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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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 从外部传入 notifyUrlreturn_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 } });

View 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();
}

View File

@@ -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: '支付状态异常,请联系管理员处理。',
};
}

View File

@@ -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
View 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);
}