2026-03-06 13:57:52 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
PaymentProvider,
|
|
|
|
|
|
PaymentType,
|
|
|
|
|
|
CreatePaymentRequest,
|
|
|
|
|
|
CreatePaymentResponse,
|
|
|
|
|
|
QueryOrderResponse,
|
|
|
|
|
|
PaymentNotification,
|
|
|
|
|
|
RefundRequest,
|
|
|
|
|
|
RefundResponse,
|
|
|
|
|
|
} from '@/lib/payment/types';
|
|
|
|
|
|
import {
|
2026-03-06 14:31:43 +08:00
|
|
|
|
createPcOrder,
|
2026-03-06 13:57:52 +08:00
|
|
|
|
createH5Order,
|
|
|
|
|
|
queryOrder,
|
|
|
|
|
|
closeOrder,
|
|
|
|
|
|
createRefund,
|
|
|
|
|
|
decipherNotify,
|
|
|
|
|
|
verifyNotifySign,
|
|
|
|
|
|
} from './client';
|
|
|
|
|
|
import { getEnv } from '@/lib/config';
|
|
|
|
|
|
import type { WxpayNotifyPayload, WxpayNotifyResource } from './types';
|
|
|
|
|
|
|
|
|
|
|
|
export class WxpayProvider implements PaymentProvider {
|
|
|
|
|
|
readonly name = 'wxpay-direct';
|
|
|
|
|
|
readonly providerKey = 'wxpay';
|
2026-03-06 19:05:21 +08:00
|
|
|
|
readonly supportedTypes: PaymentType[] = ['wxpay_direct'];
|
2026-03-06 13:57:52 +08:00
|
|
|
|
readonly defaultLimits = {
|
2026-03-06 19:05:21 +08:00
|
|
|
|
wxpay_direct: { singleMax: 1000, dailyMax: 10000 },
|
2026-03-06 13:57:52 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
|
|
|
|
|
const env = getEnv();
|
|
|
|
|
|
const notifyUrl = env.WXPAY_NOTIFY_URL || request.notifyUrl;
|
|
|
|
|
|
if (!notifyUrl) {
|
|
|
|
|
|
throw new Error('WXPAY_NOTIFY_URL is required');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 22:34:57 +08:00
|
|
|
|
if (request.isMobile && request.clientIp) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const h5Url = await createH5Order({
|
|
|
|
|
|
out_trade_no: request.orderId,
|
|
|
|
|
|
description: request.subject,
|
|
|
|
|
|
notify_url: notifyUrl,
|
|
|
|
|
|
amount: request.amount,
|
|
|
|
|
|
payer_client_ip: request.clientIp,
|
|
|
|
|
|
});
|
|
|
|
|
|
return { tradeNo: request.orderId, payUrl: h5Url };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// H5 未开通,fallback 到 Native 扫码
|
2026-03-06 22:32:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-06 13:57:52 +08:00
|
|
|
|
|
2026-03-06 14:31:43 +08:00
|
|
|
|
const codeUrl = await createPcOrder({
|
2026-03-06 13:57:52 +08:00
|
|
|
|
out_trade_no: request.orderId,
|
|
|
|
|
|
description: request.subject,
|
|
|
|
|
|
notify_url: notifyUrl,
|
|
|
|
|
|
amount: request.amount,
|
|
|
|
|
|
});
|
|
|
|
|
|
return { tradeNo: request.orderId, qrCode: codeUrl };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
|
|
|
|
|
const result = await queryOrder(tradeNo);
|
|
|
|
|
|
|
|
|
|
|
|
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
|
|
|
|
|
switch (result.trade_state) {
|
|
|
|
|
|
case 'SUCCESS':
|
|
|
|
|
|
status = 'paid';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'REFUND':
|
|
|
|
|
|
status = 'refunded';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'CLOSED':
|
|
|
|
|
|
case 'PAYERROR':
|
|
|
|
|
|
status = 'failed';
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
status = 'pending';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const amount = result.amount as { total?: number } | undefined;
|
|
|
|
|
|
const totalFen = amount?.total ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
tradeNo: (result.transaction_id as string) || tradeNo,
|
|
|
|
|
|
status,
|
|
|
|
|
|
amount: totalFen / 100,
|
|
|
|
|
|
paidAt: result.success_time ? new Date(result.success_time as string) : undefined,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async verifyNotification(
|
|
|
|
|
|
rawBody: string | Buffer,
|
|
|
|
|
|
headers: Record<string, string>,
|
|
|
|
|
|
): Promise<PaymentNotification | null> {
|
|
|
|
|
|
const env = getEnv();
|
|
|
|
|
|
if (!env.WXPAY_PUBLIC_KEY) {
|
|
|
|
|
|
throw new Error('WXPAY_PUBLIC_KEY is required for notification verification');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
|
|
|
|
|
|
|
|
|
|
|
const timestamp = headers['wechatpay-timestamp'] || '';
|
|
|
|
|
|
const nonce = headers['wechatpay-nonce'] || '';
|
|
|
|
|
|
const signature = headers['wechatpay-signature'] || '';
|
|
|
|
|
|
const serial = headers['wechatpay-serial'] || '';
|
|
|
|
|
|
|
|
|
|
|
|
if (!timestamp || !nonce || !signature || !serial) {
|
|
|
|
|
|
throw new Error('Missing required Wechatpay signature headers');
|
|
|
|
|
|
}
|
2026-03-06 22:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 验证 serial 匹配我们配置的公钥 ID
|
|
|
|
|
|
if (env.WXPAY_PUBLIC_KEY_ID && serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
|
|
|
|
|
throw new Error(`Wxpay serial mismatch: expected ${env.WXPAY_PUBLIC_KEY_ID}, got ${serial}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 13:57:52 +08:00
|
|
|
|
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
|
|
|
|
|
if (!valid) {
|
|
|
|
|
|
throw new Error('Wxpay notification signature verification failed');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
|
if (Math.abs(now - Number(timestamp)) > 300) {
|
|
|
|
|
|
throw new Error('Wechatpay notification timestamp expired');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload: WxpayNotifyPayload = JSON.parse(body);
|
|
|
|
|
|
|
|
|
|
|
|
if (payload.event_type !== 'TRANSACTION.SUCCESS') {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resource = decipherNotify<WxpayNotifyResource>(
|
|
|
|
|
|
payload.resource.ciphertext,
|
|
|
|
|
|
payload.resource.associated_data,
|
|
|
|
|
|
payload.resource.nonce,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
tradeNo: resource.transaction_id,
|
|
|
|
|
|
orderId: resource.out_trade_no,
|
|
|
|
|
|
amount: resource.amount.total / 100,
|
|
|
|
|
|
status: resource.trade_state === 'SUCCESS' ? 'success' : 'failed',
|
|
|
|
|
|
rawData: resource,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async refund(request: RefundRequest): Promise<RefundResponse> {
|
|
|
|
|
|
const orderResult = await queryOrder(request.orderId);
|
|
|
|
|
|
const amount = orderResult.amount as { total?: number } | undefined;
|
|
|
|
|
|
const totalFen = amount?.total ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
const result = await createRefund({
|
|
|
|
|
|
out_trade_no: request.orderId,
|
|
|
|
|
|
out_refund_no: `refund-${request.orderId}`,
|
|
|
|
|
|
amount: request.amount,
|
|
|
|
|
|
total: totalFen / 100,
|
|
|
|
|
|
reason: request.reason,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
refundId: (result.refund_id as string) || `${request.orderId}-refund`,
|
|
|
|
|
|
status: result.status === 'SUCCESS' ? 'success' : 'pending',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async cancelPayment(tradeNo: string): Promise<void> {
|
|
|
|
|
|
await closeOrder(tradeNo);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|