1480 lines
48 KiB
TypeScript
1480 lines
48 KiB
TypeScript
// server/routes/vocabulary.ts
|
||
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, VocabularyTestAttempt } from '../models/Vocabulary';
|
||
import mongoose from 'mongoose';
|
||
import csv from 'csv-parser';
|
||
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 VocabularyOptionPayload {
|
||
token: string;
|
||
text: string;
|
||
}
|
||
|
||
interface VocabularyAttemptQuestion {
|
||
questionToken: string;
|
||
wordId: string;
|
||
word?: string;
|
||
translation?: string;
|
||
pronunciation?: string;
|
||
options?: Array<string | VocabularyOptionPayload>;
|
||
optionTokenRequest?: boolean;
|
||
}
|
||
|
||
interface InteractionEventPayload {
|
||
type?: string;
|
||
key?: string;
|
||
inputType?: string;
|
||
value?: string;
|
||
valueLength?: number;
|
||
ctrlKey?: boolean;
|
||
metaKey?: boolean;
|
||
altKey?: boolean;
|
||
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> = {
|
||
'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_STUDY_WORD_COUNT = 10;
|
||
const MAX_STUDY_WORD_COUNT = 100;
|
||
const MIN_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MIN_SECONDS_PER_QUESTION || 2);
|
||
const MAX_SECONDS_PER_QUESTION = Number(process.env.VOCABULARY_MAX_SECONDS_PER_QUESTION || 10);
|
||
const VOCABULARY_ALLOWED_ORIGINS = (process.env.VOCABULARY_ALLOWED_ORIGINS || 'https://d1kt.cn,http://localhost:3000,http://localhost:3001')
|
||
.split(',')
|
||
.map(item => item.trim())
|
||
.filter(Boolean);
|
||
const ATTEMPT_SECRET = process.env.VOCABULARY_ATTEMPT_SECRET || config.JWT_SECRET;
|
||
const ANSWER_PROOF_SALT = 'd1ktsalt';
|
||
const INVALID_CREDENTIAL_MESSAGE = '凭证无效,请检查。';
|
||
|
||
const isVocabularyTestType = (value: unknown): value is VocabularyTestType => {
|
||
return value === 'chinese-to-english' ||
|
||
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 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,
|
||
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 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 || '');
|
||
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('md5')
|
||
.update(`${ANSWER_PROOF_SALT}:${attemptId}:${questionToken}:${submittedAt}:${String(userAnswer ?? '')}:${ANSWER_PROOF_SALT}`)
|
||
.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[],
|
||
suppliedOptions?: Array<string | VocabularyOptionPayload>
|
||
): 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
|
||
};
|
||
}
|
||
|
||
const options = suppliedOptions || buildMultipleChoiceOptions(word, optionsPool);
|
||
|
||
return {
|
||
...base,
|
||
word: word.word,
|
||
options,
|
||
optionTokenRequest: options.some(option => typeof option === 'string')
|
||
};
|
||
};
|
||
|
||
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 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 buildKeySequenceText = (events: InteractionEventPayload[]): string => {
|
||
let text = '';
|
||
|
||
events.forEach(event => {
|
||
if (event?.type !== 'keydown' || event.ctrlKey || event.metaKey || event.altKey) return;
|
||
|
||
const key = String(event.key ?? '');
|
||
if (key === 'Backspace') {
|
||
text = text.slice(0, -1);
|
||
return;
|
||
}
|
||
if (key === 'Spacebar') {
|
||
text += ' ';
|
||
return;
|
||
}
|
||
if (key.length === 1) {
|
||
text += key;
|
||
}
|
||
});
|
||
|
||
return text.slice(0, 500);
|
||
};
|
||
|
||
const analyzeInputTraceRisk = (
|
||
events: InteractionEventPayload[],
|
||
userAnswer: string,
|
||
testType: VocabularyTestType
|
||
): string[] => {
|
||
if (testType === 'multiple-choice') return [];
|
||
|
||
const flags: string[] = [];
|
||
const expectedAnswer = normalizeEnglishAnswer(userAnswer);
|
||
if (!expectedAnswer) return flags;
|
||
|
||
const safeEvents = Array.isArray(events) ? events.slice(0, 300) : [];
|
||
const inputValues = safeEvents
|
||
.filter(event => event?.type === 'input' && typeof event.value === 'string')
|
||
.map(event => String(event.value ?? '').slice(0, 500));
|
||
const lastInputValue = inputValues[inputValues.length - 1];
|
||
|
||
if (inputValues.length === 0) {
|
||
flags.push('missing_input_value_trace');
|
||
} else if (normalizeEnglishAnswer(lastInputValue) !== expectedAnswer) {
|
||
flags.push('input_value_mismatch');
|
||
}
|
||
|
||
const keySequence = normalizeEnglishAnswer(buildKeySequenceText(safeEvents));
|
||
if (keySequence && keySequence !== expectedAnswer) {
|
||
flags.push('key_sequence_mismatch');
|
||
}
|
||
|
||
return flags;
|
||
};
|
||
|
||
const analyzeAnswerRisk = (
|
||
durationSeconds: number,
|
||
summary: InteractionSummary,
|
||
testType: VocabularyTestType
|
||
): string[] => {
|
||
const flags: string[] = [];
|
||
|
||
if (durationSeconds > MAX_SECONDS_PER_QUESTION) flags.push('too_slow');
|
||
|
||
if (testType === 'multiple-choice') {
|
||
if (summary.pointerCount === 0) flags.push('missing_choice_interaction');
|
||
} else {
|
||
if (durationSeconds < MIN_SECONDS_PER_QUESTION) flags.push('too_fast');
|
||
if (summary.keyCount === 0) flags.push('missing_key_events');
|
||
if (summary.inputCount === 0) flags.push('missing_input_events');
|
||
if (summary.pasteCount > 0) flags.push('paste_used');
|
||
}
|
||
|
||
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 => {
|
||
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) {
|
||
const uploadDir = path.join(__dirname, '../uploads');
|
||
// 确保上传目录存在
|
||
if (!fs.existsSync(uploadDir)) {
|
||
fs.mkdirSync(uploadDir, { recursive: true });
|
||
}
|
||
cb(null, uploadDir);
|
||
},
|
||
filename: function (req, file, cb) {
|
||
cb(null, `${Date.now()}-${file.originalname}`);
|
||
}
|
||
});
|
||
|
||
const upload = multer({
|
||
storage: storage,
|
||
limits: {
|
||
fileSize: 5 * 1024 * 1024 // 限制5MB
|
||
},
|
||
fileFilter: function (req, file, cb) {
|
||
// 只允许上传CSV文件
|
||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||
cb(null, true);
|
||
} else {
|
||
cb(new Error('只支持CSV文件'));
|
||
}
|
||
}
|
||
});
|
||
|
||
// 获取所有单词集
|
||
router.get('/word-sets', authMiddleware, async (req, res) => {
|
||
try {
|
||
const wordSets = await WordSet.find()
|
||
.select('name description totalWords createdAt')
|
||
.sort({ createdAt: -1 });
|
||
|
||
res.json(wordSets);
|
||
} catch (error) {
|
||
console.error('获取单词集失败:', error);
|
||
res.status(500).json({ message: '获取单词集失败' });
|
||
}
|
||
});
|
||
|
||
// 创建新的单词集
|
||
router.post('/word-sets', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { name, description } = req.body;
|
||
|
||
// 检查是否已存在同名单词集
|
||
const existingSet = await WordSet.findOne({
|
||
name: name
|
||
});
|
||
|
||
if (existingSet) {
|
||
return res.status(400).json({ message: '已存在同名单词集' });
|
||
}
|
||
|
||
const newWordSet = await WordSet.create({
|
||
name,
|
||
description,
|
||
totalWords: 0
|
||
});
|
||
|
||
res.status(201).json(newWordSet);
|
||
} catch (error) {
|
||
console.error('创建单词集失败:', error);
|
||
res.status(500).json({ message: '创建单词集失败' });
|
||
}
|
||
});
|
||
|
||
// 上传单词文件并创建单词集
|
||
router.post('/upload', authMiddleware, upload.single('file'), async (req, res) => {
|
||
if (!req.file) {
|
||
return res.status(400).json({ message: '请上传文件' });
|
||
}
|
||
|
||
try {
|
||
const { name } = req.body;
|
||
const fileName = req.file.filename;
|
||
const filePath = req.file.path;
|
||
|
||
// 检查是否已存在同名单词集
|
||
const existingSet = await WordSet.findOne({
|
||
name: name || path.basename(fileName, '.csv')
|
||
});
|
||
|
||
if (existingSet) {
|
||
// 删除上传的文件
|
||
fs.unlinkSync(filePath);
|
||
return res.status(400).json({ message: '已存在同名单词集' });
|
||
}
|
||
|
||
// 创建单词集
|
||
const wordSet = await WordSet.create({
|
||
name: name || path.basename(fileName, '.csv'),
|
||
totalWords: 0
|
||
});
|
||
|
||
const results: any[] = [];
|
||
let wordCount = 0;
|
||
|
||
// 处理CSV文件
|
||
fs.createReadStream(filePath)
|
||
.pipe(csv())
|
||
.on('data', (data) => {
|
||
// 检查必要的字段是否存在
|
||
if (data.word && data.translation) {
|
||
results.push({
|
||
word: data.word.trim(),
|
||
translation: data.translation.trim(),
|
||
pronunciation: data.pronunciation?.trim(),
|
||
example: data.example?.trim(),
|
||
wordSet: wordSet._id
|
||
});
|
||
wordCount++;
|
||
}
|
||
})
|
||
.on('end', async () => {
|
||
try {
|
||
// 批量插入单词
|
||
if (results.length > 0) {
|
||
await Word.insertMany(results);
|
||
|
||
// 更新单词集的单词数量
|
||
await WordSet.findByIdAndUpdate(wordSet._id, { totalWords: wordCount });
|
||
}
|
||
|
||
// 删除上传的文件
|
||
fs.unlinkSync(filePath);
|
||
|
||
res.status(201).json({
|
||
message: `成功创建单词集并导入${wordCount}个单词`,
|
||
wordSet
|
||
});
|
||
} catch (error) {
|
||
// 如果插入单词失败,删除创建的单词集
|
||
await WordSet.findByIdAndDelete(wordSet._id);
|
||
|
||
console.error('导入单词失败:', error);
|
||
res.status(500).json({ message: '导入单词失败' });
|
||
}
|
||
})
|
||
.on('error', (error) => {
|
||
console.error('处理CSV文件失败:', error);
|
||
res.status(500).json({ message: '处理CSV文件失败' });
|
||
});
|
||
} catch (error) {
|
||
console.error('上传文件失败:', error);
|
||
res.status(500).json({ message: '上传文件失败' });
|
||
}
|
||
});
|
||
|
||
// 获取学习单词(智能抽取算法,嵌套结构版)
|
||
router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { wordSetId } = req.params;
|
||
let targetCount = 100;
|
||
if (req.query.count) {
|
||
const parsed = parseInt(req.query.count as string, 10);
|
||
if (!isNaN(parsed) && parsed > 0) {
|
||
targetCount = Math.max(MIN_STUDY_WORD_COUNT, Math.min(parsed, MAX_STUDY_WORD_COUNT));
|
||
}
|
||
}
|
||
|
||
// 检查单词集是否存在
|
||
const wordSet = await WordSet.findOne({ _id: wordSetId });
|
||
if (!wordSet) {
|
||
return res.status(404).json({ message: '未找到单词集' });
|
||
}
|
||
|
||
// 1. 获取该单词集下所有单词
|
||
const allWords = await Word.find({ wordSet: wordSetId });
|
||
const allWordIds = allWords.map(w => w._id.toString());
|
||
|
||
// 2. 获取该用户所有WordRecord(嵌套结构)
|
||
const allRecords = await WordRecord.find({ user: req.user._id, word: { $in: allWordIds } });
|
||
// 用 wordId 做 key
|
||
const recordMap = new Map();
|
||
allRecords.forEach(r => {
|
||
recordMap.set(r.word.toString(), r);
|
||
});
|
||
|
||
// 3. 分类
|
||
const wrongBookWords: any[] = [];
|
||
const notMasteredWords: any[] = [];
|
||
const masteredWords: { word: any, lastMasteredAt: Date }[] = [];
|
||
const neverLearnedWords: any[] = [];
|
||
|
||
for (const word of allWords) {
|
||
const wordId = word._id.toString();
|
||
const record = recordMap.get(wordId);
|
||
|
||
// 没有任何记录
|
||
if (!record) {
|
||
neverLearnedWords.push(word);
|
||
continue;
|
||
}
|
||
|
||
// 判断是否在错词本
|
||
const modes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'];
|
||
const anyInWrongBook = modes.some(m => record[m]?.inWrongBook);
|
||
if (anyInWrongBook) {
|
||
wrongBookWords.push(word);
|
||
continue;
|
||
}
|
||
|
||
// 判断是否全部掌握
|
||
if (record.isFullyMastered) {
|
||
masteredWords.push({ word, lastMasteredAt: record.lastFullyMasteredAt });
|
||
continue;
|
||
}
|
||
|
||
// 未完全掌握但有学习记录
|
||
if (modes.some(m => record[m] && Object.keys(record[m]).length > 0)) {
|
||
notMasteredWords.push(word);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 过滤一周内掌握的单词
|
||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||
let eligibleMasteredWords = masteredWords.filter(item =>
|
||
item.lastMasteredAt && item.lastMasteredAt < oneWeekAgo
|
||
);
|
||
|
||
// 4. 按比例抽取
|
||
let wrongBookQuota = Math.round(targetCount * 0.4);
|
||
let masteredQuota = Math.round(targetCount * 0.1);
|
||
let neverLearnedQuota = targetCount - wrongBookQuota - masteredQuota; // 剩下的给未学和新词
|
||
|
||
// 错词本优先
|
||
let selectedWrongBook = wrongBookWords.slice(0, wrongBookQuota);
|
||
let selectedMastered = eligibleMasteredWords
|
||
.sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0))
|
||
.slice(0, masteredQuota)
|
||
.map(item => item.word);
|
||
|
||
function shuffle(arr) {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
let selectedNeverLearned = shuffle([...neverLearnedWords]).slice(0, neverLearnedQuota);
|
||
|
||
// 补足不足部分
|
||
let remain = targetCount - (selectedWrongBook.length + selectedMastered.length + selectedNeverLearned.length);
|
||
// 先补未掌握(不在错词本的)
|
||
let notMasteredPool = notMasteredWords.filter(w => !selectedWrongBook.includes(w));
|
||
let selectedNotMastered: any[] = [];
|
||
if (remain > 0 && notMasteredPool.length > 0) {
|
||
selectedNotMastered = notMasteredPool.slice(0, remain);
|
||
remain -= selectedNotMastered.length;
|
||
}
|
||
// 再补新词
|
||
if (remain > 0) {
|
||
const moreNew = neverLearnedWords.slice(neverLearnedQuota, neverLearnedQuota + remain);
|
||
selectedNeverLearned = selectedNeverLearned.concat(moreNew);
|
||
remain -= moreNew.length;
|
||
}
|
||
// 再补已掌握
|
||
if (remain > 0) {
|
||
const moreMastered = eligibleMasteredWords
|
||
.sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0))
|
||
.slice(masteredQuota, masteredQuota + remain)
|
||
.map(item => item.word);
|
||
selectedMastered = selectedMastered.concat(moreMastered);
|
||
remain -= moreMastered.length;
|
||
}
|
||
// 再补错词本
|
||
if (remain > 0) {
|
||
const moreWrong = wrongBookWords.slice(wrongBookQuota, wrongBookQuota + remain);
|
||
selectedWrongBook = selectedWrongBook.concat(moreWrong);
|
||
remain -= moreWrong.length;
|
||
}
|
||
|
||
// 合并所有选中的单词
|
||
let finalWords = [
|
||
...selectedWrongBook,
|
||
...selectedNotMastered,
|
||
...selectedMastered,
|
||
...selectedNeverLearned
|
||
];
|
||
// 去重
|
||
const seen = new Set();
|
||
finalWords = finalWords.filter(w => {
|
||
const id = w._id.toString();
|
||
if (seen.has(id)) return false;
|
||
seen.add(id);
|
||
return true;
|
||
});
|
||
// 最终数量限制
|
||
finalWords = finalWords.slice(0, targetCount);
|
||
|
||
res.json(finalWords);
|
||
} catch (error) {
|
||
console.error('获取学习单词失败:', error);
|
||
res.status(500).json({ message: '获取学习单词失败' });
|
||
}
|
||
});
|
||
|
||
// 创建测试会话:服务端固定题目顺序并签发每题防伪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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!isAllowedVocabularyOrigin(req)) {
|
||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!mongoose.isValidObjectId(wordSetId)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!isVocabularyTestType(testType)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const wordSet = await WordSet.findById(wordSetId);
|
||
if (!wordSet) {
|
||
return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (wordIds.some((id: unknown) => !mongoose.isValidObjectId(id))) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const uniqueWordIds = [...new Set(wordIds.map((id: string) => id.toString()))];
|
||
if (uniqueWordIds.length !== wordIds.length) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const wordMap = new Map<string, any>(allWords.map(word => [getObjectIdString(word), word]));
|
||
selectedWords = uniqueWordIds.map(wordId => wordMap.get(wordId));
|
||
|
||
if (selectedWords.some(word => !word)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_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;
|
||
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, testType === 'multiple-choice'
|
||
? attempt.optionTexts?.[index] || []
|
||
: undefined)
|
||
);
|
||
|
||
res.status(201).json({
|
||
attemptId,
|
||
testType,
|
||
expiresAt,
|
||
questions
|
||
});
|
||
} catch (error) {
|
||
console.error('创建测试会话失败:', error);
|
||
res.status(500).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE
|
||
});
|
||
});
|
||
|
||
// 单题提交:服务端逐题判分并累计结果
|
||
router.post('/test-answer', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { attemptId, questionToken, userAnswer, submittedAt, answerProof, interactions } = req.body;
|
||
const userId = req.user?._id;
|
||
|
||
if (!userId) {
|
||
return res.status(401).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!isAllowedVocabularyOrigin(req)) {
|
||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!mongoose.isValidObjectId(attemptId)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const attempt = await VocabularyTestAttempt.findOne({
|
||
_id: attemptId,
|
||
user: userId
|
||
});
|
||
|
||
if (!attempt) {
|
||
return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (attemptAny.status !== 'active') {
|
||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const token = String(questionToken ?? '');
|
||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||
if (!token || tokenIndex < 0) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||
? attemptAny.answeredQuestionTokens
|
||
: [];
|
||
if (answeredTokens.includes(token)) {
|
||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (tokenIndex !== answeredTokens.length) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const submittedAtMs = Number.parseInt(String(submittedAt), 10);
|
||
if (!Number.isFinite(submittedAtMs)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
|
||
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
normalizedUserAnswer = optionTexts[selectedOptionIndex] || '';
|
||
}
|
||
|
||
const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs);
|
||
if (!safeEqualString(expectedProof, String(answerProof ?? ''))) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_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 = Array.from(new Set([
|
||
...analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType),
|
||
...analyzeInputTraceRisk(interactions, normalizedUserAnswer, attemptAny.testType)
|
||
]));
|
||
|
||
const word = await Word.findOne({
|
||
_id: wordId,
|
||
wordSet: attemptAny.wordSet
|
||
});
|
||
if (!word) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_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
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('提交单题答案失败:', error);
|
||
res.status(500).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!isAllowedVocabularyOrigin(req)) {
|
||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!mongoose.isValidObjectId(attemptId)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const attempt = await VocabularyTestAttempt.findOne({
|
||
_id: attemptId,
|
||
user: userId
|
||
});
|
||
|
||
if (!attempt) {
|
||
return res.status(404).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const attemptAny = attempt as any;
|
||
if (attemptAny.status !== 'active') {
|
||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (attemptAny.testType !== 'multiple-choice') {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const token = String(questionToken ?? '');
|
||
const tokenIndex = attemptAny.questionTokens.indexOf(token);
|
||
if (!token || tokenIndex < 0) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
|
||
const selectedIndex = optionTexts.findIndex((text: string) => text === String(optionText ?? ''));
|
||
if (selectedIndex < 0) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
|
||
? attemptAny.answeredQuestionTokens
|
||
: [];
|
||
if (tokenIndex !== answeredTokens.length) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const optionToken = attemptAny.optionTokens?.[tokenIndex]?.[selectedIndex];
|
||
if (!optionToken) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
res.json({
|
||
questionToken: token,
|
||
optionToken
|
||
});
|
||
} catch (error) {
|
||
console.error('获取选择项凭证失败:', error);
|
||
res.status(500).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!isAllowedVocabularyOrigin(req)) {
|
||
return res.status(403).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (!mongoose.isValidObjectId(attemptId)) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const attempt = await VocabularyTestAttempt.findOne({
|
||
_id: attemptId,
|
||
user: userId
|
||
});
|
||
|
||
if (!attempt) {
|
||
return res.status(404).json({ message: INVALID_CREDENTIAL_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: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
if (attemptAny.status !== 'active') {
|
||
return res.status(409).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
const submittedAnswers = Array.isArray(attemptAny.answers) ? attemptAny.answers : [];
|
||
if (submittedAnswers.length !== attemptAny.questionTokens.length) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
|
||
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 minimumSeconds = Math.max(2, attemptAny.questionTokens.length * MIN_SECONDS_PER_QUESTION);
|
||
if (elapsedSeconds < minimumSeconds) {
|
||
return res.status(400).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
}
|
||
|
||
const batchRiskFlags = isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || []);
|
||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||
const shouldInvalidateBatch = batchRiskFlags.length > 0 ||
|
||
['too_fast', 'too_slow', 'missing_input_value_trace', 'input_value_mismatch'].some(flag => (attemptAny.riskFlags || []).includes(flag));
|
||
|
||
const claimedAttempt = await VocabularyTestAttempt.findOneAndUpdate(
|
||
{ _id: attempt._id, user: userId, status: 'active' },
|
||
{ $set: { status: 'submitted', submittedAt: new Date(lastSubmittedAtMs), riskFlags: attemptAny.riskFlags } },
|
||
{ new: true }
|
||
);
|
||
|
||
if (!claimedAttempt) {
|
||
return res.status(409).json({ message: INVALID_CREDENTIAL_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 = shouldInvalidateBatch ? 0 : 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: submittedAnswers.map((result: any) => ({
|
||
word: result.word,
|
||
userAnswer: result.userAnswer,
|
||
correctAnswer: result.correctAnswer,
|
||
isCorrect: shouldInvalidateBatch ? false : result.isCorrect
|
||
}))
|
||
});
|
||
|
||
res.status(201).json({
|
||
message: shouldInvalidateBatch ? INVALID_CREDENTIAL_MESSAGE : '测试记录已保存',
|
||
attemptId: attempt._id.toString(),
|
||
stats: verifiedStats,
|
||
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),
|
||
invalidated: shouldInvalidateBatch
|
||
});
|
||
} catch (error) {
|
||
console.error('保存测试记录失败:', error);
|
||
res.status(500).json({ message: INVALID_CREDENTIAL_MESSAGE });
|
||
}
|
||
});
|
||
|
||
// 获取单词详情
|
||
router.get('/word/:wordId', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { wordId } = req.params;
|
||
|
||
const word = await Word.findById(wordId);
|
||
if (!word) {
|
||
return res.status(404).json({ message: '未找到单词' });
|
||
}
|
||
|
||
// 检查用户是否有权限访问这个单词
|
||
const wordSet = await WordSet.findOne({
|
||
_id: word.wordSet
|
||
});
|
||
|
||
if (!wordSet) {
|
||
return res.status(403).json({ message: '没有权限访问该单词' });
|
||
}
|
||
|
||
res.json(word);
|
||
} catch (error) {
|
||
console.error('获取单词详情失败:', error);
|
||
res.status(500).json({ message: '获取单词详情失败' });
|
||
}
|
||
});
|
||
|
||
// 获取单词测试记录
|
||
router.get('/test-records', authMiddleware, async (req, res) => {
|
||
try {
|
||
const testRecords = await VocabularyTestRecord.find({
|
||
user: req.user._id
|
||
}).populate('wordSet', 'name').sort({ createdAt: -1 });
|
||
|
||
res.json(testRecords);
|
||
} catch (error) {
|
||
console.error('获取测试记录失败:', error);
|
||
res.status(500).json({ message: '获取测试记录失败' });
|
||
}
|
||
});
|
||
|
||
// 排行榜接口:按掌握单词数和正确率排序
|
||
router.get('/leaderboard', authMiddleware, async (req, res) => {
|
||
try {
|
||
// 1. 查出所有 WordRecord
|
||
const allRecords = await WordRecord.find({});
|
||
|
||
// 2. 直接使用isFullyMastered统计每个用户掌握的单词数
|
||
const userMasteredCount = {};
|
||
const userStats = {};
|
||
|
||
for (const rec of allRecords) {
|
||
const userId = rec.user.toString();
|
||
|
||
// 初始化用户统计数据
|
||
if (!userStats[userId]) {
|
||
userStats[userId] = { correct: 0, total: 0 };
|
||
}
|
||
|
||
// 使用isFullyMastered直接统计掌握单词数
|
||
if (rec.isFullyMastered) {
|
||
userMasteredCount[userId] = (userMasteredCount[userId] || 0) + 1;
|
||
}
|
||
|
||
// 统计正确率
|
||
['chineseToEnglish', 'audioToEnglish', 'multipleChoice'].forEach(mode => {
|
||
const m = rec[mode];
|
||
if (m) {
|
||
userStats[userId].correct += m.totalCorrect || 0;
|
||
userStats[userId].total += (m.totalCorrect || 0) + (m.totalWrong || 0);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 3. 查询用户名
|
||
const userIds = Object.keys(userMasteredCount);
|
||
const users = await mongoose.model('User').find({ _id: { $in: userIds } }, { fullname: 1 });
|
||
const userMap = {};
|
||
users.forEach(u => { userMap[u._id.toString()] = u.fullname; });
|
||
|
||
// 4. 组装排行榜数组
|
||
const leaderboard = userIds.map(userId => ({
|
||
userId,
|
||
fullname: userMap[userId] || '未知用户',
|
||
totalWordsLearned: userMasteredCount[userId],
|
||
accuracy: userStats[userId] && userStats[userId].total > 0
|
||
? Math.round((userStats[userId].correct / userStats[userId].total) * 100)
|
||
: 0,
|
||
rank: 0 // 添加rank属性,初始值为0
|
||
}));
|
||
|
||
// 5. 排序
|
||
leaderboard.sort((a, b) => {
|
||
if (b.totalWordsLearned !== a.totalWordsLearned) {
|
||
return b.totalWordsLearned - a.totalWordsLearned;
|
||
}
|
||
return b.accuracy - a.accuracy;
|
||
});
|
||
|
||
// 6. 添加排名
|
||
leaderboard.forEach((item, idx) => {
|
||
item.rank = idx + 1;
|
||
});
|
||
|
||
res.json(leaderboard);
|
||
} catch (error) {
|
||
console.error('获取排行榜失败:', error);
|
||
res.status(500).json({ message: '获取排行榜失败' });
|
||
}
|
||
});
|
||
|
||
// 获取单词集详细信息
|
||
router.get('/word-set/:id', authMiddleware, async (req, res) => {
|
||
try {
|
||
const wordSet = await WordSet.findById(req.params.id);
|
||
if (!wordSet) {
|
||
return res.status(404).json({ message: '未找到单词集' });
|
||
}
|
||
res.json(wordSet);
|
||
} catch (error) {
|
||
console.error('获取单词集详细信息失败:', error);
|
||
res.status(500).json({ message: '获取单词集详细信息失败' });
|
||
}
|
||
});
|
||
|
||
// 更新单词集
|
||
router.put('/word-set/:id', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { name, description } = req.body;
|
||
const wordSet = await WordSet.findByIdAndUpdate(
|
||
req.params.id,
|
||
{ name, description },
|
||
{ new: true }
|
||
);
|
||
if (!wordSet) {
|
||
return res.status(404).json({ message: '未找到单词集' });
|
||
}
|
||
res.json(wordSet);
|
||
} catch (error) {
|
||
console.error('更新单词集失败:', error);
|
||
res.status(500).json({ message: '更新单词集失败' });
|
||
}
|
||
});
|
||
|
||
// 获取单词集中的单词
|
||
router.get('/word-set/:id/words', authMiddleware, async (req, res) => {
|
||
try {
|
||
const wordSetId = req.params.id;
|
||
console.log('Received request for wordSetId:', wordSetId); // 打印请求的 wordSetId
|
||
|
||
// 检查数据库中是否存在该单词集
|
||
const wordSetExists = await WordSet.exists({ _id: wordSetId });
|
||
console.log('WordSet exists:', wordSetExists); // 打印单词集是否存在
|
||
|
||
if (!wordSetExists) {
|
||
console.log('WordSet not found for wordSetId:', wordSetId);
|
||
return res.status(404).json({ message: '请求的资源不存在' });
|
||
}
|
||
|
||
// 查询单词集中的单词
|
||
const words = await Word.find({ wordSet: wordSetId });
|
||
console.log('Words found:', words.length); // 打印找到的单词数量
|
||
|
||
if (!words || words.length === 0) {
|
||
console.log('No words found for wordSetId:', wordSetId); // 打印未找到的情况
|
||
return res.status(404).json({ message: '请求的资源不存在' });
|
||
}
|
||
|
||
res.json(words);
|
||
} catch (error) {
|
||
console.error('获取单词失败:', error);
|
||
res.status(500).json({ message: '获取单词失败' });
|
||
}
|
||
});
|
||
|
||
// 更新单词
|
||
router.put('/words', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { words } = req.body;
|
||
console.log('Received words to update:', JSON.stringify(words, null, 2)); // 更详细的日志
|
||
|
||
for (const word of words) {
|
||
const { _id, word: wordText, translation, pronunciation, example } = word;
|
||
console.log(`Updating word ${_id}:`, { wordText, translation, pronunciation, example }); // 每个单词的更新日志
|
||
|
||
const updatedWord = await Word.findByIdAndUpdate(
|
||
_id,
|
||
{
|
||
word: wordText,
|
||
translation,
|
||
pronunciation,
|
||
example
|
||
},
|
||
{ new: true } // 返回更新后的文档
|
||
);
|
||
|
||
console.log('Updated word result:', updatedWord); // 更新结果日志
|
||
}
|
||
res.json({ message: '单词更新成功' });
|
||
} catch (error) {
|
||
console.error('更新单词失败:', error);
|
||
res.status(500).json({ message: '更新单词失败' });
|
||
}
|
||
});
|
||
|
||
export default router;
|