From 2b00fa49c101bf0469950b8b37b91989ad2d18ab Mon Sep 17 00:00:00 2001 From: txai-dev Date: Mon, 14 Sep 2026 13:23:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=80=E6=AC=BE=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E5=8C=96=E2=80=94=E2=80=94=E5=85=88=E5=86=BB=E7=BB=93=E6=98=9F?= =?UTF-8?q?=E5=B0=98(findOneAndUpdate+=E5=8E=9F=E5=AD=90=E6=89=A3=E5=87=8F?= =?UTF-8?q?=E9=98=B2=E5=B9=B6=E5=8F=91)=E2=86=92=E5=86=8D=E8=B0=83?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E2=86=92=E5=A4=B1=E8=B4=A5=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E6=98=9F=E5=B0=98=EF=BC=9B=E4=BF=AE=E5=A4=8D=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/routes/admin.js | 45 ++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 1e8ebb3..195fcbc 100755 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1240,6 +1240,7 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async // ===== 退款风控(三层,防恶意充值→消费→退款循环) ===== // 第一层:余额必须够扣(星尘已消费 = 虚拟商品已使用,不支持退款) + // 注意:这里只做预检展示,真正的原子扣减在风控通过后执行 const currentBalance = user.vpayCoins || 0 if (currentBalance < coins) { 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 }) } - // 风控通过:先调微信退钱(钱是用户的)→ 成功后本地扣星尘 - user.vpayCoins = currentBalance - coins - await user.save() + // ===== 冻结星尘(原子操作,防并发竞态) ===== + // findOneAndUpdate + $gte 条件 = 原子扣减: + // 如果在预检和这里之间用户消费了星尘导致余额不足,此操作会失败(返回 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' try { const ENV = parseInt(process.env.VPAY_ENV || '1') const APPKEY = ENV === 0 ? (process.env.VPAY_APPKEY_PROD || '') : (process.env.VPAY_APPKEY_SANDBOX || '') 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 tokenRes = await fetch(tokenUrl).then(r => r.json()) const accessToken = tokenRes.access_token @@ -1285,36 +1296,38 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async const refundBody = JSON.stringify({ offerId: OFFER_ID, outTradeNo: order.orderNo }) const paySig = require('crypto').createHmac('sha256', APPKEY).update('/xpay/refund_order&' + refundBody).digest('hex') - + const wxRes = await fetch(`https://api.weixin.qq.com/xpay/refund_order?access_token=${accessToken}&pay_sig=${paySig}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: refundBody, signal: AbortSignal.timeout(15000), }).then(r => r.json()) - + if (wxRes.errcode === 0) { wxRefundResult = 'success' logger.info(`[vpay-refund] 微信退款成功: ${order.orderNo}`) } else { - wxRefundResult = 'wx_error: ' + (wxRes.errmsg || wxRes.errcode) - logger.warn(`[vpay-refund] 微信退款返回异常: ${JSON.stringify(wxRes)}`) - // 微信退款失败时不继续本地退款 - return ApiResponse.error(res, `微信退款失败: ${wxRefundResult},请检查虚拟支付配置`, 500) + // 微信退款失败 → 回滚冻结的星尘(用户星尘恢复,钱也没退,两边一致) + await User.updateOne({ _id: user._id }, { $inc: { vpayCoins: coins } }) + logger.warn(`[vpay-refund] 微信退款异常(已回滚星尘 +${coins}): ${JSON.stringify(wxRes)}`) + return ApiResponse.error(res, `微信退款失败: ${wxRes.errmsg}。星尘已恢复,订单状态未变。`, 500) } } catch (wxErr) { - wxRefundResult = 'error: ' + wxErr.message - logger.error('[vpay-refund] 微信退款调用失败:', wxErr.message) - return ApiResponse.error(res, `微信退款调用失败: ${wxErr.message}`, 500) + // 网络异常等 → 回滚冻结的星尘 + await User.updateOne({ _id: user._id }, { $inc: { vpayCoins: coins } }) + 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 CoinLedger.create({ 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, }) - 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) { logger.error('vpay refund 失败:', error) return ApiResponse.serverError(res, '退款失败')