fixed some bugs

This commit is contained in:
2026-05-25 11:07:32 +08:00
parent 6cac2f0c9a
commit 4088fbc9e7
2 changed files with 204 additions and 111 deletions

View File

@@ -42,7 +42,11 @@ interface InteractionEventPayload {
type?: string; type?: string;
key?: string; key?: string;
inputType?: string; inputType?: string;
value?: string;
valueLength?: number; valueLength?: number;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
ts?: number; ts?: number;
} }
@@ -70,7 +74,9 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
]; ];
const ATTEMPT_TTL_MS = 30 * 60 * 1000; 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 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') const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'https://d1kt.cn,http://localhost:3000,http://localhost:3001')
.split(',') .split(',')
@@ -78,6 +84,7 @@ const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'h
.filter(Boolean); .filter(Boolean);
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET; const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
const ANSWER_PROOF_SALT = 'd1ktsalt'; const ANSWER_PROOF_SALT = 'd1ktsalt';
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => { const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
return value === 'chinese-to-english' || 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 = ( const analyzeAnswerRisk = (
durationSeconds: number, durationSeconds: number,
summary: InteractionSummary, summary: InteractionSummary,
@@ -288,12 +349,12 @@ const analyzeAnswerRisk = (
): string[] => { ): string[] => {
const flags: 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 (durationSeconds > MAX_SECONDS_PER_QUESTION) flags.push('too_slow');
if (testType === 'multiple-choice') { if (testType === 'multiple-choice') {
if (summary.pointerCount === 0) flags.push('missing_choice_interaction'); if (summary.pointerCount === 0) flags.push('missing_choice_interaction');
} else { } else {
if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast');
if (summary.keyCount === 0) flags.push('missing_key_events'); if (summary.keyCount === 0) flags.push('missing_key_events');
if (summary.inputCount === 0) flags.push('missing_input_events'); if (summary.inputCount === 0) flags.push('missing_input_events');
if (summary.pasteCount > 0) flags.push('paste_used'); 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) { if (req.query.count) {
const parsed = parseInt(req.query.count as string, 10); const parsed = parseInt(req.query.count as string, 10);
if (!isNaN(parsed) && parsed > 0) { 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; const userId = req.user?._id;
if (!userId) { if (!userId) {
return res.status(401).json({ message: '用户信息无效' }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: '测试来源异常,请从官网页面开始测试' }); return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!mongoose.isValidObjectId(wordSetId)) { if (!mongoose.isValidObjectId(wordSetId)) {
return res.status(400).json({ message: '单词集ID无效' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isVocabularyTestType(testType)) { if (!isVocabularyTestType(testType)) {
return res.status(400).json({ message: '未知的测试类型' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const wordSet = await WordSet.findById(wordSetId); const wordSet = await WordSet.findById(wordSetId);
if (!wordSet) { if (!wordSet) {
return res.status(404).json({ message: '未找到单词集' }); return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
let selectedWords: any[] = []; let selectedWords: any[] = [];
@@ -763,23 +824,23 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
if (Array.isArray(wordIds) && wordIds.length > 0) { if (Array.isArray(wordIds) && wordIds.length > 0) {
if (wordIds.length > 100) { 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))) { 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()))]; const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
if (uniqueWordIds.length !== wordIds.length) { 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])); const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId)); selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
if (selectedWords.some(word => !word)) { if (selectedWords.some(word => !word)) {
return res.status(400).json({ message: '测试单词必须属于当前单词集' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
selectedWords = shuffle(selectedWords); selectedWords = shuffle(selectedWords);
@@ -792,7 +853,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
} }
if (selectedWords.length === 0) { if (selectedWords.length === 0) {
return res.status(400).json({ message: '没有可测试的单词' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const issuedAt = new Date(); const issuedAt = new Date();
@@ -837,7 +898,7 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('创建测试会话失败:', 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(); next();
}, authMiddleware, async (req, res) => { }, authMiddleware, async (req, res) => {
return res.status(410).json({ 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; const userId = req.user?._id;
if (!userId) { if (!userId) {
return res.status(401).json({ message: '用户信息无效' }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: '测试来源异常,请从官网页面提交答案' }); return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!mongoose.isValidObjectId(attemptId)) { 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({ const attempt = await VocabularyTestAttempt.findOne({
@@ -875,7 +936,7 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
}); });
if (!attempt) { if (!attempt) {
return res.status(404).json({ message: '未找到测试会话' }); return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const attemptAny = attempt as any; 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) { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
attemptAny.status = 'expired'; attemptAny.status = 'expired';
await attempt.save(); await attempt.save();
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' }); return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (attemptAny.status !== 'active') { if (attemptAny.status !== 'active') {
return res.status(409).json({ message: '测试会话已提交或已失效' }); return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const token = String(questionToken ?? ''); const token = String(questionToken ?? '');
const tokenIndex = attemptAny.questionTokens.indexOf(token); const tokenIndex = attemptAny.questionTokens.indexOf(token);
if (!token || tokenIndex < 0) { 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) const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
? attemptAny.answeredQuestionTokens ? attemptAny.answeredQuestionTokens
: []; : [];
if (answeredTokens.includes(token)) { if (answeredTokens.includes(token)) {
return res.status(409).json({ message: '该题已提交,请继续下一题' }); return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (tokenIndex !== answeredTokens.length) { 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); const submittedAtMs = Number.parseInt(String(submittedAt), 10);
if (!Number.isFinite(submittedAtMs)) { 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) { 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]); 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: '测试题目签名无效' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500); 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 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: '选择项凭证无效' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
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: '答案校验失败' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const questionShownAtMs = tokenIndex === 0 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(); : new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime();
const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000); const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000);
const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs); 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({ const word = await Word.findOne({
_id: wordId, _id: wordId,
wordSet: attemptAny.wordSet wordSet: attemptAny.wordSet
}); });
if (!word) { if (!word) {
return res.status(400).json({ message: '测试会话中的单词已不存在' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer); const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer);
@@ -989,12 +1053,11 @@ router.post('/test-answer', authMiddleware, async (req, res) => {
answered: attemptAny.answeredQuestionTokens.length, answered: attemptAny.answeredQuestionTokens.length,
total: attemptAny.questionTokens.length, total: attemptAny.questionTokens.length,
finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length
}, }
riskFlags
}); });
} catch (error) { } catch (error) {
console.error('提交单题答案失败:', 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; const userId = req.user?._id;
if (!userId) { if (!userId) {
return res.status(401).json({ message: '用户信息无效' }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' }); return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!mongoose.isValidObjectId(attemptId)) { 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({ const attempt = await VocabularyTestAttempt.findOne({
@@ -1022,40 +1085,40 @@ router.post('/test-option-token', authMiddleware, async (req, res) => {
}); });
if (!attempt) { if (!attempt) {
return res.status(404).json({ message: '未找到测试会话' }); return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const attemptAny = attempt as any; const attemptAny = attempt as any;
if (attemptAny.status !== 'active') { if (attemptAny.status !== 'active') {
return res.status(409).json({ message: '测试会话已提交或已失效' }); return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (attemptAny.testType !== 'multiple-choice') { 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 token = String(questionToken ?? '');
const tokenIndex = attemptAny.questionTokens.indexOf(token); const tokenIndex = attemptAny.questionTokens.indexOf(token);
if (!token || tokenIndex < 0) { 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 optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? '')); const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? ''));
if (selectedIndex < 0) { if (selectedIndex < 0) {
return res.status(400).json({ message: '选项无效' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens) const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
? attemptAny.answeredQuestionTokens ? attemptAny.answeredQuestionTokens
: []; : [];
if (tokenIndex !== answeredTokens.length) { 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]; const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex];
if (!optionToken) { if (!optionToken) {
return res.status(400).json({ message: '选项凭证不存在,请重新开始测试' }); return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
res.json({ res.json({
@@ -1064,7 +1127,7 @@ router.post('/test-option-token', authMiddleware, async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('获取选择项凭证失败:', 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; const userId = req.user?._id;
if (!userId) { if (!userId) {
return res.status(401).json({ message: '用户信息无效' }); return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!isAllowedVocabularyOrigin(req)) { if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: '测试来源异常,请从官网页面完成测试' }); return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (!mongoose.isValidObjectId(attemptId)) { 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({ const attempt = await VocabularyTestAttempt.findOne({
@@ -1092,7 +1155,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
}); });
if (!attempt) { if (!attempt) {
return res.status(404).json({ message: '未找到测试会话' }); return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
const attemptAny = attempt as any; 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) { if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
attemptAny.status = 'expired'; attemptAny.status = 'expired';
await attempt.save(); await attempt.save();
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' }); return res.status(410).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
if (attemptAny.status !== 'active') { 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 : []; const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : [];
if (submittedAnswers.length !== attemptAny.questionTokens.length) { 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 lastSubmittedAtMs = Math.max(...submittedAnswers.map((item: any) => new Date(item.submittedAt).getTime()));
const isMultipleChoiceAttempt = attemptAny.testType === 'multiple-choice';
if (!isMultipleChoiceAttempt) {
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: '答题速度异常,请重新作答' }); 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])); attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
const shouldInvalidateBatch = batchRiskFlags.length > 0 || 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( const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
{ _id: attempt._id, user: userId, status: 'active' }, { _id: attempt._id, user: userId, status: 'active' },
@@ -1134,7 +1200,7 @@ router.post('/test-record', authMiddleware, async (req, res) => {
); );
if (!claimedAttempt) { 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)); 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({ res.status(201).json({
message: shouldInvalidateBatch ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存', message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存',
attemptId: attempt._id.toString(), attemptId: attempt._id.toString(),
stats: verifiedStats, stats: verifiedStats,
results: submittedAnswers.map((result: any) => ({ results: submittedAnswers.map((result: any) => ({
@@ -1198,12 +1264,11 @@ router.post('/test-record', authMiddleware, async (req, res) => {
isCorrect: shouldInvalidateBatch ? false : result.isCorrect isCorrect: shouldInvalidateBatch ? false : result.isCorrect
})), })),
submittedAt: new Date(lastSubmittedAtMs), submittedAt: new Date(lastSubmittedAtMs),
riskFlags: attemptAny.riskFlags || [],
invalidated: shouldInvalidateBatch invalidated: shouldInvalidateBatch
}); });
} catch (error) { } catch (error) {
console.error('保存测试记录失败:', error); console.error('保存测试记录失败:', error);
res.status(500).json({ message: '保存测试记录失败' }); res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
} }
}); });

View File

@@ -78,7 +78,11 @@ interface InteractionEventPayload {
ts: number; ts: number;
key?: string; key?: string;
inputType?: string; inputType?: string;
value?: string;
valueLength?: number; valueLength?: number;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
} }
interface NativeInputLikeEvent extends Event { interface NativeInputLikeEvent extends Event {
@@ -110,7 +114,6 @@ interface SavedVocabularyTestRecordResponse {
duration: number; duration: number;
}; };
results: TestResult[]; results: TestResult[];
riskFlags?: string[];
invalidated?: boolean; invalidated?: boolean;
} }
@@ -129,7 +132,6 @@ interface SubmitAnswerResponse {
total: number; total: number;
finished: boolean; finished: boolean;
}; };
riskFlags?: string[];
} }
interface OptionTokenResponse { interface OptionTokenResponse {
@@ -138,6 +140,9 @@ interface OptionTokenResponse {
} }
const ANSWER_PROOF_SALT = 'd1ktsalt'; const ANSWER_PROOF_SALT = 'd1ktsalt';
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
const MIN_STUDY_WORD_COUNT = 10;
const MAX_STUDY_WORD_COUNT = 100;
const VocabularyStudy: React.FC = () => { const VocabularyStudy: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -165,7 +170,6 @@ const VocabularyStudy: React.FC = () => {
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]); const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
const [testAttemptLoading, setTestAttemptLoading] = useState(false); const [testAttemptLoading, setTestAttemptLoading] = useState(false);
const [options, setOptions] = useState<TestOption[]>([]); const [options, setOptions] = useState<TestOption[]>([]);
const [testRiskFlags, setTestRiskFlags] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
const timeOffsetRef = useRef<number>(0); const timeOffsetRef = useRef<number>(0);
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null); const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
@@ -332,7 +336,7 @@ const VocabularyStudy: React.FC = () => {
// 处理单词数量变更 // 处理单词数量变更
const handleWordCountChange = (value: number | null) => { const handleWordCountChange = (value: number | null) => {
if (value !== null) { if (value !== null) {
setWordCount(value); setWordCount(Math.max(MIN_STUDY_WORD_COUNT, Math.min(value, MAX_STUDY_WORD_COUNT)));
} }
}; };
@@ -495,7 +499,6 @@ const VocabularyStudy: React.FC = () => {
setTestResults([]); setTestResults([]);
setPendingAnswers([]); setPendingAnswers([]);
setOptions(normalizeOptions(questions[0])); setOptions(normalizeOptions(questions[0]));
setTestRiskFlags([]);
resetInteractionTrace(); resetInteractionTrace();
setIsModalVisible(false); setIsModalVisible(false);
setStudyStats({ setStudyStats({
@@ -506,11 +509,7 @@ const VocabularyStudy: React.FC = () => {
duration: 0 duration: 0
}); });
} catch (error) { } catch (error) {
if (error instanceof ApiError) { message.error(INVALID_CREDENTIAL_MESSAGE);
message.error(`创建测试会话失败: ${error.message}`);
} else {
message.error('创建测试会话失败');
}
} finally { } finally {
setTestAttemptLoading(false); setTestAttemptLoading(false);
} }
@@ -558,6 +557,40 @@ const VocabularyStudy: React.FC = () => {
].slice(-300); ].slice(-300);
}; };
const getTestInputElement = (): HTMLInputElement | null => {
return inputRef.current?.input || null;
};
const moveTestInputCaretToEnd = () => {
window.setTimeout(() => {
const input = getTestInputElement();
if (!input) return;
const end = input.value.length;
input.setSelectionRange(end, end);
}, 0);
};
const focusTestInputAtEnd = () => {
inputRef.current?.focus?.();
moveTestInputCaretToEnd();
};
const shouldBlockInputKey = (event: React.KeyboardEvent<HTMLInputElement>): boolean => {
const input = event.currentTarget;
const selectionStart = input.selectionStart ?? input.value.length;
const selectionEnd = input.selectionEnd ?? input.value.length;
const hasSelection = selectionStart !== selectionEnd;
const caretAtEnd = selectionStart === input.value.length && selectionEnd === input.value.length;
const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
const shortcutKey = event.key.toLowerCase();
if (navigationKeys.includes(event.key) || event.key === 'Delete') return true;
if ((event.ctrlKey || event.metaKey) && ['a', 'x', 'v', 'z', 'y'].includes(shortcutKey)) return true;
if (event.key === 'Backspace') return hasSelection || !caretAtEnd;
return false;
};
const handleMultipleChoiceChange = async (option: TestOption) => { const handleMultipleChoiceChange = async (option: TestOption) => {
const currentQuestion = testWords[testIndex]; const currentQuestion = testWords[testIndex];
if (!currentQuestion || !testAttemptId || showAnswer) return; if (!currentQuestion || !testAttemptId || showAnswer) return;
@@ -586,11 +619,7 @@ const VocabularyStudy: React.FC = () => {
)); ));
setUserAnswer(response.optionToken); setUserAnswer(response.optionToken);
} catch (error) { } catch (error) {
if (error instanceof ApiError) { message.error(INVALID_CREDENTIAL_MESSAGE);
message.error(`获取选项凭证失败: ${error.message}`);
} else {
message.error('获取选项凭证失败');
}
} }
}; };
@@ -653,7 +682,6 @@ const VocabularyStudy: React.FC = () => {
setPendingAnswers(updatedPendingAnswers); setPendingAnswers(updatedPendingAnswers);
setTestResults(prev => [...prev, response.result]); setTestResults(prev => [...prev, response.result]);
setTestRiskFlags(prev => Array.from(new Set([...prev, ...(response.riskFlags || [])])));
setShowAnswer(true); setShowAnswer(true);
setUserAnswer(''); setUserAnswer('');
@@ -679,11 +707,7 @@ const VocabularyStudy: React.FC = () => {
}, 1500); }, 1500);
} catch (error) { } catch (error) {
answerSubmittingRef.current = false; answerSubmittingRef.current = false;
if (error instanceof ApiError) { message.error(INVALID_CREDENTIAL_MESSAGE);
message.error(`提交答案失败: ${error.message}`);
} else {
message.error('提交答案失败');
}
} }
}; };
@@ -712,24 +736,19 @@ const VocabularyStudy: React.FC = () => {
duration: savedRecord.stats.duration duration: savedRecord.stats.duration
}); });
setTestResults(savedRecord.results); setTestResults(savedRecord.results);
setTestRiskFlags(savedRecord.riskFlags || []);
setPendingAnswers([]); setPendingAnswers([]);
setTestAttemptId(''); setTestAttemptId('');
setShowAnswer(false); setShowAnswer(false);
setTestFinished(true); setTestFinished(true);
if (savedRecord.invalidated) { if (savedRecord.invalidated) {
message.warning('测试行为异常,本次不计入掌握'); message.error(INVALID_CREDENTIAL_MESSAGE);
} else { } else {
message.success('测试完成,记录已保存'); message.success('测试完成,记录已保存');
} }
setIsModalVisible(true); setIsModalVisible(true);
} catch (error) { } catch (error) {
if (error instanceof ApiError) { message.error(INVALID_CREDENTIAL_MESSAGE);
message.error(`保存测试记录失败: ${error.message}`);
} else {
message.error('保存测试记录失败');
}
} }
}; };
@@ -751,7 +770,6 @@ const VocabularyStudy: React.FC = () => {
setTestResults([]); setTestResults([]);
setPendingAnswers([]); setPendingAnswers([]);
setOptions([]); setOptions([]);
setTestRiskFlags([]);
interactionsRef.current = []; interactionsRef.current = [];
questionShownAtRef.current = 0; questionShownAtRef.current = 0;
answerSubmittingRef.current = false; answerSubmittingRef.current = false;
@@ -973,14 +991,14 @@ const VocabularyStudy: React.FC = () => {
<h4>:</h4> <h4>:</h4>
<InputNumber <InputNumber
min={5} min={MIN_STUDY_WORD_COUNT}
max={100} max={MAX_STUDY_WORD_COUNT}
value={wordCount} value={wordCount}
onChange={handleWordCountChange} onChange={handleWordCountChange}
style={{ width: 120 }} style={{ width: 120 }}
/> />
<span style={{ marginLeft: 10, color: '#888' }}> <span style={{ marginLeft: 10, color: '#888' }}>
(范围: 5-100) (: {MIN_STUDY_WORD_COUNT}-{MAX_STUDY_WORD_COUNT})
</span> </span>
</div> </div>
@@ -1267,19 +1285,6 @@ const VocabularyStudy: React.FC = () => {
/> />
<div style={{ margin: '20px 0' }}> <div style={{ margin: '20px 0' }}>
{testRiskFlags.length > 0 && (
<div style={{
marginBottom: 12,
padding: '8px 10px',
border: '1px solid #faad14',
borderRadius: 4,
color: '#ad6800',
background: '#fffbe6'
}}>
</div>
)}
{testType === 'chinese-to-english' && ( {testType === 'chinese-to-english' && (
<div style={{ fontSize: 24, marginBottom: 15, textAlign: 'center' }}> <div style={{ fontSize: 24, marginBottom: 15, textAlign: 'center' }}>
<div style={{ color: '#666', fontSize: '0.8em', marginBottom: 5 }}></div> <div style={{ color: '#666', fontSize: '0.8em', marginBottom: 5 }}></div>
@@ -1366,21 +1371,44 @@ const VocabularyStudy: React.FC = () => {
recordInteraction({ recordInteraction({
type: 'input', type: 'input',
inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType, inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType,
value: e.target.value,
valueLength: e.target.value.length valueLength: e.target.value.length
}); });
setUserAnswer(e.target.value); setUserAnswer(e.target.value);
moveTestInputCaretToEnd();
}} }}
onKeyDown={e => { onKeyDown={e => {
recordInteraction({ recordInteraction({
type: 'keydown', type: 'keydown',
key: e.key, key: e.key,
valueLength: userAnswer.length value: userAnswer,
valueLength: userAnswer.length,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey
}); });
if (shouldBlockInputKey(e)) {
e.preventDefault();
moveTestInputCaretToEnd();
}
}}
onFocus={() => {
recordInteraction({ type: 'focus', valueLength: userAnswer.length });
moveTestInputCaretToEnd();
}} }}
onFocus={() => recordInteraction({ type: 'focus', valueLength: userAnswer.length })}
onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })} onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })}
onMouseDown={e => {
e.preventDefault();
focusTestInputAtEnd();
}}
onMouseUp={moveTestInputCaretToEnd}
onSelect={moveTestInputCaretToEnd}
onKeyUp={moveTestInputCaretToEnd}
onContextMenu={e => e.preventDefault()}
onCut={e => e.preventDefault()}
onDrop={e => e.preventDefault()}
disabled={showAnswer} disabled={showAnswer}
style={{ marginBottom: 15 }} style={{ marginBottom: 15, userSelect: 'none', WebkitUserSelect: 'none' }}
onPressEnter={e => { onPressEnter={e => {
e.stopPropagation(); e.stopPropagation();
submitAnswer(); submitAnswer();
@@ -1388,7 +1416,7 @@ const VocabularyStudy: React.FC = () => {
autoFocus autoFocus
size="large" size="large"
onPaste={e => { onPaste={e => {
recordInteraction({ type: 'paste', valueLength: userAnswer.length }); recordInteraction({ type: 'paste', value: userAnswer, valueLength: userAnswer.length });
e.preventDefault(); e.preventDefault();
}} }}
/> />