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,57 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ExamsService, type CreateExamInput, type UpdateExamInput } from './exams.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/exams')
export class ExamsController {
constructor(private readonly examsService: ExamsService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.EXAM_CREATE))
async create(@Body() body: CreateExamInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.examsService.createExam(body);
return { data: result, timestamp: new Date().toISOString() };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['getExam']>>>> {
const data = await this.examsService.getExam(id);
return { data, timestamp: new Date().toISOString() };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.EXAM_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<ExamsService['listExamsByClass']>>>> {
const data = await this.examsService.listExamsByClass(classId);
return { data, timestamp: new Date().toISOString() };
}
@Put(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_UPDATE))
async update(@Param('id') id: string, @Body() body: UpdateExamInput): Promise<SuccessResponse<{ success: true }>> {
await this.examsService.updateExam(id, body);
return { data: { success: true }, timestamp: new Date().toISOString() };
}
@Delete(':id')
@UseGuards(new PermissionGuard(Permissions.EXAM_DELETE))
async remove(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
await this.examsService.deleteExam(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExamsController } from './exams.controller.js';
import { ExamsService } from './exams.service.js';
@Module({
controllers: [ExamsController],
providers: [ExamsService],
exports: [ExamsService],
})
export class ExamsModule {}

View File

@@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { exams, type Exam, type NewExam } from './exams.schema.js';
export class ExamsRepository {
async findById(id: string): Promise<Exam | undefined> {
const [result] = await db
.select()
.from(exams)
.where(eq(exams.id, id))
.limit(1);
return result;
}
async findByClassId(classId: string): Promise<Exam[]> {
return db.select().from(exams).where(eq(exams.classId, classId));
}
async update(id: string, data: Partial<NewExam>): Promise<void> {
await db.update(exams).set(data).where(eq(exams.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(exams).where(eq(exams.id, id));
}
}
export const examsRepository = new ExamsRepository();

View File

@@ -0,0 +1,25 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
datetime,
} from 'drizzle-orm/mysql-core';
export const exams = mysqlTable('core_edu_exams', {
id: char('id', { length: 36 }).notNull().primaryKey(),
classId: char('class_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
examDate: datetime('exam_date').notNull(),
duration: varchar('duration', { length: 50 }).notNull(),
totalScore: varchar('total_score', { length: 10 }).notNull(),
status: varchar('status', { length: 20 }).notNull().default('draft'),
createdBy: char('created_by', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
export type Exam = typeof exams.$inferSelect;
export type NewExam = typeof exams.$inferInsert;

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