diff --git a/server/models/Vocabulary.ts b/server/models/Vocabulary.ts index 5fff329..cf7bcbc 100644 --- a/server/models/Vocabulary.ts +++ b/server/models/Vocabulary.ts @@ -65,6 +65,31 @@ export interface IVocabularyTestAttempt extends Document { testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; questionWordIds: mongoose.Types.ObjectId[]; questionTokens: string[]; + optionTokens?: string[][]; + optionTexts?: string[][]; + answeredQuestionTokens?: string[]; + answerDurations?: number[]; + answers?: Array<{ + questionToken: string; + word: mongoose.Types.ObjectId; + userAnswer: string; + correctAnswer: string; + isCorrect: boolean; + submittedAt: Date; + duration: number; + riskFlags: string[]; + interactionSummary?: { + keyCount: number; + inputCount: number; + pasteCount: number; + focusCount: number; + blurCount: number; + pointerCount: number; + firstEventOffset: number; + lastEventOffset: number; + }; + }>; + riskFlags?: string[]; issuedAt: Date; expiresAt: Date; submittedAt?: Date; @@ -163,6 +188,31 @@ const VocabularyTestAttemptSchema = new Schema({ }, questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }], questionTokens: [{ type: String, required: true }], + optionTokens: [[{ type: String }]], + optionTexts: [[{ type: String }]], + answeredQuestionTokens: [{ type: String }], + answerDurations: [{ type: Number }], + answers: [{ + questionToken: { type: String, required: true }, + word: { type: Schema.Types.ObjectId, ref: 'Word', required: true }, + userAnswer: { type: String, default: '' }, + correctAnswer: { type: String, required: true }, + isCorrect: { type: Boolean, required: true }, + submittedAt: { type: Date, required: true }, + duration: { type: Number, required: true }, + riskFlags: [{ type: String }], + interactionSummary: { + keyCount: { type: Number, default: 0 }, + inputCount: { type: Number, default: 0 }, + pasteCount: { type: Number, default: 0 }, + focusCount: { type: Number, default: 0 }, + blurCount: { type: Number, default: 0 }, + pointerCount: { type: Number, default: 0 }, + firstEventOffset: { type: Number, default: 0 }, + lastEventOffset: { type: Number, default: 0 } + } + }], + riskFlags: [{ type: String }], issuedAt: { type: Date, required: true }, expiresAt: { type: Date, required: true }, submittedAt: Date, diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index f8ba9f4..0556980 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -8,7 +8,6 @@ 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(); @@ -24,13 +23,38 @@ interface EvaluatedVocabularyAnswer { isCorrect: boolean; } +interface VocabularyOptionPayload { + token: string; + text: string; +} + interface VocabularyAttemptQuestion { questionToken: string; wordId: string; word?: string; translation?: string; pronunciation?: string; - options?: string[]; + options?: Array; + optionTokenRequest?: boolean; +} + +interface InteractionEventPayload { + type?: string; + key?: string; + inputType?: string; + valueLength?: number; + 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 = { @@ -46,7 +70,12 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [ ]; const ATTEMPT_TTL_MS = 30 * 60 * 1000; -const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 0.4); +const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 1); +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'; @@ -67,6 +96,28 @@ const shuffle = (array: T[]): T[] => { 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, @@ -82,6 +133,20 @@ const signQuestionToken = ( 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 || ''); @@ -131,7 +196,8 @@ const buildQuestionPayload = ( word: any, testType: VocabularyTestType, questionToken: string, - optionsPool: any[] + optionsPool: any[], + suppliedOptions?: Array ): VocabularyAttemptQuestion => { const base = { questionToken, @@ -153,10 +219,13 @@ const buildQuestionPayload = ( }; } + const options = suppliedOptions || buildMultipleChoiceOptions(word, optionsPool); + return { ...base, word: word.word, - options: buildMultipleChoiceOptions(word, optionsPool) + options, + optionTokenRequest: options.some(option => typeof option === 'string') }; }; @@ -189,6 +258,67 @@ const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unk }; }; +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 analyzeAnswerRisk = ( + durationSeconds: number, + summary: InteractionSummary, + testType: VocabularyTestType +): string[] => { + const flags: string[] = []; + + if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast'); + 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 (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 => { @@ -611,6 +741,10 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { return res.status(401).json({ message: '用户信息无效' }); } + if (!isAllowedVocabularyOrigin(req)) { + return res.status(403).json({ message: '测试来源异常,请从官网页面开始测试' }); + } + if (!mongoose.isValidObjectId(wordSetId)) { return res.status(400).json({ message: '单词集ID无效' }); } @@ -680,10 +814,19 @@ router.post('/test-attempt', authMiddleware, async (req, res) => { ); 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) + buildQuestionPayload(word, testType, questionTokens[index], allWords, testType === 'multiple-choice' + ? attempt.optionTexts?.[index] || [] + : undefined) ); res.status(201).json({ @@ -708,22 +851,22 @@ router.post('/word-record', (req, res, next) => { }); }); -// 保存测试记录:只能通过服务端签发的 attempt 提交 -router.post('/test-record', authMiddleware, async (req, res) => { +// 单题提交:服务端逐题判分并累计结果 +router.post('/test-answer', authMiddleware, async (req, res) => { try { - const { attemptId, answers } = req.body; + const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = 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 (!isAllowedVocabularyOrigin(req)) { + return res.status(403).json({ message: '测试来源异常,请从官网页面提交答案' }); } - if (!Array.isArray(answers) || answers.length === 0) { - return res.status(400).json({ message: '测试答案不能为空' }); + if (!mongoose.isValidObjectId(attemptId)) { + return res.status(400).json({ message: '测试会话ID无效' }); } const attempt = await VocabularyTestAttempt.findOne({ @@ -750,96 +893,243 @@ router.post('/test-record', authMiddleware, async (req, res) => { return res.status(409).json({ message: '测试会话已提交或已失效' }); } - if (answers.length !== attemptAny.questionTokens.length) { - return res.status(400).json({ message: '答案数量与测试题目不一致' }); + const token = String(questionToken ?? ''); + const tokenIndex = attemptAny.questionTokens.indexOf(token); + if (!token || tokenIndex < 0) { + return res.status(400).json({ message: '测试答案的题目标识无效' }); } - const tokenIndexMap = new Map(); - attemptAny.questionTokens.forEach((token: string, index: number) => { - tokenIndexMap.set(token, index); + const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) + ? attemptAny.answeredQuestionTokens + : []; + if (answeredTokens.includes(token)) { + return res.status(409).json({ message: '该题已提交,请继续下一题' }); + } + + if (tokenIndex !== answeredTokens.length) { + return res.status(400).json({ message: '请按题目顺序提交答案' }); + } + + const submittedAtMs = Number.parseInt(String(submittedAt), 10); + 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 wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]); + if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) { + return res.status(400).json({ 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: '选择项凭证无效' }); + } + 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: '答案校验失败' }); + } + + 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 = analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType); + + const word = await Word.findOne({ + _id: wordId, + wordSet: attemptAny.wordSet + }); + if (!word) { + return res.status(400).json({ 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 + }, + riskFlags + }); + } catch (error) { + console.error('提交单题答案失败:', error); + res.status(500).json({ 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: '用户信息无效' }); + } + + if (!isAllowedVocabularyOrigin(req)) { + return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' }); + } + + if (!mongoose.isValidObjectId(attemptId)) { + return res.status(400).json({ message: '测试会话ID无效' }); + } + + const attempt = await VocabularyTestAttempt.findOne({ + _id: attemptId, + user: userId }); - 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 }); + if (!attempt) { + return res.status(404).json({ message: '未找到测试会话' }); } - const lastSubmittedAtMs = Math.max(...submittedAnswers.map(item => item.submittedAt)); + const attemptAny = attempt as any; + if (attemptAny.status !== 'active') { + return res.status(409).json({ message: '测试会话已提交或已失效' }); + } + + if (attemptAny.testType !== 'multiple-choice') { + return res.status(400).json({ message: '当前题型不需要选项凭证' }); + } + + const token = String(questionToken ?? ''); + const tokenIndex = attemptAny.questionTokens.indexOf(token); + if (!token || tokenIndex < 0) { + return res.status(400).json({ message: '测试题目标识无效' }); + } + + const optionTexts = attemptAny.optionTexts?.[tokenIndex] || []; + const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? '')); + if (selectedIndex < 0) { + return res.status(400).json({ message: '选项无效' }); + } + + const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) + ? attemptAny.answeredQuestionTokens + : []; + if (tokenIndex !== answeredTokens.length) { + return res.status(400).json({ message: '请按题目顺序选择答案' }); + } + + const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex]; + if (!optionToken) { + return res.status(400).json({ message: '选项凭证不存在,请重新开始测试' }); + } + + res.json({ + questionToken: token, + optionToken + }); + } catch (error) { + console.error('获取选择项凭证失败:', error); + res.status(500).json({ 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: '用户信息无效' }); + } + + if (!isAllowedVocabularyOrigin(req)) { + return res.status(403).json({ message: '测试来源异常,请从官网页面完成测试' }); + } + + if (!mongoose.isValidObjectId(attemptId)) { + return res.status(400).json({ message: '测试会话ID无效' }); + } + + 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: '测试会话已提交或已失效' }); + } + + const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : []; + if (submittedAnswers.length !== attemptAny.questionTokens.length) { + return res.status(400).json({ message: '请完成所有题目后再保存测试记录' }); + } + + const lastSubmittedAtMs = Math.max(...submittedAnswers.map((item: any) => new Date(item.submittedAt).getTime())); const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000); - const minimumSeconds = Math.max(2, answers.length * MIN_SECONDS_PER_QUESTION); + const minimumSeconds = Math.max(2, attemptAny.questionTokens.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 batchRiskFlags = analyzeBatchRisk(attemptAny.answerDurations || []); + attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags])); + const shouldInvalidateBatch = batchRiskFlags.length > 0 || + ['too_fast', 'too_slow'].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) } }, + { $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } }, { new: true } ); @@ -847,12 +1137,32 @@ router.post('/test-record', authMiddleware, async (req, res) => { return res.status(409).json({ message: '测试会话已提交或已失效' }); } - for (const result of evaluatedResults) { - await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect); + 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 = evaluatedResults.filter(result => result.isCorrect).length; + const correctWords = shouldInvalidateBatch ? 0 : evaluatedResults.filter(result => result.isCorrect).length; const verifiedStats = { totalWords, correctWords, @@ -868,20 +1178,28 @@ router.post('/test-record', authMiddleware, async (req, res) => { attempt: attempt._id, testType: attemptAny.testType, stats: verifiedStats, - results: evaluatedResults.map(result => ({ - word: result.wordId, + results: submittedAnswers.map((result: any) => ({ + word: result.word, userAnswer: result.userAnswer, correctAnswer: result.correctAnswer, - isCorrect: result.isCorrect + isCorrect: shouldInvalidateBatch ? false : result.isCorrect })) }); res.status(201).json({ - message: '测试记录已保存', + message: shouldInvalidateBatch ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存', attemptId: attempt._id.toString(), stats: verifiedStats, - results: evaluatedResults, - submittedAt: new Date(lastSubmittedAtMs) + 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), + riskFlags: attemptAny.riskFlags || [], + invalidated: shouldInvalidateBatch }); } catch (error) { console.error('保存测试记录失败:', error); diff --git a/src/components/VocabularyStudy.tsx b/src/components/VocabularyStudy.tsx index bd820da..7c05ee6 100644 --- a/src/components/VocabularyStudy.tsx +++ b/src/components/VocabularyStudy.tsx @@ -1,12 +1,11 @@ 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 { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, 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'; import type { TabsProps } from 'antd'; -import type { RadioChangeEvent } from 'antd/lib/radio'; interface Word { _id?: string; @@ -74,13 +73,30 @@ interface PendingAnswer { answerProof: string; } +interface InteractionEventPayload { + type: string; + ts: number; + key?: string; + inputType?: string; + valueLength?: number; +} + +interface NativeInputLikeEvent extends Event { + inputType?: string; +} + +interface TestOption { + text: string; + token: string; +} + interface TestQuestion { questionToken: string; wordId: string; word?: string; translation?: string; pronunciation?: string; - options?: string[]; + options?: Array; } interface SavedVocabularyTestRecordResponse { @@ -94,6 +110,8 @@ interface SavedVocabularyTestRecordResponse { duration: number; }; results: TestResult[]; + riskFlags?: string[]; + invalidated?: boolean; } interface TestAttemptResponse { @@ -103,6 +121,22 @@ interface TestAttemptResponse { questions: TestQuestion[]; } +interface SubmitAnswerResponse { + message: string; + result: TestResult; + progress: { + answered: number; + total: number; + finished: boolean; + }; + riskFlags?: string[]; +} + +interface OptionTokenResponse { + questionToken: string; + optionToken: string; +} + const ANSWER_PROOF_SALT = 'd1ktsalt'; const VocabularyStudy: React.FC = () => { @@ -130,7 +164,8 @@ const VocabularyStudy: React.FC = () => { const [testAttemptId, setTestAttemptId] = useState(''); const [pendingAnswers, setPendingAnswers] = useState([]); const [testAttemptLoading, setTestAttemptLoading] = useState(false); - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); + const [testRiskFlags, setTestRiskFlags] = useState([]); const [isModalVisible, setIsModalVisible] = useState(false); const timeOffsetRef = useRef(0); const [timer, setTimer] = useState(null); @@ -145,6 +180,8 @@ const VocabularyStudy: React.FC = () => { // 使用ref存储当前选中ID,这样可以立即访问 const currentWordSetIdRef = useRef(''); const answerSubmittingRef = useRef(false); + const interactionsRef = useRef([]); + const questionShownAtRef = useRef(0); // 测试输入框ref const inputRef = useRef(null); @@ -457,7 +494,9 @@ const VocabularyStudy: React.FC = () => { setShowAnswer(false); setTestResults([]); setPendingAnswers([]); - setOptions(questions[0]?.options || []); + setOptions(normalizeOptions(questions[0])); + setTestRiskFlags([]); + resetInteractionTrace(); setIsModalVisible(false); setStudyStats({ totalWords: 0, @@ -489,6 +528,72 @@ const VocabularyStudy: React.FC = () => { const getWordId = (word: Word): string => word._id || word.id; + const normalizeOptions = (question?: TestQuestion): TestOption[] => { + if (!question?.options) return []; + return question.options.map(option => { + if (typeof option === 'string') { + return { text: option, token: '' }; + } + return option; + }); + }; + + const resetInteractionTrace = () => { + const now = Date.now(); + interactionsRef.current = [{ + type: 'question-shown', + ts: now + }]; + questionShownAtRef.current = now; + }; + + const recordInteraction = (event: InteractionEventPayload) => { + if (!testStarted || testFinished || showAnswer) return; + interactionsRef.current = [ + ...interactionsRef.current, + { + ...event, + ts: Date.now() + } + ].slice(-300); + }; + + const handleMultipleChoiceChange = async (option: TestOption) => { + const currentQuestion = testWords[testIndex]; + if (!currentQuestion || !testAttemptId || showAnswer) return; + + recordInteraction({ + type: 'change', + valueLength: option.text.length + }); + + if (option.token) { + setUserAnswer(option.token); + return; + } + + try { + const response = await api.post(API_PATHS.VOCABULARY.TEST_OPTION_TOKEN, { + attemptId: testAttemptId, + questionToken: currentQuestion.questionToken, + optionText: option.text + }); + + setOptions(prev => prev.map(item => + item.text === option.text + ? { ...item, token: response.optionToken } + : item + )); + setUserAnswer(response.optionToken); + } catch (error) { + if (error instanceof ApiError) { + message.error(`获取选项凭证失败: ${error.message}`); + } else { + message.error('获取选项凭证失败'); + } + } + }; + const buildAnswerProof = ( attemptId: string, questionToken: string, @@ -508,6 +613,11 @@ const VocabularyStudy: React.FC = () => { answerSubmittingRef.current = false; return; } + if (testType === 'multiple-choice' && options.length > 0 && options.every(option => option.token !== userAnswer)) { + answerSubmittingRef.current = false; + message.warning('请选择一个选项后再提交'); + return; + } const submittedAt = Date.now(); const normalizedUserAnswer = userAnswer.trim(); const answerProof = buildAnswerProof( @@ -517,39 +627,64 @@ const VocabularyStudy: React.FC = () => { submittedAt ); - const updatedPendingAnswers = [ - ...pendingAnswers, - { + try { + const submittedAnswer = { questionToken: currentQuestion.questionToken, userAnswer: normalizedUserAnswer, submittedAt, - answerProof - } - ]; + answerProof, + interactions: interactionsRef.current + }; - setPendingAnswers(updatedPendingAnswers); - setShowAnswer(true); - setUserAnswer(''); + const response = await api.post(API_PATHS.VOCABULARY.TEST_ANSWER, { + attemptId: testAttemptId, + ...submittedAnswer + }); - setTimeout(() => { - if (testIndex < testWords.length - 1) { - const nextIndex = testIndex + 1; - setTestIndex(nextIndex); - setShowAnswer(false); - answerSubmittingRef.current = false; - if (testType === 'audio-to-english') { - setTimeout(() => { - playWordSound(testWords[nextIndex]?.word || ''); - }, 500); + const updatedPendingAnswers = [ + ...pendingAnswers, + { + questionToken: currentQuestion.questionToken, + userAnswer: normalizedUserAnswer, + submittedAt, + answerProof } - if (testType === 'multiple-choice') { - setOptions(testWords[nextIndex]?.options || []); + ]; + + setPendingAnswers(updatedPendingAnswers); + setTestResults(prev => [...prev, response.result]); + setTestRiskFlags(prev => Array.from(new Set([...prev, ...(response.riskFlags || [])]))); + setShowAnswer(true); + setUserAnswer(''); + + setTimeout(() => { + if (testIndex < testWords.length - 1) { + const nextIndex = testIndex + 1; + setTestIndex(nextIndex); + setShowAnswer(false); + resetInteractionTrace(); + answerSubmittingRef.current = false; + if (testType === 'audio-to-english') { + setTimeout(() => { + playWordSound(testWords[nextIndex]?.word || ''); + }, 500); + } + if (testType === 'multiple-choice') { + setOptions(normalizeOptions(testWords[nextIndex])); + } + } else { + answerSubmittingRef.current = false; + finishTestWithResults(updatedPendingAnswers); } + }, 1500); + } catch (error) { + answerSubmittingRef.current = false; + if (error instanceof ApiError) { + message.error(`提交答案失败: ${error.message}`); } else { - answerSubmittingRef.current = false; - finishTestWithResults(updatedPendingAnswers); + message.error('提交答案失败'); } - }, 1500); + } }; // 使用指定结果完成测试 @@ -563,15 +698,9 @@ const VocabularyStudy: React.FC = () => { currentStudyStats: studyStats }); - // 提交测试记录,后端会用数据库答案重新判分 + // 提交测试记录,后端会用服务端累计的逐题答案重新判分 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 - })) + attemptId: testAttemptId }); setStudyStats({ @@ -583,12 +712,17 @@ const VocabularyStudy: React.FC = () => { duration: savedRecord.stats.duration }); setTestResults(savedRecord.results); + setTestRiskFlags(savedRecord.riskFlags || []); setPendingAnswers([]); setTestAttemptId(''); setShowAnswer(false); setTestFinished(true); - message.success('测试完成,记录已保存'); + if (savedRecord.invalidated) { + message.warning('测试行为异常,本次不计入掌握'); + } else { + message.success('测试完成,记录已保存'); + } setIsModalVisible(true); } catch (error) { if (error instanceof ApiError) { @@ -617,6 +751,9 @@ const VocabularyStudy: React.FC = () => { setTestResults([]); setPendingAnswers([]); setOptions([]); + setTestRiskFlags([]); + interactionsRef.current = []; + questionShownAtRef.current = 0; answerSubmittingRef.current = false; // 重置统计信息 setStudyStats({ @@ -740,10 +877,16 @@ const VocabularyStudy: React.FC = () => { testType === 'multiple-choice' && testWords.length > 0 ) { - setOptions(testWords[testIndex]?.options || []); + setOptions(normalizeOptions(testWords[testIndex])); } }, [testIndex, testType, testStarted, testFinished, testWords]); + useEffect(() => { + if (testStarted && !testFinished && testWords.length > 0) { + resetInteractionTrace(); + } + }, [testIndex, testStarted, testFinished, testWords.length]); + // 自动播放第一个听力单词 useEffect(() => { if ( @@ -1124,6 +1267,19 @@ const VocabularyStudy: React.FC = () => { />
+ {testRiskFlags.length > 0 && ( +
+ 本次测试存在异常交互信号,请按正常节奏作答 +
+ )} + {testType === 'chinese-to-english' && (
请输入对应的英文单词
@@ -1185,12 +1341,19 @@ const VocabularyStudy: React.FC = () => { setUserAnswer(e.target.value)} style={{ width: '100%', display: 'flex', flexDirection: 'column' }} > {options.map(option => ( - - {option} + { + recordInteraction({ type: 'click', valueLength: option.text.length }); + handleMultipleChoiceChange(option); + }} + style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }} + > + {option.text} ))} @@ -1199,7 +1362,23 @@ const VocabularyStudy: React.FC = () => { ref={inputRef} placeholder="请输入英文单词" value={userAnswer} - onChange={e => setUserAnswer(e.target.value)} + onChange={e => { + recordInteraction({ + type: 'input', + inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType, + valueLength: e.target.value.length + }); + setUserAnswer(e.target.value); + }} + onKeyDown={e => { + recordInteraction({ + type: 'keydown', + key: e.key, + valueLength: userAnswer.length + }); + }} + onFocus={() => recordInteraction({ type: 'focus', valueLength: userAnswer.length })} + onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })} disabled={showAnswer} style={{ marginBottom: 15 }} onPressEnter={e => { @@ -1208,7 +1387,10 @@ const VocabularyStudy: React.FC = () => { }} autoFocus size="large" - onPaste={e => e.preventDefault()} + onPaste={e => { + recordInteraction({ type: 'paste', valueLength: userAnswer.length }); + e.preventDefault(); + }} /> )} diff --git a/src/config.ts b/src/config.ts index 7902a7e..9bbccf6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,8 @@ export const API_PATHS = { STUDY_WORDS: '/vocabulary/study-words', UPLOAD: '/vocabulary/upload', TEST_ATTEMPT: '/vocabulary/test-attempt', + TEST_ANSWER: '/vocabulary/test-answer', + TEST_OPTION_TOKEN: '/vocabulary/test-option-token', WORD_RECORD: '/vocabulary/word-record', TEST_RECORD: '/vocabulary/test-record', STUDY_RECORDS: '/vocabulary/test-records',