diff --git a/.gitignore b/.gitignore index e48cf5f..39676c9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build/ # Other coverage/ .codebuddy +.codex-docwork/ diff --git a/server/.env.example b/server/.env.example index 2983127..e4d5ace 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,5 +9,8 @@ MONGODB_URI=mongodb://localhost:27017/typeskill # JWT配置 JWT_SECRET=your_jwt_secret_key_here +# 词汇防作弊配置 +VOCABULARY_FULL_CORRECT_WORD_THRESHOLD=50 + # CORS配置 CORS_ORIGIN=http://localhost:3000 diff --git a/server/routes/admin.ts b/server/routes/admin.ts index cd6c4d3..5f476f3 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -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) => { + 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 = {}; + if (typeof minWords === 'number') { + baseMatch['stats.totalWords'] = { $gte: minWords }; + } + + return { + studentNo, + minWords, + userMatchStages, + baseMatch + }; +}; + const parseAdminDateRange = (source: Record) => { 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); + 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); + 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 } }) diff --git a/server/routes/vocabulary.ts b/server/routes/vocabulary.ts index 852d960..800686b 100644 --- a/server/routes/vocabulary.ts +++ b/server/routes/vocabulary.ts @@ -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(); @@ -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, diff --git a/server/utils/vocabularyRisk.test.ts b/server/utils/vocabularyRisk.test.ts new file mode 100644 index 0000000..f5226cb --- /dev/null +++ b/server/utils/vocabularyRisk.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { + DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD, + VOCABULARY_FULL_CORRECT_RISK_FLAG, + analyzeVocabularyAttemptResultRisk +} from './vocabularyRisk'; + +const defaultThreshold = DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD; + +assert.equal(defaultThreshold, 50); + +assert.deepEqual( + analyzeVocabularyAttemptResultRisk({ + testType: 'chinese-to-english', + totalWords: defaultThreshold + 1, + correctWords: defaultThreshold + 1 + }), + [VOCABULARY_FULL_CORRECT_RISK_FLAG] +); + +assert.deepEqual( + analyzeVocabularyAttemptResultRisk({ + testType: 'audio-to-english', + totalWords: defaultThreshold, + correctWords: defaultThreshold + }), + [] +); + +assert.deepEqual( + analyzeVocabularyAttemptResultRisk({ + testType: 'chinese-to-english', + totalWords: defaultThreshold + 1, + correctWords: defaultThreshold + }), + [] +); + +assert.deepEqual( + analyzeVocabularyAttemptResultRisk({ + testType: 'multiple-choice', + totalWords: defaultThreshold + 1, + correctWords: defaultThreshold + 1 + }), + [] +); + +assert.deepEqual( + analyzeVocabularyAttemptResultRisk({ + testType: 'audio-to-english', + totalWords: 41, + correctWords: 41, + threshold: 40 + }), + [VOCABULARY_FULL_CORRECT_RISK_FLAG] +); + +console.log('vocabularyRisk tests passed'); diff --git a/server/utils/vocabularyRisk.ts b/server/utils/vocabularyRisk.ts new file mode 100644 index 0000000..7d11976 --- /dev/null +++ b/server/utils/vocabularyRisk.ts @@ -0,0 +1,51 @@ +export type VocabularyRiskTestType = 'chinese-to-english' | 'audio-to-english' | 'multiple-choice'; + +export const DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD = 50; +export const VOCABULARY_FULL_CORRECT_RISK_FLAG = 'large_batch_all_correct'; + +export const INVALIDATING_VOCABULARY_RISK_FLAGS: string[] = [ + 'too_fast', + 'too_slow', + 'missing_input_value_trace', + 'input_value_mismatch', + 'uniform_answer_intervals', + 'repeated_whole_second_intervals', + VOCABULARY_FULL_CORRECT_RISK_FLAG +]; + +const parsePositiveThreshold = (value: unknown): number | undefined => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +}; + +export const getVocabularyFullCorrectWordThreshold = (): number => { + return parsePositiveThreshold(process.env.VOCABULARY_FULL_CORRECT_WORD_THRESHOLD) ?? + DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD; +}; + +export const analyzeVocabularyAttemptResultRisk = ({ + testType, + totalWords, + correctWords, + threshold = getVocabularyFullCorrectWordThreshold() +}: { + testType: VocabularyRiskTestType | string; + totalWords: number; + correctWords: number; + threshold?: number; +}): string[] => { + const effectiveThreshold = parsePositiveThreshold(threshold) ?? + DEFAULT_VOCABULARY_FULL_CORRECT_WORD_THRESHOLD; + const safeTotalWords = Number.isFinite(totalWords) ? totalWords : 0; + const safeCorrectWords = Number.isFinite(correctWords) ? correctWords : 0; + + if ( + testType !== 'multiple-choice' && + safeTotalWords > effectiveThreshold && + safeTotalWords === safeCorrectWords + ) { + return [VOCABULARY_FULL_CORRECT_RISK_FLAG]; + } + + return []; +}; diff --git a/src/api/admin.ts b/src/api/admin.ts index b7c8c81..28bb786 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -124,6 +124,13 @@ export interface VocabularyPassSummaryResponse { items: VocabularyPassSummaryItem[]; } +export interface VocabularyAuditQueryParams { + start: string; + end: string; + studentNo?: string; + minWords?: string; +} + export interface ClearVocabularyPassSummaryResponse { message: string; userId?: string; @@ -336,15 +343,15 @@ export const adminApi = { return api.put('/api/vocabulary/words', { words }); }, - getVocabularyPassSummary: async (params: { start: string; end: string; studentNo?: string }): Promise => { + getVocabularyPassSummary: async (params: VocabularyAuditQueryParams): Promise => { return api.get('/admin/vocabulary/test-pass-summary', { params }); }, - clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string }): Promise => { + clearVocabularyPassSummary: async (data: { userId: string; start: string; end: string; studentNo?: string; minWords?: string }): Promise => { return api.post('/admin/vocabulary/test-pass-summary/clear', data); }, - getVocabularyPassSummaryDetails: async (params: { userId: string; start: string; end: string }): Promise => { + getVocabularyPassSummaryDetails: async (params: { userId: string } & VocabularyAuditQueryParams): Promise => { const { userId, ...query } = params; return api.get(`/admin/vocabulary/test-pass-summary/${userId}/details`, { params: query }); }, diff --git a/src/components/AdminVocabularyScoreAudit.tsx b/src/components/AdminVocabularyScoreAudit.tsx index ff8398a..0f55cac 100644 --- a/src/components/AdminVocabularyScoreAudit.tsx +++ b/src/components/AdminVocabularyScoreAudit.tsx @@ -94,6 +94,7 @@ const getAnswerRows = (record: VocabularyAuditRecordDetail): VocabularyAuditAnsw const AdminVocabularyScoreAudit: React.FC = () => { const [range, setRange] = useState<[string, string] | null>(null); const [studentNo, setStudentNo] = useState(''); + const [minWords, setMinWords] = useState(''); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [clearingUserId, setClearingUserId] = useState(null); @@ -103,6 +104,11 @@ const AdminVocabularyScoreAudit: React.FC = () => { const [details, setDetails] = useState(null); const [approvingRecordId, setApprovingRecordId] = useState(null); + const getFilterParams = () => ({ + studentNo: studentNo.trim() || undefined, + minWords: minWords.trim() || undefined + }); + const fetchSummary = async () => { if (!range) { message.warning('请先选择开始和结束时间'); @@ -114,7 +120,7 @@ const AdminVocabularyScoreAudit: React.FC = () => { const response = await adminApi.getVocabularyPassSummary({ start: range[0], end: range[1], - studentNo: studentNo.trim() || undefined + ...getFilterParams() }); setSummary(response); } catch (error) { @@ -133,7 +139,8 @@ const AdminVocabularyScoreAudit: React.FC = () => { const response = await adminApi.getVocabularyPassSummaryDetails({ userId: row.userId, start: range[0], - end: range[1] + end: range[1], + ...getFilterParams() }); setDetails(response); } catch (error) { @@ -181,7 +188,8 @@ const AdminVocabularyScoreAudit: React.FC = () => { const response = await adminApi.clearVocabularyPassSummary({ userId: row.userId, start: range[0], - end: range[1] + end: range[1], + ...getFilterParams() }); message.success(`已清除 ${response.removedPassedWords} 个通过单词,删除 ${response.deletedRecords} 条测试记录`); await fetchSummary(); @@ -560,6 +568,15 @@ const AdminVocabularyScoreAudit: React.FC = () => { onPressEnter={fetchSummary} style={{ width: 220 }} /> + setMinWords(event.target.value)} + onPressEnter={fetchSummary} + style={{ width: 180 }} + />