feat(core-edu): 完整实现 core-edu 教学核心服务

包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:08:56 +08:00
parent 06a646ea4e
commit 58c0ba1bd9
55 changed files with 4204 additions and 305 deletions

View File

@@ -0,0 +1,82 @@
/**
* 排课冲突检测(纯函数)
*
* 冲突维度P3
* - teacher: 同一教师在同一时间段有排课冲突
* - class: 同一班级在同一时间段有排课冲突
*
* room_id 仅预留字段ISSUE-006 决策 #2P3 不校验教室冲突。
*/
export interface ScheduleSlot {
id: string;
teacherId: string;
classId: string;
startTime: Date;
endTime: Date;
roomId?: string | null;
}
export interface ConflictResult {
hasConflict: boolean;
conflictingSlot?: ScheduleSlot;
conflictType: "teacher" | "class" | null;
}
/**
* 检查时间区间重叠。
* [startA, endA) 与 [startB, endB) 重叠的条件startA < endB && startB < endA
*/
export function isTimeOverlap(
startA: Date,
endA: Date,
startB: Date,
endB: Date,
): boolean {
return startA < endB && startB < endA;
}
/**
* 检查新排课与现有排课列表的冲突。
* 排除自身(如果是更新场景,传入 excludeId
*/
export function detectConflict(
newSlot: Omit<ScheduleSlot, "id"> & { id?: string },
existingSlots: ScheduleSlot[],
excludeId?: string,
): ConflictResult {
for (const slot of existingSlots) {
if (excludeId && slot.id === excludeId) continue;
if (
!isTimeOverlap(
newSlot.startTime,
newSlot.endTime,
slot.startTime,
slot.endTime,
)
) {
continue;
}
// 教师冲突
if (newSlot.teacherId === slot.teacherId) {
return {
hasConflict: true,
conflictingSlot: slot,
conflictType: "teacher",
};
}
// 班级冲突
if (newSlot.classId === slot.classId) {
return {
hasConflict: true,
conflictingSlot: slot,
conflictType: "class",
};
}
}
return { hasConflict: false, conflictType: null };
}

View File

