fixed cheating michanism

This commit is contained in:
2026-05-24 20:11:00 +08:00
parent 0de72a629c
commit 6cac2f0c9a
4 changed files with 694 additions and 142 deletions

View File

@@ -65,6 +65,31 @@ export interface IVocabularyTestAttempt extends Document {
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
questionWordIds: mongoose.Types.ObjectId[];
questionTokens: string[];
optionTokens?: string[][];
optionTexts?: string[][];
answeredQuestionTokens?: string[];
answerDurations?: number[];
answers?: Array<{
questionToken: string;
word: mongoose.Types.ObjectId;
userAnswer: string;
correctAnswer: string;
isCorrect: boolean;
submittedAt: Date;
duration: number;
riskFlags: string[];
interactionSummary?: {
keyCount: number;
inputCount: number;
pasteCount: number;
focusCount: number;
blurCount: number;
pointerCount: number;
firstEventOffset: number;
lastEventOffset: number;
};
}>;
riskFlags?: string[];
issuedAt: Date;
expiresAt: Date;
submittedAt?: Date;
@@ -163,6 +188,31 @@ const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
},
questionWordIds: [{ type: Schema.Types.ObjectId, ref: 'Word', required: true }],
questionTokens: [{ type: String, required: true }],
optionTokens: [[{ type: String }]],
optionTexts: [[{ type: String }]],
answeredQuestionTokens: [{ type: String }],
answerDurations: [{ type: Number }],
answers: [{
questionToken: { type: String, required: true },
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
userAnswer: { type: String, default: '' },
correctAnswer: { type: String, required: true },
isCorrect: { type: Boolean, required: true },
submittedAt: { type: Date, required: true },
duration: { type: Number, required: true },
riskFlags: [{ type: String }],
interactionSummary: {
keyCount: { type: Number, default: 0 },
inputCount: { type: Number, default: 0 },
pasteCount: { type: Number, default: 0 },
focusCount: { type: Number, default: 0 },
blurCount: { type: Number, default: 0 },
pointerCount: { type: Number, default: 0 },
firstEventOffset: { type: Number, default: 0 },
lastEventOffset: { type: Number, default: 0 }
}
}],
riskFlags: [{ type: String }],
issuedAt: { type: Date, required: true },
expiresAt: { type: Date, required: true },
submittedAt: Date,

View File

@@ -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,34 +893,24 @@ 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 tokenIndexMap = new Map<string, number>();
attemptAny.questionTokens.forEach((token: string, index: number) => {
tokenIndexMap.set(token, index);
});
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)) {
const token = String(questionToken ?? '');
const tokenIndex = attemptAny.questionTokens.indexOf(token);
if (!token || tokenIndex < 0) {
return res.status(400).json({ message: '测试答案的题目标识无效' });
}
if (answerSeen.has(token)) {
return res.status(400).json({ message: '测试答案中存在重复题目' });
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
? attemptAny.answeredQuestionTokens
: [];
if (answeredTokens.includes(token)) {
return res.status(409).json({ message: '该题已提交,请继续下一题' });
}
answerSeen.add(token);
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: '答案提交时间无效' });
}
@@ -786,60 +919,217 @@ router.post('/test-record', authMiddleware, async (req, res) => {
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)) {
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
return res.status(400).json({ message: '测试题目签名无效' });
}
const expectedProof = buildAnswerProof(attemptId.toString(), token, userAnswer, submittedAtMs);
if (!safeEqualString(expectedProof, answerProof)) {
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: '答案校验失败' });
}
submittedAnswers.push({ token, userAnswer, submittedAt: submittedAtMs, answerProof });
}
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 lastSubmittedAtMs = Math.max(...submittedAnswers.map(item => item.submittedAt));
const elapsedSeconds = Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000);
const minimumSeconds = Math.max(2, answers.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 },
const word = await Word.findOne({
_id: wordId,
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 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
});
if (!attempt) {
return res.status(404).json({ message: '未找到测试会话' });
}
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, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
if (elapsedSeconds < minimumSeconds) {
return res.status(400).json({ message: '答题速度异常,请重新作答' });
}
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: '测试会话已提交或已失效' });
}
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);

