import "server-only" import { cache } from "react" import { desc, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" 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, KnowledgePointMastery, KnowledgePointStat, MasteryWithKnowledgePoint, StudentMasterySummary, } from "./types" const toNumber = (v: unknown): number => { const n = typeof v === "number" ? v : Number(v) return Number.isFinite(n) ? n : 0 } const round2 = (n: number): number => Math.round(n * 100) / 100 const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({ id: r.id, studentId: r.studentId, knowledgePointId: r.knowledgePointId, masteryLevel: toNumber(r.masteryLevel), totalQuestions: r.totalQuestions, correctQuestions: r.correctQuestions, lastAssessedAt: r.lastAssessedAt.toISOString(), createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), }) /** 获取学生在所有知识点的掌握度(含知识点名称) */ export const getStudentMastery = cache(async (studentId: string): Promise => { const rows = await db .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name, kpDescription: knowledgePoints.description, }) .from(knowledgePointMastery) .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) .where(eq(knowledgePointMastery.studentId, studentId)) .orderBy(desc(knowledgePointMastery.masteryLevel)) return rows.map((r) => ({ ...serializeMastery(r.mastery), knowledgePointName: r.kpName ?? "Unknown", knowledgePointDescription: r.kpDescription, })) }) /** 获取学生掌握度摘要(含强项/弱项分析) */ export const getStudentMasterySummary = cache(async (studentId: string): Promise => { const userMap = await getUserNamesByIds([studentId]) const student = userMap.get(studentId) if (!student) return null const allMastery = await getStudentMastery(studentId) const averageMastery = allMastery.length > 0 ? 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, weaknesses, allMastery, } }) /** 从提交答案更新掌握度(正确率作为掌握度) */ export async function updateMasteryFromSubmission(submissionId: string): Promise { const submission = await getExamSubmissionWithAnswers(submissionId) if (!submission) return const answers = submission.answers if (answers.length === 0) return const questionIds = Array.from(new Set(answers.map((a) => a.questionId))) 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() for (const [questionId, kpLinks] of kpMap.entries()) { const answer = answerByQuestionId.get(questionId) if (!answer) continue 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() await Promise.all( Array.from(kpStats.entries()).map(async ([kpId, stat]) => { const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0 await db .insert(knowledgePointMastery) .values({ studentId: submission.studentId, knowledgePointId: kpId, masteryLevel: String(masteryLevel), totalQuestions: stat.total, correctQuestions: stat.correct, lastAssessedAt: now, }) .onDuplicateKeyUpdate({ set: { masteryLevel: String(masteryLevel), totalQuestions: stat.total, correctQuestions: stat.correct, lastAssessedAt: now, updatedAt: now, }, }) }), ) } /** 获取班级掌握度摘要 */ export const getClassMasterySummary = cache(async (classId: string): Promise => { const classExists = await getClassExists(classId) if (!classExists) return null // 班级名称 与 学生列表 相互独立,并行拉取 const [classNameResult, studentIds] = await Promise.all([ getClassNameById(classId), getActiveStudentIdsByClassId(classId), ]) const className = classNameResult ?? "Unknown" if (studentIds.length === 0) { return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] } } // 学生姓名 与 掌握度记录 相互独立,并行拉取 const [userMap, masteryRows] = await Promise.all([ getUserNamesByIds(studentIds), db .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) .from(knowledgePointMastery) .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) .where(inArray(knowledgePointMastery.studentId, studentIds)), ]) const students = studentIds .map((id) => ({ id, name: userMap.get(id)?.name ?? null })) .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) const byKp = new Map() const byStudent = new Map() for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 }) for (const r of masteryRows) { const level = toNumber(r.mastery.masteryLevel) const kpId = r.mastery.knowledgePointId const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } kpEntry.levels.push(level) if (level >= 80) kpEntry.mastered += 1 if (level < 60) kpEntry.notMastered += 1 byKp.set(kpId, kpEntry) const stuEntry = byStudent.get(r.mastery.studentId) if (stuEntry) { stuEntry.levels.push(level) if (level < 60) stuEntry.weakCount += 1 } } const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({ knowledgePointId: kpId, knowledgePointName: e.name, averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0, masteredCount: e.mastered, notMasteredCount: e.notMastered, totalStudents: students.length, })) const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel)) const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0 const studentsNeedingAttention = students .map((s) => { 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, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention } }) /** 获取知识点统计(按班级或年级聚合) */ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise => { let studentIds: string[] = [] if (classId) { studentIds = await getActiveStudentIdsByClassId(classId) } else if (gradeId) { studentIds = await getUserIdsByGradeId(gradeId) } if (studentIds.length === 0) return [] const masteryRows = await db .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) .from(knowledgePointMastery) .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) .where(inArray(knowledgePointMastery.studentId, studentIds)) const byKp = new Map() for (const r of masteryRows) { const level = toNumber(r.mastery.masteryLevel) const kpId = r.mastery.knowledgePointId const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } e.levels.push(level) if (level >= 80) e.mastered += 1 if (level < 60) e.notMastered += 1 byKp.set(kpId, e) } return Array.from(byKp.entries()).map(([kpId, e]) => ({ knowledgePointId: kpId, knowledgePointName: e.name, averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0, masteredCount: e.mastered, notMasteredCount: e.notMastered, totalStudents: studentIds.length, })) })