feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { AdminService } from "./admin.service.js";
@Module({
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,64 @@
import { Injectable } from "@nestjs/common";
/**
* Admin 聚合服务P3.13 stub
*
* 这些 RPC 返回学校、年级、部门、学年等组织架构数据。
* core-edu 不持有这些表,实际数据来自 IAM / classes 服务。
* P3.13 阶段返回空数组,待跨服务 gRPC 客户端集成后补全。
*/
export interface SchoolInfo {
id: string;
name: string;
address: string;
principalId: string;
createdAt: string;
}
export interface GradeLevelInfo {
id: string;
name: string;
schoolId: string;
order: number;
}
export interface DepartmentInfo {
id: string;
name: string;
schoolId: string;
headId: string;
createdAt: string;
}
export interface AcademicYearInfo {
id: string;
name: string;
schoolId: string;
startDate: string;
endDate: string;
isCurrent: boolean;
}
@Injectable()
export class AdminService {
async listSchools(): Promise<SchoolInfo[]> {
// P3.13 stub: 待 IAM gRPC 客户端集成
return [];
}
async listGradeLevels(_schoolId: string): Promise<GradeLevelInfo[]> {
// P3.13 stub: 待 classes gRPC 客户端集成
return [];
}
async listDepartments(_schoolId: string): Promise<DepartmentInfo[]> {
// P3.13 stub: 待 IAM gRPC 客户端集成
return [];
}
async listAcademicYears(_schoolId?: string): Promise<AcademicYearInfo[]> {
// P3.13 stub: 待 classes gRPC 客户端集成
return [];
}
}

View File

@@ -6,6 +6,9 @@ import { GradesModule } from "./grades/grades.module.js";
import { AttendanceModule } from "./attendance/attendance.module.js";
import { ClassesModule } from "./classes/classes.module.js";
import { SchedulingModule } from "./scheduling/scheduling.module.js";
import { LeaveRequestsModule } from "./leave-requests/leave-requests.module.js";
import { DashboardModule } from "./dashboard/dashboard.module.js";
import { AdminModule } from "./admin/admin.module.js";
import { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
@@ -20,6 +23,10 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
AttendanceModule,
ClassesModule,
SchedulingModule,
// P3.13 新增模块
LeaveRequestsModule,
DashboardModule,
AdminModule,
IamConsumerModule,
HealthModule,
],

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DashboardService } from "./dashboard.service.js";
@Module({
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,245 @@
import { eq, inArray, gte, and } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { classes } from "../classes/classes.schema.js";
import { exams, examSubmissions } from "../exams/exams.schema.js";
import { homework, homeworkSubmissions } from "../homework/homework.schema.js";
import { grades } from "../grades/grades.schema.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface DashboardData {
teacherId: string;
totalClasses: number;
totalStudents: number;
pendingHomework: number;
upcomingExams: number;
ungradedSubmissions: number;
classes: Array<{
classId: string;
className: string;
studentCount: number;
}>;
upcomingExamList: Array<{
examId: string;
title: string;
examDate: string;
classId: string;
className: string;
}>;
generatedAt: string;
}
export interface ClassPerformance {
classId: string;
className: string;
studentCount: number;
averageScore: string;
highestScore: string;
lowestScore: string;
medianScore: string;
subjects: Array<{
subjectId: string;
subjectName: string;
averageScore: string;
studentCount: number;
}>;
generatedAt: string;
}
@Injectable()
export class DashboardService {
async getDashboard(teacherId: string): Promise<DashboardData> {
if (!teacherId) {
throw new ValidationError("teacherId is required");
}
// 1. 查询该教师负责的班级
const teacherClasses = await db
.select()
.from(classes)
.where(eq(classes.headTeacherId, teacherId));
const classIds = teacherClasses.map((c) => c.id);
const classMap = new Map(teacherClasses.map((c) => [c.id, c.name]));
if (classIds.length === 0) {
return {
teacherId,
totalClasses: 0,
totalStudents: 0,
pendingHomework: 0,
upcomingExams: 0,
ungradedSubmissions: 0,
classes: [],
upcomingExamList: [],
generatedAt: new Date().toISOString(),
};
}
// 2. 构造班级卡片(学生数需 IAM 集成P3.13 stub 用 0 占位)
const classCards = teacherClasses.map((c) => ({
classId: c.id,
className: c.name,
studentCount: 0,
}));
// 3. 统计待处理作业status='assigned'
const pendingHomeworkRows = await db
.select({ id: homework.id })
.from(homework)
.where(
and(
inArray(homework.classId, classIds),
eq(homework.status, "assigned"),
),
);
// 4. 统计即将到来的考试status='published', exam_date >= now
const now = new Date();
const upcomingExamRows = await db
.select()
.from(exams)
.where(
and(
inArray(exams.classId, classIds),
eq(exams.status, "published"),
gte(exams.examDate, now),
),
);
const upcomingExamList = upcomingExamRows.map((e) => ({
examId: e.id,
title: e.title,
examDate:
e.examDate instanceof Date ? e.examDate.toISOString() : e.examDate,
classId: e.classId,
className: classMap.get(e.classId) ?? "",
}));
// 5. 统计未批阅提交exam_submissions + homework_submissions status='submitted'
const examIds = upcomingExamRows.map((e) => e.id);
let ungradedExamCount = 0;
if (examIds.length > 0) {
const ungradedExamRows = await db
.select({ id: examSubmissions.id })
.from(examSubmissions)
.where(
and(
inArray(examSubmissions.examId, examIds),
eq(examSubmissions.status, "submitted"),
),
);
ungradedExamCount = ungradedExamRows.length;
}
const homeworkIds = pendingHomeworkRows.map((h) => h.id);
let ungradedHwCount = 0;
if (homeworkIds.length > 0) {
const ungradedHwRows = await db
.select({ id: homeworkSubmissions.id })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.homeworkId, homeworkIds),
eq(homeworkSubmissions.status, "submitted"),
),
);
ungradedHwCount = ungradedHwRows.length;
}
return {
teacherId,
totalClasses: teacherClasses.length,
totalStudents: 0, // P3.13 stub: 需 IAM 集成
pendingHomework: pendingHomeworkRows.length,
upcomingExams: upcomingExamRows.length,
ungradedSubmissions: ungradedExamCount + ungradedHwCount,
classes: classCards,
upcomingExamList,
generatedAt: new Date().toISOString(),
};
}
async getClassPerformance(
classId: string,
_subjectId?: string,
): Promise<ClassPerformance> {
if (!classId) {
throw new ValidationError("classId is required");
}
// 1. 查询班级信息
const classRows = await db
.select()
.from(classes)
.where(eq(classes.id, classId))
.limit(1);
const cls = classRows[0];
if (!cls) {
throw new NotFoundError(`Class ${classId} not found`);
}
// 2. 查询该班学生的成绩(通过 exam_id 关联 exam.class_id
const classExamIds = await db
.select({ id: exams.id })
.from(exams)
.where(eq(exams.classId, classId));
const examIdList = classExamIds.map((e) => e.id);
let allGrades: Array<{ score: string; totalScore: string }> = [];
if (examIdList.length > 0) {
const gradeRows = await db
.select({
score: grades.score,
totalScore: grades.totalScore,
})
.from(grades)
.where(inArray(grades.examId, examIdList));
allGrades = gradeRows;
}
// 3. 计算统计值
const percentages = allGrades.map((g) => {
const total = Number(g.totalScore);
return total > 0 ? (Number(g.score) / total) * 100 : 0;
});
// 学生数需 IAM 集成P3.13 stub 用成绩记录去重学生数估算
const average =
percentages.length > 0
? percentages.reduce((a, b) => a + b, 0) / percentages.length
: 0;
const highest = percentages.length > 0 ? Math.max(...percentages) : 0;
const lowest = percentages.length > 0 ? Math.min(...percentages) : 0;
const median = this.calcMedian(percentages);
return {
classId,
className: cls.name,
studentCount: 0, // P3.13 stub: 需 IAM 集成
averageScore: average.toFixed(2),
highestScore: highest.toFixed(2),
lowestScore: lowest.toFixed(2),
medianScore: median.toFixed(2),
subjects: [], // P3.13 stub: 需 content 服务集成获取学科名
generatedAt: new Date().toISOString(),
};
}
private calcMedian(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
const a = sorted[mid - 1] ?? 0;
const b = sorted[mid] ?? 0;
return (a + b) / 2;
}
return sorted[mid] ?? 0;
}
}

View File

@@ -0,0 +1,59 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
int,
json,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// 考试草稿表P3.13 新增)
export const examDrafts = mysqlTable(
"core_edu_exam_drafts",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
answers: json("answers"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqExamStudentDraft: uniqueIndex("uniq_exam_student_draft").on(
table.examId,
table.studentId,
),
idxExamDraftsStudent: index("idx_exam_drafts_student").on(table.studentId),
}),
);
// 考试违规表P3.13 新增)
export const examViolations = mysqlTable(
"core_edu_exam_violations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
violationType: varchar("violation_type", { length: 40 }).notNull(),
detail: text("detail"),
severity: int("severity").notNull().default(1),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
idxExamViolationsExam: index("idx_exam_violations_exam").on(table.examId),
idxExamViolationsStudent: index("idx_exam_violations_student").on(
table.studentId,
),
idxExamViolationsType: index("idx_exam_violations_type").on(
table.violationType,
),
}),
);
export type ExamDraft = typeof examDrafts.$inferSelect;
export type NewExamDraft = typeof examDrafts.$inferInsert;
export type ExamViolation = typeof examViolations.$inferSelect;
export type NewExamViolation = typeof examViolations.$inferInsert;

