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 警告
This commit is contained in:
388
src/modules/proctoring/data-access.ts
Normal file
388
src/modules/proctoring/data-access.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user