chore(core-edu): merge core-edu full implementation into main

Merge feat/core-edu-ai08 with complete teaching core service
This commit is contained in:
SpecialX
2026-07-10 19:12:44 +08:00
55 changed files with 4111 additions and 325 deletions

View File

@@ -26,6 +26,7 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0",
"uuid": "^10.0.0",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},

View File

@@ -3,12 +3,25 @@ import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js";
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 { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
imports: [
ExamsModule,
HomeworkModule,
GradesModule,
AttendanceModule,
ClassesModule,
SchedulingModule,
IamConsumerModule,
HealthModule,
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,

View File

@@ -0,0 +1,20 @@
/**
* 考勤状态枚举(纯常量,用于校验)
*
* 状态值ISSUE-003 仲裁 scheme A
* present / absent / late / leave
*/
export const ATTENDANCE_STATUSES = [
"present",
"absent",
"late",
"leave",
] as const;
export type AttendanceStatus = (typeof ATTENDANCE_STATUSES)[number];
export function isValidAttendanceStatus(
status: string,
): status is AttendanceStatus {
return (ATTENDANCE_STATUSES as readonly string[]).includes(status);
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { z } from "zod";
import {
AttendanceService,
type RecordAttendanceInput,
} from "./attendance.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 recordAttendanceSchema = z.object({
scheduleId: z.string().min(1),
studentId: z.string().min(1),
status: z.enum(["present", "absent", "late", "leave"]),
remark: z.string().optional(),
schoolId: z.string().min(1),
});
@Controller("v1/attendance")
export class AttendanceController {
constructor(private readonly attendanceService: AttendanceService) {}
@Post()
@RequirePermission(Permissions.ATTENDANCE_CREATE)
async record(
@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 = recordAttendanceSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid attendance input",
parsed.error.flatten(),
);
}
const input: RecordAttendanceInput = {
...parsed.data,
recordedBy: userId,
};
const result = await this.attendanceService.recordAttendance(input, userId);
return { success: true, data: result };
}
@Get(":id")
@RequirePermission(Permissions.ATTENDANCE_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<AttendanceService["getAttendance"]>>;
}> {
const data = await this.attendanceService.getAttendance(id);
return { success: true, data };
}
@Get("student/:studentId")
@RequirePermission(Permissions.ATTENDANCE_READ)
async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true;
data: Awaited<ReturnType<AttendanceService["listByStudent"]>>;
}> {
const data = await this.attendanceService.listByStudent(studentId);
return { success: true, data };
}
@Get("class/:classId")
@RequirePermission(Permissions.ATTENDANCE_READ)
async listByClass(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<AttendanceService["listByClass"]>>;
}> {
const data = await this.attendanceService.listByClass(classId);
return { success: true, data };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AttendanceController } from "./attendance.controller.js";
import { AttendanceService } from "./attendance.service.js";
@Module({
controllers: [AttendanceController],
providers: [AttendanceService],
exports: [AttendanceService],
})
export class AttendanceModule {}

View File

@@ -0,0 +1,74 @@
import { eq, and, inArray } from "drizzle-orm";
import { db } from "../config/database.js";
import {
attendance,
type Attendance,
type NewAttendance,
} from "./attendance.schema.js";
import { schedules } from "../scheduling/scheduling.schema.js";
export class AttendanceRepository {
async findById(id: string): Promise<Attendance | undefined> {
const [result] = await db
.select()
.from(attendance)
.where(eq(attendance.id, id))
.limit(1);
return result;
}
async findByScheduleAndStudent(
scheduleId: string,
studentId: string,
): Promise<Attendance | undefined> {
const [result] = await db
.select()
.from(attendance)
.where(
and(
eq(attendance.scheduleId, scheduleId),
eq(attendance.studentId, studentId),
),
)
.limit(1);
return result;
}
async findByStudentId(studentId: string): Promise<Attendance[]> {
return db
.select()
.from(attendance)
.where(eq(attendance.studentId, studentId));
}
async findByScheduleId(scheduleId: string): Promise<Attendance[]> {
return db
.select()
.from(attendance)
.where(eq(attendance.scheduleId, scheduleId));
}
async findByClassId(classId: string): Promise<Attendance[]> {
// Two-step query: find schedule IDs for class, then attendance by those IDs
const classSchedules = await db
.select({ id: schedules.id })
.from(schedules)
.where(eq(schedules.classId, classId));
const scheduleIds = classSchedules.map((s) => s.id);
if (scheduleIds.length === 0) return [];
return db
.select()
.from(attendance)
.where(inArray(attendance.scheduleId, scheduleIds));
}
async create(record: NewAttendance, tx: typeof db = db): Promise<void> {
await tx.insert(attendance).values(record);
}
async update(id: string, data: Partial<NewAttendance>): Promise<void> {
await db.update(attendance).set(data).where(eq(attendance.id, id));
}
}
export const attendanceRepository = new AttendanceRepository();

View File

@@ -0,0 +1,40 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// 考勤记录表
export const attendance = mysqlTable(
"core_edu_attendance",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
scheduleId: char("schedule_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
status: varchar("status", { length: 20 }).notNull(), // present | absent | late | leave
remark: text("remark"),
recordedBy: char("recorded_by", { length: 36 }).notNull(),
schoolId: char("school_id", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqSchedStudent: uniqueIndex("uniq_sched_student").on(
table.scheduleId,
table.studentId,
),
idxAttendanceStudentId: index("idx_attendance_student_id").on(
table.studentId,
),
idxAttendanceScheduleId: index("idx_attendance_schedule_id").on(
table.scheduleId,
),
}),
);
export type Attendance = typeof attendance.$inferSelect;
export type NewAttendance = typeof attendance.$inferInsert;

View File

@@ -0,0 +1,116 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { attendanceRepository } from "./attendance.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import { isValidAttendanceStatus } from "./attendance-status.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Attendance } from "./attendance.schema.js";
export interface RecordAttendanceInput {
scheduleId: string;
studentId: string;
status: string;
remark?: string;
recordedBy: string;
schoolId: string;
}
@Injectable()
export class AttendanceService {
async recordAttendance(
input: RecordAttendanceInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.scheduleId ||
!input.studentId ||
!input.recordedBy ||
!input.schoolId
) {
throw new ValidationError(
"scheduleId, studentId, recordedBy, schoolId are required",
);
}
if (!isValidAttendanceStatus(input.status)) {
throw new ValidationError(
`Invalid attendance status: ${input.status}. Must be one of: present, absent, late, leave`,
);
}
// Idempotency: check existing record
const existing = await attendanceRepository.findByScheduleAndStudent(
input.scheduleId,
input.studentId,
);
if (existing) {
throw new ConflictError(
`Attendance already recorded for student ${input.studentId} in schedule ${input.scheduleId}`,
);
}
const id = randomUUID();
const event = buildEvent({
aggregateId: id,
eventType: "attendance.recorded",
payload: {
attendanceId: id,
scheduleId: input.scheduleId,
studentId: input.studentId,
status: input.status,
},
userId,
});
await db.transaction(async (tx) => {
await attendanceRepository.create(
{
id,
scheduleId: input.scheduleId,
studentId: input.studentId,
status: input.status,
remark: input.remark,
recordedBy: input.recordedBy,
schoolId: input.schoolId,
},
tx,
);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "attendance",
eventType: "attendance.recorded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { id };
}
async getAttendance(id: string): Promise<Attendance> {
const record = await attendanceRepository.findById(id);
if (!record) {
throw new NotFoundError(`Attendance ${id} not found`);
}
return record;
}
async listByStudent(studentId: string): Promise<Attendance[]> {
return attendanceRepository.findByStudentId(studentId);
}
async listByClass(classId: string): Promise<Attendance[]> {
return attendanceRepository.findByClassId(classId);
}
}

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { z } from "zod";
import { ClassesService } from "./classes.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import { ValidationError } from "../shared/errors/application-error.js";
const batchGetSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
});
@Controller("v1/classes")
export class ClassesController {
constructor(private readonly classesService: ClassesService) {}
@Get(":id")
@RequirePermission(Permissions.CLASS_READ)
async findOne(@Param("id") id: string): Promise<{
success: true;
data: Awaited<ReturnType<ClassesService["getClass"]>>;
}> {
const data = await this.classesService.getClass(id);
return { success: true, data };
}
@Get("teacher/:teacherId")
@RequirePermission(Permissions.CLASS_READ)
async listByTeacher(@Param("teacherId") teacherId: string): Promise<{
success: true;
data: Awaited<ReturnType<ClassesService["getClassesByTeacher"]>>;
}> {
const data = await this.classesService.getClassesByTeacher(teacherId);
return { success: true, data };
}
@Post("batch")
@RequirePermission(Permissions.CLASS_READ)
async batchGet(@Body() body: unknown): Promise<{
success: true;
data: Awaited<ReturnType<ClassesService["batchGetClasses"]>>;
}> {
const parsed = batchGetSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid batch request",
parsed.error.flatten(),
);
}
const data = await this.classesService.batchGetClasses(parsed.data.ids);
return { success: true, data };
}
@Get(":classId/students")
@RequirePermission(Permissions.CLASS_READ)
async listStudents(@Param("classId") classId: string): Promise<{
success: true;
data: Awaited<ReturnType<ClassesService["listStudentsByClass"]>>;
}> {
const data = await this.classesService.listStudentsByClass(classId);
return { success: true, data };
}
}

View File

@@ -1,15 +1,10 @@
import { Module } from '@nestjs/common';
import { Module } from "@nestjs/common";
import { ClassesController } from "./classes.controller.js";
import { ClassesService } from "./classes.service.js";
/**
* Classes module - P3 skeleton placeholder.
*
* The classes domain was merged into CoreEdu in P3. This empty module
* exists so that future class-transfer features and event handlers can
* be registered without restructuring the AppModule imports.
*/
@Module({
controllers: [],
providers: [],
exports: [],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],
})
export class ClassesModule {}

View File

