feat: integrate Stripe payment with bugfixes and active timeout cancellation

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

View File

@@ -28,8 +28,29 @@ function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
}
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'}`);
}

View File

@@ -0,0 +1,87 @@
import type {
PaymentProvider,
PaymentType,
CreatePaymentRequest,
CreatePaymentResponse,
QueryOrderResponse,
PaymentNotification,
RefundRequest,
RefundResponse,
} from '@/lib/payment/types';
import { createPayment, queryOrder, refund } from './client';
import { verifySign } from './sign';
import { getEnv } from '@/lib/config';
export class EasyPayProvider implements PaymentProvider {
readonly name = 'easy-pay';
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const result = await createPayment({
outTradeNo: request.orderId,
amount: request.amount.toFixed(2),
paymentType: request.paymentType as 'alipay' | 'wxpay',
clientIp: request.clientIp || '127.0.0.1',
productName: request.subject,
});
return {
tradeNo: result.trade_no,
payUrl: result.payurl,
qrCode: result.qrcode,
};
}
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
const result = await queryOrder(tradeNo);
return {
tradeNo: result.trade_no,
status: result.status === 1 ? 'paid' : 'pending',
amount: parseFloat(result.money),
paidAt: result.endtime ? new Date(result.endtime) : undefined,
};
}
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
const env = getEnv();
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
const searchParams = new URLSearchParams(body);
const params: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
const sign = params.sign || '';
const paramsForSign: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
paramsForSign[key] = value;
}
}
if (!env.EASY_PAY_PKEY || !verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
throw new Error('EasyPay notification signature verification failed');
}
return {
tradeNo: params.trade_no || '',
orderId: params.out_trade_no || '',
amount: parseFloat(params.money || '0'),
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
rawData: params,
};
}
async refund(request: RefundRequest): Promise<RefundResponse> {
await refund(request.tradeNo, request.orderId, request.amount.toFixed(2));
return {
refundId: `${request.tradeNo}-refund`,
status: 'success',
};
}
async cancelPayment(): Promise<void> {
// EasyPay does not support cancelling payments
}
}

View File

@@ -2,7 +2,9 @@ import crypto from 'crypto';
export function generateSign(params: Record<string, string>, pkey: string): string {
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('&');