fixed some bugs
This commit is contained in:
@@ -42,7 +42,11 @@ interface InteractionEventPayload {
|
||||
type?: string;
|
||||
key?: string;
|
||||
inputType?: string;
|
||||
value?: string;
|
||||
valueLength?: number;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
@@ -70,7 +74,9 @@ 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 || 1);
|
||||
const MIN_STUDY_WORD_COUNT = 10;
|
||||
const MAX_STUDY_WORD_COUNT = 100;
|
||||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 2);
|
||||
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(',')
|
||||
@@ -78,6 +84,7 @@ const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'h
|
||||
.filter(Boolean);
|
||||
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
||||
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
|
||||
|
||||
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
||||
return value === 'chinese-to-english' ||
|
||||
@@ -281,6 +288,60 @@ const summarizeInteractionEvents = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildKeySequenceText = (events: InteractionEventPayload[]): string => {
|
||||
let text = '';
|
||||
|
||||
events.forEach(event => {
|
||||
if (event?.type !== 'keydown' || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
const key = String(event.key ?? '');
|
||||
if (key === 'Backspace') {
|
||||
text = text.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
if (key === 'Spacebar') {
|
||||
text += ' ';
|
||||
return;
|
||||
}
|
||||
if (key.length === 1) {
|
||||
text += key;
|
||||
}
|
||||
});
|
||||
|
||||
return text.slice(0, 500);
|
||||
};
|
||||
|
||||
const analyzeInputTraceRisk = (
|
||||
events: InteractionEventPayload[],
|
||||
userAnswer: string,
|
||||
testType: VocabularyTestType
|
||||
): string[] => {
|
||||
if (testType === 'multiple-choice') return [];
|
||||
|
||||
const flags: string[] = [];
|
||||
const expectedAnswer = normalizeEnglishAnswer(userAnswer);
|
||||
if (!expectedAnswer) return flags;
|
||||
|
||||
const safeEvents = Array.isArray(events) ? events.slice(0, 300) : [];
|
||||
const inputValues = safeEvents
|
||||
.filter(event => event?.type === 'input' && typeof event.value === 'string')
|
||||
.map(event => String(event.value ?? '').slice(0, 500));
|
||||
const lastInputValue = inputValues[inputValues.length - 1];
|
||||
|
||||
if (inputValues.length === 0) {
|
||||
flags.push('missing_input_value_trace');
|
||||
} else if (normalizeEnglishAnswer(lastInputValue) !== expectedAnswer) {
|
||||
flags.push('input_value_mismatch');
|
||||
}
|
||||
|
||||
const keySequence = normalizeEnglishAnswer(buildKeySequenceText(safeEvents));
|
||||
if (keySequence && keySequence !== expectedAnswer) {
|
||||
flags.push('key_sequence_mismatch');
|
||||
}
|
||||
|
||||
return flags;
|
||||
};
|
||||
|
||||
const analyzeAnswerRisk = (
|
||||
durationSeconds: number,
|
||||
summary: InteractionSummary,
|
||||
@@ -288,12 +349,12 @@ const analyzeAnswerRisk = (
|
||||
): 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 (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast');
|
||||
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');
|
||||
@@ -588,7 +649,7 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
||||
if (req.query.count) {
|
||||
const parsed = parseInt(req.query.count as string, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
targetCount = Math.min(parsed, 100);
|
||||
targetCount = Math.max(MIN_STUDY_WORD_COUNT, Math.min(parsed, MAX_STUDY_WORD_COUNT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,24 +799,24 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面开始测试' });
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(wordSetId)) {
|
||||
return res.status(400).json({ message: '单词集ID无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isVocabularyTestType(testType)) {
|
||||
return res.status(400).json({ message: '未知的测试类型' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const wordSet = await WordSet.findById(wordSetId);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
let selectedWords: any[] = [];
|
||||
@@ -763,23 +824,23 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
|
||||
if (Array.isArray(wordIds) && wordIds.length > 0) {
|
||||
if (wordIds.length > 100) {
|
||||
return res.status(400).json({ message: '单次测试最多100道题' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) {
|
||||
return res.status(400).json({ message: '包含无效单词ID' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
|
||||
if (uniqueWordIds.length !== wordIds.length) {
|
||||
return res.status(400).json({ message: '测试单词不能重复' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
||||
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
||||
|
||||
if (selectedWords.some(word => !word)) {
|
||||
return res.status(400).json({ message: '测试单词必须属于当前单词集' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
selectedWords = shuffle(selectedWords);
|
||||
@@ -792,7 +853,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
}
|
||||
|
||||
if (selectedWords.length === 0) {
|
||||
return res.status(400).json({ message: '没有可测试的单词' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const issuedAt = new Date();
|
||||
@@ -837,7 +898,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建测试会话失败:', error);
|
||||
res.status(500).json({ message: '创建测试会话失败' });
|
||||
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -847,7 +908,7 @@ router.post('/word-record', (req, res, next) => {
|
||||
next();
|
||||
}, authMiddleware, async (req, res) => {
|
||||
return res.status(410).json({
|
||||
message: '单题记录接口已停用,请通过测试会话提交记录'
|
||||
message: INVALID_CREDENTIAL_MESSAGE
|
||||
});
|
||||
});
|
||||
|
||||
@@ -858,15 +919,15 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面提交答案' });
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
@@ -875,7 +936,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attemptAny = attempt as any;
|
||||
@@ -886,42 +947,42 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||
attemptAny.status = 'expired';
|
||||
await attempt.save();
|
||||
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' });
|
||||
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (attemptAny.status !== 'active') {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const token = String(questionToken ?? '');
|
||||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||||
if (!token || tokenIndex < 0) {
|
||||
return res.status(400).json({ message: '测试答案的题目标识无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||
? attemptAny.answeredQuestionTokens
|
||||
: [];
|
||||
if (answeredTokens.includes(token)) {
|
||||
return res.status(409).json({ message: '该题已提交,请继续下一题' });
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (tokenIndex !== answeredTokens.length) {
|
||||
return res.status(400).json({ message: '请按题目顺序提交答案' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const submittedAtMs = Number.parseInt(String(submittedAt), 10);
|
||||
if (!Number.isFinite(submittedAtMs)) {
|
||||
return res.status(400).json({ message: '答案提交时间无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||
return res.status(400).json({ message: '答案提交时间异常' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
|
||||
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
|
||||
return res.status(400).json({ message: '测试题目签名无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500);
|
||||
@@ -930,14 +991,14 @@ 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: '选择项凭证无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_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: '答案校验失败' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const questionShownAtMs = tokenIndex === 0
|
||||
@@ -945,14 +1006,17 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
: 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 riskFlags = Array.from(new Set([
|
||||
...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType),
|
||||
...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType)
|
||||
]));
|
||||
|
||||
const word = await Word.findOne({
|
||||
_id: wordId,
|
||||
wordSet: attemptAny.wordSet
|
||||
});
|
||||
if (!word) {
|
||||
return res.status(400).json({ message: '测试会话中的单词已不存在' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer);
|
||||
@@ -989,12 +1053,11 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
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: '提交单题答案失败' });
|
||||
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1005,15 +1068,15 @@ router.post('/test-option-token', authMiddleware, async (req, res) => {
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' });
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
@@ -1022,40 +1085,40 @@ router.post('/test-option-token', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attemptAny = attempt as any;
|
||||
if (attemptAny.status !== 'active') {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (attemptAny.testType !== 'multiple-choice') {
|
||||
return res.status(400).json({ message: '当前题型不需要选项凭证' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const token = String(questionToken ?? '');
|
||||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||||
if (!token || tokenIndex < 0) {
|
||||
return res.status(400).json({ message: '测试题目标识无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||||
const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? ''));
|
||||
if (selectedIndex < 0) {
|
||||
return res.status(400).json({ message: '选项无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||
? attemptAny.answeredQuestionTokens
|
||||
: [];
|
||||
if (tokenIndex !== answeredTokens.length) {
|
||||
return res.status(400).json({ message: '请按题目顺序选择答案' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex];
|
||||
if (!optionToken) {
|
||||
return res.status(400).json({ message: '选项凭证不存在,请重新开始测试' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1064,7 +1127,7 @@ router.post('/test-option-token', authMiddleware, async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取选择项凭证失败:', error);
|
||||
res.status(500).json({ message: '获取选择项凭证失败' });
|
||||
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1075,15 +1138,15 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面完成测试' });
|
||||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
@@ -1092,7 +1155,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const attemptAny = attempt as any;
|
||||
@@ -1103,29 +1166,32 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||
attemptAny.status = 'expired';
|
||||
await attempt.save();
|
||||
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' });
|
||||
return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
if (attemptAny.status !== 'active') {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : [];
|
||||
if (submittedAnswers.length !== attemptAny.questionTokens.length) {
|
||||
return res.status(400).json({ message: '请完成所有题目后再保存测试记录' });
|
||||
return res.status(400).json({ message: INVALID_CREDENTIAL_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, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
|
||||
if (elapsedSeconds < minimumSeconds) {
|
||||
return res.status(400).json({ message: '答题速度异常,请重新作答' });
|
||||
const isMultipleChoiceAttempt = attemptAny.testType === 'multiple-choice';
|
||||
if (!isMultipleChoiceAttempt) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
const batchRiskFlags = analyzeBatchRisk(attemptAny.answerDurations || []);
|
||||
const batchRiskFlags = isMultipleChoiceAttempt ? [] : 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));
|
||||
['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||||
|
||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||
{ _id: attempt._id, user: userId, status: 'active' },
|
||||
@@ -1134,7 +1200,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
);
|
||||
|
||||
if (!claimedAttempt) {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
|
||||
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
||||
@@ -1187,7 +1253,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: shouldInvalidateBatch ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存',
|
||||
message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存',
|
||||
attemptId: attempt._id.toString(),
|
||||
stats: verifiedStats,
|
||||
results: submittedAnswers.map((result: any) => ({
|
||||
@@ -1198,12 +1264,11 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||
})),
|
||||
submittedAt: new Date(lastSubmittedAtMs),
|
||||
riskFlags: attemptAny.riskFlags || [],
|
||||
invalidated: shouldInvalidateBatch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存测试记录失败:', error);
|
||||
res.status(500).json({ message: '保存测试记录失败' });
|
||||
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user