add more info of AdminVocabularyAudit
This commit is contained in:
@@ -58,6 +58,10 @@ export interface IVocabularyTestRecord extends Document {
|
||||
}>;
|
||||
invalidated?: boolean;
|
||||
riskFlags?: string[];
|
||||
reviewDecision?: 'approved';
|
||||
reviewedBy?: mongoose.Types.ObjectId;
|
||||
reviewedAt?: Date;
|
||||
reviewNote?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,10 @@ export interface IVocabularyTestAttempt extends Document {
|
||||
};
|
||||
}>;
|
||||
riskFlags?: string[];
|
||||
reviewDecision?: 'approved';
|
||||
reviewedBy?: mongoose.Types.ObjectId;
|
||||
reviewedAt?: Date;
|
||||
reviewNote?: string;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
submittedAt?: Date;
|
||||
@@ -178,6 +186,10 @@ const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||
}],
|
||||
invalidated: { type: Boolean, default: false },
|
||||
riskFlags: [{ type: String }],
|
||||
reviewDecision: { type: String, enum: ['approved'] },
|
||||
reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
reviewedAt: Date,
|
||||
reviewNote: { type: String, trim: true, maxlength: 500 },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
@@ -217,6 +229,10 @@ const VocabularyTestAttemptSchema = new Schema<IVocabularyTestAttempt>({
|
||||
}
|
||||
}],
|
||||
riskFlags: [{ type: String }],
|
||||
reviewDecision: { type: String, enum: ['approved'] },
|
||||
reviewedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
reviewedAt: Date,
|
||||
reviewNote: { type: String, trim: true, maxlength: 500 },
|
||||
issuedAt: { type: Date, required: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
submittedAt: Date,
|
||||
|
||||
@@ -69,6 +69,8 @@ const INVALIDATING_VOCABULARY_RISK_FLAGS = [
|
||||
'repeated_whole_second_intervals'
|
||||
];
|
||||
|
||||
const getObjectIdString = (value: any): string => value?._id?.toString?.() || value?.toString?.() || '';
|
||||
|
||||
const parseAdminDateRange = (source: Record<string, any>) => {
|
||||
const start = new Date(String(source.start || source.startTime || ''));
|
||||
const end = new Date(String(source.end || source.endTime || ''));
|
||||
@@ -152,11 +154,14 @@ const rebuildVocabularyWordRecordsForUser = async (
|
||||
.map((record: any) => record.attempt?.toString?.() || '')
|
||||
.filter(id => mongoose.isValidObjectId(id));
|
||||
const attempts = attemptIds.length > 0
|
||||
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags').lean()
|
||||
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } }).select('riskFlags reviewDecision').lean()
|
||||
: [];
|
||||
const invalidatedAttemptIds = new Set(
|
||||
attempts
|
||||
.filter((attempt: any) => (attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag)))
|
||||
.filter((attempt: any) =>
|
||||
attempt.reviewDecision !== 'approved' &&
|
||||
(attempt.riskFlags || []).some((flag: string) => INVALIDATING_VOCABULARY_RISK_FLAGS.includes(flag))
|
||||
)
|
||||
.map((attempt: any) => attempt._id.toString())
|
||||
);
|
||||
|
||||
@@ -211,6 +216,49 @@ const rebuildVocabularyWordRecordsForUser = async (
|
||||
};
|
||||
};
|
||||
|
||||
const serializeUserRef = (value: any) => {
|
||||
const id = getObjectIdString(value);
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
_id: id,
|
||||
username: value?.username || '',
|
||||
fullname: value?.fullname || '',
|
||||
email: value?.email || ''
|
||||
};
|
||||
};
|
||||
|
||||
const serializeWordRef = (value: any) => {
|
||||
const id = getObjectIdString(value);
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
_id: id,
|
||||
word: value?.word || '',
|
||||
translation: value?.translation || '',
|
||||
pronunciation: value?.pronunciation || '',
|
||||
example: value?.example || ''
|
||||
};
|
||||
};
|
||||
|
||||
const serializeWordSetRef = (value: any) => {
|
||||
const id = getObjectIdString(value);
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
_id: id,
|
||||
name: value?.name || '',
|
||||
description: value?.description || ''
|
||||
};
|
||||
};
|
||||
|
||||
const flattenRiskFlags = (items: any[]): string[] => Array.from(new Set(
|
||||
(items || [])
|
||||
.flatMap(item => Array.isArray(item) ? item : [item])
|
||||
.filter(Boolean)
|
||||
.map(item => String(item))
|
||||
));
|
||||
|
||||
router.get('/users', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await User.find()
|
||||
@@ -496,7 +544,7 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const items = await VocabularyTestRecord.aggregate([
|
||||
const validPassItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
invalidated: { $ne: true },
|
||||
@@ -546,11 +594,118 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
||||
{ $sort: { passedWords: -1, lastPassedAt: -1 } }
|
||||
]);
|
||||
|
||||
const recordItems = await VocabularyTestRecord.aggregate([
|
||||
{
|
||||
$match: {
|
||||
'stats.endTime': { $gte: start, $lte: end }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$user',
|
||||
totalRecords: { $sum: 1 },
|
||||
validRecords: {
|
||||
$sum: {
|
||||
$cond: [{ $eq: ['$invalidated', true] }, 0, 1]
|
||||
}
|
||||
},
|
||||
invalidatedRecords: {
|
||||
$sum: {
|
||||
$cond: [{ $eq: ['$invalidated', true] }, 1, 0]
|
||||
}
|
||||
},
|
||||
riskFlagSets: { $addToSet: '$riskFlags' },
|
||||
wordSetIds: { $addToSet: '$wordSet' },
|
||||
testTypes: { $addToSet: '$testType' },
|
||||
firstRecordAt: { $min: '$stats.endTime' },
|
||||
lastRecordAt: { $max: '$stats.endTime' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: '_id',
|
||||
foreignField: '_id',
|
||||
as: 'user'
|
||||
}
|
||||
},
|
||||
{ $unwind: { path: '$user', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
userId: { $toString: '$_id' },
|
||||
username: '$user.username',
|
||||
fullname: '$user.fullname',
|
||||
email: '$user.email',
|
||||
totalRecords: 1,
|
||||
validRecords: 1,
|
||||
invalidatedRecords: 1,
|
||||
riskFlagSets: 1,
|
||||
wordSets: { $size: '$wordSetIds' },
|
||||
testTypes: 1,
|
||||
firstRecordAt: 1,
|
||||
lastRecordAt: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const passMap = new Map(validPassItems.map((item: any) => [item.userId, item]));
|
||||
const itemMap = new Map<string, any>();
|
||||
|
||||
recordItems.forEach((recordItem: any) => {
|
||||
const passItem = passMap.get(recordItem.userId) || {};
|
||||
itemMap.set(recordItem.userId, {
|
||||
userId: recordItem.userId,
|
||||
username: recordItem.username || passItem.username || '',
|
||||
fullname: recordItem.fullname || passItem.fullname || '',
|
||||
email: recordItem.email || passItem.email || '',
|
||||
passedWords: passItem.passedWords || 0,
|
||||
uniqueWords: passItem.uniqueWords || 0,
|
||||
testRecords: passItem.testRecords || 0,
|
||||
totalRecords: recordItem.totalRecords || 0,
|
||||
validRecords: recordItem.validRecords || 0,
|
||||
invalidatedRecords: recordItem.invalidatedRecords || 0,
|
||||
wordSets: recordItem.wordSets || passItem.wordSets || 0,
|
||||
testTypes: Array.from(new Set([...(recordItem.testTypes || []), ...(passItem.testTypes || [])])),
|
||||
riskFlags: flattenRiskFlags(recordItem.riskFlagSets || []),
|
||||
firstPassedAt: passItem.firstPassedAt,
|
||||
lastPassedAt: passItem.lastPassedAt,
|
||||
firstRecordAt: recordItem.firstRecordAt,
|
||||
lastRecordAt: recordItem.lastRecordAt
|
||||
});
|
||||
});
|
||||
|
||||
validPassItems.forEach((passItem: any) => {
|
||||
if (itemMap.has(passItem.userId)) return;
|
||||
itemMap.set(passItem.userId, {
|
||||
...passItem,
|
||||
totalRecords: passItem.testRecords || 0,
|
||||
validRecords: passItem.testRecords || 0,
|
||||
invalidatedRecords: 0,
|
||||
riskFlags: [],
|
||||
firstRecordAt: passItem.firstPassedAt,
|
||||
lastRecordAt: passItem.lastPassedAt
|
||||
});
|
||||
});
|
||||
|
||||
const items = Array.from(itemMap.values()).sort((left, right) => {
|
||||
const passedDelta = (right.passedWords || 0) - (left.passedWords || 0);
|
||||
if (passedDelta !== 0) return passedDelta;
|
||||
|
||||
const riskDelta = (right.invalidatedRecords || 0) - (left.invalidatedRecords || 0);
|
||||
if (riskDelta !== 0) return riskDelta;
|
||||
|
||||
return new Date(right.lastRecordAt || right.lastPassedAt || 0).getTime() -
|
||||
new Date(left.lastRecordAt || left.lastPassedAt || 0).getTime();
|
||||
});
|
||||
|
||||
res.json({
|
||||
start,
|
||||
end,
|
||||
totalStudents: items.length,
|
||||
totalPassedWords: items.reduce((sum: number, item: any) => sum + (item.passedWords || 0), 0),
|
||||
totalRecords: items.reduce((sum: number, item: any) => sum + (item.totalRecords || 0), 0),
|
||||
invalidatedRecords: items.reduce((sum: number, item: any) => sum + (item.invalidatedRecords || 0), 0),
|
||||
items
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -559,6 +714,240 @@ router.get('/vocabulary/test-pass-summary', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 查看某个学生在时间段内的词汇测试明细(包含风控 attempt 原始提交)
|
||||
router.get('/vocabulary/test-pass-summary/:userId/details', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const range = parseAdminDateRange(req.query as Record<string, any>);
|
||||
if (range.error) {
|
||||
return res.status(400).json({ message: range.error });
|
||||
}
|
||||
|
||||
const { start, end } = range as { start: Date; end: Date };
|
||||
const { userId } = req.params;
|
||||
if (!mongoose.isValidObjectId(userId)) {
|
||||
return res.status(400).json({ message: '学生ID无效' });
|
||||
}
|
||||
|
||||
const user = await User.findById(userId).select('_id username fullname email').lean();
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '学生不存在' });
|
||||
}
|
||||
|
||||
const records = await VocabularyTestRecord.find({
|
||||
user: user._id,
|
||||
'stats.endTime': { $gte: start, $lte: end }
|
||||
})
|
||||
.populate('wordSet', 'name description')
|
||||
.populate('results.word', 'word translation pronunciation example')
|
||||
.populate('reviewedBy', 'username fullname email')
|
||||
.sort({ 'stats.endTime': -1, createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
const attemptIds = records
|
||||
.map((record: any) => getObjectIdString(record.attempt))
|
||||
.filter(id => mongoose.isValidObjectId(id));
|
||||
const attempts = attemptIds.length > 0
|
||||
? await VocabularyTestAttempt.find({ _id: { $in: attemptIds } })
|
||||
.populate('answers.word', 'word translation pronunciation example')
|
||||
.populate('reviewedBy', 'username fullname email')
|
||||
.lean()
|
||||
: [];
|
||||
const attemptMap = new Map(attempts.map((attempt: any) => [attempt._id.toString(), attempt]));
|
||||
|
||||
const recordDetails = records.map((record: any) => {
|
||||
const attemptId = getObjectIdString(record.attempt);
|
||||
const attempt = attemptMap.get(attemptId);
|
||||
|
||||
return {
|
||||
recordId: record._id.toString(),
|
||||
attemptId,
|
||||
wordSet: serializeWordSetRef(record.wordSet),
|
||||
testType: record.testType,
|
||||
stats: record.stats,
|
||||
invalidated: Boolean(record.invalidated),
|
||||
riskFlags: record.riskFlags || [],
|
||||
reviewDecision: record.reviewDecision || attempt?.reviewDecision || '',
|
||||
reviewedAt: record.reviewedAt || attempt?.reviewedAt || null,
|
||||
reviewedBy: serializeUserRef(record.reviewedBy || attempt?.reviewedBy),
|
||||
reviewNote: record.reviewNote || attempt?.reviewNote || '',
|
||||
createdAt: record.createdAt,
|
||||
results: (record.results || []).map((result: any, index: number) => ({
|
||||
index: index + 1,
|
||||
wordId: getObjectIdString(result.word),
|
||||
word: serializeWordRef(result.word),
|
||||
userAnswer: result.userAnswer || '',
|
||||
correctAnswer: result.correctAnswer || '',
|
||||
isCorrect: Boolean(result.isCorrect)
|
||||
})),
|
||||
attempt: attempt ? {
|
||||
attemptId,
|
||||
status: attempt.status,
|
||||
testType: attempt.testType,
|
||||
wordSetId: getObjectIdString(attempt.wordSet),
|
||||
issuedAt: attempt.issuedAt,
|
||||
expiresAt: attempt.expiresAt,
|
||||
submittedAt: attempt.submittedAt,
|
||||
riskFlags: attempt.riskFlags || [],
|
||||
reviewDecision: attempt.reviewDecision || '',
|
||||
reviewedAt: attempt.reviewedAt || null,
|
||||
reviewedBy: serializeUserRef(attempt.reviewedBy),
|
||||
reviewNote: attempt.reviewNote || '',
|
||||
questionWordIds: (attempt.questionWordIds || []).map((wordId: any) => getObjectIdString(wordId)),
|
||||
questionTokens: attempt.questionTokens || [],
|
||||
optionTokens: attempt.optionTokens || [],
|
||||
optionTexts: attempt.optionTexts || [],
|
||||
answeredQuestionTokens: attempt.answeredQuestionTokens || [],
|
||||
answerDurations: attempt.answerDurations || [],
|
||||
answers: (attempt.answers || []).map((answer: any, index: number) => ({
|
||||
index: index + 1,
|
||||
questionToken: answer.questionToken || '',
|
||||
wordId: getObjectIdString(answer.word),
|
||||
word: serializeWordRef(answer.word),
|
||||
userAnswer: answer.userAnswer || '',
|
||||
correctAnswer: answer.correctAnswer || '',
|
||||
isCorrect: Boolean(answer.isCorrect),
|
||||
submittedAt: answer.submittedAt,
|
||||
duration: answer.duration,
|
||||
riskFlags: answer.riskFlags || [],
|
||||
interactionSummary: answer.interactionSummary || null
|
||||
}))
|
||||
} : null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
start,
|
||||
end,
|
||||
user: {
|
||||
userId: user._id.toString(),
|
||||
username: user.username,
|
||||
fullname: user.fullname,
|
||||
email: user.email
|
||||
},
|
||||
totalRecords: recordDetails.length,
|
||||
totalPassedWords: recordDetails
|
||||
.filter((record: any) => !record.invalidated)
|
||||
.reduce((sum: number, record: any) => sum + Number(record.stats?.correctWords || 0), 0),
|
||||
invalidatedRecords: recordDetails.filter((record: any) => record.invalidated).length,
|
||||
approvedRecords: recordDetails.filter((record: any) => record.reviewDecision === 'approved').length,
|
||||
records: recordDetails
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取词汇测试明细失败:', error);
|
||||
res.status(500).json({ message: '获取词汇测试明细失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 人工将风控作废的词汇测试记录转为有效成绩
|
||||
router.post('/vocabulary/test-records/:recordId/approve', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { recordId } = req.params;
|
||||
if (!mongoose.isValidObjectId(recordId)) {
|
||||
return res.status(400).json({ message: '测试记录ID无效' });
|
||||
}
|
||||
|
||||
const record: any = await VocabularyTestRecord.findById(recordId);
|
||||
if (!record) {
|
||||
return res.status(404).json({ message: '测试记录不存在' });
|
||||
}
|
||||
|
||||
if (!record.invalidated) {
|
||||
return res.json({
|
||||
message: '该词汇测试记录已经是有效成绩',
|
||||
recordId,
|
||||
alreadyEffective: true
|
||||
});
|
||||
}
|
||||
|
||||
const attemptId = getObjectIdString(record.attempt);
|
||||
if (!mongoose.isValidObjectId(attemptId)) {
|
||||
return res.status(400).json({ message: '该测试记录没有可用于恢复的 attempt 数据' });
|
||||
}
|
||||
|
||||
const attempt: any = await VocabularyTestAttempt.findById(attemptId);
|
||||
if (!attempt) {
|
||||
return res.status(404).json({ message: '原始提交 attempt 不存在,无法转为有效成绩' });
|
||||
}
|
||||
|
||||
const submittedAnswers = Array.isArray(attempt.answers) ? attempt.answers : [];
|
||||
const restoredResults = submittedAnswers
|
||||
.map((answer: any) => {
|
||||
const wordId = getObjectIdString(answer.word);
|
||||
if (!mongoose.isValidObjectId(wordId)) return null;
|
||||
|
||||
return {
|
||||
word: new mongoose.Types.ObjectId(wordId),
|
||||
userAnswer: String(answer.userAnswer || ''),
|
||||
correctAnswer: String(answer.correctAnswer || ''),
|
||||
isCorrect: Boolean(answer.isCorrect)
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
word: mongoose.Types.ObjectId;
|
||||
userAnswer: string;
|
||||
correctAnswer: string;
|
||||
isCorrect: boolean;
|
||||
}>;
|
||||
|
||||
if (restoredResults.length === 0) {
|
||||
return res.status(400).json({ message: '该 attempt 没有可恢复的答题数据' });
|
||||
}
|
||||
|
||||
const totalWords = restoredResults.length;
|
||||
const correctWords = restoredResults.filter(result => result.isCorrect).length;
|
||||
const reviewedAt = new Date();
|
||||
const reviewNote = String(req.body?.note || '').trim().slice(0, 500);
|
||||
const userId = getObjectIdString(record.user);
|
||||
const affectedWordIds = restoredResults.map(result => result.word.toString());
|
||||
|
||||
await VocabularyTestRecord.updateOne(
|
||||
{ _id: record._id },
|
||||
{
|
||||
$set: {
|
||||
invalidated: false,
|
||||
results: restoredResults,
|
||||
'stats.totalWords': totalWords,
|
||||
'stats.correctWords': correctWords,
|
||||
'stats.accuracy': totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
|
||||
reviewDecision: 'approved',
|
||||
reviewedBy: req.user._id,
|
||||
reviewedAt,
|
||||
reviewNote
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await VocabularyTestAttempt.updateOne(
|
||||
{ _id: attempt._id },
|
||||
{
|
||||
$set: {
|
||||
reviewDecision: 'approved',
|
||||
reviewedBy: req.user._id,
|
||||
reviewedAt,
|
||||
reviewNote
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const rebuildResult = await rebuildVocabularyWordRecordsForUser(userId, affectedWordIds);
|
||||
|
||||
res.json({
|
||||
message: '已将风控记录转为有效成绩',
|
||||
recordId,
|
||||
attemptId,
|
||||
totalWords,
|
||||
correctWords,
|
||||
accuracy: totalWords > 0 ? (correctWords / totalWords) * 100 : 0,
|
||||
riskFlags: Array.from(new Set([...(record.riskFlags || []), ...(attempt.riskFlags || [])])),
|
||||
affectedWords: rebuildResult.affectedWords,
|
||||
rebuiltWords: rebuildResult.rebuiltWords
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('人工放行词汇测试记录失败:', error);
|
||||
res.status(500).json({ message: '人工放行词汇测试记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 清除某个学生在指定时间段内的词汇测试成绩,并重算掌握状态
|
||||
router.post('/vocabulary/test-pass-summary/clear', adminAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user