@@ -0,0 +1,157 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { z } from "zod";
import {
SchedulingService,
type CreateCourseInput,
type CreateScheduleInput,
} from "./scheduling.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import {
UnauthorizedError,
ValidationError,
} from "../shared/errors/application-error.js";
const createCourseSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
teacherId: z.string().min(1),
schoolId: z.string().min(1),
name: z.string().min(1).max(200),
startDate: z.string().min(1),
endDate: z.string().min(1),
});
const createScheduleSchema = z.object({
courseId: z.string().min(1),
lessonId: z.string().optional(),
teacherId: z.string().min(1),
classId: z.string().min(1),
roomId: z.string().optional(),
startTime: z.string().min(1),
endTime: z.string().min(1),
schoolId: z.string().min(1),
});
@Controller("v1")
export class SchedulingController {
constructor(private readonly schedulingService: SchedulingService) {}
// ----- Course endpoints -----
@Post("courses")
@RequirePermission(Permissions.COURSE_CREATE)
async createCourse(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = createCourseSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid course input", parsed.error.flatten());
}
const input: CreateCourseInput = parsed.data;
const result = await this.schedulingService.createCourse(input);
return { success: true, data: result };
}
@Get("courses/:id")
@RequirePermission(Permissions.COURSE_READ)
async getCourse(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["getCourse"]>>;
}> {
const data = await this.schedulingService.getCourse(id);
return { success: true, data };
}
@Get("courses/class/:classId")
@RequirePermission(Permissions.COURSE_READ)
async listCoursesByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["listCoursesByClass"]>>;
}> {
const data = await this.schedulingService.listCoursesByClass(classId);
return { success: true, data };
}
@Get("courses/teacher/:teacherId")
@RequirePermission(Permissions.COURSE_READ)
async listCoursesByTeacher(@Param("teacherId") teacherId: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["listCoursesByTeacher"]>>;
}> {
const data = await this.schedulingService.listCoursesByTeacher(teacherId);
return { success: true, data };
}
// ----- Schedule endpoints -----
@Post("schedules")
@RequirePermission(Permissions.SCHEDULE_CREATE)
async createSchedule(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = createScheduleSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid schedule input",
parsed.error.flatten(),
);
}
const input: CreateScheduleInput = parsed.data;
const result = await this.schedulingService.createSchedule(input);
return { success: true, data: result };
}
@Get("schedules/:id")
@RequirePermission(Permissions.SCHEDULE_READ)
async getSchedule(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["getSchedule"]>>;
}> {
const data = await this.schedulingService.getSchedule(id);
return { success: true, data };
}
@Get("schedules/course/:courseId")
@RequirePermission(Permissions.SCHEDULE_READ)
async listSchedulesByCourse(@Param("courseId") courseId: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["listSchedulesByCourse"]>>;
}> {
const data = await this.schedulingService.listSchedulesByCourse(courseId);
return { success: true, data };
}
@Get("schedules/teacher/:teacherId")
@RequirePermission(Permissions.SCHEDULE_READ)
async listSchedulesByTeacher(@Param("teacherId") teacherId: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["listSchedulesByTeacher"]>>;
}> {
const data = await this.schedulingService.listSchedulesByTeacher(teacherId);
return { success: true, data };
}
@Get("schedules/class/:classId")
@RequirePermission(Permissions.SCHEDULE_READ)
async listSchedulesByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<SchedulingService["listSchedulesByClass"]>>;
}> {
const data = await this.schedulingService.listSchedulesByClass(classId);
return { success: true, data };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { SchedulingController } from "./scheduling.controller.js";
import { SchedulingService } from "./scheduling.service.js";
@Module({
controllers: [SchedulingController],
providers: [SchedulingService],
exports: [SchedulingService],
})
export class SchedulingModule {}

View File

@@ -0,0 +1,113 @@
import { eq, and, lte, gte } from "drizzle-orm";
import { db } from "../config/database.js";
import {
courses,
schedules,
type Course,
type NewCourse,
type Schedule,
type NewSchedule,
} from "./scheduling.schema.js";
export class SchedulingRepository {
// Courses
async findCourseById(id: string): Promise<Course | undefined> {
const [result] = await db
.select()
.from(courses)
.where(eq(courses.id, id))
.limit(1);
return result;
}
async findCoursesByClassId(classId: string): Promise<Course[]> {
return db.select().from(courses).where(eq(courses.classId, classId));
}
async findCoursesByTeacherId(teacherId: string): Promise<Course[]> {
return db.select().from(courses).where(eq(courses.teacherId, teacherId));
}
async createCourse(course: NewCourse): Promise<void> {
await db.insert(courses).values(course);
}
// Schedules
async findScheduleById(id: string): Promise<Schedule | undefined> {
const [result] = await db
.select()
.from(schedules)
.where(eq(schedules.id, id))
.limit(1);
return result;
}
async findSchedulesByCourseId(courseId: string): Promise<Schedule[]> {
return db.select().from(schedules).where(eq(schedules.courseId, courseId));
}
async findSchedulesByTeacherId(teacherId: string): Promise<Schedule[]> {
return db
.select()
.from(schedules)
.where(eq(schedules.teacherId, teacherId));
}
async findSchedulesByClassId(classId: string): Promise<Schedule[]> {
return db.select().from(schedules).where(eq(schedules.classId, classId));
}
/**
* Find overlapping schedules for conflict detection.
* Returns schedules where [startTime, endTime) overlaps with the given range
* for a specific teacher or class.
*/
async findOverlappingSchedules(
teacherId: string,
classId: string,
startTime: Date,
endTime: Date,
excludeId?: string,
): Promise<Schedule[]> {
// Query both teacher and class conflicts
const teacherSchedules = await db
.select()
.from(schedules)
.where(
and(
eq(schedules.teacherId, teacherId),
lte(schedules.startTime, endTime),
gte(schedules.endTime, startTime),
),
);
const classSchedules = await db
.select()
.from(schedules)
.where(
and(
eq(schedules.classId, classId),
lte(schedules.startTime, endTime),
gte(schedules.endTime, startTime),
),
);
const all = [...teacherSchedules, ...classSchedules];
// Deduplicate and exclude self
const seen = new Set<string>();
return all.filter((s) => {
if (excludeId && s.id === excludeId) return false;
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
});
}
async createSchedule(schedule: NewSchedule): Promise<void> {
await db.insert(schedules).values(schedule);
}
async updateSchedule(id: string, data: Partial<NewSchedule>): Promise<void> {
await db.update(schedules).set(data).where(eq(schedules.id, id));
}
}
export const schedulingRepository = new SchedulingRepository();

View File

@@ -0,0 +1,87 @@
import {
mysqlTable,
varchar,
timestamp,
char,
datetime,
date,
int,
json,
index,
} from "drizzle-orm/mysql-core";
// 课程表
export const courses = mysqlTable(
"core_edu_courses",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
classId: char("class_id", { length: 36 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
teacherId: char("teacher_id", { length: 36 }).notNull(),
schoolId: char("school_id", { length: 36 }).notNull(),
name: varchar("name", { length: 200 }).notNull(),
startDate: date("start_date").notNull(),
endDate: date("end_date").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxCoursesClassId: index("idx_courses_class_id").on(table.classId),
idxCoursesTeacherId: index("idx_courses_teacher_id").on(table.teacherId),
}),
);
// 课次表
export const lessons = mysqlTable(
"core_edu_lessons",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
courseId: char("course_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
order: int("order").notNull(),
knowledgePointIds: json("knowledge_point_ids"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
idxLessonsCourseId: index("idx_lessons_course_id").on(table.courseId),
}),
);
// 排课表
export const schedules = mysqlTable(
"core_edu_schedules",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
courseId: char("course_id", { length: 36 }).notNull(),
lessonId: char("lesson_id", { length: 36 }),
teacherId: char("teacher_id", { length: 36 }).notNull(),
classId: char("class_id", { length: 36 }).notNull(),
roomId: char("room_id", { length: 36 }), // P3 仅预留,不校验冲突
startTime: datetime("start_time").notNull(),
endTime: datetime("end_time").notNull(),
status: varchar("status", { length: 20 }).notNull().default("scheduled"),
schoolId: char("school_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxSchedulesTeacherTime: index("idx_schedules_teacher_time").on(
table.teacherId,
table.startTime,
table.endTime,
),
idxSchedulesClassTime: index("idx_schedules_class_time").on(
table.classId,
table.startTime,
table.endTime,
),
idxSchedulesCourseId: index("idx_schedules_course_id").on(table.courseId),
}),
);
export type Course = typeof courses.$inferSelect;
export type NewCourse = typeof courses.$inferInsert;
export type Lesson = typeof lessons.$inferSelect;
export type NewLesson = typeof lessons.$inferInsert;
export type Schedule = typeof schedules.$inferSelect;
export type NewSchedule = typeof schedules.$inferInsert;

