feat: integrate Stripe payment with bugfixes and active timeout cancellation

- Add Stripe payment provider with Checkout Session flow
- Payment provider abstraction layer (EasyPay + Stripe unified interface)
- Stripe webhook with proper raw body handling and signature verification
- Frontend: Stripe button with URL validation, anti-duplicate click, noopener
- Active timeout cancellation: query platform before expiring, recover paid orders
- Singleton Stripe client, idempotency keys, Math.round for amounts
- Handle async_payment events, return null for unknown webhook events
- Set Checkout Session expires_at aligned with order timeout
- Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op)
- Enable stripe in frontend payment type list
This commit is contained in:
erio
2026-03-01 17:58:08 +08:00
parent 2f45044073
commit d9ab65ecf2
59 changed files with 1571 additions and 432 deletions

72
.github/workflows/ci.yml vendored Normal file
View File

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

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "vendor/stripe-node"]
path = vendor/stripe-node
url = https://github.com/stripe/stripe-node

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
.next
node_modules
vendor
pnpm-lock.yaml

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2
}

View File

@@ -3,7 +3,7 @@ services:
image: sub2apipay:latest image: sub2apipay:latest
container_name: sub2apipay container_name: sub2apipay
ports: ports:
- "8087:3000" - '8087:3000'
env_file: .env env_file: .env
networks: networks:
- sub2api-network - sub2api-network

View File

@@ -2,7 +2,7 @@ services:
app: app:
build: . build: .
ports: ports:
- "${APP_PORT:-3001}:3000" - '${APP_PORT:-3001}:3000'
env_file: .env env_file: .env
environment: environment:
- DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay - DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay
@@ -20,7 +20,7 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U sub2apipay"] test: ['CMD-SHELL', 'pg_isready -U sub2apipay']
interval: 5s interval: 5s
restart: unless-stopped restart: unless-stopped

View File

@@ -1,6 +1,6 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from "eslint-config-next/typescript"; import nextTs from 'eslint-config-next/typescript';
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
@@ -8,10 +8,12 @@ const eslintConfig = defineConfig([
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", 'next-env.d.ts',
// Git submodules:
'vendor/**',
]), ]),
]); ]);

View File

@@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',

View File

@@ -7,6 +7,9 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
@@ -18,6 +21,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"stripe": "^20.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"pnpm": { "pnpm": {
@@ -37,6 +41,7 @@
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"prettier": "^3.8.1",
"prisma": "7.4.1", "prisma": "7.4.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",

26
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3(react@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: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -60,6 +63,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: 16.1.6 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) 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: prisma:
specifier: 7.4.1 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) 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==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} 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: prisma@7.4.1:
resolution: {integrity: sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==} resolution: {integrity: sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==}
engines: {node: ^20.19 || ^22.12 || >=24.0} engines: {node: ^20.19 || ^22.12 || >=24.0}
@@ -2694,6 +2705,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} 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: styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -5301,6 +5321,8 @@ snapshots:
prelude-ls@1.2.1: {} 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): 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: dependencies:
'@prisma/config': 7.4.1 '@prisma/config': 7.4.1
@@ -5651,6 +5673,10 @@ snapshots:
strip-json-comments@3.1.1: {} 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): styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1

View File

@@ -1,6 +1,6 @@
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@@ -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<CreatePaymentResponse> {
return { tradeNo: 'mock-trade-no' };
}
async queryOrder(_tradeNo: string): Promise<QueryOrderResponse> {
return { tradeNo: 'mock', status: 'pending', amount: 0 };
}
async verifyNotification(_rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
return { tradeNo: 'mock', orderId: 'mock', amount: 0, status: 'success', rawData: {} };
}
async refund(_request: RefundRequest): Promise<RefundResponse> {
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');
});
});

View File

@@ -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<string, unknown>) {
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');
});
});
});

View File

@@ -45,17 +45,18 @@ describe('Sub2API Client', () => {
it('createAndRedeem should send correct request', async () => { it('createAndRedeem should send correct request', async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
code: 1, Promise.resolve({
redeem_code: { code: 1,
id: 1, redeem_code: {
code: 's2p_test123', id: 1,
type: 'balance', code: 's2p_test123',
value: 100, type: 'balance',
status: 'used', value: 100,
used_by: 1, status: 'used',
}, used_by: 1,
}), },
}),
}); });
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes'); const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');

View File

