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