Files
d1kt/server/routes/admin.ts
2026-05-25 11:25:12 +08:00

632 lines
19 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';
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 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<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').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<string, any>);
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<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;