View File

@@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { schedulingRepository } from "./scheduling.repository.js";
import { detectConflict, type ScheduleSlot } from "./schedule-conflict.js";
import {
NotFoundError,
ValidationError,
ApplicationError,
CoreEduErrorCode,
} from "../shared/errors/application-error.js";
import type { Course, Schedule } from "./scheduling.schema.js";
export interface CreateCourseInput {
classId: string;
subjectId: string;
teacherId: string;
schoolId: string;
name: string;
startDate: Date | string;
endDate: Date | string;
}
export interface CreateScheduleInput {
courseId: string;
lessonId?: string;
teacherId: string;
classId: string;
roomId?: string;
startTime: Date | string;
endTime: Date | string;
schoolId: string;
}
@Injectable()
export class SchedulingService {
// Course operations
async createCourse(input: CreateCourseInput): Promise<{ id: string }> {
if (!input.classId || !input.subjectId || !input.teacherId || !input.name) {
throw new ValidationError(
"classId, subjectId, teacherId, name are required",
);
}
const id = randomUUID();
await schedulingRepository.createCourse({
id,
classId: input.classId,
subjectId: input.subjectId,
teacherId: input.teacherId,
schoolId: input.schoolId,
name: input.name,
startDate:
input.startDate instanceof Date
? input.startDate
: new Date(input.startDate),
endDate:
input.endDate instanceof Date ? input.endDate : new Date(input.endDate),
});
return { id };
}
async getCourse(id: string): Promise<Course> {
const course = await schedulingRepository.findCourseById(id);
if (!course) {
throw new NotFoundError(`Course ${id} not found`);
}
return course;
}
async listCoursesByClass(classId: string): Promise<Course[]> {
return schedulingRepository.findCoursesByClassId(classId);
}
async listCoursesByTeacher(teacherId: string): Promise<Course[]> {
return schedulingRepository.findCoursesByTeacherId(teacherId);
}
// Schedule operations
async createSchedule(input: CreateScheduleInput): Promise<{ id: string }> {
if (
!input.courseId ||
!input.teacherId ||
!input.classId ||
!input.schoolId
) {
throw new ValidationError(
"courseId, teacherId, classId, schoolId are required",
);
}
const startTime =
input.startTime instanceof Date
? input.startTime
: new Date(input.startTime);
const endTime =
input.endTime instanceof Date ? input.endTime : new Date(input.endTime);
if (startTime >= endTime) {
throw new ValidationError("startTime must be before endTime");
}
// Conflict detection
const overlapping = await schedulingRepository.findOverlappingSchedules(
input.teacherId,
input.classId,
startTime,
endTime,
);
const newSlot: Omit<ScheduleSlot, "id"> = {
teacherId: input.teacherId,
classId: input.classId,
startTime,
endTime,
roomId: input.roomId,
};
const conflict = detectConflict(newSlot, overlapping);
if (conflict.hasConflict && conflict.conflictingSlot) {
throw new ApplicationError(
CoreEduErrorCode.SCHEDULE_CONFLICT,
`Schedule conflict detected (${conflict.conflictType}): overlaps with existing schedule ${conflict.conflictingSlot.id}`,
409,
{
conflictType: conflict.conflictType,
conflictingScheduleId: conflict.conflictingSlot.id,
},
);
}
const id = randomUUID();
await schedulingRepository.createSchedule({
id,
courseId: input.courseId,
lessonId: input.lessonId,
teacherId: input.teacherId,
classId: input.classId,
roomId: input.roomId,
startTime,
endTime,
schoolId: input.schoolId,
});
return { id };
}
async getSchedule(id: string): Promise<Schedule> {
const schedule = await schedulingRepository.findScheduleById(id);
if (!schedule) {
throw new NotFoundError(`Schedule ${id} not found`);
}
return schedule;
}
async listSchedulesByCourse(courseId: string): Promise<Schedule[]> {
return schedulingRepository.findSchedulesByCourseId(courseId);
}
async listSchedulesByTeacher(teacherId: string): Promise<Schedule[]> {
return schedulingRepository.findSchedulesByTeacherId(teacherId);
}
async listSchedulesByClass(classId: string): Promise<Schedule[]> {
return schedulingRepository.findSchedulesByClassId(classId);
}
}