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:
57
services/core-edu/src/exams/exams.controller.ts
Normal file
57
services/core-edu/src/exams/exams.controller.ts
Normal 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() };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/exams/exams.module.ts
Normal file
10
services/core-edu/src/exams/exams.module.ts
Normal 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 {}
|
||||
28
services/core-edu/src/exams/exams.repository.ts
Normal file
28
services/core-edu/src/exams/exams.repository.ts
Normal 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();
|
||||
25
services/core-edu/src/exams/exams.schema.ts
Normal file
25
services/core-edu/src/exams/exams.schema.ts
Normal 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;
|
||||
127
services/core-edu/src/exams/exams.service.ts
Normal file
127
services/core-edu/src/exams/exams.service.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user