1038 lines
34 KiB
TypeScript
1038 lines
34 KiB
TypeScript
// 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 { 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<VocabularyTestType, WordRecordModeKey> = {
|
|
'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<string, any>) => {
|
|
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<typeof createEmptyModeStats>, 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<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>): 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<string, Record<WordRecordModeKey, ReturnType<typeof createEmptyModeStats>>>();
|
|
|
|
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<string, any>);
|
|
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<string, any>);
|
|
if ('error' in summaryFilters) {
|
|
return res.status(400).json({ message: summaryFilters.error });
|
|
}
|
|
|
|
const { userMatchStages, baseMatch } = 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<string, any>();
|
|
|
|
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 = Array.from(itemMap.values()).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<string, any>);
|
|
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<string, any>);
|
|
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<string>();
|
|
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;
|