Files
ai-dimension/server/src/routes/admin.js
T

1426 lines
47 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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