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:
55
services/core-edu/src/grades/grades.controller.ts
Normal file
55
services/core-edu/src/grades/grades.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { GradesService, type RecordGradeInput } from './grades.service.js';
|
||||
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Controller('api/v1/grades')
|
||||
export class GradesController {
|
||||
constructor(private readonly gradesService: GradesService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_CREATE))
|
||||
async record(@Body() body: RecordGradeInput): Promise<SuccessResponse<{ id: string }>> {
|
||||
const result = await this.gradesService.recordGrade(body);
|
||||
return { data: result, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['getGrade']>>>> {
|
||||
const data = await this.gradesService.getGrade(id);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('student/:studentId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByStudent(@Param('studentId') studentId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByStudent']>>>> {
|
||||
const data = await this.gradesService.listByStudent(studentId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('exam/:examId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByExam(@Param('examId') examId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByExam']>>>> {
|
||||
const data = await this.gradesService.listByExam(examId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@Get('homework/:homeworkId')
|
||||
@UseGuards(new PermissionGuard(Permissions.GRADE_READ))
|
||||
async listByHomework(@Param('homeworkId') homeworkId: string): Promise<SuccessResponse<Awaited<ReturnType<GradesService['listByHomework']>>>> {
|
||||
const data = await this.gradesService.listByHomework(homeworkId);
|
||||
return { data, timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
10
services/core-edu/src/grades/grades.module.ts
Normal file
10
services/core-edu/src/grades/grades.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GradesController } from './grades.controller.js';
|
||||
import { GradesService } from './grades.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [GradesController],
|
||||
providers: [GradesService],
|
||||
exports: [GradesService],
|
||||
})
|
||||
export class GradesModule {}
|
||||
22
services/core-edu/src/grades/grades.schema.ts
Normal file
22
services/core-edu/src/grades/grades.schema.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
char,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const grades = mysqlTable('core_edu_grades', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
studentId: char('student_id', { length: 36 }).notNull(),
|
||||
examId: char('exam_id', { length: 36 }),
|
||||
homeworkId: char('homework_id', { length: 36 }),
|
||||
score: varchar('score', { length: 10 }).notNull(),
|
||||
feedback: text('feedback'),
|
||||
gradedBy: char('graded_by', { length: 36 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Grade = typeof grades.$inferSelect;
|
||||
export type NewGrade = typeof grades.$inferInsert;
|
||||
87
services/core-edu/src/grades/grades.service.ts
Normal file
87
services/core-edu/src/grades/grades.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../config/database.js';
|
||||
import { grades } from './grades.schema.js';
|
||||
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
|
||||
import type { Grade, NewGrade } from './grades.schema.js';
|
||||
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
|
||||
|
||||
export interface RecordGradeInput {
|
||||
studentId: string;
|
||||
examId?: string;
|
||||
homeworkId?: string;
|
||||
score: string;
|
||||
feedback?: string;
|
||||
gradedBy: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GradesService {
|
||||
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
|
||||
if (!input.studentId || !input.score || !input.gradedBy) {
|
||||
throw new ValidationError('studentId, score, gradedBy are required');
|
||||
}
|
||||
if (!input.examId && !input.homeworkId) {
|
||||
throw new ValidationError('Either examId or homeworkId must be provided');
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const record: NewGrade = {
|
||||
id,
|
||||
studentId: input.studentId,
|
||||
examId: input.examId,
|
||||
homeworkId: input.homeworkId,
|
||||
score: input.score,
|
||||
feedback: input.feedback,
|
||||
gradedBy: input.gradedBy,
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(grades).values(record);
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: randomUUID(),
|
||||
aggregateId: id,
|
||||
aggregateType: 'grade',
|
||||
eventType: 'grade.recorded',
|
||||
payload: JSON.stringify({
|
||||
id,
|
||||
studentId: input.studentId,
|
||||
score: input.score,
|
||||
examId: input.examId,
|
||||
homeworkId: input.homeworkId,
|
||||
}),
|
||||
status: 'pending',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getGrade(id: string): Promise<Grade> {
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(grades)
|
||||
.where(eq(grades.id, id))
|
||||
.limit(1);
|
||||
if (!record) {
|
||||
throw new NotFoundError(`Grade ${id} not found`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async listByStudent(studentId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.studentId, studentId));
|
||||
}
|
||||
|
||||
async listByExam(examId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.examId, examId));
|
||||
}
|
||||
|
||||
async listByHomework(homeworkId: string): Promise<Grade[]> {
|
||||
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user