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:
60
services/core-edu/src/shared/errors/application-error.ts
Normal file
60
services/core-edu/src/shared/errors/application-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
63
services/core-edu/src/shared/errors/global-error.filter.ts
Normal file
63
services/core-edu/src/shared/errors/global-error.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
services/core-edu/src/shared/observability/logger.ts
Normal file
16
services/core-edu/src/shared/observability/logger.ts
Normal 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' },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
38
services/core-edu/src/shared/observability/metrics.ts
Normal file
38
services/core-edu/src/shared/observability/metrics.ts
Normal 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);
|
||||
29
services/core-edu/src/shared/observability/tracer.ts
Normal file
29
services/core-edu/src/shared/observability/tracer.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
88
services/core-edu/src/shared/outbox/outbox.publisher.ts
Normal file
88
services/core-edu/src/shared/outbox/outbox.publisher.ts
Normal 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();
|
||||
42
services/core-edu/src/shared/outbox/outbox.repository.ts
Normal file
42
services/core-edu/src/shared/outbox/outbox.repository.ts
Normal 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();
|
||||
16
services/core-edu/src/shared/outbox/outbox.schema.ts
Normal file
16
services/core-edu/src/shared/outbox/outbox.schema.ts
Normal 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;
|
||||
Reference in New Issue
Block a user