feat(core-edu): 完整实现 core-edu 教学核心服务

包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:08:56 +08:00
parent 06a646ea4e
commit 58c0ba1bd9
55 changed files with 4204 additions and 305 deletions

View File

@@ -0,0 +1,116 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { attendanceRepository } from "./attendance.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import { isValidAttendanceStatus } from "./attendance-status.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Attendance } from "./attendance.schema.js";
export interface RecordAttendanceInput {
scheduleId: string;
studentId: string;
status: string;
remark?: string;
recordedBy: string;
schoolId: string;
}
@Injectable()
export class AttendanceService {
async recordAttendance(
input: RecordAttendanceInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.scheduleId ||
!input.studentId ||
!input.recordedBy ||
!input.schoolId
) {
throw new ValidationError(
"scheduleId, studentId, recordedBy, schoolId are required",
);
}
if (!isValidAttendanceStatus(input.status)) {
throw new ValidationError(
`Invalid attendance status: ${input.status}. Must be one of: present, absent, late, leave`,
);
}
// Idempotency: check existing record
const existing = await attendanceRepository.findByScheduleAndStudent(
input.scheduleId,
input.studentId,
);
if (existing) {
throw new ConflictError(
`Attendance already recorded for student ${input.studentId} in schedule ${input.scheduleId}`,
);
}
const id = randomUUID();
const event = buildEvent({
aggregateId: id,
eventType: "attendance.recorded",
payload: {
attendanceId: id,
scheduleId: input.scheduleId,
studentId: input.studentId,
status: input.status,
},
userId,
});
await db.transaction(async (tx) => {
await attendanceRepository.create(
{
id,
scheduleId: input.scheduleId,
studentId: input.studentId,
status: input.status,
remark: input.remark,
recordedBy: input.recordedBy,
schoolId: input.schoolId,
},
tx,
);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "attendance",
eventType: "attendance.recorded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { id };
}
async getAttendance(id: string): Promise<Attendance> {
const record = await attendanceRepository.findById(id);
if (!record) {
throw new NotFoundError(`Attendance ${id} not found`);
}
return record;
}
async listByStudent(studentId: string): Promise<Attendance[]> {
return attendanceRepository.findByStudentId(studentId);
}
async listByClass(classId: string): Promise<Attendance[]> {
return attendanceRepository.findByClassId(classId);
}
}