View File

@@ -0,0 +1,171 @@
import { describe, it, expect } from "vitest";
import {
canTransition,
transition,
isTerminal,
EXAM_STATUSES,
} from "./exam-state-machine.js";
import type { ExamStatus, ExamAction } from "./exam-state-machine.js";
describe("exam-state-machine", () => {
describe("合法状态转换", () => {
it("draft --publish--> published", () => {
expect(transition("draft", "publish")).toBe("published");
});
it("published --start--> in_progress", () => {
expect(transition("published", "start")).toBe("in_progress");
});
it("in_progress --submit--> grading", () => {
expect(transition("in_progress", "submit")).toBe("grading");
});
it("grading --grade--> graded", () => {
expect(transition("grading", "grade")).toBe("graded");
});
it("graded --archive--> archived", () => {
expect(transition("graded", "archive")).toBe("archived");
});
it("cancelled 可从 draft/published/in_progress/grading 流入cancel", () => {
expect(transition("draft", "cancel")).toBe("cancelled");
expect(transition("published", "cancel")).toBe("cancelled");
expect(transition("in_progress", "cancel")).toBe("cancelled");
expect(transition("grading", "cancel")).toBe("cancelled");
});
});
describe("非法状态转换", () => {
it("draft --grade--> 抛错(不能从 draft 直接 grade", () => {
expect(() => transition("draft", "grade")).toThrow();
});
it("draft --start--> 抛错(必须先 publish", () => {
expect(() => transition("draft", "start")).toThrow();
});
it("published --submit--> 抛错(必须先 start", () => {
expect(() => transition("published", "submit")).toThrow();
});
it("graded --publish--> 抛错(只能 archive", () => {
expect(() => transition("graded", "publish")).toThrow();
});
it("graded --cancel--> 抛错graded 只能 archive", () => {
expect(() => transition("graded", "cancel")).toThrow();
});
it("archived 是终态,任何动作都抛错", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(() => transition("archived", action)).toThrow();
}
});
it("cancelled 是终态,任何动作都抛错", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(() => transition("cancelled", action)).toThrow();
}
});
it("抛错信息包含非法转换描述", () => {
expect(() => transition("draft", "grade")).toThrow(
/Invalid exam state transition/,
);
});
});
describe("canTransition", () => {
it("合法转换返回 true", () => {
expect(canTransition("draft", "publish")).toBe(true);
expect(canTransition("published", "start")).toBe(true);
expect(canTransition("in_progress", "submit")).toBe(true);
expect(canTransition("grading", "grade")).toBe(true);
expect(canTransition("graded", "archive")).toBe(true);
});
it("cancel 动作在 draft/published/in_progress/grading 下返回 true", () => {
expect(canTransition("draft", "cancel")).toBe(true);
expect(canTransition("published", "cancel")).toBe(true);
expect(canTransition("in_progress", "cancel")).toBe(true);
expect(canTransition("grading", "cancel")).toBe(true);
});
it("非法转换返回 false", () => {
expect(canTransition("draft", "grade")).toBe(false);
expect(canTransition("draft", "start")).toBe(false);
expect(canTransition("published", "submit")).toBe(false);
expect(canTransition("graded", "publish")).toBe(false);
expect(canTransition("graded", "cancel")).toBe(false);
});
it("终态对所有动作返回 false", () => {
const actions: ExamAction[] = [
"publish",
"start",
"submit",
"grade",
"archive",
"cancel",
];
for (const action of actions) {
expect(canTransition("archived", action)).toBe(false);
expect(canTransition("cancelled", action)).toBe(false);
}
});
});
describe("isTerminal", () => {
it("archived 是终态", () => {
expect(isTerminal("archived")).toBe(true);
});
it("cancelled 是终态", () => {
expect(isTerminal("cancelled")).toBe(true);
});
it("非终态状态返回 false", () => {
const nonTerminal: ExamStatus[] = [
"draft",
"published",
"in_progress",
"grading",
"graded",
];
for (const status of nonTerminal) {
expect(isTerminal(status)).toBe(false);
}
});
});
describe("EXAM_STATUSES 常量", () => {
it("包含全部 7 种状态", () => {
expect(EXAM_STATUSES).toHaveLength(7);
expect([...EXAM_STATUSES]).toContain("draft");
expect([...EXAM_STATUSES]).toContain("published");
expect([...EXAM_STATUSES]).toContain("in_progress");
expect([...EXAM_STATUSES]).toContain("grading");
expect([...EXAM_STATUSES]).toContain("graded");
expect([...EXAM_STATUSES]).toContain("archived");
expect([...EXAM_STATUSES]).toContain("cancelled");
});
});
});

View File

@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "./exams.schema.js";
import { examDrafts, examViolations } from "./exam-extensions.schema.js";
import { examsRepository } from "./exams.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
@@ -391,6 +392,92 @@ export class ExamsService {
});
}
// --------------------------------------------------------------------------
// P3.13 新增考试草稿自动保存upsert by exam_id + student_id
// --------------------------------------------------------------------------
async saveExamDraft(
examId: string,
studentId: string,
answers: AnswerInput[],
): Promise<{ draftId: string }> {
if (!examId || !studentId) {
throw new ValidationError("examId and studentId are required");
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const answersPayload = answers.map((a) => ({
questionId: a.questionId,
answer: a.answer,
}));
// 查找已有草稿unique key: exam_id + student_id
const existing = await db
.select()
.from(examDrafts)
.where(
and(eq(examDrafts.examId, examId), eq(examDrafts.studentId, studentId)),
)
.limit(1);
const draftRow = existing[0];
if (draftRow) {
const draftId = draftRow.id;
await db
.update(examDrafts)
.set({ answers: answersPayload })
.where(eq(examDrafts.id, draftId));
return { draftId };
}
const draftId = randomUUID();
await db.insert(examDrafts).values({
id: draftId,
examId,
studentId,
answers: answersPayload,
});
return { draftId };
}
// --------------------------------------------------------------------------
// P3.13 新增:考试违规事件记录(防作弊)
// --------------------------------------------------------------------------
async recordExamViolation(
examId: string,
studentId: string,
violationType: string,
detail: string,
severity: number,
): Promise<{ violationId: string }> {
if (!examId || !studentId || !violationType) {
throw new ValidationError(
"examId, studentId, violationType are required",
);
}
// 校验考试存在
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const violationId = randomUUID();
await db.insert(examViolations).values({
id: violationId,
examId,
studentId,
violationType,
detail: detail || null,
severity: severity || 1,
});
return { violationId };
}
private assertTransition(from: ExamStatus, action: ExamAction): void {
if (!canTransition(from, action)) {
throw new ApplicationError(

View File

@@ -0,0 +1,213 @@
import { describe, it, expect } from "vitest";
import { selectFormula, calculateGrade } from "./grade-calculator.js";
import type { GradeFormulaConfig, ScoreEntry } from "./grade-calculator.js";
// 固定时间基准,便于测试 effectiveFrom/effectiveTo 过滤
const NOW = new Date("2026-07-13T12:00:00Z");
const PAST = new Date("2026-07-01T00:00:00Z");
const FUTURE = new Date("2026-08-01T00:00:00Z");
// 构造公式配置的工厂函数,减少样板代码
function makeFormula(
overrides: Partial<GradeFormulaConfig>,
): GradeFormulaConfig {
return {
scope: "school",
scopeId: "s1",
formulaType: "weighted_average",
weights: null,
customExpression: null,
effectiveFrom: PAST,
effectiveTo: null,
...overrides,
};
}
describe("grade-calculator", () => {
describe("selectFormula - scope 优先级", () => {
it("class > subject > school打乱顺序仍选出 class", () => {
const school = makeFormula({ scope: "school", scopeId: "sch1" });
const subject = makeFormula({ scope: "subject", scopeId: "sub1" });
const klass = makeFormula({ scope: "class", scopeId: "cls1" });
expect(selectFormula([school, subject, klass], NOW)).toBe(klass);
expect(selectFormula([klass, school, subject], NOW)).toBe(klass);
expect(selectFormula([subject, klass, school], NOW)).toBe(klass);
});
it("仅有 subject 与 school 时选出 subject", () => {
const school = makeFormula({ scope: "school", scopeId: "sch1" });
const subject = makeFormula({ scope: "subject", scopeId: "sub1" });
expect(selectFormula([school, subject], NOW)).toBe(subject);
});
it("仅有 school 时选出 school", () => {
const school = makeFormula({ scope: "school", scopeId: "sch1" });
expect(selectFormula([school], NOW)).toBe(school);
});
});
describe("selectFormula - 时间过滤", () => {
it("effectiveFrom 在未来时被排除", () => {
const futureFormula = makeFormula({
scope: "class",
effectiveFrom: FUTURE,
});
const schoolFormula = makeFormula({ scope: "school" });
// class 优先级高但尚未生效,应返回 school
expect(selectFormula([futureFormula, schoolFormula], NOW)).toBe(
schoolFormula,
);
});
it("effectiveTo <= now 时被排除(已过期)", () => {
const expired = makeFormula({
scope: "class",
effectiveFrom: PAST,
effectiveTo: NOW, // effectiveTo <= now → 排除
});
const schoolFormula = makeFormula({ scope: "school" });
expect(selectFormula([expired, schoolFormula], NOW)).toBe(schoolFormula);
});
it("effectiveTo 在未来时生效", () => {
const active = makeFormula({
scope: "class",
effectiveFrom: PAST,
effectiveTo: FUTURE,
});
expect(selectFormula([active], NOW)).toBe(active);
});
it("effectiveFrom == now 时生效(边界包含)", () => {
const boundary = makeFormula({ scope: "class", effectiveFrom: NOW });
expect(selectFormula([boundary], NOW)).toBe(boundary);
});
it("无任何生效公式时返回 null", () => {
const futureFormula = makeFormula({
scope: "class",
effectiveFrom: FUTURE,
});
expect(selectFormula([futureFormula], NOW)).toBeNull();
});
it("空数组返回 null", () => {
expect(selectFormula([], NOW)).toBeNull();
});
});
describe("calculateGrade - weighted_average加权平均", () => {
it("加权平均计算正确", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: { a: 0.3, b: 0.7 },
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
{ sourceId: "b", score: 90, totalScore: 100 },
];
// (80*0.3 + 90*0.7) / (0.3+0.7) = (24+63)/1 = 87
expect(calculateGrade(formula, entries)).toBe(87);
});
it("不等权重计算并四舍五入到两位小数", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: { a: 2, b: 1 },
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 85, totalScore: 100 },
{ sourceId: "b", score: 90, totalScore: 100 },
];
// (85*2 + 90*1) / 3 = 260/3 = 86.666... → 86.67
expect(calculateGrade(formula, entries)).toBe(86.67);
});
it("无权重时退化为简单平均", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: null,
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
{ sourceId: "b", score: 90, totalScore: 100 },
];
// (80+90)/2 = 85
expect(calculateGrade(formula, entries)).toBe(85);
});
it("无权重且空 entries 返回 0", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: null,
});
expect(calculateGrade(formula, [])).toBe(0);
});
it("entry 的 sourceId 不在 weights 中时权重为 0", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: { a: 1 },
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
{ sourceId: "b", score: 100, totalScore: 100 }, // b 不在 weights权重 0
];
// (80*1 + 100*0) / 1 = 80
expect(calculateGrade(formula, entries)).toBe(80);
});
it("所有 entry 权重为 0 时返回 0避免除零", () => {
const formula = makeFormula({
formulaType: "weighted_average",
weights: { x: 1 },
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
];
// a 不在 weights权重 0totalWeight = 0 → 返回 0
expect(calculateGrade(formula, entries)).toBe(0);
});
});
describe("calculateGrade - sum求和", () => {
it("求和计算正确", () => {
const formula = makeFormula({ formulaType: "sum" });
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
{ sourceId: "b", score: 90, totalScore: 100 },
];
expect(calculateGrade(formula, entries)).toBe(170);
});
it("空 entries 求和返回 0", () => {
const formula = makeFormula({ formulaType: "sum" });
expect(calculateGrade(formula, [])).toBe(0);
});
it("求和结果四舍五入到两位小数", () => {
const formula = makeFormula({ formulaType: "sum" });
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80.123, totalScore: 100 },
{ sourceId: "b", score: 90.456, totalScore: 100 },
];
// 80.123 + 90.456 = 170.579 → 170.58
expect(calculateGrade(formula, entries)).toBe(170.58);
});
});
describe("calculateGrade - custom自定义P3 不支持)", () => {
it("custom 类型抛出错误", () => {
const formula = makeFormula({
formulaType: "custom",
customExpression: "a + b",
});
const entries: ScoreEntry[] = [
{ sourceId: "a", score: 80, totalScore: 100 },
];
expect(() => calculateGrade(formula, entries)).toThrow(
/Custom grade formula is not supported/,
);
});
});
});

