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,91 @@
/**
* 作业状态机(纯函数)
*
* 状态流转ISSUE-003 仲裁 scheme A
* assigned → submitted → graded
*
* SubmissionStatus学生提交维度
* not_submitted → submitted → graded
*/
export type HomeworkStatus = "assigned" | "submitted" | "graded";
export type HomeworkAction = "submit" | "grade";
const TRANSITIONS: Record<
HomeworkStatus,
Partial<Record<HomeworkAction, HomeworkStatus>>
> = {
assigned: { submit: "submitted" },
submitted: { grade: "graded" },
graded: {},
};
export function canTransition(
from: HomeworkStatus,
action: HomeworkAction,
): boolean {
return TRANSITIONS[from]?.[action] !== undefined;
}
export function transition(
from: HomeworkStatus,
action: HomeworkAction,
): HomeworkStatus {
const next = TRANSITIONS[from]?.[action];
if (!next) {
throw new Error(
`Invalid homework state transition: ${from} --${action}-->`,
);
}
return next;
}
export function isTerminal(status: HomeworkStatus): boolean {
return status === "graded";
}
// SubmissionStatus 状态机(用于 exam_submissions / homework_submissions 表的 status 字段)
export type SubmissionStatus = "not_submitted" | "submitted" | "graded";
export type SubmissionAction = "submit" | "grade";
const SUBMISSION_TRANSITIONS: Record<
SubmissionStatus,
Partial<Record<SubmissionAction, SubmissionStatus>>
> = {
not_submitted: { submit: "submitted" },
submitted: { grade: "graded" },
graded: {},
};
export function canTransitionSubmission(
from: SubmissionStatus,
action: SubmissionAction,
): boolean {
return SUBMISSION_TRANSITIONS[from]?.[action] !== undefined;
}
export function transitionSubmission(
from: SubmissionStatus,
action: SubmissionAction,
): SubmissionStatus {
const next = SUBMISSION_TRANSITIONS[from]?.[action];
if (!next) {
throw new Error(
`Invalid submission state transition: ${from} --${action}-->`,
);
}
return next;
}
export const HOMEWORK_STATUSES: readonly HomeworkStatus[] = [
"assigned",
"submitted",
"graded",
] as const;
export const SUBMISSION_STATUSES: readonly SubmissionStatus[] = [
"not_submitted",
"submitted",
"graded",
] as const;

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { z } from "zod";
import {
HomeworkService,
type AssignHomeworkInput,
@@ -8,26 +9,70 @@ import {
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
UnauthorizedError,
ValidationError,
} from "../shared/errors/application-error.js";
@Controller("homework")
const assignHomeworkSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
title: z.string().min(1).max(200),
description: z.string().optional(),
dueDate: z.string().min(1),
gracePeriod: z.number().int().positive().optional(),
schoolId: z.string().min(1),
});
const answerInputSchema = z.object({
questionId: z.string().min(1),
answer: z.string(),
});
const scoreInputSchema = z.object({
questionId: z.string().min(1),
score: z.string().min(1),
teacherComment: z.string().optional(),
});
const submitHomeworkSchema = z.object({
studentId: z.string().min(1),
answers: z.array(answerInputSchema).default([]),
});
const gradeHomeworkSchema = z.object({
submissionId: z.string().min(1),
scores: z.array(scoreInputSchema).min(1),
feedback: z.string().optional(),
});
@Controller("v1/homework")
export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {}
@Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign(
@Body() body: AssignHomeworkInput,
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.homeworkService.assignHomework({
...body,
const parsed = assignHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(
"Invalid homework input",
parsed.error.flatten(),
);
}
const input: AssignHomeworkInput = {
...parsed.data,
dueDate: new Date(parsed.data.dueDate),
createdBy: userId,
});
};
const result = await this.homeworkService.assignHomework(input, userId);
return { success: true, data: result };
}
@@ -55,8 +100,42 @@ export class HomeworkController {
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.homeworkService.submitHomework(id);
return { success: true, data: { success: true } };
@Body() body: unknown,
): Promise<{ success: true; data: { submissionId: string } }> {
const parsed = submitHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid submit input", parsed.error.flatten());
}
const result = await this.homeworkService.submitHomework(
id,
parsed.data.studentId,
parsed.data.answers,
);
return { success: true, data: result };
}
@Post(":id/grade")
@RequirePermission(Permissions.HOMEWORK_GRADE)
async grade(
@Param("id") id: string,
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { totalScore: string } }> {
const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const parsed = gradeHomeworkSchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError("Invalid grade input", parsed.error.flatten());
}
const result = await this.homeworkService.gradeHomework(
id,
parsed.data.submissionId,
parsed.data.scores,
parsed.data.feedback,
userId,
);
return { success: true, data: result };
}
}

