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 {
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,

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 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',

View File

@@ -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;