View File

@@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import { inArray } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { exams } from "../exams/exams.schema.js";
import { homework } from "../homework/homework.schema.js";
import { gradesRepository } from "./grades.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
@@ -27,6 +30,27 @@ export interface UpdateGradeInput {
feedback?: string;
}
export interface ReportCardEntry {
subjectId: string;
subjectName: string;
examScore: string;
examTotal: string;
homeworkScore: string;
homeworkTotal: string;
finalScore: string;
gradeLevel: string;
teacherComment: string;
}
export interface ReportCard {
studentId: string;
termId: string;
entries: ReportCardEntry[];
overallGrade: string;
classRank: string;
createdAt: string;
}
@Injectable()
export class GradesService {
async recordGrade(
@@ -163,4 +187,148 @@ export class GradesService {
);
});
}
// --------------------------------------------------------------------------
// P3.13 新增:成绩单生成(按学科聚合考试+作业成绩)
// --------------------------------------------------------------------------
async getReportCard(studentId: string, termId?: string): Promise<ReportCard> {
if (!studentId) {
throw new ValidationError("studentId is required");
}
const studentGrades = await gradesRepository.findByStudentId(studentId);
if (studentGrades.length === 0) {
return {
studentId,
termId: termId ?? "",
entries: [],
overallGrade: "",
classRank: "",
createdAt: new Date().toISOString(),
};
}
// 收集所有 exam_id 和 homework_id用于反查 subject_id
const examIds = studentGrades
.map((g) => g.examId)
.filter((id): id is string => Boolean(id));
const homeworkIds = studentGrades
.map((g) => g.homeworkId)
.filter((id): id is string => Boolean(id));
// 查询考试和作业以获取 subject_id
const examRows =
examIds.length > 0
? await db
.select({ id: exams.id, subjectId: exams.subjectId })
.from(exams)
.where(inArray(exams.id, examIds))
: [];
const homeworkRows =
homeworkIds.length > 0
? await db
.select({ id: homework.id, subjectId: homework.subjectId })
.from(homework)
.where(inArray(homework.id, homeworkIds))
: [];
const examSubjectMap = new Map(examRows.map((e) => [e.id, e.subjectId]));
const homeworkSubjectMap = new Map(
homeworkRows.map((h) => [h.id, h.subjectId]),
);
// 按学科聚合:每科累计 exam 分数、homework 分数
const subjectAgg = new Map<
string,
{
examScore: number;
examTotal: number;
homeworkScore: number;
homeworkTotal: number;
}
>();
for (const g of studentGrades) {
let subjectId = "";
if (g.examId) {
subjectId = examSubjectMap.get(g.examId) ?? "";
} else if (g.homeworkId) {
subjectId = homeworkSubjectMap.get(g.homeworkId) ?? "";
}
if (!subjectId) continue;
const agg = subjectAgg.get(subjectId) ?? {
examScore: 0,
examTotal: 0,
homeworkScore: 0,
homeworkTotal: 0,
};
const score = Number(g.score);
const total = Number(g.totalScore);
if (g.examId) {
agg.examScore += score;
agg.examTotal += total;
} else if (g.homeworkId) {
agg.homeworkScore += score;
agg.homeworkTotal += total;
}
subjectAgg.set(subjectId, agg);
}
// 构造 entries + 计算 grade_level
const entries: ReportCardEntry[] = [];
let overallPercentageSum = 0;
let subjectCount = 0;
for (const [subjectId, agg] of subjectAgg) {
const examScoreStr = agg.examScore.toFixed(2);
const examTotalStr = agg.examTotal.toFixed(2);
const homeworkScoreStr = agg.homeworkScore.toFixed(2);
const homeworkTotalStr = agg.homeworkTotal.toFixed(2);
const totalEarned = agg.examScore + agg.homeworkScore;
const totalPossible = agg.examTotal + agg.homeworkTotal;
const percentage =
totalPossible > 0 ? (totalEarned / totalPossible) * 100 : 0;
const finalScoreStr = percentage.toFixed(2);
const gradeLevel = this.calcGradeLevel(percentage);
overallPercentageSum += percentage;
subjectCount += 1;
entries.push({
subjectId,
subjectName: "", // subject_name 由 content 服务提供,此处留空
examScore: examScoreStr,
examTotal: examTotalStr,
homeworkScore: homeworkScoreStr,
homeworkTotal: homeworkTotalStr,
finalScore: finalScoreStr,
gradeLevel,
teacherComment: "",
});
}
const overallPercentage =
subjectCount > 0 ? overallPercentageSum / subjectCount : 0;
const overallGrade = this.calcGradeLevel(overallPercentage);
return {
studentId,
termId: termId ?? "",
entries,
overallGrade,
classRank: "", // class_rank 需要同级学生比较,当前为 stub
createdAt: new Date().toISOString(),
};
}
private calcGradeLevel(percentage: number): string {
if (percentage >= 90) return "A";
if (percentage >= 80) return "B";
if (percentage >= 70) return "C";
if (percentage >= 60) return "D";
return "F";
}
}

View File

