Files
Edu/services/core-edu/src/shared/outbox/outbox.publisher.ts
SpecialX 23246ade6d feat(p3): core teaching service with Outbox + Kafka event bus
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 领域事件契约
2026-07-08 01:38:07 +08:00

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();