Files
NextEdu/src/modules/grades/data-access-analytics.ts
SpecialX 3b6272c99d feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
2026-06-17 13:44:37 +08:00

294 lines
9.2 KiB
TypeScript

import "server-only"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
ClassComparisonItem,
GradeDistributionBucket,
GradeDistributionResult,
GradeTrendPoint,
GradeTrendResult,
SubjectComparisonItem,
} from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const normalize = (score: number, fullScore: number): number => {
if (fullScore <= 0) return 0
return Math.round((score / fullScore) * 10000) / 100
}
const buildScopeClassFilter = (scope: DataScope) => {
if (scope.type === "all") return null
if (scope.type === "class_taught") {
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
}
if (scope.type === "grade_managed") return sql`1=0`
if (scope.type === "class_members") return null
if (scope.type === "children") {
return scope.childrenIds.length > 0
? inArray(gradeRecords.studentId, scope.childrenIds)
: sql`1=0`
}
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
return sql`1=0`
}
export interface GradeTrendParams {
classId: string
subjectId?: string
studentId?: string
semester?: "1" | "2"
scope: DataScope
currentUserId?: string
}
export async function getGradeTrend(
params: GradeTrendParams
): Promise<GradeTrendResult | null> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null
const points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const className = rows[0].className ?? "Class"
const subjectName = rows[0].subjectName ?? "All Subjects"
const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}`
: "Class Average"
return {
label: params.subjectId
? `${className} · ${subjectName} · ${studentLabel}`
: `${className} · ${studentLabel}`,
points,
averageScore: Math.round(avg * 100) / 100,
}
}
export interface ClassComparisonParams {
gradeId: string
subjectId: string
examId?: string
scope: DataScope
}
export async function getClassComparison(
params: ClassComparisonParams
): Promise<ClassComparisonItem[]> {
const classRows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, params.gradeId))
if (classRows.length === 0) return []
const scope = params.scope
const allowedClassIds =
scope.type === "class_taught"
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
: classRows.map((c) => c.id)
if (allowedClassIds.length === 0) return []
const result: ClassComparisonItem[] = []
for (const cls of classRows) {
if (!allowedClassIds.includes(cls.id)) continue
const conditions = [
eq(gradeRecords.classId, cls.id),
eq(gradeRecords.subjectId, params.subjectId),
]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) {
result.push({
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
})
continue
}
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
result.push({
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
})
}
return result
}
export interface SubjectComparisonParams {
classId: string
examId?: string
scope: DataScope
}
export async function getSubjectComparison(
params: SubjectComparisonParams
): Promise<SubjectComparisonItem[]> {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
subjectId: gradeRecords.subjectId,
subjectName: subjects.name,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
const bySubject = new Map<string, { name: string; scores: number[] }>()
for (const r of rows) {
const sid = r.subjectId
if (!sid) continue
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry)
}
const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
result.push({
subjectId,
subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
})
}
return result.sort((a, b) => b.averageScore - a.averageScore)
}
export interface GradeDistributionParams {
classId: string
subjectId?: string
examId?: string
scope: DataScope
currentUserId?: string
}
export async function getGradeDistribution(
params: GradeDistributionParams
): Promise<GradeDistributionResult> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
.from(gradeRecords)
.where(and(...conditions))
const buckets: GradeDistributionBucket[] = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
}