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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
services/core-edu/src/attendance/attendance-status.ts
Normal file
20
services/core-edu/src/attendance/attendance-status.ts
Normal 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);
|
||||
}
|
||||
83
services/core-edu/src/attendance/attendance.controller.ts
Normal file
83
services/core-edu/src/attendance/attendance.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/attendance/attendance.module.ts
Normal file
10
services/core-edu/src/attendance/attendance.module.ts
Normal 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 {}
|
||||
74
services/core-edu/src/attendance/attendance.repository.ts
Normal file
74
services/core-edu/src/attendance/attendance.repository.ts
Normal 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();
|
||||
40
services/core-edu/src/attendance/attendance.schema.ts
Normal file
40
services/core-edu/src/attendance/attendance.schema.ts
Normal 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;
|
||||
116
services/core-edu/src/attendance/attendance.service.ts
Normal file
116
services/core-edu/src/attendance/attendance.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
services/core-edu/src/classes/classes.controller.ts
Normal file
64
services/core-edu/src/classes/classes.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
47
services/core-edu/src/classes/classes.repository.ts
Normal file
47
services/core-edu/src/classes/classes.repository.ts
Normal 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();
|
||||
31
services/core-edu/src/classes/classes.schema.ts
Normal file
31
services/core-edu/src/classes/classes.schema.ts
Normal 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;
|
||||
39
services/core-edu/src/classes/classes.service.ts
Normal file
39
services/core-edu/src/classes/classes.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
35
services/core-edu/src/classes/teacher-associations.schema.ts
Normal file
35
services/core-edu/src/classes/teacher-associations.schema.ts
Normal 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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
97
services/core-edu/src/config/redis.ts
Normal file
97
services/core-edu/src/config/redis.ts
Normal 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 {
|
||||
// 释放失败不影响业务(锁会自动过期)
|
||||
}
|
||||
}
|
||||
60
services/core-edu/src/exams/exam-state-machine.ts
Normal file
60
services/core-edu/src/exams/exam-state-machine.ts
Normal 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 决策 #7:archived 仅做软删除,不物理删除)
|
||||
*/
|
||||
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;
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
services/core-edu/src/grades/grade-calculator.ts
Normal file
96
services/core-edu/src/grades/grade-calculator.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 成绩计算器(纯函数)
|
||||
*
|
||||
* 支持的公式类型:
|
||||
* - weighted_average: 加权平均(weights JSON: { examId/homeworkId: weight })
|
||||
* - sum: 直接求和
|
||||
* - custom: 自定义表达式(P3 仅预留,不执行)
|
||||
*
|
||||
* Scope 优先级(ISSUE-006 决策 #3):class > 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
64
services/core-edu/src/grades/grades.repository.ts
Normal file
64
services/core-edu/src/grades/grades.repository.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
91
services/core-edu/src/homework/homework-state-machine.ts
Normal file
91
services/core-edu/src/homework/homework-state-machine.ts
Normal 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;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
100
services/core-edu/src/homework/homework.repository.ts
Normal file
100
services/core-edu/src/homework/homework.repository.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IamConsumerService } from "./iam-consumer.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [IamConsumerService],
|
||||
exports: [IamConsumerService],
|
||||
})
|
||||
export class IamConsumerModule {}
|
||||
187
services/core-edu/src/iam-consumer/iam-consumer.service.ts
Normal file
187
services/core-edu/src/iam-consumer/iam-consumer.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
82
services/core-edu/src/scheduling/schedule-conflict.ts
Normal file
82
services/core-edu/src/scheduling/schedule-conflict.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 排课冲突检测(纯函数)
|
||||
*
|
||||
* 冲突维度(P3):
|
||||
* - teacher: 同一教师在同一时间段有排课冲突
|
||||
* - class: 同一班级在同一时间段有排课冲突
|
||||
*
|
||||
* room_id 仅预留字段(ISSUE-006 决策 #2),P3 不校验教室冲突。
|
||||
*/
|
||||
|
||||
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 };
|
||||
}
|
||||
157
services/core-edu/src/scheduling/scheduling.controller.ts
Normal file
157
services/core-edu/src/scheduling/scheduling.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/scheduling/scheduling.module.ts
Normal file
10
services/core-edu/src/scheduling/scheduling.module.ts
Normal 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 {}
|
||||
113
services/core-edu/src/scheduling/scheduling.repository.ts
Normal file
113
services/core-edu/src/scheduling/scheduling.repository.ts
Normal 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();
|
||||
87
services/core-edu/src/scheduling/scheduling.schema.ts
Normal file
87
services/core-edu/src/scheduling/scheduling.schema.ts
Normal 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;
|
||||
164
services/core-edu/src/scheduling/scheduling.service.ts
Normal file
164
services/core-edu/src/scheduling/scheduling.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
61
services/core-edu/src/shared/outbox/event-builder.ts
Normal file
61
services/core-edu/src/shared/outbox/event-builder.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user