1426 lines
47 KiB
JavaScript
Executable File
1426 lines
47 KiB
JavaScript
Executable File
const express = require('express')
|
||
const router = express.Router()
|
||
const mongoose = require('mongoose')
|
||
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')
|
||
|
||
// 所有管理路由都需要认证
|
||
router.use(authMiddleware)
|
||
|
||
/**
|
||
* 记录操作日志
|
||
*/
|
||
const logAction = async (req, action, resource, resourceId = null, details = {}) => {
|
||
try {
|
||
await AdminLog.create({
|
||
adminId: req.admin._id,
|
||
adminName: req.admin.username,
|
||
action,
|
||
resource,
|
||
resourceId,
|
||
details,
|
||
ip: req.ip,
|
||
userAgent: req.get('User-Agent')
|
||
})
|
||
} catch (error) {
|
||
logger.error('记录操作日志失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取仪表盘数据
|
||
* GET /api/admin/dashboard
|
||
*/
|
||
router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) => {
|
||
try {
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||
|
||
// 今日数据
|
||
const [todayNewUsers, todayActiveUsers, todayOrders] = await Promise.all([
|
||
User.countDocuments({ createdAt: { $gte: today } }),
|
||
User.countDocuments({ lastLoginAt: { $gte: today } }),
|
||
Order.getTodayCount()
|
||
])
|
||
|
||
// 7日趋势
|
||
const [userTrend, orderTrend] = await Promise.all([
|
||
User.aggregate([
|
||
{ $match: { createdAt: { $gte: weekAgo } } },
|
||
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, count: { $sum: 1 } } },
|
||
{ $sort: { '_id': 1 } }
|
||
]),
|
||
Order.getWeekTrend()
|
||
])
|
||
|
||
await logAction(req, 'read', 'dashboard')
|
||
|
||
return ApiResponse.success(res, {
|
||
today: {
|
||
newUsers: todayNewUsers,
|
||
activeUsers: todayActiveUsers,
|
||
newOrders: todayOrders,
|
||
newWorks: 0
|
||
},
|
||
trends: {
|
||
users: userTrend.map(t => ({ date: t._id, count: t.count })),
|
||
orders: orderTrend.map(t => ({ date: t._id, count: t.count, revenue: t.revenue })),
|
||
works: []
|
||
}
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取仪表盘数据失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取用户列表
|
||
* GET /api/admin/users
|
||
*/
|
||
router.get('/users', requirePermission('users:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, search, dimCompleted, hasPurchase } = req.query
|
||
|
||
const query = {}
|
||
|
||
if (search) {
|
||
query.nickName = { $regex: search, $options: 'i' }
|
||
}
|
||
|
||
if (dimCompleted) {
|
||
query[`exploreData.dim${dimCompleted}.completed`] = true
|
||
}
|
||
|
||
if (hasPurchase === 'true') {
|
||
query.ownedSkins = { $exists: true, $ne: [] }
|
||
}
|
||
|
||
const total = await User.countDocuments(query)
|
||
const users = await User.find(query)
|
||
.sort({ createdAt: -1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.lean()
|
||
|
||
// 画廊(Gallery)功能已移除,作品数固定为 0
|
||
const worksCountMap = {}
|
||
|
||
// 格式化用户数据,添加等级和统计信息
|
||
const formattedUsers = users.map(user => {
|
||
// 计算等级
|
||
const scorePoints = Math.floor((user.totalScore || 0) / 100)
|
||
const timePoints = Math.floor((user.totalPlayTime || 0) / 60)
|
||
const achievementPoints = (user.achievements?.length || 0) * 2
|
||
const collectionPoints = Math.floor((user.collectedKnowledge?.length || 0) / 5)
|
||
const levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints
|
||
|
||
const levelThresholds = [
|
||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||
]
|
||
|
||
let level = 1
|
||
for (let i = 0; i < levelThresholds.length; i++) {
|
||
if (levelPoints >= levelThresholds[i]) {
|
||
level = i + 1
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
const levelNames = [
|
||
'星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者',
|
||
'时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰',
|
||
'维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主',
|
||
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
||
]
|
||
|
||
return {
|
||
...user,
|
||
level,
|
||
levelName: levelNames[Math.min(level - 1, levelNames.length - 1)],
|
||
levelPoints,
|
||
worksCount: worksCountMap[user._id.toString()] || 0,
|
||
collectionCount: user.collectedKnowledge?.length || 0,
|
||
achievementCount: user.achievements?.length || 0
|
||
}
|
||
})
|
||
|
||
await logAction(req, 'read', 'users', null, { page, limit, search })
|
||
|
||
return ApiResponse.paginated(res, formattedUsers, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取用户列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取用户详情
|
||
* GET /api/admin/users/:id
|
||
*/
|
||
router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
|
||
try {
|
||
const user = await User.findById(req.params.id).lean()
|
||
|
||
if (!user) {
|
||
return ApiResponse.notFound(res, '用户不存在')
|
||
}
|
||
|
||
// 获取订单历史
|
||
const orders = await Order.find({ openid: user.openid })
|
||
.sort({ createdAt: -1 })
|
||
.limit(20)
|
||
.lean()
|
||
|
||
// 计算等级
|
||
const scorePoints = Math.floor((user.totalScore || 0) / 100)
|
||
const timePoints = Math.floor((user.totalPlayTime || 0) / 60)
|
||
const achievementPoints = (user.achievements?.length || 0) * 2
|
||
const collectionPoints = Math.floor((user.collectedKnowledge?.length || 0) / 5)
|
||
const levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints
|
||
|
||
const levelThresholds = [
|
||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||
]
|
||
|
||
let level = 1
|
||
for (let i = 0; i < levelThresholds.length; i++) {
|
||
if (levelPoints >= levelThresholds[i]) {
|
||
level = i + 1
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
const levelNames = [
|
||
'星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者',
|
||
'时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰',
|
||
'维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主',
|
||
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
||
]
|
||
|
||
// 画廊(Gallery)功能已移除,作品数为 0
|
||
|
||
await logAction(req, 'read', 'users', req.params.id)
|
||
|
||
return ApiResponse.success(res, {
|
||
...user,
|
||
level,
|
||
levelName: levelNames[Math.min(level - 1, levelNames.length - 1)],
|
||
levelPoints,
|
||
worksCount: 0,
|
||
collectionCount: user.collectedKnowledge?.length || 0,
|
||
achievementCount: user.achievements?.length || 0,
|
||
orders,
|
||
works: []
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取用户详情失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取知识列表
|
||
* GET /api/admin/knowledge
|
||
*/
|
||
router.get('/knowledge', requirePermission('knowledge:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, dim, status } = req.query
|
||
|
||
const query = {}
|
||
if (dim) query.dim = parseInt(dim)
|
||
if (status) query.status = status
|
||
|
||
const total = await Knowledge.countDocuments(query)
|
||
const knowledge = await Knowledge.find(query)
|
||
.sort({ createdAt: -1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.lean()
|
||
|
||
await logAction(req, 'read', 'knowledge', null, { page, limit, dim, status })
|
||
|
||
return ApiResponse.paginated(res, knowledge, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取知识列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 创建/更新知识
|
||
* POST /api/admin/knowledge
|
||
*/
|
||
router.post('/knowledge', requirePermission('knowledge:write'), async (req, res) => {
|
||
try {
|
||
const { knowledgeId, ...knowledgeData } = req.body
|
||
|
||
let knowledge
|
||
|
||
if (knowledgeId) {
|
||
// 更新
|
||
knowledge = await Knowledge.findByIdAndUpdate(
|
||
knowledgeId,
|
||
{ ...knowledgeData, updatedAt: new Date() },
|
||
{ new: true }
|
||
)
|
||
|
||
if (!knowledge) {
|
||
return ApiResponse.notFound(res, '知识不存在')
|
||
}
|
||
|
||
await logAction(req, 'update', 'knowledge', knowledgeId, { title: knowledgeData.title })
|
||
} else {
|
||
// 创建
|
||
knowledge = new Knowledge({
|
||
...knowledgeData,
|
||
status: 'draft',
|
||
viewCount: 0,
|
||
collectionCount: 0
|
||
})
|
||
await knowledge.save()
|
||
|
||
await logAction(req, 'create', 'knowledge', knowledge._id, { title: knowledgeData.title })
|
||
}
|
||
|
||
return ApiResponse.success(res, knowledge, knowledgeId ? '更新成功' : '创建成功')
|
||
|
||
} catch (error) {
|
||
logger.error('保存知识失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 审核知识
|
||
* POST /api/admin/knowledge/review
|
||
*/
|
||
router.post('/knowledge/review', requirePermission('knowledge:write'), async (req, res) => {
|
||
try {
|
||
const { knowledgeId, status, comment } = req.body
|
||
|
||
const knowledge = await Knowledge.findByIdAndUpdate(
|
||
knowledgeId,
|
||
{
|
||
status,
|
||
reviewComment: comment,
|
||
reviewedAt: new Date(),
|
||
reviewerId: req.admin._id
|
||
},
|
||
{ new: true }
|
||
)
|
||
|
||
if (!knowledge) {
|
||
return ApiResponse.notFound(res, '知识不存在')
|
||
}
|
||
|
||
await logAction(req, 'review', 'knowledge', knowledgeId, { status, comment })
|
||
|
||
return ApiResponse.success(res, knowledge, '审核完成')
|
||
|
||
} catch (error) {
|
||
logger.error('审核知识失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 删除知识
|
||
* DELETE /api/admin/knowledge/:id
|
||
*/
|
||
router.delete('/knowledge/:id', requirePermission('knowledge:write'), async (req, res) => {
|
||
try {
|
||
const { id } = req.params
|
||
|
||
const knowledge = await Knowledge.findByIdAndDelete(id)
|
||
|
||
if (!knowledge) {
|
||
return ApiResponse.notFound(res, '知识不存在')
|
||
}
|
||
|
||
await logAction(req, 'delete', 'knowledge', id, { title: knowledge.title })
|
||
|
||
return ApiResponse.success(res, { message: '删除成功' })
|
||
|
||
} catch (error) {
|
||
logger.error('删除知识失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取订单列表
|
||
* GET /api/admin/orders
|
||
*/
|
||
router.get('/orders', requirePermission('orders:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, status } = req.query
|
||
|
||
const query = {}
|
||
if (status) query.status = status
|
||
|
||
const total = await Order.countDocuments(query)
|
||
const orders = await Order.find(query)
|
||
.sort({ createdAt: -1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.lean()
|
||
|
||
await logAction(req, 'read', 'orders', null, { page, limit, status })
|
||
|
||
return ApiResponse.paginated(res, orders, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取订单列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取商品列表
|
||
* GET /api/admin/shop
|
||
*/
|
||
router.get('/shop', requirePermission('shop:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, status = 'active' } = req.query
|
||
|
||
const total = await ShopItem.countDocuments({ status })
|
||
const items = await ShopItem.find({ status })
|
||
.sort({ sortOrder: 1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.lean()
|
||
|
||
await logAction(req, 'read', 'shop', null, { page, limit, status })
|
||
|
||
return ApiResponse.paginated(res, items, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取商品列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 创建/更新商品
|
||
* POST /api/admin/shop-item
|
||
*/
|
||
router.post('/shop-item', requirePermission('shop:write'), async (req, res) => {
|
||
try {
|
||
const { _id, ...itemData } = req.body
|
||
|
||
let item
|
||
|
||
if (_id) {
|
||
// 更新
|
||
item = await ShopItem.findByIdAndUpdate(
|
||
_id,
|
||
{ ...itemData, updatedAt: new Date() },
|
||
{ new: true }
|
||
)
|
||
|
||
if (!item) {
|
||
return ApiResponse.notFound(res, '商品不存在')
|
||
}
|
||
|
||
await logAction(req, 'update', 'shop', _id, { name: itemData.name, price: itemData.price })
|
||
} else {
|
||
// 创建
|
||
item = new ShopItem({
|
||
...itemData,
|
||
purchaseCount: 0
|
||
})
|
||
await item.save()
|
||
|
||
await logAction(req, 'create', 'shop', item._id, { name: itemData.name, price: itemData.price })
|
||
}
|
||
|
||
return ApiResponse.success(res, item, _id ? '更新成功' : '创建成功')
|
||
|
||
} catch (error) {
|
||
logger.error('保存商品失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 删除商品
|
||
* DELETE /api/admin/shop-item/:id
|
||
*/
|
||
router.delete('/shop-item/:id', requirePermission('shop:write'), async (req, res) => {
|
||
try {
|
||
const { id } = req.params
|
||
|
||
const item = await ShopItem.findByIdAndDelete(id)
|
||
|
||
if (!item) {
|
||
return ApiResponse.notFound(res, '商品不存在')
|
||
}
|
||
|
||
await logAction(req, 'delete', 'shop', id, { name: item.name })
|
||
|
||
return ApiResponse.success(res, { message: '删除成功' })
|
||
|
||
} catch (error) {
|
||
logger.error('删除商品失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取管理员列表
|
||
* GET /api/admin/admins
|
||
*/
|
||
router.get('/admins', requirePermission('*'), async (req, res) => {
|
||
try {
|
||
const admins = await mongoose.model('Admin').find({})
|
||
.select('username role status lastLogin loginCount createdAt')
|
||
.sort({ createdAt: -1 })
|
||
.lean()
|
||
|
||
await logAction(req, 'read', 'admins')
|
||
|
||
return ApiResponse.success(res, admins)
|
||
|
||
} catch (error) {
|
||
logger.error('获取管理员列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取商品列表
|
||
* GET /api/admin/shop-items
|
||
*/
|
||
router.get('/shop-items', requirePermission('shop:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, status = 'active' } = req.query
|
||
|
||
const total = await ShopItem.countDocuments({ status })
|
||
const items = await ShopItem.find({ status })
|
||
.sort({ sortOrder: 1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.lean()
|
||
|
||
await logAction(req, 'read', 'shop', null, { page, limit, status })
|
||
|
||
return ApiResponse.paginated(res, items, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取商品列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取当前管理员信息
|
||
* GET /api/admin/profile
|
||
*/
|
||
router.get('/profile', async (req, res) => {
|
||
try {
|
||
const admin = await mongoose.model('Admin').findById(req.admin._id)
|
||
.select('username role email realName phone avatar permissions status lastLogin loginCount createdAt')
|
||
.lean()
|
||
|
||
if (!admin) {
|
||
return ApiResponse.notFound(res, '管理员不存在')
|
||
}
|
||
|
||
return ApiResponse.success(res, admin)
|
||
|
||
} catch (error) {
|
||
logger.error('获取管理员信息失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取系统设置
|
||
* GET /api/admin/settings
|
||
*/
|
||
router.get('/settings', requirePermission('*'), async (req, res) => {
|
||
try {
|
||
// 返回默认系统设置
|
||
const settings = {
|
||
appName: '星辰绘线',
|
||
version: '1.0.0',
|
||
maintenanceMode: false,
|
||
allowRegistration: true,
|
||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||
maxWorksPerUser: 100,
|
||
autoApproveWorks: false,
|
||
currency: 'CNY',
|
||
taxRate: 0.06
|
||
}
|
||
|
||
return ApiResponse.success(res, settings)
|
||
|
||
} catch (error) {
|
||
logger.error('获取系统设置失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 更新系统设置
|
||
* PUT /api/admin/settings
|
||
*/
|
||
router.put('/settings', requirePermission('*'), async (req, res) => {
|
||
try {
|
||
const { settings } = req.body
|
||
|
||
// 这里应该保存到数据库,暂时返回成功
|
||
await logAction(req, 'update', 'settings', null, settings)
|
||
|
||
return ApiResponse.success(res, settings, '设置更新成功')
|
||
|
||
} catch (error) {
|
||
logger.error('更新系统设置失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取操作日志列表
|
||
* GET /api/admin/logs
|
||
*/
|
||
router.get('/logs', requirePermission('*'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 50, action, resource, adminId } = req.query
|
||
|
||
const query = {}
|
||
if (action) query.action = action
|
||
if (resource) query.resource = resource
|
||
if (adminId) query.adminId = adminId
|
||
|
||
const pageNum = parseInt(page)
|
||
const limitNum = parseInt(limit)
|
||
const skip = (pageNum - 1) * limitNum
|
||
|
||
const [logs, total] = await Promise.all([
|
||
AdminLog.find(query)
|
||
.sort({ timestamp: -1 })
|
||
.skip(skip)
|
||
.limit(limitNum)
|
||
.populate('adminId', 'username role')
|
||
.lean(),
|
||
AdminLog.countDocuments(query)
|
||
])
|
||
|
||
return ApiResponse.paginated(res, logs, {
|
||
page: pageNum,
|
||
limit: limitNum,
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取操作日志失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取AI问答统计数据
|
||
* GET /api/admin/ai-chat/stats
|
||
*/
|
||
router.get('/ai-chat/stats', requirePermission('dashboard:read'), async (req, res) => {
|
||
try {
|
||
// 总用户数
|
||
const totalUsers = await AIChatQuota.countDocuments()
|
||
|
||
// 总提问数
|
||
const totalQuestions = await AIChatQuota.aggregate([
|
||
{ $group: { _id: null, total: { $sum: '$usedQuota' } } }
|
||
])
|
||
|
||
// 总分享次数
|
||
const totalShares = await AIChatQuota.aggregate([
|
||
{ $group: { _id: null, total: { $sum: { $size: '$shareRecords' } } } }
|
||
])
|
||
|
||
// 日均提问数(最近7天)
|
||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||
const dailyQuestions = await AIChatQuota.aggregate([
|
||
{ $match: { lastUsedDate: { $gte: weekAgo } } },
|
||
{ $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$lastUsedDate' } }, count: { $sum: '$dailyUsed' } } }
|
||
])
|
||
|
||
const avgDailyQuestions = dailyQuestions.length > 0
|
||
? Math.round(dailyQuestions.reduce((sum, d) => sum + d.count, 0) / dailyQuestions.length)
|
||
: 0
|
||
|
||
return ApiResponse.success(res, {
|
||
totalUsers,
|
||
totalQuestions: totalQuestions[0]?.total || 0,
|
||
totalShares: totalShares[0]?.total || 0,
|
||
avgDailyQuestions
|
||
})
|
||
} catch (error) {
|
||
logger.error('获取AI问答统计失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取AI问答配额列表
|
||
* GET /api/admin/ai-chat/quota-list
|
||
*/
|
||
router.get('/ai-chat/quota-list', requirePermission('user:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, pageSize = 10, keyword = '' } = req.query
|
||
|
||
const query = keyword ? { openid: { $regex: keyword, $options: 'i' } } : {}
|
||
|
||
const [list, total] = await Promise.all([
|
||
AIChatQuota.find(query)
|
||
.sort({ updatedAt: -1 })
|
||
.skip((page - 1) * pageSize)
|
||
.limit(parseInt(pageSize)),
|
||
AIChatQuota.countDocuments(query)
|
||
])
|
||
|
||
// 计算剩余次数
|
||
const formattedList = list.map(item => ({
|
||
...item.toObject(),
|
||
remainingQuota: item.getRemainingQuota()
|
||
}))
|
||
|
||
return ApiResponse.success(res, {
|
||
list: formattedList,
|
||
total,
|
||
page: parseInt(page),
|
||
pageSize: parseInt(pageSize)
|
||
})
|
||
} catch (error) {
|
||
logger.error('获取AI问答配额列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 增加用户AI问答次数
|
||
* POST /api/admin/ai-chat/add-quota
|
||
*/
|
||
router.post('/ai-chat/add-quota', requirePermission('user:write'), async (req, res) => {
|
||
try {
|
||
const { openid, amount = 5 } = req.body
|
||
|
||
if (!openid) {
|
||
return ApiResponse.error(res, '用户openid不能为空', 400)
|
||
}
|
||
|
||
let quota = await AIChatQuota.findOne({ openid })
|
||
|
||
if (!quota) {
|
||
quota = new AIChatQuota({ openid })
|
||
}
|
||
|
||
quota.sharedQuota += parseInt(amount)
|
||
await quota.save()
|
||
|
||
await logAction(req, 'add-quota', 'ai-chat', null, { openid, amount })
|
||
|
||
return ApiResponse.success(res, {
|
||
message: `成功增加${amount}次问答机会`,
|
||
remainingQuota: quota.getRemainingQuota()
|
||
})
|
||
} catch (error) {
|
||
logger.error('增加AI问答次数失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 重置用户AI问答配额
|
||
* POST /api/admin/ai-chat/reset-quota
|
||
*/
|
||
router.post('/ai-chat/reset-quota', requirePermission('user:write'), async (req, res) => {
|
||
try {
|
||
const { openid } = req.body
|
||
|
||
if (!openid) {
|
||
return ApiResponse.error(res, '用户openid不能为空', 400)
|
||
}
|
||
|
||
const quota = await AIChatQuota.findOneAndUpdate(
|
||
{ openid },
|
||
{
|
||
freeQuota: 5,
|
||
sharedQuota: 0,
|
||
usedQuota: 0,
|
||
dailyUsed: 0,
|
||
shareRecords: []
|
||
},
|
||
{ new: true, upsert: true }
|
||
)
|
||
|
||
await logAction(req, 'reset-quota', 'ai-chat', null, { openid })
|
||
|
||
return ApiResponse.success(res, {
|
||
message: '重置成功',
|
||
remainingQuota: quota.getRemainingQuota()
|
||
})
|
||
} catch (error) {
|
||
logger.error('重置AI问答配额失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取反馈列表
|
||
* GET /api/admin/feedback
|
||
*/
|
||
router.get('/feedback', requirePermission('feedback:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, status, type, priority, search } = req.query
|
||
|
||
const query = {}
|
||
if (status) query.status = status
|
||
if (type) query.type = type
|
||
if (priority) query.priority = priority
|
||
if (search) {
|
||
query.$or = [
|
||
{ title: { $regex: search, $options: 'i' } },
|
||
{ content: { $regex: search, $options: 'i' } },
|
||
{ userName: { $regex: search, $options: 'i' } }
|
||
]
|
||
}
|
||
|
||
const total = await Feedback.countDocuments(query)
|
||
const feedbacks = await Feedback.find(query)
|
||
.sort({ createdAt: -1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.populate('userId', 'nickName avatarUrl')
|
||
.lean()
|
||
|
||
// 格式化返回数据
|
||
const formattedFeedbacks = feedbacks.map(fb => ({
|
||
id: fb._id,
|
||
type: fb.type,
|
||
title: fb.title,
|
||
content: fb.content.substring(0, 100) + (fb.content.length > 100 ? '...' : ''),
|
||
userName: fb.userName || fb.userId?.nickName || '匿名用户',
|
||
userAvatar: fb.userId?.avatarUrl || '',
|
||
status: fb.status,
|
||
priority: fb.priority,
|
||
createdAt: fb.createdAt,
|
||
hasImages: fb.images?.length > 0
|
||
}))
|
||
|
||
await logAction(req, 'read', 'feedback', null, { page, limit, status, type })
|
||
|
||
return ApiResponse.paginated(res, formattedFeedbacks, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取反馈列表失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取反馈详情
|
||
* GET /api/admin/feedback/:id
|
||
*/
|
||
router.get('/feedback/:id', requirePermission('feedback:read'), async (req, res) => {
|
||
try {
|
||
const feedback = await Feedback.findById(req.params.id)
|
||
.populate('userId', 'nickName avatarUrl openid')
|
||
.lean()
|
||
|
||
if (!feedback) {
|
||
return ApiResponse.notFound(res, '反馈不存在')
|
||
}
|
||
|
||
await logAction(req, 'read', 'feedback', req.params.id)
|
||
|
||
return ApiResponse.success(res, {
|
||
id: feedback._id,
|
||
type: feedback.type,
|
||
title: feedback.title,
|
||
content: feedback.content,
|
||
images: feedback.images,
|
||
userName: feedback.userName || feedback.userId?.nickName || '匿名用户',
|
||
userAvatar: feedback.userId?.avatarUrl || '',
|
||
userContact: feedback.userContact,
|
||
relatedPage: feedback.relatedPage,
|
||
deviceInfo: feedback.deviceInfo,
|
||
networkInfo: feedback.networkInfo,
|
||
status: feedback.status,
|
||
priority: feedback.priority,
|
||
processLog: feedback.processLog,
|
||
result: feedback.result,
|
||
handler: feedback.handler,
|
||
handledAt: feedback.handledAt,
|
||
rating: feedback.rating,
|
||
userRemark: feedback.userRemark,
|
||
createdAt: feedback.createdAt,
|
||
updatedAt: feedback.updatedAt
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取反馈详情失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 处理反馈
|
||
* PUT /api/admin/feedback/:id
|
||
*/
|
||
router.put('/feedback/:id', requirePermission('feedback:update'), async (req, res) => {
|
||
try {
|
||
const { status, priority, result, comment } = req.body
|
||
|
||
const feedback = await Feedback.findById(req.params.id)
|
||
if (!feedback) {
|
||
return ApiResponse.notFound(res, '反馈不存在')
|
||
}
|
||
|
||
// 更新字段
|
||
if (status) feedback.status = status
|
||
if (priority) feedback.priority = priority
|
||
if (result) feedback.result = result
|
||
|
||
// 添加处理记录
|
||
if (comment || status) {
|
||
feedback.processLog.push({
|
||
operator: req.admin.username,
|
||
action: status || 'update',
|
||
comment: comment || '',
|
||
createdAt: new Date()
|
||
})
|
||
}
|
||
|
||
// 如果状态变为已解决或已关闭,记录处理人和时间
|
||
if (status === 'resolved' || status === 'closed') {
|
||
feedback.handler = req.admin.username
|
||
feedback.handledAt = new Date()
|
||
}
|
||
|
||
await feedback.save()
|
||
|
||
await logAction(req, 'update', 'feedback', req.params.id, { status, priority })
|
||
|
||
return ApiResponse.success(res, {}, '反馈处理成功')
|
||
|
||
} catch (error) {
|
||
logger.error('处理反馈失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 删除反馈
|
||
* DELETE /api/admin/feedback/:id
|
||
*/
|
||
router.delete('/feedback/:id', requirePermission('feedback:delete'), async (req, res) => {
|
||
try {
|
||
const feedback = await Feedback.findById(req.params.id)
|
||
if (!feedback) {
|
||
return ApiResponse.notFound(res, '反馈不存在')
|
||
}
|
||
|
||
await Feedback.findByIdAndDelete(req.params.id)
|
||
|
||
await logAction(req, 'delete', 'feedback', req.params.id)
|
||
|
||
return ApiResponse.success(res, {}, '反馈删除成功')
|
||
|
||
} catch (error) {
|
||
logger.error('删除反馈失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取反馈统计
|
||
* GET /api/admin/feedback/stats
|
||
*/
|
||
router.get('/feedback/stats', requirePermission('feedback:read'), async (req, res) => {
|
||
try {
|
||
const { startDate, endDate } = req.query
|
||
|
||
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||
const end = endDate ? new Date(endDate) : new Date()
|
||
|
||
// 状态统计
|
||
const statusStats = await Feedback.aggregate([
|
||
{ $match: { createdAt: { $gte: start, $lte: end } } },
|
||
{ $group: { _id: '$status', count: { $sum: 1 } } }
|
||
])
|
||
|
||
// 类型统计
|
||
const typeStats = await Feedback.aggregate([
|
||
{ $match: { createdAt: { $gte: start, $lte: end } } },
|
||
{ $group: { _id: '$type', count: { $sum: 1 } } }
|
||
])
|
||
|
||
// 今日新增
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const todayCount = await Feedback.countDocuments({ createdAt: { $gte: today } })
|
||
|
||
// 待处理数量
|
||
const pendingCount = await Feedback.countDocuments({ status: 'pending' })
|
||
|
||
return ApiResponse.success(res, {
|
||
statusStats: statusStats.reduce((acc, item) => {
|
||
acc[item._id] = item.count
|
||
return acc
|
||
}, {}),
|
||
typeStats: typeStats.reduce((acc, item) => {
|
||
acc[item._id] = item.count
|
||
return acc
|
||
}, {}),
|
||
todayCount,
|
||
pendingCount,
|
||
totalCount: await Feedback.countDocuments()
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取反馈统计失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 获取登录日志列表
|
||
* GET /api/admin/login-logs
|
||
*/
|
||
router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, userId, type, method, startDate, endDate } = req.query
|
||
|
||
const query = {}
|
||
if (userId) query.userId = userId
|
||
if (type) query.type = type
|
||
if (method) query.method = method
|
||
if (startDate || endDate) {
|
||
query.createdAt = {}
|
||
if (startDate) query.createdAt.$gte = new Date(startDate)
|
||
if (endDate) query.createdAt.$lte = new Date(endDate)
|
||
}
|
||
|
||
const total = await LoginLog.countDocuments(query)
|
||
const logs = await LoginLog.find(query)
|
||
.sort({ createdAt: -1 })
|
||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||
.limit(parseInt(limit))
|
||
.populate('userId', 'nickName avatarUrl')
|
||
.lean()
|
||
|
||
// 格式化返回数据
|
||
const formattedLogs = logs.map(log => ({
|
||
id: log._id,
|
||
userName: log.userId?.nickName || '未知用户',
|
||
userAvatar: log.userId?.avatarUrl || '',
|
||
type: log.type,
|
||
method: log.method,
|
||
source: log.source,
|
||
ip: log.ip,
|
||
device: log.device,
|
||
os: log.os,
|
||
browser: log.browser,
|
||
success: log.success,
|
||
failReason: log.failReason,
|
||
createdAt: log.createdAt
|
||
}))
|
||
|
||
await logAction(req, 'read', 'login-logs', null, { page, limit })
|
||
|
||
return ApiResponse.paginated(res, formattedLogs, {
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
total
|
||
})
|
||
|
||
} catch (error) {
|
||
logger.error('获取登录日志失败:', error)
|
||
return ApiResponse.serverError(res, error.message)
|
||
}
|
||
})
|
||
|
||
|
||
/**
|
||
* ===== 趋势资讯管理 CRUD =====
|
||
*/
|
||
router.get('/trends', requirePermission('trend:read'), async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, keyword = '' } = req.query
|
||
const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {}
|
||
const total = await Trend.countDocuments(query)
|
||
const items = await Trend.find(query).sort({ newsDate: -1, 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('获取趋势列表失败:', error)
|
||
return ApiResponse.serverError(res, '获取趋势列表失败')
|
||
}
|
||
})
|
||
|
||
router.post('/trends', requirePermission('trend:write'), async (req, res) => {
|
||
try {
|
||
const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate, plainExplain, takeAway } = req.body
|
||
if (!title) return ApiResponse.error(res, '标题不能为空', 400)
|
||
const item = new Trend({ title, summary, source, sourceUrl, category, tags, hot: !!hot, picked: !!picked, newsDate: newsDate || new Date(), plainExplain: plainExplain || '', takeAway: takeAway || '' })
|
||
await item.save()
|
||
await logAction(req, 'create', 'trend', item._id, { title })
|
||
return ApiResponse.success(res, item, '创建成功')
|
||
} catch (error) {
|
||
logger.error('创建趋势失败:', error)
|
||
return ApiResponse.serverError(res, '创建失败')
|
||
}
|
||
})
|
||
|
||
router.put('/trends/:id', requirePermission('trend:write'), async (req, res) => {
|
||
try {
|
||
const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate, plainExplain, takeAway } = req.body
|
||
const item = await Trend.findByIdAndUpdate(req.params.id,
|
||
{ title, summary, source, sourceUrl, category, tags, hot: !!hot, picked: !!picked, plainExplain: plainExplain || '', takeAway: takeAway || '', ...(newsDate ? { newsDate } : {}) },
|
||
{ new: true })
|
||
if (!item) return ApiResponse.notFound(res, '趋势不存在')
|
||
await logAction(req, 'update', 'trend', item._id, { title })
|
||
return ApiResponse.success(res, item, '更新成功')
|
||
} catch (error) {
|
||
logger.error('更新趋势失败:', error)
|
||
return ApiResponse.serverError(res, '更新失败')
|
||
}
|
||
})
|
||
|
||
router.delete('/trends/:id', requirePermission('trend:write'), async (req, res) => {
|
||
try {
|
||
const item = await Trend.findByIdAndDelete(req.params.id)
|
||
if (!item) return ApiResponse.notFound(res, '趋势不存在')
|
||
await logAction(req, 'delete', 'trend', item._id, { title: item.title })
|
||
return ApiResponse.success(res, {}, '删除成功')
|
||
} catch (error) {
|
||
logger.error('删除趋势失败:', error)
|
||
return ApiResponse.serverError(res, '删除失败')
|
||
}
|
||
})
|
||
|
||
/**
|
||
* ===== 增强看板:产品全量指标 + 最近访问记录 =====
|
||
* GET /api/admin/dashboard-v2
|
||
*/
|
||
router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res) => {
|
||
try {
|
||
const [
|
||
totalUsers, newUsers7d, totalKnowledge, approvedKnowledge, withQuiz,
|
||
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks, totalOrders, paidOrders,
|
||
totalLits, litUsers, recentLogins, litByDimAgg, aiStatsRaw
|
||
] = await Promise.all([
|
||
User.countDocuments({}),
|
||
User.countDocuments({ createdAt: { $gte: new Date(Date.now() - 7 * 86400000) } }),
|
||
Knowledge.countDocuments({}),
|
||
Knowledge.countDocuments({ status: 'approved' }),
|
||
Knowledge.countDocuments({ 'options.0': { $exists: true } }),
|
||
Trend.countDocuments({}),
|
||
Trend.countDocuments({ plainExplain: { $exists: true, $ne: '' } }),
|
||
Feedback.countDocuments({}),
|
||
Feedback.countDocuments({ status: 'pending' }),
|
||
Order.countDocuments({}),
|
||
Order.countDocuments({ status: 'paid' }),
|
||
KnowledgeLit.countDocuments({}),
|
||
KnowledgeLit.distinct('openid'),
|
||
LoginLog.find({}).sort({ createdAt: -1 }).limit(10).lean(),
|
||
KnowledgeLit.aggregate([{ $group: { _id: '$dim', count: { $sum: 1 } } }]),
|
||
AIChatQuota.aggregate([{ $group: { _id: null, used: { $sum: '$usedQuota' }, users: { $sum: 1 } } }]),
|
||
])
|
||
|
||
const litByDim = {}
|
||
for (let i = 1; i <= 5; i++) litByDim[i] = 0
|
||
litByDimAgg.forEach(r => { if (r._id >= 1 && r._id <= 5) litByDim[r._id] = r.count })
|
||
|
||
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
|
||
const ai = aiStatsRaw[0] || { used: 0, users: 0 }
|
||
|
||
return ApiResponse.success(res, {
|
||
stats: {
|
||
totalUsers, newUsers7d,
|
||
totalKnowledge, approvedKnowledge, withQuiz,
|
||
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks,
|
||
totalOrders, paidOrders,
|
||
totalLits, litUsers: litUsers.length,
|
||
aiChatUsed: ai.used, aiChatUsers: ai.users,
|
||
},
|
||
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
|
||
recentLogins: recentLogins.map(l => ({
|
||
openid: l.openid,
|
||
type: l.type,
|
||
method: l.method,
|
||
ip: l.ip,
|
||
success: l.success,
|
||
failReason: l.failReason,
|
||
userAgent: (l.userAgent || '').slice(0, 80),
|
||
createdAt: l.createdAt,
|
||
})),
|
||
})
|
||
} catch (error) {
|
||
logger.error('获取增强看板失败:', error)
|
||
return ApiResponse.serverError(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)
|
||
|
||
// 旧订单可能没有 metadata.coins,按金额 ×100(1:100 兑换比例)兜底
|
||
const coins = (order.metadata || {}).coins || Math.round(order.amount * 100)
|
||
if (!coins || coins <= 0) return ApiResponse.error(res, '无法确定退款星尘数量', 400)
|
||
const user = await User.findById(order.userId)
|
||
if (!user) return ApiResponse.error(res, '用户不存在', 400)
|
||
|
||
// ===== 退款风控(三层,防恶意充值→消费→退款循环) =====
|
||
// 第一层:余额必须够扣(星尘已消费 = 虚拟商品已使用,不支持退款)
|
||
// 注意:这里只做预检展示,真正的原子扣减在风控通过后执行
|
||
const currentBalance = user.vpayCoins || 0
|
||
if (currentBalance < coins) {
|
||
return ApiResponse.error(res,
|
||
`不可退款:用户星尘余额 ${currentBalance},需扣 ${coins}。已消费的星尘不支持退款。`,
|
||
409, { blocked: 'insufficient_balance', currentBalance, coinsToDeduct: coins })
|
||
}
|
||
|
||
// 第二层:退款时间窗口(支付后 7 天内可退)
|
||
const paidAt = order.paidAt || order.createdAt
|
||
const daysSincePaid = (Date.now() - new Date(paidAt).getTime()) / 86400000
|
||
if (daysSincePaid > 7) {
|
||
return ApiResponse.error(res,
|
||
`不可退款:订单支付已 ${Math.floor(daysSincePaid)} 天,超过 7 天退款窗口。`,
|
||
409, { blocked: 'expired' })
|
||
}
|
||
|
||
// 第三层:同一用户 30 天内最多 1 次退款
|
||
const recentRefund = await Order.findOne({
|
||
userId: user._id, status: 'refunded',
|
||
refundedAt: { $gte: new Date(Date.now() - 30 * 86400000) },
|
||
})
|
||
if (recentRefund) {
|
||
return ApiResponse.error(res,
|
||
`不可退款:该用户 30 天内已有退款记录(${recentRefund.orderNo})。每人每月限退 1 次。`,
|
||
409, { blocked: 'rate_limited', lastRefund: recentRefund.orderNo })
|
||
}
|
||
|
||
// ===== 冻结星尘(原子操作,防并发竞态) =====
|
||
// 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(星尘已冻结,现在退钱) =====
|
||
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 || ''
|
||
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
|
||
if (!accessToken) throw new Error('获取access_token失败: ' + JSON.stringify(tokenRes))
|
||
|
||
// refund_order 正确参数(snake_case,官方文档 https://developers.weixin.qq.com/miniprogram/dev/server/API/VirtualPayment/api_refund_order.html)
|
||
const refundOrderId = `RF${Date.now()}${require('crypto').randomBytes(3).toString('hex').toUpperCase()}`
|
||
const refundFee = Math.round(order.amount * 100) // 退款金额(分)= 订单金额
|
||
const refundBody = JSON.stringify({
|
||
openid: user.openid,
|
||
order_id: order.orderNo, // 商户单号(下单时的 outTradeNo)
|
||
refund_order_id: refundOrderId, // 本次退款单号(8-32位)
|
||
left_fee: refundFee, // 剩余可退金额(分)
|
||
refund_fee: refundFee, // 本次退款金额(分),须 ≤ left_fee
|
||
biz_meta: JSON.stringify({ admin: req.admin.username, reason: 'user_refund' }),
|
||
refund_reason: 0, // 0=用户申请退款
|
||
req_from: 1, // 1=商户后台发起
|
||
env: ENV,
|
||
})
|
||
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 {
|
||
// 微信退款失败 → 回滚冻结的星尘(用户星尘恢复,钱也没退,两边一致)
|
||
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) {
|
||
// 网络异常等 → 回滚冻结的星尘
|
||
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: balanceAfterFreeze, description: `退款 ${order.orderNo}`,
|
||
orderNo: order.orderNo, adminName: req.admin.username,
|
||
})
|
||
return ApiResponse.success(res, { orderNo: order.orderNo, coinsDeducted: coins, balanceAfter: balanceAfterFreeze, wxRefund: wxRefundResult }, '退款成功')
|
||
} 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)
|
||
// 支持前缀匹配:输入至少8位openid前缀即可(完整openid有28位,手打太长)
|
||
let user = await User.findOne({ openid })
|
||
if (!user && openid.length >= 8) {
|
||
const matches = await User.find({ openid: { $regex: '^' + openid } }).limit(5)
|
||
if (matches.length === 1) user = matches[0]
|
||
else if (matches.length > 1) return ApiResponse.error(res, `匹配到 ${matches.length} 个用户,请输入更多字符`, 400)
|
||
}
|
||
if (!user) return ApiResponse.notFound(res, '用户不存在(支持输入openid前8位以上)')
|
||
|
||
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
|