Files
d1kt/server/routes/vocabulary.ts
2026-05-24 20:11:00 +08:00

1415 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
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> = {
'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 || 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';
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 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 => {
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.min(parsed, 100);
}
}
// 检查单词集是否存在
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: '用户信息无效' });
}
if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).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;
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: '创建测试会话失败' });
}
});
// 记录单词学习结果嵌套结构版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: '单题记录接口已停用,请通过测试会话提交记录'
});
});
// 单题提交:服务端逐题判分并累计结果
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: '用户信息无效' });
}
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 token = String(questionToken ?? '');
const tokenIndex = attemptAny.questionTokens.indexOf(token);
if (!token || tokenIndex < 0) {
return res.status(400).json({ message: '测试答案的题目标识无效' });
}
const answeredTokens = Array.isArray(attemptAny.answeredQuestionTokens)
? attemptAny.answeredQuestionTokens
: [];
if (answeredTokens.includes(token)) {
return res.status(409).json({ message: '该题已提交,请继续下一题' });
}
if (tokenIndex !== answeredTokens.length) {
return res.status(400).json({ message: '请按题目顺序提交答案' });
}
const submittedAtMs = Number.parseInt(String(submittedAt), 10);
if (!Number.isFinite(submittedAtMs)) {
return res.status(400).json({ message: '答案提交时间无效' });
}
if (Math.abs(now - submittedAtMs) > 5 * 60 * 1000) {
return res.status(400).json({ message: '答案提交时间异常' });
}
const wordId = getObjectIdString(attemptAny.questionWordIds[tokenIndex]);
if (!wordId || !verifyQuestionToken(token, attemptId.toString(), wordId, tokenIndex, issuedAt)) {
return res.status(400).json({ message: '测试题目签名无效' });
}
let normalizedUserAnswer = String(userAnswer ?? '').trim().slice(0, 500);
if (attemptAny.testType === 'multiple-choice') {
const optionTokens = attemptAny.optionTokens?.[tokenIndex] || [];
const optionTexts = attemptAny.optionTexts?.[tokenIndex] || [];
const selectedOptionIndex = optionTokens.findIndex((optionToken: string) => safeEqualString(optionToken, normalizedUserAnswer));
if (selectedOptionIndex < 0) {
return res.status(400).json({ message: '选择项凭证无效' });
}
normalizedUserAnswer = optionTexts[selectedOptionIndex] || '';
}
const expectedProof = buildAnswerProof(attemptId.toString(), token, String(userAnswer ?? '').trim().slice(0, 500), submittedAtMs);
if (!safeEqualString(expectedProof, String(answerProof ?? ''))) {
return res.status(400).json({ message: '答案校验失败' });
}
const questionShownAtMs = tokenIndex === 0
? issuedAt.getTime()
: new Date(attemptAny.answers?.[tokenIndex - 1]?.submittedAt || issuedAt).getTime();
const duration = Math.max(0, (submittedAtMs - questionShownAtMs) / 1000);
const interactionSummary = summarizeInteractionEvents(interactions, questionShownAtMs);
const riskFlags = analyzeAnswerRisk(duration, interactionSummary, attemptAny.testType);
const word = await Word.findOne({
_id: wordId,
wordSet: attemptAny.wordSet
});
if (!word) {
return res.status(400).json({ message: '测试会话中的单词已不存在' });
}
const evaluation = evaluateWordAnswer(word, attemptAny.testType, normalizedUserAnswer);
attemptAny.answeredQuestionTokens = [...answeredTokens, token];
attemptAny.answerDurations = [...(attemptAny.answerDurations || []), duration];
attemptAny.answers = [
...(attemptAny.answers || []),
{
questionToken: token,
word: word._id,
userAnswer: evaluation.userAnswer,
correctAnswer: evaluation.correctAnswer,
isCorrect: evaluation.isCorrect,
submittedAt: new Date(submittedAtMs),
duration,
riskFlags,
interactionSummary
}
];
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...riskFlags]));
await attempt.save();
res.status(201).json({
message: '答案已提交',
result: {
questionToken: token,
wordId,
word: evaluation.word,
userAnswer: evaluation.userAnswer,
correctAnswer: evaluation.correctAnswer,
isCorrect: evaluation.isCorrect
},
progress: {
answered: attemptAny.answeredQuestionTokens.length,
total: attemptAny.questionTokens.length,
finished: attemptAny.answeredQuestionTokens.length === attemptAny.questionTokens.length
},
riskFlags
});
} catch (error) {
console.error('提交单题答案失败:', error);
res.status(500).json({ message: '提交单题答案失败' });
}
});
// 选择题点击选项后换取该选项的一次性凭证
router.post('/test-option-token', authMiddleware, async (req, res) => {
try {
const { attemptId, questionToken, optionText } = req.body;
const userId = req.user?._id;
if (!userId) {
return res.status(401).json({ message: '用户信息无效' });
}
if (!isAllowedVocabularyOrigin(req)) {
return res.status(403).json({ message: '测试来源异常,请从官网页面选择答案' });
}
if (!mongoose.isValidObjectId(attemptId)) {
return res.status(400).json({ message: '测试会话ID无效' });
}
const attempt = await VocabularyTestAttempt.findOne({
_id: attemptId,
user: userId
});
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), riskFlags: attemptAny.riskFlags } },
{ new: true }
);
if (!claimedAttempt) {
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 = 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 ? '测试行为异常,本次记录已保存但不计入掌握' : '测试记录已保存',
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),
riskFlags: attemptAny.riskFlags || [],
invalidated: shouldInvalidateBatch
});
} catch (error) {
console.error('保存测试记录失败:', error);
res.status(500).json({ 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;