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 领域事件契约
This commit is contained in:
SpecialX
2026-07-08 01:38:07 +08:00
parent 524204d30a
commit 23246ade6d
37 changed files with 1592 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
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();

View File

@@ -0,0 +1,42 @@
import { eq, sql } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { outbox, type OutboxMessage, type NewOutboxMessage } from './outbox.schema.js';
type DbClient = typeof db;
export class OutboxRepository {
async create(message: NewOutboxMessage, tx: DbClient = db): Promise<void> {
await tx.insert(outbox).values(message);
}
async findPending(limit: number = 100): Promise<OutboxMessage[]> {
return db
.select()
.from(outbox)
.where(eq(outbox.status, 'pending'))
.limit(limit);
}
async markProcessed(id: string): Promise<void> {
await db
.update(outbox)
.set({ status: 'processed', processedAt: new Date() })
.where(eq(outbox.id, id));
}
async incrementRetry(id: string): Promise<void> {
await db
.update(outbox)
.set({ retryCount: sql`${outbox.retryCount} + 1` })
.where(eq(outbox.id, id));
}
async markFailed(id: string): Promise<void> {
await db
.update(outbox)
.set({ status: 'failed' })
.where(eq(outbox.id, id));
}
}
export const outboxRepository = new OutboxRepository();

View File

@@ -0,0 +1,16 @@
import { mysqlTable, varchar, text, timestamp, char, bigint } from 'drizzle-orm/mysql-core';
export const outbox = mysqlTable('core_edu_outbox', {
id: char('id', { length: 36 }).notNull().primaryKey(),
aggregateId: char('aggregate_id', { length: 36 }).notNull(),
aggregateType: varchar('aggregate_type', { length: 50 }).notNull(),
eventType: varchar('event_type', { length: 100 }).notNull(),
payload: text('payload').notNull(),
status: varchar('status', { length: 20 }).notNull().default('pending'),
retryCount: bigint('retry_count', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
processedAt: timestamp('processed_at'),
});
export type OutboxMessage = typeof outbox.$inferSelect;
export type NewOutboxMessage = typeof outbox.$inferInsert;