// 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 { 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 VocabularyOptionPayload { token: string; text: string; } interface VocabularyAttemptQuestion { questionToken: string; wordId: string; word?: string; translation?: string; pronunciation?: string; options?: Array; optionTokenRequest?: boolean; } interface InteractionEventPayload { type?: string; key?: string; inputType?: string; value?: string; valueLength?: number; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; ts?: number; } interface InteractionSummary { keyCount: number; inputCount: number; pasteCount: number; focusCount: number; blurCount: number; pointerCount: number; firstEventOffset: number; lastEventOffset: number; } 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_STUDY_WORD_COUNT = 10; const MAX_STUDY_WORD_COUNT = 100; const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 2); const MAX_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MAX_SECONDS_PER_QUESTION || 10); const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'https://d1kt.cn,http://localhost:3000,http://localhost:3001') .split(',') .map(item => item.trim()) .filter(Boolean); const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET; const ANSWER_PROOF_SALT = 'd1ktsalt'; const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。'; 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 getRequestOrigin = (req: express.Request): string => { const origin = req.get('origin'); if (origin) return origin; const referer = req.get('referer'); if (!referer) return ''; try { return new URL(referer).origin; } catch { return ''; } }; const isAllowedVocabularyOrigin = (req: express.Request): boolean => { const origin = getRequestOrigin(req); if (!origin) { return config.NODE_ENV !== 'production'; } return VOCABULARY_ALLOWED_ORIGINS.includes(origin); }; 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 signOptionToken = ( attemptId: string, questionToken: string, optionText: string, index: number ): string => { const payload = `${attemptId}:${questionToken}:${index}:${optionText}`; const signature = crypto .createHmac('sha256', ATTEMPT_SECRET) .update(payload) .digest('hex'); return `${index}.${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('md5') .update(`${ANSWER_PROOF_SALT}:${attemptId}:${questionToken}:${submittedAt}:${String(userAnswer ?? '')}:${ANSWER_PROOF_SALT}`) .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[], suppliedOptions?: Array ): 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 }; } const options = suppliedOptions || buildMultipleChoiceOptions(word, optionsPool); return { ...base, word: word.word, options, optionTokenRequest: options.some(option => typeof option === 'string') }; }; 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 summarizeInteractionEvents = ( events: InteractionEventPayload[], questionShownAtMs: number ): InteractionSummary => { const safeEvents = Array.isArray(events) ? events.slice(0, 300) : []; const timestamps = safeEvents .map(event => Number(event?.ts)) .filter(value => Number.isFinite(value)); const firstEventTs = timestamps.length > 0 ? Math.min(...timestamps) : questionShownAtMs; const lastEventTs = timestamps.length > 0 ? Math.max(...timestamps) : questionShownAtMs; return { keyCount: safeEvents.filter(event => event?.type === 'keydown' || event?.type === 'keyup').length, inputCount: safeEvents.filter(event => event?.type === 'input').length, pasteCount: safeEvents.filter(event => event?.type === 'paste').length, focusCount: safeEvents.filter(event => event?.type === 'focus').length, blurCount: safeEvents.filter(event => event?.type === 'blur').length, pointerCount: safeEvents.filter(event => event?.type === 'pointerdown' || event?.type === 'click' || event?.type === 'change').length, firstEventOffset: Math.max(0, firstEventTs - questionShownAtMs), lastEventOffset: Math.max(0, lastEventTs - questionShownAtMs) }; }; const buildKeySequenceText = (events: InteractionEventPayload[]): string => { let text = ''; events.forEach(event => { if (event?.type !== 'keydown' || event.ctrlKey || event.metaKey || event.altKey) return; const key = String(event.key ?? ''); if (key === 'Backspace') { text = text.slice(0, -1); return; } if (key === 'Spacebar') { text += ' '; return; } if (key.length === 1) { text += key; } }); return text.slice(0, 500); }; const analyzeInputTraceRisk = ( events: InteractionEventPayload[], userAnswer: string, testType: VocabularyTestType ): string[] => { if (testType === 'multiple-choice') return []; const flags: string[] = []; const expectedAnswer = normalizeEnglishAnswer(userAnswer); if (!expectedAnswer) return flags; const safeEvents = Array.isArray(events) ? events.slice(0, 300) : []; const inputValues = safeEvents .filter(event => event?.type === 'input' && typeof event.value === 'string') .map(event => String(event.value ?? '').slice(0, 500)); const lastInputValue = inputValues[inputValues.length - 1]; if (inputValues.length === 0) { flags.push('missing_input_value_trace'); } else if (normalizeEnglishAnswer(lastInputValue) !== expectedAnswer) { flags.push('input_value_mismatch'); } const keySequence = normalizeEnglishAnswer(buildKeySequenceText(safeEvents)); if (keySequence && keySequence !== expectedAnswer) { flags.push('key_sequence_mismatch'); } return flags; }; const analyzeAnswerRisk = ( durationSeconds: number, summary: InteractionSummary, testType: VocabularyTestType ): string[] => { const flags: string[] = []; if (durationSeconds > MAX_SECONDS_PER_QUESTION) flags.push('too_slow'); if (testType === 'multiple-choice') { if (summary.pointerCount === 0) flags.push('missing_choice_interaction'); } else { if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast'); if (summary.keyCount === 0) flags.push('missing_key_events'); if (summary.inputCount === 0) flags.push('missing_input_events'); if (summary.pasteCount > 0) flags.push('paste_used'); } return flags; }; const analyzeBatchRisk = (durations: number[]): string[] => { if (durations.length < 3) return []; const roundedDurations = durations.map(value => Math.round(value * 10) / 10); const average = roundedDurations.reduce((sum, value) => sum + value, 0) / roundedDurations.length; const variance = roundedDurations.reduce((sum, value) => sum + Math.pow(value - average, 2), 0) / roundedDurations.length; const uniqueWholeSecondCount = new Set(durations.map(value => Math.round(value))).size; const durationRange = Math.max(...durations) - Math.min(...durations); const flags: string[] = []; if (durationRange <= 1) flags.push('uniform_answer_intervals'); if (variance < 0.05 && roundedDurations.length >= 4) flags.push('uniform_answer_intervals'); if (uniqueWholeSecondCount <= 2 && durations.length >= 5) flags.push('repeated_whole_second_intervals'); return Array.from(new Set(flags)); }; 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.max(MIN_STUDY_WORD_COUNT, Math.min(parsed, MAX_STUDY_WORD_COUNT)); } } // 检查单词集是否存在 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: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(wordSetId)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isVocabularyTestType(testType)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const wordSet = await WordSet.findById(wordSetId); if (!wordSet) { return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE }); } if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))]; if (uniqueWordIds.length !== wordIds.length) { return res.status(400).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_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; if (testType === 'multiple-choice') { const optionTexts = selectedWords.map(word => buildMultipleChoiceOptions(word, allWords)); attempt.optionTexts = optionTexts; attempt.optionTokens = optionTexts.map((options, index) => options.map((option, optionIndex) => signOptionToken(attemptId, questionTokens[index], option, optionIndex)) ); } await attempt.save(); const questions = selectedWords.map((word, index) => buildQuestionPayload(word, testType, questionTokens[index], allWords, testType === 'multiple-choice' ? attempt.optionTexts?.[index] || [] : undefined) ); res.status(201).json({ attemptId, testType, expiresAt, questions }); } catch (error) { console.error('创建测试会话失败:', error); res.status(500).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE }); }); // 单题提交:服务端逐题判分并累计结果 router.post('/test-answer', authMiddleware, async (req, res) => { try { const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body; const userId = req.user?._id; if (!userId) { return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ _id: attemptId, user: userId }); if (!attempt) { return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.status !== 'active') { return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const token = String(questionToken ?? ''); const tokenIndex = attemptAny.questionTokens.indexOf(token); if (!token || tokenIndex < 0) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) ? attemptAny.answeredQuestionTokens : []; if (answeredTokens.includes(token)) { return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (tokenIndex !== answeredTokens.length) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedAtMs = Number.parseInt(String(submittedAt), 10); if (!Number.isFinite(submittedAtMs)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]); if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500); if (attemptAny.testType === 'multiple-choice') { const optionTokens = attemptAny.optionTokens?.[tokenIndex] || []; const optionTexts = attemptAny.optionTexts?.[tokenIndex] || []; const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer)); if (selectedOptionIndex < 0) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } normalizedUserAnswer = optionTexts[selectedOptionIndex] || ''; } const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs); if (!safeEqualString(expectedProof, String(answerProof ?? ''))) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const questionShownAtMs = tokenIndex === 0 ? issuedAt.getTime() : new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime(); const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000); const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs); const riskFlags = Array.from(new Set([ ...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType), ...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType) ])); const word = await Word.findOne({ _id: wordId, wordSet: attemptAny.wordSet }); if (!word) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer); attemptAny.answeredQuestionTokens = [...answeredTokens, token]; attemptAny.answerDurations = [...(attemptAny.answerDurations || []), duration]; attemptAny.answers = [ ...(attemptAny.answers || []), { questionToken: token, word: word._id, userAnswer: evaluation.userAnswer, correctAnswer: evaluation.correctAnswer, isCorrect: evaluation.isCorrect, submittedAt: new Date(submittedAtMs), duration, riskFlags, interactionSummary } ]; attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...riskFlags])); await attempt.save(); res.status(201).json({ message: '答案已提交', result: { questionToken: token, wordId, word: evaluation.word, userAnswer: evaluation.userAnswer, correctAnswer: evaluation.correctAnswer, isCorrect: evaluation.isCorrect }, progress: { answered: attemptAny.answeredQuestionTokens.length, total: attemptAny.questionTokens.length, finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length } }); } catch (error) { console.error('提交单题答案失败:', error); res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); // 选择题点击选项后换取该选项的一次性凭证 router.post('/test-option-token', authMiddleware, async (req, res) => { try { const { attemptId, questionToken, optionText } = req.body; const userId = req.user?._id; if (!userId) { return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ _id: attemptId, user: userId }); if (!attempt) { return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attemptAny = attempt as any; if (attemptAny.status !== 'active') { return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.testType !== 'multiple-choice') { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const token = String(questionToken ?? ''); const tokenIndex = attemptAny.questionTokens.indexOf(token); if (!token || tokenIndex < 0) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const optionTexts = attemptAny.optionTexts?.[tokenIndex] || []; const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? '')); if (selectedIndex < 0) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) ? attemptAny.answeredQuestionTokens : []; if (tokenIndex !== answeredTokens.length) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex]; if (!optionToken) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } res.json({ questionToken: token, optionToken }); } catch (error) { console.error('获取选择项凭证失败:', error); res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE }); } }); // 保存测试记录:只能通过服务端签发的 attempt 提交 router.post('/test-record', authMiddleware, async (req, res) => { try { const { attemptId } = req.body; const userId = req.user?._id; if (!userId) { return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!mongoose.isValidObjectId(attemptId)) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const attempt = await VocabularyTestAttempt.findOne({ _id: attemptId, user: userId }); if (!attempt) { return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE }); } if (attemptAny.status !== 'active') { return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : []; if (submittedAnswers.length !== attemptAny.questionTokens.length) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const lastSubmittedAtMs = Math.max(...submittedAnswers.map((item: any) => new Date(item.submittedAt).getTime())); const isMultipleChoiceAttempt = attemptAny.testType === 'multiple-choice'; if (!isMultipleChoiceAttempt) { const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000); const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION); if (elapsedSeconds < minimumSeconds) { return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); } } const batchRiskFlags = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []); attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags])); const shouldInvalidateBatch = batchRiskFlags.length > 0 || ['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag)); const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate( { _id: attempt._id, user: userId, status: 'active' }, { $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } }, { new: true } ); if (!claimedAttempt) { return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE }); } const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word)); const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word'); const submittedWordMap = new Map(); submittedWords.forEach(word => { submittedWordMap.set(getObjectIdString(word), word.word); }); const evaluatedResults: EvaluatedVocabularyAnswer[] = submittedAnswers.map((item: any) => { const wordId = getObjectIdString(item.word); return { wordId, word: submittedWordMap.get(wordId) || '', userAnswer: item.userAnswer, correctAnswer: item.correctAnswer, isCorrect: Boolean(item.isCorrect) }; }); if (!shouldInvalidateBatch) { for (const result of evaluatedResults) { await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect); } } const totalWords = evaluatedResults.length; const correctWords = shouldInvalidateBatch ? 0 : 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: submittedAnswers.map((result: any) => ({ word: result.word, userAnswer: result.userAnswer, correctAnswer: result.correctAnswer, isCorrect: shouldInvalidateBatch ? false : result.isCorrect })) }); res.status(201).json({ message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存', attemptId: attempt._id.toString(), stats: verifiedStats, results: submittedAnswers.map((result: any) => ({ wordId: getObjectIdString(result.word), word: submittedWordMap.get(getObjectIdString(result.word)) || '', userAnswer: result.userAnswer, correctAnswer: result.correctAnswer, isCorrect: shouldInvalidateBatch ? false : result.isCorrect })), submittedAt: new Date(lastSubmittedAtMs), invalidated: shouldInvalidateBatch }); } catch (error) { console.error('保存测试记录失败:', error); res.status(500).json({ message: INVALID_CREDENTIAL_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;