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

@@ -13,6 +13,14 @@ import {
publishDiagnosticReport,
deleteDiagnosticReport,
} from "./data-access-reports"
import {
GenerateStudentReportSchema,
GenerateClassReportSchema,
PublishReportSchema,
DeleteReportSchema,
GetDiagnosticReportsSchema,
GetDiagnosticReportByIdSchema,
} from "./schema"
import type { DiagnosticReportQueryParams } from "./types"
/** 生成学生个人诊断报告 */
@@ -23,15 +31,15 @@ export async function generateStudentReportAction(
try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const studentId = formData.get("studentId")
const period = formData.get("period")
if (typeof studentId !== "string" || studentId.length === 0) {
return { success: false, message: "Missing studentId" }
}
if (typeof period !== "string" || period.length === 0) {
return { success: false, message: "Missing period" }
const parsed = GenerateStudentReportSchema.safeParse({
studentId: formData.get("studentId"),
period: formData.get("period"),
})
if (!parsed.success) {
return { success: false, message: "Missing studentId or period" }
}
const { studentId, period } = parsed.data
const id = await generateDiagnosticReport(studentId, period, ctx.userId)
revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
@@ -51,15 +59,15 @@ export async function generateClassReportAction(
try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const classId = formData.get("classId")
const period = formData.get("period")
if (typeof classId !== "string" || classId.length === 0) {
return { success: false, message: "Missing classId" }
}
if (typeof period !== "string" || period.length === 0) {
return { success: false, message: "Missing period" }
const parsed = GenerateClassReportSchema.safeParse({
classId: formData.get("classId"),
period: formData.get("period"),
})
if (!parsed.success) {
return { success: false, message: "Missing classId or period" }
}
const { classId, period } = parsed.data
const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/class/${classId}`)
@@ -79,12 +87,14 @@ export async function publishReportAction(
try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id")
if (typeof id !== "string" || id.length === 0) {
const parsed = PublishReportSchema.safeParse({
id: formData.get("id"),
})
if (!parsed.success) {
return { success: false, message: "Missing report id" }
}
await publishDiagnosticReport(id)
await publishDiagnosticReport(parsed.data.id)
revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report published" }
} catch (e) {
@@ -102,12 +112,14 @@ export async function deleteReportAction(
try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id")
if (typeof id !== "string" || id.length === 0) {
const parsed = DeleteReportSchema.safeParse({
id: formData.get("id"),
})
if (!parsed.success) {
return { success: false, message: "Missing report id" }
}
await deleteDiagnosticReport(id)
await deleteDiagnosticReport(parsed.data.id)
revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report deleted" }
} catch (e) {
@@ -123,7 +135,13 @@ export async function getDiagnosticReportsAction(
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_READ)
const reports = await getDiagnosticReports(params)
const parsed = GetDiagnosticReportsSchema.safeParse(params)
if (!parsed.success) {
return { success: false, message: "Invalid query params" }
}
const reports = await getDiagnosticReports(parsed.data)
return { success: true, data: reports }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -138,7 +156,13 @@ export async function getDiagnosticReportByIdAction(
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_READ)
const report = await getDiagnosticReportById(id)
const parsed = GetDiagnosticReportByIdSchema.safeParse({ id })
if (!parsed.success) {
return { success: false, message: "Missing report id" }
}
const report = await getDiagnosticReportById(parsed.data.id)
return { success: true, data: report }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }

View File

@@ -1,6 +1,8 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { learningDiagnosticReports, users } from "@/shared/db/schema"
@@ -19,6 +21,12 @@ const toNumber = (v: unknown): number => {
const round2 = (n: number): number => Math.round(n * 100) / 100
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every((item) => typeof item === "string")
const toStringArrayNullable = (v: unknown): string[] | null =>
isStringArray(v) ? v : null
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id,
studentId: r.studentId,
@@ -26,9 +34,9 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag
reportType: r.reportType,
period: r.period,
summary: r.summary,
strengths: (r.strengths as string[] | null) ?? null,
weaknesses: (r.weaknesses as string[] | null) ?? null,
recommendations: (r.recommendations as string[] | null) ?? null,
strengths: toStringArrayNullable(r.strengths),
weaknesses: toStringArrayNullable(r.weaknesses),
recommendations: toStringArrayNullable(r.recommendations),
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
status: r.status,
createdAt: r.createdAt.toISOString(),
@@ -56,7 +64,6 @@ export async function generateDiagnosticReport(
const summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
@@ -100,7 +107,6 @@ export async function generateClassDiagnosticReport(
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
@@ -119,71 +125,71 @@ export async function generateClassDiagnosticReport(
}
/** 查询诊断报告列表 */
export async function getDiagnosticReports(
filters: DiagnosticReportQueryParams
): Promise<DiagnosticReportWithDetails[]> {
const conditions = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
export const getDiagnosticReports = cache(
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
const conditions = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db
.select({
report: learningDiagnosticReports,
studentName: users.name,
})
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
const rows = await db
.select({
report: learningDiagnosticReports,
studentName: users.name,
})
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(learningDiagnosticReports.createdAt))
const generatorIds = Array.from(
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
)
const generatorMap = new Map<string, string>()
if (generatorIds.length > 0) {
const generators = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, generatorIds))
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
}
const generatorIds = Array.from(
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
)
const generatorMap = new Map<string, string>()
if (generatorIds.length > 0) {
const generators = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, generatorIds))
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
}
return rows.map((r) => ({
...serializeReport(r.report),
studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
}))
}
return rows.map((r) => ({
...serializeReport(r.report),
studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
}))
},
)
/** 获取报告详情 */
export async function getDiagnosticReportById(
id: string
): Promise<DiagnosticReportWithDetails | null> {
const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
if (!row) return null
let generatedByName: string | null = null
if (row.report.generatedBy) {
const [gen] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, row.report.generatedBy))
export const getDiagnosticReportById = cache(
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name })
.from(learningDiagnosticReports)
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
generatedByName = gen?.name ?? null
}
return {
...serializeReport(row.report),
studentName: row.studentName ?? "Unknown",
generatedByName,
}
}
if (!row) return null
let generatedByName: string | null = null
if (row.report.generatedBy) {
const [gen] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, row.report.generatedBy))
.limit(1)
generatedByName = gen?.name ?? null
}
return {
...serializeReport(row.report),
studentName: row.studentName ?? "Unknown",
generatedByName,
}
},
)
/** 发布诊断报告 */
export async function publishDiagnosticReport(id: string): Promise<void> {

View File

@@ -1,18 +1,15 @@
import "server-only"
import { and, asc, desc, eq, inArray } from "drizzle-orm"
import { cache } from "react"
import { desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
classes,
examSubmissions,
knowledgePointMastery,
knowledgePoints,
questionsToKnowledgePoints,
submissionAnswers,
users,
} from "@/shared/db/schema"
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
import type {
ClassMasterySummary,
@@ -42,7 +39,7 @@ const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): Knowled
})
/** 获取学生在所有知识点的掌握度(含知识点名称) */
export async function getStudentMastery(studentId: string): Promise<MasteryWithKnowledgePoint[]> {
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
const rows = await db
.select({
mastery: knowledgePointMastery,
@@ -59,11 +56,12 @@ export async function getStudentMastery(studentId: string): Promise<MasteryWithK
knowledgePointName: r.kpName ?? "Unknown",
knowledgePointDescription: r.kpDescription,
}))
}
})
/** 获取学生掌握度摘要(含强项/弱项分析) */
export async function getStudentMasterySummary(studentId: string): Promise<StudentMasterySummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
const userMap = await getUserNamesByIds([studentId])
const student = userMap.get(studentId)
if (!student) return null
const allMastery = await getStudentMastery(studentId)
@@ -72,53 +70,49 @@ export async function getStudentMasterySummary(studentId: string): Promise<Stude
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
: 0
// Single-pass classification: strengths (>=80) and weaknesses (<60)
const strengths: MasteryWithKnowledgePoint[] = []
const weaknesses: MasteryWithKnowledgePoint[] = []
for (const m of allMastery) {
if (m.masteryLevel >= 80) strengths.push(m)
if (m.masteryLevel < 60) weaknesses.push(m)
}
return {
studentId,
studentName: student.name ?? "Unknown",
averageMastery,
totalKnowledgePoints: allMastery.length,
strengths: allMastery.filter((m) => m.masteryLevel >= 80),
weaknesses: allMastery.filter((m) => m.masteryLevel < 60),
strengths,
weaknesses,
allMastery,
}
}
})
/** 从提交答案更新掌握度(正确率作为掌握度) */
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
const [submission] = await db
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
const submission = await getExamSubmissionWithAnswers(submissionId)
if (!submission) return
const answers = await db
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
const answers = submission.answers
if (answers.length === 0) return
const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
const kpLinks = await db
.select({
questionId: questionsToKnowledgePoints.questionId,
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.questionId, questionIds))
const kpMap = await getKnowledgePointsForQuestions(questionIds)
// Build a Map for O(1) answer lookup instead of find() in loop
const answerByQuestionId = new Map(answers.map((a) => [a.questionId, a]))
const kpStats = new Map<string, { total: number; correct: number }>()
for (const link of kpLinks) {
const answer = answers.find((a) => a.questionId === link.questionId)
for (const [questionId, kpLinks] of kpMap.entries()) {
const answer = answerByQuestionId.get(questionId)
if (!answer) continue
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
stat.total += 1
if ((answer.score ?? 0) > 0) stat.correct += 1
kpStats.set(link.knowledgePointId, stat)
for (const link of kpLinks) {
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
stat.total += 1
if ((answer.score ?? 0) > 0) stat.correct += 1
kpStats.set(link.knowledgePointId, stat)
}
}
const now = new Date()
@@ -147,22 +141,21 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
}
/** 获取班级掌握度摘要 */
export async function getClassMasterySummary(classId: string): Promise<ClassMasterySummary | null> {
const [classRow] = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.id, classId)).limit(1)
if (!classRow) return null
export const getClassMasterySummary = cache(async (classId: string): Promise<ClassMasterySummary | null> => {
const classExists = await getClassExists(classId)
if (!classExists) return null
const className = (await getClassNameById(classId)) ?? "Unknown"
const students = await db
.select({ id: users.id, name: users.name })
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
if (students.length === 0) {
return { classId, className: classRow.name, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) {
return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
}
const studentIds = students.map((s) => s.id)
const userMap = await getUserNamesByIds(studentIds)
const students = studentIds
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
const masteryRows = await db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery)
@@ -203,25 +196,25 @@ export async function getClassMasterySummary(classId: string): Promise<ClassMast
const studentsNeedingAttention = students
.map((s) => {
const e = byStudent.get(s.id)!
const e = byStudent.get(s.id)
if (!e) return null
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
})
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
.filter((s) => s.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
return { classId, className: classRow.name, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
}
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
})
/** 获取知识点统计(按班级或年级聚合) */
export async function getKnowledgePointStats(classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> {
export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
let studentIds: string[] = []
if (classId) {
const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
studentIds = rows.map((r) => r.studentId)
studentIds = await getActiveStudentIdsByClassId(classId)
} else if (gradeId) {
const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId))
studentIds = rows.map((r) => r.id)
studentIds = await getUserIdsByGradeId(gradeId)
}
if (studentIds.length === 0) return []
@@ -251,4 +244,4 @@ export async function getKnowledgePointStats(classId?: string, gradeId?: string)
notMasteredCount: e.notMastered,
totalStudents: studentIds.length,
}))
}
})

View File

@@ -0,0 +1,48 @@
import { z } from "zod"
/** 生成学生个人诊断报告 */
export const GenerateStudentReportSchema = z.object({
studentId: z.string().min(1),
period: z.string().min(1),
})
export type GenerateStudentReportInput = z.infer<typeof GenerateStudentReportSchema>
/** 生成班级诊断报告 */
export const GenerateClassReportSchema = z.object({
classId: z.string().min(1),
period: z.string().min(1),
})
export type GenerateClassReportInput = z.infer<typeof GenerateClassReportSchema>
/** 发布诊断报告 */
export const PublishReportSchema = z.object({
id: z.string().min(1),
})
export type PublishReportInput = z.infer<typeof PublishReportSchema>
/** 删除诊断报告 */
export const DeleteReportSchema = z.object({
id: z.string().min(1),
})
export type DeleteReportInput = z.infer<typeof DeleteReportSchema>
/** 查询诊断报告列表 */
export const GetDiagnosticReportsSchema = z.object({
studentId: z.string().optional(),
reportType: z.enum(["individual", "class", "grade"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
period: z.string().optional(),
})
export type GetDiagnosticReportsInput = z.infer<typeof GetDiagnosticReportsSchema>
/** 获取诊断报告详情 */
export const GetDiagnosticReportByIdSchema = z.object({
id: z.string().min(1),
})
export type GetDiagnosticReportByIdInput = z.infer<typeof GetDiagnosticReportByIdSchema>