@@ -0,0 +1,47 @@
import { eq, inArray } from "drizzle-orm";
import { db } from "../config/database.js";
import { classes, type Class } from "./classes.schema.js";
import { teacherAssociations } from "./teacher-associations.schema.js";
export class ClassesRepository {
async findById(id: string): Promise<Class | undefined> {
const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, id))
.limit(1);
return result;
}
async findByIds(ids: string[]): Promise<Class[]> {
if (ids.length === 0) return [];
return db.select().from(classes).where(inArray(classes.id, ids));
}
async findByTeacherId(teacherId: string): Promise<Class[]> {
// Via teacher associations
const associations = await db
.select({ classId: teacherAssociations.classId })
.from(teacherAssociations)
.where(eq(teacherAssociations.teacherId, teacherId));
const classIds = [...new Set(associations.map((a) => a.classId))];
if (classIds.length === 0) return [];
return db.select().from(classes).where(inArray(classes.id, classIds));
}
async create(data: {
id: string;
name: string;
gradeId: string;
headTeacherId?: string;
description?: string;
}): Promise<void> {
await db.insert(classes).values(data);
}
async update(id: string, data: Partial<Class>): Promise<void> {
await db.update(classes).set(data).where(eq(classes.id, id));
}
}
export const classesRepository = new ClassesRepository();

View File

@@ -0,0 +1,31 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
index,
} from "drizzle-orm/mysql-core";
// 班级表(从 classes 服务合并,保留原表名兼容 CDC
export const classes = mysqlTable(
"classes",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
gradeId: char("grade_id", { length: 36 }).notNull(),
headTeacherId: char("head_teacher_id", { length: 36 }),
description: text("description"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxClassesGradeId: index("idx_classes_grade_id").on(table.gradeId),
idxClassesHeadTeacher: index("idx_classes_head_teacher").on(
table.headTeacherId,
),
}),
);
export type Class = typeof classes.$inferSelect;
export type NewClass = typeof classes.$inferInsert;

View File

@@ -0,0 +1,39 @@
import { Injectable } from "@nestjs/common";
import { classesRepository } from "./classes.repository.js";
import { NotFoundError } from "../shared/errors/application-error.js";
import type { Class } from "./classes.schema.js";
@Injectable()
export class ClassesService {
async getClass(id: string): Promise<Class> {
const cls = await classesRepository.findById(id);
if (!cls) {
throw new NotFoundError(`Class ${id} not found`);
}
return cls;
}
async getClassesByTeacher(teacherId: string): Promise<Class[]> {
return classesRepository.findByTeacherId(teacherId);
}
async batchGetClasses(ids: string[]): Promise<Class[]> {
return classesRepository.findByIds(ids);
}
/**
* List students by class.
* P3: This requires IAM service integration via gRPC.
* Currently returns empty array as placeholder - IAM consumer will populate
* teacher_associations and the actual student list comes from IAM.
*/
async listStudentsByClass(
classId: string,
): Promise<Array<{ id: string; name: string; classId: string }>> {
// Verify class exists
await this.getClass(classId);
// P3: Student list comes from IAM service (gRPC call to iam.GetUserByClass)
// For now, return empty - this will be implemented when IAM gRPC client is available
return [];
}
}

View File

@@ -0,0 +1,35 @@
import {
mysqlTable,
varchar,
timestamp,
char,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// 教师关联表IAM 事件消费幂等)
export const teacherAssociations = mysqlTable(
"core_edu_teacher_associations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
teacherId: char("teacher_id", { length: 36 }).notNull(),
classId: char("class_id", { length: 36 }).notNull(),
subjectId: char("subject_id", { length: 36 }),
schoolId: char("school_id", { length: 36 }).notNull(),
source: varchar("source", { length: 20 }).notNull().default("iam_event"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
uniqTeacherClassSubject: uniqueIndex("uniq_teacher_class_subject").on(
table.teacherId,
table.classId,
table.subjectId,
),
idxTeacherAssocTeacherId: index("idx_teacher_assoc_teacher_id").on(
table.teacherId,
),
}),
);
export type TeacherAssociation = typeof teacherAssociations.$inferSelect;
export type NewTeacherAssociation = typeof teacherAssociations.$inferInsert;

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default("3004"),
GRPC_PORT: z.string().default("50053"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().optional(),
@@ -23,7 +24,7 @@ export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error(
"Invalid environment variables:",
"Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");

View File

@@ -1,5 +1,6 @@
import { Kafka } from "kafkajs";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
export const kafka = new Kafka({
brokers: env.KAFKA_BROKERS.split(","),
@@ -13,15 +14,35 @@ export const producer = kafka.producer({
export const consumer = kafka.consumer({ groupId: "core-edu-group" });
let producerConnected = false;
let consumerConnected = false;
producer.on("producer.connect", () => {
producerConnected = true;
});
producer.on("producer.disconnect", () => {
producerConnected = false;
});
consumer.on("consumer.connect", () => {
consumerConnected = true;
});
consumer.on("consumer.disconnect", () => {
consumerConnected = false;
});
export function isKafkaConnected(): boolean {
return producerConnected && consumerConnected;
}
export async function connectKafka(): Promise<void> {
try {
await producer.connect();
await consumer.connect();
console.log("Kafka connected");
logger.info("Kafka connected");
} catch (err) {
console.warn(
"Kafka connect failed, running without Kafka:",
err instanceof Error ? err.message : String(err),
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Kafka connect failed, running without Kafka",
);
}
}

View File

@@ -0,0 +1,97 @@
import { createClient, type RedisClientType } from "redis";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
let client: RedisClientType | null = null;
/**
* 获取 Redis 客户端(单例)。
* REDIS_URL 未配置时返回 null调用方需做 null 检查。
*/
export function getRedisClient(): RedisClientType | null {
if (!env.REDIS_URL) return null;
if (!client) {
client = createClient({ url: env.REDIS_URL });
client.on("error", (err) => {
logger.error({ err: err.message }, "Redis client error");
});
}
return client;
}
/**
* 连接 Redis非阻塞连接失败不影响服务启动
*/
export async function connectRedis(): Promise<void> {
const c = getRedisClient();
if (!c) {
logger.warn("REDIS_URL not set, Redis disabled");
return;
}
if (c.isOpen) return;
try {
await c.connect();
logger.info("Redis connected");
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Redis connect failed, running without Redis",
);
}
}
/**
* 关闭 Redis 连接。
*/
export async function disconnectRedis(): Promise<void> {
if (client && client.isOpen) {
await client.quit();
logger.info("Redis disconnected");
}
}
/**
* Redis 健康检查(用于 /readyz
*/
export async function isRedisHealthy(): Promise<boolean> {
if (!client || !client.isOpen) return false;
try {
const pong = await client.ping();
return pong === "PONG";
} catch {
return false;
}
}
/**
* 分布式锁(用于作业提交幂等性保护)。
*
* 使用 SET NX EX 实现:
* - 成功获取锁返回 true
* - 锁已被持有返回 false
*
* 锁自动过期TTL避免死锁。
*/
export async function acquireLock(
key: string,
ttlSeconds: number,
): Promise<boolean> {
const c = getRedisClient();
if (!c || !c.isOpen) return false;
try {
const result = await c.set(key, "1", { NX: true, EX: ttlSeconds });
return result === "OK";
} catch {
return false;
}
}
export async function releaseLock(key: string): Promise<void> {
const c = getRedisClient();
if (!c || !c.isOpen) return;
try {
await c.del(key);
} catch {
// 释放失败不影响业务(锁会自动过期)
}
}

View File

@@ -0,0 +1,60 @@
/**
* 考试状态机(纯函数)
*
* 状态流转ISSUE-003 仲裁 scheme A
* draft → published → in_progress → grading → graded → archived
* └→ cancelled终态
* cancelled 可从 published/in_progress/grading 流入(异常终止)
*
* archived 为软删除终态ISSUE-006 决策 #7archived 仅做软删除,不物理删除)
*/
export type ExamStatus =
| "draft"
| "published"
| "in_progress"
| "grading"
| "graded"
| "archived"
| "cancelled";
export type ExamAction =
"publish" | "start" | "submit" | "grade" | "archive" | "cancel";
const TRANSITIONS: Record<
ExamStatus,
Partial<Record<ExamAction, ExamStatus>>
> = {
draft: { publish: "published", cancel: "cancelled" },
published: { start: "in_progress", cancel: "cancelled" },
in_progress: { submit: "grading", cancel: "cancelled" },
grading: { grade: "graded", cancel: "cancelled" },
graded: { archive: "archived" },
archived: {},
cancelled: {},
};
export function canTransition(from: ExamStatus, action: ExamAction): boolean {
return TRANSITIONS[from]?.[action] !== undefined;
}
export function transition(from: ExamStatus, action: ExamAction): ExamStatus {
const next = TRANSITIONS[from]?.[action];
if (!next) {
throw new Error(`Invalid exam state transition: ${from} --${action}-->`);
}
return next;
}
export function isTerminal(status: ExamStatus): boolean {
return status === "archived" || status === "cancelled";
}
export const EXAM_STATUSES: readonly ExamStatus[] = [
"draft",
"published",
"in_progress",
"grading",
"graded",
"archived",
"cancelled",
] as const;

View File

@@ -8,6 +8,7 @@ import {
Put,
Req,
} from "@nestjs/common";
import { z } from "zod";
import {
ExamsService,
type CreateExamInput,
@@ -18,26 +19,75 @@ import {
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
UnauthorizedError,
ValidationError,
} from "../shared/errors/application-error.js";
@Controller("exams")
const createExamSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().optional(),
examDate: z.string().min(1),
duration: z.number().int().positive(),
totalScore: z.string().min(1),
schoolId: z.string().min(1),
});
const updateExamSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
examDate: z.string().min(1).optional(),
duration: z.number().int().positive().optional(),
totalScore: z.string().min(1).optional(),
});
const answerInputSchema = z.object({
questionId: z.string().min(1),
answer: z.string(),
});
const scoreInputSchema = z.object({
questionId: z.string().min(1),
score: z.string().min(1),
teacherComment: z.string().optional(),
});
const submitExamSchema = z.object({
studentId: z.string().min(1),
answers: z.array(answerInputSchema).default([]),
});
const gradeExamSchema = z.object({
submissionId: z.string().min(1),
scores: z.array(scoreInputSchema).min(1),
});
@Controller("v1/exams")
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@RequirePermission(Permissions.EXAM_CREATE)
async create(
@Body() body: CreateExamInput,
@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 result = await this.examsService.createExam({
...body,
const parsed = createExamSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid exam input", parsed.error.flatten());
}
const input: CreateExamInput = {
...parsed.data,
examDate: new Date(parsed.data.examDate),
createdBy: userId,
});
};
const result = await this.examsService.createExam(input, userId);
return { success: true, data: result };
}
@@ -65,9 +115,22 @@ export class ExamsController {
@RequirePermission(Permissions.EXAM_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateExamInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.examsService.updateExam(id, body);
const parsed = updateExamSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid exam update input",
parsed.error.flatten(),
);
}
const data: UpdateExamInput = {
...parsed.data,
examDate: parsed.data.examDate
? new Date(parsed.data.examDate)
: undefined,
};
await this.examsService.updateExam(id, data);
return { success: true, data: { success: true } };
}
@@ -79,4 +142,74 @@ export class ExamsController {
await this.examsService.deleteExam(id);
return { success: true, data: { success: true } };
}
@Post(":id/publish")
@RequirePermission(Permissions.EXAM_PUBLISH)
async publish(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: true } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
await this.examsService.publishExam(id, userId);
return { success: true, data: { success: true } };
}
@Post(":id/submit")
@RequirePermission(Permissions.EXAM_SUBMIT)
async submit(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: { submissionId: string } }> {
const parsed = submitExamSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid submit input", parsed.error.flatten());
}
const result = await this.examsService.submitExam(
id,
parsed.data.studentId,
parsed.data.answers,
);
return { success: true, data: result };
}
@Post(":id/grade")
@RequirePermission(Permissions.EXAM_GRADE)
async grade(
@Param("id") id: string,
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { totalScore: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = gradeExamSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid grade input", parsed.error.flatten());
}
const result = await this.examsService.gradeExam(
id,
parsed.data.submissionId,
parsed.data.scores,
userId,
);
return { success: true, data: result };
}
@Post(":id/archive")
@RequirePermission(Permissions.EXAM_UPDATE)
async archive(
@Param("id") id: string,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: true } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
await this.examsService.archiveExam(id, userId);
return { success: true, data: { success: true } };
}
}

