From 8b10bc3bd58454f142f2801fbee58bddb801cca2 Mon Sep 17 00:00:00 2001 From: daguimu Date: Tue, 10 Mar 2026 11:52:37 +0800 Subject: [PATCH] fix: harden alipay direct pay flow --- .../app/api/order-status-route.test.ts | 70 ++++ .../app/pay/alipay-short-link-route.test.ts | 247 ++++++++++++++ src/__tests__/lib/alipay/client.test.ts | 98 ++++++ src/__tests__/lib/alipay/codec.test.ts | 31 ++ src/__tests__/lib/alipay/provider.test.ts | 152 ++++++--- src/__tests__/lib/alipay/sign.test.ts | 36 ++- src/__tests__/lib/order/status-access.test.ts | 39 +++ src/__tests__/lib/order/status.test.ts | 66 ++++ src/__tests__/lib/sub2api/client.test.ts | 38 ++- src/__tests__/lib/time/biz-day.test.ts | 18 ++ src/__tests__/payment-flow.test.ts | 52 +-- src/app/api/admin/dashboard/route.ts | 18 +- src/app/api/limits/route.ts | 10 +- src/app/api/orders/[id]/route.ts | 28 +- src/app/pay/[orderId]/route.ts | 302 ++++++++++++++++++ src/app/pay/page.tsx | 31 +- src/app/pay/result/page.tsx | 230 ++++++++----- src/app/pay/stripe-popup/page.tsx | 6 +- src/components/OrderStatus.tsx | 201 +++++++----- src/components/PaymentQRCode.tsx | 54 +++- src/lib/alipay/client.ts | 17 +- src/lib/alipay/codec.ts | 103 ++++++ src/lib/alipay/provider.ts | 169 +++++++--- src/lib/alipay/sign.ts | 56 +++- src/lib/order/limits.ts | 20 +- src/lib/order/service.ts | 18 +- src/lib/order/status-access.ts | 37 +++ src/lib/order/status.ts | 101 +++++- src/lib/sub2api/client.ts | 66 ++-- src/lib/time/biz-day.ts | 16 + 30 files changed, 1893 insertions(+), 437 deletions(-) create mode 100644 src/__tests__/app/api/order-status-route.test.ts create mode 100644 src/__tests__/app/pay/alipay-short-link-route.test.ts create mode 100644 src/__tests__/lib/alipay/client.test.ts create mode 100644 src/__tests__/lib/alipay/codec.test.ts create mode 100644 src/__tests__/lib/order/status-access.test.ts create mode 100644 src/__tests__/lib/order/status.test.ts create mode 100644 src/__tests__/lib/time/biz-day.test.ts create mode 100644 src/app/pay/[orderId]/route.ts create mode 100644 src/lib/alipay/codec.ts create mode 100644 src/lib/order/status-access.ts create mode 100644 src/lib/time/biz-day.ts diff --git a/src/__tests__/app/api/order-status-route.test.ts b/src/__tests__/app/api/order-status-route.test.ts new file mode 100644 index 0000000..e0cf2e0 --- /dev/null +++ b/src/__tests__/app/api/order-status-route.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/app/pay/alipay-short-link-route.test.ts b/src/__tests__/app/pay/alipay-short-link-route.test.ts new file mode 100644 index 0000000..351fc98 --- /dev/null +++ b/src/__tests__/app/pay/alipay-short-link-route.test.ts @@ -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 = {}) { + 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('