## 新增功能模块 ### 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 警告
389 lines
11 KiB
TypeScript
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 }
|