View File

@@ -1,6 +1,16 @@
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { db } from "../config/database.js";
import { exams, type Exam, type NewExam } from "./exams.schema.js";
import {
exams,
examQuestions,
examSubmissions,
type Exam,
type NewExam,
type ExamQuestion,
type NewExamQuestion,
type ExamSubmission,
type NewExamSubmission,
} from "./exams.schema.js";
export class ExamsRepository {
async findById(id: string): Promise<Exam | undefined> {
@@ -23,6 +33,63 @@ export class ExamsRepository {
async delete(id: string): Promise<void> {
await db.delete(exams).where(eq(exams.id, id));
}
// Exam questions
async addQuestions(questions: NewExamQuestion[]): Promise<void> {
if (questions.length === 0) return;
await db.insert(examQuestions).values(questions);
}
async listQuestions(examId: string): Promise<ExamQuestion[]> {
return db
.select()
.from(examQuestions)
.where(eq(examQuestions.examId, examId));
}
// Exam submissions
async findSubmission(
examId: string,
studentId: string,
): Promise<ExamSubmission | undefined> {
const [result] = await db
.select()
.from(examSubmissions)
.where(
and(
eq(examSubmissions.examId, examId),
eq(examSubmissions.studentId, studentId),
),
)
.limit(1);
return result;
}
async findSubmissionById(id: string): Promise<ExamSubmission | undefined> {
const [result] = await db
.select()
.from(examSubmissions)
.where(eq(examSubmissions.id, id))
.limit(1);
return result;
}
async createSubmission(
submission: NewExamSubmission,
tx: typeof db = db,
): Promise<void> {
await tx.insert(examSubmissions).values(submission);
}
async updateSubmission(
id: string,
data: Partial<NewExamSubmission>,
): Promise<void> {
await db
.update(examSubmissions)
.set(data)
.where(eq(examSubmissions.id, id));
}
}
export const examsRepository = new ExamsRepository();

View File

@@ -5,21 +5,102 @@ import {
timestamp,
char,
datetime,
} from 'drizzle-orm/mysql-core';
int,
decimal,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
export const exams = mysqlTable('core_edu_exams', {
id: char('id', { length: 36 }).notNull().primaryKey(),
classId: char('class_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
examDate: datetime('exam_date').notNull(),
duration: varchar('duration', { length: 50 }).notNull(),
totalScore: varchar('total_score', { length: 10 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('draft'),
createdBy: char('created_by', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
// 考试主表
export const exams = mysqlTable(
"core_edu_exams",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
classId: char("class_id", { length: 36 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
description: text("description"),
examDate: datetime("exam_date").notNull(),
duration: int("duration").notNull(), // 秒
totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("draft"),
statusChangedAt: timestamp("status_changed_at").notNull().defaultNow(),
statusChangedBy: char("status_changed_by", { length: 36 }),
schoolId: char("school_id", { length: 36 }).notNull(),
createdBy: char("created_by", { length: 36 }).notNull(),
archivedAt: datetime("archived_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxExamsClassStatus: index("idx_exams_class_status").on(
table.classId,
table.status,
),
idxExamsSchoolDate: index("idx_exams_school_date").on(
table.schoolId,
table.examDate,
),
idxExamsCreatedBy: index("idx_exams_created_by").on(table.createdBy),
}),
);
// 考试题目关联表
export const examQuestions = mysqlTable(
"core_edu_exam_questions",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
questionId: char("question_id", { length: 36 }).notNull(),
order: int("order").notNull(),
score: decimal("score", { precision: 6, scale: 2 }).notNull(),
questionType: varchar("question_type", { length: 30 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
idxExamQuestionsExamId: index("idx_exam_questions_exam_id").on(
table.examId,
),
idxExamQuestionsQuestionId: index("idx_exam_questions_question_id").on(
table.questionId,
),
}),
);
// 考试提交表
export const examSubmissions = mysqlTable(
"core_edu_exam_submissions",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
examId: char("exam_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
status: varchar("status", { length: 20 })
.notNull()
.default("not_submitted"),
submittedAt: datetime("submitted_at"),
gradedAt: datetime("graded_at"),
gradedBy: char("graded_by", { length: 36 }),
totalScore: decimal("total_score", { precision: 6, scale: 2 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqExamStudent: uniqueIndex("uniq_exam_student").on(
table.examId,
table.studentId,
),
idxExamSubmissionsExamId: index("idx_exam_submissions_exam_id").on(
table.examId,
),
idxExamSubmissionsStudentId: index("idx_exam_submissions_student_id").on(
table.studentId,
),
}),
);
export type Exam = typeof exams.$inferSelect;
export type NewExam = typeof exams.$inferInsert;
export type ExamQuestion = typeof examQuestions.$inferSelect;
export type NewExamQuestion = typeof examQuestions.$inferInsert;
export type ExamSubmission = typeof examSubmissions.$inferSelect;
export type NewExamSubmission = typeof examSubmissions.$inferInsert;

View File

@@ -5,19 +5,31 @@ import { db } from "../config/database.js";
import { exams } from "./exams.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";
import {
canTransition,
transition,
type ExamStatus,
type ExamAction,
} from "./exam-state-machine.js";
import type { Exam, NewExam } from "./exams.schema.js";
import {
NotFoundError,
ValidationError,
ConflictError,
ApplicationError,
CoreEduErrorCode,
} from "../shared/errors/application-error.js";
export interface CreateExamInput {
classId: string;
subjectId: string;
title: string;
description?: string;
examDate: Date | string;
duration: string;
duration: number;
totalScore: string;
schoolId: string;
createdBy: string;
}
@@ -25,26 +37,45 @@ export interface UpdateExamInput {
title?: string;
description?: string;
examDate?: Date;
duration?: string;
duration?: number;
totalScore?: string;
status?: string;
}
export interface AnswerInput {
questionId: string;
answer: string;
}
export interface ScoreInput {
questionId: string;
score: string;
teacherComment?: string;
}
@Injectable()
export class ExamsService {
async createExam(input: CreateExamInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError("classId, title, createdBy are required");
async createExam(
input: CreateExamInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.classId ||
!input.title ||
!input.createdBy ||
!input.subjectId
) {
throw new ValidationError(
"classId, subjectId, title, createdBy are required",
);
}
const id = randomUUID();
const exam: NewExam = {
id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
description: input.description,
// Drizzle datetime 列需要 Date 对象(调用 toISOString。HTTP 请求体里的
// examDate 是 ISO 字符串,这里统一转成 Date避免 "toISOString is not a function"。
examDate:
input.examDate instanceof Date
? input.examDate
@@ -52,22 +83,34 @@ export class ExamsService {
duration: input.duration,
totalScore: input.totalScore,
status: "draft",
statusChangedAt: new Date(),
schoolId: input.schoolId,
createdBy: input.createdBy,
};
const event = buildEvent({
aggregateId: id,
eventType: "exam.created",
payload: {
examId: id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
},
userId,
});
await db.transaction(async (tx) => {
await tx.insert(exams).values(exam);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.created",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
@@ -94,16 +137,28 @@ export class ExamsService {
if (!existing) {
throw new NotFoundError(`Exam ${id} not found`);
}
if (existing.status !== "draft") {
throw new ConflictError(
`Cannot update exam in status ${existing.status} (only draft allows edits)`,
);
}
await db.transaction(async (tx) => {
await tx.update(exams).set(data).where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.updated",
payload: { examId: id, changes: data },
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.updated",
payload: JSON.stringify({ id, changes: data }),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
@@ -119,17 +174,231 @@ export class ExamsService {
await db.transaction(async (tx) => {
await tx.delete(exams).where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.deleted",
payload: { examId: id },
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.deleted",
payload: JSON.stringify({ id }),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
async publishExam(id: string, publishedBy: string): Promise<void> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
const currentStatus = exam.status as ExamStatus;
this.assertTransition(currentStatus, "publish");
const newStatus = transition(currentStatus, "publish");
await db.transaction(async (tx) => {
await tx
.update(exams)
.set({
status: newStatus,
statusChangedAt: new Date(),
statusChangedBy: publishedBy,
})
.where(eq(exams.id, id));
const event = buildEvent({
aggregateId: id,
eventType: "exam.published",
payload: {
examId: id,
classId: exam.classId,
subjectId: exam.subjectId,
},
userId: publishedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "exam",
eventType: "exam.published",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
async submitExam(
examId: string,
studentId: string,
_answers: AnswerInput[],
): Promise<{ submissionId: string }> {
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
// Check existing submission (idempotency via unique index)
const existing = await examsRepository.findSubmission(examId, studentId);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted exam ${examId}`,
);
}
const submissionId = randomUUID();
await db.transaction(async (tx) => {
await examsRepository.createSubmission(
{
id: submissionId,
examId,
studentId,
status: "submitted",
submittedAt: new Date(),
},
tx,
);
// ISSUE-006 决策 #6: exam.submitted 事件只带 submission_id
const event = buildEvent({
aggregateId: examId,
eventType: "exam.submitted",
payload: {
examId,
submissionId,
studentId,
},
userId: studentId,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: examId,
aggregateType: "exam",
eventType: "exam.submitted",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { submissionId };
}
async gradeExam(
examId: string,
submissionId: string,
scores: ScoreInput[],
gradedBy: string,
): Promise<{ totalScore: string }> {
const exam = await examsRepository.findById(examId);
if (!exam) {
throw new NotFoundError(`Exam ${examId} not found`);
}
const submission = await examsRepository.findSubmissionById(submissionId);
if (!submission || submission.examId !== examId) {
throw new NotFoundError(
`Submission ${submissionId} not found for exam ${examId}`,
);
}
if (submission.status === "graded") {
throw new ConflictError(`Submission ${submissionId} already graded`);
}
// Calculate total score
const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0);
const totalScoreStr = totalScore.toFixed(2);
await db.transaction(async (tx) => {
await examsRepository.updateSubmission(submissionId, {
status: "graded",
gradedAt: new Date(),
gradedBy,
totalScore: totalScore.toFixed(2),
});
const event = buildEvent({
aggregateId: examId,
eventType: "exam.graded",
payload: {
examId,
submissionId,
studentId: submission.studentId,
totalScore: totalScoreStr,
},
userId: gradedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: examId,
aggregateType: "exam",
eventType: "exam.graded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { totalScore: totalScoreStr };
}
async archiveExam(id: string, archivedBy: string): Promise<void> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
// ISSUE-006 决策 #7: archived 仅做软删除
if (exam.status === "archived") {
return; // Already archived
}
if (exam.status !== "graded") {
throw new ConflictError(
`Cannot archive exam in status ${exam.status} (only graded allows archive)`,
);
}
await db.transaction(async (tx) => {
await tx
.update(exams)
.set({
status: "archived",
statusChangedAt: new Date(),
statusChangedBy: archivedBy,
archivedAt: new Date(),
})
.where(eq(exams.id, id));
});
}
private assertTransition(from: ExamStatus, action: ExamAction): void {
if (!canTransition(from, action)) {
throw new ApplicationError(
CoreEduErrorCode.EXAM_INVALID_TRANSITION,
`Invalid exam state transition: ${from} --${action}-->`,
409,
{ from, action },
);
}
}
}

View File

@@ -0,0 +1,96 @@
/**
* 成绩计算器(纯函数)
*
* 支持的公式类型:
* - weighted_average: 加权平均weights JSON: { examId/homeworkId: weight }
* - sum: 直接求和
* - custom: 自定义表达式P3 仅预留,不执行)
*
* Scope 优先级ISSUE-006 决策 #3class > subject > school
*/
export type GradeScope = "class" | "subject" | "school";
export type FormulaType = "weighted_average" | "sum" | "custom";
export interface GradeFormulaConfig {
scope: GradeScope;
scopeId: string;
formulaType: FormulaType;
weights: Record<string, number> | null;
customExpression: string | null;
effectiveFrom: Date;
effectiveTo: Date | null;
}
export interface ScoreEntry {
sourceId: string; // examId or homeworkId
score: number;
totalScore: number;
}
const SCOPE_PRIORITY: Record<GradeScope, number> = {
class: 3,
subject: 2,
school: 1,
};
/**
* 按 scope 优先级选择生效公式class > subject > school
* 同时满足 effectiveFrom <= now < effectiveTo如果 effectiveTo 存在)。
*/
export function selectFormula(
formulas: GradeFormulaConfig[],
now: Date = new Date(),
): GradeFormulaConfig | null {
const effective = formulas.filter((f) => {
if (f.effectiveFrom > now) return false;
if (f.effectiveTo && f.effectiveTo <= now) return false;
return true;
});
if (effective.length === 0) return null;
effective.sort((a, b) => SCOPE_PRIORITY[b.scope] - SCOPE_PRIORITY[a.scope]);
return effective[0]!;
}
/**
* 计算最终成绩。
* - weighted_average: Σ(score * weight) / Σ(weight)
* - sum: Σ(score)
* - custom: P3 不执行,抛出错误
*/
export function calculateGrade(
formula: GradeFormulaConfig,
entries: ScoreEntry[],
): number {
switch (formula.formulaType) {
case "weighted_average": {
if (!formula.weights) {
// 无权重时退化为简单平均
if (entries.length === 0) return 0;
const sum = entries.reduce((acc, e) => acc + e.score, 0);
return Math.round((sum / entries.length) * 100) / 100;
}
let weightedSum = 0;
let totalWeight = 0;
for (const entry of entries) {
const weight = formula.weights[entry.sourceId] ?? 0;
weightedSum += entry.score * weight;
totalWeight += weight;
}
if (totalWeight === 0) return 0;
return Math.round((weightedSum / totalWeight) * 100) / 100;
}
case "sum": {
return (
Math.round(entries.reduce((acc, e) => acc + e.score, 0) * 100) / 100
);
}
case "custom": {
throw new Error(
"Custom grade formula is not supported in P3 (reserved for future)",
);
}
}
}

View File

@@ -1,30 +1,63 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { GradesService, type RecordGradeInput } from "./grades.service.js";
import { Body, Controller, Get, Param, Post, Put, Req } from "@nestjs/common";
import { z } from "zod";
import {
GradesService,
type RecordGradeInput,
type UpdateGradeInput,
} from "./grades.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
UnauthorizedError,
ValidationError,
} from "../shared/errors/application-error.js";
@Controller("grades")
const recordGradeSchema = z
.object({
studentId: z.string().min(1),
examId: z.string().optional(),
homeworkId: z.string().optional(),
score: z.string().min(1),
totalScore: z.string().min(1),
feedback: z.string().optional(),
schoolId: z.string().min(1),
idempotencyKey: z.string().optional(),
})
.refine((data) => data.examId || data.homeworkId, {
message: "Either examId or homeworkId must be provided",
});
const updateGradeSchema = z.object({
score: z.string().min(1).optional(),
feedback: z.string().optional(),
});
@Controller("v1/grades")
export class GradesController {
constructor(private readonly gradesService: GradesService) {}
@Post()
@RequirePermission(Permissions.GRADE_CREATE)
async record(
@Body() body: RecordGradeInput,
@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 result = await this.gradesService.recordGrade({
...body,
const parsed = recordGradeSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid grade input", parsed.error.flatten());
}
const input: RecordGradeInput = {
...parsed.data,
gradedBy: userId,
});
};
const result = await this.gradesService.recordGrade(input, userId);
return { success: true, data: result };
}
@@ -67,4 +100,27 @@ export class GradesController {
const data = await this.gradesService.listByHomework(homeworkId);
return { success: true, data };
}
@Put(":id")
@RequirePermission(Permissions.GRADE_UPDATE)
async update(
@Param("id") id: string,
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { success: true } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = updateGradeSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid grade update input",
parsed.error.flatten(),
);
}
const data: UpdateGradeInput = parsed.data;
await this.gradesService.updateGrade(id, data, userId);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,64 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import {
grades,
gradeFormulas,
type Grade,
type NewGrade,
type GradeFormula,
} from "./grades.schema.js";
export class GradesRepository {
async findById(id: string): Promise<Grade | undefined> {
const [result] = await db
.select()
.from(grades)
.where(eq(grades.id, id))
.limit(1);
return result;
}
async findByStudentId(studentId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.studentId, studentId));
}
async findByExamId(examId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.examId, examId));
}
async findByHomeworkId(homeworkId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
}
async findByIdempotencyKey(
idempotencyKey: string,
): Promise<Grade | undefined> {
const [result] = await db
.select()
.from(grades)
.where(eq(grades.idempotencyKey, idempotencyKey))
.limit(1);
return result;
}
async create(grade: NewGrade, tx: typeof db = db): Promise<void> {
await tx.insert(grades).values(grade);
}
async update(id: string, data: Partial<NewGrade>): Promise<void> {
await db.update(grades).set(data).where(eq(grades.id, id));
}
// Grade formulas
async findFormulasByScope(
scope: string,
_scopeId: string,
): Promise<GradeFormula[]> {
return db
.select()
.from(gradeFormulas)
.where(eq(gradeFormulas.scope, scope));
}
}
export const gradesRepository = new GradesRepository();

View File

@@ -4,19 +4,74 @@ import {
text,
timestamp,
char,
} from 'drizzle-orm/mysql-core';
decimal,
json,
datetime,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
export const grades = mysqlTable('core_edu_grades', {
id: char('id', { length: 36 }).notNull().primaryKey(),
studentId: char('student_id', { length: 36 }).notNull(),
examId: char('exam_id', { length: 36 }),
homeworkId: char('homework_id', { length: 36 }),
score: varchar('score', { length: 10 }).notNull(),
feedback: text('feedback'),
gradedBy: char('graded_by', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
// 成绩主表
export const grades = mysqlTable(
"core_edu_grades",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
studentId: char("student_id", { length: 36 }).notNull(),
examId: char("exam_id", { length: 36 }),
homeworkId: char("homework_id", { length: 36 }),
score: decimal("score", { precision: 6, scale: 2 }).notNull(),
totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(),
feedback: text("feedback"),
gradedBy: char("graded_by", { length: 36 }).notNull(),
schoolId: char("school_id", { length: 36 }).notNull(),
idempotencyKey: varchar("idempotency_key", { length: 128 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqStudentExam: uniqueIndex("uniq_student_exam").on(
table.studentId,
table.examId,
),
uniqStudentHw: uniqueIndex("uniq_student_hw").on(
table.studentId,
table.homeworkId,
),
uniqIdempotency: uniqueIndex("uniq_idempotency").on(table.idempotencyKey),
idxGradesStudentId: index("idx_grades_student_id").on(table.studentId),
idxGradesExamId: index("idx_grades_exam_id").on(table.examId),
idxGradesHomeworkId: index("idx_grades_homework_id").on(table.homeworkId),
idxGradesGradedBy: index("idx_grades_graded_by").on(table.gradedBy),
}),
);
// 成绩公式表(按 scope 优先级class > subject > school
export const gradeFormulas = mysqlTable(
"core_edu_grade_formulas",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
scope: varchar("scope", { length: 20 }).notNull(), // class | subject | school
scopeId: char("scope_id", { length: 36 }).notNull(),
formulaType: varchar("formula_type", { length: 20 }).notNull(),
weights: json("weights"),
customExpression: text("custom_expression"),
effectiveFrom: datetime("effective_from").notNull(),
effectiveTo: datetime("effective_to"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
idxGradeFormulasScope: index("idx_grade_formulas_scope").on(
table.scope,
table.scopeId,
),
idxGradeFormulasEffective: index("idx_grade_formulas_effective").on(
table.effectiveFrom,
table.effectiveTo,
),
}),
);
export type Grade = typeof grades.$inferSelect;
export type NewGrade = typeof grades.$inferInsert;
export type GradeFormula = typeof gradeFormulas.$inferSelect;
export type NewGradeFormula = typeof gradeFormulas.$inferInsert;

View File

@@ -1,34 +1,62 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { grades } from "./grades.schema.js";
import { gradesRepository } from "./grades.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Grade, NewGrade } from "./grades.schema.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
import type { Grade, NewGrade } from "./grades.schema.js";
export interface RecordGradeInput {
studentId: string;
examId?: string;
homeworkId?: string;
score: string;
totalScore: string;
feedback?: string;
gradedBy: string;
schoolId: string;
idempotencyKey?: string;
}
export interface UpdateGradeInput {
score?: string;
feedback?: string;
}
@Injectable()
export class GradesService {
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
if (!input.studentId || !input.score || !input.gradedBy) {
throw new ValidationError("studentId, score, gradedBy are required");
async recordGrade(
input: RecordGradeInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.studentId ||
!input.score ||
!input.gradedBy ||
!input.schoolId
) {
throw new ValidationError(
"studentId, score, gradedBy, schoolId are required",
);
}
if (!input.examId && !input.homeworkId) {
throw new ValidationError("Either examId or homeworkId must be provided");
}
// Idempotency check
if (input.idempotencyKey) {
const existing = await gradesRepository.findByIdempotencyKey(
input.idempotencyKey,
);
if (existing) {
return { id: existing.id };
}
}
const id = randomUUID();
const record: NewGrade = {
id,
@@ -36,25 +64,38 @@ export class GradesService {
examId: input.examId,
homeworkId: input.homeworkId,
score: input.score,
totalScore: input.totalScore,
feedback: input.feedback,
gradedBy: input.gradedBy,
schoolId: input.schoolId,
idempotencyKey: input.idempotencyKey,
};
const event = buildEvent({
aggregateId: id,
eventType: "grade.recorded",
payload: {
gradeId: id,
studentId: input.studentId,
score: input.score,
totalScore: input.totalScore,
examId: input.examId,
homeworkId: input.homeworkId,
},
userId,
});
await db.transaction(async (tx) => {
await tx.insert(grades).values(record);
await gradesRepository.create(record, tx);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "grade",
eventType: "grade.recorded",
payload: JSON.stringify({
id,
studentId: input.studentId,
score: input.score,
examId: input.examId,
homeworkId: input.homeworkId,
}),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
@@ -65,11 +106,7 @@ export class GradesService {
}
async getGrade(id: string): Promise<Grade> {
const [record] = await db
.select()
.from(grades)
.where(eq(grades.id, id))
.limit(1);
const record = await gradesRepository.findById(id);
if (!record) {
throw new NotFoundError(`Grade ${id} not found`);
}
@@ -77,14 +114,53 @@ export class GradesService {
}
async listByStudent(studentId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.studentId, studentId));
return gradesRepository.findByStudentId(studentId);
}
async listByExam(examId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.examId, examId));
return gradesRepository.findByExamId(examId);
}
async listByHomework(homeworkId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
return gradesRepository.findByHomeworkId(homeworkId);
}
async updateGrade(
id: string,
data: UpdateGradeInput,
updatedBy: string,
): Promise<void> {
const existing = await gradesRepository.findById(id);
if (!existing) {
throw new NotFoundError(`Grade ${id} not found`);
}
await db.transaction(async (tx) => {
await gradesRepository.update(id, data);
const event = buildEvent({
aggregateId: id,
eventType: "grade.updated",
payload: {
gradeId: id,
studentId: existing.studentId,
changes: data,
},
userId: updatedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "grade",
eventType: "grade.updated",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
}
}

View File

@@ -0,0 +1,91 @@
/**
* 作业状态机(纯函数)
*
* 状态流转ISSUE-003 仲裁 scheme A
* assigned → submitted → graded
*
* SubmissionStatus学生提交维度
* not_submitted → submitted → graded
*/
export type HomeworkStatus = "assigned" | "submitted" | "graded";
export type HomeworkAction = "submit" | "grade";
const TRANSITIONS: Record<
HomeworkStatus,
Partial<Record<HomeworkAction, HomeworkStatus>>
> = {
assigned: { submit: "submitted" },
submitted: { grade: "graded" },
graded: {},
};
export function canTransition(
from: HomeworkStatus,
action: HomeworkAction,
): boolean {
return TRANSITIONS[from]?.[action] !== undefined;
}
export function transition(
from: HomeworkStatus,
action: HomeworkAction,
): HomeworkStatus {
const next = TRANSITIONS[from]?.[action];
if (!next) {
throw new Error(
`Invalid homework state transition: ${from} --${action}-->`,
);
}
return next;
}
export function isTerminal(status: HomeworkStatus): boolean {
return status === "graded";
}
// SubmissionStatus 状态机(用于 exam_submissions / homework_submissions 表的 status 字段)
export type SubmissionStatus = "not_submitted" | "submitted" | "graded";
export type SubmissionAction = "submit" | "grade";
const SUBMISSION_TRANSITIONS: Record<
SubmissionStatus,
Partial<Record<SubmissionAction, SubmissionStatus>>
> = {
not_submitted: { submit: "submitted" },
submitted: { grade: "graded" },
graded: {},
};
export function canTransitionSubmission(
from: SubmissionStatus,
action: SubmissionAction,
): boolean {
return SUBMISSION_TRANSITIONS[from]?.[action] !== undefined;
}
export function transitionSubmission(
from: SubmissionStatus,
action: SubmissionAction,
): SubmissionStatus {
const next = SUBMISSION_TRANSITIONS[from]?.[action];
if (!next) {
throw new Error(
`Invalid submission state transition: ${from} --${action}-->`,
);
}
return next;
}
export const HOMEWORK_STATUSES: readonly HomeworkStatus[] = [
"assigned",
"submitted",
"graded",
] as const;
export const SUBMISSION_STATUSES: readonly SubmissionStatus[] = [
"not_submitted",
"submitted",
"graded",
] as const;

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { z } from "zod";
import {
HomeworkService,
type AssignHomeworkInput,
@@ -8,26 +9,70 @@ import {
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
UnauthorizedError,
ValidationError,
} from "../shared/errors/application-error.js";
@Controller("homework")
const assignHomeworkSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().optional(),
dueDate: z.string().min(1),
gracePeriod: z.number().int().positive().optional(),
schoolId: z.string().min(1),
});
const answerInputSchema = z.object({
questionId: z.string().min(1),
answer: z.string(),
});
const scoreInputSchema = z.object({
questionId: z.string().min(1),
score: z.string().min(1),
teacherComment: z.string().optional(),
});
const submitHomeworkSchema = z.object({
studentId: z.string().min(1),
answers: z.array(answerInputSchema).default([]),
});
const gradeHomeworkSchema = z.object({
submissionId: z.string().min(1),
scores: z.array(scoreInputSchema).min(1),
feedback: z.string().optional(),
});
@Controller("v1/homework")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign(
@Body() body: AssignHomeworkInput,
@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 result = await this.homeworkService.assignHomework({
...body,
const parsed = assignHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid homework input",
parsed.error.flatten(),
);
}
const input: AssignHomeworkInput = {
...parsed.data,
dueDate: new Date(parsed.data.dueDate),
createdBy: userId,
});
};
const result = await this.homeworkService.assignHomework(input, userId);
return { success: true, data: result };
}
@@ -55,8 +100,42 @@ export class HomeworkController {
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.homeworkService.submitHomework(id);
return { success: true, data: { success: true } };
@Body() body: unknown,
): Promise<{ success: true; data: { submissionId: string } }> {
const parsed = submitHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid submit input", parsed.error.flatten());
}
const result = await this.homeworkService.submitHomework(
id,
parsed.data.studentId,
parsed.data.answers,
);
return { success: true, data: result };
}
@Post(":id/grade")
@RequirePermission(Permissions.HOMEWORK_GRADE)
async grade(
@Param("id") id: string,
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { totalScore: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = gradeHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid grade input", parsed.error.flatten());
}
const result = await this.homeworkService.gradeHomework(
id,
parsed.data.submissionId,
parsed.data.scores,
parsed.data.feedback,
userId,
);
return { success: true, data: result };
}
}

View File

@@ -0,0 +1,100 @@
import { eq, and } from "drizzle-orm";
import { db } from "../config/database.js";
import {
homework,
homeworkSubmissions,
homeworkAnswers,
type Homework,
type NewHomework,
type HomeworkSubmission,
type NewHomeworkSubmission,
type HomeworkAnswer,
type NewHomeworkAnswer,
} from "./homework.schema.js";
export class HomeworkRepository {
async findById(id: string): Promise<Homework | undefined> {
const [result] = await db
.select()
.from(homework)
.where(eq(homework.id, id))
.limit(1);
return result;
}
async findByClassId(classId: string): Promise<Homework[]> {
return db.select().from(homework).where(eq(homework.classId, classId));
}
async update(id: string, data: Partial<NewHomework>): Promise<void> {
await db.update(homework).set(data).where(eq(homework.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(homework).where(eq(homework.id, id));
}
// Submissions
async findSubmission(
homeworkId: string,
studentId: string,
): Promise<HomeworkSubmission | undefined> {
const [result] = await db
.select()
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.homeworkId, homeworkId),
eq(homeworkSubmissions.studentId, studentId),
),
)
.limit(1);
return result;
}
async findSubmissionById(
id: string,
): Promise<HomeworkSubmission | undefined> {
const [result] = await db
.select()
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.id, id))
.limit(1);
return result;
}
async createSubmission(
submission: NewHomeworkSubmission,
tx: typeof db = db,
): Promise<void> {
await tx.insert(homeworkSubmissions).values(submission);
}
async updateSubmission(
id: string,
data: Partial<NewHomeworkSubmission>,
): Promise<void> {
await db
.update(homeworkSubmissions)
.set(data)
.where(eq(homeworkSubmissions.id, id));
}
// Answers
async createAnswers(
answers: NewHomeworkAnswer[],
tx: typeof db = db,
): Promise<void> {
if (answers.length === 0) return;
await tx.insert(homeworkAnswers).values(answers);
}
async listAnswers(submissionId: string): Promise<HomeworkAnswer[]> {
return db
.select()
.from(homeworkAnswers)
.where(eq(homeworkAnswers.submissionId, submissionId));
}
}
export const homeworkRepository = new HomeworkRepository();

View File

@@ -5,19 +5,98 @@ import {
timestamp,
char,
datetime,
} from 'drizzle-orm/mysql-core';
int,
decimal,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
export const homework = mysqlTable('core_edu_homework', {
id: char('id', { length: 36 }).notNull().primaryKey(),
classId: char('class_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
dueDate: datetime('due_date').notNull(),
status: varchar('status', { length: 20 }).notNull().default('assigned'),
createdBy: char('created_by', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
// 作业主表
export const homework = mysqlTable(
"core_edu_homework",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
classId: char("class_id", { length: 36 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
description: text("description"),
dueDate: datetime("due_date").notNull(),
gracePeriod: int("grace_period").notNull().default(300), // 秒,仲裁默认 300
status: varchar("status", { length: 20 }).notNull().default("assigned"),
schoolId: char("school_id", { length: 36 }).notNull(),
createdBy: char("created_by", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxHomeworkClassStatus: index("idx_homework_class_status").on(
table.classId,
table.status,
),
idxHomeworkCreatedBy: index("idx_homework_created_by").on(table.createdBy),
}),
);
// 作业提交表
export const homeworkSubmissions = mysqlTable(
"core_edu_homework_submissions",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
homeworkId: char("homework_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
status: varchar("status", { length: 20 })
.notNull()
.default("not_submitted"),
submittedAt: datetime("submitted_at"),
gradedAt: datetime("graded_at"),
gradedBy: char("graded_by", { length: 36 }),
totalScore: decimal("total_score", { precision: 6, scale: 2 }),
feedback: text("feedback"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqHwStudent: uniqueIndex("uniq_hw_student").on(
table.homeworkId,
table.studentId,
),
idxHwSubmissionsHomeworkId: index("idx_hw_submissions_homework_id").on(
table.homeworkId,
),
idxHwSubmissionsStudentId: index("idx_hw_submissions_student_id").on(
table.studentId,
),
}),
);
// 作业答题表
export const homeworkAnswers = mysqlTable(
"core_edu_homework_answers",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
submissionId: char("submission_id", { length: 36 }).notNull(),
questionId: char("question_id", { length: 36 }).notNull(),
answer: text("answer"),
score: decimal("score", { precision: 6, scale: 2 }),
teacherComment: text("teacher_comment"),
isCorrect: boolean("is_correct"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxHwAnswersSubmissionId: index("idx_hw_answers_submission_id").on(
table.submissionId,
),
idxHwAnswersQuestionId: index("idx_hw_answers_question_id").on(
table.questionId,
),
}),
);
export type Homework = typeof homework.$inferSelect;
export type NewHomework = typeof homework.$inferInsert;
export type HomeworkSubmission = typeof homeworkSubmissions.$inferSelect;
export type NewHomeworkSubmission = typeof homeworkSubmissions.$inferInsert;
export type HomeworkAnswer = typeof homeworkAnswers.$inferSelect;
export type NewHomeworkAnswer = typeof homeworkAnswers.$inferInsert;

View File

@@ -1,56 +1,97 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { homework } from "./homework.schema.js";
import { homeworkRepository } from "./homework.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Homework, NewHomework } from "./homework.schema.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import { acquireLock, releaseLock } from "../config/redis.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Homework, NewHomework } from "./homework.schema.js";
export interface AssignHomeworkInput {
classId: string;
subjectId: string;
title: string;
description?: string;
dueDate: Date | string;
gracePeriod?: number;
schoolId: string;
createdBy: string;
}
export interface AnswerInput {
questionId: string;
answer: string;
}
export interface ScoreInput {
questionId: string;
score: string;
teacherComment?: string;
}
const LOCK_TTL_SECONDS = 30;
@Injectable()
export class HomeworkService {
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError("classId, title, createdBy are required");
async assignHomework(
input: AssignHomeworkInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.classId ||
!input.title ||
!input.createdBy ||
!input.subjectId
) {
throw new ValidationError(
"classId, subjectId, title, createdBy are required",
);
}
const id = randomUUID();
const record: NewHomework = {
id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
description: input.description,
// Drizzle datetime 列需要 Date 对象HTTP 请求体里 dueDate 是 ISO 字符串。
dueDate:
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
gracePeriod: input.gracePeriod ?? 300,
status: "assigned",
schoolId: input.schoolId,
createdBy: input.createdBy,
};
const event = buildEvent({
aggregateId: id,
eventType: "homework.assigned",
payload: {
homeworkId: id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
},
userId,
});
await db.transaction(async (tx) => {
await tx.insert(homework).values(record);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "homework",
eventType: "homework.assigned",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
@@ -61,11 +102,7 @@ export class HomeworkService {
}
async getHomework(id: string): Promise<Homework> {
const [record] = await db
.select()
.from(homework)
.where(eq(homework.id, id))
.limit(1);
const record = await homeworkRepository.findById(id);
if (!record) {
throw new NotFoundError(`Homework ${id} not found`);
}
@@ -73,31 +110,178 @@ export class HomeworkService {
}
async listByClass(classId: string): Promise<Homework[]> {
return db.select().from(homework).where(eq(homework.classId, classId));
return homeworkRepository.findByClassId(classId);
}
async submitHomework(id: string): Promise<void> {
const existing = await this.getHomework(id);
if (existing.status === "submitted") {
throw new ValidationError(`Homework ${id} already submitted`);
async submitHomework(
homeworkId: string,
studentId: string,
answers: AnswerInput[],
): Promise<{ submissionId: string }> {
const hw = await homeworkRepository.findById(homeworkId);
if (!hw) {
throw new NotFoundError(`Homework ${homeworkId} not found`);
}
// Redis distributed lock for idempotency (P2 feature)
const lockKey = `hw:submit:${homeworkId}:${studentId}`;
const locked = await acquireLock(lockKey, LOCK_TTL_SECONDS);
if (!locked) {
// Lock unavailable - check if submission already exists
const existing = await homeworkRepository.findSubmission(
homeworkId,
studentId,
);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted homework ${homeworkId}`,
);
}
throw new ConflictError("Submission in progress, please retry");
}
try {
// Double-check after acquiring lock
const existing = await homeworkRepository.findSubmission(
homeworkId,
studentId,
);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted homework ${homeworkId}`,
);
}
const submissionId = randomUUID();
const now = new Date();
// Check grace period
const dueWithGrace = new Date(
hw.dueDate.getTime() + hw.gracePeriod * 1000,
);
const isLate = now > dueWithGrace;
await db.transaction(async (tx) => {
await homeworkRepository.createSubmission(
{
id: submissionId,
homeworkId,
studentId,
status: "submitted",
submittedAt: now,
},
tx,
);
// Insert answers
if (answers.length > 0) {
const answerRecords = answers.map((a) => ({
id: randomUUID(),
submissionId,
questionId: a.questionId,
answer: a.answer,
}));
await homeworkRepository.createAnswers(answerRecords, tx);
}
const event = buildEvent({
aggregateId: homeworkId,
eventType: "homework.submitted",
payload: {
homeworkId,
submissionId,
studentId,
isLate,
},
userId: studentId,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: homeworkId,
aggregateType: "homework",
eventType: "homework.submitted",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { submissionId };
} finally {
await releaseLock(lockKey);
}
}
async gradeHomework(
homeworkId: string,
submissionId: string,
scores: ScoreInput[],
feedback: string | undefined,
gradedBy: string,
): Promise<{ totalScore: string }> {
const hw = await homeworkRepository.findById(homeworkId);
if (!hw) {
throw new NotFoundError(`Homework ${homeworkId} not found`);
}
const submission =
await homeworkRepository.findSubmissionById(submissionId);
if (!submission || submission.homeworkId !== homeworkId) {
throw new NotFoundError(
`Submission ${submissionId} not found for homework ${homeworkId}`,
);
}
if (submission.status === "graded") {
throw new ConflictError(`Submission ${submissionId} already graded`);
}
const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0);
const totalScoreStr = totalScore.toFixed(2);
await db.transaction(async (tx) => {
await tx
.update(homework)
.set({ status: "submitted" })
.where(eq(homework.id, id));
await homeworkRepository.updateSubmission(submissionId, {
status: "graded",
gradedAt: new Date(),
gradedBy,
totalScore: totalScore.toFixed(2),
feedback,
});
const event = buildEvent({
aggregateId: homeworkId,
eventType: "homework.graded",
payload: {
homeworkId,
submissionId,
studentId: submission.studentId,
totalScore: totalScoreStr,
},
userId: gradedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
eventId: event.event_id,
aggregateId: homeworkId,
aggregateType: "homework",
eventType: "homework.submitted",
payload: JSON.stringify({ id }),
eventType: "homework.graded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { totalScore: totalScoreStr };
}
}

View File

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

View File

@@ -0,0 +1,187 @@
import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { Injectable, type OnModuleInit } from "@nestjs/common";
import { db } from "../config/database.js";
import { teacherAssociations } from "../classes/teacher-associations.schema.js";
import { classes } from "../classes/classes.schema.js";
import { consumer } from "../config/kafka.js";
import { logger } from "../shared/observability/logger.js";
const IAM_TOPICS = [
"edu.iam.teacher.assigned",
"edu.iam.class.created",
] as const;
interface TeacherAssignedEvent {
teacher_id: string;
class_id: string;
subject_id?: string;
school_id: string;
}
interface ClassCreatedEvent {
class_id: string;
name: string;
grade_id: string;
head_teacher_id?: string;
description?: string;
}
@Injectable()
export class IamConsumerService implements OnModuleInit {
private isRunning = false;
async onModuleInit(): Promise<void> {
// Subscribe to IAM events (non-blocking - failures don't prevent service start)
void this.start().catch((err) => {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"IAM consumer failed to start",
);
});
}
private async start(): Promise<void> {
if (this.isRunning) return;
try {
for (const topic of IAM_TOPICS) {
await consumer.subscribe({ topic, fromBeginning: false });
}
this.isRunning = true;
logger.info({ topics: [...IAM_TOPICS] }, "IAM consumer subscribed");
await consumer.run({
eachMessage: async ({ topic, message }) => {
try {
const value = message.value?.toString();
if (!value) return;
const payload = JSON.parse(value) as Record<string, unknown>;
if (topic === "edu.iam.teacher.assigned") {
await this.handleTeacherAssigned(
payload as unknown as TeacherAssignedEvent,
);
} else if (topic === "edu.iam.class.created") {
await this.handleClassCreated(
payload as unknown as ClassCreatedEvent,
);
}
} catch (err) {
logger.error(
{
err: err instanceof Error ? err.message : String(err),
topic,
offset: message.offset,
},
"IAM consumer message processing failed",
);
}
},
});
} catch (err) {
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"IAM consumer start failed",
);
}
}
/**
* Handle teacher.assigned event - upsert teacher_associations (idempotent via unique index).
*/
private async handleTeacherAssigned(
event: TeacherAssignedEvent,
): Promise<void> {
if (!event.teacher_id || !event.class_id || !event.school_id) {
logger.warn(
{ event },
"Invalid teacher.assigned event: missing required fields",
);
return;
}
// Check existing (idempotent)
const conditions = [
eq(teacherAssociations.teacherId, event.teacher_id),
eq(teacherAssociations.classId, event.class_id),
];
if (event.subject_id) {
conditions.push(eq(teacherAssociations.subjectId, event.subject_id));
}
const [existing] = await db
.select()
.from(teacherAssociations)
.where(and(...conditions))
.limit(1);
if (existing) {
logger.debug(
{ teacherId: event.teacher_id, classId: event.class_id },
"Teacher association already exists, skipping",
);
return;
}
await db.insert(teacherAssociations).values({
id: randomUUID(),
teacherId: event.teacher_id,
classId: event.class_id,
subjectId: event.subject_id,
schoolId: event.school_id,
source: "iam_event",
});
logger.info(
{ teacherId: event.teacher_id, classId: event.class_id },
"Teacher association created from IAM event",
);
}
/**
* Handle class.created event - sync class record (idempotent via primary key).
*/
private async handleClassCreated(event: ClassCreatedEvent): Promise<void> {
if (!event.class_id || !event.name || !event.grade_id) {
logger.warn(
{ event },
"Invalid class.created event: missing required fields",
);
return;
}
// Check existing
const [existing] = await db
.select()
.from(classes)
.where(eq(classes.id, event.class_id))
.limit(1);
if (existing) {
// Update if changed
await db
.update(classes)
.set({
name: event.name,
gradeId: event.grade_id,
headTeacherId: event.head_teacher_id,
description: event.description,
})
.where(eq(classes.id, event.class_id));
logger.debug({ classId: event.class_id }, "Class updated from IAM event");
return;
}
await db.insert(classes).values({
id: event.class_id,
name: event.name,
gradeId: event.grade_id,
headTeacherId: event.head_teacher_id,
description: event.description,
});
logger.info({ classId: event.class_id }, "Class created from IAM event");
}
}

View File

@@ -2,6 +2,7 @@ import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.js";
import { connectKafka, disconnectKafka } from "./config/kafka.js";
import { connectRedis, disconnectRedis } from "./config/redis.js";
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
@@ -27,33 +28,33 @@ async function bootstrap(): Promise<void> {
// publisher will retry sends and messages stay pending until Kafka recovers.
void connectKafka();
// Connect Redis for distributed lock (homework submission idempotency).
// Non-blocking: if Redis is unavailable, service still starts; lock-based
// operations will fall back to DB unique index for idempotency.
void connectRedis();
// Start the transactional outbox publisher - polls pending messages
// and publishes them to Kafka topics defined in TOPIC_MAP.
await outboxPublisher.start();
await app.listen(env.PORT);
logger.info(
{ port: env.PORT, service: "core-edu" },
"CoreEdu service is listening",
{ port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" },
"CoreEdu service is listening (HTTP; gRPC port reserved for P3)",
);
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "Shutting down gracefully...");
await outboxPublisher.stop();
await disconnectRedis();
await disconnectKafka();
await shutdownTracer();
await app.close();
process.exit(0);
});
};
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await outboxPublisher.stop();
await disconnectKafka();
await shutdownTracer();
await app.close();
process.exit(0);
});
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}
void bootstrap();

View File

@@ -9,16 +9,35 @@ import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
// Exam permissions
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
EXAM_PUBLISH: "CORE_EDU_EXAM_PUBLISH" as const,
EXAM_SUBMIT: "CORE_EDU_EXAM_SUBMIT" as const,
EXAM_GRADE: "CORE_EDU_EXAM_GRADE" as const,
// Homework permissions
HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
HOMEWORK_GRADE: "CORE_EDU_HOMEWORK_GRADE" as const,
// Grade permissions
GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
GRADE_READ: "CORE_EDU_GRADE_READ" as const,
GRADE_UPDATE: "CORE_EDU_GRADE_UPDATE" as const,
// Attendance permissions
ATTENDANCE_CREATE: "CORE_EDU_ATTENDANCE_CREATE" as const,
ATTENDANCE_READ: "CORE_EDU_ATTENDANCE_READ" as const,
// Class permissions
CLASS_READ: "CORE_EDU_CLASS_READ" as const,
// Course permissions
COURSE_CREATE: "CORE_EDU_COURSE_CREATE" as const,
COURSE_READ: "CORE_EDU_COURSE_READ" as const,
// Schedule permissions
SCHEDULE_CREATE: "CORE_EDU_SCHEDULE_CREATE" as const,
SCHEDULE_READ: "CORE_EDU_SCHEDULE_READ" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
@@ -33,29 +52,56 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_SUBMIT,
Permissions.EXAM_GRADE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.HOMEWORK_GRADE,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
Permissions.GRADE_UPDATE,
Permissions.ATTENDANCE_CREATE,
Permissions.ATTENDANCE_READ,
Permissions.CLASS_READ,
Permissions.COURSE_CREATE,
Permissions.COURSE_READ,
Permissions.SCHEDULE_CREATE,
Permissions.SCHEDULE_READ,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_GRADE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.HOMEWORK_GRADE,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
Permissions.GRADE_UPDATE,
Permissions.ATTENDANCE_CREATE,
Permissions.ATTENDANCE_READ,
Permissions.CLASS_READ,
Permissions.COURSE_CREATE,
Permissions.COURSE_READ,
Permissions.SCHEDULE_CREATE,
Permissions.SCHEDULE_READ,
],
student: [
Permissions.EXAM_READ,
Permissions.EXAM_SUBMIT,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_READ,
Permissions.ATTENDANCE_READ,
Permissions.CLASS_READ,
Permissions.COURSE_READ,
Permissions.SCHEDULE_READ,
],
};

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);
}
}

View File

@@ -5,9 +5,19 @@ export enum CoreEduErrorCode {
FORBIDDEN = "CORE_EDU_FORBIDDEN",
CONFLICT = "CORE_EDU_CONFLICT",
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
// Domain-specific errors
EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
EXAM_INVALID_TRANSITION = "CORE_EDU_EXAM_INVALID_TRANSITION",
HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
HOMEWORK_INVALID_TRANSITION = "CORE_EDU_HOMEWORK_INVALID_TRANSITION",
GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
GRADE_DUPLICATE = "CORE_EDU_GRADE_DUPLICATE",
ATTENDANCE_NOT_FOUND = "CORE_EDU_ATTENDANCE_NOT_FOUND",
ATTENDANCE_DUPLICATE = "CORE_EDU_ATTENDANCE_DUPLICATE",
CLASS_NOT_FOUND = "CORE_EDU_CLASS_NOT_FOUND",
COURSE_NOT_FOUND = "CORE_EDU_COURSE_NOT_FOUND",
SCHEDULE_NOT_FOUND = "CORE_EDU_SCHEDULE_NOT_FOUND",
SCHEDULE_CONFLICT = "CORE_EDU_SCHEDULE_CONFLICT",
OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
}

View File

@@ -1,13 +1,22 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { db } from "../../config/database.js";
import { isRedisHealthy } from "../../config/redis.js";
import { isKafkaConnected } from "../../config/kafka.js";
const SERVICE_NAME = "core-edu";
interface HealthResponse {
status: string;
service: string;
timestamp: string;
checks?: Record<string, string>;
}
@Controller()
export class HealthController {
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
liveness(): HealthResponse {
return {
status: "ok",
service: SERVICE_NAME,
@@ -16,29 +25,51 @@ export class HealthController {
}
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
async readiness(): Promise<HealthResponse> {
const checks: Record<string, string> = {};
let allHealthy = true;
// DB check
try {
await db.execute(sql`SELECT 1`);
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
checks.db = "ok";
} catch (error) {
checks.db = error instanceof Error ? error.message : "unreachable";
allHealthy = false;
}
// Redis check (optional - degrade gracefully)
try {
const redisOk = await isRedisHealthy();
checks.redis = redisOk ? "ok" : "disabled";
} catch {
checks.redis = "disabled";
}
// Kafka check (optional - degrade gracefully)
try {
checks.kafka = isKafkaConnected() ? "ok" : "disconnected";
} catch {
checks.kafka = "unknown";
}
if (!allHealthy) {
throw new HttpException(
{
status: "error",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error:
error instanceof Error ? error.message : "database unreachable",
checks,
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
checks,
};
}
}

View File

@@ -5,6 +5,9 @@ import {
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
import { disconnectRedis } from "../../config/redis.js";
import { disconnectKafka } from "../../config/kafka.js";
import { outboxPublisher } from "../outbox/outbox.publisher.js";
const SERVICE_NAME = "core-edu";
@@ -21,6 +24,9 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
try {
await outboxPublisher.stop();
await disconnectRedis();
await disconnectKafka();
await closeDb();
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} catch (error) {

View File

@@ -0,0 +1,61 @@
import { randomUUID } from "node:crypto";
/**
* Outbox 事件构建器
*
* 所有事件 payload 必须包含events.proto 仲裁 §2.1
* - event_id: UUID幂等消费
* - occurred_at: 业务时间戳ms epoch
* - schema_version: 默认 "v1"
* - metadata: { traceId, userId }
*/
export interface EventMetadata {
schema_version: string;
trace_id: string;
user_id: string;
}
export interface OutboxEvent<T = Record<string, unknown>> {
event_id: string;
aggregate_id: string;
event_type: string;
occurred_at: number;
payload: T;
metadata: EventMetadata;
}
export interface BuildEventInput {
aggregateId: string;
eventType: string;
payload: Record<string, unknown>;
userId?: string;
traceId?: string;
schemaVersion?: string;
}
/**
* 构建标准 Outbox 事件(含 event_id / occurred_at / schema_version / metadata
* 返回的对象可直接 JSON.stringify 作为 outbox.payload 存储。
*/
export function buildEvent(input: BuildEventInput): OutboxEvent {
return {
event_id: randomUUID(),
aggregate_id: input.aggregateId,
event_type: input.eventType,
occurred_at: Date.now(),
payload: input.payload,
metadata: {
schema_version: input.schemaVersion ?? "v1",
trace_id: input.traceId ?? "unknown",
user_id: input.userId ?? "system",
},
};
}
/**
* 将 OutboxEvent 序列化为 JSON 字符串(用于 outbox.payload 列)。
*/
export function serializeEvent(event: OutboxEvent): string {
return JSON.stringify(event);
}

View File

@@ -1,30 +1,46 @@
import { logger } from '../observability/logger.js';
import { producer } from '../../config/kafka.js';
import { outboxRepository } from './outbox.repository.js';
import type { OutboxMessage } from './outbox.schema.js';
import { logger } from "../observability/logger.js";
import { producer } from "../../config/kafka.js";
import { outboxRepository } from "./outbox.repository.js";
import type { OutboxMessage } from "./outbox.schema.js";
/**
* TOPIC_MAP: 事件类型 → Kafka topic
*
* 仲裁依据ISSUE-002 / ISSUE-004 / events.proto 头注释
* 命名规范edu.teaching.<aggregate>.<action>
*/
const TOPIC_MAP: Record<string, string> = {
'exam.created': 'edu.exam.events',
'exam.updated': 'edu.exam.events',
'exam.deleted': 'edu.exam.events',
'homework.assigned': 'edu.homework.events',
'homework.submitted': 'edu.homework.events',
'homework.graded': 'edu.homework.events',
'grade.recorded': 'edu.grade.events',
'grade.updated': 'edu.grade.events',
'class.transferred': 'edu.class.events',
// Exam events
"exam.created": "edu.teaching.exam.created",
"exam.updated": "edu.teaching.exam.updated",
"exam.published": "edu.teaching.exam.published",
"exam.submitted": "edu.teaching.exam.submitted",
"exam.graded": "edu.teaching.exam.graded",
"exam.deleted": "edu.teaching.exam.deleted",
// Homework events
"homework.assigned": "edu.teaching.homework.assigned",
"homework.submitted": "edu.teaching.homework.submitted",
"homework.graded": "edu.teaching.homework.graded",
// Grade events
"grade.recorded": "edu.teaching.grade.recorded",
"grade.updated": "edu.teaching.grade.updated",
// Attendance events
"attendance.recorded": "edu.teaching.attendance.recorded",
// Class events (ISSUE-004: 统一为 edu.teaching.class.transferred)
"class.transferred": "edu.teaching.class.transferred",
};
const POLL_INTERVAL_MS = 5000;
const BATCH_SIZE = 100;
const MAX_RETRY = 5;
const RETRY_BACKOFF_BASE_MS = 1000;
export class OutboxPublisher {
private intervalId: NodeJS.Timeout | null = null;
private isPolling = false;
async start(): Promise<void> {
logger.info('OutboxPublisher started');
logger.info("OutboxPublisher started");
this.intervalId = setInterval(() => {
void this.poll();
}, POLL_INTERVAL_MS);
@@ -35,7 +51,7 @@ export class OutboxPublisher {
clearInterval(this.intervalId);
this.intervalId = null;
}
logger.info('OutboxPublisher stopped');
logger.info("OutboxPublisher stopped");
}
private async poll(): Promise<void> {
@@ -47,14 +63,14 @@ export class OutboxPublisher {
await this.publish(message);
}
} catch (error) {
logger.error({ error }, 'Outbox poll failed');
logger.error({ error }, "Outbox poll failed");
} finally {
this.isPolling = false;
}
}
private async publish(message: OutboxMessage): Promise<void> {
const topic = TOPIC_MAP[message.eventType] ?? 'edu.fallback.events';
const topic = TOPIC_MAP[message.eventType] ?? "edu.teaching.fallback";
try {
await producer.send({
topic,
@@ -65,21 +81,32 @@ export class OutboxPublisher {
headers: {
eventType: message.eventType,
aggregateType: message.aggregateType,
eventId: message.eventId,
},
},
],
});
await outboxRepository.markProcessed(message.id);
logger.info(
{ id: message.id, eventType: message.eventType, topic },
'Outbox message published',
{
id: message.id,
eventId: message.eventId,
eventType: message.eventType,
topic,
},
"Outbox message published",
);
} catch (error) {
logger.error({ error, id: message.id }, 'Outbox publish failed');
if (message.retryCount + 1 >= MAX_RETRY) {
logger.error(
{ error, id: message.id, eventId: message.eventId },
"Outbox publish failed",
);
const nextRetry = message.retryCount + 1;
if (nextRetry >= MAX_RETRY) {
await outboxRepository.markFailed(message.id);
} else {
await outboxRepository.incrementRetry(message.id);
const backoff = RETRY_BACKOFF_BASE_MS * Math.pow(2, nextRetry);
await outboxRepository.incrementRetry(message.id, backoff);
}
}
}

View File

@@ -1,6 +1,10 @@
import { eq, sql } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { outbox, type OutboxMessage, type NewOutboxMessage } from './outbox.schema.js';
import { eq, sql, and, lte, isNull, or } from "drizzle-orm";
import { db } from "../../config/database.js";
import {
outbox,
type OutboxMessage,
type NewOutboxMessage,
} from "./outbox.schema.js";
type DbClient = typeof db;
@@ -9,33 +13,45 @@ export class OutboxRepository {
await tx.insert(outbox).values(message);
}
/**
* 查找待发布消息:
* - status = 'pending'
* - next_retry_at IS NULL 或 next_retry_at <= NOW()
* 按 created_at 升序,取 limit 条
*/
async findPending(limit: number = 100): Promise<OutboxMessage[]> {
return db
.select()
.from(outbox)
.where(eq(outbox.status, 'pending'))
.where(
and(
eq(outbox.status, "pending"),
or(lte(outbox.nextRetryAt, new Date()), isNull(outbox.nextRetryAt)),
),
)
.limit(limit);
}
async markProcessed(id: string): Promise<void> {
await db
.update(outbox)
.set({ status: 'processed', processedAt: new Date() })
.set({ status: "processed", processedAt: new Date() })
.where(eq(outbox.id, id));
}
async incrementRetry(id: string): Promise<void> {
async incrementRetry(id: string, backoffMs: number): Promise<void> {
const nextRetryAt = new Date(Date.now() + backoffMs);
await db
.update(outbox)
.set({ retryCount: sql`${outbox.retryCount} + 1` })
.set({
retryCount: sql`${outbox.retryCount} + 1`,
nextRetryAt,
})
.where(eq(outbox.id, id));
}
async markFailed(id: string): Promise<void> {
await db
.update(outbox)
.set({ status: 'failed' })
.where(eq(outbox.id, id));
await db.update(outbox).set({ status: "failed" }).where(eq(outbox.id, id));
}
}

View File

@@ -1,16 +1,44 @@
import { mysqlTable, varchar, text, timestamp, char, bigint } from 'drizzle-orm/mysql-core';
import {
mysqlTable,
varchar,
text,
timestamp,
char,
bigint,
datetime,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
export const outbox = mysqlTable('core_edu_outbox', {
id: char('id', { length: 36 }).notNull().primaryKey(),
aggregateId: char('aggregate_id', { length: 36 }).notNull(),
aggregateType: varchar('aggregate_type', { length: 50 }).notNull(),
eventType: varchar('event_type', { length: 100 }).notNull(),
payload: text('payload').notNull(),
status: varchar('status', { length: 20 }).notNull().default('pending'),
retryCount: bigint('retry_count', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
processedAt: timestamp('processed_at'),
});
// 事务性发件箱Outbox 模式)
export const outbox = mysqlTable(
"core_edu_outbox",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
eventId: char("event_id", { length: 36 }).notNull(),
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(),
eventType: varchar("event_type", { length: 100 }).notNull(),
occurredAt: datetime("occurred_at").notNull(),
payload: text("payload").notNull(),
status: varchar("status", { length: 20 }).notNull().default("pending"),
retryCount: bigint("retry_count", { mode: "number" }).notNull().default(0),
nextRetryAt: datetime("next_retry_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
processedAt: timestamp("processed_at"),
},
(table) => ({
uniqEventId: uniqueIndex("uniq_event_id").on(table.eventId),
idxOutboxStatusRetry: index("idx_outbox_status_retry").on(
table.status,
table.nextRetryAt,
),
idxOutboxAggregate: index("idx_outbox_aggregate").on(
table.aggregateType,
table.aggregateId,
),
}),
);
export type OutboxMessage = typeof outbox.$inferSelect;
export type NewOutboxMessage = typeof outbox.$inferInsert;