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,
});
}
}