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

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