fix: 退款原子化——先冻结星尘(findOneAndUpdate+原子扣减防并发)→再调微信→失败回滚星尘;修复语法残留
This commit is contained in:
+27
-14
@@ -1240,6 +1240,7 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async
|
|||||||
|
|
||||||
// ===== 退款风控(三层,防恶意充值→消费→退款循环) =====
|
// ===== 退款风控(三层,防恶意充值→消费→退款循环) =====
|
||||||
// 第一层:余额必须够扣(星尘已消费 = 虚拟商品已使用,不支持退款)
|
// 第一层:余额必须够扣(星尘已消费 = 虚拟商品已使用,不支持退款)
|
||||||
|
// 注意:这里只做预检展示,真正的原子扣减在风控通过后执行
|
||||||
const currentBalance = user.vpayCoins || 0
|
const currentBalance = user.vpayCoins || 0
|
||||||
if (currentBalance < coins) {
|
if (currentBalance < coins) {
|
||||||
return ApiResponse.error(res,
|
return ApiResponse.error(res,
|
||||||
@@ -1267,17 +1268,27 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async
|
|||||||
409, { blocked: 'rate_limited', lastRefund: recentRefund.orderNo })
|
409, { blocked: 'rate_limited', lastRefund: recentRefund.orderNo })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 风控通过:先调微信退钱(钱是用户的)→ 成功后本地扣星尘
|
// ===== 冻结星尘(原子操作,防并发竞态) =====
|
||||||
user.vpayCoins = currentBalance - coins
|
// findOneAndUpdate + $gte 条件 = 原子扣减:
|
||||||
await user.save()
|
// 如果在预检和这里之间用户消费了星尘导致余额不足,此操作会失败(返回 null)
|
||||||
|
const frozen = await User.findOneAndUpdate(
|
||||||
|
{ _id: user._id, vpayCoins: { $gte: coins } }, // 条件:余额仍然够
|
||||||
|
{ $inc: { vpayCoins: -coins } }, // 原子扣减
|
||||||
|
{ new: true } // 返回更新后的文档
|
||||||
|
)
|
||||||
|
if (!frozen) {
|
||||||
|
return ApiResponse.error(res,
|
||||||
|
`退款失败:并发冲突,用户星尘在退款过程中被消费。余额已不足 ${coins},请确认后再试。`,
|
||||||
|
409, { blocked: 'concurrent_spend' })
|
||||||
|
}
|
||||||
|
const balanceAfterFreeze = frozen.vpayCoins
|
||||||
|
|
||||||
// ===== 调用微信虚拟支付退款 API(真正退钱给用户) =====
|
// ===== 调用微信虚拟支付退款 API(星尘已冻结,现在退钱) =====
|
||||||
let wxRefundResult = 'not_called'
|
let wxRefundResult = 'not_called'
|
||||||
try {
|
try {
|
||||||
const ENV = parseInt(process.env.VPAY_ENV || '1')
|
const ENV = parseInt(process.env.VPAY_ENV || '1')
|
||||||
const APPKEY = ENV === 0 ? (process.env.VPAY_APPKEY_PROD || '') : (process.env.VPAY_APPKEY_SANDBOX || '')
|
const APPKEY = ENV === 0 ? (process.env.VPAY_APPKEY_PROD || '') : (process.env.VPAY_APPKEY_SANDBOX || '')
|
||||||
const OFFER_ID = process.env.VPAY_OFFER_ID || ''
|
const OFFER_ID = process.env.VPAY_OFFER_ID || ''
|
||||||
// 获取 access_token(小程序全局凭证)
|
|
||||||
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
|
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
|
||||||
const tokenRes = await fetch(tokenUrl).then(r => r.json())
|
const tokenRes = await fetch(tokenUrl).then(r => r.json())
|
||||||
const accessToken = tokenRes.access_token
|
const accessToken = tokenRes.access_token
|
||||||
@@ -1297,24 +1308,26 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async
|
|||||||
wxRefundResult = 'success'
|
wxRefundResult = 'success'
|
||||||
logger.info(`[vpay-refund] 微信退款成功: ${order.orderNo}`)
|
logger.info(`[vpay-refund] 微信退款成功: ${order.orderNo}`)
|
||||||
} else {
|
} else {
|
||||||
wxRefundResult = 'wx_error: ' + (wxRes.errmsg || wxRes.errcode)
|
// 微信退款失败 → 回滚冻结的星尘(用户星尘恢复,钱也没退,两边一致)
|
||||||
logger.warn(`[vpay-refund] 微信退款返回异常: ${JSON.stringify(wxRes)}`)
|
await User.updateOne({ _id: user._id }, { $inc: { vpayCoins: coins } })
|
||||||
// 微信退款失败时不继续本地退款
|
logger.warn(`[vpay-refund] 微信退款异常(已回滚星尘 +${coins}): ${JSON.stringify(wxRes)}`)
|
||||||
return ApiResponse.error(res, `微信退款失败: ${wxRefundResult},请检查虚拟支付配置`, 500)
|
return ApiResponse.error(res, `微信退款失败: ${wxRes.errmsg}。星尘已恢复,订单状态未变。`, 500)
|
||||||
}
|
}
|
||||||
} catch (wxErr) {
|
} catch (wxErr) {
|
||||||
wxRefundResult = 'error: ' + wxErr.message
|
// 网络异常等 → 回滚冻结的星尘
|
||||||
logger.error('[vpay-refund] 微信退款调用失败:', wxErr.message)
|
await User.updateOne({ _id: user._id }, { $inc: { vpayCoins: coins } })
|
||||||
return ApiResponse.error(res, `微信退款调用失败: ${wxErr.message}`, 500)
|
logger.error(`[vpay-refund] 微信退款调用异常(已回滚星尘 +${coins}):`, wxErr.message)
|
||||||
|
return ApiResponse.error(res, `微信退款调用失败: ${wxErr.message}。星尘已恢复,订单状态未变。`, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 微信退款成功 → 写订单状态 + 流水(星尘已在冻结时扣减)
|
||||||
await Order.updateOne({ _id: order._id }, { status: 'refunded', refundedAt: new Date() })
|
await Order.updateOne({ _id: order._id }, { status: 'refunded', refundedAt: new Date() })
|
||||||
await CoinLedger.create({
|
await CoinLedger.create({
|
||||||
openid: user.openid, userId: user._id, type: 'refund', amount: -coins,
|
openid: user.openid, userId: user._id, type: 'refund', amount: -coins,
|
||||||
balanceAfter: user.vpayCoins, description: `退款 ${order.orderNo}`,
|
balanceAfter: balanceAfterFreeze, description: `退款 ${order.orderNo}`,
|
||||||
orderNo: order.orderNo, adminName: req.admin.username,
|
orderNo: order.orderNo, adminName: req.admin.username,
|
||||||
})
|
})
|
||||||
return ApiResponse.success(res, { orderNo: order.orderNo, coinsDeducted: coins, balanceAfter: user.vpayCoins, wxRefund: wxRefundResult }, '退款成功')
|
return ApiResponse.success(res, { orderNo: order.orderNo, coinsDeducted: coins, balanceAfter: balanceAfterFreeze, wxRefund: wxRefundResult }, '退款成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('vpay refund 失败:', error)
|
logger.error('vpay refund 失败:', error)
|
||||||
return ApiResponse.serverError(res, '退款失败')
|
return ApiResponse.serverError(res, '退款失败')
|
||||||
|
|||||||
Reference in New Issue
Block a user