diff --git a/server/models/Vocabulary.ts b/server/models/Vocabulary.ts index c5458ed..5fff329 100644 --- a/server/models/Vocabulary.ts +++ b/server/models/Vocabulary.ts @@ -41,6 +41,7 @@ export interface IVocabularyTestRecord extends Document { user: mongoose.Types.ObjectId; wordSet: mongoose.Types.ObjectId; testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; + attempt?: mongoose.Types.ObjectId; stats: { totalWords: number; correctWords: number; @@ -49,9 +50,29 @@ export interface IVocabularyTestRecord extends Document { endTime: Date; duration: number; }; + results?: Array<{ + word: mongoose.Types.ObjectId; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; + }>; createdAt: Date; } +export interface IVocabularyTestAttempt extends Document { + user: mongoose.Types.ObjectId; + wordSet: mongoose.Types.ObjectId; + testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; + questionWordIds: mongoose.Types.ObjectId[]; + questionTokens: string[]; + issuedAt: Date; + expiresAt: Date; + submittedAt?: Date; + status: 'active' | 'submitted' | 'expired'; + createdAt: Date; + updatedAt: Date; +} + // 单词模式 const WordSchema = new Schema({ word: { type: String, required: true, trim: true }, @@ -83,7 +104,8 @@ const ModeStatsSchema = new Schema({ totalWrong: { type: Number, default: 0 }, mastered: { type: Boolean, default: false }, inWrongBook: { type: Boolean, default: false }, - lastTestedAt: Date + lastTestedAt: Date, + lastMasteredAt: Date }, { _id: false }); // 简化后的主记录模式 @@ -107,6 +129,7 @@ const WordRecordSchema = new Schema({ const VocabularyTestRecordSchema = new Schema({ user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true }, + attempt: { type: Schema.Types.ObjectId, ref: 'VocabularyTestAttempt' }, testType: { type: String, enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'], @@ -120,18 +143,51 @@ const VocabularyTestRecordSchema = new Schema({ endTime: { type: Date, required: true }, duration: { type: Number, required: true } }, + results: [{ + word: { type: Schema.Types.ObjectId, ref: 'Word', required: true }, + userAnswer: { type: String, default: '' }, + correctAnswer: { type: String, required: true }, + isCorrect: { type: Boolean, required: true } + }], createdAt: { type: Date, default: Date.now } }); +// 单词测试会话模式,用于防止直接伪造测试提交 +const VocabularyTestAttemptSchema = new Schema({ + user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true }, + testType: { + type: String, + enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'], + required: true + }, + questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }], + questionTokens: [{ type: String, required: true }], + issuedAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true }, + submittedAt: Date, + status: { + type: String, + enum: ['active', 'submitted', 'expired'], + default: 'active', + required: true + } +}, { timestamps: true }); + +VocabularyTestAttemptSchema.index({ user: 1, status: 1, expiresAt: 1 }); +VocabularyTestAttemptSchema.index({ expiresAt: 1 }); + // 创建和导出模型 export const Word = mongoose.model('Word', WordSchema); export const WordSet = mongoose.model('WordSet', WordSetSchema); export const WordRecord = mongoose.model('WordRecord', WordRecordSchema); export const VocabularyTestRecord = mongoose.model('VocabularyTestRecord', VocabularyTestRecordSchema); +export const VocabularyTestAttempt = mongoose.model('VocabularyTestAttempt', VocabularyTestAttemptSchema); export default { Word, WordSet, WordRecord, - VocabularyTestRecord -}; \ No newline at end of file + VocabularyTestRecord, + VocabularyTestAttempt +}; diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index 2d29a3c..f4e76b8 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -3,14 +3,299 @@ 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 } from '../models/Vocabulary'; +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) { @@ -315,177 +600,288 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => { } }); +// 创建测试会话:服务端固定题目顺序并签发每题防伪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 { wordId, isCorrect, testType } = req.body; - const userId = req.user._id; + const { attemptId, answers } = req.body; + const userId = req.user?._id; - // 检查单词是否存在 - const word = await Word.findById(wordId); - if (!word) { - return res.status(404).json({ message: '未找到单词' }); + if (!userId) { + return res.status(401).json({ message: '用户信息无效' }); } - // 嵌套字段名映射 - const modeMap = { - 'chinese-to-english': 'chineseToEnglish', - 'audio-to-english': 'audioToEnglish', - 'multiple-choice': 'multipleChoice' - }; - const modeKey = modeMap[testType]; - if (!modeKey) { - return res.status(400).json({ message: '未知的测试类型' }); + if (!mongoose.isValidObjectId(attemptId)) { + return res.status(400).json({ message: '测试会话ID无效' }); } - // 先查当前记录 - let record = await WordRecord.findOne({ user: userId, word: wordId }); - let modeObj = record ? (record[modeKey] || {}) : {}; - - // 更新 streak、totalCorrect、totalWrong - 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 (!Array.isArray(answers) || answers.length === 0) { + return res.status(400).json({ message: '测试答案不能为空' }); } - // 判定是否进入错词本 - if (!modeObj.mastered && modeObj.totalWrong >= 5) { - modeObj.inWrongBook = true; + const attempt = await VocabularyTestAttempt.findOne({ + _id: attemptId, + user: userId + }); + + if (!attempt) { + return res.status(404).json({ message: '未找到测试会话' }); } - // 构造更新对象 - const updateObj = {}; - updateObj[modeKey] = modeObj; + const attemptAny = attempt as any; + const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt); + const expiresAt = new Date(attemptAny.expiresAt); + const now = Date.now(); - record = await WordRecord.findOneAndUpdate( - { user: userId, word: wordId }, - { $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } }, - { upsert: true, new: true } + 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 } ); - // 检查三种模式是否都已掌握 - const allModes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice']; - const isFullyMastered = allModes.every(m => record[m]?.mastered); - let lastMasteredAt: Date | null = null; - if (isFullyMastered) { - // 取三种模式中最近一次掌握的时间 - lastMasteredAt = allModes.reduce((latest: Date | null, m) => { - const t = record[m]?.lastMasteredAt; - if (!latest && t) return t; - if (t instanceof Date && latest instanceof Date && t > latest) { - return t; - } - return latest; - }, null); - - // 取三种模式中最近一次测试的时间,如果比掌握时间更新则使用测试时间 - const lastTestedAt = allModes.reduce((latest: Date | null, m) => { - const t = record[m]?.lastTestedAt; - if (!latest && t) return t; - if (t instanceof Date && latest instanceof Date && t > latest) { - return t; - } - return latest; - }, null); - - // 使用最新的日期(掌握时间或测试时间) - if (lastTestedAt && (!lastMasteredAt || lastTestedAt > lastMasteredAt)) { - lastMasteredAt = lastTestedAt; - } - - // 更新isFullyMastered和lastFullyMasteredAt字段 - if (!record.isFullyMastered || !record.lastFullyMasteredAt || - (lastMasteredAt && record.lastFullyMasteredAt < lastMasteredAt)) { - record = await WordRecord.findOneAndUpdate( - { _id: record._id }, - { - $set: { - isFullyMastered: true, - lastFullyMasteredAt: lastMasteredAt - } - }, - { new: true } - ); - } - } else if (record.isFullyMastered) { - // 如果之前标记为完全掌握,但现在不是,更新状态 - record = await WordRecord.findOneAndUpdate( - { _id: record._id }, - { $set: { isFullyMastered: false } }, - { new: true } - ); + if (!claimedAttempt) { + return res.status(409).json({ message: '测试会话已提交或已失效' }); } - // 返回当前单词的所有模式掌握状态和是否在错词本 - const masteryStatus = {}; - allModes.forEach(m => { - masteryStatus[m] = { - mastered: record[m]?.mastered || false, - streak: record[m]?.streak || 0, - totalCorrect: record[m]?.totalCorrect || 0, - totalWrong: record[m]?.totalWrong || 0, - inWrongBook: record[m]?.inWrongBook || false, - lastMasteredAt: record[m]?.lastMasteredAt || null - }; + 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: '记录已保存', - masteryStatus, - isFullyMastered: record.isFullyMastered, - lastMasteredAt: record.lastFullyMasteredAt, - inWrongBook: allModes.some(m => record[m]?.inWrongBook) + message: '测试记录已保存', + attemptId: attempt._id.toString(), + stats: verifiedStats, + results: evaluatedResults, + submittedAt: new Date(lastSubmittedAtMs) }); - } catch (error) { - console.error('保存单词学习记录失败:', error); - res.status(500).json({ message: '保存单词学习记录失败' }); - } -}); - -// 保存测试记录 -router.post('/test-record', authMiddleware, async (req, res) => { - try { - const { wordSetId, testType, stats } = req.body; - - // 检查单词集是否存在并属于当前用户 - const wordSet = await WordSet.findOne({ - _id: wordSetId - }); - - if (!wordSet) { - return res.status(404).json({ message: '未找到单词集' }); - } - - // 创建测试记录 - await VocabularyTestRecord.create({ - user: req.user._id, - wordSet: wordSetId, - testType, - stats: { - totalWords: stats.totalWords, - correctWords: stats.correctWords, - accuracy: stats.accuracy, - startTime: new Date(stats.startTime), - endTime: new Date(stats.endTime), - duration: stats.duration - } - }); - - res.status(201).json({ message: '测试记录已保存' }); } catch (error) { console.error('保存测试记录失败:', error); res.status(500).json({ message: '保存测试记录失败' }); @@ -696,4 +1092,4 @@ router.put('/words', authMiddleware, async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/src/components/VocabularyStudy.tsx b/src/components/VocabularyStudy.tsx index 15782d7..1b7bddc 100644 --- a/src/components/VocabularyStudy.tsx +++ b/src/components/VocabularyStudy.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Select, Table, Tag, InputNumber, Space } from 'antd'; import { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons'; +import CryptoJS from 'crypto-js'; import { useNavigate } from 'react-router-dom'; import { api, ApiError, authEvents } from '../api/apiClient'; import { API_PATHS } from '../config'; @@ -8,6 +9,7 @@ import type { TabsProps } from 'antd'; import type { RadioChangeEvent } from 'antd/lib/radio'; interface Word { + _id?: string; id: string; word: string; translation: string; @@ -56,11 +58,57 @@ interface LeaderboardItem { rank: number; } +interface TestResult { + questionToken?: string; + wordId: string; + word: string; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; +} + +interface PendingAnswer { + questionToken: string; + userAnswer: string; + submittedAt: number; + answerProof: string; +} + +interface TestQuestion { + questionToken: string; + wordId: string; + word?: string; + translation?: string; + pronunciation?: string; + options?: string[]; +} + +interface SavedVocabularyTestRecordResponse { + message: string; + stats: { + totalWords: number; + correctWords: number; + accuracy: number; + startTime: string | Date; + endTime: string | Date; + duration: number; + }; + results: TestResult[]; +} + +interface TestAttemptResponse { + attemptId: string; + testType: string; + expiresAt: string | Date; + questions: TestQuestion[]; +} + const VocabularyStudy: React.FC = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); const [wordSets, setWordSets] = useState([]); const [selectedWordSet, setSelectedWordSet] = useState(''); + const [loadedWordSetId, setLoadedWordSetId] = useState(''); const [currentWords, setCurrentWords] = useState([]); const [studyStats, setStudyStats] = useState({ totalWords: 0, @@ -76,12 +124,10 @@ const VocabularyStudy: React.FC = () => { const [showAnswer, setShowAnswer] = useState(false); const [testStarted, setTestStarted] = useState(false); const [testFinished, setTestFinished] = useState(false); - const [testResults, setTestResults] = useState<{ - word: string; - userAnswer: string; - correctAnswer: string; - isCorrect: boolean; - }[]>([]); + const [testResults, setTestResults] = useState([]); + const [testAttemptId, setTestAttemptId] = useState(''); + const [pendingAnswers, setPendingAnswers] = useState([]); + const [testAttemptLoading, setTestAttemptLoading] = useState(false); const [options, setOptions] = useState([]); const [isModalVisible, setIsModalVisible] = useState(false); const timeOffsetRef = useRef(0); @@ -96,6 +142,7 @@ const VocabularyStudy: React.FC = () => { // 使用ref存储当前选中ID,这样可以立即访问 const currentWordSetIdRef = useRef(''); + const answerSubmittingRef = useRef(false); // 测试输入框ref const inputRef = useRef(null); @@ -103,7 +150,7 @@ const VocabularyStudy: React.FC = () => { // 新增 state const [studyWords, setStudyWords] = useState([]); const [studyIndex, setStudyIndex] = useState(0); - const [testWords, setTestWords] = useState([]); + const [testWords, setTestWords] = useState([]); const [testIndex, setTestIndex] = useState(0); // 添加声音相关的状态 @@ -192,9 +239,14 @@ const VocabularyStudy: React.FC = () => { console.log('获取到的学习单词:', response); if (response && response.length > 0) { - const shuffledWords = shuffleArray(response); + const normalizedWords = response.map(item => ({ + ...item, + id: item.id || item._id || '' + })); + const shuffledWords = shuffleArray(normalizedWords); setStudyWords(shuffledWords); setStudyIndex(0); + setLoadedWordSetId(effectiveId); setActiveTab('study'); message.success(`成功加载 ${shuffledWords.length} 个单词`); } else { @@ -365,54 +417,61 @@ const VocabularyStudy: React.FC = () => { } }; - // 开始测试 - const startTest = () => { + // 开始测试会话 + const startTest = async () => { if (studyWords.length === 0) { message.error('请先加载单词'); return; } - const shuffled = shuffleArray([...studyWords]); - setTestWords(shuffled); - setTestIndex(0); - setTestStarted(true); - setTestFinished(false); - setUserAnswer(''); - setShowAnswer(false); - setTestResults([]); - // 重置统计信息 - setStudyStats({ - totalWords: 0, - correctWords: 0, - accuracy: 0, - startTime: new Date(), - duration: 0 - }); - setActiveTab('test'); - }; - // 生成多选题选项 - const generateMultipleChoiceOptions = (index: number, wordsArr?: Word[]) => { - const arr = wordsArr || testWords; - const correctTranslation = arr[index].translation; - let availableOptions = arr - .filter(w => w.translation !== correctTranslation) - .map(w => w.translation); - - // 如果可用选项太少,添加一些假选项 - if (availableOptions.length < 3) { - const fakeOptions = [ - '假选项1', '假选项2', '假选项3', '假选项4', '假选项5' - ].filter(opt => opt !== correctTranslation); - availableOptions = [...availableOptions, ...fakeOptions]; + try { + setTestAttemptLoading(true); + setTestAttemptId(''); + setPendingAnswers([]); + setTestResults([]); + const wordIds = studyWords.map(getWordId).filter(Boolean); + if (wordIds.length === 0) { + message.error('单词数据不完整,请重新加载后再试'); + return; + } + const response = await api.post(API_PATHS.VOCABULARY.TEST_ATTEMPT, { + wordSetId: loadedWordSetId || selectedWordSet || currentWordSetIdRef.current, + testType, + wordIds + }); + + const questions = response.questions.map(question => ({ + ...question, + options: question.options || [] + })); + + setTestAttemptId(response.attemptId); + setTestWords(questions); + setTestIndex(0); + answerSubmittingRef.current = false; + setTestStarted(true); + setTestFinished(false); + setUserAnswer(''); + setShowAnswer(false); + setTestResults([]); + setPendingAnswers([]); + setOptions(questions[0]?.options || []); + setStudyStats({ + totalWords: 0, + correctWords: 0, + accuracy: 0, + startTime: new Date(), + duration: 0 + }); + } catch (error) { + if (error instanceof ApiError) { + message.error(`创建测试会话失败: ${error.message}`); + } else { + message.error('创建测试会话失败'); + } + } finally { + setTestAttemptLoading(false); } - - // 打乱并选择3个错误选项 - availableOptions = shuffleArray(availableOptions); - const incorrectOptions = availableOptions.slice(0, 3); - - // 合并正确选项和错误选项,然后打乱 - const allOptions = shuffleArray([correctTranslation, ...incorrectOptions]); - setOptions(allOptions); }; // 数组随机排序 @@ -425,6 +484,17 @@ const VocabularyStudy: React.FC = () => { return newArray; }; + const getWordId = (word: Word): string => word._id || word.id; + + const buildAnswerProof = ( + attemptId: string, + questionToken: string, + userAnswer: string, + submittedAt: number + ): string => { + return CryptoJS.SHA256(`${attemptId}:${questionToken}:${submittedAt}:${userAnswer}`).toString(); + }; + function normalizeAnswer(str: string): string { return str .replace(/(/g, '(') @@ -438,130 +508,92 @@ const VocabularyStudy: React.FC = () => { // 提交答案 const submitAnswer = async () => { - if (!testStarted || testWords.length === 0) return; + if (!testStarted || testWords.length === 0 || !testAttemptId || answerSubmittingRef.current) return; + answerSubmittingRef.current = true; - const currentWord = testWords[testIndex]; - let isCorrect = false; - let correctAnswer = ''; - - switch (testType) { - case 'chinese-to-english': - case 'audio-to-english': - correctAnswer = currentWord.word; - const normUser = normalizeAnswer(userAnswer); - const normCorrect = normalizeAnswer(correctAnswer); - isCorrect = normUser === normCorrect; - break; - case 'multiple-choice': - correctAnswer = currentWord.translation; - isCorrect = userAnswer === correctAnswer; - break; + const currentQuestion = testWords[testIndex]; + if (!currentQuestion) { + answerSubmittingRef.current = false; + return; } + const submittedAt = Date.now(); + const normalizedUserAnswer = userAnswer.trim(); + const answerProof = buildAnswerProof( + testAttemptId, + currentQuestion.questionToken, + normalizedUserAnswer, + submittedAt + ); - // 更新测试结果数组 - const updatedResults = [ - ...testResults, + const updatedPendingAnswers = [ + ...pendingAnswers, { - word: currentWord.word, - userAnswer, - correctAnswer, - isCorrect + questionToken: currentQuestion.questionToken, + userAnswer: normalizedUserAnswer, + submittedAt, + answerProof } ]; - setTestResults(updatedResults); - - // 更新学习统计信息 - const correctCount = updatedResults.filter(r => r.isCorrect).length; - const totalCount = updatedResults.length; - - setStudyStats(prev => ({ - ...prev, - totalWords: totalCount, - correctWords: correctCount, - accuracy: (correctCount / totalCount) * 100 - })); - - // 记录学习记录 - try { - await api.post(API_PATHS.VOCABULARY.WORD_RECORD, { - wordId: currentWord._id || currentWord.id, - isCorrect, - testType - }); - } catch (error) { - console.error('记录单词学习结果失败', error); - } - - if (isCorrect) { - message.success('正确!'); - } else { - message.error(`错误! 正确答案是: ${correctAnswer}`); - } + setPendingAnswers(updatedPendingAnswers); setShowAnswer(true); + setUserAnswer(''); setTimeout(() => { if (testIndex < testWords.length - 1) { setTestIndex(testIndex + 1); - setUserAnswer(''); setShowAnswer(false); - // 多选题生成选项 - if (testType === 'multiple-choice') { - generateMultipleChoiceOptions(testIndex + 1); - } - // 听力自动播放 + answerSubmittingRef.current = false; if (testType === 'audio-to-english') { setTimeout(() => { - playWordSound(testWords[testIndex + 1].word); + playWordSound(testWords[testIndex + 1]?.word || ''); }, 500); } + if (testType === 'multiple-choice') { + setOptions(testWords[testIndex + 1]?.options || []); + } } else { - // 最后一题,使用本地更新的结果,避免异步状态更新问题 - finishTestWithResults(updatedResults); + answerSubmittingRef.current = false; + finishTestWithResults(updatedPendingAnswers); } }, 1500); }; // 使用指定结果完成测试 - const finishTestWithResults = async (finalResults: any[]) => { + const finishTestWithResults = async (finalAnswers: PendingAnswer[]) => { try { setTestFinished(true); - - // 获取服务器时间 - const { serverTime } = await api.get<{ serverTime: number }>(API_PATHS.SYSTEM.SERVER_TIME); - - const correctCount = finalResults.filter(r => r.isCorrect).length; - const totalCount = finalResults.length; // 添加调试信息 console.log('测试完成状态(带结果):', { - finalResultsLength: finalResults.length, + finalResultsLength: finalAnswers.length, testWordsLength: testWords.length, - correctCount, - totalCount, + attemptId: testAttemptId, currentStudyStats: studyStats }); - - // 直接使用传入的测试结果计算 - const updatedStats = { - totalWords: totalCount, - correctWords: correctCount, - accuracy: totalCount > 0 ? (correctCount / totalCount) * 100 : 0, - startTime: studyStats.startTime, - endTime: new Date(serverTime), - duration: (serverTime - studyStats.startTime.getTime()) / 1000 - }; - // 更新统计信息 - setStudyStats(updatedStats); - - // 提交测试记录 - 使用计算好的最新数据 - await api.post(API_PATHS.VOCABULARY.TEST_RECORD, { - wordSetId: selectedWordSet, - testType, - stats: updatedStats, - results: finalResults // 发送详细的测试结果,包括每道题的回答情况 + // 提交测试记录,后端会用数据库答案重新判分 + const savedRecord = await api.post(API_PATHS.VOCABULARY.TEST_RECORD, { + attemptId: testAttemptId, + answers: finalAnswers.map(answer => ({ + questionToken: answer.questionToken, + userAnswer: answer.userAnswer, + submittedAt: answer.submittedAt, + answerProof: answer.answerProof + })) }); + + setStudyStats({ + totalWords: savedRecord.stats.totalWords, + correctWords: savedRecord.stats.correctWords, + accuracy: savedRecord.stats.accuracy, + startTime: new Date(savedRecord.stats.startTime), + endTime: new Date(savedRecord.stats.endTime), + duration: savedRecord.stats.duration + }); + setTestResults(savedRecord.results); + setPendingAnswers([]); + setTestAttemptId(''); message.success('测试完成,记录已保存'); setIsModalVisible(true); @@ -577,19 +609,22 @@ const VocabularyStudy: React.FC = () => { // 完成测试 - 兼容原来的调用方式 const finishTest = async () => { // 使用当前的测试结果完成测试 - await finishTestWithResults([...testResults]); + await finishTestWithResults([...pendingAnswers]); }; // 重新开始测试 const restartTest = () => { - const shuffled = shuffleArray([...testWords]); - setTestWords(shuffled); + setTestWords([]); setTestIndex(0); + setTestAttemptId(''); setTestStarted(false); // 回到模式选择界面 setTestFinished(false); setUserAnswer(''); setShowAnswer(false); setTestResults([]); + setPendingAnswers([]); + setOptions([]); + answerSubmittingRef.current = false; // 重置统计信息 setStudyStats({ totalWords: 0, @@ -704,7 +739,7 @@ const VocabularyStudy: React.FC = () => { } }, [testIndex, testStarted, testType, testFinished]); - // 切换题目时自动生成选项 + // 切换题目时同步多选题选项 useEffect(() => { if ( testStarted && @@ -712,7 +747,7 @@ const VocabularyStudy: React.FC = () => { testType === 'multiple-choice' && testWords.length > 0 ) { - generateMultipleChoiceOptions(testIndex); + setOptions(testWords[testIndex]?.options || []); } }, [testIndex, testType, testStarted, testFinished, testWords]); @@ -729,6 +764,14 @@ const VocabularyStudy: React.FC = () => { } }, [testStarted, testType, testWords, testFinished, testIndex]); + const handleTabChange = (nextTab: string) => { + if (testStarted && !testFinished && activeTab === 'test' && nextTab !== 'test') { + message.warning('测试进行中,请完成本次测试后再切换页面'); + return; + } + setActiveTab(nextTab); + }; + // Tab 切换 const items: TabsProps['items'] = [ { @@ -976,30 +1019,9 @@ const VocabularyStudy: React.FC = () => { - + {/* 添加声音设置弹窗 */} {renderVoiceSettings()} @@ -1570,4 +1589,4 @@ const VocabularyStudy: React.FC = () => { ); }; -export default VocabularyStudy; \ No newline at end of file +export default VocabularyStudy; diff --git a/src/config.ts b/src/config.ts index e22e0bf..7902a7e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,7 @@ export const API_PATHS = { WORD_SETS: '/vocabulary/word-sets', STUDY_WORDS: '/vocabulary/study-words', UPLOAD: '/vocabulary/upload', + TEST_ATTEMPT: '/vocabulary/test-attempt', WORD_RECORD: '/vocabulary/word-record', TEST_RECORD: '/vocabulary/test-record', STUDY_RECORDS: '/vocabulary/test-records', @@ -117,4 +118,4 @@ export const config = { } as const; // 默认导出 -export default config; \ No newline at end of file +export default config;