import "server-only" import { db } from "@/shared/db" import { exams, examProctoringEvents, examSubmissions, users, } from "@/shared/db/schema" import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm" import { cache } from "react" import { createId } from "@paralleldrive/cuid2" import type { ProctoringEvent, ProctoringEventWithDetails, ExamProctoringSummary, StudentProctoringStatus, RecordProctoringEventInput, GetProctoringEventsFilters, ExamModeConfig, ProctoringEventType, ExamMode, } from "./types" import { ABNORMAL_EVENT_THRESHOLD } from "./types" const ALL_EVENT_TYPES: ProctoringEventType[] = [ "tab_switch", "window_blur", "copy_attempt", "paste_attempt", "right_click", "devtools_open", "fullscreen_exit", "idle_timeout", ] const emptyEventsByType = (): Record => ({ tab_switch: 0, window_blur: 0, copy_attempt: 0, paste_attempt: 0, right_click: 0, devtools_open: 0, fullscreen_exit: 0, idle_timeout: 0, }) const toExamMode = (value: unknown): ExamMode => { if (value === "homework" || value === "timed" || value === "proctored") { return value } return "homework" } /** * 记录一条监考事件 */ export async function recordProctoringEvent( input: RecordProctoringEventInput, ): Promise { const eventId = createId() const now = new Date() await db.insert(examProctoringEvents).values({ id: eventId, submissionId: input.submissionId, studentId: input.studentId, examId: input.examId, eventType: input.eventType, eventDetail: input.eventDetail ?? null, occurredAt: now, }) return { id: eventId, submissionId: input.submissionId, studentId: input.studentId, examId: input.examId, eventType: input.eventType, eventDetail: input.eventDetail ?? null, occurredAt: now.toISOString(), createdAt: now.toISOString(), } } /** * 查询某场考试的监考事件(含学生姓名、考试标题) */ export const getProctoringEvents = cache( async ( examId: string, filters?: GetProctoringEventsFilters, ): Promise => { const conditions = [eq(examProctoringEvents.examId, examId)] if (filters?.studentId) { conditions.push(eq(examProctoringEvents.studentId, filters.studentId)) } if (filters?.eventType) { conditions.push(eq(examProctoringEvents.eventType, filters.eventType)) } if (filters?.startedAt) { conditions.push(gte(examProctoringEvents.occurredAt, new Date(filters.startedAt))) } if (filters?.endedAt) { conditions.push(lte(examProctoringEvents.occurredAt, new Date(filters.endedAt))) } const rows = await db .select({ event: examProctoringEvents, studentName: users.name, examTitle: exams.title, }) .from(examProctoringEvents) .innerJoin(users, eq(users.id, examProctoringEvents.studentId)) .innerJoin(exams, eq(exams.id, examProctoringEvents.examId)) .where(and(...conditions)) .orderBy(desc(examProctoringEvents.occurredAt)) return rows.map((row) => ({ id: row.event.id, submissionId: row.event.submissionId, studentId: row.event.studentId, examId: row.event.examId, eventType: row.event.eventType as ProctoringEventType, eventDetail: row.event.eventDetail, occurredAt: row.event.occurredAt.toISOString(), createdAt: row.event.createdAt.toISOString(), studentName: row.studentName ?? "未知学生", examTitle: row.examTitle, })) }, ) /** * 查询某次提交的监考事件 */ export const getProctoringEventsBySubmission = cache( async (submissionId: string): Promise => { const rows = await db.query.examProctoringEvents.findMany({ where: eq(examProctoringEvents.submissionId, submissionId), orderBy: [desc(examProctoringEvents.occurredAt)], }) return rows.map((row) => ({ id: row.id, submissionId: row.submissionId, studentId: row.studentId, examId: row.examId, eventType: row.eventType as ProctoringEventType, eventDetail: row.eventDetail, occurredAt: row.occurredAt.toISOString(), createdAt: row.createdAt.toISOString(), })) }, ) /** * 获取考试监考摘要 */ export const getExamProctoringSummary = cache( async (examId: string): Promise => { const exam = await db.query.exams.findFirst({ where: eq(exams.id, examId), columns: { id: true, title: true, examMode: true, }, }) const examTitle = exam?.title ?? "未知考试" const examMode = toExamMode(exam?.examMode) // 统计提交记录 const submissions = await db.query.examSubmissions.findMany({ where: eq(examSubmissions.examId, examId), columns: { id: true, studentId: true, status: true, }, }) const totalStudents = submissions.length const startedStudents = submissions.filter( (s) => s.status === "started", ).length const submittedStudents = submissions.filter( (s) => s.status === "submitted" || s.status === "graded", ).length // 按事件类型分组统计 const eventStats = await db .select({ eventType: examProctoringEvents.eventType, count: sql`count(*)::int`, }) .from(examProctoringEvents) .where(eq(examProctoringEvents.examId, examId)) .groupBy(examProctoringEvents.eventType) const eventsByType = emptyEventsByType() let totalEvents = 0 for (const stat of eventStats) { const type = stat.eventType as ProctoringEventType if (eventsByType[type] !== undefined) { eventsByType[type] = stat.count totalEvents += stat.count } } // 统计异常学生数(事件数 >= 阈值) const studentEventCounts = await db .select({ studentId: examProctoringEvents.studentId, count: sql`count(*)::int`, }) .from(examProctoringEvents) .where(eq(examProctoringEvents.examId, examId)) .groupBy(examProctoringEvents.studentId) const abnormalStudents = studentEventCounts.filter( (s) => s.count >= ABNORMAL_EVENT_THRESHOLD, ).length return { examId, examTitle, examMode, totalStudents, startedStudents, submittedStudents, totalEvents, abnormalStudents, eventsByType, } }, ) /** * 获取所有学生监考状态 */ export const getStudentProctoringStatuses = cache( async (examId: string): Promise => { // 1. 拉取所有提交记录及学生姓名 const submissions = await db .select({ submission: examSubmissions, studentName: users.name, }) .from(examSubmissions) .innerJoin(users, eq(users.id, examSubmissions.studentId)) .where(eq(examSubmissions.examId, examId)) if (submissions.length === 0) return [] const studentIds = submissions.map((s) => s.submission.studentId) // 2. 拉取这些提交的事件,按学生聚合 const eventRows = await db .select({ studentId: examProctoringEvents.studentId, eventType: examProctoringEvents.eventType, occurredAt: examProctoringEvents.occurredAt, }) .from(examProctoringEvents) .where( and( eq(examProctoringEvents.examId, examId), inArray(examProctoringEvents.studentId, studentIds), ), ) .orderBy(desc(examProctoringEvents.occurredAt)) // 3. 按学生聚合 const statsByStudent = new Map< string, { count: number lastEventAt: Date | null byType: Record } >() for (const row of eventRows) { const sid = row.studentId const type = row.eventType as ProctoringEventType const existing = statsByStudent.get(sid) ?? { count: 0, lastEventAt: null, byType: emptyEventsByType(), } existing.count += 1 if (existing.byType[type] !== undefined) { existing.byType[type] += 1 } if (!existing.lastEventAt || row.occurredAt > existing.lastEventAt) { existing.lastEventAt = row.occurredAt } statsByStudent.set(sid, existing) } return submissions.map((row) => { const studentId = row.submission.studentId const stats = statsByStudent.get(studentId) return { studentId, studentName: row.studentName ?? "未知学生", submissionId: row.submission.id, submissionStatus: (row.submission.status ?? null) as StudentProctoringStatus["submissionStatus"], eventCount: stats?.count ?? 0, lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null, isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD, eventsByType: stats?.byType ?? emptyEventsByType(), } }) }, ) /** * 获取考试信息(含监考模式相关字段) */ export const getExamForProctoring = cache( async (examId: string): Promise<{ id: string title: string examMode: ExamMode config: ExamModeConfig } | null> => { const exam = await db.query.exams.findFirst({ where: eq(exams.id, examId), }) if (!exam) return null return { id: exam.id, title: exam.title, examMode: toExamMode(exam.examMode), config: { examMode: toExamMode(exam.examMode), durationMinutes: exam.durationMinutes ?? null, shuffleQuestions: exam.shuffleQuestions ?? false, allowLateStart: exam.allowLateStart ?? false, lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0, antiCheatEnabled: exam.antiCheatEnabled ?? false, }, } }, ) /** * 获取最近 N 条监考事件(用于面板实时展示) */ export const getRecentProctoringEvents = cache( async (examId: string, limit = 20): Promise => { const rows = await db .select({ event: examProctoringEvents, studentName: users.name, examTitle: exams.title, }) .from(examProctoringEvents) .innerJoin(users, eq(users.id, examProctoringEvents.studentId)) .innerJoin(exams, eq(exams.id, examProctoringEvents.examId)) .where(eq(examProctoringEvents.examId, examId)) .orderBy(desc(examProctoringEvents.occurredAt)) .limit(limit) return rows.map((row) => ({ id: row.event.id, submissionId: row.event.submissionId, studentId: row.event.studentId, examId: row.event.examId, eventType: row.event.eventType as ProctoringEventType, eventDetail: row.event.eventDetail, occurredAt: row.event.occurredAt.toISOString(), createdAt: row.event.createdAt.toISOString(), studentName: row.studentName ?? "未知学生", examTitle: row.examTitle, })) }, ) export { ALL_EVENT_TYPES }