@@ -5,11 +5,42 @@ import { useState, useEffect, useCallback, Suspense } from 'react';
import OrderTable from '@/components/admin/OrderTable'; import OrderTable from '@/components/admin/OrderTable';
import OrderDetail from '@/components/admin/OrderDetail'; 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() { function AdminContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const [orders, setOrders] = useState<any[]>([]); const [orders, setOrders] = useState<AdminOrder[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@@ -17,8 +48,7 @@ function AdminContent() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Dialogs const [detailOrder, setDetailOrder] = useState<AdminOrderDetail | null>(null);
const [detailOrder, setDetailOrder] = useState<any>(null);
const fetchOrders = useCallback(async () => { const fetchOrders = useCallback(async () => {
if (!token) return; if (!token) return;
@@ -134,7 +164,9 @@ function AdminContent() {
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600"> <div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
{error} {error}
<button onClick={() => setError('')} className="ml-2 text-red-400 hover:text-red-600"></button> <button onClick={() => setError('')} className="ml-2 text-red-400 hover:text-red-600">
</button>
</div> </div>
)} )}
@@ -143,11 +175,12 @@ function AdminContent() {
{statuses.map((s) => ( {statuses.map((s) => (
<button <button
key={s} key={s}
onClick={() => { setStatusFilter(s); setPage(1); }} onClick={() => {
setStatusFilter(s);
setPage(1);
}}
className={`rounded-full px-3 py-1 text-sm transition-colors ${ className={`rounded-full px-3 py-1 text-sm transition-colors ${
statusFilter === s statusFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`} }`}
> >
{statusLabels[s]} {statusLabels[s]}
@@ -160,12 +193,7 @@ function AdminContent() {
{loading ? ( {loading ? (
<div className="py-12 text-center text-gray-500">...</div> <div className="py-12 text-center text-gray-500">...</div>
) : ( ) : (
<OrderTable <OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} />
orders={orders}
onRetry={handleRetry}
onCancel={handleCancel}
onViewDetail={handleViewDetail}
/>
)} )}
</div> </div>
@@ -175,15 +203,17 @@ function AdminContent() {
<span> {total} </span> <span> {total} </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
className="rounded border px-3 py-1 disabled:opacity-50" className="rounded border px-3 py-1 disabled:opacity-50"
> >
</button> </button>
<span className="px-3 py-1">{page} / {totalPages}</span> <span className="px-3 py-1">
{page} / {totalPages}
</span>
<button <button
onClick={() => setPage(p => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
className="rounded border px-3 py-1 disabled:opacity-50" className="rounded border px-3 py-1 disabled:opacity-50"
> >
@@ -194,23 +224,20 @@ function AdminContent() {
)} )}
{/* Order Detail */} {/* Order Detail */}
{detailOrder && ( {detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />}
<OrderDetail
order={detailOrder}
onClose={() => setDetailOrder(null)}
/>
)}
</div> </div>
); );
} }
export default function AdminPage() { export default function AdminPage() {
return ( return (
<Suspense fallback={ <Suspense
<div className="flex min-h-screen items-center justify-center"> fallback={
<div className="text-gray-500">...</div> <div className="flex min-h-screen items-center justify-center">
</div> <div className="text-gray-500">...</div>
}> </div>
}
>
<AdminContent /> <AdminContent />
</Suspense> </Suspense>
); );

View File

@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { adminCancelOrder, OrderError } from '@/lib/order/service'; import { adminCancelOrder, OrderError } from '@/lib/order/service';
export async function POST( export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!verifyAdminToken(request)) return unauthorizedResponse(); if (!verifyAdminToken(request)) return unauthorizedResponse();
try { try {
@@ -14,10 +11,7 @@ export async function POST(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof OrderError) { if (error instanceof OrderError) {
return NextResponse.json( return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
{ error: error.message, code: error.code },
{ status: error.statusCode },
);
} }
console.error('Admin cancel order error:', error); console.error('Admin cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 }); return NextResponse.json({ error: '取消订单失败' }, { status: 500 });

View File

@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { retryRecharge, OrderError } from '@/lib/order/service'; import { retryRecharge, OrderError } from '@/lib/order/service';
export async function POST( export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!verifyAdminToken(request)) return unauthorizedResponse(); if (!verifyAdminToken(request)) return unauthorizedResponse();
try { try {
@@ -14,10 +11,7 @@ export async function POST(
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof OrderError) { if (error instanceof OrderError) {
return NextResponse.json( return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
{ error: error.message, code: error.code },
{ status: error.statusCode },
);
} }
console.error('Retry recharge error:', error); console.error('Retry recharge error:', error);
return NextResponse.json({ error: '重试充值失败' }, { status: 500 }); return NextResponse.json({ error: '重试充值失败' }, { status: 500 });

View File

@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
export async function GET( export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!verifyAdminToken(request)) return unauthorizedResponse(); if (!verifyAdminToken(request)) return unauthorizedResponse();
const { id } = await params; const { id } = await params;

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { Prisma } from '@prisma/client'; import { Prisma, OrderStatus } from '@prisma/client';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
if (!verifyAdminToken(request)) return unauthorizedResponse(); if (!verifyAdminToken(request)) return unauthorizedResponse();
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
const dateTo = searchParams.get('date_to'); const dateTo = searchParams.get('date_to');
const where: Prisma.OrderWhereInput = {}; const where: Prisma.OrderWhereInput = {};
if (status) where.status = status as any; if (status && status in OrderStatus) where.status = status as OrderStatus;
if (userId) where.userId = Number(userId); if (userId) where.userId = Number(userId);
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
where.createdAt = {}; where.createdAt = {};
@@ -48,7 +48,7 @@ export async function GET(request: NextRequest) {
]); ]);
return NextResponse.json({ return NextResponse.json({
orders: orders.map(o => ({ orders: orders.map((o) => ({
...o, ...o,
amount: Number(o.amount), amount: Number(o.amount),
})), })),

View File

@@ -17,10 +17,7 @@ export async function POST(request: NextRequest) {
const parsed = refundSchema.safeParse(body); const parsed = refundSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
} }
const result = await processRefund({ const result = await processRefund({
@@ -32,10 +29,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(result); return NextResponse.json(result);
} catch (error) { } catch (error) {
if (error instanceof OrderError) { if (error instanceof OrderError) {
return NextResponse.json( return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
{ error: error.message, code: error.code },
{ status: error.statusCode },
);
} }
console.error('Refund error:', error); console.error('Refund error:', error);
return NextResponse.json({ error: '退款失败' }, { status: 500 }); return NextResponse.json({ error: '退款失败' }, { status: 500 });

View File

@@ -1,25 +1,19 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service'; import { handlePaymentNotify } from '@/lib/order/service';
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types'; import { EasyPayProvider } from '@/lib/easy-pay/provider';
const easyPayProvider = new EasyPayProvider();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const searchParams = request.nextUrl.searchParams; const rawBody = request.nextUrl.searchParams.toString();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const params: EasyPayNotifyParams = { const notification = await easyPayProvider.verifyNotification(rawBody, headers);
pid: searchParams.get('pid') || '', const success = await handlePaymentNotify(notification, easyPayProvider.name);
name: searchParams.get('name') || '',
money: searchParams.get('money') || '',
out_trade_no: searchParams.get('out_trade_no') || '',
trade_no: searchParams.get('trade_no') || '',
param: searchParams.get('param') || '',
trade_status: searchParams.get('trade_status') || '',
type: searchParams.get('type') || '',
sign: searchParams.get('sign') || '',
sign_type: searchParams.get('sign_type') || '',
};
const success = await handlePaymentNotify(params);
return new Response(success ? 'success' : 'fail', { return new Response(success ? 'success' : 'fail', {
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
}); });

View File

@@ -6,30 +6,21 @@ const cancelSchema = z.object({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
}); });
export async function POST( export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try { try {
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const parsed = cancelSchema.safeParse(body); const parsed = cancelSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
} }
await cancelOrder(id, parsed.data.user_id); await cancelOrder(id, parsed.data.user_id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof OrderError) { if (error instanceof OrderError) {
return NextResponse.json( return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
{ error: error.message, code: error.code },
{ status: error.statusCode },
);
} }
console.error('Cancel order error:', error); console.error('Cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 }); return NextResponse.json({ error: '取消订单失败' }, { status: 500 });

View File

@@ -1,10 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
export async function GET( export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({

View File

@@ -6,7 +6,7 @@ import { getEnv } from '@/lib/config';
const createOrderSchema = z.object({ const createOrderSchema = z.object({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number().positive(), amount: z.number().positive(),
payment_type: z.enum(['alipay', 'wxpay']), payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
}); });
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -16,10 +16,7 @@ export async function POST(request: NextRequest) {
const parsed = createOrderSchema.safeParse(body); const parsed = createOrderSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
} }
const { user_id, amount, payment_type } = parsed.data; const { user_id, amount, payment_type } = parsed.data;
@@ -34,15 +31,11 @@ export async function POST(request: NextRequest) {
// Validate payment type is enabled // Validate payment type is enabled
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) { if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
return NextResponse.json( return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
{ error: `不支持的支付方式: ${payment_type}` },
{ status: 400 },
);
} }
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() const clientIp =
|| request.headers.get('x-real-ip') request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || '127.0.0.1';
|| '127.0.0.1';
const result = await createOrder({ const result = await createOrder({
userId: user_id, userId: user_id,
@@ -54,15 +47,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json(result); return NextResponse.json(result);
} catch (error) { } catch (error) {
if (error instanceof OrderError) { if (error instanceof OrderError) {
return NextResponse.json( return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
{ error: error.message, code: error.code },
{ status: error.statusCode },
);
} }
console.error('Create order error:', error); console.error('Create order error:', error);
return NextResponse.json( return NextResponse.json({ error: '创建订单失败,请稍后重试' }, { status: 500 });
{ error: '创建订单失败,请稍后重试' },
{ status: 500 },
);
} }
} }

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { handlePaymentNotify } from '@/lib/order/service';
// Stripe needs raw body - ensure Next.js doesn't parse it
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider('stripe' as PaymentType);
const rawBody = Buffer.from(await request.arrayBuffer());
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
// Unknown event type — acknowledge receipt
return NextResponse.json({ received: true });
}
await handlePaymentNotify(notification, provider.name);
return NextResponse.json({ received: true });
} catch (error) {
console.error('Stripe webhook error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 400 },
);
}
}

View File

@@ -1,10 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getUser } from '@/lib/sub2api/client'; import { getUser } from '@/lib/sub2api/client';
export async function GET( export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
const userId = Number(id); const userId = Number(id);

View File

@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
--background: #f9fafb; --background: #f9fafb;

View File

@@ -1,9 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import "./globals.css"; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sub2API 充值", title: 'Sub2API 充值',
description: "Sub2API 余额充值平台", description: 'Sub2API 余额充值平台',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -13,9 +13,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className="bg-gray-50 text-gray-900 antialiased"> <body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
{children}
</body>
</html> </html>
); );
} }

View File

@@ -154,7 +154,9 @@ function OrdersContent() {
if (isMobile) { if (isMobile) {
return ( return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}> <div
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
>
Tab... Tab...
</div> </div>
); );
@@ -184,7 +186,9 @@ function OrdersContent() {
onClick={loadOrders} onClick={loadOrders}
className={[ className={[
'rounded-lg border px-3 py-2 text-xs font-medium', 'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')} ].join(' ')}
> >
@@ -193,7 +197,9 @@ function OrdersContent() {
href={payUrl} href={payUrl}
className={[ className={[
'rounded-lg border px-3 py-2 text-xs font-medium', 'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')} ].join(' ')}
> >

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useState, useEffect, Suspense, useMemo } from 'react'; import { useState, useEffect, Suspense } from 'react';
import PaymentForm from '@/components/PaymentForm'; import PaymentForm from '@/components/PaymentForm';
import PaymentQRCode from '@/components/PaymentQRCode'; import PaymentQRCode from '@/components/PaymentQRCode';
import OrderStatus from '@/components/OrderStatus'; import OrderStatus from '@/components/OrderStatus';
@@ -13,9 +13,10 @@ interface OrderResult {
orderId: string; orderId: string;
amount: number; amount: number;
status: string; status: string;
paymentType: 'alipay' | 'wxpay'; paymentType: 'alipay' | 'wxpay' | 'stripe';
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
checkoutUrl?: string | null;
expiresAt: string; expiresAt: string;
} }
@@ -47,7 +48,7 @@ function PayContent() {
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay'); const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config] = useState<AppConfig>({ const [config] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay'], enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
minAmount: 1, minAmount: 1,
maxAmount: 10000, maxAmount: 10000,
}); });
@@ -185,6 +186,7 @@ function PayContent() {
paymentType: data.paymentType || paymentType, paymentType: data.paymentType || paymentType,
payUrl: data.payUrl, payUrl: data.payUrl,
qrCode: data.qrCode, qrCode: data.qrCode,
checkoutUrl: data.checkoutUrl,
expiresAt: data.expiresAt, expiresAt: data.expiresAt,
}); });
@@ -385,6 +387,7 @@ function PayContent() {
orderId={orderResult.orderId} orderId={orderResult.orderId}
payUrl={orderResult.payUrl} payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode} qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl}
paymentType={orderResult.paymentType} paymentType={orderResult.paymentType}
amount={orderResult.amount} amount={orderResult.amount}
expiresAt={orderResult.expiresAt} expiresAt={orderResult.expiresAt}

View File

@@ -5,8 +5,9 @@ import { useEffect, useState, Suspense } from 'react';
function ResultContent() { function ResultContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const outTradeNo = searchParams.get('out_trade_no'); // Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const tradeStatus = searchParams.get('trade_status'); const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -62,9 +63,7 @@ function ResultContent() {
{status === 'COMPLETED' ? '充值成功' : '充值处理中'} {status === 'COMPLETED' ? '充值成功' : '充值处理中'}
</h1> </h1>
<p className="mt-2 text-gray-500"> <p className="mt-2 text-gray-500">
{status === 'COMPLETED' {status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
? '余额已成功到账!'
: '支付成功,余额正在充值中...'}
</p> </p>
</> </>
) : isPending ? ( ) : isPending ? (
@@ -89,9 +88,7 @@ function ResultContent() {
</> </>
)} )}
<p className="mt-4 text-xs text-gray-400"> <p className="mt-4 text-xs text-gray-400">: {outTradeNo || '未知'}</p>
: {outTradeNo || '未知'}
</p>
</div> </div>
</div> </div>
); );
@@ -99,11 +96,13 @@ function ResultContent() {
export default function PayResultPage() { export default function PayResultPage() {
return ( return (
<Suspense fallback={ <Suspense
<div className="flex min-h-screen items-center justify-center"> fallback={
<div className="text-gray-500">...</div> <div className="flex min-h-screen items-center justify-center">
</div> <div className="text-gray-500">...</div>
}> </div>
}
>
<ResultContent /> <ResultContent />
</Suspense> </Suspense>
); );

View File

@@ -2,7 +2,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import OrderFilterBar from '@/components/OrderFilterBar'; import OrderFilterBar from '@/components/OrderFilterBar';
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils'; import {
formatStatus,
formatCreatedAt,
getStatusBadgeClass,
type MyOrder,
type OrderStatusFilter,
} from '@/lib/pay-utils';
interface MobileOrderListProps { interface MobileOrderListProps {
isDark: boolean; isDark: boolean;
@@ -22,13 +28,17 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}></h3> <h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
</h3>
<button <button
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
className={[ className={[
'rounded-lg border px-2.5 py-1 text-xs font-medium', 'rounded-lg border px-2.5 py-1 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')} ].join(' ')}
> >
@@ -38,11 +48,21 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} /> <OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
{!hasToken ? ( {!hasToken ? (
<div className={['rounded-xl border border-dashed px-4 py-8 text-center text-sm', isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700'].join(' ')}> <div
token"我的订单" className={[
'rounded-xl border border-dashed px-4 py-8 text-center text-sm',
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
].join(' ')}
>
token&ldquo;&rdquo;
</div> </div>
) : filteredOrders.length === 0 ? ( ) : filteredOrders.length === 0 ? (
<div className={['rounded-xl border border-dashed px-4 py-8 text-center text-sm', isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500'].join(' ')}> <div
className={[
'rounded-xl border border-dashed px-4 py-8 text-center text-sm',
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
</div> </div>
) : ( ) : (
@@ -50,11 +70,16 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
{filteredOrders.map((order) => ( {filteredOrders.map((order) => (
<div <div
key={order.id} key={order.id}
className={['rounded-xl border px-3 py-3', isDark ? 'border-slate-700 bg-slate-900/70' : 'border-slate-200 bg-white'].join(' ')} className={[
'rounded-xl border px-3 py-3',
isDark ? 'border-slate-700 bg-slate-900/70' : 'border-slate-200 bg-white',
].join(' ')}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-2xl font-semibold">¥{order.amount.toFixed(2)}</span> <span className="text-2xl font-semibold">¥{order.amount.toFixed(2)}</span>
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}> <span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
>
{formatStatus(order.status)} {formatStatus(order.status)}
</span> </span>
</div> </div>

View File

@@ -17,8 +17,12 @@ export default function OrderFilterBar({ isDark, activeFilter, onChange }: Order
className={[ className={[
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors', 'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
activeFilter === item.key activeFilter === item.key
? (isDark ? 'border-slate-500 bg-slate-700 text-slate-100' : 'border-slate-400 bg-slate-900 text-white') ? isDark
: (isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100'), ? 'border-slate-500 bg-slate-700 text-slate-100'
: 'border-slate-400 bg-slate-900 text-white'
: isDark
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ')} ].join(' ')}
> >
{item.label} {item.label}

View File

@@ -11,7 +11,10 @@ interface OrderSummaryCardsProps {
} }
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) { export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
const cardClass = ['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' '); const cardClass = [
'rounded-xl border p-3',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ');
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' '); const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
return ( return (

View File

@@ -9,22 +9,47 @@ interface OrderTableProps {
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) { export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
return ( return (
<div className={['rounded-2xl border p-3 sm:p-4', isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80'].join(' ')}> <div
className={[
'rounded-2xl border p-3 sm:p-4',
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80',
].join(' ')}
>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<div className={['h-6 w-6 animate-spin rounded-full border-2 border-t-transparent', isDark ? 'border-slate-400' : 'border-slate-500'].join(' ')} /> <div
className={[
'h-6 w-6 animate-spin rounded-full border-2 border-t-transparent',
isDark ? 'border-slate-400' : 'border-slate-500',
].join(' ')}
/>
</div> </div>
) : error ? ( ) : error ? (
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700'].join(' ')}> <div
className={[
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
].join(' ')}
>
{error} {error}
</div> </div>
) : orders.length === 0 ? ( ) : orders.length === 0 ? (
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500'].join(' ')}> <div
className={[
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
</div> </div>
) : ( ) : (
<> <>
<div className={['hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr]', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}> <div
className={[
'hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr]',
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@@ -35,13 +60,20 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
{orders.map((order) => ( {orders.map((order) => (
<div <div
key={order.id} key={order.id}
className={['border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr] md:items-center', isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700'].join(' ')} className={[
'border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr] md:items-center',
isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700',
].join(' ')}
> >
<div className="font-medium">#{order.id.slice(0, 12)}</div> <div className="font-medium">#{order.id.slice(0, 12)}</div>
<div className="font-semibold">¥{order.amount.toFixed(2)}</div> <div className="font-semibold">¥{order.amount.toFixed(2)}</div>
<div>{order.paymentType}</div> <div>{order.paymentType}</div>
<div> <div>
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}> <span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
' ',
)}
>
{formatStatus(order.status)} {formatStatus(order.status)}
</span> </span>
</div> </div>

View File

@@ -60,22 +60,15 @@ export default function PayPageLayout({
Sub2API Secure Pay Sub2API Secure Pay
</div> </div>
<h1 <h1
className={[ className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(
'text-2xl font-semibold tracking-tight', ' ',
isDark ? 'text-slate-100' : 'text-slate-900', )}
].join(' ')}
> >
{title} {title}
</h1> </h1>
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}> <p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{subtitle}</p>
{subtitle}
</p>
</div> </div>
{actions && ( {actions && <div className="flex items-center gap-2">{actions}</div>}
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div> </div>
{children} {children}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
interface PaymentFormProps { interface PaymentFormProps {
userId: number; userId: number;
@@ -70,13 +71,62 @@ export default function PaymentForm({
await onSubmit(selectedAmount, paymentType); await onSubmit(selectedAmount, paymentType);
}; };
const isAlipay = (type: string) => type === 'alipay'; const renderPaymentIcon = (type: string) => {
if (type === 'alipay') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
</span>
);
}
if (type === 'wxpay') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5 10.2 17 19 8"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
if (type === 'stripe') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</svg>
</span>
);
}
return null;
};
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* User Info */} {/* User Info */}
<div className={['rounded-xl border p-4', dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50'].join(' ')}> <div
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div> className={[
'rounded-xl border p-4',
dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
</div>
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}> <div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{userName || `用户 #${userId}`} {userName || `用户 #${userId}`}
</div> </div>
@@ -118,7 +168,13 @@ export default function PaymentForm({
</label> </label>
<div className="relative"> <div className="relative">
<span className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>¥</span> <span
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
' ',
)}
>
¥
</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
@@ -138,51 +194,50 @@ export default function PaymentForm({
{customAmount !== '' && !isValid && ( {customAmount !== '' && !isValid && (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}> <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
{'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'} {
'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'
}
</div> </div>
)} )}
{/* Payment Type */} {/* Payment Type */}
<div> <div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}></label> <label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
</label>
<div className="flex gap-3"> <div className="flex gap-3">
{enabledPaymentTypes.map((type) => ( {enabledPaymentTypes.map((type) => {
<button const meta = PAYMENT_TYPE_META[type];
key={type} const isSelected = paymentType === type;
type="button" return (
onClick={() => setPaymentType(type)} <button
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${ key={type}
paymentType === type type="button"
? isAlipay(type) onClick={() => setPaymentType(type)}
? 'border-cyan-400 bg-cyan-50 text-slate-900 shadow-sm' className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
: 'border-green-500 bg-green-50 text-slate-900 shadow-sm' isSelected
: dark ? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500' : dark
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400' ? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
}`} : 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
> }`}
{isAlipay(type) ? ( >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white"> {renderPaymentIcon(type)}
</span>
<span className="flex flex-col items-start leading-none"> <span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight"></span> <span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
<span className="text-[10px] tracking-[0.25em] text-slate-600">ALIPAY</span> {meta?.sublabel && (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
>
{meta.sublabel}
</span>
)}
</span> </span>
</span> </span>
) : ( </button>
<span className="flex items-center gap-2"> );
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white"> })}
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path d="M5 12.5 10.2 17 19 8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="text-xl font-semibold tracking-tight"></span>
</span>
)}
</button>
))}
</div> </div>
</div> </div>
@@ -192,8 +247,12 @@ export default function PaymentForm({
disabled={!isValid || loading} disabled={!isValid || loading}
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${ className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
isValid && !loading isValid && !loading
? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800' ? paymentType === 'stripe'
: dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300' ? '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}`} {loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}

View File

@@ -7,7 +7,8 @@ interface PaymentQRCodeProps {
orderId: string; orderId: string;
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
paymentType?: 'alipay' | 'wxpay'; checkoutUrl?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe';
amount: number; amount: number;
expiresAt: string; expiresAt: string;
onStatusChange: (status: string) => void; onStatusChange: (status: string) => void;
@@ -23,10 +24,20 @@ const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355'; const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']); 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({ export default function PaymentQRCode({
orderId, orderId,
payUrl, payUrl,
qrCode, qrCode,
checkoutUrl,
paymentType, paymentType,
amount, amount,
expiresAt, expiresAt,
@@ -38,6 +49,7 @@ export default function PaymentQRCode({
const [expired, setExpired] = useState(false); const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState(''); const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false); const [imageLoading, setImageLoading] = useState(false);
const [stripeOpened, setStripeOpened] = useState(false);
const qrPayload = useMemo(() => { const qrPayload = useMemo(() => {
const value = (qrCode || payUrl || '').trim(); const value = (qrCode || payUrl || '').trim();
@@ -124,13 +136,14 @@ export default function PaymentQRCode({
const handleCancel = async () => { const handleCancel = async () => {
try { try {
const res = await fetch(`/api/orders/${orderId}`); const res = await fetch(`/api/orders/${orderId}`);
if (res.ok) { if (!res.ok) return;
const data = await res.json(); const data = await res.json();
await fetch(`/api/orders/${orderId}/cancel`, { const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: data.user_id }), body: JSON.stringify({ user_id: data.user_id }),
}); });
if (cancelRes.ok) {
onStatusChange('CANCELLED'); onStatusChange('CANCELLED');
} }
} catch { } catch {
@@ -138,10 +151,11 @@ export default function PaymentQRCode({
} }
}; };
const isStripe = paymentType === 'stripe';
const isWx = paymentType === 'wxpay'; const isWx = paymentType === 'wxpay';
const iconSrc = isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
const channelLabel = isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
const iconBgClass = isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]'; const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
return ( return (
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
@@ -154,44 +168,91 @@ export default function PaymentQRCode({
{!expired && ( {!expired && (
<> <>
{qrDataUrl && ( {isStripe ? (
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}> <>
{imageLoading && ( <button
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10"> type="button"
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" /> disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened}
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
setStripeOpened(true);
}
}}
className={[
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors',
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'}
</button>
{stripeOpened && (
<button
type="button"
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
}
}}
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')}
>
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'}
</button>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{!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'}
</p>
</>
) : (
<>
{qrDataUrl && (
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
{imageLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
)}
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div> </div>
)} )}
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
)}
{!qrDataUrl && payUrl && ( {!qrDataUrl && payUrl && (
<a <a
href={payUrl} href={payUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700" className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
> >
{TEXT_GO_PAY} {TEXT_GO_PAY}
</a> </a>
)} )}
{!qrDataUrl && !payUrl && ( {!qrDataUrl && !payUrl && (
<div className="text-center"> <div className="text-center">
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}> <div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p> <p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
</div> </div>
</div> </div>
)} )}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}> <p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`} {`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
</p> </p>
</>
)}
</> </>
)} )}

View File

@@ -75,11 +75,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div <div
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl" className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl"
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold"></h3> <h3 className="text-lg font-bold"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button> <button onClick={onClose} className="text-gray-400 hover:text-gray-600">
</button>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -99,21 +101,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
<div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3"> <div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span> <span className="text-sm font-medium">{log.action}</span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
{new Date(log.createdAt).toLocaleString('zh-CN')}
</span>
</div> </div>
{log.detail && ( {log.detail && <div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>}
<div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div> {log.operator && <div className="mt-1 text-xs text-gray-400">: {log.operator}</div>}
)}
{log.operator && (
<div className="mt-1 text-xs text-gray-400">: {log.operator}</div>
)}
</div> </div>
))} ))}
{order.auditLogs.length === 0 && ( {order.auditLogs.length === 0 && <div className="text-center text-sm text-gray-400"></div>}
<div className="text-center text-sm text-gray-400"></div>
)}
</div> </div>
</div> </div>

View File

@@ -55,14 +55,14 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{orders.map((order) => { {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 ( return (
<tr key={order.id} className="hover:bg-gray-50"> <tr key={order.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-3 text-sm"> <td className="whitespace-nowrap px-4 py-3 text-sm">
<button <button onClick={() => onViewDetail(order.id)} className="text-blue-600 hover:underline">
onClick={() => onViewDetail(order.id)}
className="text-blue-600 hover:underline"
>
{order.id.slice(0, 12)}... {order.id.slice(0, 12)}...
</button> </button>
</td> </td>
@@ -70,9 +70,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
<div>{order.userName || '-'}</div> <div>{order.userName || '-'}</div>
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div> <div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium"> <td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{order.amount.toFixed(2)}</td>
¥{order.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm"> <td className="whitespace-nowrap px-4 py-3 text-sm">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${statusInfo.className}`}> <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${statusInfo.className}`}>
{statusInfo.label} {statusInfo.label}
@@ -109,9 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
})} })}
</tbody> </tbody>
</table> </table>
{orders.length === 0 && ( {orders.length === 0 && <div className="py-12 text-center text-gray-500"></div>}
<div className="py-12 text-center text-gray-500"></div>
)}
</div> </div>
); );
} }

View File

@@ -34,7 +34,7 @@ export default function RefundDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={e => e.stopPropagation()}> <div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900">退</h3> <h3 className="text-lg font-bold text-gray-900">退</h3>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
@@ -48,11 +48,7 @@ export default function RefundDialog({
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div> <div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
</div> </div>
{warning && ( {warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
<div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
{warning}
</div>
)}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700">退</label> <label className="mb-1 block text-sm font-medium text-gray-700">退</label>

View File

@@ -12,16 +12,24 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(), SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1), SUB2API_ADMIN_API_KEY: z.string().min(1),
EASY_PAY_PID: z.string().min(1), // ── Easy-Pay (optional when only using Stripe) ──
EASY_PAY_PKEY: z.string().min(1), EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_API_BASE: z.string().url(), EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_NOTIFY_URL: z.string().url(), EASY_PAY_API_BASE: optionalTrimmedString,
EASY_PAY_RETURN_URL: z.string().url(), EASY_PAY_NOTIFY_URL: optionalTrimmedString,
EASY_PAY_RETURN_URL: optionalTrimmedString,
EASY_PAY_CID: optionalTrimmedString, EASY_PAY_CID: optionalTrimmedString,
EASY_PAY_CID_ALIPAY: optionalTrimmedString, EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: 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()), 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()), MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),

View File

@@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID); return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
} }
function assertEasyPayEnv(env: ReturnType<typeof getEnv>) {
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<EasyPayCreateResponse> { export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
const env = getEnv(); const env = assertEasyPayEnv(getEnv());
const params: Record<string, string> = { const params: Record<string, string> = {
pid: env.EASY_PAY_PID, pid: env.EASY_PAY_PID,
type: opts.paymentType, type: opts.paymentType,
@@ -57,7 +78,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}); });
const data = await response.json() as EasyPayCreateResponse; const data = (await response.json()) as EasyPayCreateResponse;
if (data.code !== 1) { if (data.code !== 1) {
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`); throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
} }
@@ -65,10 +86,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
} }
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> { export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
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 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 response = await fetch(url);
const data = await response.json() as EasyPayQueryResponse; const data = (await response.json()) as EasyPayQueryResponse;
if (data.code !== 1) { if (data.code !== 1) {
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`); throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
} }
@@ -76,7 +97,7 @@ export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryRespon
} }
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> { export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
const env = getEnv(); const env = assertEasyPayEnv(getEnv());
const params = new URLSearchParams({ const params = new URLSearchParams({
pid: env.EASY_PAY_PID, pid: env.EASY_PAY_PID,
key: env.EASY_PAY_PKEY, key: env.EASY_PAY_PKEY,
@@ -89,7 +110,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
body: params, body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 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) { if (data.code !== 1) {
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`); throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
} }

View File

@@ -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<CreatePaymentResponse> {
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<QueryOrderResponse> {
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<string, string>): Promise<PaymentNotification> {
const env = getEnv();
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
const searchParams = new URLSearchParams(body);
const params: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
const sign = params.sign || '';
const paramsForSign: Record<string, string> = {};
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<RefundResponse> {
await refund(request.tradeNo, request.orderId, request.amount.toFixed(2));
return {
refundId: `${request.tradeNo}-refund`,
status: 'success',
};
}
async cancelPayment(): Promise<void> {
// EasyPay does not support cancelling payments
}
}

View File

@@ -2,7 +2,9 @@ import crypto from 'crypto';
export function generateSign(params: Record<string, string>, pkey: string): string { export function generateSign(params: Record<string, string>, pkey: string): string {
const filtered = Object.entries(params) 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)); .sort(([a], [b]) => a.localeCompare(b));
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&'); const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');

View File

@@ -1,12 +1,10 @@
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config'; import { getEnv } from '@/lib/config';
import { generateRechargeCode } from './code-gen'; import { generateRechargeCode } from './code-gen';
import { createPayment } from '@/lib/easy-pay/client'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { verifySign } from '@/lib/easy-pay/sign'; import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { refund as easyPayRefund } from '@/lib/easy-pay/client';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client'; import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types';
import { deriveOrderState, isRefundStatus } from './status'; import { deriveOrderState, isRefundStatus } from './status';
const MAX_PENDING_ORDERS = 3; const MAX_PENDING_ORDERS = 3;
@@ -14,7 +12,7 @@ const MAX_PENDING_ORDERS = 3;
export interface CreateOrderInput { export interface CreateOrderInput {
userId: number; userId: number;
amount: number; amount: number;
paymentType: 'alipay' | 'wxpay'; paymentType: PaymentType;
clientIp: string; clientIp: string;
} }
@@ -22,11 +20,12 @@ export interface CreateOrderResult {
orderId: string; orderId: string;
amount: number; amount: number;
status: string; status: string;
paymentType: 'alipay' | 'wxpay'; paymentType: PaymentType;
userName: string; userName: string;
userBalance: number; userBalance: number;
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
checkoutUrl?: string | null;
expiresAt: Date; expiresAt: Date;
} }
@@ -67,20 +66,24 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
}); });
try { try {
const easyPayResult = await createPayment({ initPaymentProviders();
outTradeNo: order.id, const provider = paymentRegistry.getProvider(input.paymentType);
amount: input.amount.toFixed(2), const paymentResult = await provider.createPayment({
orderId: order.id,
amount: input.amount,
paymentType: input.paymentType, paymentType: input.paymentType,
subject: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
returnUrl: env.EASY_PAY_RETURN_URL || '',
clientIp: input.clientIp, clientIp: input.clientIp,
productName: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
}); });
await prisma.order.update({ await prisma.order.update({
where: { id: order.id }, where: { id: order.id },
data: { data: {
paymentTradeNo: easyPayResult.trade_no, paymentTradeNo: paymentResult.tradeNo,
payUrl: easyPayResult.payurl || null, payUrl: paymentResult.payUrl || null,
qrCode: easyPayResult.qrcode || null, qrCode: paymentResult.qrCode || null,
}, },
}); });
@@ -100,8 +103,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
paymentType: input.paymentType, paymentType: input.paymentType,
userName: user.username, userName: user.username,
userBalance: user.balance, userBalance: user.balance,
payUrl: easyPayResult.payurl, payUrl: paymentResult.payUrl,
qrCode: easyPayResult.qrcode, qrCode: paymentResult.qrCode,
checkoutUrl: paymentResult.checkoutUrl,
expiresAt, expiresAt,
}; };
} catch (error) { } catch (error) {
@@ -155,46 +159,41 @@ export async function adminCancelOrder(orderId: string): Promise<void> {
}); });
} }
export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<boolean> { /**
const env = getEnv(); * Provider-agnostic: confirm a payment and trigger recharge.
* Called by any provider's webhook/notify handler after verification.
const { sign, ...rest } = params; */
const paramsForSign: Record<string, string> = {}; export async function confirmPayment(input: {
for (const [key, value] of Object.entries(rest)) { orderId: string;
if (value !== undefined && value !== null) { tradeNo: string;
paramsForSign[key] = String(value); paidAmount: number;
} providerName: string;
} }): Promise<boolean> {
if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
console.error('EasyPay notify: invalid signature');
return false;
}
if (params.trade_status !== 'TRADE_SUCCESS') {
return true;
}
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: params.out_trade_no }, where: { id: input.orderId },
}); });
if (!order) { 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; return false;
} }
let paidAmount: Prisma.Decimal; let paidAmount: Prisma.Decimal;
try { try {
paidAmount = new Prisma.Decimal(params.money); paidAmount = new Prisma.Decimal(input.paidAmount.toFixed(2));
} catch { } catch {
console.error('EasyPay notify: invalid money format:', params.money); console.error(`${input.providerName} notify: invalid amount:`, input.paidAmount);
return false; return false;
} }
if (paidAmount.lte(0)) { 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; return false;
} }
if (!paidAmount.equals(order.amount)) { 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({ const result = await prisma.order.updateMany({
@@ -205,7 +204,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
data: { data: {
status: 'PAID', status: 'PAID',
amount: paidAmount, amount: paidAmount,
paymentTradeNo: params.trade_no, paymentTradeNo: input.tradeNo,
paidAt: new Date(), paidAt: new Date(),
failedAt: null, failedAt: null,
failedReason: null, failedReason: null,
@@ -222,25 +221,41 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
action: 'ORDER_PAID', action: 'ORDER_PAID',
detail: JSON.stringify({ detail: JSON.stringify({
previous_status: order.status, previous_status: order.status,
trade_no: params.trade_no, trade_no: input.tradeNo,
expected_amount: order.amount.toString(), expected_amount: order.amount.toString(),
paid_amount: paidAmount.toString(), paid_amount: paidAmount.toString(),
}), }),
operator: 'easy-pay', operator: input.providerName,
}, },
}); });
try { try {
// Recharge inline to avoid "paid but still recharging" async gaps.
await executeRecharge(order.id); await executeRecharge(order.id);
} catch (err) { } catch (err) {
// Payment has been confirmed, always ack notify to avoid endless retries from gateway.
console.error('Recharge failed for order:', order.id, err); console.error('Recharge failed for order:', order.id, err);
} }
return true; 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<boolean> {
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<void> { export async function executeRecharge(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({ where: { id: orderId } }); const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) { if (!order) {
@@ -442,15 +457,17 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
try { try {
if (order.paymentTradeNo) { 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( await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
order.userId,
amount,
`sub2apipay refund order:${order.id}`,
`sub2apipay:refund:${order.id}`,
);
await prisma.order.update({ await prisma.order.update({
where: { id: input.orderId }, where: { id: input.orderId },

View File

@@ -1,10 +1,4 @@
export type RechargeStatus = export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed';
| 'not_paid'
| 'paid_pending'
| 'recharging'
| 'success'
| 'failed'
| 'closed';
export interface OrderStatusLike { export interface OrderStatusLike {
status: string; status: string;
@@ -12,13 +6,7 @@ export interface OrderStatusLike {
completedAt?: Date | string | null; completedAt?: Date | string | null;
} }
const CLOSED_STATUSES = new Set([ const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
'EXPIRED',
'CANCELLED',
'REFUNDING',
'REFUNDED',
'REFUND_FAILED',
]);
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']); const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);

View File

@@ -1,22 +1,83 @@
import { prisma } from '@/lib/db'; 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 const INTERVAL_MS = 30_000; // 30 seconds
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
export async function expireOrders(): Promise<number> { export async function expireOrders(): Promise<number> {
const result = await prisma.order.updateMany({ const orders = await prisma.order.findMany({
where: { where: {
status: 'PENDING', status: 'PENDING',
expiresAt: { lt: new Date() }, expiresAt: { lt: new Date() },
}, },
data: { status: 'EXPIRED' }, select: {
id: true,
paymentTradeNo: true,
paymentType: true,
},
}); });
if (result.count > 0) { if (orders.length === 0) return 0;
console.log(`Expired ${result.count} orders`);
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 { export function startTimeoutScheduler(): void {

View File

@@ -56,6 +56,41 @@ export function formatCreatedAt(value: string): string {
return date.toLocaleString(); return date.toLocaleString();
} }
export interface PaymentTypeMeta {
label: string;
sublabel?: string;
color: string;
selectedBorder: string;
selectedBg: string;
iconBg: string;
}
export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
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 { export function getStatusBadgeClass(status: string, isDark: boolean): string {
if (['COMPLETED', 'PAID'].includes(status)) { if (['COMPLETED', 'PAID'].includes(status)) {
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700'; return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';

30
src/lib/payment/index.ts Normal file
View File

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

View File

@@ -0,0 +1,29 @@
import type { PaymentProvider, PaymentType } from './types';
export class PaymentProviderRegistry {
private providers = new Map<PaymentType, PaymentProvider>();
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();

66
src/lib/payment/types.ts Normal file
View File

@@ -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<CreatePaymentResponse>;
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;
/** Returns null for unrecognized/irrelevant webhook events (caller should return 200). */
verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null>;
refund(request: RefundRequest): Promise<RefundResponse>;
/** Cancel/expire a pending payment on the platform. Optional — not all providers support it. */
cancelPayment?(tradeNo: string): Promise<void>;
}

139
src/lib/stripe/provider.ts Normal file
View File

@@ -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<CreatePaymentResponse> {
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<QueryOrderResponse> {
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<string, string>): Promise<PaymentNotification | null> {
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<RefundResponse> {
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<void> {
const stripe = this.getClient();
await stripe.checkout.sessions.expire(tradeNo);
}
}

View File

@@ -51,20 +51,17 @@ export async function createAndRedeem(
notes: string, notes: string,
): Promise<Sub2ApiRedeemCode> { ): Promise<Sub2ApiRedeemCode> {
const env = getEnv(); const env = getEnv();
const response = await fetch( const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, method: 'POST',
{ headers: getHeaders(`sub2apipay:recharge:${code}`),
method: 'POST', body: JSON.stringify({
headers: getHeaders(`sub2apipay:recharge:${code}`), code,
body: JSON.stringify({ type: 'balance',
code, value,
type: 'balance', user_id: userId,
value, notes,
user_id: userId, }),
notes, });
}),
},
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
@@ -82,18 +79,15 @@ export async function subtractBalance(
idempotencyKey: string, idempotencyKey: string,
): Promise<void> { ): Promise<void> {
const env = getEnv(); const env = getEnv();
const response = await fetch( const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, method: 'POST',
{ headers: getHeaders(idempotencyKey),
method: 'POST', body: JSON.stringify({
headers: getHeaders(idempotencyKey), operation: 'subtract',
body: JSON.stringify({ amount,
operation: 'subtract', notes,
amount, }),
notes, });
}),
},
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));

View File

@@ -7,7 +7,10 @@ export function middleware(request: NextRequest) {
// IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔) // IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔)
const allowOrigins = process.env.IFRAME_ALLOW_ORIGINS || ''; 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) { if (origins.length > 0) {
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`); response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`);

View File

@@ -22,13 +22,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
"next-env.d.ts", "exclude": ["node_modules", "third-party", "vendor"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules", "third-party"]
} }

1
vendor/stripe-node vendored Submodule

Submodule vendor/stripe-node added at 701eb047f3

View File

@@ -5,7 +5,7 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
exclude: ['**/node_modules/**', '**/third-party/**'], exclude: ['**/node_modules/**', '**/third-party/**', '**/vendor/**'],
}, },
resolve: { resolve: {
alias: { alias: {