@@ -0,0 +1,973 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import type { INestApplicationContext } from "@nestjs/common";
import { logger } from "../shared/observability/logger.js";
import { env } from "../config/env.js";
import { ExamsService } from "../exams/exams.service.js";
import { HomeworkService } from "../homework/homework.service.js";
import { GradesService } from "../grades/grades.service.js";
import { ClassesService } from "../classes/classes.service.js";
import { AttendanceService } from "../attendance/attendance.service.js";
import { SchedulingService } from "../scheduling/scheduling.service.js";
import { LeaveRequestsService } from "../leave-requests/leave-requests.service.js";
import { DashboardService } from "../dashboard/dashboard.service.js";
import { AdminService } from "../admin/admin.service.js";
import {
ApplicationError,
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Exam } from "../exams/exams.schema.js";
import type { Homework } from "../homework/homework.schema.js";
import type { Grade } from "../grades/grades.schema.js";
import type { Class } from "../classes/classes.schema.js";
import type { Attendance } from "../attendance/attendance.schema.js";
// proto 包名(与 core_edu.proto 中 package 声明一致)
const PROTO_PACKAGE = "next_edu_cloud.core_edu.v1";
// protoLoader 加载选项keepCase 保留 snake_case 字段名
const PROTO_OPTIONS: protoLoader.Options = {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
};
let grpcServer: grpc.Server | null = null;
// ----------------------------------------------------------------------------
// proto 文件路径解析
// ----------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 解析 core_edu.proto 文件路径。
* 按优先级尝试多个候选路径,兼容本地开发与 Docker 运行时:
* 1. 相对于源文件位置devsrc/grpcproddist/grpc目录层级一致
* 2. 相对于 cwd 的 packages/shared-proto/protocwd = repo 根)
* 3. Docker 中 COPY 到 /app/proto 的副本
*/
function resolveProtoPath(): string {
const candidates = [
path.resolve(
__dirname,
"../../../../packages/shared-proto/proto/core_edu.proto",
),
path.resolve(process.cwd(), "packages/shared-proto/proto/core_edu.proto"),
path.resolve(process.cwd(), "proto/core_edu.proto"),
"/app/proto/core_edu.proto",
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
// 全部缺失时回退到首选路径,让 proto-loader 抛出明确的文件不存在错误
const fallback = candidates[0];
return fallback ?? candidates[1] ?? "";
}
/**
* 沿 next_edu_cloud.core_edu.v1 导航 proto 包定义,返回目标包 GrpcObject。
* proto-loader 的 GrpcObject 索引签名包含 ProtobufTypeDefinition
* 包层级在运行时一定是 GrpcObject经 unknown 取出以避开联合类型。
*/
function getCoreEduPackage(protoDescriptor: grpc.GrpcObject): grpc.GrpcObject {
const root = protoDescriptor as unknown as Record<string, unknown>;
const segment = root["next_edu_cloud"];
const coreEdu = (segment as Record<string, unknown> | undefined)?.[
"core_edu"
];
const v1 = (coreEdu as Record<string, unknown> | undefined)?.["v1"];
if (!v1 || typeof v1 !== "object") {
throw new Error(`gRPC package "${PROTO_PACKAGE}" not found in proto`);
}
return v1 as unknown as grpc.GrpcObject;
}
/**
* 从 proto 包中取出服务的 ServiceDefinition。
* proto-loader 生成的服务构造器上有静态 .service 属性(方法定义表),
* 该属性不在 grpc.Client 基类类型声明中,需要经 unknown 断言取出。
*/
function getServiceDefinition(
pkg: grpc.GrpcObject,
serviceName: string,
): grpc.ServiceDefinition<grpc.UntypedServiceImplementation> {
const service = pkg[serviceName];
if (service === undefined || typeof service !== "function") {
throw new Error(`gRPC service "${serviceName}" not found in proto package`);
}
const ctor = service as unknown as {
service: grpc.ServiceDefinition<grpc.UntypedServiceImplementation>;
};
if (!ctor.service) {
throw new Error(
`Service definition missing on "${serviceName}" constructor`,
);
}
return ctor.service;
}
// ----------------------------------------------------------------------------
// 错误处理:将 ApplicationError 映射为 gRPC status code
// ----------------------------------------------------------------------------
/**
* gRPC 服务端错误,实现 grpc.ServiceError 接口以便 callback 直接使用。
*/
class GrpcServiceError extends Error implements grpc.ServiceError {
readonly code: grpc.status;
readonly details: string;
readonly metadata: grpc.Metadata;
constructor(code: grpc.status, message: string) {
super(message);
this.name = "GrpcServiceError";
this.code = code;
this.details = message;
this.metadata = new grpc.Metadata();
}
}
function toGrpcError(err: unknown): grpc.ServiceError {
if (err instanceof NotFoundError) {
return new GrpcServiceError(grpc.status.NOT_FOUND, err.message);
}
if (err instanceof ValidationError) {
return new GrpcServiceError(grpc.status.INVALID_ARGUMENT, err.message);
}
if (err instanceof ConflictError) {
return new GrpcServiceError(grpc.status.FAILED_PRECONDITION, err.message);
}
if (err instanceof ApplicationError) {
// 按业务错误状态码补充映射
if (err.statusCode === 401) {
return new GrpcServiceError(grpc.status.UNAUTHENTICATED, err.message);
}
if (err.statusCode === 403) {
return new GrpcServiceError(grpc.status.PERMISSION_DENIED, err.message);
}
return new GrpcServiceError(grpc.status.INTERNAL, err.message);
}
const message = err instanceof Error ? err.message : "Internal server error";
return new GrpcServiceError(grpc.status.INTERNAL, message);
}
// ----------------------------------------------------------------------------
// handler 包装:把 async (req) => Promise<resp> 转为 grpc handleUnaryCall
// ----------------------------------------------------------------------------
type ProtoRequest = Record<string, unknown>;
type ProtoResponse = Record<string, unknown>;
function wrapHandler(
handler: (req: ProtoRequest) => Promise<ProtoResponse>,
): grpc.handleUnaryCall<unknown, unknown> {
return (call, callback) => {
// call.request 类型为 unknownaddService 的实现签名),此处从 unknown 转换为记录
const request = call.request as ProtoRequest;
Promise.resolve()
.then(() => handler(request))
.then((result) => callback(null, result))
.catch((err: unknown) => callback(toGrpcError(err)));
};
}
// ----------------------------------------------------------------------------
// 字段转换工具Date -> ISO stringnull -> ""
// ----------------------------------------------------------------------------
function toIso(value: Date | string | null | undefined): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
return value.toISOString();
}
function str(value: string | null | undefined): string {
return value ?? "";
}
// ----------------------------------------------------------------------------
// 实体 -> proto message 映射camelCase -> snake_case + ISO 日期)
// ----------------------------------------------------------------------------
function toExamProto(exam: Exam): ProtoResponse {
return {
id: exam.id,
class_id: exam.classId,
subject_id: exam.subjectId,
title: exam.title,
description: str(exam.description),
exam_date: toIso(exam.examDate),
duration: exam.duration,
total_score: exam.totalScore,
status: exam.status,
status_changed_at: toIso(exam.statusChangedAt),
status_changed_by: str(exam.statusChangedBy),
school_id: exam.schoolId,
created_by: exam.createdBy,
archived_at: toIso(exam.archivedAt),
created_at: toIso(exam.createdAt),
updated_at: toIso(exam.updatedAt),
};
}
function toHomeworkProto(hw: Homework): ProtoResponse {
return {
id: hw.id,
class_id: hw.classId,
subject_id: hw.subjectId,
title: hw.title,
description: str(hw.description),
due_date: toIso(hw.dueDate),
grace_period: hw.gracePeriod,
status: hw.status,
school_id: hw.schoolId,
created_by: hw.createdBy,
created_at: toIso(hw.createdAt),
updated_at: toIso(hw.updatedAt),
};
}
function toGradeProto(grade: Grade): ProtoResponse {
return {
id: grade.id,
student_id: grade.studentId,
exam_id: str(grade.examId),
homework_id: str(grade.homeworkId),
score: grade.score,
total_score: grade.totalScore,
feedback: str(grade.feedback),
graded_by: grade.gradedBy,
school_id: grade.schoolId,
idempotency_key: str(grade.idempotencyKey),
created_at: toIso(grade.createdAt),
updated_at: toIso(grade.updatedAt),
};
}
function toClassProto(cls: Class): ProtoResponse {
return {
id: cls.id,
name: cls.name,
grade_id: cls.gradeId,
head_teacher_id: str(cls.headTeacherId),
description: str(cls.description),
created_at: toIso(cls.createdAt),
updated_at: toIso(cls.updatedAt),
};
}
function toAttendanceProto(att: Attendance): ProtoResponse {
return {
id: att.id,
schedule_id: att.scheduleId,
student_id: att.studentId,
status: att.status,
remark: str(att.remark),
recorded_by: att.recordedBy,
school_id: att.schoolId,
created_at: toIso(att.createdAt),
updated_at: toIso(att.updatedAt),
};
}
// ----------------------------------------------------------------------------
// proto 请求字段读取工具(从 snake_case 请求中安全取值)
// ----------------------------------------------------------------------------
function reqStr(req: ProtoRequest, key: string): string {
const v = req[key];
return typeof v === "string" ? v : "";
}
function reqNum(req: ProtoRequest, key: string): number {
const v = req[key];
return typeof v === "number" ? v : 0;
}
function reqStrArr(req: ProtoRequest, key: string): string[] {
const v = req[key];
if (!Array.isArray(v)) {
return [];
}
// Array.isArray 将 unknown 收窄为 any[],先经 unknown[] 再用类型守卫过滤
const arr = v as unknown[];
return arr.filter((x): x is string => typeof x === "string");
}
function reqObjArr(req: ProtoRequest, key: string): ProtoRequest[] {
const v = req[key];
if (!Array.isArray(v)) {
return [];
}
const arr = v as unknown[];
return arr.map((item) =>
item !== null && typeof item === "object" ? (item as ProtoRequest) : {},
);
}
// ----------------------------------------------------------------------------
// ExamService handlers8 RPC
// ----------------------------------------------------------------------------
function buildExamHandlers(
service: ExamsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
CreateExam: wrapHandler(async (req) => {
const result = await service.createExam({
classId: reqStr(req, "class_id"),
subjectId: reqStr(req, "subject_id"),
title: reqStr(req, "title"),
description: reqStr(req, "description") || undefined,
examDate: reqStr(req, "exam_date"),
duration: reqNum(req, "duration"),
totalScore: reqStr(req, "total_score"),
schoolId: reqStr(req, "school_id"),
createdBy: reqStr(req, "created_by"),
});
return { id: result.id };
}),
GetExam: wrapHandler(async (req) => {
const exam = await service.getExam(reqStr(req, "id"));
return toExamProto(exam);
}),
ListExamsByClass: wrapHandler(async (req) => {
const exams = await service.listExamsByClass(reqStr(req, "class_id"));
return { exams: exams.map(toExamProto) };
}),
UpdateExam: wrapHandler(async (req) => {
await service.updateExam(reqStr(req, "id"), {
title: reqStr(req, "title") || undefined,
description: reqStr(req, "description") || undefined,
examDate: reqStr(req, "exam_date")
? new Date(reqStr(req, "exam_date"))
: undefined,
duration: reqNum(req, "duration") || undefined,
totalScore: reqStr(req, "total_score") || undefined,
});
return { success: true };
}),
DeleteExam: wrapHandler(async (req) => {
await service.deleteExam(reqStr(req, "id"));
return { success: true };
}),
PublishExam: wrapHandler(async (req) => {
await service.publishExam(reqStr(req, "id"), reqStr(req, "published_by"));
return { success: true };
}),
SubmitExam: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.submitExam(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
answers,
);
return { submission_id: result.submissionId };
}),
GradeExam: wrapHandler(async (req) => {
const scores = reqObjArr(req, "scores").map((s) => ({
questionId: reqStr(s, "question_id"),
score: reqStr(s, "score"),
teacherComment: reqStr(s, "teacher_comment") || undefined,
}));
const result = await service.gradeExam(
reqStr(req, "exam_id"),
reqStr(req, "submission_id"),
scores,
reqStr(req, "graded_by"),
);
return { success: true, total_score: result.totalScore };
}),
// P3.13 新增
SaveExamDraft: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.saveExamDraft(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
answers,
);
return { draft_id: result.draftId };
}),
RecordExamViolation: wrapHandler(async (req) => {
const result = await service.recordExamViolation(
reqStr(req, "exam_id"),
reqStr(req, "student_id"),
reqStr(req, "violation_type"),
reqStr(req, "detail"),
reqNum(req, "severity"),
);
return { violation_id: result.violationId };
}),
};
}
// ----------------------------------------------------------------------------
// HomeworkService handlers5 RPC
// ----------------------------------------------------------------------------
function buildHomeworkHandlers(
service: HomeworkService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
AssignHomework: wrapHandler(async (req) => {
const result = await service.assignHomework({
classId: reqStr(req, "class_id"),
subjectId: reqStr(req, "subject_id"),
title: reqStr(req, "title"),
description: reqStr(req, "description") || undefined,
dueDate: reqStr(req, "due_date"),
gracePeriod: reqNum(req, "grace_period"),
schoolId: reqStr(req, "school_id"),
createdBy: reqStr(req, "created_by"),
});
return { id: result.id };
}),
GetHomework: wrapHandler(async (req) => {
const hw = await service.getHomework(reqStr(req, "id"));
return toHomeworkProto(hw);
}),
ListHomeworkByClass: wrapHandler(async (req) => {
const list = await service.listByClass(reqStr(req, "class_id"));
return { homework: list.map(toHomeworkProto) };
}),
SubmitHomework: wrapHandler(async (req) => {
const answers = reqObjArr(req, "answers").map((a) => ({
questionId: reqStr(a, "question_id"),
answer: reqStr(a, "answer"),
}));
const result = await service.submitHomework(
reqStr(req, "homework_id"),
reqStr(req, "student_id"),
answers,
);
return { submission_id: result.submissionId };
}),
GradeHomework: wrapHandler(async (req) => {
const scores = reqObjArr(req, "scores").map((s) => ({
questionId: reqStr(s, "question_id"),
score: reqStr(s, "score"),
teacherComment: reqStr(s, "teacher_comment") || undefined,
}));
const result = await service.gradeHomework(
reqStr(req, "homework_id"),
reqStr(req, "submission_id"),
scores,
reqStr(req, "feedback") || undefined,
reqStr(req, "graded_by"),
);
return { success: true, total_score: result.totalScore };
}),
};
}
// ----------------------------------------------------------------------------
// GradeService handlers6 RPC
// ----------------------------------------------------------------------------
function buildGradeHandlers(
service: GradesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
RecordGrade: wrapHandler(async (req) => {
const result = await service.recordGrade({
studentId: reqStr(req, "student_id"),
examId: reqStr(req, "exam_id") || undefined,
homeworkId: reqStr(req, "homework_id") || undefined,
score: reqStr(req, "score"),
totalScore: reqStr(req, "total_score"),
feedback: reqStr(req, "feedback") || undefined,
gradedBy: reqStr(req, "graded_by"),
schoolId: reqStr(req, "school_id"),
idempotencyKey: reqStr(req, "idempotency_key") || undefined,
});
return { id: result.id };
}),
GetGrade: wrapHandler(async (req) => {
const grade = await service.getGrade(reqStr(req, "id"));
return toGradeProto(grade);
}),
ListGradesByStudent: wrapHandler(async (req) => {
const grades = await service.listByStudent(reqStr(req, "student_id"));
return { grades: grades.map(toGradeProto) };
}),
ListGradesByExam: wrapHandler(async (req) => {
const grades = await service.listByExam(reqStr(req, "exam_id"));
return { grades: grades.map(toGradeProto) };
}),
ListGradesByHomework: wrapHandler(async (req) => {
const grades = await service.listByHomework(reqStr(req, "homework_id"));
return { grades: grades.map(toGradeProto) };
}),
UpdateGrade: wrapHandler(async (req) => {
await service.updateGrade(
reqStr(req, "id"),
{
score: reqStr(req, "score") || undefined,
feedback: reqStr(req, "feedback") || undefined,
},
reqStr(req, "updated_by"),
);
return { success: true };
}),
// P3.13 新增
GetReportCard: wrapHandler(async (req) => {
const reportCard = await service.getReportCard(
reqStr(req, "student_id"),
reqStr(req, "term_id") || undefined,
);
return {
student_id: reportCard.studentId,
term_id: reportCard.termId,
entries: reportCard.entries.map((e) => ({
subject_id: e.subjectId,
subject_name: e.subjectName,
exam_score: e.examScore,
exam_total: e.examTotal,
homework_score: e.homeworkScore,
homework_total: e.homeworkTotal,
final_score: e.finalScore,
grade_level: e.gradeLevel,
teacher_comment: e.teacherComment,
})),
overall_grade: reportCard.overallGrade,
class_rank: reportCard.classRank,
created_at: reportCard.createdAt,
};
}),
};
}
// ----------------------------------------------------------------------------
// ClassService handlers4 RPC
// ----------------------------------------------------------------------------
function buildClassHandlers(
service: ClassesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetClass: wrapHandler(async (req) => {
const cls = await service.getClass(reqStr(req, "id"));
return toClassProto(cls);
}),
GetClassesByTeacher: wrapHandler(async (req) => {
const list = await service.getClassesByTeacher(reqStr(req, "teacher_id"));
return { classes: list.map(toClassProto) };
}),
BatchGetClasses: wrapHandler(async (req) => {
const list = await service.batchGetClasses(reqStrArr(req, "ids"));
return { classes: list.map(toClassProto) };
}),
ListStudentsByClass: wrapHandler(async (req) => {
const students = await service.listStudentsByClass(
reqStr(req, "class_id"),
);
return {
students: students.map((s) => ({
id: s.id,
name: s.name,
class_id: s.classId,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// AttendanceService handlers4 RPC
// ----------------------------------------------------------------------------
function buildAttendanceHandlers(
service: AttendanceService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
RecordAttendance: wrapHandler(async (req) => {
const result = await service.recordAttendance({
scheduleId: reqStr(req, "schedule_id"),
studentId: reqStr(req, "student_id"),
status: reqStr(req, "status"),
remark: reqStr(req, "remark") || undefined,
recordedBy: reqStr(req, "recorded_by"),
schoolId: reqStr(req, "school_id"),
});
return { id: result.id };
}),
GetAttendance: wrapHandler(async (req) => {
const att = await service.getAttendance(reqStr(req, "id"));
return toAttendanceProto(att);
}),
ListAttendanceByStudent: wrapHandler(async (req) => {
const list = await service.listByStudent(reqStr(req, "student_id"));
return { attendance: list.map(toAttendanceProto) };
}),
ListAttendanceByClass: wrapHandler(async (req) => {
const list = await service.listByClass(reqStr(req, "class_id"));
return { attendance: list.map(toAttendanceProto) };
}),
};
}
// ----------------------------------------------------------------------------
// ScheduleService handlersP3.13 新增1 RPC
// ----------------------------------------------------------------------------
function buildScheduleHandlers(
service: SchedulingService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetScheduleByStudent: wrapHandler(async (req) => {
const slots = await service.getScheduleByStudent(
reqStr(req, "student_id"),
reqStr(req, "week_start") || undefined,
);
return {
slots: slots.map((s) => ({
id: s.id,
course_id: s.courseId,
course_name: s.courseName,
teacher_id: s.teacherId,
class_id: s.classId,
room_id: s.roomId,
start_time: s.startTime,
end_time: s.endTime,
subject_id: s.subjectId,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// LeaveRequestService handlersP3.13 新增3 RPC
// ----------------------------------------------------------------------------
function buildLeaveRequestHandlers(
service: LeaveRequestsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
ListLeaveRequestsByStudent: wrapHandler(async (req) => {
const list = await service.listByStudent(
reqStr(req, "student_id"),
reqStr(req, "status") || undefined,
);
return {
leave_requests: list.map((lr) => ({
id: lr.id,
student_id: lr.studentId,
class_id: lr.classId,
leave_type: lr.leaveType,
start_date:
lr.startDate instanceof Date
? lr.startDate.toISOString().slice(0, 10)
: lr.startDate,
end_date:
lr.endDate instanceof Date
? lr.endDate.toISOString().slice(0, 10)
: lr.endDate,
reason: lr.reason,
status: lr.status,
submitted_by: lr.submittedBy,
reviewed_by: str(lr.reviewedBy),
review_comment: str(lr.reviewComment),
school_id: lr.schoolId,
created_at: toIso(lr.createdAt),
updated_at: toIso(lr.updatedAt),
})),
};
}),
CreateLeaveRequest: wrapHandler(async (req) => {
const result = await service.create({
studentId: reqStr(req, "student_id"),
classId: reqStr(req, "class_id"),
leaveType: reqStr(req, "leave_type"),
startDate: reqStr(req, "start_date"),
endDate: reqStr(req, "end_date"),
reason: reqStr(req, "reason"),
submittedBy: reqStr(req, "submitted_by"),
schoolId: reqStr(req, "school_id"),
});
return { id: result.id };
}),
CancelLeaveRequest: wrapHandler(async (req) => {
await service.cancel(reqStr(req, "id"), reqStr(req, "cancelled_by"));
return { success: true };
}),
};
}
// ----------------------------------------------------------------------------
// DashboardService handlersP3.13 新增2 RPC
// ----------------------------------------------------------------------------
function buildDashboardHandlers(
service: DashboardService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
GetDashboard: wrapHandler(async (req) => {
const data = await service.getDashboard(reqStr(req, "teacher_id"));
return {
teacher_id: data.teacherId,
total_classes: data.totalClasses,
total_students: data.totalStudents,
pending_homework: data.pendingHomework,
upcoming_exams: data.upcomingExams,
ungraded_submissions: data.ungradedSubmissions,
classes: data.classes.map((c) => ({
class_id: c.classId,
class_name: c.className,
student_count: c.studentCount,
})),
upcoming_exam_list: data.upcomingExamList.map((e) => ({
exam_id: e.examId,
title: e.title,
exam_date: e.examDate,
class_id: e.classId,
class_name: e.className,
})),
generated_at: data.generatedAt,
};
}),
GetClassPerformance: wrapHandler(async (req) => {
const perf = await service.getClassPerformance(
reqStr(req, "class_id"),
reqStr(req, "subject_id") || undefined,
);
return {
class_id: perf.classId,
class_name: perf.className,
student_count: perf.studentCount,
average_score: perf.averageScore,
highest_score: perf.highestScore,
lowest_score: perf.lowestScore,
median_score: perf.medianScore,
subjects: perf.subjects.map((s) => ({
subject_id: s.subjectId,
subject_name: s.subjectName,
average_score: s.averageScore,
student_count: s.studentCount,
})),
generated_at: perf.generatedAt,
};
}),
};
}
// ----------------------------------------------------------------------------
// AdminService handlersP3.13 新增4 RPC - aggregation stubs
// ----------------------------------------------------------------------------
function buildAdminHandlers(
service: AdminService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
return {
ListSchools: wrapHandler(async () => {
const schools = await service.listSchools();
return {
schools: schools.map((s) => ({
id: s.id,
name: s.name,
address: s.address,
principal_id: s.principalId,
created_at: s.createdAt,
})),
};
}),
ListGradeLevels: wrapHandler(async (req) => {
const levels = await service.listGradeLevels(reqStr(req, "school_id"));
return {
grade_levels: levels.map((l) => ({
id: l.id,
name: l.name,
school_id: l.schoolId,
order: l.order,
})),
};
}),
ListDepartments: wrapHandler(async (req) => {
const depts = await service.listDepartments(reqStr(req, "school_id"));
return {
departments: depts.map((d) => ({
id: d.id,
name: d.name,
school_id: d.schoolId,
head_id: d.headId,
created_at: d.createdAt,
})),
};
}),
ListAcademicYears: wrapHandler(async (req) => {
const years = await service.listAcademicYears(
reqStr(req, "school_id") || undefined,
);
return {
academic_years: years.map((y) => ({
id: y.id,
name: y.name,
school_id: y.schoolId,
start_date: y.startDate,
end_date: y.endDate,
is_current: y.isCurrent,
})),
};
}),
};
}
// ----------------------------------------------------------------------------
// 启动 / 停止
// ----------------------------------------------------------------------------
/**
* 启动 gRPC server监听 env.GRPC_PORT默认 50053
* 通过 NestJS application context 获取各 Service 实例并注册 handler。
*/
export async function startGrpcServer(
app: INestApplicationContext,
): Promise<void> {
const protoPath = resolveProtoPath();
const packageDefinition = protoLoader.loadSync(protoPath, PROTO_OPTIONS);
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
const pkg = getCoreEduPackage(protoDescriptor);
// 从 NestJS 容器获取 service 实例
const examsService = app.get(ExamsService);
const homeworkService = app.get(HomeworkService);
const gradesService = app.get(GradesService);
const classesService = app.get(ClassesService);
const attendanceService = app.get(AttendanceService);
const schedulingService = app.get(SchedulingService);
const leaveRequestsService = app.get(LeaveRequestsService);
const dashboardService = app.get(DashboardService);
const adminService = app.get(AdminService);
const server = new grpc.Server();
server.addService(
getServiceDefinition(pkg, "ExamService"),
buildExamHandlers(examsService),
);
server.addService(
getServiceDefinition(pkg, "HomeworkService"),
buildHomeworkHandlers(homeworkService),
);
server.addService(
getServiceDefinition(pkg, "GradeService"),
buildGradeHandlers(gradesService),
);
server.addService(
getServiceDefinition(pkg, "ClassService"),
buildClassHandlers(classesService),
);
server.addService(
getServiceDefinition(pkg, "AttendanceService"),
buildAttendanceHandlers(attendanceService),
);
// P3.13 新增 4 个服务
server.addService(
getServiceDefinition(pkg, "ScheduleService"),
buildScheduleHandlers(schedulingService),
);
server.addService(
getServiceDefinition(pkg, "LeaveRequestService"),
buildLeaveRequestHandlers(leaveRequestsService),
);
server.addService(
getServiceDefinition(pkg, "DashboardService"),
buildDashboardHandlers(dashboardService),
);
server.addService(
getServiceDefinition(pkg, "AdminService"),
buildAdminHandlers(adminService),
);
const address = `0.0.0.0:${env.GRPC_PORT}`;
await new Promise<void>((resolve, reject) => {
server.bindAsync(
address,
grpc.ServerCredentials.createInsecure(),
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
},
);
});
grpcServer = server;
logger.info(
{ port: env.GRPC_PORT, protoPath, service: "core-edu" },
"gRPC server is listening",
);
}
/**
* 优雅停止 gRPC server。
*/
export async function stopGrpcServer(): Promise<void> {
const server = grpcServer;
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
grpcServer = null;
logger.info({ service: "core-edu" }, "gRPC server stopped");
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import {
canTransition,
transition,
isTerminal,
canTransitionSubmission,
transitionSubmission,
HOMEWORK_STATUSES,
SUBMISSION_STATUSES,
} from "./homework-state-machine.js";
describe("homework-state-machine", () => {
describe("HomeworkStatus 状态机", () => {
describe("合法状态转换", () => {
it("assigned --submit--> submitted", () => {
expect(transition("assigned", "submit")).toBe("submitted");
});
it("submitted --grade--> graded", () => {
expect(transition("submitted", "grade")).toBe("graded");
});
});
describe("非法状态转换", () => {
it("assigned --grade--> 抛错(必须先 submit", () => {
expect(() => transition("assigned", "grade")).toThrow();
});
it("submitted --submit--> 抛错(不能重复提交)", () => {
expect(() => transition("submitted", "submit")).toThrow();
});
it("graded --submit--> 抛错(终态)", () => {
expect(() => transition("graded", "submit")).toThrow();
});
it("graded --grade--> 抛错(终态)", () => {
expect(() => transition("graded", "grade")).toThrow();
});
it("抛错信息包含非法转换描述", () => {
expect(() => transition("assigned", "grade")).toThrow(
/Invalid homework state transition/,
);
});
});
describe("canTransition", () => {
it("合法转换返回 true", () => {
expect(canTransition("assigned", "submit")).toBe(true);
expect(canTransition("submitted", "grade")).toBe(true);
});
it("非法转换返回 false", () => {
expect(canTransition("assigned", "grade")).toBe(false);
expect(canTransition("submitted", "submit")).toBe(false);
expect(canTransition("graded", "submit")).toBe(false);
expect(canTransition("graded", "grade")).toBe(false);
});
});
describe("isTerminal", () => {
it("graded 是终态", () => {
expect(isTerminal("graded")).toBe(true);
});
it("assigned/submitted 不是终态", () => {
expect(isTerminal("assigned")).toBe(false);
expect(isTerminal("submitted")).toBe(false);
});
});
});
describe("SubmissionStatus 状态机", () => {
describe("合法状态转换", () => {
it("not_submitted --submit--> submitted", () => {
expect(transitionSubmission("not_submitted", "submit")).toBe(
"submitted",
);
});
it("submitted --grade--> graded", () => {
expect(transitionSubmission("submitted", "grade")).toBe("graded");
});
});
describe("非法状态转换", () => {
it("not_submitted --grade--> 抛错(必须先 submit", () => {
expect(() => transitionSubmission("not_submitted", "grade")).toThrow();
});
it("submitted --submit--> 抛错(不能重复提交)", () => {
expect(() => transitionSubmission("submitted", "submit")).toThrow();
});
it("graded --submit--> 抛错(终态)", () => {
expect(() => transitionSubmission("graded", "submit")).toThrow();
});
it("graded --grade--> 抛错(终态)", () => {
expect(() => transitionSubmission("graded", "grade")).toThrow();
});
it("抛错信息包含非法转换描述", () => {
expect(() => transitionSubmission("not_submitted", "grade")).toThrow(
/Invalid submission state transition/,
);
});
});
describe("canTransitionSubmission", () => {
it("合法转换返回 true", () => {
expect(canTransitionSubmission("not_submitted", "submit")).toBe(true);
expect(canTransitionSubmission("submitted", "grade")).toBe(true);
});
it("非法转换返回 false", () => {
expect(canTransitionSubmission("not_submitted", "grade")).toBe(false);
expect(canTransitionSubmission("submitted", "submit")).toBe(false);
expect(canTransitionSubmission("graded", "submit")).toBe(false);
expect(canTransitionSubmission("graded", "grade")).toBe(false);
});
});
});
describe("状态常量", () => {
it("HOMEWORK_STATUSES 包含 3 种状态", () => {
expect(HOMEWORK_STATUSES).toHaveLength(3);
expect([...HOMEWORK_STATUSES]).toContain("assigned");
expect([...HOMEWORK_STATUSES]).toContain("submitted");
expect([...HOMEWORK_STATUSES]).toContain("graded");
});
it("SUBMISSION_STATUSES 包含 3 种状态", () => {
expect(SUBMISSION_STATUSES).toHaveLength(3);
expect([...SUBMISSION_STATUSES]).toContain("not_submitted");
expect([...SUBMISSION_STATUSES]).toContain("submitted");
expect([...SUBMISSION_STATUSES]).toContain("graded");
});
});
});

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { LeaveRequestsService } from "./leave-requests.service.js";
@Module({
providers: [LeaveRequestsService],
exports: [LeaveRequestsService],
})
export class LeaveRequestsModule {}

View File

@@ -0,0 +1,54 @@
import { eq, and, desc } from "drizzle-orm/expressions";
import { db } from "../config/database.js";
import { leaveRequests } from "./leave-requests.schema.js";
import type { LeaveRequest, NewLeaveRequest } from "./leave-requests.schema.js";
export const leaveRequestRepository = {
async create(record: NewLeaveRequest): Promise<void> {
await db.insert(leaveRequests).values(record);
},
async findById(id: string): Promise<LeaveRequest | undefined> {
const rows = await db
.select()
.from(leaveRequests)
.where(eq(leaveRequests.id, id))
.limit(1);
return rows[0];
},
async findByStudentId(
studentId: string,
status?: string,
): Promise<LeaveRequest[]> {
if (status) {
return db
.select()
.from(leaveRequests)
.where(
and(
eq(leaveRequests.studentId, studentId),
eq(leaveRequests.status, status),
),
)
.orderBy(desc(leaveRequests.createdAt));
}
return db
.select()
.from(leaveRequests)
.where(eq(leaveRequests.studentId, studentId))
.orderBy(desc(leaveRequests.createdAt));
},
async updateStatus(
id: string,
status: string,
reviewedBy?: string,
reviewComment?: string,
): Promise<void> {
const updates: Partial<LeaveRequest> = { status };
if (reviewedBy !== undefined) updates.reviewedBy = reviewedBy;
if (reviewComment !== undefined) updates.reviewComment = reviewComment;
await db.update(leaveRequests).set(updates).where(eq(leaveRequests.id, id));
},
};

View File

@@ -0,0 +1,43 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
date,
index,
} from "drizzle-orm/mysql-core";
// 请假申请表P3.13 新增)
export const leaveRequests = mysqlTable(
"core_edu_leave_requests",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
studentId: char("student_id", { length: 36 }).notNull(),
classId: char("class_id", { length: 36 }).notNull(),
leaveType: varchar("leave_type", { length: 20 }).notNull(),
startDate: date("start_date").notNull(),
endDate: date("end_date").notNull(),
reason: text("reason").notNull(),
status: varchar("status", { length: 20 }).notNull().default("pending"),
submittedBy: char("submitted_by", { length: 36 }).notNull(),
reviewedBy: char("reviewed_by", { length: 36 }),
reviewComment: text("review_comment"),
schoolId: char("school_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxLeaveRequestsStudent: index("idx_leave_requests_student").on(
table.studentId,
),
idxLeaveRequestsClass: index("idx_leave_requests_class").on(table.classId),
idxLeaveRequestsStatus: index("idx_leave_requests_status").on(table.status),
idxLeaveRequestsSchool: index("idx_leave_requests_school").on(
table.schoolId,
),
}),
);
export type LeaveRequest = typeof leaveRequests.$inferSelect;
export type NewLeaveRequest = typeof leaveRequests.$inferInsert;

View File

@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { leaveRequestRepository } from "./leave-requests.repository.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { LeaveRequest, NewLeaveRequest } from "./leave-requests.schema.js";
export interface CreateLeaveRequestInput {
studentId: string;
classId: string;
leaveType: string;
startDate: string;
endDate: string;
reason: string;
submittedBy: string;
schoolId: string;
}
@Injectable()
export class LeaveRequestsService {
async listByStudent(
studentId: string,
status?: string,
): Promise<LeaveRequest[]> {
if (!studentId) {
throw new ValidationError("studentId is required");
}
return leaveRequestRepository.findByStudentId(studentId, status);
}
async create(input: CreateLeaveRequestInput): Promise<{ id: string }> {
if (
!input.studentId ||
!input.classId ||
!input.leaveType ||
!input.startDate ||
!input.endDate ||
!input.reason ||
!input.submittedBy ||
!input.schoolId
) {
throw new ValidationError(
"studentId, classId, leaveType, startDate, endDate, reason, submittedBy, schoolId are required",
);
}
const start = new Date(input.startDate);
const end = new Date(input.endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
throw new ValidationError(
"startDate and endDate must be valid ISO dates",
);
}
if (start > end) {
throw new ValidationError("startDate must not be after endDate");
}
const id = randomUUID();
const record: NewLeaveRequest = {
id,
studentId: input.studentId,
classId: input.classId,
leaveType: input.leaveType,
startDate: start,
endDate: end,
reason: input.reason,
status: "pending",
submittedBy: input.submittedBy,
schoolId: input.schoolId,
};
await leaveRequestRepository.create(record);
return { id };
}
async cancel(id: string, cancelledBy: string): Promise<void> {
if (!id || !cancelledBy) {
throw new ValidationError("id and cancelledBy are required");
}
const existing = await leaveRequestRepository.findById(id);
if (!existing) {
throw new NotFoundError(`LeaveRequest ${id} not found`);
}
// 状态机pending -> cancelledapproved/rejected/cancelled 不允许再取消
if (existing.status !== "pending") {
throw new ConflictError(
`Cannot cancel leave request in status ${existing.status} (only pending allows cancel)`,
);
}
await leaveRequestRepository.updateStatus(id, "cancelled", cancelledBy);
}
}

View File

@@ -8,6 +8,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js";
import { startGrpcServer, stopGrpcServer } from "./grpc/grpc.server.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> {
@@ -38,13 +39,18 @@ async function bootstrap(): Promise<void> {
await outboxPublisher.start();
await app.listen(env.PORT);
// 启动 gRPC server9 Service / 40 RPC
await startGrpcServer(app);
logger.info(
{ port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" },
"CoreEdu service is listening (HTTP; gRPC port reserved for P3)",
"CoreEdu service is listening (HTTP + gRPC)",
);
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "Shutting down gracefully...");
await stopGrpcServer();
await outboxPublisher.stop();
await disconnectRedis();
await disconnectKafka();

View File

@@ -0,0 +1,201 @@
import { describe, it, expect } from "vitest";
import { isTimeOverlap, detectConflict } from "./schedule-conflict.js";
import type { ScheduleSlot } from "./schedule-conflict.js";
// 构造时间辅助函数基于固定日期UTC
function at(hour: number, minute: number = 0): Date {
return new Date(
`2026-07-13T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00Z`,
);
}
// 构造排课槽位的工厂函数
function makeSlot(
overrides: Partial<ScheduleSlot> & { id: string },
): ScheduleSlot {
return {
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
...overrides,
};
}
describe("schedule-conflict", () => {
describe("isTimeOverlap", () => {
it("完全重叠返回 true", () => {
expect(isTimeOverlap(at(10), at(11), at(10), at(11))).toBe(true);
});
it("部分重叠返回 true", () => {
expect(isTimeOverlap(at(10), at(11), at(10, 30), at(11, 30))).toBe(true);
});
it("包含关系返回 true", () => {
expect(isTimeOverlap(at(9), at(12), at(10), at(11))).toBe(true);
});
it("不重叠返回 false", () => {
expect(isTimeOverlap(at(8), at(9), at(10), at(11))).toBe(false);
});
it("刚好接续endA == startB返回 false", () => {
expect(isTimeOverlap(at(10), at(11), at(11), at(12))).toBe(false);
});
it("刚好接续反向endB == startA返回 false", () => {
expect(isTimeOverlap(at(11), at(12), at(10), at(11))).toBe(false);
});
});
describe("detectConflict", () => {
it("无现有排课时不冲突", () => {
const newSlot = makeSlot({ id: "new1" });
expect(detectConflict(newSlot, [])).toEqual({
hasConflict: false,
conflictType: null,
});
});
it("同一教师同一时间段冲突conflictType 为 teacher", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c-other",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("teacher");
expect(result.conflictingSlot).toBe(existing);
});
it("同一班级同一时间段冲突conflictType 为 class", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t-other",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("class");
expect(result.conflictingSlot).toBe(existing);
});
it("不同教师不同班级不冲突(即使时间重叠)", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(false);
expect(result.conflictType).toBe(null);
});
it("不同教师不冲突", () => {
const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" });
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
});
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false);
});
it("不同班级不冲突", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(12),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t2",
classId: "c2",
startTime: at(10, 30),
endTime: at(11, 30),
});
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false);
});
it("边界情况:刚好接续不冲突", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c1",
startTime: at(11), // 接续,不重叠
endTime: at(12),
});
const result = detectConflict(newSlot, [existing]);
expect(result.hasConflict).toBe(false);
});
it("excludeId 排除自身(更新场景)", () => {
const existing = makeSlot({
id: "e1",
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
const newSlot = makeSlot({
id: "e1", // 同一 id更新场景
teacherId: "t1",
classId: "c1",
startTime: at(10),
endTime: at(11),
});
// 不排除时会冲突
expect(detectConflict(newSlot, [existing]).hasConflict).toBe(true);
// 排除自身后不冲突
expect(detectConflict(newSlot, [existing], "e1").hasConflict).toBe(false);
});
it("多个现有排课时跳过无冲突项并返回首个冲突", () => {
const nonConflicting = makeSlot({
id: "e1",
teacherId: "t-other",
classId: "c-other",
startTime: at(10, 30),
endTime: at(11, 30),
});
const conflicting = makeSlot({
id: "e2",
teacherId: "t1",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const newSlot = makeSlot({
id: "new1",
teacherId: "t1",
classId: "c1",
startTime: at(10, 30),
endTime: at(11, 30),
});
const result = detectConflict(newSlot, [nonConflicting, conflicting]);
expect(result.hasConflict).toBe(true);
expect(result.conflictType).toBe("teacher");
expect(result.conflictingSlot).toBe(conflicting);
});
});
});

