Files
NextEdu/src/modules/diagnostic/data-access.ts
SpecialX 8c2fe14c20 refactor(modules): update classes, course-plans, diagnostic, questions, settings, student, layout
- Update classes data-access (invitations, main) for invitation management

- Update course-plans actions, data-access, and types

- Update diagnostic data-access for report queries

- Update questions data-access for question bank queries

- Update settings actions, ai-provider-settings-card, data-access, and types

- Update student course-filters, student-courses-view, student-schedule-filters, student-schedule-view

- Update layout app-sidebar, site-header, and navigation config
2026-06-24 12:03:35 +08:00

478 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MasteryWithKnowledgePoint[]> => {
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<StudentMasterySummary | null> => {
// 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<void> {
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<string, { total: number; correct: number }>()
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<string, { total: number; correct: number }>()
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<void> {
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<string, { total: number; correct: number }>()
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<string, { total: number; correct: number }>()
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<void> {
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<string>()
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<string, { total: number; correct: number }>()
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<ClassMasterySummary | null> => {
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<KnowledgePointStat[]> => {
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<string, {
masteryLevel: number
totalQuestions: number
correctQuestions: number
lastAssessedAt: Date | null
}>()
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
}
)