import "server-only" import { cache } from "react" import { and, 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, getExamWithQuestionsForHomework } from "@/modules/exams/data-access" import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection" import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" import { aggregateClassMastery, buildClassMasterySummary, buildStudentMasterySummary, computeKpStats, computeMasteryLevel, serializeMasteryWithKp, type RawClassMasteryRow, type RawMasteryWithKpRow, } from "./stats-service" import type { ClassMasterySummary, KnowledgePointStat, MasteryWithKnowledgePoint, StudentMasterySummary, } from "./types" /** 获取学生在所有知识点的掌握度(含知识点名称) */ 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) => serializeMasteryWithKp({ mastery: r.mastery, kpName: r.kpName, kpDescription: r.kpDescription, } satisfies RawMasteryWithKpRow), ) }) /** 获取学生掌握度摘要(含强项/弱项分析) */ export const getStudentMasterySummary = cache(async (studentId: string): Promise => { // P3-18 修复:用户名查询与掌握度查询相互独立,并行执行 const [userMap, allMastery] = await Promise.all([ getUserNamesByIds([studentId]), getStudentMastery(studentId), ]) const student = userMap.get(studentId) if (!student) return null return buildStudentMasterySummary(studentId, student.name ?? "Unknown", 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 existingRows = await db .select() .from(knowledgePointMastery) .where( and( eq(knowledgePointMastery.studentId, submission.studentId), inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())), ), ) const existingByKp = new Map() for (const row of existingRows) { existingByKp.set(row.knowledgePointId, { total: row.totalQuestions, correct: row.correctQuestions, }) } const now = new Date() // 使用事务保证多个 upsert 的原子性 await db.transaction(async (tx) => { await Promise.all( Array.from(kpStats.entries()).map(async ([kpId, stat]) => { const existing = existingByKp.get(kpId) const totalQuestions = (existing?.total ?? 0) + stat.total const correctQuestions = (existing?.correct ?? 0) + stat.correct const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions) await tx .insert(knowledgePointMastery) .values({ studentId: submission.studentId, knowledgePointId: kpId, masteryLevel: String(masteryLevel), totalQuestions, correctQuestions, lastAssessedAt: now, }) .onDuplicateKeyUpdate({ set: { masteryLevel: String(masteryLevel), totalQuestions, correctQuestions, lastAssessedAt: now, updatedAt: now, }, }) }), ) }) } /** * 从作业提交更新掌握度(累积模式)。 * * 与 updateMasteryFromSubmission 类似,但数据来源是作业提交而非考试提交。 * 通过 homework 模块的跨模块接口获取作业提交的答案数据,避免直查 homeworkSubmissions 表。 * * @param submissionId 作业提交 ID */ export async function updateMasteryFromHomeworkSubmission(submissionId: string): Promise { const submission = await getHomeworkSubmissionWithAnswersForMastery(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 existingRows = await db .select() .from(knowledgePointMastery) .where( and( eq(knowledgePointMastery.studentId, submission.studentId), inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())), ), ) const existingByKp = new Map() for (const row of existingRows) { existingByKp.set(row.knowledgePointId, { total: row.totalQuestions, correct: row.correctQuestions, }) } const now = new Date() // 使用事务保证多个 upsert 的原子性 await db.transaction(async (tx) => { await Promise.all( Array.from(kpStats.entries()).map(async ([kpId, stat]) => { const existing = existingByKp.get(kpId) const totalQuestions = (existing?.total ?? 0) + stat.total const correctQuestions = (existing?.correct ?? 0) + stat.correct const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions) await tx .insert(knowledgePointMastery) .values({ studentId: submission.studentId, knowledgePointId: kpId, masteryLevel: String(masteryLevel), totalQuestions, correctQuestions, lastAssessedAt: now, }) .onDuplicateKeyUpdate({ set: { masteryLevel: String(masteryLevel), totalQuestions, correctQuestions, lastAssessedAt: now, updatedAt: now, }, }) }), ) }) } /** * v3-P1-5:从手动录入的成绩更新掌握度。 * * 当教师手动录入关联了 examId 的成绩时,根据得分率(score/fullScore) * 更新该考试涉及的所有知识点的掌握度。采用累积模式,与 updateMasteryFromSubmission 一致。 * * 注意:此函数假设学生在所有知识点上的掌握度等于整体得分率, * 这是一种近似(无法区分知识点级别的强弱),适用于无题目级别答案的场景。 */ export async function updateMasteryFromExamScore( studentId: string, examId: string, score: number, fullScore: number, ): Promise { if (fullScore <= 0) return // 获取考试的所有题目 const examWithQuestions = await getExamWithQuestionsForHomework(examId) if (!examWithQuestions || examWithQuestions.questions.length === 0) return // 获取题目关联的知识点 const questionIds = examWithQuestions.questions.map((q) => q.questionId) const kpMap = await getKnowledgePointsForQuestions(questionIds) // 收集所有涉及的知识点 ID const kpIds = new Set() for (const links of kpMap.values()) { for (const link of links) { kpIds.add(link.knowledgePointId) } } if (kpIds.size === 0) return // 计算得分率作为掌握度 const masteryLevel = computeMasteryLevel(score, fullScore) // 读取已有掌握度记录,累积计算 const existingRows = await db .select() .from(knowledgePointMastery) .where( and( eq(knowledgePointMastery.studentId, studentId), inArray(knowledgePointMastery.knowledgePointId, Array.from(kpIds)), ), ) const existingByKp = new Map() for (const row of existingRows) { existingByKp.set(row.knowledgePointId, { total: row.totalQuestions, correct: row.correctQuestions, }) } const now = new Date() // 使用事务保证多个 upsert 的原子性 await db.transaction(async (tx) => { await Promise.all( Array.from(kpIds).map(async (kpId) => { const existing = existingByKp.get(kpId) // 累积:将本次成绩视为 1 道题,得分率作为掌握度 const totalQuestions = (existing?.total ?? 0) + 1 const correctQuestions = (existing?.correct ?? 0) + Math.round(masteryLevel / 100) const newMasteryLevel = computeMasteryLevel(correctQuestions, totalQuestions) await tx .insert(knowledgePointMastery) .values({ studentId, knowledgePointId: kpId, masteryLevel: String(newMasteryLevel), totalQuestions, correctQuestions, lastAssessedAt: now, }) .onDuplicateKeyUpdate({ set: { masteryLevel: String(newMasteryLevel), totalQuestions, correctQuestions, 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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({ mastery: { studentId: r.mastery.studentId, knowledgePointId: r.mastery.knowledgePointId, masteryLevel: r.mastery.masteryLevel, }, kpName: r.kpName, })) return buildClassMasterySummary(classId, className, students, rawRows) }) /** 获取知识点统计(按班级或年级聚合) */ 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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({ mastery: { studentId: r.mastery.studentId, knowledgePointId: r.mastery.knowledgePointId, masteryLevel: r.mastery.masteryLevel, }, kpName: r.kpName, })) const { byKp } = aggregateClassMastery(rawRows, studentIds) return computeKpStats(byKp) }) /** * v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。 * * 用于"按知识点筛选学生"功能:教师选择某个知识点后,列出班级所有学生 * 在该知识点上的掌握度,便于针对性辅导。掌握度低于阈值(默认 60)的学生 * 排在前面并标记为"需关注"。 */ export const getClassStudentsByKnowledgePoint = cache( async ( classId: string, knowledgePointId: string, options?: { threshold?: number } ): Promise< Array<{ studentId: string studentName: string masteryLevel: number totalQuestions: number correctQuestions: number lastAssessedAt: string | null needsAttention: boolean }> > => { const threshold = options?.threshold ?? 60 const studentIds = await getActiveStudentIdsByClassId(classId) if (studentIds.length === 0) return [] const [userMap, masteryRows] = await Promise.all([ getUserNamesByIds(studentIds), db .select({ mastery: knowledgePointMastery, }) .from(knowledgePointMastery) .where( and( eq(knowledgePointMastery.knowledgePointId, knowledgePointId), inArray(knowledgePointMastery.studentId, studentIds), ), ), ]) const masteryByStudent = new Map() for (const row of masteryRows) { masteryByStudent.set(row.mastery.studentId, { masteryLevel: Number(row.mastery.masteryLevel) || 0, totalQuestions: row.mastery.totalQuestions, correctQuestions: row.mastery.correctQuestions, lastAssessedAt: row.mastery.lastAssessedAt, }) } const result = studentIds.map((id) => { const info = userMap.get(id) const mastery = masteryByStudent.get(id) const masteryLevel = mastery?.masteryLevel ?? 0 return { studentId: id, studentName: info?.name ?? "Unknown", masteryLevel, totalQuestions: mastery?.totalQuestions ?? 0, correctQuestions: mastery?.correctQuestions ?? 0, lastAssessedAt: mastery?.lastAssessedAt ? mastery.lastAssessedAt.toISOString() : null, needsAttention: masteryLevel < threshold, } }) // 需关注的学生排前面,相同关注状态按掌握度升序 result.sort((a, b) => { if (a.needsAttention !== b.needsAttention) { return a.needsAttention ? -1 : 1 } return a.masteryLevel - b.masteryLevel }) return result } )