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:
erio
2026-03-06 13:57:52 +08:00
parent e9e164babc
commit 937f54dec2
17 changed files with 728 additions and 28 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { WxpayProvider } from '@/lib/wxpay';
const wxpayProvider = new WxpayProvider();
export async function POST(request: NextRequest) {
try {
const rawBody = await request.text();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const notification = await wxpayProvider.verifyNotification(rawBody, headers);
if (!notification) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const success = await handlePaymentNotify(notification, wxpayProvider.name);
return Response.json(
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
{ status: success ? 200 : 500 },
);
} catch (error) {
console.error('Wxpay notify error:', error);
return Response.json(
{ code: 'FAIL', message: '处理失败' },
{ status: 500 },
);
}
}

View File

@@ -108,7 +108,7 @@ export default function PaymentForm({
}
if (type === 'wxpay') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#07C160] text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M10 3C6.13 3 3 5.58 3 8.75c0 1.7.84 3.23 2.17 4.29l-.5 2.21 2.4-1.32c.61.17 1.25.27 1.93.27.22 0 .43-.01.64-.03C9.41 13.72 9 12.88 9 12c0-3.31 3.13-6 7-6 .26 0 .51.01.76.03C15.96 3.98 13.19 3 10 3z" />
<path d="M16 8c-3.31 0-6 2.24-6 5s2.69 5 6 5c.67 0 1.31-.1 1.9-.28l2.1 1.15-.55-2.44C20.77 15.52 22 13.86 22 12c0-2.21-2.69-4-6-4z" />

View File

@@ -78,6 +78,7 @@ export async function execute<T extends AlipayResponse>(
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
signal: AbortSignal.timeout(10_000),
});
const data = await response.json();

View File

@@ -78,14 +78,7 @@ export class AlipayProvider implements PaymentProvider {
}
const sign = params.sign || '';
const paramsForVerify: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
paramsForVerify[key] = value;
}
}
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(paramsForVerify, env.ALIPAY_PUBLIC_KEY, sign)) {
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
throw new Error('Alipay notification signature verification failed');
}

View File