View File

@@ -0,0 +1,100 @@
import { eq, and } from "drizzle-orm";
import { db } from "../config/database.js";
import {
homework,
homeworkSubmissions,
homeworkAnswers,
type Homework,
type NewHomework,
type HomeworkSubmission,
type NewHomeworkSubmission,
type HomeworkAnswer,
type NewHomeworkAnswer,
} from "./homework.schema.js";
export class HomeworkRepository {
async findById(id: string): Promise<Homework | undefined> {
const [result] = await db
.select()
.from(homework)
.where(eq(homework.id, id))
.limit(1);
return result;
}
async findByClassId(classId: string): Promise<Homework[]> {
return db.select().from(homework).where(eq(homework.classId, classId));
}
async update(id: string, data: Partial<NewHomework>): Promise<void> {
await db.update(homework).set(data).where(eq(homework.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(homework).where(eq(homework.id, id));
}
// Submissions
async findSubmission(
homeworkId: string,
studentId: string,
): Promise<HomeworkSubmission | undefined> {
const [result] = await db
.select()
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.homeworkId, homeworkId),
eq(homeworkSubmissions.studentId, studentId),
),
)
.limit(1);
return result;
}
async findSubmissionById(
id: string,
): Promise<HomeworkSubmission | undefined> {
const [result] = await db
.select()
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.id, id))
.limit(1);
return result;
}
async createSubmission(
submission: NewHomeworkSubmission,
tx: typeof db = db,
): Promise<void> {
await tx.insert(homeworkSubmissions).values(submission);
}
async updateSubmission(
id: string,
data: Partial<NewHomeworkSubmission>,
): Promise<void> {
await db
.update(homeworkSubmissions)
.set(data)
.where(eq(homeworkSubmissions.id, id));
}
// Answers
async createAnswers(
answers: NewHomeworkAnswer[],
tx: typeof db = db,
): Promise<void> {
if (answers.length === 0) return;
await tx.insert(homeworkAnswers).values(answers);
}
async listAnswers(submissionId: string): Promise<HomeworkAnswer[]> {
return db
.select()
.from(homeworkAnswers)
.where(eq(homeworkAnswers.submissionId, submissionId));
}
}
export const homeworkRepository = new HomeworkRepository();

View File

