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
+19
View File
@@ -0,0 +1,19 @@
const mongoose = require('mongoose')
/**
* 星尘余额流水 —— 每次充值/消费/退款/管理员调整都记一条
*/
const CoinLedgerSchema = new mongoose.Schema({
openid: { type: String, required: true, index: true },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
// recharge=充值 | consume=消费 | refund=退款 | adjust=管理员调整 | gift=赠送
type: { type: String, enum: ['recharge', 'consume', 'refund', 'adjust', 'gift'], required: true },
amount: { type: Number, required: true }, // 正=入账 负=扣减
balanceAfter: { type: Number, required: true }, // 操作后余额(对账用)
description: { type: String, default: '' },
orderNo: { type: String, default: '', index: true },
adminName: { type: String, default: '' }, // adjust 时记录操作管理员
createdAt: { type: Date, default: Date.now, index: true }
})
module.exports = mongoose.model('CoinLedger', CoinLedgerSchema)
+31 -29
View File
@@ -1,29 +1,31 @@
const User = require('./User')
const Order = require('./Order')
const Knowledge = require('./Knowledge')
const KnowledgeLit = require('./KnowledgeLit')
const ShopItem = require('./ShopItem')
const Admin = require('./Admin')
const AdminLog = require('./AdminLog')
const AIChatQuota = require('./AIChatQuota')
const AIModel = require('./AIModel')
const LoginLog = require('./LoginLog')
const Feedback = require('./Feedback')
const Trend = require('./Trend')
const VerificationCode = require('./VerificationCode')
module.exports = {
User,
Order,
Knowledge,
KnowledgeLit,
ShopItem,
Admin,
AdminLog,
AIChatQuota,
AIModel,
LoginLog,
Feedback,
Trend,
VerificationCode
}
const User = require('./User')
const Order = require('./Order')
const Knowledge = require('./Knowledge')
const KnowledgeLit = require('./KnowledgeLit')
const ShopItem = require('./ShopItem')
const Admin = require('./Admin')
const AdminLog = require('./AdminLog')
const AIChatQuota = require('./AIChatQuota')
const AIModel = require('./AIModel')
const LoginLog = require('./LoginLog')
const Feedback = require('./Feedback')
const Trend = require('./Trend')
const VerificationCode = require('./VerificationCode')
const CoinLedger = require('./CoinLedger')
module.exports = {
User,
Order,
Knowledge,
KnowledgeLit,
ShopItem,
Admin,
AdminLog,
AIChatQuota,
AIModel,
LoginLog,
Feedback,
Trend,
VerificationCode,
CoinLedger
}
+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
+6 -1
View File
@@ -1,6 +1,6 @@
const express = require('express')
const router = express.Router()
const { AIChatQuota, AIModel, User } = require('../models')
const { AIChatQuota, AIModel, User, CoinLedger } = require('../models')
const ApiResponse = require('../utils/response')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger')
@@ -92,6 +92,11 @@ router.post('/ask', askRateLimit, optionalAuthMiddleware, async (req, res) => {
}
// 扣星尘(原子操作防并发)
const updated = await User.updateOne({ _id: user._id, vpayCoins: { $gte: 10 } }, { $inc: { vpayCoins: -10 } })
if (updated.modifiedCount > 0) {
const { CoinLedger } = require('../models')
const u3 = await User.findById(user._id, 'vpayCoins openid')
await CoinLedger.create({ openid: u3.openid, userId: user._id, type: 'consume', amount: -10, balanceAfter: u3.vpayCoins, description: 'AI问答' })
}
if (updated.modifiedCount === 0) {
return ApiResponse.error(res, '星尘余额不足,请充值后继续', 402, { needRecharge: true })
}
+21 -1
View File
@@ -1,7 +1,7 @@
const express = require('express')
const crypto = require('crypto')
const router = express.Router()
const { User, Order, AIChatQuota } = require('../models')
const { User, Order, AIChatQuota, CoinLedger } = require('../models')
const ApiResponse = require('../utils/response')
const { userAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger')
@@ -82,6 +82,26 @@ router.get('/info', userAuthMiddleware, async (req, res) => {
}
})
/**
* 我的星尘明细(充值/消费/退款/调整 全部记录)
* GET /api/vpay/ledger?page=1&limit=20
*/
router.get('/ledger', userAuthMiddleware, async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1)
const limit = Math.min(50, parseInt(req.query.limit) || 20)
const query = { openid: req.openid }
const [items, total] = await Promise.all([
CoinLedger.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
CoinLedger.countDocuments(query),
])
return ApiResponse.success(res, { items, total, page, limit })
} catch (error) {
logger.error('vpay ledger 失败:', error)
return ApiResponse.serverError(res, '获取明细失败')
}
})
/**
* 创建充值订单(返回客户端拉起支付的完整参数)
* POST /api/vpay/recharge { packageId }
+2 -2
View File
@@ -51,7 +51,7 @@ module.exports = function registerWxMsgRoutes(app) {
// 代币充值到账:改写用户星尘余额 + 订单状态
if (event === 'xpay_coin_pay_notify') {
try {
const { User, Order } = require('../models')
const { User, Order, CoinLedger } = require('../models')
const outTradeNo = fields.OutTradeNo || ''
// 微信回调格式:CoinInfo.Quantity(嵌套)——兼容 JSON 和 XML 两种
const coinInfo = fields.CoinInfo || fields.coinInfo || {}
@@ -90,7 +90,7 @@ module.exports = function registerWxMsgRoutes(app) {
// 退款:扣回星尘
if (event === 'xpay_refund_notify') {
try {
const { User, Order } = require('../models')
const { User, Order, CoinLedger } = require('../models')
const outTradeNo = fields.OutTradeNo || ''
const order = await Order.findOne({ orderNo: outTradeNo, status: 'paid' })
if (order) {