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,48 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { HomeworkService, type AssignHomeworkInput } from './homework.service.js';
import { PermissionGuard, Permissions } from '../../middleware/permission.guard.js';
interface SuccessResponse<T> {
data: T;
timestamp: string;
}
@Controller('api/v1/homework')
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_CREATE))
async assign(@Body() body: AssignHomeworkInput): Promise<SuccessResponse<{ id: string }>> {
const result = await this.homeworkService.assignHomework(body);
return { data: result, timestamp: new Date().toISOString() };
}
@Get(':id')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async findOne(@Param('id') id: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['getHomework']>>>> {
const data = await this.homeworkService.getHomework(id);
return { data, timestamp: new Date().toISOString() };
}
@Get('class/:classId')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_READ))
async listByClass(@Param('classId') classId: string): Promise<SuccessResponse<Awaited<ReturnType<HomeworkService['listByClass']>>>> {
const data = await this.homeworkService.listByClass(classId);
return { data, timestamp: new Date().toISOString() };
}
@Post(':id/submit')
@UseGuards(new PermissionGuard(Permissions.HOMEWORK_SUBMIT))
async submit(@Param('id') id: string): Promise<SuccessResponse<{ success: true }>> {
await this.homeworkService.submitHomework(id);
return { data: { success: true }, timestamp: new Date().toISOString() };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HomeworkController } from './homework.controller.js';
import { HomeworkService } from './homework.service.js';
@Module({
controllers: [HomeworkController],
providers: [HomeworkService],
exports: [HomeworkService],
})
export class HomeworkModule {}

View File

@@ -0,0 +1,23 @@
import {
mysqlTable,
varchar,
text,
timestamp,
char,
datetime,
} from 'drizzle-orm/mysql-core';
export const homework = mysqlTable('core_edu_homework', {
id: char('id', { length: 36 }).notNull().primaryKey(),
classId: char('class_id', { length: 36 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
dueDate: datetime('due_date').notNull(),
status: varchar('status', { length: 20 }).notNull().default('assigned'),
createdBy: char('created_by', { length: 36 }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
});
export type Homework = typeof homework.$inferSelect;
export type NewHomework = typeof homework.$inferInsert;

View File

@@ -0,0 +1,98 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Injectable } from '@nestjs/common';
import { db } from '../../config/database.js';
import { homework } from './homework.schema.js';
import { outboxRepository } from '../../shared/outbox/outbox.repository.js';
import type { Homework, NewHomework } from './homework.schema.js';
import { NotFoundError, ValidationError } from '../../shared/errors/application-error.js';
export interface AssignHomeworkInput {
classId: string;
title: string;
description?: string;
dueDate: Date;
createdBy: string;
}
@Injectable()
export class HomeworkService {
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
if (!input.classId || !input.title || !input.createdBy) {
throw new ValidationError('classId, title, createdBy are required');
}
const id = randomUUID();
const record: NewHomework = {
id,
classId: input.classId,
title: input.title,
description: input.description,
dueDate: input.dueDate,
status: 'assigned',
createdBy: input.createdBy,
};
await db.transaction(async (tx) => {
await tx.insert(homework).values(record);
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.assigned',
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
status: 'pending',
},
tx,
);
});
return { id };
}
async getHomework(id: string): Promise<Homework> {
const [record] = await db
.select()
.from(homework)
.where(eq(homework.id, id))
.limit(1);
if (!record) {
throw new NotFoundError(`Homework ${id} not found`);
}
return record;
}
async listByClass(classId: string): Promise<Homework[]> {
return db.select().from(homework).where(eq(homework.classId, classId));
}
async submitHomework(id: string): Promise<void> {
const existing = await this.getHomework(id);
if (existing.status === 'submitted') {
throw new ValidationError(`Homework ${id} already submitted`);
}
await db.transaction(async (tx) => {
await tx
.update(homework)
.set({ status: 'submitted' })
.where(eq(homework.id, id));
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
aggregateType: 'homework',
eventType: 'homework.submitted',
payload: JSON.stringify({ id }),
status: 'pending',
},
tx,
);
});
}
}