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:
72
.github/workflows/ci.yml
vendored
Normal file
72
.github/workflows/ci.yml
vendored
Normal 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
3
.gitmodules
vendored
Normal 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
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.next
|
||||
node_modules
|
||||
vendor
|
||||
pnpm-lock.yaml
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
image: sub2apipay:latest
|
||||
container_name: sub2apipay
|
||||
ports:
|
||||
- "8087:3000"
|
||||
- '8087:3000'
|
||||
env_file: .env
|
||||
networks:
|
||||
- sub2api-network
|
||||
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${APP_PORT:-3001}:3000"
|
||||
- '${APP_PORT:-3001}:3000'
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U sub2apipay"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U sub2apipay']
|
||||
interval: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
@@ -8,10 +8,12 @@ const eslintConfig = defineConfig([
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
// Git submodules:
|
||||
'vendor/**',
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
@@ -18,6 +21,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"stripe": "^20.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -37,6 +41,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prisma": "7.4.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
stripe:
|
||||
specifier: ^20.4.0
|
||||
version: 20.4.0(@types/node@20.19.35)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -60,6 +63,9 @@ importers:
|
||||
eslint-config-next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
prisma:
|
||||
specifier: 7.4.1
|
||||
version: 7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
@@ -2436,6 +2442,11 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.8.1:
|
||||
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
prisma@7.4.1:
|
||||
resolution: {integrity: sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==}
|
||||
engines: {node: ^20.19 || ^22.12 || >=24.0}
|
||||
@@ -2694,6 +2705,15 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
stripe@20.4.0:
|
||||
resolution: {integrity: sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=16'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -5301,6 +5321,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.1: {}
|
||||
|
||||
prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@prisma/config': 7.4.1
|
||||
@@ -5651,6 +5673,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
stripe@20.4.0(@types/node@20.19.35):
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.35
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
87
src/__tests__/lib/payment/registry.test.ts
Normal file
87
src/__tests__/lib/payment/registry.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
281
src/__tests__/lib/stripe/provider.test.ts
Normal file
281
src/__tests__/lib/stripe/provider.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,8 @@ describe('Sub2API Client', () => {
|
||||
it('createAndRedeem should send correct request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
code: 1,
|
||||
redeem_code: {
|
||||
id: 1,
|
||||
|
||||
@@ -5,11 +5,42 @@ import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import OrderTable from '@/components/admin/OrderTable';
|
||||
import OrderDetail from '@/components/admin/OrderDetail';
|
||||
|
||||
interface AdminOrder {
|
||||
id: string;
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedReason: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface AdminOrderDetail extends AdminOrder {
|
||||
rechargeCode: string;
|
||||
paymentTradeNo: string | null;
|
||||
refundAmount: number | null;
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
forceRefund: boolean;
|
||||
failedAt: string | null;
|
||||
updatedAt: string;
|
||||
clientIp: string | null;
|
||||
paymentSuccess?: boolean;
|
||||
rechargeSuccess?: boolean;
|
||||
rechargeStatus?: string;
|
||||
auditLogs: { id: string; action: string; detail: string | null; operator: string | null; createdAt: string }[];
|
||||
}
|
||||
|
||||
function AdminContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
@@ -17,8 +48,7 @@ function AdminContent() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Dialogs
|
||||
const [detailOrder, setDetailOrder] = useState<any>(null);
|
||||
const [detailOrder, setDetailOrder] = useState<AdminOrderDetail | null>(null);
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
@@ -134,7 +164,9 @@ function AdminContent() {
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -143,11 +175,12 @@ function AdminContent() {
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setStatusFilter(s); setPage(1); }}
|
||||
onClick={() => {
|
||||
setStatusFilter(s);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`rounded-full px-3 py-1 text-sm transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
statusFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{statusLabels[s]}
|
||||
@@ -160,12 +193,7 @@ function AdminContent() {
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
onRetry={handleRetry}
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -175,15 +203,17 @@ function AdminContent() {
|
||||
<span>共 {total} 条记录</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1">{page} / {totalPages}</span>
|
||||
<span className="px-3 py-1">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
@@ -194,23 +224,20 @@ function AdminContent() {
|
||||
)}
|
||||
|
||||
{/* Order Detail */}
|
||||
{detailOrder && (
|
||||
<OrderDetail
|
||||
order={detailOrder}
|
||||
onClose={() => setDetailOrder(null)}
|
||||
/>
|
||||
)}
|
||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
@@ -14,10 +11,7 @@ export async function POST(
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Admin cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
|
||||
@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { retryRecharge, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
@@ -14,10 +11,7 @@ export async function POST(
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Retry recharge error:', error);
|
||||
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
|
||||
|
||||
@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, OrderStatus } from '@prisma/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
|
||||
const dateTo = searchParams.get('date_to');
|
||||
|
||||
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 (dateFrom || dateTo) {
|
||||
where.createdAt = {};
|
||||
@@ -48,7 +48,7 @@ export async function GET(request: NextRequest) {
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
orders: orders.map(o => ({
|
||||
orders: orders.map((o) => ({
|
||||
...o,
|
||||
amount: Number(o.amount),
|
||||
})),
|
||||
|
||||
@@ -17,10 +17,7 @@ export async function POST(request: NextRequest) {
|
||||
const parsed = refundSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await processRefund({
|
||||
@@ -32,10 +29,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Refund error:', error);
|
||||
return NextResponse.json({ error: '退款失败' }, { status: 500 });
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
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) {
|
||||
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 = {
|
||||
pid: searchParams.get('pid') || '',
|
||||
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);
|
||||
const notification = await easyPayProvider.verifyNotification(rawBody, headers);
|
||||
const success = await handlePaymentNotify(notification, easyPayProvider.name);
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
@@ -6,30 +6,21 @@ const cancelSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = cancelSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||
}
|
||||
|
||||
await cancelOrder(id, parsed.data.user_id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getEnv } from '@/lib/config';
|
||||
const createOrderSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().positive(),
|
||||
payment_type: z.enum(['alipay', 'wxpay']),
|
||||
payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -16,10 +16,7 @@ export async function POST(request: NextRequest) {
|
||||
const parsed = createOrderSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||
}
|
||||
|
||||
const { user_id, amount, payment_type } = parsed.data;
|
||||
@@ -34,15 +31,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Validate payment type is enabled
|
||||
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的支付方式: ${payment_type}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| '127.0.0.1';
|
||||
const clientIp =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || '127.0.0.1';
|
||||
|
||||
const result = await createOrder({
|
||||
userId: user_id,
|
||||
@@ -54,15 +47,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
|
||||
}
|
||||
console.error('Create order error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '创建订单失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
return NextResponse.json({ error: '创建订单失败,请稍后重试' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/api/stripe/webhook/route.ts
Normal file
35
src/app/api/stripe/webhook/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getUser } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const userId = Number(id);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #f9fafb;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sub2API 充值",
|
||||
description: "Sub2API 余额充值平台",
|
||||
title: 'Sub2API 充值',
|
||||
description: 'Sub2API 余额充值平台',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -13,9 +13,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-gray-50 text-gray-900 antialiased">
|
||||
{children}
|
||||
</body>
|
||||
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,9 @@ function OrdersContent() {
|
||||
|
||||
if (isMobile) {
|
||||
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...
|
||||
</div>
|
||||
);
|
||||
@@ -184,7 +186,9 @@ function OrdersContent() {
|
||||
onClick={loadOrders}
|
||||
className={[
|
||||
'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(' ')}
|
||||
>
|
||||
刷新
|
||||
@@ -193,7 +197,9 @@ function OrdersContent() {
|
||||
href={payUrl}
|
||||
className={[
|
||||
'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(' ')}
|
||||
>
|
||||
返回充值
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, Suspense, useMemo } from 'react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import PaymentForm from '@/components/PaymentForm';
|
||||
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
@@ -13,9 +13,10 @@ interface OrderResult {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: 'alipay' | 'wxpay' | 'stripe';
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ function PayContent() {
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
|
||||
const [config] = useState<AppConfig>({
|
||||
enabledPaymentTypes: ['alipay', 'wxpay'],
|
||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||
minAmount: 1,
|
||||
maxAmount: 10000,
|
||||
});
|
||||
@@ -185,6 +186,7 @@ function PayContent() {
|
||||
paymentType: data.paymentType || paymentType,
|
||||
payUrl: data.payUrl,
|
||||
qrCode: data.qrCode,
|
||||
checkoutUrl: data.checkoutUrl,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
|
||||
@@ -385,6 +387,7 @@ function PayContent() {
|
||||
orderId={orderResult.orderId}
|
||||
payUrl={orderResult.payUrl}
|
||||
qrCode={orderResult.qrCode}
|
||||
checkoutUrl={orderResult.checkoutUrl}
|
||||
paymentType={orderResult.paymentType}
|
||||
amount={orderResult.amount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useEffect, useState, Suspense } from 'react';
|
||||
|
||||
function ResultContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const outTradeNo = searchParams.get('out_trade_no');
|
||||
const tradeStatus = searchParams.get('trade_status');
|
||||
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
|
||||
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 [loading, setLoading] = useState(true);
|
||||
@@ -62,9 +63,7 @@ function ResultContent() {
|
||||
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{status === 'COMPLETED'
|
||||
? '余额已成功到账!'
|
||||
: '支付成功,余额正在充值中...'}
|
||||
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
|
||||
</p>
|
||||
</>
|
||||
) : isPending ? (
|
||||
@@ -89,9 +88,7 @@ function ResultContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
订单号: {outTradeNo || '未知'}
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-gray-400">订单号: {outTradeNo || '未知'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -99,11 +96,13 @@ function ResultContent() {
|
||||
|
||||
export default function PayResultPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<ResultContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
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 {
|
||||
isDark: boolean;
|
||||
@@ -22,13 +28,17 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className={[
|
||||
'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(' ')}
|
||||
>
|
||||
刷新
|
||||
@@ -38,11 +48,21 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
|
||||
{!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(' ')}>
|
||||
当前链接未携带登录 token,无法查询"我的订单"。
|
||||
<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(' ')}
|
||||
>
|
||||
当前链接未携带登录 token,无法查询“我的订单”。
|
||||
</div>
|
||||
) : 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>
|
||||
) : (
|
||||
@@ -50,11 +70,16 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
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">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,12 @@ export default function OrderFilterBar({ isDark, activeFilter, onChange }: Order
|
||||
className={[
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
activeFilter === item.key
|
||||
? (isDark ? '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'),
|
||||
? isDark
|
||||
? '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(' ')}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -11,7 +11,10 @@ interface 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(' ');
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,22 +9,47 @@ interface OrderTableProps {
|
||||
|
||||
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
|
||||
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 ? (
|
||||
<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>
|
||||
) : 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}
|
||||
</div>
|
||||
) : 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 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>
|
||||
@@ -35,13 +60,20 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
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-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>{order.paymentType}</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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -60,22 +60,15 @@ export default function PayPageLayout({
|
||||
Sub2API Secure Pay
|
||||
</div>
|
||||
<h1
|
||||
className={[
|
||||
'text-2xl font-semibold tracking-tight',
|
||||
isDark ? 'text-slate-100' : 'text-slate-900',
|
||||
].join(' ')}
|
||||
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{subtitle}</p>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
|
||||
interface PaymentFormProps {
|
||||
userId: number;
|
||||
@@ -70,13 +71,62 @@ export default function PaymentForm({
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 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 className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>充值账户</div>
|
||||
<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(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
</div>
|
||||
@@ -118,7 +168,13 @@ export default function PaymentForm({
|
||||
自定义金额
|
||||
</label>
|
||||
<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
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -138,51 +194,50 @@ export default function PaymentForm({
|
||||
|
||||
{customAmount !== '' && !isValid && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Payment Type */}
|
||||
<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">
|
||||
{enabledPaymentTypes.map((type) => (
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = paymentType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
paymentType === type
|
||||
? isAlipay(type)
|
||||
? 'border-cyan-400 bg-cyan-50 text-slate-900 shadow-sm'
|
||||
: 'border-green-500 bg-green-50 text-slate-900 shadow-sm'
|
||||
isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? '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 h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">支付宝</span>
|
||||
<span className="text-[10px] tracking-[0.25em] text-slate-600">ALIPAY</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<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 className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{meta?.sublabel && (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,8 +247,12 @@ export default function PaymentForm({
|
||||
disabled={!isValid || loading}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
isValid && !loading
|
||||
? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300'
|
||||
? paymentType === 'stripe'
|
||||
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-300'
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
||||
|
||||
@@ -7,7 +7,8 @@ interface PaymentQRCodeProps {
|
||||
orderId: string;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay';
|
||||
checkoutUrl?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
||||
amount: number;
|
||||
expiresAt: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
@@ -23,10 +24,20 @@ const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
function isSafeCheckoutUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' && parsed.hostname.endsWith('.stripe.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
payUrl,
|
||||
qrCode,
|
||||
checkoutUrl,
|
||||
paymentType,
|
||||
amount,
|
||||
expiresAt,
|
||||
@@ -38,6 +49,7 @@ export default function PaymentQRCode({
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [stripeOpened, setStripeOpened] = useState(false);
|
||||
|
||||
const qrPayload = useMemo(() => {
|
||||
const value = (qrCode || payUrl || '').trim();
|
||||
@@ -124,13 +136,14 @@ export default function PaymentQRCode({
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
if (res.ok) {
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
await fetch(`/api/orders/${orderId}/cancel`, {
|
||||
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: data.user_id }),
|
||||
});
|
||||
if (cancelRes.ok) {
|
||||
onStatusChange('CANCELLED');
|
||||
}
|
||||
} catch {
|
||||
@@ -138,10 +151,11 @@ export default function PaymentQRCode({
|
||||
}
|
||||
};
|
||||
|
||||
const isStripe = paymentType === 'stripe';
|
||||
const isWx = paymentType === 'wxpay';
|
||||
const iconSrc = isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
const iconBgClass = isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
@@ -153,6 +167,51 @@ export default function PaymentQRCode({
|
||||
</div>
|
||||
|
||||
{!expired && (
|
||||
<>
|
||||
{isStripe ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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(' ')}>
|
||||
@@ -194,6 +253,8 @@ export default function PaymentQRCode({
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<button
|
||||
|
||||
@@ -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="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">
|
||||
<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 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 className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
<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.detail && <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>}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-400">暂无日志</div>
|
||||
)}
|
||||
{order.auditLogs.length === 0 && <div className="text-center text-sm text-gray-400">暂无日志</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{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 (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => onViewDetail(order.id)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<button onClick={() => onViewDetail(order.id)} className="text-blue-600 hover:underline">
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
@@ -70,9 +70,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
<div>{order.userName || '-'}</div>
|
||||
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{order.amount.toFixed(2)}</td>
|
||||
<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}`}>
|
||||
{statusInfo.label}
|
||||
@@ -109,9 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500">暂无订单</div>
|
||||
)}
|
||||
{orders.length === 0 && <div className="py-12 text-center text-gray-500">暂无订单</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function RefundDialog({
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{warning && (
|
||||
<div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
{warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">退款原因</label>
|
||||
|
||||
@@ -12,16 +12,24 @@ const envSchema = z.object({
|
||||
SUB2API_BASE_URL: z.string().url(),
|
||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||
|
||||
EASY_PAY_PID: z.string().min(1),
|
||||
EASY_PAY_PKEY: z.string().min(1),
|
||||
EASY_PAY_API_BASE: z.string().url(),
|
||||
EASY_PAY_NOTIFY_URL: z.string().url(),
|
||||
EASY_PAY_RETURN_URL: z.string().url(),
|
||||
// ── Easy-Pay (optional when only using Stripe) ──
|
||||
EASY_PAY_PID: optionalTrimmedString,
|
||||
EASY_PAY_PKEY: optionalTrimmedString,
|
||||
EASY_PAY_API_BASE: optionalTrimmedString,
|
||||
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
|
||||
EASY_PAY_RETURN_URL: optionalTrimmedString,
|
||||
EASY_PAY_CID: optionalTrimmedString,
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z
|
||||
.string()
|
||||
.default('alipay,wxpay')
|
||||
.transform((v) => v.split(',').map((s) => s.trim())),
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
||||
|
||||
@@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
|
||||
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||
}
|
||||
|
||||
function assertEasyPayEnv(env: ReturnType<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> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params: Record<string, string> = {
|
||||
pid: env.EASY_PAY_PID,
|
||||
type: opts.paymentType,
|
||||
@@ -57,7 +78,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
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) {
|
||||
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> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as EasyPayQueryResponse;
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
@@ -76,7 +97,7 @@ export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryRespon
|
||||
}
|
||||
|
||||
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
|
||||
const env = getEnv();
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const params = new URLSearchParams({
|
||||
pid: env.EASY_PAY_PID,
|
||||
key: env.EASY_PAY_PKEY,
|
||||
@@ -89,7 +110,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
const data = await response.json() as EasyPayRefundResponse;
|
||||
const data = (await response.json()) as EasyPayRefundResponse;
|
||||
if (data.code !== 1) {
|
||||
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
87
src/lib/easy-pay/provider.ts
Normal file
87
src/lib/easy-pay/provider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import crypto from 'crypto';
|
||||
|
||||
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateRechargeCode } from './code-gen';
|
||||
import { createPayment } from '@/lib/easy-pay/client';
|
||||
import { verifySign } from '@/lib/easy-pay/sign';
|
||||
import { refund as easyPayRefund } from '@/lib/easy-pay/client';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types';
|
||||
import { deriveOrderState, isRefundStatus } from './status';
|
||||
|
||||
const MAX_PENDING_ORDERS = 3;
|
||||
@@ -14,7 +12,7 @@ const MAX_PENDING_ORDERS = 3;
|
||||
export interface CreateOrderInput {
|
||||
userId: number;
|
||||
amount: number;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: PaymentType;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
@@ -22,11 +20,12 @@ export interface CreateOrderResult {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: PaymentType;
|
||||
userName: string;
|
||||
userBalance: number;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
@@ -67,20 +66,24 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
});
|
||||
|
||||
try {
|
||||
const easyPayResult = await createPayment({
|
||||
outTradeNo: order.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
const paymentResult = await provider.createPayment({
|
||||
orderId: order.id,
|
||||
amount: input.amount,
|
||||
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,
|
||||
productName: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
paymentTradeNo: easyPayResult.trade_no,
|
||||
payUrl: easyPayResult.payurl || null,
|
||||
qrCode: easyPayResult.qrcode || null,
|
||||
paymentTradeNo: paymentResult.tradeNo,
|
||||
payUrl: paymentResult.payUrl || null,
|
||||
qrCode: paymentResult.qrCode || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,8 +103,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
paymentType: input.paymentType,
|
||||
userName: user.username,
|
||||
userBalance: user.balance,
|
||||
payUrl: easyPayResult.payurl,
|
||||
qrCode: easyPayResult.qrcode,
|
||||
payUrl: paymentResult.payUrl,
|
||||
qrCode: paymentResult.qrCode,
|
||||
checkoutUrl: paymentResult.checkoutUrl,
|
||||
expiresAt,
|
||||
};
|
||||
} 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();
|
||||
|
||||
const { sign, ...rest } = params;
|
||||
const paramsForSign: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
paramsForSign[key] = String(value);
|
||||
}
|
||||
}
|
||||
if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
|
||||
console.error('EasyPay notify: invalid signature');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.trade_status !== 'TRADE_SUCCESS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-agnostic: confirm a payment and trigger recharge.
|
||||
* Called by any provider's webhook/notify handler after verification.
|
||||
*/
|
||||
export async function confirmPayment(input: {
|
||||
orderId: string;
|
||||
tradeNo: string;
|
||||
paidAmount: number;
|
||||
providerName: string;
|
||||
}): Promise<boolean> {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: params.out_trade_no },
|
||||
where: { id: input.orderId },
|
||||
});
|
||||
if (!order) {
|
||||
console.error('EasyPay notify: order not found:', params.out_trade_no);
|
||||
console.error(`${input.providerName} notify: order not found:`, input.orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
let paidAmount: Prisma.Decimal;
|
||||
try {
|
||||
paidAmount = new Prisma.Decimal(params.money);
|
||||
paidAmount = new Prisma.Decimal(input.paidAmount.toFixed(2));
|
||||
} catch {
|
||||
console.error('EasyPay notify: invalid money format:', params.money);
|
||||
console.error(`${input.providerName} notify: invalid amount:`, input.paidAmount);
|
||||
return false;
|
||||
}
|
||||
if (paidAmount.lte(0)) {
|
||||
console.error('EasyPay notify: non-positive money:', params.money);
|
||||
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
||||
return false;
|
||||
}
|
||||
if (!paidAmount.equals(order.amount)) {
|
||||
console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money);
|
||||
console.warn(
|
||||
`${input.providerName} notify: amount changed, use paid amount`,
|
||||
order.amount.toString(),
|
||||
paidAmount.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.order.updateMany({
|
||||
@@ -205,7 +204,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
|
||||
data: {
|
||||
status: 'PAID',
|
||||
amount: paidAmount,
|
||||
paymentTradeNo: params.trade_no,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
failedAt: null,
|
||||
failedReason: null,
|
||||
@@ -222,25 +221,41 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
|
||||
action: 'ORDER_PAID',
|
||||
detail: JSON.stringify({
|
||||
previous_status: order.status,
|
||||
trade_no: params.trade_no,
|
||||
trade_no: input.tradeNo,
|
||||
expected_amount: order.amount.toString(),
|
||||
paid_amount: paidAmount.toString(),
|
||||
}),
|
||||
operator: 'easy-pay',
|
||||
operator: input.providerName,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Recharge inline to avoid "paid but still recharging" async gaps.
|
||||
await executeRecharge(order.id);
|
||||
} catch (err) {
|
||||
// Payment has been confirmed, always ack notify to avoid endless retries from gateway.
|
||||
console.error('Recharge failed for order:', order.id, err);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a verified payment notification from any provider.
|
||||
* The caller (webhook route) is responsible for verifying the notification
|
||||
* via provider.verifyNotification() before calling this function.
|
||||
*/
|
||||
export async function handlePaymentNotify(notification: PaymentNotification, providerName: string): Promise<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> {
|
||||
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) {
|
||||
@@ -442,15 +457,17 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
|
||||
try {
|
||||
if (order.paymentTradeNo) {
|
||||
await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2));
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
|
||||
await subtractBalance(
|
||||
order.userId,
|
||||
amount,
|
||||
`sub2apipay refund order:${order.id}`,
|
||||
`sub2apipay:refund:${order.id}`,
|
||||
);
|
||||
await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
export type RechargeStatus =
|
||||
| 'not_paid'
|
||||
| 'paid_pending'
|
||||
| 'recharging'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'closed';
|
||||
export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed';
|
||||
|
||||
export interface OrderStatusLike {
|
||||
status: string;
|
||||
@@ -12,13 +6,7 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set([
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REFUNDING',
|
||||
'REFUNDED',
|
||||
'REFUND_FAILED',
|
||||
]);
|
||||
const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType } from '@/lib/payment';
|
||||
import { confirmPayment } from './service';
|
||||
|
||||
const INTERVAL_MS = 30_000; // 30 seconds
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function expireOrders(): Promise<number> {
|
||||
const result = await prisma.order.updateMany({
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
paymentTradeNo: true,
|
||||
paymentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (orders.length === 0) return 0;
|
||||
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const order of orders) {
|
||||
try {
|
||||
// If order has a payment on the platform, check its actual status
|
||||
if (order.paymentTradeNo && order.paymentType) {
|
||||
try {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
|
||||
|
||||
// Query the real payment status before expiring
|
||||
const queryResult = await provider.queryOrder(order.paymentTradeNo);
|
||||
|
||||
if (queryResult.status === 'paid') {
|
||||
// User already paid — process as success instead of expiring
|
||||
await confirmPayment({
|
||||
orderId: order.id,
|
||||
tradeNo: order.paymentTradeNo,
|
||||
paidAmount: queryResult.amount,
|
||||
providerName: provider.name,
|
||||
});
|
||||
console.log(`Order ${order.id} was paid during timeout, processed as success`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not paid — cancel on the platform
|
||||
if (provider.cancelPayment) {
|
||||
try {
|
||||
await provider.cancelPayment(order.paymentTradeNo);
|
||||
} catch (cancelErr) {
|
||||
// Cancel may fail if session already expired on platform side — that's fine
|
||||
console.warn(`Failed to cancel payment for order ${order.id}:`, cancelErr);
|
||||
}
|
||||
}
|
||||
} catch (platformErr) {
|
||||
// Platform unreachable — still expire the order locally
|
||||
console.warn(`Platform check failed for order ${order.id}, expiring anyway:`, platformErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as expired in database (WHERE status='PENDING' ensures idempotency)
|
||||
const result = await prisma.order.updateMany({
|
||||
where: { id: order.id, status: 'PENDING' },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
console.log(`Expired ${result.count} orders`);
|
||||
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 {
|
||||
|
||||
@@ -56,6 +56,41 @@ export function formatCreatedAt(value: string): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export interface PaymentTypeMeta {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
color: string;
|
||||
selectedBorder: string;
|
||||
selectedBg: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
export const PAYMENT_TYPE_META: Record<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 {
|
||||
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||
|
||||
30
src/lib/payment/index.ts
Normal file
30
src/lib/payment/index.ts
Normal 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;
|
||||
}
|
||||
29
src/lib/payment/registry.ts
Normal file
29
src/lib/payment/registry.ts
Normal 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
66
src/lib/payment/types.ts
Normal 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
139
src/lib/stripe/provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,7 @@ export async function createAndRedeem(
|
||||
notes: string,
|
||||
): Promise<Sub2ApiRedeemCode> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(
|
||||
`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`,
|
||||
{
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||
body: JSON.stringify({
|
||||
@@ -63,8 +61,7 @@ export async function createAndRedeem(
|
||||
user_id: userId,
|
||||
notes,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -82,9 +79,7 @@ export async function subtractBalance(
|
||||
idempotencyKey: string,
|
||||
): Promise<void> {
|
||||
const env = getEnv();
|
||||
const response = await fetch(
|
||||
`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`,
|
||||
{
|
||||
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
@@ -92,8 +87,7 @@ export async function subtractBalance(
|
||||
amount,
|
||||
notes,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -7,7 +7,10 @@ export function middleware(request: NextRequest) {
|
||||
// IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔)
|
||||
const allowOrigins = process.env.IFRAME_ALLOW_ORIGINS || '';
|
||||
|
||||
const origins = allowOrigins.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const origins = allowOrigins
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (origins.length > 0) {
|
||||
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`);
|
||||
|
||||
@@ -22,13 +22,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules", "third-party"]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||
"exclude": ["node_modules", "third-party", "vendor"]
|
||||
}
|
||||
|
||||
1
vendor/stripe-node
vendored
Submodule
1
vendor/stripe-node
vendored
Submodule
Submodule vendor/stripe-node added at 701eb047f3
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
exclude: ['**/node_modules/**', '**/third-party/**'],
|
||||
exclude: ['**/node_modules/**', '**/third-party/**', '**/vendor/**'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user