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

View 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 {}

View 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;

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