feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user