continue fixing
This commit is contained in:
@@ -11,6 +11,7 @@ import path from 'path';
|
||||
import csv from 'csv-parser';
|
||||
import { Word, WordRecord, WordSet, VocabularyTestAttempt, VocabularyTestRecord } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
import { INVALIDATING_VOCABULARY_RISK_FLAGS } from '../utils/vocabularyRisk';
|
||||
|
||||
const router = express.Router();
|
||||
const adminController = new AdminController();
|
||||
@@ -60,18 +61,55 @@ const WORD_RECORD_MODES: WordRecordModeKey[] = [
|
||||
'multipleChoice'
|
||||
];
|
||||
|
||||
const INVALIDATING_VOCABULARY_RISK_FLAGS = [
|
||||
'too_fast',
|
||||
'too_slow',
|
||||
'missing_input_value_trace',
|
||||
'input_value_mismatch',
|
||||
'uniform_answer_intervals',
|
||||
'repeated_whole_second_intervals'
|
||||
];
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const parseOptionalPositiveInt = (value: unknown, fieldName: string) => {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return { value: undefined as number | undefined };
|
||||
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return { error: `${fieldName}必须是正整数` };
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return { error: `${fieldName}必须大于0` };
|
||||
}
|
||||
|
||||
return { value: parsed };
|
||||
};
|
||||
|
||||
const buildVocabularySummaryFilters = (query: Record<string, any>) => {
|
||||
const studentNo = String(query.studentNo || '').trim();
|
||||
const wordCountResult = parseOptionalPositiveInt(query.minWords, '单次练习单词数量');
|
||||
if ('error' in wordCountResult) return { error: wordCountResult.error };
|
||||
|
||||
const minWords = wordCountResult.value;
|
||||
const userMatchStages = studentNo
|
||||
? [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: escapeRegex(studentNo),
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]
|
||||
: [];
|
||||
|
||||
const baseMatch: Record<string, any> = {};
|
||||
if (typeof minWords === 'number') {
|
||||
baseMatch['stats.totalWords'] = { $gte: minWords };
|
||||
}
|
||||
|
||||
return {
|
||||
studentNo,
|
||||
minWords,
|
||||
userMatchStages,
|
||||
baseMatch
|
||||
};
|
||||
};
|
||||
|
||||
const parseAdminDateRange = (source: Record<string, any>) => {
|
||||
const start = new Date(String(source.start || source.startTime || ''));
|
||||
const end = new Date(String(source.end || source.endTime || ''));
|
||||
@@ -545,20 +583,16 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const studentNo = String(req.query.studentNo || '').trim();
|
||||
const userMatchStages = studentNo
|
||||
? [{
|
||||
$match: {
|
||||
'user.username': {
|
||||
$regex: escapeRegex(studentNo),
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}]
|
||||
: [];
|
||||
const summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { userMatchStages, baseMatch } = summaryFilters;
|
||||
const validPassItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
...baseMatch,
|
||||
invalidated: { $ne: true },
|
||||
'stats.endTime': { $gte: start, $lte: end },
|
||||
'stats.correctWords': { $gt: 0 }
|
||||
@@ -610,6 +644,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
||||
const recordItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
...baseMatch,
|
||||
'stats.endTime': { $gte: start, $lte: end }
|
||||
}
|
||||
},
|
||||
@@ -737,6 +772,12 @@ router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (re
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const summaryFilters = buildVocabularySummaryFilters(req.query as Record<string, any>);
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { baseMatch } = summaryFilters;
|
||||
const { userId } = req.params;
|
||||
if (!mongoose.isValidObjectId(userId)) {
|
||||
return res.status(400).json({ message: '学生ID无效' });
|
||||
@@ -749,6 +790,7 @@ router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (re
|
||||
|
||||
const records = await VocabularyTestRecord.find({
|
||||
user: user._id,
|
||||
...baseMatch,
|
||||
'stats.endTime': { $gte: start, $lte: end }
|
||||
})
|
||||
.populate('wordSet', 'name description')
|
||||
@@ -971,6 +1013,12 @@ router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) =
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const summaryFilters = buildVocabularySummaryFilters(req.body || {});
|
||||
if ('error' in summaryFilters) {
|
||||
return res.status(400).json({ message: summaryFilters.error });
|
||||
}
|
||||
|
||||
const { baseMatch } = summaryFilters;
|
||||
const { userId } = req.body || {};
|
||||
if (!mongoose.isValidObjectId(userId)) {
|
||||
return res.status(400).json({ message: '学生ID无效' });
|
||||
@@ -984,6 +1032,7 @@ router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) =
|
||||
const records = await VocabularyTestRecord.find({
|
||||
user: user._id,
|
||||
invalidated: { $ne: true },
|
||||
...baseMatch,
|
||||
'stats.endTime': { $gte: start, $lte: end },
|
||||
'stats.correctWords': { $gt: 0 }
|
||||
})
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Word, WordSet, WordRecord, VocabularyTestRecord, VocabularyTestAttempt
|
||||
import mongoose from 'mongoose';
|
||||
import csv from 'csv-parser';
|
||||
import { config } from '../config';
|
||||
import {
|
||||
INVALIDATING_VOCABULARY_RISK_FLAGS,
|
||||
analyzeVocabularyAttemptResultRisk
|
||||
} from '../utils/vocabularyRisk';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -1196,21 +1200,6 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
@@ -1229,14 +1218,36 @@ router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
const totalWords = evaluatedResults.length;
|
||||
const originallyCorrectWords = evaluatedResults.filter(result => result.isCorrect).length;
|
||||
const batchRiskFlags = [
|
||||
...(isMultipleChoiceAttempt ? [] : analyzeBatchRisk(attemptAny.answerDurations || [])),
|
||||
...analyzeVocabularyAttemptResultRisk({
|
||||
testType: attemptAny.testType,
|
||||
totalWords,
|
||||
correctWords: originallyCorrectWords
|
||||
})
|
||||
];
|
||||
attemptAny.riskFlags = Array.from(new Set([...(attemptAny.riskFlags || []), ...batchRiskFlags]));
|
||||
const shouldInvalidateBatch = INVALIDATING_VOCABULARY_RISK_FLAGS.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 });
|
||||
}
|
||||
|
||||
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 correctWords = shouldInvalidateBatch ? 0 : originallyCorrectWords;
|
||||
const verifiedStats = {
|
||||
totalWords,
|
||||
correctWords,
|
||||
|
||||
Reference in New Issue
Block a user