// server/routes/vocabulary.ts import express from 'express'; import multer from 'multer'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { auth as authMiddleware } from '../middleware/auth'; import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt } from '../models/Vocabulary'; import mongoose from 'mongoose'; import csv from 'csv-parser'; import { User } from '../models/User'; import { config } from '../config'; const router = express.Router(); type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice'; interface EvaluatedVocabularyAnswer { wordId: string; word: string; userAnswer: string; correctAnswer: string; isCorrect: boolean; } interface VocabularyAttemptQuestion { questionToken: string; wordId: string; word?: string; translation?: string; pronunciation?: string; options?: string[]; } 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 ATTEMPT_TTL_MS = 30 * 60 * 1000; const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 0.4); const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET; const isVocabularyTestType = (value: unknown): value is VocabularyTestType => { return value === 'chinese-to-english' || value === 'audio-to-english' || value === 'multiple-choice'; }; const shuffle = (array: T[]): T[] => { const newArray = [...array]; for (let i = newArray.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [newArray[i], newArray[j]] = [newArray[j], newArray[i]]; } return newArray; }; const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || ''; const signQuestionToken = ( attemptId: string, wordId: string, index: number, issuedAt: Date ): string => { const issuedAtMs = issuedAt.getTime(); const payload = `${attemptId}:${wordId}:${index}:${issuedAtMs}`; const signature = crypto .createHmac('sha256', ATTEMPT_SECRET) .update(payload) .digest('hex'); return `${index}.${issuedAtMs}.${signature}`; }; const safeEqualString = (left: string, right: string): boolean => { const leftBuffer = Buffer.from(left || ''); const rightBuffer = Buffer.from(right || ''); return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); }; const verifyQuestionToken = ( token: string, attemptId: string, wordId: string, index: number, issuedAt: Date ): boolean => { const expected = signQuestionToken(attemptId, wordId, index, issuedAt); return safeEqualString(expected, token); }; const buildAnswerProof = ( attemptId: string, questionToken: string, userAnswer: string, submittedAt: number ): string => crypto .createHash('sha256') .update(`${attemptId}:${questionToken}:${submittedAt}:${String(userAnswer ?? '')}`) .digest('hex'); const buildMultipleChoiceOptions = (word: any, pool: any[]): string[] => { const correctTranslation = String(word.translation ?? '').trim(); let incorrectOptions = shuffle( pool .filter(item => getObjectIdString(item) !== getObjectIdString(word)) .map(item => String(item.translation ?? '').trim()) .filter(item => item && item !== correctTranslation) ).slice(0, 3); if (incorrectOptions.length < 3) { const fakeOptions = ['假选项1', '假选项2', '假选项3', '假选项4', '假选项5'] .filter(item => item !== correctTranslation && !incorrectOptions.includes(item)); incorrectOptions = [...incorrectOptions, ...fakeOptions].slice(0, 3); } return shuffle([correctTranslation, ...incorrectOptions]); }; const buildQuestionPayload = ( word: any, testType: VocabularyTestType, questionToken: string, optionsPool: any[] ): VocabularyAttemptQuestion => { const base = { questionToken, wordId: getObjectIdString(word), pronunciation: word.pronunciation }; if (testType === 'chinese-to-english') { return { ...base, translation: word.translation }; } if (testType === 'audio-to-english') { return { ...base, word: word.word }; } return { ...base, word: word.word, options: buildMultipleChoiceOptions(word, optionsPool) }; }; const normalizeEnglishAnswer = (value: unknown): string => { return String(value ?? '') .replace(/(/g, '(') .replace(/)/g, ')') .replace(/,/g, ',') .replace(/\s+/g, '') .replace(/,/g, '') .replace(/[^\w()]/g, '') .toLowerCase(); }; const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unknown): EvaluatedVocabularyAnswer => { const userAnswer = String(answer ?? '').trim().slice(0, 500); const correctAnswer = testType === 'multiple-choice' ? String(word.translation ?? '').trim() : String(word.word ?? '').trim(); const isCorrect = testType === 'multiple-choice' ? userAnswer === correctAnswer : normalizeEnglishAnswer(userAnswer) === normalizeEnglishAnswer(correctAnswer); return { wordId: word._id.toString(), word: word.word, userAnswer, correctAnswer, isCorrect }; }; const buildMasteryStatus = (record: any) => { const masteryStatus: Record = {}; WORD_RECORD_MODES.forEach(mode => { masteryStatus[mode] = { mastered: record[mode]?.mastered || false, streak: record[mode]?.streak || 0, totalCorrect: record[mode]?.totalCorrect || 0, totalWrong: record[mode]?.totalWrong || 0, inWrongBook: record[mode]?.inWrongBook || false, lastMasteredAt: record[mode]?.lastMasteredAt || null }; }); return masteryStatus; }; const updateWordRecordForAnswer = async ( userId: string, wordId: string, testType: VocabularyTestType, isCorrect: boolean ) => { const modeKey = TEST_TYPE_TO_MODE[testType]; let record: any = await WordRecord.findOne({ user: userId, word: wordId }); const modeObj = record ? { ...(record[modeKey]?.toObject?.() || record[modeKey] || {}) } : {}; if (isCorrect) { modeObj.streak = (modeObj.streak || 0) + 1; modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1; } else { modeObj.streak = 0; modeObj.totalWrong = (modeObj.totalWrong || 0) + 1; } modeObj.lastTestedAt = new Date(); if (modeObj.streak >= 1) { modeObj.mastered = true; modeObj.lastMasteredAt = new Date(); modeObj.inWrongBook = false; } if (!modeObj.mastered && modeObj.totalWrong >= 5) { modeObj.inWrongBook = true; } const updateObj: Record = {}; updateObj[modeKey] = modeObj; record = await WordRecord.findOneAndUpdate( { user: userId, word: wordId }, { $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } }, { upsert: true, new: true } ); const recordAny = record as any; const isFullyMastered = WORD_RECORD_MODES.every(mode => recordAny[mode]?.mastered); if (isFullyMastered) { let latestMasteredAt: Date | null = null; WORD_RECORD_MODES.forEach(mode => { [recordAny[mode]?.lastMasteredAt, recordAny[mode]?.lastTestedAt].forEach(value => { if (!value) return; const candidate = new Date(value); if (!Number.isNaN(candidate.getTime()) && (!latestMasteredAt || candidate > latestMasteredAt)) { latestMasteredAt = candidate; } }); }); if (!recordAny.isFullyMastered || !recordAny.lastFullyMasteredAt || (latestMasteredAt && recordAny.lastFullyMasteredAt < latestMasteredAt)) { record = await WordRecord.findOneAndUpdate( { _id: recordAny._id }, { $set: { isFullyMastered: true, lastFullyMasteredAt: latestMasteredAt } }, { new: true } ); } } else if (recordAny.isFullyMastered) { record = await WordRecord.findOneAndUpdate( { _id: recordAny._id }, { $set: { isFullyMastered: false } }, { new: true } ); } const updatedRecord = record as any; return { masteryStatus: buildMasteryStatus(updatedRecord), isFullyMastered: updatedRecord.isFullyMastered, lastMasteredAt: updatedRecord.lastFullyMasteredAt, inWrongBook: WORD_RECORD_MODES.some(mode => updatedRecord[mode]?.inWrongBook) }; }; const getClientStartTime = (stats: any, fallbackTime: number): Date => { const parsed = stats?.startTime ? new Date(stats.startTime).getTime() : NaN; const maxDuration = 4 * 60 * 60 * 1000; if (Number.isFinite(parsed) && parsed <= fallbackTime && fallbackTime - parsed <= maxDuration) { return new Date(parsed); } return new Date(fallbackTime); }; // 配置 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文件')); } } }); // 获取所有单词集 router.get('/word-sets', authMiddleware, async (req, res) => { try { const wordSets = await WordSet.find() .select('name description totalWords createdAt') .sort({ createdAt: -1 }); res.json(wordSets); } catch (error) { console.error('获取单词集失败:', error); res.status(500).json({ message: '获取单词集失败' }); } }); // 创建新的单词集 router.post('/word-sets', authMiddleware, async (req, res) => { try { const { name, description } = req.body; // 检查是否已存在同名单词集 const existingSet = await WordSet.findOne({ name: name }); if (existingSet) { return res.status(400).json({ message: '已存在同名单词集' }); } const newWordSet = await WordSet.create({ name, description, totalWords: 0 }); res.status(201).json(newWordSet); } catch (error) { console.error('创建单词集失败:', error); res.status(500).json({ message: '创建单词集失败' }); } }); // 上传单词文件并创建单词集 router.post('/upload', authMiddleware, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ message: '请上传文件' }); } try { const { name } = req.body; const fileName = req.file.filename; const filePath = req.file.path; // 检查是否已存在同名单词集 const existingSet = await WordSet.findOne({ name: name || path.basename(fileName, '.csv') }); if (existingSet) { // 删除上传的文件 fs.unlinkSync(filePath); return res.status(400).json({ message: '已存在同名单词集' }); } // 创建单词集 const wordSet = await WordSet.create({ name: name || path.basename(fileName, '.csv'), 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.get('/study-words/:wordSetId', authMiddleware, async (req, res) => { try { const { wordSetId } = req.params; let targetCount = 100; if (req.query.count) { const parsed = parseInt(req.query.count as string, 10); if (!isNaN(parsed) && parsed > 0) { targetCount = Math.min(parsed, 100); } } // 检查单词集是否存在 const wordSet = await WordSet.findOne({ _id: wordSetId }); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } // 1. 获取该单词集下所有单词 const allWords = await Word.find({ wordSet: wordSetId }); const allWordIds = allWords.map(w => w._id.toString()); // 2. 获取该用户所有WordRecord(嵌套结构) const allRecords = await WordRecord.find({ user: req.user._id, word: { $in: allWordIds } }); // 用 wordId 做 key const recordMap = new Map(); allRecords.forEach(r => { recordMap.set(r.word.toString(), r); }); // 3. 分类 const wrongBookWords: any[] = []; const notMasteredWords: any[] = []; const masteredWords: { word: any, lastMasteredAt: Date }[] = []; const neverLearnedWords: any[] = []; for (const word of allWords) { const wordId = word._id.toString(); const record = recordMap.get(wordId); // 没有任何记录 if (!record) { neverLearnedWords.push(word); continue; } // 判断是否在错词本 const modes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice']; const anyInWrongBook = modes.some(m => record[m]?.inWrongBook); if (anyInWrongBook) { wrongBookWords.push(word); continue; } // 判断是否全部掌握 if (record.isFullyMastered) { masteredWords.push({ word, lastMasteredAt: record.lastFullyMasteredAt }); continue; } // 未完全掌握但有学习记录 if (modes.some(m => record[m] && Object.keys(record[m]).length > 0)) { notMasteredWords.push(word); continue; } } // 过滤一周内掌握的单词 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); let eligibleMasteredWords = masteredWords.filter(item => item.lastMasteredAt && item.lastMasteredAt < oneWeekAgo ); // 4. 按比例抽取 let wrongBookQuota = Math.round(targetCount * 0.4); let masteredQuota = Math.round(targetCount * 0.1); let neverLearnedQuota = targetCount - wrongBookQuota - masteredQuota; // 剩下的给未学和新词 // 错词本优先 let selectedWrongBook = wrongBookWords.slice(0, wrongBookQuota); let selectedMastered = eligibleMasteredWords .sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0)) .slice(0, masteredQuota) .map(item => item.word); function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } let selectedNeverLearned = shuffle([...neverLearnedWords]).slice(0, neverLearnedQuota); // 补足不足部分 let remain = targetCount - (selectedWrongBook.length + selectedMastered.length + selectedNeverLearned.length); // 先补未掌握(不在错词本的) let notMasteredPool = notMasteredWords.filter(w => !selectedWrongBook.includes(w)); let selectedNotMastered: any[] = []; if (remain > 0 && notMasteredPool.length > 0) { selectedNotMastered = notMasteredPool.slice(0, remain); remain -= selectedNotMastered.length; } // 再补新词 if (remain > 0) { const moreNew = neverLearnedWords.slice(neverLearnedQuota, neverLearnedQuota + remain); selectedNeverLearned = selectedNeverLearned.concat(moreNew); remain -= moreNew.length; } // 再补已掌握 if (remain > 0) { const moreMastered = eligibleMasteredWords .sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0)) .slice(masteredQuota, masteredQuota + remain) .map(item => item.word); selectedMastered = selectedMastered.concat(moreMastered); remain -= moreMastered.length; } // 再补错词本 if (remain > 0) { const moreWrong = wrongBookWords.slice(wrongBookQuota, wrongBookQuota + remain); selectedWrongBook = selectedWrongBook.concat(moreWrong); remain -= moreWrong.length; } // 合并所有选中的单词 let finalWords = [ ...selectedWrongBook, ...selectedNotMastered, ...selectedMastered, ...selectedNeverLearned ]; // 去重 const seen = new Set(); finalWords = finalWords.filter(w => { const id = w._id.toString(); if (seen.has(id)) return false; seen.add(id); return true; }); // 最终数量限制 finalWords = finalWords.slice(0, targetCount); res.json(finalWords); } catch (error) { console.error('获取学习单词失败:', error); res.status(500).json({ message: '获取学习单词失败' }); } }); // 创建测试会话:服务端固定题目顺序并签发每题防伪token router.post('/test-attempt', authMiddleware, async (req, res) => { try { const { wordSetId, testType, wordIds, count } = req.body; const userId = req.user?._id; if (!userId) { return res.status(401).json({ message: '用户信息无效' }); } if (!mongoose.isValidObjectId(wordSetId)) { return res.status(400).json({ message: '单词集ID无效' }); } if (!isVocabularyTestType(testType)) { return res.status(400).json({ message: '未知的测试类型' }); } const wordSet = await WordSet.findById(wordSetId); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } let selectedWords: any[] = []; const allWords = await Word.find({ wordSet: wordSetId }); if (Array.isArray(wordIds) && wordIds.length > 0) { if (wordIds.length > 100) { return res.status(400).json({ message: '单次测试最多100道题' }); } if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) { return res.status(400).json({ message: '包含无效单词ID' }); } const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))]; if (uniqueWordIds.length !== wordIds.length) { return res.status(400).json({ message: '测试单词不能重复' }); } const wordMap = new Map(allWords.map(word => [getObjectIdString(word), word])); selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId)); if (selectedWords.some(word => !word)) { return res.status(400).json({ message: '测试单词必须属于当前单词集' }); } selectedWords = shuffle(selectedWords); } else { const parsedCount = Number.parseInt(String(count || 100), 10); const targetCount = Number.isFinite(parsedCount) ? Math.max(1, Math.min(parsedCount, 100)) : 100; selectedWords = shuffle(allWords).slice(0, targetCount); } if (selectedWords.length === 0) { return res.status(400).json({ message: '没有可测试的单词' }); } const issuedAt = new Date(); const expiresAt = new Date(issuedAt.getTime() + ATTEMPT_TTL_MS); const attempt = await VocabularyTestAttempt.create({ user: userId, wordSet: wordSetId, testType, questionWordIds: selectedWords.map(word => word._id), questionTokens: [], issuedAt, expiresAt, status: 'active' }); const attemptId = attempt._id.toString(); const questionTokens = selectedWords.map((word, index) => signQuestionToken(attemptId, getObjectIdString(word), index, issuedAt) ); attempt.questionTokens = questionTokens; await attempt.save(); const questions = selectedWords.map((word, index) => buildQuestionPayload(word, testType, questionTokens[index], allWords) ); res.status(201).json({ attemptId, testType, expiresAt, questions }); } catch (error) { console.error('创建测试会话失败:', error); res.status(500).json({ message: '创建测试会话失败' }); } }); // 记录单词学习结果(嵌套结构版,upsert防止重复) router.post('/word-record', (req, res, next) => { console.log('收到 /word-record 请求', req.method, req.body); next(); }, authMiddleware, async (req, res) => { return res.status(410).json({ message: '单题记录接口已停用,请通过测试会话提交记录' }); }); // 保存测试记录:只能通过服务端签发的 attempt 提交 router.post('/test-record', authMiddleware, async (req, res) => { try { const { attemptId, answers } = req.body; const userId = req.user?._id; if (!userId) { return res.status(401).json({ message: '用户信息无效' }); } if (!mongoose.isValidObjectId(attemptId)) { return res.status(400).json({ message: '测试会话ID无效' }); } if (!Array.isArray(answers) || answers.length === 0) { return res.status(400).json({ message: '测试答案不能为空' }); } const attempt = await VocabularyTestAttempt.findOne({ _id: attemptId, user: userId }); if (!attempt) { return res.status(404).json({ message: '未找到测试会话' }); } const attemptAny = attempt as any; const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt); const expiresAt = new Date(attemptAny.expiresAt); const now = Date.now(); if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) { attemptAny.status = 'expired'; await attempt.save(); return res.status(410).json({ message: '测试会话已过期,请重新开始测试' }); } if (attemptAny.status !== 'active') { return res.status(409).json({ message: '测试会话已提交或已失效' }); } if (answers.length !== attemptAny.questionTokens.length) { return res.status(400).json({ message: '答案数量与测试题目不一致' }); } const tokenIndexMap = new Map(); attemptAny.questionTokens.forEach((token: string, index: number) => { tokenIndexMap.set(token, index); }); const answerSeen = new Set(); const evaluatedResults: EvaluatedVocabularyAnswer[] = []; const submittedAnswers: Array<{ token: string; userAnswer: string; submittedAt: number; answerProof: string }> = []; for (const item of answers) { const token = String(item?.questionToken ?? ''); const userAnswer = String(item?.userAnswer ?? '').trim().slice(0, 500); const answerProof = String(item?.answerProof ?? ''); const submittedAtMs = Number.parseInt(String(item?.submittedAt), 10); if (!token || !tokenIndexMap.has(token)) { return res.status(400).json({ message: '测试答案的题目标识无效' }); } if (answerSeen.has(token)) { return res.status(400).json({ message: '测试答案中存在重复题目' }); } answerSeen.add(token); if (!Number.isFinite(submittedAtMs)) { return res.status(400).json({ message: '答案提交时间无效' }); } if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) { return res.status(400).json({ message: '答案提交时间异常' }); } const index = tokenIndexMap.get(token)!; const wordId = getObjectIdString(attemptAny.questionWordIds[index]); if (!wordId) { return res.status(400).json({ message: '测试会话题目数据损坏' }); } if (!verifyQuestionToken(token, attemptId.toString(), wordId, index, issuedAt)) { return res.status(400).json({ message: '测试题目签名无效' }); } const expectedProof = buildAnswerProof(attemptId.toString(), token, userAnswer, submittedAtMs); if (!safeEqualString(expectedProof, answerProof)) { return res.status(400).json({ message: '答案校验失败' }); } submittedAnswers.push({ token, userAnswer, submittedAt: submittedAtMs, answerProof }); } const lastSubmittedAtMs = Math.max(...submittedAnswers.map(item => item.submittedAt)); const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000); const minimumSeconds = Math.max(2, answers.length * MIN_SECONDS_PER_QUESTION); if (elapsedSeconds < minimumSeconds) { return res.status(400).json({ message: '答题速度异常,请重新作答' }); } const questionWordIds = attemptAny.questionWordIds.map((wordId: any) => getObjectIdString(wordId)); const wordMap = new Map(); const words = await Word.find({ _id: { $in: questionWordIds }, wordSet: attemptAny.wordSet }); words.forEach(word => { wordMap.set(getObjectIdString(word), word); }); for (const item of submittedAnswers) { const index = tokenIndexMap.get(item.token)!; const wordId = questionWordIds[index]; const word = wordMap.get(wordId); if (!word) { return res.status(400).json({ message: '测试会话中的单词已不存在' }); } const evaluation = evaluateWordAnswer(word, attemptAny.testType, item.userAnswer); evaluatedResults.push({ ...evaluation, wordId, word: evaluation.word }); } const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate( { _id: attempt._id, user: userId, status: 'active' }, { $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs) } }, { new: true } ); if (!claimedAttempt) { return res.status(409).json({ message: '测试会话已提交或已失效' }); } for (const result of evaluatedResults) { await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect); } const totalWords = evaluatedResults.length; const correctWords = evaluatedResults.filter(result => result.isCorrect).length; const verifiedStats = { totalWords, correctWords, accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0, startTime: issuedAt, endTime: new Date(lastSubmittedAtMs), duration: Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000) }; await VocabularyTestRecord.create({ user: userId, wordSet: attemptAny.wordSet, attempt: attempt._id, testType: attemptAny.testType, stats: verifiedStats, results: evaluatedResults.map(result => ({ word: result.wordId, userAnswer: result.userAnswer, correctAnswer: result.correctAnswer, isCorrect: result.isCorrect })) }); res.status(201).json({ message: '测试记录已保存', attemptId: attempt._id.toString(), stats: verifiedStats, submittedAt: new Date(lastSubmittedAtMs) }); } catch (error) { console.error('保存测试记录失败:', error); res.status(500).json({ message: '保存测试记录失败' }); } }); // 获取单词详情 router.get('/word/:wordId', authMiddleware, async (req, res) => { try { const { wordId } = req.params; const word = await Word.findById(wordId); if (!word) { return res.status(404).json({ message: '未找到单词' }); } // 检查用户是否有权限访问这个单词 const wordSet = await WordSet.findOne({ _id: word.wordSet }); if (!wordSet) { return res.status(403).json({ message: '没有权限访问该单词' }); } res.json(word); } catch (error) { console.error('获取单词详情失败:', error); res.status(500).json({ message: '获取单词详情失败' }); } }); // 获取单词测试记录 router.get('/test-records', authMiddleware, async (req, res) => { try { const testRecords = await VocabularyTestRecord.find({ user: req.user._id }).populate('wordSet', 'name').sort({ createdAt: -1 }); res.json(testRecords); } catch (error) { console.error('获取测试记录失败:', error); res.status(500).json({ message: '获取测试记录失败' }); } }); // 排行榜接口:按掌握单词数和正确率排序 router.get('/leaderboard', authMiddleware, async (req, res) => { try { // 1. 查出所有 WordRecord const allRecords = await WordRecord.find({}); // 2. 直接使用isFullyMastered统计每个用户掌握的单词数 const userMasteredCount = {}; const userStats = {}; for (const rec of allRecords) { const userId = rec.user.toString(); // 初始化用户统计数据 if (!userStats[userId]) { userStats[userId] = { correct: 0, total: 0 }; } // 使用isFullyMastered直接统计掌握单词数 if (rec.isFullyMastered) { userMasteredCount[userId] = (userMasteredCount[userId] || 0) + 1; } // 统计正确率 ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'].forEach(mode => { const m = rec[mode]; if (m) { userStats[userId].correct += m.totalCorrect || 0; userStats[userId].total += (m.totalCorrect || 0) + (m.totalWrong || 0); } }); } // 3. 查询用户名 const userIds = Object.keys(userMasteredCount); const users = await mongoose.model('User').find({ _id: { $in: userIds } }, { fullname: 1 }); const userMap = {}; users.forEach(u => { userMap[u._id.toString()] = u.fullname; }); // 4. 组装排行榜数组 const leaderboard = userIds.map(userId => ({ userId, fullname: userMap[userId] || '未知用户', totalWordsLearned: userMasteredCount[userId], accuracy: userStats[userId] && userStats[userId].total > 0 ? Math.round((userStats[userId].correct / userStats[userId].total) * 100) : 0, rank: 0 // 添加rank属性,初始值为0 })); // 5. 排序 leaderboard.sort((a, b) => { if (b.totalWordsLearned !== a.totalWordsLearned) { return b.totalWordsLearned - a.totalWordsLearned; } return b.accuracy - a.accuracy; }); // 6. 添加排名 leaderboard.forEach((item, idx) => { item.rank = idx + 1; }); res.json(leaderboard); } catch (error) { console.error('获取排行榜失败:', error); res.status(500).json({ message: '获取排行榜失败' }); } }); // 获取单词集详细信息 router.get('/word-set/:id', authMiddleware, async (req, res) => { try { const wordSet = await WordSet.findById(req.params.id); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } res.json(wordSet); } catch (error) { console.error('获取单词集详细信息失败:', error); res.status(500).json({ message: '获取单词集详细信息失败' }); } }); // 更新单词集 router.put('/word-set/:id', authMiddleware, async (req, res) => { try { const { name, description } = req.body; const wordSet = await WordSet.findByIdAndUpdate( req.params.id, { name, description }, { new: true } ); if (!wordSet) { return res.status(404).json({ message: '未找到单词集' }); } res.json(wordSet); } catch (error) { console.error('更新单词集失败:', error); res.status(500).json({ message: '更新单词集失败' }); } }); // 获取单词集中的单词 router.get('/word-set/:id/words', authMiddleware, async (req, res) => { try { const wordSetId = req.params.id; console.log('Received request for wordSetId:', wordSetId); // 打印请求的 wordSetId // 检查数据库中是否存在该单词集 const wordSetExists = await WordSet.exists({ _id: wordSetId }); console.log('WordSet exists:', wordSetExists); // 打印单词集是否存在 if (!wordSetExists) { console.log('WordSet not found for wordSetId:', wordSetId); return res.status(404).json({ message: '请求的资源不存在' }); } // 查询单词集中的单词 const words = await Word.find({ wordSet: wordSetId }); console.log('Words found:', words.length); // 打印找到的单词数量 if (!words || words.length === 0) { console.log('No words found for wordSetId:', wordSetId); // 打印未找到的情况 return res.status(404).json({ message: '请求的资源不存在' }); } res.json(words); } catch (error) { console.error('获取单词失败:', error); res.status(500).json({ message: '获取单词失败' }); } }); // 更新单词 router.put('/words', authMiddleware, async (req, res) => { try { const { words } = req.body; console.log('Received words to update:', JSON.stringify(words, null, 2)); // 更详细的日志 for (const word of words) { const { _id, word: wordText, translation, pronunciation, example } = word; console.log(`Updating word ${_id}:`, { wordText, translation, pronunciation, example }); // 每个单词的更新日志 const updatedWord = await Word.findByIdAndUpdate( _id, { word: wordText, translation, pronunciation, example }, { new: true } // 返回更新后的文档 ); console.log('Updated word result:', updatedWord); // 更新结果日志 } res.json({ message: '单词更新成功' }); } catch (error) { console.error('更新单词失败:', error); res.status(500).json({ message: '更新单词失败' }); } }); export default router;