feat(grades): add scope-check and update analytics

- Add scope-check lib for grade data access scope validation

- Update actions, actions-analytics, data-access, data-access-analytics

- Update batch-grade-entry, schema, and types
This commit is contained in:
SpecialX
2026-06-24 12:02:50 +08:00
parent 6bc113eaff
commit 0cee93676b
8 changed files with 897 additions and 684 deletions

View File

@@ -8,9 +8,11 @@ import { handleActionError } from "@/shared/lib/action-utils"
import {
getClassComparison,
getGradeDistribution,
getGradeDistributionByGradeId,
getGradeTrend,
getSubjectComparison,
type ClassComparisonParams,
type GradeDistributionByGradeParams,
type GradeDistributionParams,
type GradeTrendParams,
type SubjectComparisonParams,
@@ -18,14 +20,16 @@ import {
import { getRankingTrend } from "./data-access-ranking"
import {
ClassComparisonQuerySchema,
GradeDistributionByGradeQuerySchema,
GradeDistributionQuerySchema,
GradeTrendQuerySchema,
RankingTrendQuerySchema,
SubjectComparisonQuerySchema,
} from "./schema"
import { assertClassInScope } from "./actions"
import { assertClassInScope } from "./lib/scope-check"
import type {
ClassComparisonItem,
GradeDistributionByGradeResult,
GradeDistributionResult,
GradeTrendResult,
RankingTrendResult,
@@ -191,3 +195,32 @@ export async function getRankingTrendAction(
return handleActionError(e)
}
}
/**
* 年级仪表盘 - 维度1获取年级整体 + 按班级拆分的成绩分布。
* grade_managed / all / class_taught scope 均可调用data-access 层已应用行级过滤。
*/
export async function getGradeDistributionByGradeIdAction(
params: Omit<GradeDistributionByGradeParams, "scope">
): Promise<ActionState<GradeDistributionByGradeResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const parsed = GradeDistributionByGradeQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await getGradeDistributionByGradeId({
...parsed.data,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import {
CreateGradeRecordSchema,
BatchCreateGradeRecordSchema,
BatchGradeEntryByExamSchema,
UpdateGradeRecordSchema,
DeleteGradeRecordSchema,
GetGradeRecordByIdSchema,
@@ -24,6 +25,7 @@ import {
import {
createGradeRecord,
batchCreateGradeRecords,
batchCreateGradeRecordsByExam,
undoBatchCreateGradeRecords,
updateGradeRecord,
deleteGradeRecord,
@@ -40,6 +42,7 @@ import {
} from "./data-access"
import type { PaginatedGradeRecords } from "./data-access"
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
import { getExamForGradeEntry } from "@/modules/exams/data-access"
import {
exportGradeRecordsToExcel,
exportClassGradeReportToExcel,
@@ -52,37 +55,7 @@ import type {
ClassRankingItem,
GradeRecord,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
/**
* Validate that the given classId is within the user's DataScope.
* Returns an error message string when access is denied, otherwise null.
*
* - `all` / `children` / `grade_managed`: allowed (children/grade_managed are
* validated at the data layer via row-level filters; classId scoping would
* require extra cross-module lookups and is intentionally permissive here).
* - `class_taught` / `class_members`: classId must be in scope.classIds.
* - `owned`: never allowed (owned scope has no class context).
*/
export function assertClassInScope(
scope: DataScope,
classId: string
): string | null {
if (scope.type === "all") return null
if (scope.type === "children") return null
if (scope.type === "grade_managed") return null
if (scope.type === "class_taught") {
return scope.classIds.includes(classId)
? null
: "You can only access classes you teach"
}
if (scope.type === "class_members") {
return scope.classIds.includes(classId)
? null
: "You can only access your own class"
}
return "Access denied for your scope"
}
import { assertClassInScope } from "./lib/scope-check"
/**
* v4-P1-6: 成绩录入后通知学生和家长。
@@ -282,6 +255,115 @@ export async function batchCreateGradeRecordsAction(
}
}
/**
* 按试卷批量录入成绩(每题得分)。
*
* 必须指定试卷 ID系统自动从试卷带出科目、标题、满分和题目列表。
* 教师只需为每个学生填写每题得分,总分自动计算。
*
* 写入时同步投影到 exam_submissions + submission_answers
* 使错题集、成绩分析等下游模块无需改造即可读取。
*/
export async function batchCreateGradeRecordsByExamAction(
prevState: ActionState<string[]> | null,
formData: FormData
): Promise<ActionState<string[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const examId = formData.get("examId")
const classId = formData.get("classId")
const recordsJson = formData.get("recordsJson")
if (typeof examId !== "string" || examId.length === 0) {
return { success: false, message: "缺少试卷 ID" }
}
if (typeof classId !== "string" || classId.length === 0) {
return { success: false, message: "缺少班级 ID" }
}
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "缺少成绩数据" }
}
// 获取试卷详情(验证试卷存在 + scope 校验 + 获取 subjectId/fullScore/title
const exam = await getExamForGradeEntry(examId, ctx.dataScope)
if (!exam) {
return { success: false, message: "试卷不存在或无权访问" }
}
if (exam.questions.length === 0) {
return { success: false, message: "试卷没有题目,无法录入" }
}
if (!exam.subjectId) {
return { success: false, message: "试卷未关联科目" }
}
// 校验班级在 scope 内
const scopeError = assertClassInScope(ctx.dataScope, classId)
if (scopeError) {
return { success: false, message: scopeError }
}
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
const parsed = BatchGradeEntryByExamSchema.safeParse({
examId,
classId,
subjectId: exam.subjectId,
title: exam.title,
fullScore: exam.totalScore,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
records,
})
if (!parsed.success) {
return {
success: false,
message: "表单数据无效",
errors: parsed.error.flatten().fieldErrors,
}
}
const ids = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
// 更新诊断掌握度
for (const record of parsed.data.records) {
const totalScore = record.answers.reduce((sum, a) => sum + a.score, 0)
try {
await updateMasteryFromExamScore(
record.studentId,
parsed.data.examId,
totalScore,
parsed.data.fullScore,
)
} catch {
// 掌握度更新失败不阻断成绩录入
}
}
// 通知学生和家长
try {
await notifyGradeEntered({
studentIds: parsed.data.records.map((r) => r.studentId),
title: parsed.data.title,
subjectId: parsed.data.subjectId,
fullScore: parsed.data.fullScore,
})
} catch {
// 通知失败不阻断成绩录入
}
revalidatePath("/teacher/grades")
return {
success: true,
message: `录入 ${ids.length} 条成绩记录`,
data: ids,
}
} catch (e) {
return handleActionError(e)
}
}
/**
* v3-P2-3: 撤销最近一次批量成绩录入。
* 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import {
} from "./stats-service"
import type {
ClassComparisonItem,
GradeDistributionByGradeResult,
GradeDistributionResult,
GradeTrendResult,
SchoolWideGradeSummary,
@@ -391,3 +392,90 @@ export const getSchoolWideGradeSummary = cache(
}
}
)
/**
* 年级仪表盘 - 维度1获取年级整体 + 按班级拆分的成绩分布。
* 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询成绩记录,
* 应用 buildScopeClassFilter 行级权限过滤,复用 computeGradeDistribution / computeGradeStats 纯函数。
*/
export interface GradeDistributionByGradeParams {
gradeId: string
subjectId?: string
examId?: string
semester?: "1" | "2"
scope: DataScope
}
export const getGradeDistributionByGradeId = cache(
async (params: GradeDistributionByGradeParams): Promise<GradeDistributionByGradeResult> => {
const classRows = await getClassesByGradeId(params.gradeId)
if (classRows.length === 0) {
return {
overall: computeGradeDistribution([]),
stats: null,
byClass: [],
}
}
// scope 过滤class_taught 仅保留当前教师所教班级
const scope = params.scope
const scopeClassIdSet =
scope.type === "class_taught" ? new Set(scope.classIds) : null
const allowedClassRows = scopeClassIdSet
? classRows.filter((c) => scopeClassIdSet.has(c.id))
: classRows
if (allowedClassRows.length === 0) {
return {
overall: computeGradeDistribution([]),
stats: null,
byClass: [],
}
}
const allowedClassIds = allowedClassRows.map((c) => c.id)
const conditions = [inArray(gradeRecords.classId, allowedClassIds)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
classId: gradeRecords.classId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
const overall = computeGradeDistribution(rows)
const stats = computeGradeStats(rows)
const byClassMap = new Map<string, typeof rows>()
for (const r of rows) {
const existing = byClassMap.get(r.classId)
if (existing) {
existing.push(r)
} else {
byClassMap.set(r.classId, [r])
}
}
const byClass = allowedClassRows.map((cls) => {
const classRowsForClass = byClassMap.get(cls.id) ?? []
return {
classId: cls.id,
className: cls.name,
distribution: computeGradeDistribution(classRowsForClass),
stats: computeGradeStats(classRowsForClass),
}
})
return { overall, stats, byClass }
}
)

View File

@@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeDrafts, gradeRecords } from "@/shared/db/schema"
import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import {
getActiveStudentIdsByClassId,
getClassExists,
@@ -32,6 +32,7 @@ import type {
} from "./types"
import type {
BatchCreateGradeRecordInput,
BatchGradeEntryByExamInput,
CreateGradeRecordInput,
UpdateGradeRecordInput,
} from "./schema"
@@ -196,6 +197,163 @@ export async function batchCreateGradeRecords(
return rows.map((r) => r.id)
}
/**
* 按试卷批量录入成绩(每题得分)。
*
* 写入流程(单事务):
* 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷
* 2. 查询已有 exam_submissions避免主键冲突
* 3. 为每个学生:
* - 创建 grade_recordsscore = 各题得分之和)
* - 创建 grade_record_answers每题得分
* - 创建/更新 exam_submissionsstatus='graded',投影供下游模块使用)
* - 创建/替换 submission_answersanswerContent=null投影每题得分
*
* 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现,
* 这样错题集、成绩分析等下游模块无需改造即可读取。
*/
export async function batchCreateGradeRecordsByExam(
data: BatchGradeEntryByExamInput,
recordedBy: string
): Promise<string[]> {
if (data.records.length === 0) return []
const now = new Date()
const gradeRecordIds: string[] = []
await db.transaction(async (tx) => {
// 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属)
const examQuestionRows = await tx
.select({
questionId: examQuestions.questionId,
score: examQuestions.score,
})
.from(examQuestions)
.where(eq(examQuestions.examId, data.examId))
if (examQuestionRows.length === 0) {
throw new Error("试卷没有题目,无法录入成绩")
}
const questionFullScoreMap = new Map(
examQuestionRows.map((q) => [q.questionId, q.score ?? 0])
)
// 校验所有 questionId 都属于该试卷
for (const record of data.records) {
for (const answer of record.answers) {
if (!questionFullScoreMap.has(answer.questionId)) {
throw new Error(
`题目 ${answer.questionId} 不属于试卷 ${data.examId}`
)
}
}
}
// 2. 查询已有 exam_submissions避免冲突
const studentIds = data.records.map((r) => r.studentId)
const existingSubmissions = await tx
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
})
.from(examSubmissions)
.where(
and(
eq(examSubmissions.examId, data.examId),
inArray(examSubmissions.studentId, studentIds)
)
)
const existingSubmissionMap = new Map(
existingSubmissions.map((s) => [s.studentId, s.id])
)
// 3. 为每个学生创建记录
for (const record of data.records) {
const gradeRecordId = createId()
gradeRecordIds.push(gradeRecordId)
const totalScore = record.answers.reduce(
(sum, a) => sum + a.score,
0
)
// 3a. grade_records
await tx.insert(gradeRecords).values({
id: gradeRecordId,
studentId: record.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId,
academicYearId: null,
title: data.title,
score: String(totalScore),
fullScore: String(data.fullScore),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: record.remark ?? null,
})
// 3b. grade_record_answers
const answerRows = record.answers.map((a) => ({
id: createId(),
gradeRecordId,
questionId: a.questionId,
score: String(a.score),
fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0),
feedback: null,
}))
await tx.insert(gradeRecordAnswers).values(answerRows)
// 3c. 投影到 exam_submissions
const existingSubmissionId = existingSubmissionMap.get(record.studentId)
let submissionId: string
if (existingSubmissionId) {
await tx
.update(examSubmissions)
.set({
score: totalScore,
status: "graded",
submittedAt: now,
updatedAt: now,
})
.where(eq(examSubmissions.id, existingSubmissionId))
submissionId = existingSubmissionId
// 删除旧 submission_answers
await tx
.delete(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
} else {
submissionId = createId()
await tx.insert(examSubmissions).values({
id: submissionId,
examId: data.examId,
studentId: record.studentId,
score: totalScore,
status: "graded",
submittedAt: now,
})
}
// 3d. submission_answers 投影answerContent=null只有得分
const submissionAnswerRows = record.answers.map((a) => ({
id: createId(),
submissionId,
questionId: a.questionId,
answerContent: null,
score: a.score,
feedback: null,
}))
await tx.insert(submissionAnswers).values(submissionAnswerRows)
}
})
return gradeRecordIds
}
/**
* v3-P2-3: 批量撤销成绩录入。
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。

View File

@@ -0,0 +1,32 @@
import type { DataScope } from "@/shared/types/permissions"
/**
* 校验给定 classId 是否在用户 DataScope 范围内。
* 返回错误消息字符串表示拒绝访问;返回 null 表示允许。
*
* - `all` / `children` / `grade_managed`: 允许children/grade_managed 在数据层做行级过滤)
* - `class_taught` / `class_members`: classId 必须在 scope.classIds 中
* - `owned`: 永远拒绝owned scope 无 class 上下文)
*
* 注意:本函数为纯同步函数,**不能**放在 "use server" 文件中直接 export
* 否则 Next.js 会将其视为 Server Action 并要求 async。故独立到此文件。
*/
export function assertClassInScope(
scope: DataScope,
classId: string,
): string | null {
if (scope.type === "all") return null
if (scope.type === "children") return null
if (scope.type === "grade_managed") return null
if (scope.type === "class_taught") {
return scope.classIds.includes(classId)
? null
: "You can only access classes you teach"
}
if (scope.type === "class_members") {
return scope.classIds.includes(classId)
? null
: "You can only access your own class"
}
return "Access denied for your scope"
}

View File

@@ -130,8 +130,41 @@ export const GradeDistributionQuerySchema = z.object({
semester: GradeRecordSemesterEnum.optional(),
})
export const GradeDistributionByGradeQuerySchema = z.object({
gradeId: z.string().min(1),
subjectId: z.string().optional(),
examId: z.string().optional(),
semester: GradeRecordSemesterEnum.optional(),
})
export const RankingTrendQuerySchema = z.object({
studentId: z.string().min(1),
subjectId: z.string().optional(),
semester: GradeRecordSemesterEnum.optional(),
})
// --- 按试卷批量录入(每题得分)---
export const BatchGradeEntryByExamQuestionSchema = z.object({
questionId: z.string().min(1),
score: z.coerce.number().min(0).max(1000),
})
export const BatchGradeEntryByExamItemSchema = z.object({
studentId: z.string().min(1),
answers: z.array(BatchGradeEntryByExamQuestionSchema).min(1),
remark: z.string().optional(),
})
export const BatchGradeEntryByExamSchema = z.object({
examId: z.string().min(1),
classId: z.string().min(1),
subjectId: z.string().min(1),
title: z.string().min(1).max(255),
fullScore: z.coerce.number().min(1).max(1000),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
records: z.array(BatchGradeEntryByExamItemSchema).min(1).max(500),
})
export type BatchGradeEntryByExamInput = z.infer<typeof BatchGradeEntryByExamSchema>

View File

@@ -194,6 +194,26 @@ export interface GradeDistributionResult {
totalCount: number
}
/**
* 年级维度成绩分布(按班级拆分)。
* 用于年级主任/教学主任仪表盘的"成绩分布"维度。
*/
export interface GradeDistributionByGradeClassItem {
classId: string
className: string
distribution: GradeDistributionResult
stats: GradeStats | null
}
export interface GradeDistributionByGradeResult {
/** 年级整体分布(已应用 scope 过滤) */
overall: GradeDistributionResult
/** 年级整体统计(均分/及格率/优秀率等) */
stats: GradeStats | null
/** 按班级拆分的分布 + 统计 */
byClass: GradeDistributionByGradeClassItem[]
}
export interface RankingTrendPoint {
/** Title of the exam/assessment */
title: string
@@ -238,3 +258,30 @@ export interface SelectOption {
id: string
name: string
}
// --- 按试卷批量录入(每题得分)---
/** 成绩每题得分记录(对应 grade_record_answers 表) */
export interface GradeRecordAnswer {
id: string
gradeRecordId: string
questionId: string
score: number
fullScore: number
feedback: string | null
createdAt: string
updatedAt: string
}
/** 单个学生的每题得分录入项 */
export interface BatchGradeEntryByExamQuestion {
questionId: string
score: number
}
/** 单个学生的录入项 */
export interface BatchGradeEntryByExamItem {
studentId: string
answers: BatchGradeEntryByExamQuestion[]
remark?: string
}