P3 阶段交付物: - services/core-edu: 教学核心服务(DDD 限界上下文:exams/grades/homework/classes) - exams: 考试 CRUD + 事务内写 exam + outbox - grades: 成绩 CRUD - homework: 作业 CRUD - classes.module: 复用 P1 classes 模块(聚合到 core-edu 服务) - Outbox 模式实现: - outbox.schema.ts: core_edu_outbox 表(id/aggregate_id/event_type/payload/status/retry_count) - outbox.repository.ts: 支持事务参数 tx,确保业务+事件原子性 - outbox.publisher.ts: Kafka idempotent producer + transactionalId,TOPIC_MAP 路由 9 种事件,MAX_RETRY=5 - config/kafka.ts: idempotent producer + transactionalId 配置 - main.ts: 启动顺序 initTracer → connectKafka → outboxPublisher.start → app.listen - packages/shared-proto/proto/core_edu.proto: ExamService/HomeworkService/GradeService 契约 - packages/shared-proto/proto/events.proto: ClassEvent/ExamEvent/HomeworkEvent/GradeEvent 领域事件契约
89 lines
2.5 KiB
TypeScript
89 lines
2.5 KiB
TypeScript
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';
|
|
|
|
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',
|
|
};
|
|
|
|
const POLL_INTERVAL_MS = 5000;
|
|
const BATCH_SIZE = 100;
|
|
const MAX_RETRY = 5;
|
|
|
|
export class OutboxPublisher {
|
|
private intervalId: NodeJS.Timeout | null = null;
|
|
private isPolling = false;
|
|
|
|
async start(): Promise<void> {
|
|
logger.info('OutboxPublisher started');
|
|
this.intervalId = setInterval(() => {
|
|
void this.poll();
|
|
}, POLL_INTERVAL_MS);
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
logger.info('OutboxPublisher stopped');
|
|
}
|
|
|
|
private async poll(): Promise<void> {
|
|
if (this.isPolling) return;
|
|
this.isPolling = true;
|
|
try {
|
|
const messages = await outboxRepository.findPending(BATCH_SIZE);
|
|
for (const message of messages) {
|
|
await this.publish(message);
|
|
}
|
|
} catch (error) {
|
|
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';
|
|
try {
|
|
await producer.send({
|
|
topic,
|
|
messages: [
|
|
{
|
|
key: message.aggregateId,
|
|
value: message.payload,
|
|
headers: {
|
|
eventType: message.eventType,
|
|
aggregateType: message.aggregateType,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
await outboxRepository.markProcessed(message.id);
|
|
logger.info(
|
|
{ id: message.id, eventType: message.eventType, topic },
|
|
'Outbox message published',
|
|
);
|
|
} catch (error) {
|
|
logger.error({ error, id: message.id }, 'Outbox publish failed');
|
|
if (message.retryCount + 1 >= MAX_RETRY) {
|
|
await outboxRepository.markFailed(message.id);
|
|
} else {
|
|
await outboxRepository.incrementRetry(message.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const outboxPublisher = new OutboxPublisher();
|