feat: 集成微信支付直连(Native + H5)及金融级安全修复
- 新增 wxpay provider(wechatpay-node-v3 SDK),支持 Native 扫码和 H5 跳转 - 新增 /api/wxpay/notify 回调路由,AES-256-GCM 解密 + RSA 签名验证 - 修复 confirmPayment count=0 静默成功、充值失败返回 true 等 P0 问题 - 修复 notifyUrl 硬编码 easypay、回调金额覆盖订单金额等 P1 问题 - 手续费计算改用 Prisma.Decimal 精确运算,消除浮点误差 - 支付宝 provider 移除冗余 paramsForVerify,fetch 添加超时 - 补充 .env.example 配置文档和 CLAUDE.md 支付渠道说明
This commit is contained in:
154
src/lib/wxpay/client.ts
Normal file
154
src/lib/wxpay/client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import WxPay from 'wechatpay-node-v3';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { WxpayNativeOrderParams, WxpayH5OrderParams, WxpayRefundParams } from './types';
|
||||
|
||||
const BASE_URL = 'https://api.mch.weixin.qq.com';
|
||||
|
||||
function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (!env.WXPAY_APP_ID || !env.WXPAY_MCH_ID || !env.WXPAY_PRIVATE_KEY || !env.WXPAY_API_V3_KEY) {
|
||||
throw new Error(
|
||||
'Wxpay environment variables (WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY) are required',
|
||||
);
|
||||
}
|
||||
return env as typeof env & {
|
||||
WXPAY_APP_ID: string;
|
||||
WXPAY_MCH_ID: string;
|
||||
WXPAY_PRIVATE_KEY: string;
|
||||
WXPAY_API_V3_KEY: string;
|
||||
};
|
||||
}
|
||||
|
||||
let payInstance: WxPay | null = null;
|
||||
|
||||
function getPayInstance(): WxPay {
|
||||
if (payInstance) return payInstance;
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
|
||||
const privateKey = Buffer.from(env.WXPAY_PRIVATE_KEY);
|
||||
const publicKey = env.WXPAY_PUBLIC_KEY ? Buffer.from(env.WXPAY_PUBLIC_KEY) : Buffer.alloc(0);
|
||||
|
||||
payInstance = new WxPay({
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
publicKey,
|
||||
privateKey,
|
||||
key: env.WXPAY_API_V3_KEY,
|
||||
serial_no: env.WXPAY_CERT_SERIAL,
|
||||
});
|
||||
return payInstance;
|
||||
}
|
||||
|
||||
function yuanToFen(yuan: number): number {
|
||||
return Math.round(yuan * 100);
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body?: Record<string, unknown>): Promise<T> {
|
||||
const pay = getPayInstance();
|
||||
const nonce_str = Math.random().toString(36).substring(2, 15);
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const signature = pay.getSignature(method, nonce_str, timestamp, url, body ? JSON.stringify(body) : '');
|
||||
const authorization = pay.getAuthorization(nonce_str, timestamp, signature);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Sub2ApiPay/1.0',
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.status === 204) return {} as T;
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const code = (data as Record<string, string>).code || res.status;
|
||||
const message = (data as Record<string, string>).message || res.statusText;
|
||||
throw new Error(`Wxpay API error: [${code}] ${message}`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function createNativeOrder(params: WxpayNativeOrderParams): Promise<string> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const result = await request<{ code_url: string }>('POST', '/v3/pay/transactions/native', {
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
description: params.description,
|
||||
out_trade_no: params.out_trade_no,
|
||||
notify_url: params.notify_url,
|
||||
amount: { total: yuanToFen(params.amount), currency: 'CNY' },
|
||||
});
|
||||
return result.code_url;
|
||||
}
|
||||
|
||||
export async function createH5Order(params: WxpayH5OrderParams): Promise<string> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const result = await request<{ h5_url: string }>('POST', '/v3/pay/transactions/h5', {
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
description: params.description,
|
||||
out_trade_no: params.out_trade_no,
|
||||
notify_url: params.notify_url,
|
||||
amount: { total: yuanToFen(params.amount), currency: 'CNY' },
|
||||
scene_info: {
|
||||
payer_client_ip: params.payer_client_ip,
|
||||
h5_info: { type: 'Wap' },
|
||||
},
|
||||
});
|
||||
return result.h5_url;
|
||||
}
|
||||
|
||||
export async function queryOrder(outTradeNo: string): Promise<Record<string, unknown>> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const url = `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${env.WXPAY_MCH_ID}`;
|
||||
return request<Record<string, unknown>>('GET', url);
|
||||
}
|
||||
|
||||
export async function closeOrder(outTradeNo: string): Promise<void> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const url = `/v3/pay/transactions/out-trade-no/${outTradeNo}/close`;
|
||||
await request('POST', url, { mchid: env.WXPAY_MCH_ID });
|
||||
}
|
||||
|
||||
export async function createRefund(params: WxpayRefundParams): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>('POST', '/v3/refund/domestic/refunds', {
|
||||
out_trade_no: params.out_trade_no,
|
||||
out_refund_no: params.out_refund_no,
|
||||
reason: params.reason,
|
||||
amount: {
|
||||
refund: yuanToFen(params.amount),
|
||||
total: yuanToFen(params.total),
|
||||
currency: 'CNY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function decipherNotify<T>(ciphertext: string, associatedData: string, nonce: string): T {
|
||||
const pay = getPayInstance();
|
||||
return pay.decipher_gcm<T>(ciphertext, associatedData, nonce);
|
||||
}
|
||||
|
||||
export async function verifyNotifySign(params: {
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
body: string;
|
||||
serial: string;
|
||||
signature: string;
|
||||
}): Promise<boolean> {
|
||||
const pay = getPayInstance();
|
||||
return pay.verifySign({
|
||||
timestamp: params.timestamp,
|
||||
nonce: params.nonce,
|
||||
body: params.body,
|
||||
serial: params.serial,
|
||||
signature: params.signature,
|
||||
});
|
||||
}
|
||||
1
src/lib/wxpay/index.ts
Normal file
1
src/lib/wxpay/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WxpayProvider } from './provider';
|
||||
160
src/lib/wxpay/provider.ts
Normal file
160
src/lib/wxpay/provider.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import {
|
||||
createNativeOrder,
|
||||
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';
|
||||
readonly supportedTypes: PaymentType[] = ['wxpay'];
|
||||
readonly defaultLimits = {
|
||||
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (request.clientIp) {
|
||||
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 };
|
||||
}
|
||||
|
||||
const codeUrl = await createNativeOrder({
|
||||
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');
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
52
src/lib/wxpay/types.ts
Normal file
52
src/lib/wxpay/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface WxpayNativeOrderParams {
|
||||
out_trade_no: string;
|
||||
description: string;
|
||||
notify_url: string;
|
||||
amount: number; // in yuan, will be converted to fen
|
||||
}
|
||||
|
||||
export interface WxpayH5OrderParams {
|
||||
out_trade_no: string;
|
||||
description: string;
|
||||
notify_url: string;
|
||||
amount: number; // in yuan
|
||||
payer_client_ip: string;
|
||||
}
|
||||
|
||||
export interface WxpayRefundParams {
|
||||
out_trade_no: string;
|
||||
out_refund_no: string;
|
||||
amount: number; // refund amount in yuan
|
||||
total: number; // original total in yuan
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WxpayNotifyPayload {
|
||||
id: string;
|
||||
create_time: string;
|
||||
event_type: string;
|
||||
resource: {
|
||||
algorithm: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
associated_data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WxpayNotifyResource {
|
||||
appid: string;
|
||||
mchid: string;
|
||||
out_trade_no: string;
|
||||
transaction_id: string;
|
||||
trade_type: string;
|
||||
trade_state: string;
|
||||
trade_state_desc: string;
|
||||
bank_type: string;
|
||||
success_time: string;
|
||||
payer: { openid?: string };
|
||||
amount: {
|
||||
total: number; // in fen
|
||||
payer_total: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user