import "server-only" import { cache } from "react" import { and, asc, eq } from "drizzle-orm" import { db } from "@/shared/db" import { gradeRecords } from "@/shared/db/schema" import { getStudentActiveClassId } from "@/modules/classes/data-access" import { getUserNamesByIds } from "@/modules/users/data-access" import type { DataScope } from "@/shared/types/permissions" import { normalize, toNumber } from "./lib/grade-utils" import { buildScopeClassFilter } from "./lib/scope-filter" import { buildRankingTrendPoints, type RankingTrendEntry } from "./stats-service" import type { ClassAverageTrendPoint, ClassAverageTrendResult, RankingTrendResult, } from "./types" /** * v3-P2-2: 班级平均成绩趋势点 * 与 RankingTrendPoint 对齐(按 title 分组),用于在学生趋势图上叠加班级平均对比线。 */ export type { ClassAverageTrendPoint, ClassAverageTrendResult } /** * Get a student's ranking trend across assessments within their class. * Each point represents one assessment (grouped by title), with the * student's normalized score, rank, and total participants. * * P3 修复:添加 scope 参数,对 class_taught scope 校验学生归属 */ export const getRankingTrend = cache( async ( studentId: string, subjectId?: string, semester?: "1" | "2", scope?: DataScope ): Promise => { // P3 修复:对 class_taught scope 校验学生是否属于教师所教的班级 if (scope?.type === "class_taught") { const allowedClassIds = new Set(scope.classIds) const studentClassId = await getStudentActiveClassId(studentId) if (studentClassId && !allowedClassIds.has(studentClassId)) { return null } } const studentNameMap = await getUserNamesByIds([studentId]) const studentInfo = studentNameMap.get(studentId) if (!studentInfo) return null const studentName = studentInfo.name ?? "Unknown" const classId = await getStudentActiveClassId(studentId) if (!classId) { return { studentId, studentName, points: [], } } const conditions = [eq(gradeRecords.classId, classId)] if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId)) if (semester) conditions.push(eq(gradeRecords.semester, semester)) // 应用 scope 过滤 if (scope) { const scopeFilter = buildScopeClassFilter(scope, studentId) if (scopeFilter) conditions.push(scopeFilter) } const rows = await db .select({ title: gradeRecords.title, createdAt: gradeRecords.createdAt, studentId: gradeRecords.studentId, score: gradeRecords.score, fullScore: gradeRecords.fullScore, }) .from(gradeRecords) .where(and(...conditions)) .orderBy(asc(gradeRecords.createdAt)) const byTitle = new Map() for (const r of rows) { const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] } entry.entries.push({ studentId: r.studentId, normalized: normalize(toNumber(r.score), toNumber(r.fullScore)), }) byTitle.set(r.title, entry) } const points = buildRankingTrendPoints(byTitle, studentId) return { studentId, studentName, points, } } ) /** * v3-P2-2: 获取班级平均成绩趋势(按 assessment title 分组)。 * * 用于在学生个人成绩趋势图上叠加"班级平均"对比线,让学生/家长能直观 * 看到个人与班级整体的差距。与 `getRankingTrend` 共享相同的过滤条件 * 与分组逻辑,确保两条线的 X 轴对齐。 * * @param studentId 目标学生 ID(用于定位其所在班级) * @param subjectId 可选科目过滤 * @param semester 可选学期过滤 * @param scope 数据权限范围 */ export const getClassAverageTrend = cache( async ( studentId: string, subjectId?: string, semester?: "1" | "2", scope?: DataScope ): Promise => { // 对 class_taught scope 校验学生归属 if (scope?.type === "class_taught") { const allowedClassIds = new Set(scope.classIds) const studentClassId = await getStudentActiveClassId(studentId) if (studentClassId && !allowedClassIds.has(studentClassId)) { return null } } const classId = await getStudentActiveClassId(studentId) if (!classId) return null const conditions = [eq(gradeRecords.classId, classId)] if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId)) if (semester) conditions.push(eq(gradeRecords.semester, semester)) if (scope) { const scopeFilter = buildScopeClassFilter(scope, studentId) if (scopeFilter) conditions.push(scopeFilter) } const rows = await db .select({ title: gradeRecords.title, createdAt: gradeRecords.createdAt, score: gradeRecords.score, fullScore: gradeRecords.fullScore, }) .from(gradeRecords) .where(and(...conditions)) .orderBy(asc(gradeRecords.createdAt)) // 按 title 分组,计算每次 assessment 的班级平均分(normalized 0-100) const byTitle = new Map() for (const r of rows) { const normalized = normalize(toNumber(r.score), toNumber(r.fullScore)) const entry = byTitle.get(r.title) ?? { date: r.createdAt, scores: [] } entry.scores.push(normalized) byTitle.set(r.title, entry) } const points: ClassAverageTrendPoint[] = [] for (const [title, entry] of byTitle.entries()) { if (entry.scores.length === 0) continue const sum = entry.scores.reduce((acc, s) => acc + s, 0) const avg = Math.round((sum / entry.scores.length) * 100) / 100 points.push({ title, date: entry.date.toISOString(), averageScore: avg, studentCount: entry.scores.length, }) } points.sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() ) return { classId, points, } } )