View File

@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { schedulingRepository } from "./scheduling.repository.js";
import { courses, schedules } from "./scheduling.schema.js";
import { attendance } from "../attendance/attendance.schema.js";
import { detectConflict, type ScheduleSlot } from "./schedule-conflict.js";
import {
NotFoundError,
@@ -10,6 +14,18 @@ import {
} from "../shared/errors/application-error.js";
import type { Course, Schedule } from "./scheduling.schema.js";
export interface ScheduleSlotInfo {
id: string;
courseId: string;
courseName: string;
teacherId: string;
classId: string;
roomId: string;
startTime: string;
endTime: string;
subjectId: string;
}
export interface CreateCourseInput {
classId: string;
subjectId: string;
@@ -161,4 +177,89 @@ export class SchedulingService {
async listSchedulesByClass(classId: string): Promise<Schedule[]> {
return schedulingRepository.findSchedulesByClassId(classId);
}
// --------------------------------------------------------------------------
// P3.13 新增:按学生查询课表(通过考勤记录反查 class_id再查该班所有排课
// --------------------------------------------------------------------------
async getScheduleByStudent(
studentId: string,
weekStart?: string,
): Promise<ScheduleSlotInfo[]> {
if (!studentId) {
throw new ValidationError("studentId is required");
}
// 1. 从考勤记录中查找该学生关联的 schedule_id
const attendanceRows = await db
.select({ scheduleId: attendance.scheduleId })
.from(attendance)
.where(eq(attendance.studentId, studentId));
if (attendanceRows.length === 0) {
return [];
}
const scheduleIds = attendanceRows.map((r) => r.scheduleId);
// 2. 查找这些 schedule提取 class_id
const studentSchedules = await db
.select({ classId: schedules.classId })
.from(schedules)
.where(inArray(schedules.id, scheduleIds));
const classIds = [...new Set(studentSchedules.map((s) => s.classId))];
if (classIds.length === 0) {
return [];
}
// 3. 查找这些 class 的所有排课(含未来排课)
let allSchedules = await db
.select()
.from(schedules)
.where(inArray(schedules.classId, classIds));
// 4. 可选:按 week_start 过滤(该周起始日之后 7 天内)
if (weekStart) {
const weekStartDate = new Date(weekStart);
if (!Number.isNaN(weekStartDate.getTime())) {
const weekEndDate = new Date(weekStartDate);
weekEndDate.setDate(weekEndDate.getDate() + 7);
allSchedules = allSchedules.filter((s) => {
const start = new Date(s.startTime);
return start >= weekStartDate && start < weekEndDate;
});
}
}
if (allSchedules.length === 0) {
return [];
}
// 5. 关联课程表获取 course_name 和 subject_id
const courseIds = [...new Set(allSchedules.map((s) => s.courseId))];
const courseRows = await db
.select()
.from(courses)
.where(inArray(courses.id, courseIds));
const courseMap = new Map(courseRows.map((c) => [c.id, c]));
// 6. 构造返回结果
return allSchedules.map((s) => {
const course = courseMap.get(s.courseId);
return {
id: s.id,
courseId: s.courseId,
courseName: course?.name ?? "",
teacherId: s.teacherId,
classId: s.classId,
roomId: s.roomId ?? "",
startTime:
s.startTime instanceof Date ? s.startTime.toISOString() : s.startTime,
endTime:
s.endTime instanceof Date ? s.endTime.toISOString() : s.endTime,
subjectId: course?.subjectId ?? "",
};
});
}
}

View File

@@ -0,0 +1,73 @@
import { and, eq, inArray } from "drizzle-orm";
import type { Column, SQL } from "drizzle-orm";
// 数据范围类型
export type DataScope =
| "SELF" // 仅本人
| "CLASS" // 班级
| "GRADE" // 年级
| "SCHOOL" // 学校
| "DISTRICT" // 学区
| "ALL"; // 全部
// 数据范围上下文,由请求头解析得到
export interface DataScopeContext {
scope: DataScope;
userId: string;
classIds?: string[]; // 教师关联的班级 ID 列表
schoolId?: string;
}
// 数据范围列映射,指定各维度对应的表列
export interface DataScopeColumns {
studentId?: Column;
classId?: Column;
gradeId?: Column;
schoolId?: Column;
}
// 根据数据范围上下文构建 WHERE 条件
// 返回 SQL 表达式或 undefined无需过滤时
export function buildDataScopeCondition(
ctx: DataScopeContext,
columns: DataScopeColumns,
): SQL | undefined {
switch (ctx.scope) {
case "SELF":
// 学生只能看自己的数据,按 student_id 过滤
if (!columns.studentId) return undefined;
return eq(columns.studentId, ctx.userId);
case "CLASS":
// 教师只能看自己班级的数据,按 class_id 过滤
if (!columns.classId || !ctx.classIds?.length) return undefined;
return inArray(columns.classId, ctx.classIds);
case "GRADE":
// 按年级过滤,按 grade_id 过滤
// 当前上下文未提供年级 ID暂不加过滤
return undefined;
case "SCHOOL":
// 按学校过滤,按 school_id 过滤
if (!columns.schoolId || !ctx.schoolId) return undefined;
return eq(columns.schoolId, ctx.schoolId);
case "DISTRICT":
case "ALL":
// 区级和全局范围不加过滤
return undefined;
default:
return undefined;
}
}
// 将数据范围条件注入到基础条件中,返回组合后的 WHERE 条件
// baseCondition: 已有的 WHERE 条件(可选)
// ctx: 数据范围上下文(可选,为空时返回基础条件)
// columns: 数据范围列映射
export function injectDataScope(
baseCondition: SQL | undefined,
ctx: DataScopeContext | undefined,
columns: DataScopeColumns,
): SQL | undefined {
if (!ctx) return baseCondition;
const scopeCondition = buildDataScopeCondition(ctx, columns);
return and(baseCondition, scopeCondition);
}