// 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'; import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk'; import { applyVocabularySummaryItemFilters, buildVocabularySummaryFilters } from '../utils/vocabularyAuditFilters'; 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 getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || ''; 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 reviewDecision').lean() : []; const invalidatedAttemptIds = new Set( attempts .filter((attempt: any) => attempt.reviewDecision !== 'approved' && (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 }; }; const serializeUserRef = (value: any) => { const id = getObjectIdString(value); if (!id) return null; return { _id: id, username: value?.username || '', fullname: value?.fullname || '', email: value?.email || '' }; }; const serializeWordRef = (value: any) => { const id = getObjectIdString(value); if (!id) return null; return { _id: id, word: value?.word || '', translation: value?.translation || '', pronunciation: value?.pronunciation || '', example: value?.example || '' }; }; const serializeWordSetRef = (value: any) => { const id = getObjectIdString(value); if (!id) return null; return { _id: id, name: value?.name || '', description: value?.description || '' }; }; const flattenRiskFlags = (items: any[]): string[] => Array.from(new Set( (items || []) .flatMap(item => Array.isArray(item) ? item : [item]) .filter(Boolean) .map(item => String(item)) )); 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 summaryFilters = buildVocabularySummaryFilters(req.query as Record); if ('error' in summaryFilters) { return res.status(400).json({ message: summaryFilters.error }); } const { userMatchStages, baseMatch, minUniqueWords } = summaryFilters; const validPassItems = await VocabularyTestRecord.aggregate([ { $match: { ...baseMatch, 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 } }, ...userMatchStages, { $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 } } ]); const recordItems = await VocabularyTestRecord.aggregate([ { $match: { ...baseMatch, 'stats.endTime': { $gte: start, $lte: end } } }, { $group: { _id: '$user', totalRecords: { $sum: 1 }, validRecords: { $sum: { $cond: [{ $eq: ['$invalidated', true] }, 0, 1] } }, invalidatedRecords: { $sum: { $cond: [{ $eq: ['$invalidated', true] }, 1, 0] } }, riskFlagSets: { $addToSet: '$riskFlags' }, wordSetIds: { $addToSet: '$wordSet' }, testTypes: { $addToSet: '$testType' }, firstRecordAt: { $min: '$stats.endTime' }, lastRecordAt: { $max: '$stats.endTime' } } }, { $lookup: { from: 'users', localField: '_id', foreignField: '_id', as: 'user' } }, { $unwind: { path: '$user', preserveNullAndEmptyArrays: true } }, ...userMatchStages, { $project: { _id: 0, userId: { $toString: '$_id' }, username: '$user.username', fullname: '$user.fullname', email: '$user.email', totalRecords: 1, validRecords: 1, invalidatedRecords: 1, riskFlagSets: 1, wordSets: { $size: '$wordSetIds' }, testTypes: 1, firstRecordAt: 1, lastRecordAt: 1 } } ]); const passMap = new Map(validPassItems.map((item: any) => [item.userId, item])); const itemMap = new Map(); recordItems.forEach((recordItem: any) => { const passItem = passMap.get(recordItem.userId) || {}; itemMap.set(recordItem.userId, { userId: recordItem.userId, username: recordItem.username || passItem.username || '', fullname: recordItem.fullname || passItem.fullname || '', email: recordItem.email || passItem.email || '', passedWords: passItem.passedWords || 0, uniqueWords: passItem.uniqueWords || 0, testRecords: passItem.testRecords || 0, totalRecords: recordItem.totalRecords || 0, validRecords: recordItem.validRecords || 0, invalidatedRecords: recordItem.invalidatedRecords || 0, wordSets: recordItem.wordSets || passItem.wordSets || 0, testTypes: Array.from(new Set([...(recordItem.testTypes || []), ...(passItem.testTypes || [])])), riskFlags: flattenRiskFlags(recordItem.riskFlagSets || []), firstPassedAt: passItem.firstPassedAt, lastPassedAt: passItem.lastPassedAt, firstRecordAt: recordItem.firstRecordAt, lastRecordAt: recordItem.lastRecordAt }); }); validPassItems.forEach((passItem: any) => { if (itemMap.has(passItem.userId)) return; itemMap.set(passItem.userId, { ...passItem, totalRecords: passItem.testRecords || 0, validRecords: passItem.testRecords || 0, invalidatedRecords: 0, riskFlags: [], firstRecordAt: passItem.firstPassedAt, lastRecordAt: passItem.lastPassedAt }); }); const items = applyVocabularySummaryItemFilters(Array.from(itemMap.values()), minUniqueWords).sort((left, right) => { const passedDelta = (right.passedWords || 0) - (left.passedWords || 0); if (passedDelta !== 0) return passedDelta; const riskDelta = (right.invalidatedRecords || 0) - (left.invalidatedRecords || 0); if (riskDelta !== 0) return riskDelta; return new Date(right.lastRecordAt || right.lastPassedAt || 0).getTime() - new Date(left.lastRecordAt || left.lastPassedAt || 0).getTime(); }); res.json({ start, end, totalStudents: items.length, totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0), totalRecords: items.reduce((sum: number, item: any) => sum + (item.totalRecords || 0), 0), invalidatedRecords: items.reduce((sum: number, item: any) => sum + (item.invalidatedRecords || 0), 0), items }); } catch (error) { console.error('获取词汇测试通过统计失败:', error); res.status(500).json({ message: '获取词汇测试通过统计失败' }); } }); // 查看某个学生在时间段内的词汇测试明细(包含风控 attempt 原始提交) router.get('/vocabulary/test-pass-summary/:userId/details', 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 summaryFilters = buildVocabularySummaryFilters(req.query as Record); if ('error' in summaryFilters) { return res.status(400).json({ message: summaryFilters.error }); } const { baseMatch } = summaryFilters; const { userId } = req.params; if (!mongoose.isValidObjectId(userId)) { return res.status(400).json({ message: '学生ID无效' }); } const user = await User.findById(userId).select('_id username fullname email').lean(); if (!user) { return res.status(404).json({ message: '学生不存在' }); } const records = await VocabularyTestRecord.find({ user: user._id, ...baseMatch, 'stats.endTime': { $gte: start, $lte: end } }) .populate('wordSet', 'name description') .populate('results.word', 'word translation pronunciation example') .populate('reviewedBy', 'username fullname email') .sort({ 'stats.endTime': -1, createdAt: -1 }) .lean(); const attemptIds = records .map((record: any) => getObjectIdString(record.attempt)) .filter(id => mongoose.isValidObjectId(id)); const attempts = attemptIds.length > 0 ? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }) .populate('answers.word', 'word translation pronunciation example') .populate('reviewedBy', 'username fullname email') .lean() : []; const attemptMap = new Map(attempts.map((attempt: any) => [attempt._id.toString(), attempt])); const recordDetails = records.map((record: any) => { const attemptId = getObjectIdString(record.attempt); const attempt = attemptMap.get(attemptId); return { recordId: record._id.toString(), attemptId, wordSet: serializeWordSetRef(record.wordSet), testType: record.testType, stats: record.stats, invalidated: Boolean(record.invalidated), riskFlags: record.riskFlags || [], reviewDecision: record.reviewDecision || attempt?.reviewDecision || '', reviewedAt: record.reviewedAt || attempt?.reviewedAt || null, reviewedBy: serializeUserRef(record.reviewedBy || attempt?.reviewedBy), reviewNote: record.reviewNote || attempt?.reviewNote || '', createdAt: record.createdAt, results: (record.results || []).map((result: any, index: number) => ({ index: index + 1, wordId: getObjectIdString(result.word), word: serializeWordRef(result.word), userAnswer: result.userAnswer || '', correctAnswer: result.correctAnswer || '', isCorrect: Boolean(result.isCorrect) })), attempt: attempt ? { attemptId, status: attempt.status, testType: attempt.testType, wordSetId: getObjectIdString(attempt.wordSet), issuedAt: attempt.issuedAt, expiresAt: attempt.expiresAt, submittedAt: attempt.submittedAt, riskFlags: attempt.riskFlags || [], reviewDecision: attempt.reviewDecision || '', reviewedAt: attempt.reviewedAt || null, reviewedBy: serializeUserRef(attempt.reviewedBy), reviewNote: attempt.reviewNote || '', questionWordIds: (attempt.questionWordIds || []).map((wordId: any) => getObjectIdString(wordId)), questionTokens: attempt.questionTokens || [], optionTokens: attempt.optionTokens || [], optionTexts: attempt.optionTexts || [], answeredQuestionTokens: attempt.answeredQuestionTokens || [], answerDurations: attempt.answerDurations || [], answers: (attempt.answers || []).map((answer: any, index: number) => ({ index: index + 1, questionToken: answer.questionToken || '', wordId: getObjectIdString(answer.word), word: serializeWordRef(answer.word), userAnswer: answer.userAnswer || '', correctAnswer: answer.correctAnswer || '', isCorrect: Boolean(answer.isCorrect), submittedAt: answer.submittedAt, duration: answer.duration, riskFlags: answer.riskFlags || [], interactionSummary: answer.interactionSummary || null })) } : null }; }); res.json({ start, end, user: { userId: user._id.toString(), username: user.username, fullname: user.fullname, email: user.email }, totalRecords: recordDetails.length, totalPassedWords: recordDetails .filter((record: any) => !record.invalidated) .reduce((sum: number, record: any) => sum + Number(record.stats?.correctWords || 0), 0), invalidatedRecords: recordDetails.filter((record: any) => record.invalidated).length, approvedRecords: recordDetails.filter((record: any) => record.reviewDecision === 'approved').length, records: recordDetails }); } catch (error) { console.error('获取词汇测试明细失败:', error); res.status(500).json({ message: '获取词汇测试明细失败' }); } }); // 人工将风控作废的词汇测试记录转为有效成绩 router.post('/vocabulary/test-records/:recordId/approve', adminAuth, async (req, res) => { try { const { recordId } = req.params; if (!mongoose.isValidObjectId(recordId)) { return res.status(400).json({ message: '测试记录ID无效' }); } const record: any = await VocabularyTestRecord.findById(recordId); if (!record) { return res.status(404).json({ message: '测试记录不存在' }); } if (!record.invalidated) { return res.json({ message: '该词汇测试记录已经是有效成绩', recordId, alreadyEffective: true }); } const attemptId = getObjectIdString(record.attempt); if (!mongoose.isValidObjectId(attemptId)) { return res.status(400).json({ message: '该测试记录没有可用于恢复的 attempt 数据' }); } const attempt: any = await VocabularyTestAttempt.findById(attemptId); if (!attempt) { return res.status(404).json({ message: '原始提交 attempt 不存在,无法转为有效成绩' }); } const submittedAnswers = Array.isArray(attempt.answers) ? attempt.answers : []; const restoredResults = submittedAnswers .map((answer: any) => { const wordId = getObjectIdString(answer.word); if (!mongoose.isValidObjectId(wordId)) return null; return { word: new mongoose.Types.ObjectId(wordId), userAnswer: String(answer.userAnswer || ''), correctAnswer: String(answer.correctAnswer || ''), isCorrect: Boolean(answer.isCorrect) }; }) .filter(Boolean) as Array<{ word: mongoose.Types.ObjectId; userAnswer: string; correctAnswer: string; isCorrect: boolean; }>; if (restoredResults.length === 0) { return res.status(400).json({ message: '该 attempt 没有可恢复的答题数据' }); } const totalWords = restoredResults.length; const correctWords = restoredResults.filter(result => result.isCorrect).length; const reviewedAt = new Date(); const reviewNote = String(req.body?.note || '').trim().slice(0, 500); const userId = getObjectIdString(record.user); const affectedWordIds = restoredResults.map(result => result.word.toString()); await VocabularyTestRecord.updateOne( { _id: record._id }, { $set: { invalidated: false, results: restoredResults, 'stats.totalWords': totalWords, 'stats.correctWords': correctWords, 'stats.accuracy': totalWords > 0 ? (correctWords / totalWords) * 100 : 0, reviewDecision: 'approved', reviewedBy: req.user._id, reviewedAt, reviewNote } } ); await VocabularyTestAttempt.updateOne( { _id: attempt._id }, { $set: { reviewDecision: 'approved', reviewedBy: req.user._id, reviewedAt, reviewNote } } ); const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, affectedWordIds); res.json({ message: '已将风控记录转为有效成绩', recordId, attemptId, totalWords, correctWords, accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0, riskFlags: Array.from(new Set([...(record.riskFlags || []), ...(attempt.riskFlags || [])])), affectedWords: rebuildResult.affectedWords, rebuiltWords: rebuildResult.rebuiltWords }); } 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 summaryFilters = buildVocabularySummaryFilters(req.body || {}); if ('error' in summaryFilters) { return res.status(400).json({ message: summaryFilters.error }); } const { baseMatch } = summaryFilters; 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 }, ...baseMatch, '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;