fixed fake post attack
This commit is contained in:
@@ -3,14 +3,299 @@ import express from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { auth as authMiddleware } from '../middleware/auth';
|
||||
import { Word, WordSet, WordRecord, VocabularyTestRecord } from '../models/Vocabulary';
|
||||
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();
|
||||
|
||||
type VocabularyTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||
type WordRecordModeKey = 'chineseToEnglish' | 'audioToEnglish' | 'multipleChoice';
|
||||
|
||||
interface EvaluatedVocabularyAnswer {
|
||||
wordId: string;
|
||||
word: string;
|
||||
userAnswer: string;
|
||||
correctAnswer: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface VocabularyAttemptQuestion {
|
||||
questionToken: string;
|
||||
wordId: string;
|
||||
word?: string;
|
||||
translation?: string;
|
||||
pronunciation?: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
const TEST_TYPE_TO_MODE: Record<VocabularyTestType, WordRecordModeKey> = {
|
||||
'chinese-to-english': 'chineseToEnglish',
|
||||
'audio-to-english': 'audioToEnglish',
|
||||
'multiple-choice': 'multipleChoice'
|
||||
};
|
||||
|
||||
const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
||||
'chineseToEnglish',
|
||||
'audioToEnglish',
|
||||
'multipleChoice'
|
||||
];
|
||||
|
||||
const ATTEMPT_TTL_MS = 30 * 60 * 1000;
|
||||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 0.4);
|
||||
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||||
|
||||
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
||||
return value === 'chinese-to-english' ||
|
||||
value === 'audio-to-english' ||
|
||||
value === 'multiple-choice';
|
||||
};
|
||||
|
||||
const shuffle = <T,>(array: T[]): T[] => {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
|
||||
const signQuestionToken = (
|
||||
attemptId: string,
|
||||
wordId: string,
|
||||
index: number,
|
||||
issuedAt: Date
|
||||
): string => {
|
||||
const issuedAtMs = issuedAt.getTime();
|
||||
const payload = `${attemptId}:${wordId}:${index}:${issuedAtMs}`;
|
||||
const signature = crypto
|
||||
.createHmac('sha256', ATTEMPT_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
return `${index}.${issuedAtMs}.${signature}`;
|
||||
};
|
||||
|
||||
const safeEqualString = (left: string, right: string): boolean => {
|
||||
const leftBuffer = Buffer.from(left || '');
|
||||
const rightBuffer = Buffer.from(right || '');
|
||||
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
const verifyQuestionToken = (
|
||||
token: string,
|
||||
attemptId: string,
|
||||
wordId: string,
|
||||
index: number,
|
||||
issuedAt: Date
|
||||
): boolean => {
|
||||
const expected = signQuestionToken(attemptId, wordId, index, issuedAt);
|
||||
return safeEqualString(expected, token);
|
||||
};
|
||||
|
||||
const buildAnswerProof = (
|
||||
attemptId: string,
|
||||
questionToken: string,
|
||||
userAnswer: string,
|
||||
submittedAt: number
|
||||
): string => crypto
|
||||
.createHash('sha256')
|
||||
.update(`${attemptId}:${questionToken}:${submittedAt}:${String(userAnswer ?? '')}`)
|
||||
.digest('hex');
|
||||
|
||||
const buildMultipleChoiceOptions = (word: any, pool: any[]): string[] => {
|
||||
const correctTranslation = String(word.translation ?? '').trim();
|
||||
let incorrectOptions = shuffle(
|
||||
pool
|
||||
.filter(item => getObjectIdString(item) !== getObjectIdString(word))
|
||||
.map(item => String(item.translation ?? '').trim())
|
||||
.filter(item => item && item !== correctTranslation)
|
||||
).slice(0, 3);
|
||||
|
||||
if (incorrectOptions.length < 3) {
|
||||
const fakeOptions = ['假选项1', '假选项2', '假选项3', '假选项4', '假选项5']
|
||||
.filter(item => item !== correctTranslation && !incorrectOptions.includes(item));
|
||||
incorrectOptions = [...incorrectOptions, ...fakeOptions].slice(0, 3);
|
||||
}
|
||||
|
||||
return shuffle([correctTranslation, ...incorrectOptions]);
|
||||
};
|
||||
|
||||
const buildQuestionPayload = (
|
||||
word: any,
|
||||
testType: VocabularyTestType,
|
||||
questionToken: string,
|
||||
optionsPool: any[]
|
||||
): VocabularyAttemptQuestion => {
|
||||
const base = {
|
||||
questionToken,
|
||||
wordId: getObjectIdString(word),
|
||||
pronunciation: word.pronunciation
|
||||
};
|
||||
|
||||
if (testType === 'chinese-to-english') {
|
||||
return {
|
||||
...base,
|
||||
translation: word.translation
|
||||
};
|
||||
}
|
||||
|
||||
if (testType === 'audio-to-english') {
|
||||
return {
|
||||
...base,
|
||||
word: word.word
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
word: word.word,
|
||||
options: buildMultipleChoiceOptions(word, optionsPool)
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeEnglishAnswer = (value: unknown): string => {
|
||||
return String(value ?? '')
|
||||
.replace(/(/g, '(')
|
||||
.replace(/)/g, ')')
|
||||
.replace(/,/g, ',')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '')
|
||||
.replace(/[^\w()]/g, '')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const evaluateWordAnswer = (word: any, testType: VocabularyTestType, answer: unknown): EvaluatedVocabularyAnswer => {
|
||||
const userAnswer = String(answer ?? '').trim().slice(0, 500);
|
||||
const correctAnswer = testType === 'multiple-choice'
|
||||
? String(word.translation ?? '').trim()
|
||||
: String(word.word ?? '').trim();
|
||||
const isCorrect = testType === 'multiple-choice'
|
||||
? userAnswer === correctAnswer
|
||||
: normalizeEnglishAnswer(userAnswer) === normalizeEnglishAnswer(correctAnswer);
|
||||
|
||||
return {
|
||||
wordId: word._id.toString(),
|
||||
word: word.word,
|
||||
userAnswer,
|
||||
correctAnswer,
|
||||
isCorrect
|
||||
};
|
||||
};
|
||||
|
||||
const buildMasteryStatus = (record: any) => {
|
||||
const masteryStatus: Record<string, any> = {};
|
||||
WORD_RECORD_MODES.forEach(mode => {
|
||||
masteryStatus[mode] = {
|
||||
mastered: record[mode]?.mastered || false,
|
||||
streak: record[mode]?.streak || 0,
|
||||
totalCorrect: record[mode]?.totalCorrect || 0,
|
||||
totalWrong: record[mode]?.totalWrong || 0,
|
||||
inWrongBook: record[mode]?.inWrongBook || false,
|
||||
lastMasteredAt: record[mode]?.lastMasteredAt || null
|
||||
};
|
||||
});
|
||||
return masteryStatus;
|
||||
};
|
||||
|
||||
const updateWordRecordForAnswer = async (
|
||||
userId: string,
|
||||
wordId: string,
|
||||
testType: VocabularyTestType,
|
||||
isCorrect: boolean
|
||||
) => {
|
||||
const modeKey = TEST_TYPE_TO_MODE[testType];
|
||||
|
||||
let record: any = await WordRecord.findOne({ user: userId, word: wordId });
|
||||
const modeObj = record ? { ...(record[modeKey]?.toObject?.() || record[modeKey] || {}) } : {};
|
||||
|
||||
if (isCorrect) {
|
||||
modeObj.streak = (modeObj.streak || 0) + 1;
|
||||
modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1;
|
||||
} else {
|
||||
modeObj.streak = 0;
|
||||
modeObj.totalWrong = (modeObj.totalWrong || 0) + 1;
|
||||
}
|
||||
modeObj.lastTestedAt = new Date();
|
||||
|
||||
if (modeObj.streak >= 1) {
|
||||
modeObj.mastered = true;
|
||||
modeObj.lastMasteredAt = new Date();
|
||||
modeObj.inWrongBook = false;
|
||||
}
|
||||
|
||||
if (!modeObj.mastered && modeObj.totalWrong >= 5) {
|
||||
modeObj.inWrongBook = true;
|
||||
}
|
||||
|
||||
const updateObj: Record<string, any> = {};
|
||||
updateObj[modeKey] = modeObj;
|
||||
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ user: userId, word: wordId },
|
||||
{ $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
const recordAny = record as any;
|
||||
const isFullyMastered = WORD_RECORD_MODES.every(mode => recordAny[mode]?.mastered);
|
||||
|
||||
if (isFullyMastered) {
|
||||
let latestMasteredAt: Date | null = null;
|
||||
WORD_RECORD_MODES.forEach(mode => {
|
||||
[recordAny[mode]?.lastMasteredAt, recordAny[mode]?.lastTestedAt].forEach(value => {
|
||||
if (!value) return;
|
||||
const candidate = new Date(value);
|
||||
if (!Number.isNaN(candidate.getTime()) && (!latestMasteredAt || candidate > latestMasteredAt)) {
|
||||
latestMasteredAt = candidate;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!recordAny.isFullyMastered || !recordAny.lastFullyMasteredAt ||
|
||||
(latestMasteredAt && recordAny.lastFullyMasteredAt < latestMasteredAt)) {
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: recordAny._id },
|
||||
{
|
||||
$set: {
|
||||
isFullyMastered: true,
|
||||
lastFullyMasteredAt: latestMasteredAt
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
} else if (recordAny.isFullyMastered) {
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: recordAny._id },
|
||||
{ $set: { isFullyMastered: false } },
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedRecord = record as any;
|
||||
return {
|
||||
masteryStatus: buildMasteryStatus(updatedRecord),
|
||||
isFullyMastered: updatedRecord.isFullyMastered,
|
||||
lastMasteredAt: updatedRecord.lastFullyMasteredAt,
|
||||
inWrongBook: WORD_RECORD_MODES.some(mode => updatedRecord[mode]?.inWrongBook)
|
||||
};
|
||||
};
|
||||
|
||||
const getClientStartTime = (stats: any, fallbackTime: number): Date => {
|
||||
const parsed = stats?.startTime ? new Date(stats.startTime).getTime() : NaN;
|
||||
const maxDuration = 4 * 60 * 60 * 1000;
|
||||
if (Number.isFinite(parsed) && parsed <= fallbackTime && fallbackTime - parsed <= maxDuration) {
|
||||
return new Date(parsed);
|
||||
}
|
||||
return new Date(fallbackTime);
|
||||
};
|
||||
|
||||
// 配置 multer 用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -315,177 +600,288 @@ router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 创建测试会话:服务端固定题目顺序并签发每题防伪token
|
||||
router.post('/test-attempt', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordSetId, testType, wordIds, count } = req.body;
|
||||
const userId = req.user?._id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
if (!mongoose.isValidObjectId(wordSetId)) {
|
||||
return res.status(400).json({ message: '单词集ID无效' });
|
||||
}
|
||||
|
||||
if (!isVocabularyTestType(testType)) {
|
||||
return res.status(400).json({ message: '未知的测试类型' });
|
||||
}
|
||||
|
||||
const wordSet = await WordSet.findById(wordSetId);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
let selectedWords: any[] = [];
|
||||
const allWords = await Word.find({ wordSet: wordSetId });
|
||||
|
||||
if (Array.isArray(wordIds) && wordIds.length > 0) {
|
||||
if (wordIds.length > 100) {
|
||||
return res.status(400).json({ message: '单次测试最多100道题' });
|
||||
}
|
||||
|
||||
if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) {
|
||||
return res.status(400).json({ message: '包含无效单词ID' });
|
||||
}
|
||||
|
||||
const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
|
||||
if (uniqueWordIds.length !== wordIds.length) {
|
||||
return res.status(400).json({ message: '测试单词不能重复' });
|
||||
}
|
||||
|
||||
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
||||
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
||||
|
||||
if (selectedWords.some(word => !word)) {
|
||||
return res.status(400).json({ message: '测试单词必须属于当前单词集' });
|
||||
}
|
||||
|
||||
selectedWords = shuffle(selectedWords);
|
||||
} else {
|
||||
const parsedCount = Number.parseInt(String(count || 100), 10);
|
||||
const targetCount = Number.isFinite(parsedCount)
|
||||
? Math.max(1, Math.min(parsedCount, 100))
|
||||
: 100;
|
||||
selectedWords = shuffle(allWords).slice(0, targetCount);
|
||||
}
|
||||
|
||||
if (selectedWords.length === 0) {
|
||||
return res.status(400).json({ message: '没有可测试的单词' });
|
||||
}
|
||||
|
||||
const issuedAt = new Date();
|
||||
const expiresAt = new Date(issuedAt.getTime() + ATTEMPT_TTL_MS);
|
||||
const attempt = await VocabularyTestAttempt.create({
|
||||
user: userId,
|
||||
wordSet: wordSetId,
|
||||
testType,
|
||||
questionWordIds: selectedWords.map(word => word._id),
|
||||
questionTokens: [],
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const attemptId = attempt._id.toString();
|
||||
const questionTokens = selectedWords.map((word, index) =>
|
||||
signQuestionToken(attemptId, getObjectIdString(word), index, issuedAt)
|
||||
);
|
||||
|
||||
attempt.questionTokens = questionTokens;
|
||||
await attempt.save();
|
||||
|
||||
const questions = selectedWords.map((word, index) =>
|
||||
buildQuestionPayload(word, testType, questionTokens[index], allWords)
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
attemptId,
|
||||
testType,
|
||||
expiresAt,
|
||||
questions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建测试会话失败:', error);
|
||||
res.status(500).json({ message: '创建测试会话失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 记录单词学习结果(嵌套结构版,upsert防止重复)
|
||||
router.post('/word-record', (req, res, next) => {
|
||||
console.log('收到 /word-record 请求', req.method, req.body);
|
||||
next();
|
||||
}, authMiddleware, async (req, res) => {
|
||||
return res.status(410).json({
|
||||
message: '单题记录接口已停用,请通过测试会话提交记录'
|
||||
});
|
||||
});
|
||||
|
||||
// 保存测试记录:只能通过服务端签发的 attempt 提交
|
||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordId, isCorrect, testType } = req.body;
|
||||
const userId = req.user._id;
|
||||
const { attemptId, answers } = req.body;
|
||||
const userId = req.user?._id;
|
||||
|
||||
// 检查单词是否存在
|
||||
const word = await Word.findById(wordId);
|
||||
if (!word) {
|
||||
return res.status(404).json({ message: '未找到单词' });
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: '用户信息无效' });
|
||||
}
|
||||
|
||||
// 嵌套字段名映射
|
||||
const modeMap = {
|
||||
'chinese-to-english': 'chineseToEnglish',
|
||||
'audio-to-english': 'audioToEnglish',
|
||||
'multiple-choice': 'multipleChoice'
|
||||
};
|
||||
const modeKey = modeMap[testType];
|
||||
if (!modeKey) {
|
||||
return res.status(400).json({ message: '未知的测试类型' });
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '测试会话ID无效' });
|
||||
}
|
||||
|
||||
// 先查当前记录
|
||||
let record = await WordRecord.findOne({ user: userId, word: wordId });
|
||||
let modeObj = record ? (record[modeKey] || {}) : {};
|
||||
|
||||
// 更新 streak、totalCorrect、totalWrong
|
||||
if (isCorrect) {
|
||||
modeObj.streak = (modeObj.streak || 0) + 1;
|
||||
modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1;
|
||||
} else {
|
||||
modeObj.streak = 0;
|
||||
modeObj.totalWrong = (modeObj.totalWrong || 0) + 1;
|
||||
}
|
||||
modeObj.lastTestedAt = new Date();
|
||||
|
||||
// 判定是否掌握
|
||||
if (modeObj.streak >= 1) {
|
||||
modeObj.mastered = true;
|
||||
modeObj.lastMasteredAt = new Date();
|
||||
modeObj.inWrongBook = false; // 掌握后自动移出错词本
|
||||
if (!Array.isArray(answers) || answers.length === 0) {
|
||||
return res.status(400).json({ message: '测试答案不能为空' });
|
||||
}
|
||||
|
||||
// 判定是否进入错词本
|
||||
if (!modeObj.mastered && modeObj.totalWrong >= 5) {
|
||||
modeObj.inWrongBook = true;
|
||||
const attempt = await VocabularyTestAttempt.findOne({
|
||||
_id: attemptId,
|
||||
user: userId
|
||||
});
|
||||
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '未找到测试会话' });
|
||||
}
|
||||
|
||||
// 构造更新对象
|
||||
const updateObj = {};
|
||||
updateObj[modeKey] = modeObj;
|
||||
const attemptAny = attempt as any;
|
||||
const issuedAt = new Date(attemptAny.issuedAt || attemptAny.createdAt);
|
||||
const expiresAt = new Date(attemptAny.expiresAt);
|
||||
const now = Date.now();
|
||||
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ user: userId, word: wordId },
|
||||
{ $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
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: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
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)) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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 },
|
||||
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 claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||||
{ _id: attempt._id, user: userId, status: 'active' },
|
||||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs) } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// 检查三种模式是否都已掌握
|
||||
const allModes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'];
|
||||
const isFullyMastered = allModes.every(m => record[m]?.mastered);
|
||||
let lastMasteredAt: Date | null = null;
|
||||
if (isFullyMastered) {
|
||||
// 取三种模式中最近一次掌握的时间
|
||||
lastMasteredAt = allModes.reduce((latest: Date | null, m) => {
|
||||
const t = record[m]?.lastMasteredAt;
|
||||
if (!latest && t) return t;
|
||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
||||
return t;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
// 取三种模式中最近一次测试的时间,如果比掌握时间更新则使用测试时间
|
||||
const lastTestedAt = allModes.reduce((latest: Date | null, m) => {
|
||||
const t = record[m]?.lastTestedAt;
|
||||
if (!latest && t) return t;
|
||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
||||
return t;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
// 使用最新的日期(掌握时间或测试时间)
|
||||
if (lastTestedAt && (!lastMasteredAt || lastTestedAt > lastMasteredAt)) {
|
||||
lastMasteredAt = lastTestedAt;
|
||||
}
|
||||
|
||||
// 更新isFullyMastered和lastFullyMasteredAt字段
|
||||
if (!record.isFullyMastered || !record.lastFullyMasteredAt ||
|
||||
(lastMasteredAt && record.lastFullyMasteredAt < lastMasteredAt)) {
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: record._id },
|
||||
{
|
||||
$set: {
|
||||
isFullyMastered: true,
|
||||
lastFullyMasteredAt: lastMasteredAt
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
} else if (record.isFullyMastered) {
|
||||
// 如果之前标记为完全掌握,但现在不是,更新状态
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: record._id },
|
||||
{ $set: { isFullyMastered: false } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!claimedAttempt) {
|
||||
return res.status(409).json({ message: '测试会话已提交或已失效' });
|
||||
}
|
||||
|
||||
// 返回当前单词的所有模式掌握状态和是否在错词本
|
||||
const masteryStatus = {};
|
||||
allModes.forEach(m => {
|
||||
masteryStatus[m] = {
|
||||
mastered: record[m]?.mastered || false,
|
||||
streak: record[m]?.streak || 0,
|
||||
totalCorrect: record[m]?.totalCorrect || 0,
|
||||
totalWrong: record[m]?.totalWrong || 0,
|
||||
inWrongBook: record[m]?.inWrongBook || false,
|
||||
lastMasteredAt: record[m]?.lastMasteredAt || null
|
||||
};
|
||||
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 verifiedStats = {
|
||||
totalWords,
|
||||
correctWords,
|
||||
accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
|
||||
startTime: issuedAt,
|
||||
endTime: new Date(lastSubmittedAtMs),
|
||||
duration: Math.max(0, (lastSubmittedAtMs - issuedAt.getTime()) / 1000)
|
||||
};
|
||||
|
||||
await VocabularyTestRecord.create({
|
||||
user: userId,
|
||||
wordSet: attemptAny.wordSet,
|
||||
attempt: attempt._id,
|
||||
testType: attemptAny.testType,
|
||||
stats: verifiedStats,
|
||||
results: evaluatedResults.map(result => ({
|
||||
word: result.wordId,
|
||||
userAnswer: result.userAnswer,
|
||||
correctAnswer: result.correctAnswer,
|
||||
isCorrect: result.isCorrect
|
||||
}))
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: '记录已保存',
|
||||
masteryStatus,
|
||||
isFullyMastered: record.isFullyMastered,
|
||||
lastMasteredAt: record.lastFullyMasteredAt,
|
||||
inWrongBook: allModes.some(m => record[m]?.inWrongBook)
|
||||
message: '测试记录已保存',
|
||||
attemptId: attempt._id.toString(),
|
||||
stats: verifiedStats,
|
||||
results: evaluatedResults,
|
||||
submittedAt: new Date(lastSubmittedAtMs)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存单词学习记录失败:', error);
|
||||
res.status(500).json({ message: '保存单词学习记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 保存测试记录
|
||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordSetId, testType, stats } = req.body;
|
||||
|
||||
// 检查单词集是否存在并属于当前用户
|
||||
const wordSet = await WordSet.findOne({
|
||||
_id: wordSetId
|
||||
});
|
||||
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
// 创建测试记录
|
||||
await VocabularyTestRecord.create({
|
||||
user: req.user._id,
|
||||
wordSet: wordSetId,
|
||||
testType,
|
||||
stats: {
|
||||
totalWords: stats.totalWords,
|
||||
correctWords: stats.correctWords,
|
||||
accuracy: stats.accuracy,
|
||||
startTime: new Date(stats.startTime),
|
||||
endTime: new Date(stats.endTime),
|
||||
duration: stats.duration
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ message: '测试记录已保存' });
|
||||
} catch (error) {
|
||||
console.error('保存测试记录失败:', error);
|
||||
res.status(500).json({ message: '保存测试记录失败' });
|
||||
@@ -696,4 +1092,4 @@ router.put('/words', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user