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

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

View File

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

View File

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