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.. */ const TOPIC_MAP: Record = { // 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 { logger.info("OutboxPublisher started"); this.intervalId = setInterval(() => { void this.poll(); }, POLL_INTERVAL_MS); } async stop(): Promise { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } logger.info("OutboxPublisher stopped"); } private async poll(): Promise { 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 { const topic = TOPIC_MAP[message.eventType] ?? "edu.teaching.fallback"; try { await producer.send({ topic, messages: [ { key: message.aggregateId, value: message.payload, headers: { eventType: message.eventType, aggregateType: message.aggregateType, eventId: message.eventId, }, }, ], }); await outboxRepository.markProcessed(message.id); logger.info( { id: message.id, eventId: message.eventId, eventType: message.eventType, topic, }, "Outbox message published", ); } catch (error) { 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 { const backoff = RETRY_BACKOFF_BASE_MS * Math.pow(2, nextRetry); await outboxRepository.incrementRetry(message.id, backoff); } } } } export const outboxPublisher = new OutboxPublisher();