diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15eac3f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8552f6d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/stripe-node"] + path = vendor/stripe-node + url = https://github.com/stripe/stripe-node diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..cabf43b --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..54b0a2d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +.next +node_modules +vendor +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..62f91d7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2 +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0d36325..90e93f1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,7 +3,7 @@ services: image: sub2apipay:latest container_name: sub2apipay ports: - - "8087:3000" + - '8087:3000' env_file: .env networks: - sub2api-network diff --git a/docker-compose.yml b/docker-compose.yml index 808fd28..e10658c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: app: build: . ports: - - "${APP_PORT:-3001}:3000" + - '${APP_PORT:-3001}:3000' env_file: .env environment: - DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay @@ -20,7 +20,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U sub2apipay"] + test: ['CMD-SHELL', 'pg_isready -U sub2apipay'] interval: 5s restart: unless-stopped diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..6fda7e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,6 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; const eslintConfig = defineConfig([ ...nextVitals, @@ -8,10 +8,12 @@ const eslintConfig = defineConfig([ // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + // Git submodules: + 'vendor/**', ]), ]); diff --git a/next.config.ts b/next.config.ts index 225e495..94647ad 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,4 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', diff --git a/package.json b/package.json index fc73f1b..48ad98c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest" }, @@ -18,6 +21,7 @@ "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", + "stripe": "^20.4.0", "zod": "^4.3.6" }, "pnpm": { @@ -37,6 +41,7 @@ "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", + "prettier": "^3.8.1", "prisma": "7.4.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20c69e2..90ef098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + stripe: + specifier: ^20.4.0 + version: 20.4.0(@types/node@20.19.35) zod: specifier: ^4.3.6 version: 4.3.6 @@ -60,6 +63,9 @@ importers: eslint-config-next: specifier: 16.1.6 version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + prettier: + specifier: ^3.8.1 + version: 3.8.1 prisma: specifier: 7.4.1 version: 7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -2436,6 +2442,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + prisma@7.4.1: resolution: {integrity: sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -2694,6 +2705,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@20.4.0: + resolution: {integrity: sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5301,6 +5321,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.8.1: {} + prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): dependencies: '@prisma/config': 7.4.1 @@ -5651,6 +5673,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@20.4.0(@types/node@20.19.35): + optionalDependencies: + '@types/node': 20.19.35 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): dependencies: client-only: 0.0.1 diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..297374d 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/src/__tests__/lib/payment/registry.test.ts b/src/__tests__/lib/payment/registry.test.ts new file mode 100644 index 0000000..bd0d744 --- /dev/null +++ b/src/__tests__/lib/payment/registry.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { + PaymentProvider, + PaymentType, + CreatePaymentRequest, + CreatePaymentResponse, + QueryOrderResponse, + PaymentNotification, + RefundRequest, + RefundResponse, +} from '@/lib/payment/types'; + +class MockProvider implements PaymentProvider { + readonly name: string; + readonly supportedTypes: PaymentType[]; + + constructor(name: string, types: PaymentType[]) { + this.name = name; + this.supportedTypes = types; + } + + async createPayment(_request: CreatePaymentRequest): Promise { + return { tradeNo: 'mock-trade-no' }; + } + async queryOrder(_tradeNo: string): Promise { + return { tradeNo: 'mock', status: 'pending', amount: 0 }; + } + async verifyNotification(_rawBody: string | Buffer, _headers: Record): Promise { + return { tradeNo: 'mock', orderId: 'mock', amount: 0, status: 'success', rawData: {} }; + } + async refund(_request: RefundRequest): Promise { + return { refundId: 'mock', status: 'success' }; + } +} + +import { PaymentProviderRegistry } from '@/lib/payment/registry'; + +describe('PaymentProviderRegistry', () => { + let registry: PaymentProviderRegistry; + + beforeEach(() => { + registry = new PaymentProviderRegistry(); + }); + + it('should register and retrieve a provider', () => { + const provider = new MockProvider('test-pay', ['alipay']); + registry.register(provider); + expect(registry.getProvider('alipay')).toBe(provider); + }); + + it('should throw for unregistered payment type', () => { + expect(() => registry.getProvider('stripe')).toThrow('No payment provider registered for type: stripe'); + }); + + it('should register a provider for multiple types', () => { + const provider = new MockProvider('multi-pay', ['alipay', 'wxpay']); + registry.register(provider); + expect(registry.getProvider('alipay')).toBe(provider); + expect(registry.getProvider('wxpay')).toBe(provider); + }); + + it('hasProvider should return correct boolean', () => { + expect(registry.hasProvider('stripe')).toBe(false); + const provider = new MockProvider('stripe-mock', ['stripe']); + registry.register(provider); + expect(registry.hasProvider('stripe')).toBe(true); + }); + + it('getSupportedTypes should list registered types', () => { + const p1 = new MockProvider('easy', ['alipay', 'wxpay']); + const p2 = new MockProvider('stripe', ['stripe']); + registry.register(p1); + registry.register(p2); + const types = registry.getSupportedTypes(); + expect(types).toContain('alipay'); + expect(types).toContain('wxpay'); + expect(types).toContain('stripe'); + }); + + it('later registration should override earlier for same type', () => { + const p1 = new MockProvider('old-provider', ['alipay']); + const p2 = new MockProvider('new-provider', ['alipay']); + registry.register(p1); + registry.register(p2); + expect(registry.getProvider('alipay').name).toBe('new-provider'); + }); +}); diff --git a/src/__tests__/lib/stripe/provider.test.ts b/src/__tests__/lib/stripe/provider.test.ts new file mode 100644 index 0000000..68c9389 --- /dev/null +++ b/src/__tests__/lib/stripe/provider.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/config', () => ({ + getEnv: () => ({ + STRIPE_SECRET_KEY: 'sk_test_fake_key', + STRIPE_WEBHOOK_SECRET: 'whsec_test_fake_secret', + NEXT_PUBLIC_APP_URL: 'https://pay.example.com', + ORDER_TIMEOUT_MINUTES: 5, + }), +})); + +const mockSessionCreate = vi.fn(); +const mockSessionRetrieve = vi.fn(); +const mockRefundCreate = vi.fn(); +const mockWebhooksConstructEvent = vi.fn(); + +vi.mock('stripe', () => { + const StripeMock = function (this: Record) { + this.checkout = { + sessions: { + create: mockSessionCreate, + retrieve: mockSessionRetrieve, + }, + }; + this.refunds = { + create: mockRefundCreate, + }; + this.webhooks = { + constructEvent: mockWebhooksConstructEvent, + }; + }; + return { default: StripeMock }; +}); + +import { StripeProvider } from '@/lib/stripe/provider'; +import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types'; + +describe('StripeProvider', () => { + let provider: StripeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new StripeProvider(); + }); + + describe('metadata', () => { + it('should have name "stripe"', () => { + expect(provider.name).toBe('stripe'); + }); + + it('should support "stripe" payment type', () => { + expect(provider.supportedTypes).toEqual(['stripe']); + }); + }); + + describe('createPayment', () => { + it('should create a checkout session and return checkoutUrl', async () => { + mockSessionCreate.mockResolvedValue({ + id: 'cs_test_abc123', + url: 'https://checkout.stripe.com/pay/cs_test_abc123', + }); + + const request: CreatePaymentRequest = { + orderId: 'order-001', + amount: 99.99, + paymentType: 'stripe', + subject: 'Sub2API Balance Recharge 99.99 CNY', + clientIp: '127.0.0.1', + }; + + const result = await provider.createPayment(request); + + expect(result.tradeNo).toBe('cs_test_abc123'); + expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123'); + expect(mockSessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'payment', + payment_method_types: ['card'], + metadata: { orderId: 'order-001' }, + expires_at: expect.any(Number), + line_items: [ + expect.objectContaining({ + price_data: expect.objectContaining({ + currency: 'cny', + unit_amount: 9999, + }), + quantity: 1, + }), + ], + }), + expect.objectContaining({ + idempotencyKey: 'checkout-order-001', + }), + ); + }); + + it('should handle session with null url', async () => { + mockSessionCreate.mockResolvedValue({ + id: 'cs_test_no_url', + url: null, + }); + + const request: CreatePaymentRequest = { + orderId: 'order-002', + amount: 10, + paymentType: 'stripe', + subject: 'Test', + }; + + const result = await provider.createPayment(request); + expect(result.tradeNo).toBe('cs_test_no_url'); + expect(result.checkoutUrl).toBeUndefined(); + }); + }); + + describe('queryOrder', () => { + it('should return paid status for paid session', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_abc123', + payment_status: 'paid', + amount_total: 9999, + }); + + const result = await provider.queryOrder('cs_test_abc123'); + expect(result.tradeNo).toBe('cs_test_abc123'); + expect(result.status).toBe('paid'); + expect(result.amount).toBe(99.99); + }); + + it('should return failed status for expired session', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_expired', + payment_status: 'unpaid', + status: 'expired', + amount_total: 5000, + }); + + const result = await provider.queryOrder('cs_test_expired'); + expect(result.status).toBe('failed'); + expect(result.amount).toBe(50); + }); + + it('should return pending status for unpaid session', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_pending', + payment_status: 'unpaid', + status: 'open', + amount_total: 1000, + }); + + const result = await provider.queryOrder('cs_test_pending'); + expect(result.status).toBe('pending'); + }); + }); + + describe('verifyNotification', () => { + it('should verify and parse checkout.session.completed event', async () => { + const mockEvent = { + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_test_abc123', + metadata: { orderId: 'order-001' }, + amount_total: 9999, + payment_status: 'paid', + }, + }, + }; + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' }); + + expect(result).not.toBeNull(); + expect(result!.tradeNo).toBe('cs_test_abc123'); + expect(result!.orderId).toBe('order-001'); + expect(result!.amount).toBe(99.99); + expect(result!.status).toBe('success'); + }); + + it('should return failed status for unpaid session', async () => { + const mockEvent = { + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_test_unpaid', + metadata: { orderId: 'order-002' }, + amount_total: 5000, + payment_status: 'unpaid', + }, + }, + }; + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await provider.verifyNotification('body', { 'stripe-signature': 'sig' }); + expect(result).not.toBeNull(); + expect(result!.status).toBe('failed'); + }); + + it('should return null for unhandled event types', async () => { + mockWebhooksConstructEvent.mockReturnValue({ + type: 'payment_intent.created', + data: { object: {} }, + }); + + const result = await provider.verifyNotification('body', { 'stripe-signature': 'sig' }); + expect(result).toBeNull(); + }); + }); + + describe('refund', () => { + it('should refund via payment intent from session', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_abc123', + payment_intent: 'pi_test_payment_intent', + }); + + mockRefundCreate.mockResolvedValue({ + id: 're_test_refund_001', + status: 'succeeded', + }); + + const request: RefundRequest = { + tradeNo: 'cs_test_abc123', + orderId: 'order-001', + amount: 50, + reason: 'customer request', + }; + + const result = await provider.refund(request); + expect(result.refundId).toBe('re_test_refund_001'); + expect(result.status).toBe('success'); + expect(mockRefundCreate).toHaveBeenCalledWith({ + payment_intent: 'pi_test_payment_intent', + amount: 5000, + reason: 'requested_by_customer', + }); + }); + + it('should handle payment intent as object', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_abc123', + payment_intent: { id: 'pi_test_obj_intent', amount: 10000 }, + }); + + mockRefundCreate.mockResolvedValue({ + id: 're_test_refund_002', + status: 'pending', + }); + + const result = await provider.refund({ + tradeNo: 'cs_test_abc123', + orderId: 'order-002', + amount: 100, + }); + + expect(result.status).toBe('pending'); + expect(mockRefundCreate).toHaveBeenCalledWith( + expect.objectContaining({ + payment_intent: 'pi_test_obj_intent', + }), + ); + }); + + it('should throw if no payment intent found', async () => { + mockSessionRetrieve.mockResolvedValue({ + id: 'cs_test_no_pi', + payment_intent: null, + }); + + await expect( + provider.refund({ + tradeNo: 'cs_test_no_pi', + orderId: 'order-003', + amount: 20, + }), + ).rejects.toThrow('No payment intent found'); + }); + }); +}); diff --git a/src/__tests__/lib/sub2api/client.test.ts b/src/__tests__/lib/sub2api/client.test.ts index 30139c8..5f3d01b 100644 --- a/src/__tests__/lib/sub2api/client.test.ts +++ b/src/__tests__/lib/sub2api/client.test.ts @@ -45,17 +45,18 @@ describe('Sub2API Client', () => { it('createAndRedeem should send correct request', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - code: 1, - redeem_code: { - id: 1, - code: 's2p_test123', - type: 'balance', - value: 100, - status: 'used', - used_by: 1, - }, - }), + json: () => + Promise.resolve({ + code: 1, + redeem_code: { + id: 1, + code: 's2p_test123', + type: 'balance', + value: 100, + status: 'used', + used_by: 1, + }, + }), }); const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes'); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8860d25..3a4bb0f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,11 +5,42 @@ import { useState, useEffect, useCallback, Suspense } from 'react'; import OrderTable from '@/components/admin/OrderTable'; import OrderDetail from '@/components/admin/OrderDetail'; +interface AdminOrder { + id: string; + userId: number; + userName: string | null; + userEmail: string | null; + amount: number; + status: string; + paymentType: string; + createdAt: string; + paidAt: string | null; + completedAt: string | null; + failedReason: string | null; + expiresAt: string; +} + +interface AdminOrderDetail extends AdminOrder { + rechargeCode: string; + paymentTradeNo: string | null; + refundAmount: number | null; + refundReason: string | null; + refundAt: string | null; + forceRefund: boolean; + failedAt: string | null; + updatedAt: string; + clientIp: string | null; + paymentSuccess?: boolean; + rechargeSuccess?: boolean; + rechargeStatus?: string; + auditLogs: { id: string; action: string; detail: string | null; operator: string | null; createdAt: string }[]; +} + function AdminContent() { const searchParams = useSearchParams(); const token = searchParams.get('token'); - const [orders, setOrders] = useState([]); + const [orders, setOrders] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); @@ -17,8 +48,7 @@ function AdminContent() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - // Dialogs - const [detailOrder, setDetailOrder] = useState(null); + const [detailOrder, setDetailOrder] = useState(null); const fetchOrders = useCallback(async () => { if (!token) return; @@ -134,7 +164,9 @@ function AdminContent() { {error && (
{error} - +
)} @@ -143,11 +175,12 @@ function AdminContent() { {statuses.map((s) => ( - {page} / {totalPages} + + {page} / {totalPages} + - ))} + + ); + })} @@ -192,8 +247,12 @@ export default function PaymentForm({ disabled={!isValid || loading} className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${ isValid && !loading - ? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800' - : dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300' + ? paymentType === 'stripe' + ? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]' + : 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800' + : dark + ? 'cursor-not-allowed bg-slate-700 text-slate-300' + : 'cursor-not-allowed bg-gray-300' }`} > {loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`} diff --git a/src/components/PaymentQRCode.tsx b/src/components/PaymentQRCode.tsx index 4e676d8..bce2a7c 100644 --- a/src/components/PaymentQRCode.tsx +++ b/src/components/PaymentQRCode.tsx @@ -7,7 +7,8 @@ interface PaymentQRCodeProps { orderId: string; payUrl?: string | null; qrCode?: string | null; - paymentType?: 'alipay' | 'wxpay'; + checkoutUrl?: string | null; + paymentType?: 'alipay' | 'wxpay' | 'stripe'; amount: number; expiresAt: string; onStatusChange: (status: string) => void; @@ -23,10 +24,20 @@ const TEXT_BACK = '\u8FD4\u56DE'; const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355'; const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']); +function isSafeCheckoutUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' && parsed.hostname.endsWith('.stripe.com'); + } catch { + return false; + } +} + export default function PaymentQRCode({ orderId, payUrl, qrCode, + checkoutUrl, paymentType, amount, expiresAt, @@ -38,6 +49,7 @@ export default function PaymentQRCode({ const [expired, setExpired] = useState(false); const [qrDataUrl, setQrDataUrl] = useState(''); const [imageLoading, setImageLoading] = useState(false); + const [stripeOpened, setStripeOpened] = useState(false); const qrPayload = useMemo(() => { const value = (qrCode || payUrl || '').trim(); @@ -124,13 +136,14 @@ export default function PaymentQRCode({ const handleCancel = async () => { try { const res = await fetch(`/api/orders/${orderId}`); - if (res.ok) { - const data = await res.json(); - await fetch(`/api/orders/${orderId}/cancel`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user_id: data.user_id }), - }); + if (!res.ok) return; + const data = await res.json(); + const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: data.user_id }), + }); + if (cancelRes.ok) { onStatusChange('CANCELLED'); } } catch { @@ -138,10 +151,11 @@ export default function PaymentQRCode({ } }; + const isStripe = paymentType === 'stripe'; const isWx = paymentType === 'wxpay'; - const iconSrc = isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; - const channelLabel = isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; - const iconBgClass = isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; + const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; + const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; + const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; return (
@@ -154,44 +168,91 @@ export default function PaymentQRCode({ {!expired && ( <> - {qrDataUrl && ( -
- {imageLoading && ( -
-
+ {isStripe ? ( + <> + + {stripeOpened && ( + + )} +

+ {!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) + ? '\u652F\u4ED8\u94FE\u63A5\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u8FD4\u56DE\u91CD\u8BD5' + : '\u5728\u65B0\u7A97\u53E3\u5B8C\u6210\u652F\u4ED8\u540E\uFF0C\u6B64\u9875\u9762\u5C06\u81EA\u52A8\u66F4\u65B0'} +

+ + ) : ( + <> + {qrDataUrl && ( +
+ {imageLoading && ( +
+
+
+ )} + payment qrcode +
+ + {channelLabel} + +
)} - payment qrcode -
- - {channelLabel} - -
-
- )} - {!qrDataUrl && payUrl && ( - - {TEXT_GO_PAY} - - )} + {!qrDataUrl && payUrl && ( + + {TEXT_GO_PAY} + + )} - {!qrDataUrl && !payUrl && ( -
-
-

{TEXT_SCAN_PAY}

-
-
- )} + {!qrDataUrl && !payUrl && ( +
+
+

{TEXT_SCAN_PAY}

+
+
+ )} -

- {`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`} -

+

+ {`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`} +

+ + )} )} diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index 86d64d5..18d8d4b 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -75,11 +75,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >

订单详情

- +
@@ -99,21 +101,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
{log.action} - - {new Date(log.createdAt).toLocaleString('zh-CN')} - + {new Date(log.createdAt).toLocaleString('zh-CN')}
- {log.detail && ( -
{log.detail}
- )} - {log.operator && ( -
操作者: {log.operator}
- )} + {log.detail &&
{log.detail}
} + {log.operator &&
操作者: {log.operator}
}
))} - {order.auditLogs.length === 0 && ( -
暂无日志
- )} + {order.auditLogs.length === 0 &&
暂无日志
}
diff --git a/src/components/admin/OrderTable.tsx b/src/components/admin/OrderTable.tsx index d01bfa4..5844140 100644 --- a/src/components/admin/OrderTable.tsx +++ b/src/components/admin/OrderTable.tsx @@ -55,14 +55,14 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }: {orders.map((order) => { - const statusInfo = STATUS_LABELS[order.status] || { label: order.status, className: 'bg-gray-100 text-gray-800' }; + const statusInfo = STATUS_LABELS[order.status] || { + label: order.status, + className: 'bg-gray-100 text-gray-800', + }; return ( - @@ -70,9 +70,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
{order.userName || '-'}
{order.userEmail || `ID: ${order.userId}`}
- - ¥{order.amount.toFixed(2)} - + ¥{order.amount.toFixed(2)} {statusInfo.label} @@ -109,9 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }: })} - {orders.length === 0 && ( -
暂无订单
- )} + {orders.length === 0 &&
暂无订单
}
); } diff --git a/src/components/admin/RefundDialog.tsx b/src/components/admin/RefundDialog.tsx index bc93e51..a50676b 100644 --- a/src/components/admin/RefundDialog.tsx +++ b/src/components/admin/RefundDialog.tsx @@ -34,7 +34,7 @@ export default function RefundDialog({ return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

确认退款

@@ -48,11 +48,7 @@ export default function RefundDialog({
¥{amount.toFixed(2)}
- {warning && ( -
- {warning} -
- )} + {warning &&
{warning}
}
diff --git a/src/lib/config.ts b/src/lib/config.ts index 8541a99..b68a1d8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -12,16 +12,24 @@ const envSchema = z.object({ SUB2API_BASE_URL: z.string().url(), SUB2API_ADMIN_API_KEY: z.string().min(1), - EASY_PAY_PID: z.string().min(1), - EASY_PAY_PKEY: z.string().min(1), - EASY_PAY_API_BASE: z.string().url(), - EASY_PAY_NOTIFY_URL: z.string().url(), - EASY_PAY_RETURN_URL: z.string().url(), + // ── Easy-Pay (optional when only using Stripe) ── + EASY_PAY_PID: optionalTrimmedString, + EASY_PAY_PKEY: optionalTrimmedString, + EASY_PAY_API_BASE: optionalTrimmedString, + EASY_PAY_NOTIFY_URL: optionalTrimmedString, + EASY_PAY_RETURN_URL: optionalTrimmedString, EASY_PAY_CID: optionalTrimmedString, EASY_PAY_CID_ALIPAY: optionalTrimmedString, EASY_PAY_CID_WXPAY: optionalTrimmedString, - ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())), + STRIPE_SECRET_KEY: optionalTrimmedString, + STRIPE_PUBLISHABLE_KEY: optionalTrimmedString, + STRIPE_WEBHOOK_SECRET: optionalTrimmedString, + + ENABLED_PAYMENT_TYPES: z + .string() + .default('alipay,wxpay') + .transform((v) => v.split(',').map((s) => s.trim())), ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()), MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()), diff --git a/src/lib/easy-pay/client.ts b/src/lib/easy-pay/client.ts index a84f455..e3ab35a 100644 --- a/src/lib/easy-pay/client.ts +++ b/src/lib/easy-pay/client.ts @@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined { return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID); } +function assertEasyPayEnv(env: ReturnType) { + if ( + !env.EASY_PAY_PID || + !env.EASY_PAY_PKEY || + !env.EASY_PAY_API_BASE || + !env.EASY_PAY_NOTIFY_URL || + !env.EASY_PAY_RETURN_URL + ) { + throw new Error( + 'EasyPay environment variables (EASY_PAY_PID, EASY_PAY_PKEY, EASY_PAY_API_BASE, EASY_PAY_NOTIFY_URL, EASY_PAY_RETURN_URL) are required', + ); + } + return env as typeof env & { + EASY_PAY_PID: string; + EASY_PAY_PKEY: string; + EASY_PAY_API_BASE: string; + EASY_PAY_NOTIFY_URL: string; + EASY_PAY_RETURN_URL: string; + }; +} + export async function createPayment(opts: CreatePaymentOptions): Promise { - const env = getEnv(); + const env = assertEasyPayEnv(getEnv()); const params: Record = { pid: env.EASY_PAY_PID, type: opts.paymentType, @@ -57,7 +78,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise { - const env = getEnv(); + const env = assertEasyPayEnv(getEnv()); const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`; const response = await fetch(url); - const data = await response.json() as EasyPayQueryResponse; + const data = (await response.json()) as EasyPayQueryResponse; if (data.code !== 1) { throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`); } @@ -76,7 +97,7 @@ export async function queryOrder(outTradeNo: string): Promise { - const env = getEnv(); + const env = assertEasyPayEnv(getEnv()); const params = new URLSearchParams({ pid: env.EASY_PAY_PID, key: env.EASY_PAY_PKEY, @@ -89,7 +110,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string) body: params, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); - const data = await response.json() as EasyPayRefundResponse; + const data = (await response.json()) as EasyPayRefundResponse; if (data.code !== 1) { throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`); } diff --git a/src/lib/easy-pay/provider.ts b/src/lib/easy-pay/provider.ts new file mode 100644 index 0000000..f849cd6 --- /dev/null +++ b/src/lib/easy-pay/provider.ts @@ -0,0 +1,87 @@ +import type { + PaymentProvider, + PaymentType, + CreatePaymentRequest, + CreatePaymentResponse, + QueryOrderResponse, + PaymentNotification, + RefundRequest, + RefundResponse, +} from '@/lib/payment/types'; +import { createPayment, queryOrder, refund } from './client'; +import { verifySign } from './sign'; +import { getEnv } from '@/lib/config'; + +export class EasyPayProvider implements PaymentProvider { + readonly name = 'easy-pay'; + readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay']; + + async createPayment(request: CreatePaymentRequest): Promise { + const result = await createPayment({ + outTradeNo: request.orderId, + amount: request.amount.toFixed(2), + paymentType: request.paymentType as 'alipay' | 'wxpay', + clientIp: request.clientIp || '127.0.0.1', + productName: request.subject, + }); + + return { + tradeNo: result.trade_no, + payUrl: result.payurl, + qrCode: result.qrcode, + }; + } + + async queryOrder(tradeNo: string): Promise { + const result = await queryOrder(tradeNo); + return { + tradeNo: result.trade_no, + status: result.status === 1 ? 'paid' : 'pending', + amount: parseFloat(result.money), + paidAt: result.endtime ? new Date(result.endtime) : undefined, + }; + } + + async verifyNotification(rawBody: string | Buffer, _headers: Record): Promise { + const env = getEnv(); + const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8'); + const searchParams = new URLSearchParams(body); + + const params: Record = {}; + for (const [key, value] of searchParams.entries()) { + params[key] = value; + } + + const sign = params.sign || ''; + const paramsForSign: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) { + paramsForSign[key] = value; + } + } + + if (!env.EASY_PAY_PKEY || !verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) { + throw new Error('EasyPay notification signature verification failed'); + } + + return { + tradeNo: params.trade_no || '', + orderId: params.out_trade_no || '', + amount: parseFloat(params.money || '0'), + status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed', + rawData: params, + }; + } + + async refund(request: RefundRequest): Promise { + await refund(request.tradeNo, request.orderId, request.amount.toFixed(2)); + return { + refundId: `${request.tradeNo}-refund`, + status: 'success', + }; + } + + async cancelPayment(): Promise { + // EasyPay does not support cancelling payments + } +} diff --git a/src/lib/easy-pay/sign.ts b/src/lib/easy-pay/sign.ts index 55b3834..a937609 100644 --- a/src/lib/easy-pay/sign.ts +++ b/src/lib/easy-pay/sign.ts @@ -2,7 +2,9 @@ import crypto from 'crypto'; export function generateSign(params: Record, pkey: 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' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null, + ) .sort(([a], [b]) => a.localeCompare(b)); const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&'); diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index 7c113eb..a218e74 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -1,12 +1,10 @@ import { prisma } from '@/lib/db'; import { getEnv } from '@/lib/config'; import { generateRechargeCode } from './code-gen'; -import { createPayment } from '@/lib/easy-pay/client'; -import { verifySign } from '@/lib/easy-pay/sign'; -import { refund as easyPayRefund } from '@/lib/easy-pay/client'; +import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; +import type { PaymentType, PaymentNotification } from '@/lib/payment'; import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client'; import { Prisma } from '@prisma/client'; -import type { EasyPayNotifyParams } from '@/lib/easy-pay/types'; import { deriveOrderState, isRefundStatus } from './status'; const MAX_PENDING_ORDERS = 3; @@ -14,7 +12,7 @@ const MAX_PENDING_ORDERS = 3; export interface CreateOrderInput { userId: number; amount: number; - paymentType: 'alipay' | 'wxpay'; + paymentType: PaymentType; clientIp: string; } @@ -22,11 +20,12 @@ export interface CreateOrderResult { orderId: string; amount: number; status: string; - paymentType: 'alipay' | 'wxpay'; + paymentType: PaymentType; userName: string; userBalance: number; payUrl?: string | null; qrCode?: string | null; + checkoutUrl?: string | null; expiresAt: Date; } @@ -67,20 +66,24 @@ export async function createOrder(input: CreateOrderInput): Promise { }); } -export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise { - const env = getEnv(); - - const { sign, ...rest } = params; - const paramsForSign: Record = {}; - for (const [key, value] of Object.entries(rest)) { - if (value !== undefined && value !== null) { - paramsForSign[key] = String(value); - } - } - if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) { - console.error('EasyPay notify: invalid signature'); - return false; - } - - if (params.trade_status !== 'TRADE_SUCCESS') { - return true; - } - +/** + * Provider-agnostic: confirm a payment and trigger recharge. + * Called by any provider's webhook/notify handler after verification. + */ +export async function confirmPayment(input: { + orderId: string; + tradeNo: string; + paidAmount: number; + providerName: string; +}): Promise { const order = await prisma.order.findUnique({ - where: { id: params.out_trade_no }, + where: { id: input.orderId }, }); if (!order) { - console.error('EasyPay notify: order not found:', params.out_trade_no); + console.error(`${input.providerName} notify: order not found:`, input.orderId); return false; } let paidAmount: Prisma.Decimal; try { - paidAmount = new Prisma.Decimal(params.money); + paidAmount = new Prisma.Decimal(input.paidAmount.toFixed(2)); } catch { - console.error('EasyPay notify: invalid money format:', params.money); + console.error(`${input.providerName} notify: invalid amount:`, input.paidAmount); return false; } if (paidAmount.lte(0)) { - console.error('EasyPay notify: non-positive money:', params.money); + console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount); return false; } if (!paidAmount.equals(order.amount)) { - console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money); + console.warn( + `${input.providerName} notify: amount changed, use paid amount`, + order.amount.toString(), + paidAmount.toString(), + ); } const result = await prisma.order.updateMany({ @@ -205,7 +204,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise< data: { status: 'PAID', amount: paidAmount, - paymentTradeNo: params.trade_no, + paymentTradeNo: input.tradeNo, paidAt: new Date(), failedAt: null, failedReason: null, @@ -222,25 +221,41 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise< action: 'ORDER_PAID', detail: JSON.stringify({ previous_status: order.status, - trade_no: params.trade_no, + trade_no: input.tradeNo, expected_amount: order.amount.toString(), paid_amount: paidAmount.toString(), }), - operator: 'easy-pay', + operator: input.providerName, }, }); try { - // Recharge inline to avoid "paid but still recharging" async gaps. await executeRecharge(order.id); } catch (err) { - // Payment has been confirmed, always ack notify to avoid endless retries from gateway. console.error('Recharge failed for order:', order.id, err); } return true; } +/** + * Handle a verified payment notification from any provider. + * The caller (webhook route) is responsible for verifying the notification + * via provider.verifyNotification() before calling this function. + */ +export async function handlePaymentNotify(notification: PaymentNotification, providerName: string): Promise { + if (notification.status !== 'success') { + return true; + } + + return confirmPayment({ + orderId: notification.orderId, + tradeNo: notification.tradeNo, + paidAmount: notification.amount, + providerName, + }); +} + export async function executeRecharge(orderId: string): Promise { const order = await prisma.order.findUnique({ where: { id: orderId } }); if (!order) { @@ -442,15 +457,17 @@ export async function processRefund(input: RefundInput): Promise { try { if (order.paymentTradeNo) { - await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2)); + initPaymentProviders(); + const provider = paymentRegistry.getProvider(order.paymentType as PaymentType); + await provider.refund({ + tradeNo: order.paymentTradeNo, + orderId: order.id, + amount, + reason: input.reason, + }); } - await subtractBalance( - order.userId, - amount, - `sub2apipay refund order:${order.id}`, - `sub2apipay:refund:${order.id}`, - ); + await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`); await prisma.order.update({ where: { id: input.orderId }, diff --git a/src/lib/order/status.ts b/src/lib/order/status.ts index 008d159..237bdd6 100644 --- a/src/lib/order/status.ts +++ b/src/lib/order/status.ts @@ -1,10 +1,4 @@ -export type RechargeStatus = - | 'not_paid' - | 'paid_pending' - | 'recharging' - | 'success' - | 'failed' - | 'closed'; +export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed'; export interface OrderStatusLike { status: string; @@ -12,13 +6,7 @@ export interface OrderStatusLike { completedAt?: Date | string | null; } -const CLOSED_STATUSES = new Set([ - 'EXPIRED', - 'CANCELLED', - 'REFUNDING', - 'REFUNDED', - 'REFUND_FAILED', -]); +const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']); const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']); diff --git a/src/lib/order/timeout.ts b/src/lib/order/timeout.ts index 00b3562..a3f165e 100644 --- a/src/lib/order/timeout.ts +++ b/src/lib/order/timeout.ts @@ -1,22 +1,83 @@ import { prisma } from '@/lib/db'; +import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; +import type { PaymentType } from '@/lib/payment'; +import { confirmPayment } from './service'; const INTERVAL_MS = 30_000; // 30 seconds let timer: ReturnType | null = null; export async function expireOrders(): Promise { - const result = await prisma.order.updateMany({ + const orders = await prisma.order.findMany({ where: { status: 'PENDING', expiresAt: { lt: new Date() }, }, - data: { status: 'EXPIRED' }, + select: { + id: true, + paymentTradeNo: true, + paymentType: true, + }, }); - if (result.count > 0) { - console.log(`Expired ${result.count} orders`); + if (orders.length === 0) return 0; + + let expiredCount = 0; + + for (const order of orders) { + try { + // If order has a payment on the platform, check its actual status + if (order.paymentTradeNo && order.paymentType) { + try { + initPaymentProviders(); + const provider = paymentRegistry.getProvider(order.paymentType as PaymentType); + + // Query the real payment status before expiring + const queryResult = await provider.queryOrder(order.paymentTradeNo); + + if (queryResult.status === 'paid') { + // User already paid — process as success instead of expiring + await confirmPayment({ + orderId: order.id, + tradeNo: order.paymentTradeNo, + paidAmount: queryResult.amount, + providerName: provider.name, + }); + console.log(`Order ${order.id} was paid during timeout, processed as success`); + continue; + } + + // Not paid — cancel on the platform + if (provider.cancelPayment) { + try { + await provider.cancelPayment(order.paymentTradeNo); + } catch (cancelErr) { + // Cancel may fail if session already expired on platform side — that's fine + console.warn(`Failed to cancel payment for order ${order.id}:`, cancelErr); + } + } + } catch (platformErr) { + // Platform unreachable — still expire the order locally + console.warn(`Platform check failed for order ${order.id}, expiring anyway:`, platformErr); + } + } + + // Mark as expired in database (WHERE status='PENDING' ensures idempotency) + const result = await prisma.order.updateMany({ + where: { id: order.id, status: 'PENDING' }, + data: { status: 'EXPIRED' }, + }); + + if (result.count > 0) expiredCount++; + } catch (err) { + console.error(`Error expiring order ${order.id}:`, err); + } } - return result.count; + if (expiredCount > 0) { + console.log(`Expired ${expiredCount} orders`); + } + + return expiredCount; } export function startTimeoutScheduler(): void { diff --git a/src/lib/pay-utils.ts b/src/lib/pay-utils.ts index da223f4..da0112e 100644 --- a/src/lib/pay-utils.ts +++ b/src/lib/pay-utils.ts @@ -56,6 +56,41 @@ export function formatCreatedAt(value: string): string { return date.toLocaleString(); } +export interface PaymentTypeMeta { + label: string; + sublabel?: string; + color: string; + selectedBorder: string; + selectedBg: string; + iconBg: string; +} + +export const PAYMENT_TYPE_META: Record = { + alipay: { + label: '支付宝', + sublabel: 'ALIPAY', + color: '#00AEEF', + selectedBorder: 'border-cyan-400', + selectedBg: 'bg-cyan-50', + iconBg: 'bg-[#00AEEF]', + }, + wxpay: { + label: '微信支付', + color: '#2BB741', + selectedBorder: 'border-green-500', + selectedBg: 'bg-green-50', + iconBg: 'bg-[#2BB741]', + }, + stripe: { + label: 'Stripe', + sublabel: '信用卡 / 借记卡', + color: '#635bff', + selectedBorder: 'border-[#635bff]', + selectedBg: 'bg-[#635bff]/10', + iconBg: 'bg-[#635bff]', + }, +}; + export function getStatusBadgeClass(status: string, isDark: boolean): string { if (['COMPLETED', 'PAID'].includes(status)) { return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700'; diff --git a/src/lib/payment/index.ts b/src/lib/payment/index.ts new file mode 100644 index 0000000..fdccca2 --- /dev/null +++ b/src/lib/payment/index.ts @@ -0,0 +1,30 @@ +import { paymentRegistry } from './registry'; +import { EasyPayProvider } from '@/lib/easy-pay/provider'; +import { StripeProvider } from '@/lib/stripe/provider'; +import { getEnv } from '@/lib/config'; + +export { paymentRegistry } from './registry'; +export type { + PaymentType, + PaymentProvider, + CreatePaymentRequest, + CreatePaymentResponse, + QueryOrderResponse, + PaymentNotification, + RefundRequest, + RefundResponse, +} from './types'; + +let initialized = false; + +export function initPaymentProviders(): void { + if (initialized) return; + paymentRegistry.register(new EasyPayProvider()); + + const env = getEnv(); + if (env.STRIPE_SECRET_KEY) { + paymentRegistry.register(new StripeProvider()); + } + + initialized = true; +} diff --git a/src/lib/payment/registry.ts b/src/lib/payment/registry.ts new file mode 100644 index 0000000..3e769c7 --- /dev/null +++ b/src/lib/payment/registry.ts @@ -0,0 +1,29 @@ +import type { PaymentProvider, PaymentType } from './types'; + +export class PaymentProviderRegistry { + private providers = new Map(); + + register(provider: PaymentProvider): void { + for (const type of provider.supportedTypes) { + this.providers.set(type, provider); + } + } + + getProvider(type: PaymentType): PaymentProvider { + const provider = this.providers.get(type); + if (!provider) { + throw new Error(`No payment provider registered for type: ${type}`); + } + return provider; + } + + hasProvider(type: PaymentType): boolean { + return this.providers.has(type); + } + + getSupportedTypes(): PaymentType[] { + return Array.from(this.providers.keys()); + } +} + +export const paymentRegistry = new PaymentProviderRegistry(); diff --git a/src/lib/payment/types.ts b/src/lib/payment/types.ts new file mode 100644 index 0000000..814a4b1 --- /dev/null +++ b/src/lib/payment/types.ts @@ -0,0 +1,66 @@ +/** Unified payment method types across all providers */ +export type PaymentType = 'alipay' | 'wxpay' | 'stripe'; + +/** Request to create a payment with any provider */ +export interface CreatePaymentRequest { + orderId: string; + amount: number; // in CNY (yuan) + paymentType: PaymentType; + subject: string; // product description + notifyUrl?: string; + returnUrl?: string; + clientIp?: string; +} + +/** Response from creating a payment */ +export interface CreatePaymentResponse { + tradeNo: string; // third-party transaction ID + payUrl?: string; // H5 payment URL (alipay/wxpay) + qrCode?: string; // QR code content + checkoutUrl?: string; // Stripe Checkout URL +} + +/** Response from querying an order's payment status */ +export interface QueryOrderResponse { + tradeNo: string; + status: 'pending' | 'paid' | 'failed' | 'refunded'; + amount: number; + paidAt?: Date; +} + +/** Parsed payment notification from webhook/notify callback */ +export interface PaymentNotification { + tradeNo: string; + orderId: string; + amount: number; + status: 'success' | 'failed'; + rawData: unknown; +} + +/** Request to refund a payment */ +export interface RefundRequest { + tradeNo: string; + orderId: string; + amount: number; + reason?: string; +} + +/** Response from a refund request */ +export interface RefundResponse { + refundId: string; + status: 'success' | 'pending' | 'failed'; +} + +/** Common interface that all payment providers must implement */ +export interface PaymentProvider { + readonly name: string; + readonly supportedTypes: PaymentType[]; + + createPayment(request: CreatePaymentRequest): Promise; + queryOrder(tradeNo: string): Promise; + /** Returns null for unrecognized/irrelevant webhook events (caller should return 200). */ + verifyNotification(rawBody: string | Buffer, headers: Record): Promise; + refund(request: RefundRequest): Promise; + /** Cancel/expire a pending payment on the platform. Optional — not all providers support it. */ + cancelPayment?(tradeNo: string): Promise; +} diff --git a/src/lib/stripe/provider.ts b/src/lib/stripe/provider.ts new file mode 100644 index 0000000..6286121 --- /dev/null +++ b/src/lib/stripe/provider.ts @@ -0,0 +1,139 @@ +import Stripe from 'stripe'; +import { Prisma } from '@prisma/client'; +import { getEnv } from '@/lib/config'; +import type { + PaymentProvider, + PaymentType, + CreatePaymentRequest, + CreatePaymentResponse, + QueryOrderResponse, + PaymentNotification, + RefundRequest, + RefundResponse, +} from '@/lib/payment/types'; + +export class StripeProvider implements PaymentProvider { + readonly name = 'stripe'; + readonly supportedTypes: PaymentType[] = ['stripe']; + + private client: Stripe | null = null; + + private getClient(): Stripe { + if (this.client) return this.client; + const env = getEnv(); + if (!env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY not configured'); + this.client = new Stripe(env.STRIPE_SECRET_KEY); + return this.client; + } + + async createPayment(request: CreatePaymentRequest): Promise { + const stripe = this.getClient(); + const env = getEnv(); + + const timeoutMinutes = Math.max(30, env.ORDER_TIMEOUT_MINUTES); // Stripe minimum is 30 minutes + + const session = await stripe.checkout.sessions.create( + { + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'cny', + product_data: { name: request.subject }, + unit_amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()), + }, + quantity: 1, + }, + ], + metadata: { orderId: request.orderId }, + expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60, + success_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=success`, + cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=cancelled`, + }, + { idempotencyKey: `checkout-${request.orderId}` }, + ); + + return { + tradeNo: session.id, + checkoutUrl: session.url || undefined, + }; + } + + async queryOrder(tradeNo: string): Promise { + const stripe = this.getClient(); + const session = await stripe.checkout.sessions.retrieve(tradeNo); + + let status: QueryOrderResponse['status'] = 'pending'; + if (session.payment_status === 'paid') status = 'paid'; + else if (session.status === 'expired') status = 'failed'; + + return { + tradeNo: session.id, + status, + amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(), + }; + } + + async verifyNotification(rawBody: string | Buffer, headers: Record): Promise { + const stripe = this.getClient(); + const env = getEnv(); + if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured'); + + const sig = headers['stripe-signature'] || ''; + const event = stripe.webhooks.constructEvent( + typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody, + sig, + env.STRIPE_WEBHOOK_SECRET, + ); + + if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') { + const session = event.data.object as Stripe.Checkout.Session; + return { + tradeNo: session.id, + orderId: session.metadata?.orderId || '', + amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(), + status: session.payment_status === 'paid' ? 'success' : 'failed', + rawData: event, + }; + } + + if (event.type === 'checkout.session.async_payment_failed') { + const session = event.data.object as Stripe.Checkout.Session; + return { + tradeNo: session.id, + orderId: session.metadata?.orderId || '', + amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(), + status: 'failed', + rawData: event, + }; + } + + // Unknown event — return null (caller returns 200 to Stripe) + return null; + } + + async refund(request: RefundRequest): Promise { + const stripe = this.getClient(); + + // Retrieve checkout session to find the payment intent + const session = await stripe.checkout.sessions.retrieve(request.tradeNo); + if (!session.payment_intent) throw new Error('No payment intent found for session'); + + const refund = await stripe.refunds.create({ + payment_intent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id, + amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()), + reason: 'requested_by_customer', + }); + + return { + refundId: refund.id, + status: refund.status === 'succeeded' ? 'success' : 'pending', + }; + } + + async cancelPayment(tradeNo: string): Promise { + const stripe = this.getClient(); + await stripe.checkout.sessions.expire(tradeNo); + } +} diff --git a/src/lib/sub2api/client.ts b/src/lib/sub2api/client.ts index 63acf7f..3f83f5e 100644 --- a/src/lib/sub2api/client.ts +++ b/src/lib/sub2api/client.ts @@ -51,20 +51,17 @@ export async function createAndRedeem( notes: string, ): Promise { 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, - }), - }, - ); + 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, + }), + }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); @@ -82,18 +79,15 @@ export async function subtractBalance( idempotencyKey: string, ): Promise { const env = getEnv(); - const response = await fetch( - `${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, - { - method: 'POST', - headers: getHeaders(idempotencyKey), - body: JSON.stringify({ - operation: 'subtract', - amount, - notes, - }), - }, - ); + const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, { + method: 'POST', + headers: getHeaders(idempotencyKey), + body: JSON.stringify({ + operation: 'subtract', + amount, + notes, + }), + }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); diff --git a/src/middleware.ts b/src/middleware.ts index c4e9414..f5cb063 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,7 +7,10 @@ export function middleware(request: NextRequest) { // IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔) const allowOrigins = process.env.IFRAME_ALLOW_ORIGINS || ''; - const origins = allowOrigins.split(',').map(s => s.trim()).filter(Boolean); + const origins = allowOrigins + .split(',') + .map((s) => s.trim()) + .filter(Boolean); if (origins.length > 0) { response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`); diff --git a/tsconfig.json b/tsconfig.json index 6718602..52cec71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,13 +22,6 @@ "@/*": ["./src/*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules", "third-party"] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules", "third-party", "vendor"] } diff --git a/vendor/stripe-node b/vendor/stripe-node new file mode 160000 index 0000000..701eb04 --- /dev/null +++ b/vendor/stripe-node @@ -0,0 +1 @@ +Subproject commit 701eb047f3cddc4fb005b6944dca06921ae037c8 diff --git a/vitest.config.ts b/vitest.config.ts index 4736fe8..d6486cc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - exclude: ['**/node_modules/**', '**/third-party/**'], + exclude: ['**/node_modules/**', '**/third-party/**', '**/vendor/**'], }, resolve: { alias: {