fixed cheating michanism
This commit is contained in:
@@ -8,7 +8,6 @@ import { auth as authMiddleware } from '../middleware/auth';
|
||||
import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
import csv from 'csv-parser';
|
||||
import { User } from '../models/User';
|
||||
import { config } from '../config';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -24,13 +23,38 @@ interface EvaluatedVocabularyAnswer {
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface VocabularyOptionPayload {
|
||||
token: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface VocabularyAttemptQuestion {
|
||||
questionToken: string;
|
||||
wordId: string;
|
||||
word?: string;
|
||||
translation?: string;
|
||||
pronunciation?: string;
|
||||
options?: string[];
|
||||
options?: Array<string | VocabularyOptionPayload>;
|
||||
optionTokenRequest?: boolean;
|
||||
}
|
||||
|
||||
interface InteractionEventPayload {
|
||||
type?: string;
|
||||
key?: string;
|
||||
inputType?: string;
|
||||
valueLength?: number;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
interface InteractionSummary {
|
||||
keyCount: number;
|
||||
inputCount: number;
|
||||
pasteCount: number;
|
||||
focusCount: number;
|
||||
blurCount: number;
|
||||
pointerCount: number;
|
||||
firstEventOffset: number;
|
||||
lastEventOffset: number;
|
||||
}
|
||||
|
||||
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
|
||||
@@ -46,7 +70,12 @@ 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 || 0.4);
|
||||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 1);
|
||||
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(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
||||
|
||||
@@ -67,6 +96,28 @@ const shuffle = <T,>(array: T[]): T[] => {
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
|
||||
const getRequestOrigin = (req: express.Request): string => {
|
||||
const origin = req.get('origin');
|
||||
if (origin) return origin;
|
||||
|
||||
const referer = req.get('referer');
|
||||
if (!referer) return '';
|
||||
|
||||
try {
|
||||
return new URL(referer).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isAllowedVocabularyOrigin = (req: express.Request): boolean => {
|
||||
const origin = getRequestOrigin(req);
|
||||
if (!origin) {
|
||||
return config.NODE_ENV !== 'production';
|
||||
}
|
||||
return VOCABULARY_ALLOWED_ORIGINS.includes(origin);
|
||||
};
|
||||
|
||||
const signQuestionToken = (
|
||||
attemptId: string,
|
||||
wordId: string,
|
||||
@@ -82,6 +133,20 @@ const signQuestionToken = (
|
||||
return `${index}.${issuedAtMs}.${signature}`;
|
||||
};
|
||||
|
||||
const signOptionToken = (
|
||||
attemptId: string,
|
||||
questionToken: string,
|
||||
optionText: string,
|
||||
index: number
|
||||
): string => {
|
||||
const payload = `${attemptId}:${questionToken}:${index}:${optionText}`;
|
||||
const signature = crypto
|
||||
.createHmac('sha256', ATTEMPT_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
return `${index}.${signature}`;
|
||||
};
|
||||
|
||||
const safeEqualString = (left: string, right: string): boolean => {
|
||||
const leftBuffer = Buffer.from(left || '');
|
||||
const rightBuffer = Buffer.from(right || '');
|
||||
@@ -131,7 +196,8 @@ const buildQuestionPayload = (
|
||||
word: any,
|
||||
testType: VocabularyTestType,
|
||||
questionToken: string,
|
||||
optionsPool: any[]
|
||||
optionsPool: any[],
|
||||
suppliedOptions?: Array<string | VocabularyOptionPayload>
|
||||
): VocabularyAttemptQuestion => {
|
||||
const base = {
|
||||
questionToken,
|
||||
@@ -153,10 +219,13 @@ const buildQuestionPayload = (
|
||||
};
|
||||
}
|
||||
|
||||
const options = suppliedOptions || buildMultipleChoiceOptions(word, optionsPool);
|
||||
|
||||
return {
|
||||
...base,
|
||||
word: word.word,
|
||||
options: buildMultipleChoiceOptions(word, optionsPool)
|
||||
options,
|
||||
optionTokenRequest: options.some(option => typeof option === 'string')
|
||||
};
|
||||
};
|
||||
|
||||
@@ -189,6 +258,67 @@ const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unk
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeInteractionEvents = (
|
||||
events: InteractionEventPayload[],
|
||||
questionShownAtMs: number
|
||||
): InteractionSummary => {
|
||||
const safeEvents = Array.isArray(events) ? events.slice(0, 300) : [];
|
||||
const timestamps = safeEvents
|
||||
.map(event => Number(event?.ts))
|
||||
.filter(value => Number.isFinite(value));
|
||||
const firstEventTs = timestamps.length > 0 ? Math.min(...timestamps) : questionShownAtMs;
|
||||
const lastEventTs = timestamps.length > 0 ? Math.max(...timestamps) : questionShownAtMs;
|
||||
|
||||
return {
|
||||
keyCount: safeEvents.filter(event => event?.type === 'keydown' || event?.type === 'keyup').length,
|
||||
inputCount: safeEvents.filter(event => event?.type === 'input').length,
|
||||
pasteCount: safeEvents.filter(event => event?.type === 'paste').length,
|
||||
focusCount: safeEvents.filter(event => event?.type === 'focus').length,
|
||||
blurCount: safeEvents.filter(event => event?.type === 'blur').length,
|
||||
pointerCount: safeEvents.filter(event => event?.type === 'pointerdown' || event?.type === 'click' || event?.type === 'change').length,
|
||||
firstEventOffset: Math.max(0, firstEventTs - questionShownAtMs),
|
||||
lastEventOffset: Math.max(0, lastEventTs - questionShownAtMs)
|
||||
};
|
||||
};
|
||||
|
||||
const analyzeAnswerRisk = (
|
||||
durationSeconds: number,
|
||||
summary: InteractionSummary,
|
||||
testType: VocabularyTestType
|
||||
): 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 (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');
|
||||
}
|
||||
|
||||
return flags;
|
||||
};
|
||||
|
||||
const analyzeBatchRisk = (durations: number[]): string[] => {
|
||||
if (durations.length < 3) return [];
|
||||
|
||||
const roundedDurations = durations.map(value => Math.round(value * 10) / 10);
|
||||
const average = roundedDurations.reduce((sum, value) => sum + value, 0) / roundedDurations.length;
|
||||
const variance = roundedDurations.reduce((sum, value) => sum + Math.pow(value - average, 2), 0) / roundedDurations.length;
|
||||
const uniqueWholeSecondCount = new Set(durations.map(value => Math.round(value))).size;
|
||||
const durationRange = Math.max(...durations) - Math.min(...durations);
|
||||
const flags: string[] = [];
|
||||
|
||||
if (durationRange <= 1) flags.push('uniform_answer_intervals');
|
||||
if (variance < 0.05 && roundedDurations.length >= 4) flags.push('uniform_answer_intervals');
|
||||
if (uniqueWholeSecondCount <= 2 && durations.length >= 5) flags.push('repeated_whole_second_intervals');
|
||||
|
||||
return Array.from(new Set(flags));
|
||||
};
|
||||
|
||||
const buildMasteryStatus = (record: any) => {
|
||||
const masteryStatus: Record<string, any> = {};
|
||||
WORD_RECORD_MODES.forEach(mode => {
|
||||
@@ -611,6 +741,10 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面开始测试' });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(wordSetId)) {
|
||||
return res.status(400).json({ message: '单词集ID无效' });
|
||||
}
|
||||
@@ -680,10 +814,19 @@ router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
);
|
||||
|
||||
attempt.questionTokens = questionTokens;
|
||||
if (testType === 'multiple-choice') {
|
||||
const optionTexts = selectedWords.map(word => buildMultipleChoiceOptions(word, allWords));
|
||||
attempt.optionTexts = optionTexts;
|
||||
attempt.optionTokens = optionTexts.map((options, index) =>
|
||||
options.map((option, optionIndex) => signOptionToken(attemptId, questionTokens[index], option, optionIndex))
|
||||
);
|
||||
}
|
||||
await attempt.save();
|
||||
|
||||
const questions = selectedWords.map((word, index) =>
|
||||
buildQuestionPayload(word, testType, questionTokens[index], allWords)
|
||||
buildQuestionPayload(word, testType, questionTokens[index], allWords, testType === 'multiple-choice'
|
||||
? attempt.optionTexts?.[index] || []
|
||||
: undefined)
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
@@ -708,22 +851,22 @@ router.post('/word-record', (req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
// 单题提交:服务端逐题判分并累计结果
|
||||
router.post('/test-answer', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { attemptId, answers } = req.body;
|
||||
const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body;
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面提交答案' });
|
||||
}
|
||||
|
||||
if (!Array.isArray(answers) || answers.length === 0) {
|
||||
return res.status(400).json({ message: '测试答案不能为空' });
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
@@ -750,96 +893,243 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
if (answers.length !== attemptAny.questionTokens.length) {
|
||||
return res.status(400).json({ message: '答案数量与测试题目不一致' });
|
||||
const token = String(questionToken ?? '');
|
||||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||||
if (!token || tokenIndex < 0) {
|
||||
return res.status(400).json({ message: '测试答案的题目标识无效' });
|
||||
}
|
||||
|
||||
const tokenIndexMap = new Map<string, number>();
|
||||
attemptAny.questionTokens.forEach((token: string, index: number) => {
|
||||
tokenIndexMap.set(token, index);
|
||||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||
? attemptAny.answeredQuestionTokens
|
||||
: [];
|
||||
if (answeredTokens.includes(token)) {
|
||||
return res.status(409).json({ message: '该题已提交,请继续下一题' });
|
||||
}
|
||||
|
||||
if (tokenIndex !== answeredTokens.length) {
|
||||
return res.status(400).json({ message: '请按题目顺序提交答案' });
|
||||
}
|
||||
|
||||
const submittedAtMs = Number.parseInt(String(submittedAt), 10);
|
||||
if (!Number.isFinite(submittedAtMs)) {
|
||||
return res.status(400).json({ message: '答案提交时间无效' });
|
||||
}
|
||||
|
||||
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||
return res.status(400).json({ message: '答案提交时间异常' });
|
||||
}
|
||||
|
||||
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
|
||||
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
|
||||
return res.status(400).json({ message: '测试题目签名无效' });
|
||||
}
|
||||
|
||||
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500);
|
||||
if (attemptAny.testType === 'multiple-choice') {
|
||||
const optionTokens = attemptAny.optionTokens?.[tokenIndex] || [];
|
||||
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||||
const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer));
|
||||
if (selectedOptionIndex < 0) {
|
||||
return res.status(400).json({ 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: '答案校验失败' });
|
||||
}
|
||||
|
||||
const questionShownAtMs = tokenIndex === 0
|
||||
? issuedAt.getTime()
|
||||
: 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 word = await Word.findOne({
|
||||
_id: wordId,
|
||||
wordSet: attemptAny.wordSet
|
||||
});
|
||||
if (!word) {
|
||||
return res.status(400).json({ message: '测试会话中的单词已不存在' });
|
||||
}
|
||||
|
||||
const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer);
|
||||
attemptAny.answeredQuestionTokens = [...answeredTokens, token];
|
||||
attemptAny.answerDurations = [...(attemptAny.answerDurations || []), duration];
|
||||
attemptAny.answers = [
|
||||
...(attemptAny.answers || []),
|
||||
{
|
||||
questionToken: token,
|
||||
word: word._id,
|
||||
userAnswer: evaluation.userAnswer,
|
||||
correctAnswer: evaluation.correctAnswer,
|
||||
isCorrect: evaluation.isCorrect,
|
||||
submittedAt: new Date(submittedAtMs),
|
||||
duration,
|
||||
riskFlags,
|
||||
interactionSummary
|
||||
}
|
||||
];
|
||||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...riskFlags]));
|
||||
await attempt.save();
|
||||
|
||||
res.status(201).json({
|
||||
message: '答案已提交',
|
||||
result: {
|
||||
questionToken: token,
|
||||
wordId,
|
||||
word: evaluation.word,
|
||||
userAnswer: evaluation.userAnswer,
|
||||
correctAnswer: evaluation.correctAnswer,
|
||||
isCorrect: evaluation.isCorrect
|
||||
},
|
||||
progress: {
|
||||
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: '提交单题答案失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 选择题点击选项后换取该选项的一次性凭证
|
||||
router.post('/test-option-token', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { attemptId, questionToken, optionText } = req.body;
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
_id: attemptId,
|
||||
user: userId
|
||||
});
|
||||
|
||||
const answerSeen = new Set<string>();
|
||||
const evaluatedResults: EvaluatedVocabularyAnswer[] = [];
|
||||
const submittedAnswers: Array<{ token: string; userAnswer: string; submittedAt: number; answerProof: string }> = [];
|
||||
|
||||
for (const item of answers) {
|
||||
const token = String(item?.questionToken ?? '');
|
||||
const userAnswer = String(item?.userAnswer ?? '').trim().slice(0, 500);
|
||||
const answerProof = String(item?.answerProof ?? '');
|
||||
const submittedAtMs = Number.parseInt(String(item?.submittedAt), 10);
|
||||
|
||||
if (!token || !tokenIndexMap.has(token)) {
|
||||
return res.status(400).json({ message: '测试答案的题目标识无效' });
|
||||
}
|
||||
|
||||
if (answerSeen.has(token)) {
|
||||
return res.status(400).json({ message: '测试答案中存在重复题目' });
|
||||
}
|
||||
answerSeen.add(token);
|
||||
|
||||
if (!Number.isFinite(submittedAtMs)) {
|
||||
return res.status(400).json({ message: '答案提交时间无效' });
|
||||
}
|
||||
|
||||
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||||
return res.status(400).json({ message: '答案提交时间异常' });
|
||||
}
|
||||
|
||||
const index = tokenIndexMap.get(token)!;
|
||||
const wordId = getObjectIdString(attemptAny.questionWordIds[index]);
|
||||
if (!wordId) {
|
||||
return res.status(400).json({ message: '测试会话题目数据损坏' });
|
||||
}
|
||||
|
||||
if (!verifyQuestionToken(token, attemptId.toString(), wordId, index, issuedAt)) {
|
||||
return res.status(400).json({ message: '测试题目签名无效' });
|
||||
}
|
||||
|
||||
const expectedProof = buildAnswerProof(attemptId.toString(), token, userAnswer, submittedAtMs);
|
||||
if (!safeEqualString(expectedProof, answerProof)) {
|
||||
return res.status(400).json({ message: '答案校验失败' });
|
||||
}
|
||||
|
||||
submittedAnswers.push({ token, userAnswer, submittedAt: submittedAtMs, answerProof });
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
}
|
||||
|
||||
const lastSubmittedAtMs = Math.max(...submittedAnswers.map(item => item.submittedAt));
|
||||
const attemptAny = attempt as any;
|
||||
if (attemptAny.status !== 'active') {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
if (attemptAny.testType !== 'multiple-choice') {
|
||||
return res.status(400).json({ message: '当前题型不需要选项凭证' });
|
||||
}
|
||||
|
||||
const token = String(questionToken ?? '');
|
||||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||||
if (!token || tokenIndex < 0) {
|
||||
return res.status(400).json({ message: '测试题目标识无效' });
|
||||
}
|
||||
|
||||
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||||
const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? ''));
|
||||
if (selectedIndex < 0) {
|
||||
return res.status(400).json({ message: '选项无效' });
|
||||
}
|
||||
|
||||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||||
? attemptAny.answeredQuestionTokens
|
||||
: [];
|
||||
if (tokenIndex !== answeredTokens.length) {
|
||||
return res.status(400).json({ message: '请按题目顺序选择答案' });
|
||||
}
|
||||
|
||||
const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex];
|
||||
if (!optionToken) {
|
||||
return res.status(400).json({ message: '选项凭证不存在,请重新开始测试' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
questionToken: token,
|
||||
optionToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取选择项凭证失败:', error);
|
||||
res.status(500).json({ message: '获取选择项凭证失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { attemptId } = req.body;
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
if (!isAllowedVocabularyOrigin(req)) {
|
||||
return res.status(403).json({ message: '测试来源异常,请从官网页面完成测试' });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
}
|
||||
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
_id: attemptId,
|
||||
user: userId
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
}
|
||||
|
||||
const attemptAny = attempt as any;
|
||||
const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt);
|
||||
const expiresAt = new Date(attemptAny.expiresAt);
|
||||
const now = Date.now();
|
||||
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() < now) {
|
||||
attemptAny.status = 'expired';
|
||||
await attempt.save();
|
||||
return res.status(410).json({ message: '测试会话已过期,请重新开始测试' });
|
||||
}
|
||||
|
||||
if (attemptAny.status !== 'active') {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : [];
|
||||
if (submittedAnswers.length !== attemptAny.questionTokens.length) {
|
||||
return res.status(400).json({ 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, answers.length * MIN_SECONDS_PER_QUESTION);
|
||||
const minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
|
||||
if (elapsedSeconds < minimumSeconds) {
|
||||
return res.status(400).json({ message: '答题速度异常,请重新作答' });
|
||||
}
|
||||
|
||||
const questionWordIds = attemptAny.questionWordIds.map((wordId: any) => getObjectIdString(wordId));
|
||||
const wordMap = new Map<string, any>();
|
||||
const words = await Word.find({
|
||||
_id: { $in: questionWordIds },
|
||||
wordSet: attemptAny.wordSet
|
||||
});
|
||||
words.forEach(word => {
|
||||
wordMap.set(getObjectIdString(word), word);
|
||||
});
|
||||
|
||||
for (const item of submittedAnswers) {
|
||||
const index = tokenIndexMap.get(item.token)!;
|
||||
const wordId = questionWordIds[index];
|
||||
const word = wordMap.get(wordId);
|
||||
if (!word) {
|
||||
return res.status(400).json({ message: '测试会话中的单词已不存在' });
|
||||
}
|
||||
|
||||
const evaluation = evaluateWordAnswer(word, attemptAny.testType, item.userAnswer);
|
||||
evaluatedResults.push({
|
||||
...evaluation,
|
||||
wordId,
|
||||
word: evaluation.word
|
||||
});
|
||||
}
|
||||
const batchRiskFlags = 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));
|
||||
|
||||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||
{ _id: attempt._id, user: userId, status: 'active' },
|
||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs) } },
|
||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
@@ -847,12 +1137,32 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
for (const result of evaluatedResults) {
|
||||
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||
const submittedWordIds = submittedAnswers.map((item: any) => getObjectIdString(item.word));
|
||||
const submittedWords = await Word.find({ _id: { $in: submittedWordIds } }).select('_id word');
|
||||
const submittedWordMap = new Map<string, string>();
|
||||
submittedWords.forEach(word => {
|
||||
submittedWordMap.set(getObjectIdString(word), word.word);
|
||||
});
|
||||
|
||||
const evaluatedResults: EvaluatedVocabularyAnswer[] = submittedAnswers.map((item: any) => {
|
||||
const wordId = getObjectIdString(item.word);
|
||||
return {
|
||||
wordId,
|
||||
word: submittedWordMap.get(wordId) || '',
|
||||
userAnswer: item.userAnswer,
|
||||
correctAnswer: item.correctAnswer,
|
||||
isCorrect: Boolean(item.isCorrect)
|
||||
};
|
||||
});
|
||||
|
||||
if (!shouldInvalidateBatch) {
|
||||
for (const result of evaluatedResults) {
|
||||
await updateWordRecordForAnswer(userId, result.wordId, attemptAny.testType, result.isCorrect);
|
||||
}
|
||||
}
|
||||
|
||||
const totalWords = evaluatedResults.length;
|
||||
const correctWords = evaluatedResults.filter(result => result.isCorrect).length;
|
||||
const correctWords = shouldInvalidateBatch ? 0 : evaluatedResults.filter(result => result.isCorrect).length;
|
||||
const verifiedStats = {
|
||||
totalWords,
|
||||
correctWords,
|
||||
@@ -868,20 +1178,28 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
attempt: attempt._id,
|
||||
testType: attemptAny.testType,
|
||||
stats: verifiedStats,
|
||||
results: evaluatedResults.map(result => ({
|
||||
word: result.wordId,
|
||||
results: submittedAnswers.map((result: any) => ({
|
||||
word: result.word,
|
||||
userAnswer: result.userAnswer,
|
||||
correctAnswer: result.correctAnswer,
|
||||
isCorrect: result.isCorrect
|
||||
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||
}))
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: '测试记录已保存',
|
||||
message: shouldInvalidateBatch ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存',
|
||||
attemptId: attempt._id.toString(),
|
||||
stats: verifiedStats,
|
||||
results: evaluatedResults,
|
||||
submittedAt: new Date(lastSubmittedAtMs)
|
||||
results: submittedAnswers.map((result: any) => ({
|
||||
wordId: getObjectIdString(result.word),
|
||||
word: submittedWordMap.get(getObjectIdString(result.word)) || '',
|
||||
userAnswer: result.userAnswer,
|
||||
correctAnswer: result.correctAnswer,
|
||||
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||||
})),
|
||||
submittedAt: new Date(lastSubmittedAtMs),
|
||||
riskFlags: attemptAny.riskFlags || [],
|
||||
invalidated: shouldInvalidateBatch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存测试记录失败:', error);
|
||||
|
||||
Reference in New Issue
Block a user