feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口

This commit is contained in:
2026-09-14 12:26:41 +08:00
parent 968396bed5
commit f13309e6cd
11 changed files with 497 additions and 36 deletions
+125 -1
View File
@@ -1,7 +1,7 @@
const express = require('express')
const router = express.Router()
const mongoose = require('mongoose')
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit } = require('../models')
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger } = require('../models')
const { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response')
const logger = require('../utils/logger')
@@ -1199,4 +1199,128 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
}
})
/**
* ===== 虚拟支付订单管理 =====
*/
// 订单列表(含虚拟支付 + 旧订单)
router.get('/vpay/orders', requirePermission('orders:read'), async (req, res) => {
try {
const { page = 1, limit = 20, status = '', keyword = '' } = req.query
const query = {}
if (status) query.status = status
if (keyword) query.orderNo = { $regex: keyword, $options: 'i' }
const total = await Order.countDocuments(query)
const items = await Order.find(query)
.sort({ createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.populate('userId', 'nickname openid')
.lean()
return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total })
} catch (error) {
logger.error('vpay orders 失败:', error)
return ApiResponse.serverError(res, '获取订单失败')
}
})
// 订单退款(星尘扣回 + 状态改 refunded + 写流水)
router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async (req, res) => {
try {
const order = await Order.findById(req.params.id)
if (!order) return ApiResponse.notFound(res, '订单不存在')
if (order.status !== 'paid') return ApiResponse.error(res, '仅已支付订单可退款', 400)
const coins = (order.metadata || {}).coins || 0
const user = await User.findById(order.userId)
if (!user) return ApiResponse.error(res, '用户不存在', 400)
// 扣回星尘(可能不够扣,允许负数让管理员判断)
user.vpayCoins = (user.vpayCoins || 0) - coins
await user.save()
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}`,
orderNo: order.orderNo, adminName: req.admin.username,
})
await logAction(req, 'refund', 'vpay_order', order._id, { orderNo: order.orderNo, coins })
return ApiResponse.success(res, { orderNo: order.orderNo, coinsDeducted: coins, balanceAfter: user.vpayCoins }, '退款成功')
} catch (error) {
logger.error('vpay refund 失败:', error)
return ApiResponse.serverError(res, '退款失败')
}
})
// 管理员调整余额(赠/扣)
router.post('/vpay/adjust', requirePermission('orders:write'), async (req, res) => {
try {
const { openid, amount, reason } = req.body
if (!openid || !amount || Math.abs(amount) > 100000) return ApiResponse.error(res, '参数无效', 400)
const user = await User.findOne({ openid })
if (!user) return ApiResponse.notFound(res, '用户不存在')
user.vpayCoins = (user.vpayCoins || 0) + amount
await user.save()
await CoinLedger.create({
openid, userId: user._id, type: 'adjust', amount,
balanceAfter: user.vpayCoins, description: reason || '管理员调整',
adminName: req.admin.username,
})
await logAction(req, 'adjust', 'vpay_balance', user._id, { openid, amount, reason })
return ApiResponse.success(res, { balance: user.vpayCoins }, '调整成功')
} catch (error) {
logger.error('vpay adjust 失败:', error)
return ApiResponse.serverError(res, '调整失败')
}
})
// 全站星尘流水
router.get('/vpay/ledger', requirePermission('orders:read'), async (req, res) => {
try {
const { page = 1, limit = 20, type = '', keyword = '' } = req.query
const query = {}
if (type) query.type = type
if (keyword) query.openid = { $regex: keyword, $options: 'i' }
const total = await CoinLedger.countDocuments(query)
const items = await CoinLedger.find(query)
.sort({ createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.lean()
return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total })
} catch (error) {
logger.error('vpay ledger 失败:', error)
return ApiResponse.serverError(res, '获取流水失败')
}
})
// 收入统计
router.get('/vpay/stats', requirePermission('dashboard:read'), async (req, res) => {
try {
const today = new Date(); today.setHours(0, 0, 0, 0)
const [totalRevenue, todayRevenue, totalRecharged, totalConsumed, paidCount, refundedCount] = await Promise.all([
Order.aggregate([{ $match: { status: 'paid' } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]),
Order.aggregate([{ $match: { status: 'paid', paidAt: { $gte: today } } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]),
CoinLedger.aggregate([{ $match: { type: 'recharge' } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]),
CoinLedger.aggregate([{ $match: { type: 'consume' } }, { $group: { _id: null, sum: { $sum: { $abs: '$amount' } } } }]),
Order.countDocuments({ status: 'paid' }),
Order.countDocuments({ status: 'refunded' }),
])
return ApiResponse.success(res, {
totalRevenueYuan: totalRevenue[0]?.sum || 0,
todayRevenueYuan: todayRevenue[0]?.sum || 0,
totalRechargedCoins: totalRecharged[0]?.sum || 0,
totalConsumedCoins: totalConsumed[0]?.sum || 0,
paidOrders: paidCount,
refundedOrders: refundedCount,
})
} catch (error) {
logger.error('vpay stats 失败:', error)
return ApiResponse.serverError(res, '获取统计失败')
}
})
module.exports = router