Files
NextEdu/src/modules/proctoring/data-access.ts
SpecialX b86255f0ea feat(P2): 实现选课管理、考试监考、学情诊断三大功能模块
## 新增功能模块

### 1. 选课管理(elective)
- 新增表:electiveCourses、courseSelections
- 新增权限:ELECTIVE_MANAGE/ELECTIVE_READ/ELECTIVE_SELECT
- 支持先到先得 + 抽签两种选课模式
- admin/teacher/student 三端页面

### 2. 考试监考(proctoring)
- exams 表扩展:examMode/durationMinutes/antiCheatEnabled 等字段
- 新增表:examProctoringEvents
- 新增权限:EXAM_PROCTOR/EXAM_PROCTOR_READ
- 教师监考面板 + 学生端防作弊监控
- API:/api/proctoring/event 接收事件上报

### 3. 学情诊断报告(diagnostic)
- 新增表:knowledgePointMastery、learningDiagnosticReports
- 新增权限:DIAGNOSTIC_MANAGE/DIAGNOSTIC_READ
- 基于提交答案自动计算知识点掌握度
- 生成个人/班级诊断报告(强项/弱项/建议)
- 雷达图可视化

## 其他改动
- 项目规则:单文件行数限制从 300 行调整为企业级规范(组件≤500/Actions≤800/硬上限1000)
- scripts/seed.ts:消除全部 any 类型,定义内部类型,0 lint 错误
- 架构文档 004/005 同步更新三个新模块
- 迁移文件 0001_heavy_sage.sql 生成

## 验证
- npx tsc --noEmit:0 错误
- npm run lint:0 错误 0 警告
2026-06-17 19:12:51 +08:00

389 lines
11 KiB
TypeScript

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<ProctoringEventType, number> => ({
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<ProctoringEvent> {
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<ProctoringEventWithDetails[]> => {
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<ProctoringEvent[]> => {
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<ExamProctoringSummary> => {
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<number>`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<number>`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<StudentProctoringStatus[]> => {
// 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<ProctoringEventType, number>
}
>()
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<ProctoringEventWithDetails[]> => {
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 }