View File

@@ -1,12 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Select, Table, Tag, InputNumber, Space } from 'antd';
import { Card, Button, Input, Progress, Modal, message, Tabs, Radio, Spin, Table, Tag, InputNumber, Space } from 'antd';
import { SoundOutlined, CaretLeftOutlined, CaretRightOutlined, TrophyOutlined, HistoryOutlined, ReloadOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
import CryptoJS from 'crypto-js';
import { useNavigate } from 'react-router-dom';
import { api, ApiError, authEvents } from '../api/apiClient';
import { API_PATHS } from '../config';
import type { TabsProps } from 'antd';
import type { RadioChangeEvent } from 'antd/lib/radio';
interface Word {
_id?: string;
@@ -74,13 +73,30 @@ interface PendingAnswer {
answerProof: string;
}
interface InteractionEventPayload {
type: string;
ts: number;
key?: string;
inputType?: string;
valueLength?: number;
}
interface NativeInputLikeEvent extends Event {
inputType?: string;
}
interface TestOption {
text: string;
token: string;
}
interface TestQuestion {
questionToken: string;
wordId: string;
word?: string;
translation?: string;
pronunciation?: string;
options?: string[];
options?: Array<string | TestOption>;
}
interface SavedVocabularyTestRecordResponse {
@@ -94,6 +110,8 @@ interface SavedVocabularyTestRecordResponse {
duration: number;
};
results: TestResult[];
riskFlags?: string[];
invalidated?: boolean;
}
interface TestAttemptResponse {
@@ -103,6 +121,22 @@ interface TestAttemptResponse {
questions: TestQuestion[];
}
interface SubmitAnswerResponse {
message: string;
result: TestResult;
progress: {
answered: number;
total: number;
finished: boolean;
};
riskFlags?: string[];
}
interface OptionTokenResponse {
questionToken: string;
optionToken: string;
}
const ANSWER_PROOF_SALT = 'd1ktsalt';
const VocabularyStudy: React.FC = () => {
@@ -130,7 +164,8 @@ const VocabularyStudy: React.FC = () => {
const [testAttemptId, setTestAttemptId] = useState<string>('');
const [pendingAnswers, setPendingAnswers] = useState<PendingAnswer[]>([]);
const [testAttemptLoading, setTestAttemptLoading] = useState(false);
const [options, setOptions] = useState<string[]>([]);
const [options, setOptions] = useState<TestOption[]>([]);
const [testRiskFlags, setTestRiskFlags] = useState<string[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const timeOffsetRef = useRef<number>(0);
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
@@ -145,6 +180,8 @@ const VocabularyStudy: React.FC = () => {
// 使用ref存储当前选中ID这样可以立即访问
const currentWordSetIdRef = useRef<string>('');
const answerSubmittingRef = useRef(false);
const interactionsRef = useRef<InteractionEventPayload[]>([]);
const questionShownAtRef = useRef<number>(0);
// 测试输入框ref
const inputRef = useRef<any>(null);
@@ -457,7 +494,9 @@ const VocabularyStudy: React.FC = () => {
setShowAnswer(false);
setTestResults([]);
setPendingAnswers([]);
setOptions(questions[0]?.options || []);
setOptions(normalizeOptions(questions[0]));
setTestRiskFlags([]);
resetInteractionTrace();
setIsModalVisible(false);
setStudyStats({
totalWords: 0,
@@ -489,6 +528,72 @@ const VocabularyStudy: React.FC = () => {
const getWordId = (word: Word): string => word._id || word.id;
const normalizeOptions = (question?: TestQuestion): TestOption[] => {
if (!question?.options) return [];
return question.options.map(option => {
if (typeof option === 'string') {
return { text: option, token: '' };
}
return option;
});
};
const resetInteractionTrace = () => {
const now = Date.now();
interactionsRef.current = [{
type: 'question-shown',
ts: now
}];
questionShownAtRef.current = now;
};
const recordInteraction = (event: InteractionEventPayload) => {
if (!testStarted || testFinished || showAnswer) return;
interactionsRef.current = [
...interactionsRef.current,
{
...event,
ts: Date.now()
}
].slice(-300);
};
const handleMultipleChoiceChange = async (option: TestOption) => {
const currentQuestion = testWords[testIndex];
if (!currentQuestion || !testAttemptId || showAnswer) return;
recordInteraction({
type: 'change',
valueLength: option.text.length
});
if (option.token) {
setUserAnswer(option.token);
return;
}
try {
const response = await api.post<OptionTokenResponse>(API_PATHS.VOCABULARY.TEST_OPTION_TOKEN, {
attemptId: testAttemptId,
questionToken: currentQuestion.questionToken,
optionText: option.text
});
setOptions(prev => prev.map(item =>
item.text === option.text
? { ...item, token: response.optionToken }
: item
));
setUserAnswer(response.optionToken);
} catch (error) {
if (error instanceof ApiError) {
message.error(`获取选项凭证失败: ${error.message}`);
} else {
message.error('获取选项凭证失败');
}
}
};
const buildAnswerProof = (
attemptId: string,
questionToken: string,
@@ -508,6 +613,11 @@ const VocabularyStudy: React.FC = () => {
answerSubmittingRef.current = false;
return;
}
if (testType === 'multiple-choice' && options.length > 0 && options.every(option => option.token !== userAnswer)) {
answerSubmittingRef.current = false;
message.warning('请选择一个选项后再提交');
return;
}
const submittedAt = Date.now();
const normalizedUserAnswer = userAnswer.trim();
const answerProof = buildAnswerProof(
@@ -517,6 +627,20 @@ const VocabularyStudy: React.FC = () => {
submittedAt
);
try {
const submittedAnswer = {
questionToken: currentQuestion.questionToken,
userAnswer: normalizedUserAnswer,
submittedAt,
answerProof,
interactions: interactionsRef.current
};
const response = await api.post<SubmitAnswerResponse>(API_PATHS.VOCABULARY.TEST_ANSWER, {
attemptId: testAttemptId,
...submittedAnswer
});
const updatedPendingAnswers = [
...pendingAnswers,
{
@@ -528,6 +652,8 @@ const VocabularyStudy: React.FC = () => {
];
setPendingAnswers(updatedPendingAnswers);
setTestResults(prev => [...prev, response.result]);
setTestRiskFlags(prev => Array.from(new Set([...prev, ...(response.riskFlags || [])])));
setShowAnswer(true);
setUserAnswer('');
@@ -536,6 +662,7 @@ const VocabularyStudy: React.FC = () => {
const nextIndex = testIndex + 1;
setTestIndex(nextIndex);
setShowAnswer(false);
resetInteractionTrace();
answerSubmittingRef.current = false;
if (testType === 'audio-to-english') {
setTimeout(() => {
@@ -543,13 +670,21 @@ const VocabularyStudy: React.FC = () => {
}, 500);
}
if (testType === 'multiple-choice') {
setOptions(testWords[nextIndex]?.options || []);
setOptions(normalizeOptions(testWords[nextIndex]));
}
} else {
answerSubmittingRef.current = false;
finishTestWithResults(updatedPendingAnswers);
}
}, 1500);
} catch (error) {
answerSubmittingRef.current = false;
if (error instanceof ApiError) {
message.error(`提交答案失败: ${error.message}`);
} else {
message.error('提交答案失败');
}
}
};
// 使用指定结果完成测试
@@ -563,15 +698,9 @@ const VocabularyStudy: React.FC = () => {
currentStudyStats: studyStats
});
// 提交测试记录,后端会用数据库答案重新判分
// 提交测试记录,后端会用服务端累计的逐题答案重新判分
const savedRecord = await api.post<SavedVocabularyTestRecordResponse>(API_PATHS.VOCABULARY.TEST_RECORD, {
attemptId: testAttemptId,
answers: finalAnswers.map(answer => ({
questionToken: answer.questionToken,
userAnswer: answer.userAnswer,
submittedAt: answer.submittedAt,
answerProof: answer.answerProof
}))
attemptId: testAttemptId
});
setStudyStats({
@@ -583,12 +712,17 @@ const VocabularyStudy: React.FC = () => {
duration: savedRecord.stats.duration
});
setTestResults(savedRecord.results);
setTestRiskFlags(savedRecord.riskFlags || []);
setPendingAnswers([]);
setTestAttemptId('');
setShowAnswer(false);
setTestFinished(true);
if (savedRecord.invalidated) {
message.warning('测试行为异常,本次不计入掌握');
} else {
message.success('测试完成,记录已保存');
}
setIsModalVisible(true);
} catch (error) {
if (error instanceof ApiError) {
@@ -617,6 +751,9 @@ const VocabularyStudy: React.FC = () => {
setTestResults([]);
setPendingAnswers([]);
setOptions([]);
setTestRiskFlags([]);
interactionsRef.current = [];
questionShownAtRef.current = 0;
answerSubmittingRef.current = false;
// 重置统计信息
setStudyStats({
@@ -740,10 +877,16 @@ const VocabularyStudy: React.FC = () => {
testType === 'multiple-choice' &&
testWords.length > 0
) {
setOptions(testWords[testIndex]?.options || []);
setOptions(normalizeOptions(testWords[testIndex]));
}
}, [testIndex, testType, testStarted, testFinished, testWords]);
useEffect(() => {
if (testStarted && !testFinished && testWords.length > 0) {
resetInteractionTrace();
}
}, [testIndex, testStarted, testFinished, testWords.length]);
// 自动播放第一个听力单词
useEffect(() => {
if (
@@ -1124,6 +1267,19 @@ const VocabularyStudy: React.FC = () => {
/>
<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' && (
<div style={{ fontSize: 24, marginBottom: 15, textAlign: 'center' }}>
<div style={{ color: '#666', fontSize: '0.8em', marginBottom: 5 }}></div>
@@ -1185,12 +1341,19 @@ const VocabularyStudy: React.FC = () => {
<Radio.Group
value={userAnswer}
disabled={showAnswer}
onChange={e => setUserAnswer(e.target.value)}
style={{ width: '100%', display: 'flex', flexDirection: 'column' }}
>
{options.map(option => (
<Radio key={option} value={option} style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}>
{option}
<Radio
key={option.text}
value={option.token || option.text}
onClick={() => {
recordInteraction({ type: 'click', valueLength: option.text.length });
handleMultipleChoiceChange(option);
}}
style={{ marginBottom: 10, height: 'auto', padding: '8px 5px' }}
>
{option.text}
</Radio>
))}
</Radio.Group>
@@ -1199,7 +1362,23 @@ const VocabularyStudy: React.FC = () => {
ref={inputRef}
placeholder="请输入英文单词"
value={userAnswer}
onChange={e => setUserAnswer(e.target.value)}
onChange={e => {
recordInteraction({
type: 'input',
inputType: (e.nativeEvent as NativeInputLikeEvent)?.inputType,
valueLength: e.target.value.length
});
setUserAnswer(e.target.value);
}}
onKeyDown={e => {
recordInteraction({
type: 'keydown',
key: e.key,
valueLength: userAnswer.length
});
}}
onFocus={() => recordInteraction({ type: 'focus', valueLength: userAnswer.length })}
onBlur={() => recordInteraction({ type: 'blur', valueLength: userAnswer.length })}
disabled={showAnswer}
style={{ marginBottom: 15 }}
onPressEnter={e => {
@@ -1208,7 +1387,10 @@ const VocabularyStudy: React.FC = () => {
}}
autoFocus
size="large"
onPaste={e => e.preventDefault()}
onPaste={e => {
recordInteraction({ type: 'paste', valueLength: userAnswer.length });
e.preventDefault();
}}
/>
)}

View File

@@ -49,6 +49,8 @@ export const API_PATHS = {
STUDY_WORDS: '/vocabulary/study-words',
UPLOAD: '/vocabulary/upload',
TEST_ATTEMPT: '/vocabulary/test-attempt',
TEST_ANSWER: '/vocabulary/test-answer',
TEST_OPTION_TOKEN: '/vocabulary/test-option-token',
WORD_RECORD: '/vocabulary/word-record',
TEST_RECORD: '/vocabulary/test-record',
STUDY_RECORDS: '/vocabulary/test-records',