@@ -12,7 +12,7 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
// ── 支付服务商显式声明启用哪些服务商逗号分隔easypay, stripe ──
// ── 支付服务商显式声明启用哪些服务商逗号分隔easypay, alipay, wxpay, stripe ──
PAYMENT_PROVIDERS: z
.string()
.default('')
@@ -40,6 +40,16 @@ const envSchema = z.object({
ALIPAY_NOTIFY_URL: optionalTrimmedString,
ALIPAY_RETURN_URL: optionalTrimmedString,
// ── 微信支付直连PAYMENT_PROVIDERS 含 wxpay 时必填) ──
WXPAY_APP_ID: optionalTrimmedString,
WXPAY_MCH_ID: optionalTrimmedString,
WXPAY_PRIVATE_KEY: optionalTrimmedString,
WXPAY_CERT_SERIAL: optionalTrimmedString,
WXPAY_API_V3_KEY: optionalTrimmedString,
WXPAY_NOTIFY_URL: optionalTrimmedString,
WXPAY_PUBLIC_KEY: optionalTrimmedString,
WXPAY_PUBLIC_KEY_ID: optionalTrimmedString,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,

View File

@@ -1,4 +1,5 @@
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { Prisma } from '@prisma/client';
/**
* 获取指定支付渠道的手续费率(百分比)。
@@ -26,13 +27,18 @@ export function getMethodFeeRate(paymentType: string): number {
return 0;
}
/** decimal.js ROUND_UP = 0远离零方向取整 */
const ROUND_UP = 0;
/**
* 根据到账金额和手续费率计算实付金额。
* feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分)
* 根据到账金额和手续费率计算实付金额(使用 Decimal 精确计算,避免浮点误差)
* feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数)
* payAmount = rechargeAmount + feeAmount
*/
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
if (feeRate <= 0) return rechargeAmount;
const feeAmount = Math.ceil(((rechargeAmount * feeRate) / 100) * 100) / 100;
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
const amount = new Prisma.Decimal(rechargeAmount);
const rate = new Prisma.Decimal(feeRate.toString());
const feeAmount = amount.mul(rate).div(100).toDecimalPlaces(2, ROUND_UP);
return amount.plus(feeAmount).toNumber();
}

View File

@@ -127,13 +127,22 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider(input.paymentType);
// 只有 easypay 从外部传入 notifyUrl/returnUrl其他 provider 内部读取自己的环境变量
let notifyUrl: string | undefined;
let returnUrl: string | undefined;
if (provider.providerKey === 'easypay') {
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
returnUrl = env.EASY_PAY_RETURN_URL || '';
}
const paymentResult = await provider.createPayment({
orderId: order.id,
amount: payAmount,
paymentType: input.paymentType,
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
returnUrl: env.EASY_PAY_RETURN_URL || '',
notifyUrl,
returnUrl,
clientIp: input.clientIp,
});
@@ -322,8 +331,30 @@ export async function confirmPayment(input: {
}
const expectedAmount = order.payAmount ?? order.amount;
if (!paidAmount.equals(expectedAmount)) {
const diff = paidAmount.minus(expectedAmount).abs();
if (diff.gt(new Prisma.Decimal('0.01'))) {
// 写审计日志
await prisma.auditLog.create({
data: {
orderId: order.id,
action: 'PAYMENT_AMOUNT_MISMATCH',
detail: JSON.stringify({
expected: expectedAmount.toString(),
paid: paidAmount.toString(),
diff: diff.toString(),
tradeNo: input.tradeNo,
}),
operator: input.providerName,
},
});
console.error(
`${input.providerName} notify: amount mismatch beyond threshold`,
`expected=${expectedAmount.toString()}, paid=${paidAmount.toString()}, diff=${diff.toString()}`,
);
return false;
}
console.warn(
`${input.providerName} notify: amount changed, use paid amount`,
`${input.providerName} notify: minor amount difference (rounding)`,
expectedAmount.toString(),
paidAmount.toString(),
);
@@ -336,7 +367,6 @@ export async function confirmPayment(input: {
},
data: {
status: 'PAID',
amount: paidAmount,
paymentTradeNo: input.tradeNo,
paidAt: new Date(),
failedAt: null,
@@ -345,6 +375,35 @@ export async function confirmPayment(input: {
});
if (result.count === 0) {
// 重新查询当前状态,区分「已成功」和「需重试」
const current = await prisma.order.findUnique({
where: { id: order.id },
select: { status: true },
});
if (!current) return true;
// 已完成或已退款 — 告知支付平台成功
if (current.status === 'COMPLETED' || current.status === 'REFUNDED') {
return true;
}
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
if (current.status === 'FAILED') {
try {
await executeRecharge(order.id);
return true;
} catch (err) {
console.error('Recharge retry failed for order:', order.id, err);
return false; // 让支付平台继续重试
}
}
// PAID / RECHARGING — 正在处理中,让支付平台稍后重试
if (current.status === 'PAID' || current.status === 'RECHARGING') {
return false;
}
// 其他状态CANCELLED 等)— 不应该出现,返回 true 停止重试
return true;
}
@@ -366,6 +425,7 @@ export async function confirmPayment(input: {
await executeRecharge(order.id);
} catch (err) {
console.error('Recharge failed for order:', order.id, err);
return false;
}
return true;
@@ -404,6 +464,16 @@ export async function executeRecharge(orderId: string): Promise<void> {
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
}
// 原子 CAS将状态从 PAID/FAILED → RECHARGING防止并发竞态
const lockResult = await prisma.order.updateMany({
where: { id: orderId, status: { in: ['PAID', 'FAILED'] } },
data: { status: 'RECHARGING' },
});
if (lockResult.count === 0) {
// 另一个并发请求已经在处理
return;
}
try {
await createAndRedeem(
order.rechargeCode,

View File

@@ -83,10 +83,11 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
},
wxpay: {
label: '微信支付',
color: '#2BB741',
sublabel: 'WECHAT PAY',
color: '#07C160',
selectedBorder: 'border-green-500',
selectedBg: 'bg-green-50',
iconBg: 'bg-[#2BB741]',
iconBg: 'bg-[#07C160]',
},
stripe: {
label: 'Stripe',

View File

@@ -3,6 +3,7 @@ import type { PaymentType } from './types';
import { EasyPayProvider } from '@/lib/easy-pay/provider';
import { StripeProvider } from '@/lib/stripe/provider';
import { AlipayProvider } from '@/lib/alipay/provider';
import { WxpayProvider } from '@/lib/wxpay/provider';
import { getEnv } from '@/lib/config';
export { paymentRegistry } from './registry';
@@ -33,12 +34,31 @@ export function initPaymentProviders(): void {
}
if (providers.includes('alipay')) {
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 alipay但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY');
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_NOTIFY_URL) {
throw new Error(
'PAYMENT_PROVIDERS includes alipay but required env vars are missing: ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_NOTIFY_URL',
);
}
paymentRegistry.register(new AlipayProvider());
}
if (providers.includes('wxpay')) {
if (
!env.WXPAY_APP_ID ||
!env.WXPAY_MCH_ID ||
!env.WXPAY_PRIVATE_KEY ||
!env.WXPAY_API_V3_KEY ||
!env.WXPAY_PUBLIC_KEY ||
!env.WXPAY_CERT_SERIAL ||
!env.WXPAY_NOTIFY_URL
) {
throw new Error(
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
);
}
paymentRegistry.register(new WxpayProvider());
}
if (providers.includes('stripe')) {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 stripe但缺少 STRIPE_SECRET_KEY');

154
src/lib/wxpay/client.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export { WxpayProvider } from './provider';

160
src/lib/wxpay/provider.ts Normal file
View 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
View 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;
};
}