@@ -5,19 +5,98 @@ import {
timestamp,
char,
datetime,
} from 'drizzle-orm/mysql-core';
int,
decimal,
boolean,
index,
uniqueIndex,
} 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 const homework = mysqlTable(
"core_edu_homework",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
classId: char("class_id", { length: 36 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
description: text("description"),
dueDate: datetime("due_date").notNull(),
gracePeriod: int("grace_period").notNull().default(300), // 秒,仲裁默认 300
status: varchar("status", { length: 20 }).notNull().default("assigned"),
schoolId: char("school_id", { length: 36 }).notNull(),
createdBy: char("created_by", { length: 36 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxHomeworkClassStatus: index("idx_homework_class_status").on(
table.classId,
table.status,
),
idxHomeworkCreatedBy: index("idx_homework_created_by").on(table.createdBy),
}),
);
// 作业提交表
export const homeworkSubmissions = mysqlTable(
"core_edu_homework_submissions",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
homeworkId: char("homework_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
status: varchar("status", { length: 20 })
.notNull()
.default("not_submitted"),
submittedAt: datetime("submitted_at"),
gradedAt: datetime("graded_at"),
gradedBy: char("graded_by", { length: 36 }),
totalScore: decimal("total_score", { precision: 6, scale: 2 }),
feedback: text("feedback"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
uniqHwStudent: uniqueIndex("uniq_hw_student").on(
table.homeworkId,
table.studentId,
),
idxHwSubmissionsHomeworkId: index("idx_hw_submissions_homework_id").on(
table.homeworkId,
),
idxHwSubmissionsStudentId: index("idx_hw_submissions_student_id").on(
table.studentId,
),
}),
);
// 作业答题表
export const homeworkAnswers = mysqlTable(
"core_edu_homework_answers",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
submissionId: char("submission_id", { length: 36 }).notNull(),
questionId: char("question_id", { length: 36 }).notNull(),
answer: text("answer"),
score: decimal("score", { precision: 6, scale: 2 }),
teacherComment: text("teacher_comment"),
isCorrect: boolean("is_correct"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
idxHwAnswersSubmissionId: index("idx_hw_answers_submission_id").on(
table.submissionId,
),
idxHwAnswersQuestionId: index("idx_hw_answers_question_id").on(
table.questionId,
),
}),
);
export type Homework = typeof homework.$inferSelect;
export type NewHomework = typeof homework.$inferInsert;
export type HomeworkSubmission = typeof homeworkSubmissions.$inferSelect;
export type NewHomeworkSubmission = typeof homeworkSubmissions.$inferInsert;
export type HomeworkAnswer = typeof homeworkAnswers.$inferSelect;
export type NewHomeworkAnswer = typeof homeworkAnswers.$inferInsert;

View File

@@ -1,56 +1,97 @@
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 { homeworkRepository } from "./homework.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js";
import type { Homework, NewHomework } from "./homework.schema.js";
import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js";
import { acquireLock, releaseLock } from "../config/redis.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
import type { Homework, NewHomework } from "./homework.schema.js";
export interface AssignHomeworkInput {
classId: string;
subjectId: string;
title: string;
description?: string;
dueDate: Date | string;
gracePeriod?: number;
schoolId: string;
createdBy: string;
}
export interface AnswerInput {
questionId: string;
answer: string;
}
export interface ScoreInput {
questionId: string;
score: string;
teacherComment?: string;
}
const LOCK_TTL_SECONDS = 30;
@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");
async assignHomework(
input: AssignHomeworkInput,
userId?: string,
): Promise<{ id: string }> {
if (
!input.classId ||
!input.title ||
!input.createdBy ||
!input.subjectId
) {
throw new ValidationError(
"classId, subjectId, title, createdBy are required",
);
}
const id = randomUUID();
const record: NewHomework = {
id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
description: input.description,
// Drizzle datetime 列需要 Date 对象HTTP 请求体里 dueDate 是 ISO 字符串。
dueDate:
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
gracePeriod: input.gracePeriod ?? 300,
status: "assigned",
schoolId: input.schoolId,
createdBy: input.createdBy,
};
const event = buildEvent({
aggregateId: id,
eventType: "homework.assigned",
payload: {
homeworkId: id,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
},
userId,
});
await db.transaction(async (tx) => {
await tx.insert(homework).values(record);
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: id,
aggregateType: "homework",
eventType: "homework.assigned",
payload: JSON.stringify({
id,
classId: input.classId,
title: input.title,
}),
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
@@ -61,11 +102,7 @@ export class HomeworkService {
}
async getHomework(id: string): Promise<Homework> {
const [record] = await db
.select()
.from(homework)
.where(eq(homework.id, id))
.limit(1);
const record = await homeworkRepository.findById(id);
if (!record) {
throw new NotFoundError(`Homework ${id} not found`);
}
@@ -73,31 +110,178 @@ export class HomeworkService {
}
async listByClass(classId: string): Promise<Homework[]> {
return db.select().from(homework).where(eq(homework.classId, classId));
return homeworkRepository.findByClassId(classId);
}
async submitHomework(id: string): Promise<void> {
const existing = await this.getHomework(id);
if (existing.status === "submitted") {
throw new ValidationError(`Homework ${id} already submitted`);
async submitHomework(
homeworkId: string,
studentId: string,
answers: AnswerInput[],
): Promise<{ submissionId: string }> {
const hw = await homeworkRepository.findById(homeworkId);
if (!hw) {
throw new NotFoundError(`Homework ${homeworkId} not found`);
}
// Redis distributed lock for idempotency (P2 feature)
const lockKey = `hw:submit:${homeworkId}:${studentId}`;
const locked = await acquireLock(lockKey, LOCK_TTL_SECONDS);
if (!locked) {
// Lock unavailable - check if submission already exists
const existing = await homeworkRepository.findSubmission(
homeworkId,
studentId,
);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted homework ${homeworkId}`,
);
}
throw new ConflictError("Submission in progress, please retry");
}
try {
// Double-check after acquiring lock
const existing = await homeworkRepository.findSubmission(
homeworkId,
studentId,
);
if (
existing &&
(existing.status === "submitted" || existing.status === "graded")
) {
throw new ConflictError(
`Student ${studentId} already submitted homework ${homeworkId}`,
);
}
const submissionId = randomUUID();
const now = new Date();
// Check grace period
const dueWithGrace = new Date(
hw.dueDate.getTime() + hw.gracePeriod * 1000,
);
const isLate = now > dueWithGrace;
await db.transaction(async (tx) => {
await homeworkRepository.createSubmission(
{
id: submissionId,
homeworkId,
studentId,
status: "submitted",
submittedAt: now,
},
tx,
);
// Insert answers
if (answers.length > 0) {
const answerRecords = answers.map((a) => ({
id: randomUUID(),
submissionId,
questionId: a.questionId,
answer: a.answer,
}));
await homeworkRepository.createAnswers(answerRecords, tx);
}
const event = buildEvent({
aggregateId: homeworkId,
eventType: "homework.submitted",
payload: {
homeworkId,
submissionId,
studentId,
isLate,
},
userId: studentId,
});
await outboxRepository.create(
{
id: randomUUID(),
eventId: event.event_id,
aggregateId: homeworkId,
aggregateType: "homework",
eventType: "homework.submitted",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { submissionId };
} finally {
await releaseLock(lockKey);
}
}
async gradeHomework(
homeworkId: string,
submissionId: string,
scores: ScoreInput[],
feedback: string | undefined,
gradedBy: string,
): Promise<{ totalScore: string }> {
const hw = await homeworkRepository.findById(homeworkId);
if (!hw) {
throw new NotFoundError(`Homework ${homeworkId} not found`);
}
const submission =
await homeworkRepository.findSubmissionById(submissionId);
if (!submission || submission.homeworkId !== homeworkId) {
throw new NotFoundError(
`Submission ${submissionId} not found for homework ${homeworkId}`,
);
}
if (submission.status === "graded") {
throw new ConflictError(`Submission ${submissionId} already graded`);
}
const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0);
const totalScoreStr = totalScore.toFixed(2);
await db.transaction(async (tx) => {
await tx
.update(homework)
.set({ status: "submitted" })
.where(eq(homework.id, id));
await homeworkRepository.updateSubmission(submissionId, {
status: "graded",
gradedAt: new Date(),
gradedBy,
totalScore: totalScore.toFixed(2),
feedback,
});
const event = buildEvent({
aggregateId: homeworkId,
eventType: "homework.graded",
payload: {
homeworkId,
submissionId,
studentId: submission.studentId,
totalScore: totalScoreStr,
},
userId: gradedBy,
});
await outboxRepository.create(
{
id: randomUUID(),
aggregateId: id,
eventId: event.event_id,
aggregateId: homeworkId,
aggregateType: "homework",
eventType: "homework.submitted",
payload: JSON.stringify({ id }),
eventType: "homework.graded",
occurredAt: new Date(event.occurred_at),
payload: serializeEvent(event),
status: "pending",
},
tx,
);
});
return { totalScore: totalScoreStr };
}
}