refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,7 +1,7 @@
"use server"
import { revalidatePath } from "next/cache"
import { ActionState } from "@/shared/types/action-state"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
@@ -267,7 +267,8 @@ export async function createExamAction(
try {
const ctx = await requirePermission(Permissions.EXAM_CREATE)
const rawQuestions = formData.get("questionsJson") as string | null
const rawQuestionsValue = formData.get("questionsJson")
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
const parsed = ExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
@@ -346,9 +347,12 @@ export async function createAiExamAction(
try {
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
const rawQuestions = formData.get("questionsJson") as string | null
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
const rawStructure = formData.get("structureJson") as string | null
const rawQuestionsValue = formData.get("questionsJson")
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
const rawAiQuestionsValue = formData.get("aiQuestionsJson")
const rawAiQuestions = typeof rawAiQuestionsValue === "string" ? rawAiQuestionsValue : null
const rawStructureValue = formData.get("structureJson")
const rawStructure = typeof rawStructureValue === "string" ? rawStructureValue : null
const aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId")

View File

@@ -461,16 +461,19 @@ const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) =
} satisfies SplitQuestionItem))
}
const rows: SplitQuestionItem[] = []
draft.sections!.forEach((section, sectionIndex) => {
section.questions.forEach((q) => {
rows.push({
sectionIndex,
sectionTitle: section.title,
text: q.text,
score: q.score,
const sections = draft.sections
if (sections) {
sections.forEach((section, sectionIndex) => {
section.questions.forEach((q) => {
rows.push({
sectionIndex,
sectionTitle: section.title,
text: q.text,
score: q.score,
})
})
})
})
}
return rows
}
@@ -654,15 +657,16 @@ const buildPreviewPayload = (
}
): AiPreviewData => {
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
const baseQuestions = hasSections ? aiParsed.sections!.flatMap((s) => s.questions) : aiParsed.questions ?? []
const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? []
const limit = input.questionCount
let sections = aiParsed.sections
let flatQuestions = baseQuestions
if (typeof limit === "number" && limit > 0) {
if (hasSections) {
const parsedSections = aiParsed.sections
let remaining = limit
sections = aiParsed.sections!.map((s) => {
sections = (parsedSections ?? []).map((s) => {
if (remaining <= 0) return { ...s, questions: [] }
const sliced = s.questions.slice(0, remaining)
remaining -= sliced.length

View File

@@ -86,15 +86,16 @@ export function ExamAssembly(props: ExamAssemblyProps) {
pageSize: 20
})
if (result && result.data) {
if (result.success && result.data) {
const questionsList = result.data.data
setBankQuestions(prev => {
if (reset) return result.data
if (reset) return questionsList
// Deduplicate just in case
const existingIds = new Set(prev.map(q => q.id))
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
const newQuestions = questionsList.filter(q => !existingIds.has(q.id))
return [...prev, ...newQuestions]
})
setHasMore(result.data.length === 20)
setHasMore(questionsList.length === 20)
setPage(nextPage)
}
} catch {

View File

@@ -1,8 +1,10 @@
import { db } from "@/shared/db"
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
import { exams, examQuestions, examSubmissions, submissionAnswers, subjects, grades } from "@/shared/db/schema"
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { createQuestionWithRelations } from "@/modules/questions/data-access"
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
@@ -45,6 +47,12 @@ const getStringArray = (obj: Record<string, unknown>, key: string): string[] | u
return items.length === v.length ? items : undefined
}
const isExamStatus = (v: unknown): v is ExamStatus =>
v === "draft" || v === "published" || v === "archived"
const toExamStatus = (v: string | null | undefined): ExamStatus =>
isExamStatus(v) ? v : "draft"
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
return 1
@@ -69,11 +77,8 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Teacher can see exams for grades their classes belong to
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, params.scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
}
@@ -105,7 +110,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
@@ -153,11 +158,8 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
return null
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
return null
}
@@ -169,7 +171,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
@@ -191,9 +193,9 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
export const omitScheduledAtFromDescription = (description: string | null): string => {
if (!description) return "{}"
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
const rest = { ...(meta as Record<string, unknown>) }
const parsed: unknown = JSON.parse(description)
if (isRecord(parsed)) {
const rest = { ...parsed }
delete rest.scheduledAt
return JSON.stringify(rest)
}
@@ -299,8 +301,31 @@ export const persistAiGeneratedExamDraft = async (input: {
description: string
structure: AiGeneratedStructureNode[]
generated: AiGeneratedQuestion[]
}) => {
}): Promise<void> => {
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
// P0-1 fix: create questions via questions module data-access instead of direct table insert.
// createQuestionWithRelations generates new IDs, so we remap structure references accordingly.
const questionIdMapping = new Map<string, string>()
for (const q of input.generated) {
const newQuestionId = await createQuestionWithRelations(
{
content: q.content,
type: q.type,
difficulty: q.difficulty,
},
input.creatorId
)
questionIdMapping.set(q.id, newQuestionId)
}
const remappedOrderedQuestions = orderedQuestions
.map((q) => {
const mappedId = questionIdMapping.get(q.id)
return mappedId ? { id: mappedId, score: q.score } : null
})
.filter((q): q is { id: string; score: number } => q !== null)
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: input.examId,
@@ -314,21 +339,9 @@ export const persistAiGeneratedExamDraft = async (input: {
structure: input.structure,
})
if (input.generated.length > 0) {
await tx.insert(questions).values(
input.generated.map((q) => ({
id: q.id,
content: q.content,
type: q.type,
difficulty: q.difficulty,
authorId: input.creatorId,
}))
)
}
if (orderedQuestions.length > 0) {
if (remappedOrderedQuestions.length > 0) {
await tx.insert(examQuestions).values(
orderedQuestions.map((q, idx) => ({
remappedOrderedQuestions.map((q, idx) => ({
examId: input.examId,
questionId: q.id,
score: q.score ?? 0,
@@ -354,11 +367,8 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
conditions.push(inArray(exams.gradeId, scope.gradeIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
}
@@ -522,3 +532,193 @@ export const getExamGrades = async (): Promise<Array<{ id: string; name: string
})
return allGrades.map((g) => ({ id: g.id, name: g.name }))
}
// ---------------------------------------------------------------------------
// Cross-module query interfaces (供其他模块调用,避免直查 exams/examSubmissions 表)
// ---------------------------------------------------------------------------
/**
* 获取指定年级 ID 列表对应的所有考试 ID。
* 供 homework/grades 等模块跨模块调用使用。
*/
export const getExamIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
if (gradeIds.length === 0) return []
const rows = await db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
return rows.map((r) => r.id)
}
/**
* 获取考试的基本信息(含题目列表),供 homework 模块创建作业时使用。
* 返回的数据包含 examId、title、subjectId、structure 和题目列表。
*/
export type ExamWithQuestionsForHomework = {
id: string
title: string
subjectId: string | null
structure: unknown
questions: Array<{ questionId: string; score: number | null; order: number | null }>
}
export const getExamWithQuestionsForHomework = async (
examId: string
): Promise<ExamWithQuestionsForHomework | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
subjectId: exam.subjectId,
structure: exam.structure,
questions: exam.questions.map((q) => ({
questionId: q.questionId,
score: q.score ?? null,
order: q.order ?? null,
})),
}
}
/**
* 获取多个考试的 subjectId 映射examId -> subjectId
* 供 homework 模块查询作业对应科目时使用。
*/
export const getExamSubjectIdMap = async (examIds: string[]): Promise<Map<string, string | null>> => {
if (examIds.length === 0) return new Map()
const rows = await db
.select({ id: exams.id, subjectId: exams.subjectId })
.from(exams)
.where(inArray(exams.id, examIds))
const map = new Map<string, string | null>()
for (const r of rows) map.set(r.id, r.subjectId)
return map
}
/**
* 获取考试标题。
* 供 proctoring 等模块跨模块调用使用。
*/
export const getExamTitleById = async (examId: string): Promise<string | null> => {
const [row] = await db
.select({ title: exams.title })
.from(exams)
.where(eq(exams.id, examId))
.limit(1)
return row?.title ?? null
}
/**
* 获取考试的基本信息(含监考模式相关字段),供 proctoring 模块使用。
*/
export type ExamForProctoring = {
id: string
title: string
examMode: string | null
durationMinutes: number | null
shuffleQuestions: boolean | null
allowLateStart: boolean | null
lateStartGraceMinutes: number | null
antiCheatEnabled: boolean | null
}
export const getExamForProctoringCrossModule = async (examId: string): Promise<ExamForProctoring | 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: exam.examMode,
durationMinutes: exam.durationMinutes ?? null,
shuffleQuestions: exam.shuffleQuestions ?? false,
allowLateStart: exam.allowLateStart ?? false,
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
antiCheatEnabled: exam.antiCheatEnabled ?? false,
}
}
/**
* 校验提交记录归属(监考事件上报前的安全校验)。
* 供 proctoring 模块跨模块调用使用。
*/
export const getExamSubmissionForProctoringCrossModule = async (
submissionId: string,
studentId: string
): Promise<{ id: string; examId: string; studentId: string } | null> => {
const submission = await db.query.examSubmissions.findFirst({
where: and(
eq(examSubmissions.id, submissionId),
eq(examSubmissions.studentId, studentId),
),
columns: {
id: true,
examId: true,
studentId: true,
},
})
return submission ?? null
}
/**
* 获取考试提交记录及其答题数据,供 diagnostic 模块更新知识点掌握度使用。
*/
export type ExamSubmissionWithAnswers = {
studentId: string
answers: Array<{ questionId: string; score: number | null }>
}
export const getExamSubmissionWithAnswers = async (
submissionId: string
): Promise<ExamSubmissionWithAnswers | null> => {
const [submission] = await db
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
if (!submission) return null
const answers = await db
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
return {
studentId: submission.studentId,
answers,
}
}
/**
* 获取一场考试的所有提交记录(含学生 ID 和状态),供 proctoring 模块使用。
*/
export type ExamSubmissionForProctoringSummary = {
id: string
studentId: string
status: string | null
}
export const getExamSubmissionsForExam = async (
examId: string
): Promise<ExamSubmissionForProctoringSummary[]> => {
const rows = await db
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
status: examSubmissions.status,
})
.from(examSubmissions)
.where(eq(examSubmissions.examId, examId))
return rows
}