merge: 合并 wxpay_direct 微信支付直连分支
解决冲突:保留 main 的常量化/provider 字段/ENABLED_PAYMENT_TYPES 移除, 合并 worktree 的微信支付直连实现、notifyUrl/returnUrl 传递、签名验证优化。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface CreateOrderInput {
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
clientIp: string;
|
||||
isMobile?: boolean;
|
||||
srcHost?: string;
|
||||
srcUrl?: string;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateOrderResult {
|
||||
@@ -129,11 +129,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,
|
||||
returnUrl,
|
||||
clientIp: input.clientIp,
|
||||
isMobile: input.isMobile,
|
||||
});
|
||||
@@ -323,8 +334,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(),
|
||||
);
|
||||
@@ -346,6 +379,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 === ORDER_STATUS.COMPLETED || current.status === ORDER_STATUS.REFUNDED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
||||
if (current.status === ORDER_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 === ORDER_STATUS.PAID || current.status === ORDER_STATUS.RECHARGING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他状态(CANCELLED 等)— 不应该出现,返回 true 停止重试
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,6 +429,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;
|
||||
@@ -405,6 +468,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: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
|
||||
data: { status: ORDER_STATUS.RECHARGING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
// 另一个并发请求已经在处理
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createAndRedeem(
|
||||
order.rechargeCode,
|
||||
|
||||
Reference in New Issue
Block a user