fixed a bug

This commit is contained in:
2026-06-13 12:04:54 +08:00
parent c6b2bb7800
commit 2841413927
3 changed files with 38 additions and 23 deletions

View File

@@ -929,13 +929,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
try { try {
const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body; const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body;
const userId = req.user?._id; const userId = req.user?._id;
const softRiskFlags: string[] = [];
if (!userId) { if (!userId) {
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); softRiskFlags.push('invalid_origin');
} }
if (!mongoose.isValidObjectId(attemptId)) { if (!mongoose.isValidObjectId(attemptId)) {
@@ -957,9 +958,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
const now = Date.now(); const now = Date.now();
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
attemptAny.status = 'expired'; softRiskFlags.push('attempt_expired');
await attempt.save();
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (attemptAny.status !== 'active') { if (attemptAny.status !== 'active') {
@@ -985,16 +984,14 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
const submittedAtMs = Number.parseInt(String(submittedAt), 10); const submittedAtMs = Number.parseInt(String(submittedAt), 10);
if (!Number.isFinite(submittedAtMs)) { if (!Number.isFinite(submittedAtMs)) {
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');
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]); const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) { 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); 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 optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer)); const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer));
if (selectedOptionIndex < 0) { 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); const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs);
if (!safeEqualString(expectedProof, String(answerProof ?? ''))) { if (!safeEqualString(expectedProof, String(answerProof ?? ''))) {
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE }); softRiskFlags.push('invalid_answer_proof');
} }
const questionShownAtMs = tokenIndex === 0 const questionShownAtMs = tokenIndex === 0
? issuedAt.getTime() ? issuedAt.getTime()
: new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || 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 interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs);
const riskFlags = Array.from(new Set([ const riskFlags = Array.from(new Set([
...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType), ...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType),
...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType) ...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType),
...softRiskFlags
])); ]));
const word = await Word.findOne({ const word = await Word.findOne({
@@ -1042,7 +1046,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
userAnswer: evaluation.userAnswer, userAnswer: evaluation.userAnswer,
correctAnswer: evaluation.correctAnswer, correctAnswer: evaluation.correctAnswer,
isCorrect: evaluation.isCorrect, isCorrect: evaluation.isCorrect,
submittedAt: new Date(submittedAtMs), submittedAt: new Date(effectiveSubmittedAtMs),
duration, duration,
riskFlags, riskFlags,
interactionSummary interactionSummary
@@ -1148,13 +1152,14 @@ router.post('/test-record', authMiddleware, async (req, res) => {
try { try {
const { attemptId } = req.body; const { attemptId } = req.body;
const userId = req.user?._id; const userId = req.user?._id;
const finalRiskFlags: string[] = [];
if (!userId) { if (!userId) {
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE }); finalRiskFlags.push('invalid_origin');
} }
if (!mongoose.isValidObjectId(attemptId)) { if (!mongoose.isValidObjectId(attemptId)) {
@@ -1176,9 +1181,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
const now = Date.now(); const now = Date.now();
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
attemptAny.status = 'expired'; finalRiskFlags.push('attempt_expired');
await attempt.save();
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (attemptAny.status !== 'active') { 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 elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000);
const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION); const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
if (elapsedSeconds < minimumSeconds) { 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 totalWords = evaluatedResults.length;
const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length; const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length;
const batchRiskFlags = [ const batchRiskFlags = [
...finalRiskFlags,
...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])), ...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])),
...analyzeVocabularyAttemptResultRisk({ ...analyzeVocabularyAttemptResultRisk({
testType: attemptAny.testType, testType: attemptAny.testType,

View File

@@ -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 VOCABULARY_FULL_CORRECT_RISK_FLAG = 'large_batch_all_correct';
export const INVALIDATING_VOCABULARY_RISK_FLAGS: string[] = [ 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_fast',
'too_slow', 'too_slow',
'missing_choice_interaction',
'missing_input_value_trace', 'missing_input_value_trace',
'input_value_mismatch', 'input_value_mismatch',
'uniform_answer_intervals', 'uniform_answer_intervals',

View File

@@ -619,7 +619,7 @@ const VocabularyStudy: React.FC = () => {
)); ));
setUserAnswer(response.optionToken); setUserAnswer(response.optionToken);
} catch (error) { } catch (error) {
message.error(INVALID_CREDENTIAL_MESSAGE); setUserAnswer(option.text);
} }
}; };
@@ -647,7 +647,10 @@ const VocabularyStudy: React.FC = () => {
answerSubmittingRef.current = false; answerSubmittingRef.current = false;
return; 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; answerSubmittingRef.current = false;
message.warning('请选择一个选项后再提交'); message.warning('请选择一个选项后再提交');
return; return;