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,60 @@
export enum CoreEduErrorCode {
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR',
NOT_FOUND = 'CORE_EDU_NOT_FOUND',
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED',
FORBIDDEN = 'CORE_EDU_FORBIDDEN',
CONFLICT = 'CORE_EDU_CONFLICT',
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR',
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND',
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND',
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND',
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED',
}
export class ApplicationError extends Error {
constructor(
public readonly code: CoreEduErrorCode,
message: string,
public readonly statusCode: number = 500,
public readonly details?: unknown,
) {
super(message);
this.name = 'ApplicationError';
}
}
export class ValidationError extends ApplicationError {
constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.VALIDATION_ERROR, message, 400, details);
}
}
export class NotFoundError extends ApplicationError {
constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.NOT_FOUND, message, 404, details);
}
}
export class UnauthorizedError extends ApplicationError {
constructor(message: string = 'Unauthorized') {
super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
}
}
export class ForbiddenError extends ApplicationError {
constructor(message: string = 'Forbidden') {
super(CoreEduErrorCode.FORBIDDEN, message, 403);
}
}
export class ConflictError extends ApplicationError {
constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.CONFLICT, message, 409, details);
}
}
export class InternalError extends ApplicationError {
constructor(message: string = 'Internal server error', details?: unknown) {
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
}
}

View File

@@ -0,0 +1,63 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ZodError } from 'zod';
import { ApplicationError, CoreEduErrorCode } from './application-error.js';
import { logger } from '../observability/logger.js';
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let code = CoreEduErrorCode.INTERNAL_ERROR;
let message = 'Internal server error';
let details: unknown;
if (exception instanceof ApplicationError) {
statusCode = exception.statusCode;
code = exception.code;
message = exception.message;
details = exception.details;
} else if (exception instanceof ZodError) {
statusCode = HttpStatus.BAD_REQUEST;
code = CoreEduErrorCode.VALIDATION_ERROR;
message = 'Validation failed';
details = exception.flatten().fieldErrors;
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const resp = exception.getResponse();
message =
typeof resp === 'string'
? resp
: (resp as { message?: string }).message ?? exception.message;
} else if (exception instanceof Error) {
message = exception.message;
}
logger.error(
{
err: exception,
path: request.url,
method: request.method,
code,
},
`Request failed: ${message}`,
);
response.status(statusCode).json({
code,
message,
details,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -0,0 +1,16 @@
import pino from 'pino';
import { env } from '../../config/env.js';
export const logger = pino({
name: 'core-edu',
level: env.LOG_LEVEL,
base: { service: 'core-edu' },
...(env.NODE_ENV === 'development'
? {
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:standard' },
},
}
: {}),
});

View File

@@ -0,0 +1,38 @@
import {
Registry,
Counter,
Histogram,
Gauge,
collectDefaultMetrics,
} from 'prom-client';
export const registry = new Registry();
collectDefaultMetrics({ register: registry });
export const httpRequestCounter = new Counter({
name: 'core_edu_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status'] as const,
});
registry.registerMetric(httpRequestCounter);
export const httpRequestDuration = new Histogram({
name: 'core_edu_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'] as const,
buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
});
registry.registerMetric(httpRequestDuration);
export const outboxPendingGauge = new Gauge({
name: 'core_edu_outbox_pending',
help: 'Number of pending outbox messages',
});
registry.registerMetric(outboxPendingGauge);
export const outboxPublishedCounter = new Counter({
name: 'core_edu_outbox_published_total',
help: 'Total outbox messages published to Kafka',
labelNames: ['eventType', 'topic'] as const,
});
registry.registerMetric(outboxPublishedCounter);

View File

@@ -0,0 +1,29 @@
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { env } from '../../config/env.js';
import { logger } from './logger.js';
let sdk: NodeSDK | undefined;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.warn('OTEL_EXPORTER_OTLP_ENDPOINT not set, tracing disabled');
return;
}
sdk = new NodeSDK({
serviceName: 'core-edu',
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
});
sdk.start();
logger.info('OpenTelemetry tracer initialized');
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = undefined;
logger.info('OpenTelemetry tracer shutdown');
}
}

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;