diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index 800686b..cc6db99 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -929,13 +929,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => { try { const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body; const userId = req.user?._id; + const softRiskFlags: string[] = []; if (!userId) { return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); + softRiskFlags.push('invalid_origin'); } if (!mongoose.isValidObjectId(attemptId)) { @@ -957,9 +958,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => { 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 }); + softRiskFlags.push('attempt_expired'); } if (attemptAny.status !== 'active') { @@ -985,16 +984,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => { 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 }); + softRiskFlags.push('invalid_submitted_at'); + } else if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) { + softRiskFlags.push('submitted_at_out_of_sync'); } const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]); if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) { - return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); + softRiskFlags.push('invalid_question_token'); } let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500); @@ -1003,24 +1000,31 @@ router.post('/test-answer', authMiddleware, async (req, res) => { 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 }); + softRiskFlags.push('invalid_option_token'); + const fallbackOptionIndex = optionTexts.findIndex((text: string) => text === normalizedUserAnswer); + normalizedUserAnswer = fallbackOptionIndex >= 0 + ? optionTexts[fallbackOptionIndex] + : String(userAnswer ?? '').trim().slice(0, 500); + } else { + normalizedUserAnswer = optionTexts[selectedOptionIndex] || ''; } - 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 }); + softRiskFlags.push('invalid_answer_proof'); } const questionShownAtMs = tokenIndex === 0 ? issuedAt.getTime() : new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime(); - const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000); + const effectiveSubmittedAtMs = Number.isFinite(submittedAtMs) ? submittedAtMs : now; + const duration = Math.max(0, (effectiveSubmittedAtMs - questionShownAtMs) / 1000); const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs); const riskFlags = Array.from(new Set([ ...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType), - ...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType) + ...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType), + ...softRiskFlags ])); const word = await Word.findOne({ @@ -1042,7 +1046,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => { userAnswer: evaluation.userAnswer, correctAnswer: evaluation.correctAnswer, isCorrect: evaluation.isCorrect, - submittedAt: new Date(submittedAtMs), + submittedAt: new Date(effectiveSubmittedAtMs), duration, riskFlags, interactionSummary @@ -1148,13 +1152,14 @@ router.post('/test-record', authMiddleware, async (req, res) => { try { const { attemptId } = req.body; const userId = req.user?._id; + const finalRiskFlags: string[] = []; if (!userId) { return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); } if (!isAllowedVocabularyOrigin(req)) { - return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); + finalRiskFlags.push('invalid_origin'); } if (!mongoose.isValidObjectId(attemptId)) { @@ -1176,9 +1181,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { 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 }); + finalRiskFlags.push('attempt_expired'); } if (attemptAny.status !== 'active') { @@ -1196,7 +1199,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { 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 }); + finalRiskFlags.push('too_fast'); } } @@ -1221,6 +1224,7 @@ router.post('/test-record', authMiddleware, async (req, res) => { const totalWords = evaluatedResults.length; const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length; const batchRiskFlags = [ + ...finalRiskFlags, ...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])), ...analyzeVocabularyAttemptResultRisk({ testType: attemptAny.testType, diff --git a/server/utils/vocabularyRisk.ts b/server/utils/vocabularyRisk.ts index 7d11976..09653cf 100644 --- a/server/utils/vocabularyRisk.ts +++ b/server/utils/vocabularyRisk.ts @@ -4,8 +4,16 @@ export const DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD = 50; export const VOCABULARY_FULL_CORRECT_RISK_FLAG = 'large_batch_all_correct'; export const INVALIDATING_VOCABULARY_RISK_FLAGS: string[] = [ + 'invalid_origin', + 'invalid_answer_proof', + 'invalid_question_token', + 'invalid_option_token', + 'invalid_submitted_at', + 'submitted_at_out_of_sync', + 'attempt_expired', 'too_fast', 'too_slow', + 'missing_choice_interaction', 'missing_input_value_trace', 'input_value_mismatch', 'uniform_answer_intervals', diff --git a/src/components/VocabularyStudy.tsx b/src/components/VocabularyStudy.tsx index a1d9b87..6ca4cc9 100644 --- a/src/components/VocabularyStudy.tsx +++ b/src/components/VocabularyStudy.tsx @@ -619,7 +619,7 @@ const VocabularyStudy: React.FC = () => { )); setUserAnswer(response.optionToken); } catch (error) { - message.error(INVALID_CREDENTIAL_MESSAGE); + setUserAnswer(option.text); } }; @@ -647,7 +647,10 @@ const VocabularyStudy: React.FC = () => { answerSubmittingRef.current = false; return; } - if (testType === 'multiple-choice' && options.length > 0 && options.every(option => option.token !== normalizedUserAnswer)) { + const isKnownMultipleChoiceAnswer = options.some(option => + option.token === normalizedUserAnswer || option.text === normalizedUserAnswer + ); + if (testType === 'multiple-choice' && options.length > 0 && !isKnownMultipleChoiceAnswer) { answerSubmittingRef.current = false; message.warning('请选择一个选项后再提交'); return;