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,127 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { exams } from './exams.schema.js';
import { examsRepository } from './exams.repository.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Exam, NewExam } from './exams.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
export interface CreateExamInput {
classId: string;
title: string;
description?: string;
examDate: Date;
duration: string;
totalScore: string;
createdBy: string;
}
export interface UpdateExamInput {
title?: string;
description?: string;
examDate?: Date;
duration?: string;
totalScore?: string;
status?: string;
}
@Injectable()
export class ExamsService {
async createExam(input: CreateExamInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
}
const id = randomUUID();
const exam: NewExam = {
id,
classId: input.classId,
title: input.title,
description: input.description,
examDate: input.examDate,
duration: input.duration,
totalScore: input.totalScore,
status: 'draft',
createdBy: input.createdBy,
};
await db.transaction(async (tx) => {
await tx.insert(exams).values(exam);
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.created',
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
},
tx,
);
});
return { id };
}
async getExam(id: string): Promise<Exam> {
const exam = await examsRepository.findById(id);
if (!exam) {
throw new NotFoundError(`Exam ${id} not found`);
}
return exam;
}
async listExamsByClass(classId: string): Promise<Exam[]> {
return examsRepository.findByClassId(classId);
}
async updateExam(id: string, data: UpdateExamInput): Promise<void> {
const existing = await examsRepository.findById(id);
if (!existing) {
throw new NotFoundError(`Exam ${id} not found`);
}
await db.transaction(async (tx) => {
await tx.update(exams).set(data).where(eq(exams.id, id));
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.updated',
payload: JSON.stringify({ id, changes: data }),
status: 'pending',
},
tx,
);
});
}
async deleteExam(id: string): Promise<void> {
const existing = await examsRepository.findById(id);
if (!existing) {
throw new NotFoundError(`Exam ${id} not found`);
}
await db.transaction(async (tx) => {
await tx.delete(exams).where(eq(exams.id, id));
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'exam',
eventType: 'exam.deleted',
payload: JSON.stringify({ id }),
status: 'pending',
},
tx,
);
});
}
}