// server/routes/admin.ts import express from 'express'; import { adminAuth } from '../middleware/auth'; import { User } from '../models/User'; import bcrypt from 'bcrypt'; import { OAuth2Client } from '../models/oauth2'; import { AdminController } from './admin.controller'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; import csv from 'csv-parser'; import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } from '../models/Vocabulary'; import mongoose from 'mongoose'; const router = express.Router(); const adminController = new AdminController(); // 配置 multer 用于文件上传 const storage = multer.diskStorage({ destination: function (req, file, cb) { const uploadDir = path.join(__dirname, '../uploads'); // 确保上传目录存在 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: function (req, file, cb) { cb(null, `${Date.now()}-${file.originalname}`); } }); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 // 限制5MB }, fileFilter: function (req, file, cb) { // 只允许上传CSV文件 if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) { cb(null, true); } else { cb(new Error('只支持CSV文件')); } } }); type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice'; const TEST_TYPE_TO_MODE: Record = { 'chinese-to-english': 'chineseToEnglish', 'audio-to-english': 'audioToEnglish', 'multiple-choice': 'multipleChoice' }; const WORD_RECORD_MODES: WordRecordModeKey[] = [ 'chineseToEnglish', 'audioToEnglish', 'multipleChoice' ]; const INVALIDATING_VOCABULARY_RISK_FLAGS = [ 'too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch', 'uniform_answer_intervals', 'repeated_whole_second_intervals' ]; const parseAdminDateRange = (source: Record) => { const start = new Date(String(source.start || source.startTime || '')); const end = new Date(String(source.end || source.endTime || '')); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return { error: '请选择有效的开始和结束时间' }; } if (start > end) { return { error: '开始时间不能晚于结束时间' }; } return { start, end }; }; const createEmptyModeStats = () => ({ streak: 0, totalCorrect: 0, totalWrong: 0, mastered: false, inWrongBook: false, lastTestedAt: undefined as Date | undefined, lastMasteredAt: undefined as Date | undefined }); const applyVocabularyAnswer = (modeStats: ReturnType, isCorrect: boolean, testedAt: Date) => { if (isCorrect) { modeStats.streak = (modeStats.streak || 0) + 1; modeStats.totalCorrect = (modeStats.totalCorrect || 0) + 1; } else { modeStats.streak = 0; modeStats.totalWrong = (modeStats.totalWrong || 0) + 1; } modeStats.lastTestedAt = testedAt; if (modeStats.streak >= 1) { modeStats.mastered = true; modeStats.lastMasteredAt = testedAt; modeStats.inWrongBook = false; } if (!modeStats.mastered && modeStats.totalWrong >= 5) { modeStats.inWrongBook = true; } }; const getLatestMasteredAt = (state: Record>): Date | undefined => { let latest: Date | undefined; WORD_RECORD_MODES.forEach(mode => { [state[mode].lastMasteredAt, state[mode].lastTestedAt].forEach(value => { if (!value) return; if (!latest || value > latest) latest = value; }); }); return latest; }; const rebuildVocabularyWordRecordsForUser = async ( userId: string, wordIds: string[] ) => { const uniqueWordIds = Array.from(new Set(wordIds.filter(id => mongoose.isValidObjectId(id)))); if (uniqueWordIds.length === 0) return { affectedWords: 0, rebuiltWords: 0 }; const userObjectId = new mongoose.Types.ObjectId(userId); const wordObjectIds = uniqueWordIds.map(id => new mongoose.Types.ObjectId(id)); const wordIdSet = new Set(uniqueWordIds); const states = new Map>>(); const records = await VocabularyTestRecord.find({ user: userObjectId, invalidated: { $ne: true }, 'results.word': { $in: wordObjectIds } }) .select('testType stats.endTime createdAt results attempt') .sort({ 'stats.endTime': 1, createdAt: 1 }) .lean(); const attemptIds = records .map((record: any) => record.attempt?.toString?.() || '') .filter(id => mongoose.isValidObjectId(id)); const attempts = attemptIds.length > 0 ? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags').lean() : []; const invalidatedAttemptIds = new Set( attempts .filter((attempt: any) => (attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag))) .map((attempt: any) => attempt._id.toString()) ); records.forEach((record: any) => { const attemptId = record.attempt?.toString?.() || ''; if (attemptId && invalidatedAttemptIds.has(attemptId)) return; const modeKey = TEST_TYPE_TO_MODE[record.testType as VocabularyTestType]; if (!modeKey) return; const testedAt = new Date(record.stats?.endTime || record.createdAt || Date.now()); (record.results || []).forEach((result: any) => { const wordId = result.word?.toString?.() || String(result.word || ''); if (!wordIdSet.has(wordId)) return; if (!states.has(wordId)) { states.set(wordId, { chineseToEnglish: createEmptyModeStats(), audioToEnglish: createEmptyModeStats(), multipleChoice: createEmptyModeStats() }); } const state = states.get(wordId)!; applyVocabularyAnswer(state[modeKey], Boolean(result.isCorrect), testedAt); }); }); await WordRecord.deleteMany({ user: userObjectId, word: { $in: wordObjectIds } }); const rebuiltDocs = Array.from(states.entries()).map(([wordId, state]) => { const isFullyMastered = WORD_RECORD_MODES.every(mode => state[mode].mastered); return { user: userObjectId, word: new mongoose.Types.ObjectId(wordId), chineseToEnglish: state.chineseToEnglish, audioToEnglish: state.audioToEnglish, multipleChoice: state.multipleChoice, isFullyMastered, lastFullyMasteredAt: isFullyMastered ? getLatestMasteredAt(state) : undefined, createdAt: new Date() }; }); if (rebuiltDocs.length > 0) { await WordRecord.insertMany(rebuiltDocs); } return { affectedWords: uniqueWordIds.length, rebuiltWords: rebuiltDocs.length }; }; router.get('/users', adminAuth, async (req, res) => { try { const users = await User.find() .select('-password') .sort({ createdAt: -1 }); res.json(users); } catch (error) { res.status(500).json({ message: '获取用户列表失败' }); } }); // 添加 PUT 路由用于更新用户 router.put('/users/:id', adminAuth, async (req, res) => { try { // 检查用户名是否已存在(排除当前用户) const existingUser = await User.findOne({ username: req.body.username, _id: { $ne: req.params.id } }); if (existingUser) { return res.status(400).json({ message: '用户名已被使用' }); } // 检查邮箱是否已存在(排除当前用户) if (req.body.email) { const existingEmail = await User.findOne({ email: req.body.email, _id: { $ne: req.params.id } }); if (existingEmail) { return res.status(400).json({ message: '邮箱已被使用' }); } } const user = await User.findByIdAndUpdate( req.params.id, req.body, { new: true, select: '-password' } ); if (!user) { return res.status(404).json({ message: '用户不存在' }); } res.json(user); } catch (error) { console.error('Error updating user:', error); res.status(500).json({ message: '更新用户失败' }); } }); router.post('/users', adminAuth, async (req, res) => { try { const { username, email, password, fullname, isAdmin } = req.body; // 验证必填字段 if (!username || !email || !password || !fullname) { return res.status(400).json({ message: '请填写所有必填字段' }); } // 检查用户名是否已存在 const existingUser = await User.findOne({ username }); if (existingUser) { return res.status(400).json({ message: '用户名已被使用' }); } // 检查邮箱是否已存在 const existingEmail = await User.findOne({ email }); if (existingEmail) { return res.status(400).json({ message: '邮箱已被使用' }); } // 创建新用户 const newUser = new User({ username, email, password: password, fullname, isAdmin: isAdmin || false }); await newUser.save(); // 返回用户信息(不包含密码) const userResponse = await User.findById(newUser._id).select('-password'); res.status(201).json(userResponse); } catch (error) { console.error('Error creating user:', error); res.status(500).json({ message: '创建用户失败' }); } }); // 添加删除用户路由 router.delete('/users/:id', adminAuth, async (req, res) => { try { const user = await User.findById(req.params.id); if (!user) { return res.status(404).json({ message: '用户不存在' }); } // 防止删除最后一个管理员 if (user.isAdmin) { const adminCount = await User.countDocuments({ isAdmin: true }); if (adminCount <= 1) { return res.status(400).json({ message: '不能删除最后一个管理员' }); } } await User.findByIdAndDelete(req.params.id); res.json({ message: '用户已删除' }); } catch (error) { console.error('Error deleting user:', error); res.status(500).json({ message: '删除用户失败' }); } }); // OAuth2 客户端管理路由 router.get('/oauth2/clients', adminAuth, (req, res) => adminController.listOAuth2Clients(req, res)); router.post('/oauth2/clients', adminAuth, (req, res) => adminController.createOAuth2Client(req, res)); router.put('/oauth2/clients/:id', adminAuth, (req, res) => adminController.updateOAuth2Client(req, res)); router.delete('/oauth2/clients/:id', adminAuth, (req, res) => adminController.deleteOAuth2Client(req, res)); // ===== 单词库管理路由 ===== // 获取所有单词集 router.get('/vocabulary/word-sets', adminAuth, async (req, res) => { try { const wordSets = await WordSet.find() .select('name description totalWords createdAt owner') .populate('owner', 'username fullname') .sort({ createdAt: -1 }); res.json(wordSets); } catch (error) { console.error('获取单词集失败:', error); res.status(500).json({ message: '获取单词集失败' }); } }); // 上传单词文件并创建单词集(管理员版本) router.post('/vocabulary/upload', adminAuth, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ message: '请上传文件' }); } try { const { name, description, ownerId } = req.body; const fileName = req.file.filename; const filePath = req.file.path; // 如果没有指定所有者,默认为当前管理员 const owner = ownerId || req.user._id; // 检查是否已存在同名单词集 const existingSet = await WordSet.findOne({ name: name || path.basename(fileName, '.csv'), owner: owner }); if (existingSet) { // 删除上传的文件 fs.unlinkSync(filePath); return res.status(400).json({ message: '已存在同名单词集' }); } // 创建单词集 const wordSet = await WordSet.create({ name: name || path.basename(fileName, '.csv'), description: description || '', owner: owner, totalWords: 0 }); const results: any[] = []; let wordCount = 0; // 处理CSV文件 fs.createReadStream(filePath) .pipe(csv()) .on('data', (data) => { // 检查必要的字段是否存在 if (data.word && data.translation) { results.push({ word: data.word.trim(), translation: data.translation.trim(), pronunciation: data.pronunciation?.trim(), example: data.example?.trim(), wordSet: wordSet._id }); wordCount++; } }) .on('end', async () => { try { // 批量插入单词 if (results.length > 0) { await Word.insertMany(results); // 更新单词集的单词数量 await WordSet.findByIdAndUpdate(wordSet._id, { totalWords: wordCount }); } // 删除上传的文件 fs.unlinkSync(filePath); res.status(201).json({ message: `成功创建单词集并导入${wordCount}个单词`, wordSet }); } catch (error) { // 如果插入单词失败,删除创建的单词集 await WordSet.findByIdAndDelete(wordSet._id); console.error('导入单词失败:', error); res.status(500).json({ message: '导入单词失败' }); } }) .on('error', (error) => { console.error('处理CSV文件失败:', error); res.status(500).json({ message: '处理CSV文件失败' }); }); } catch (error) { console.error('上传文件失败:', error); res.status(500).json({ message: '上传文件失败' }); } }); // 删除单词集 router.delete('/vocabulary/word-sets/:id', adminAuth, async (req, res) => { try { const { id } = req.params; // 检查单词集是否存在 const wordSet = await WordSet.findById(id); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } // 删除单词集及其关联的所有单词 await Word.deleteMany({ wordSet: id }); await WordSet.findByIdAndDelete(id); res.json({ message: '单词集已删除' }); } catch (error) { console.error('删除单词集失败:', error); res.status(500).json({ message: '删除单词集失败' }); } }); // 单词集详情(包括所有单词) router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => { try { const { id } = req.params; // 检查单词集是否存在 const wordSet = await WordSet.findById(id); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } // 获取单词集中的所有单词 const words = await Word.find({ wordSet: id }); res.json({ wordSet, words }); } catch (error) { console.error('获取单词集详情失败:', error); res.status(500).json({ message: '获取单词集详情失败' }); } }); // 按时间段汇总学生词汇测试通过情况 router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => { try { const range = parseAdminDateRange(req.query as Record); if (range.error) { return res.status(400).json({ message: range.error }); } const { start, end } = range as { start: Date; end: Date }; const items = await VocabularyTestRecord.aggregate([ { $match: { invalidated: { $ne: true }, 'stats.endTime': { $gte: start, $lte: end }, 'stats.correctWords': { $gt: 0 } } }, { $unwind: '$results' }, { $match: { 'results.isCorrect': true } }, { $group: { _id: '$user', passedWords: { $sum: 1 }, uniqueWordIds: { $addToSet: '$results.word' }, testRecordIds: { $addToSet: '$_id' }, wordSetIds: { $addToSet: '$wordSet' }, testTypes: { $addToSet: '$testType' }, firstPassedAt: { $min: '$stats.endTime' }, lastPassedAt: { $max: '$stats.endTime' } } }, { $lookup: { from: 'users', localField: '_id', foreignField: '_id', as: 'user' } }, { $unwind: { path: '$user', preserveNullAndEmptyArrays: true } }, { $project: { _id: 0, userId: { $toString: '$_id' }, username: '$user.username', fullname: '$user.fullname', email: '$user.email', passedWords: 1, uniqueWords: { $size: '$uniqueWordIds' }, testRecords: { $size: '$testRecordIds' }, wordSets: { $size: '$wordSetIds' }, testTypes: 1, firstPassedAt: 1, lastPassedAt: 1 } }, { $sort: { passedWords: -1, lastPassedAt: -1 } } ]); res.json({ start, end, totalStudents: items.length, totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0), items }); } catch (error) { console.error('获取词汇测试通过统计失败:', error); res.status(500).json({ message: '获取词汇测试通过统计失败' }); } }); // 清除某个学生在指定时间段内的词汇测试成绩,并重算掌握状态 router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) => { try { const range = parseAdminDateRange(req.body || {}); if (range.error) { return res.status(400).json({ message: range.error }); } const { start, end } = range as { start: Date; end: Date }; const { userId } = req.body || {}; if (!mongoose.isValidObjectId(userId)) { return res.status(400).json({ message: '学生ID无效' }); } const user = await User.findById(userId).select('_id username fullname'); if (!user) { return res.status(404).json({ message: '学生不存在' }); } const records = await VocabularyTestRecord.find({ user: user._id, invalidated: { $ne: true }, 'stats.endTime': { $gte: start, $lte: end }, 'stats.correctWords': { $gt: 0 } }) .select('_id stats.correctWords results.word results.isCorrect') .lean(); if (records.length === 0) { return res.json({ message: '没有找到需要清除的词汇测试成绩', deletedRecords: 0, removedPassedWords: 0, affectedWords: 0, rebuiltWords: 0 }); } const recordIds = records.map(record => record._id); const affectedWordIds = new Set(); let removedPassedWords = 0; records.forEach((record: any) => { removedPassedWords += Number(record.stats?.correctWords || 0); (record.results || []).forEach((result: any) => { const wordId = result.word?.toString?.() || String(result.word || ''); if (wordId) affectedWordIds.add(wordId); }); }); await VocabularyTestRecord.deleteMany({ _id: { $in: recordIds } }); const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, Array.from(affectedWordIds)); res.json({ message: '词汇测试成绩已清除', userId, username: user.username, fullname: user.fullname, deletedRecords: records.length, removedPassedWords, affectedWords: rebuildResult.affectedWords, rebuiltWords: rebuildResult.rebuiltWords }); } catch (error) { console.error('清除词汇测试成绩失败:', error); res.status(500).json({ message: '清除词汇测试成绩失败' }); } }); export default router;