feat(content): v2 扩展 Elective/LessonPlan/CoursePlan 三业务域

新增 3 个业务域(10 RPC):
- ElectiveService: 选修课列表/学生选课记录/选课/退课(含容量与重复校验)
- LessonPlanService: 教师备课列表/学生备课列表(仅 published)/详情
- CoursePlanService: 学生课程计划列表/详情
- KnowledgeGraphService.GetKnowledgePath: 与 GetLearningPath 同实现

新增 4 张 MySQL 表(elective_courses/selections/lesson_plans/course_plans),含完整索引。

新增 11 个权限点,覆盖 admin/teacher/student/parent 四角色。

proto 由 4 Service/22 RPC 扩展至 7 Service/32 RPC,v1 全部 RPC 保持向后兼容。

修复 logger.ts pino 导入: default import 在 NodeNext ESM 下不可调用,
改用 named import(与 iam/msg/core-edu 对齐)。

Docker 本地测试全部通过(HTTP + gRPC 双协议),健康检查、
Elective/LessonPlan/CoursePlan CRUD、4 个新 gRPC Service 全部验证通过。

nextstep-v2.md 已创建,记录上下游依赖与 6 项联调待办。
This commit is contained in:
SpecialX
2026-07-14 17:54:37 +08:00
parent abde336876
commit 78e406b317
32 changed files with 2086 additions and 13 deletions

View File

@@ -0,0 +1,84 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { ElectivesService } from "./electives.service.js";
import type { ElectiveCourse, ElectiveSelection } from "./electives.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createElectiveCourseSchema,
listAvailableElectiveCoursesSchema,
listElectiveSelectionsSchema,
selectCourseSchema,
dropCourseSchema,
} from "./electives.dto.js";
@Controller("electives")
export class ElectivesController {
constructor(private readonly service: ElectivesService) {}
@Post("courses")
@RequirePermission(Permissions.CONTENT_ELECTIVE_CREATE)
async createCourse(
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const input = createElectiveCourseSchema.parse(body);
const result = await this.service.createCourse(input);
return { success: true, data: result };
}
@Get("courses")
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
async listAvailableCourses(
@Query() query: unknown,
): Promise<{ success: true; data: ElectiveCourse[] }> {
const input = listAvailableElectiveCoursesSchema.parse(query);
const data = await this.service.listAvailableCourses(input);
return { success: true, data };
}
@Get("courses/:id")
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
async getCourse(
@Param("id") id: string,
): Promise<{ success: true; data: ElectiveCourse }> {
const data = await this.service.getCourseById(id);
return { success: true, data };
}
@Get("selections")
@RequirePermission(Permissions.CONTENT_ELECTIVE_READ)
async listSelections(
@Query() query: unknown,
): Promise<{ success: true; data: ElectiveSelection[] }> {
const input = listElectiveSelectionsSchema.parse(query);
const data = await this.service.listSelectionsByStudent(
input.studentId,
input.status,
);
return { success: true, data };
}
@Post("select")
@RequirePermission(Permissions.CONTENT_ELECTIVE_SELECT)
async selectCourse(
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const input = selectCourseSchema.parse(body);
const result = await this.service.selectCourse(
input.studentId,
input.courseId,
);
return { success: true, data: result };
}
@Post("drop")
@RequirePermission(Permissions.CONTENT_ELECTIVE_SELECT)
async dropCourse(
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
const input = dropCourseSchema.parse(body);
await this.service.dropCourse(input.studentId, input.courseId);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
export const createElectiveCourseSchema = z.object({
title: z.string().min(1).max(255),
subjectId: z.string().min(1).max(32),
teacherId: z.string().min(1).max(32),
capacity: z.number().int().min(1).max(500).optional().default(30),
description: z.string().max(2000).optional(),
});
export const listAvailableElectiveCoursesSchema = z.object({
subjectId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export const listElectiveSelectionsSchema = z.object({
studentId: z.string().min(1).max(32),
status: z.string().optional(),
});
export const selectCourseSchema = z.object({
studentId: z.string().min(1).max(32),
courseId: z.string().min(1).max(32),
});
export const dropCourseSchema = z.object({
studentId: z.string().min(1).max(32),
courseId: z.string().min(1).max(32),
});
export type CreateElectiveCourseDto = z.infer<
typeof createElectiveCourseSchema
>;
export type ListAvailableElectiveCoursesDto = z.infer<
typeof listAvailableElectiveCoursesSchema
>;
export type ListElectiveSelectionsDto = z.infer<
typeof listElectiveSelectionsSchema
>;
export type SelectCourseDto = z.infer<typeof selectCourseSchema>;
export type DropCourseDto = z.infer<typeof dropCourseSchema>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ElectivesController } from "./electives.controller.js";
import { ElectivesService } from "./electives.service.js";
@Module({
controllers: [ElectivesController],
providers: [ElectivesService],
exports: [ElectivesService],
})
export class ElectivesModule {}

View File

@@ -0,0 +1,118 @@
import { eq, and } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
electiveCourses,
electiveSelections,
type ElectiveCourse,
type NewElectiveCourse,
type ElectiveSelection,
type NewElectiveSelection,
} from "./electives.schema.js";
export class ElectivesRepository {
async findCourseById(id: string): Promise<ElectiveCourse | undefined> {
const [result] = await getDb()
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, id))
.limit(1);
return result;
}
async findCourses(query?: {
subjectId?: string;
page?: number;
pageSize?: number;
}): Promise<ElectiveCourse[]> {
const db = getDb();
let q = db.select().from(electiveCourses).$dynamic();
if (query?.subjectId) {
q = q.where(eq(electiveCourses.subjectId, query.subjectId));
}
const pageSize = query?.pageSize ?? 20;
const page = query?.page ?? 1;
return q.limit(pageSize).offset((page - 1) * pageSize);
}
async createCourse(data: NewElectiveCourse): Promise<void> {
await getDb().insert(electiveCourses).values(data);
}
async updateCourse(
id: string,
data: Partial<NewElectiveCourse>,
): Promise<void> {
await getDb()
.update(electiveCourses)
.set(data)
.where(eq(electiveCourses.id, id));
}
async deleteCourse(id: string): Promise<void> {
await getDb().delete(electiveCourses).where(eq(electiveCourses.id, id));
}
async findSelectionsByStudent(
studentId: string,
status?: string,
): Promise<ElectiveSelection[]> {
const db = getDb();
let q = db
.select()
.from(electiveSelections)
.where(eq(electiveSelections.studentId, studentId))
.$dynamic();
if (status) {
q = q.where(eq(electiveSelections.status, status));
}
return q;
}
async findActiveSelection(
studentId: string,
courseId: string,
): Promise<ElectiveSelection | undefined> {
const [result] = await getDb()
.select()
.from(electiveSelections)
.where(
and(
eq(electiveSelections.studentId, studentId),
eq(electiveSelections.courseId, courseId),
eq(electiveSelections.status, "selected"),
),
)
.limit(1);
return result;
}
async createSelection(data: NewElectiveSelection): Promise<void> {
await getDb().insert(electiveSelections).values(data);
}
async updateSelection(
id: string,
data: Partial<NewElectiveSelection>,
): Promise<void> {
await getDb()
.update(electiveSelections)
.set(data)
.where(eq(electiveSelections.id, id));
}
async countEnrolled(courseId: string): Promise<number> {
const db = getDb();
const rows = await db
.select()
.from(electiveSelections)
.where(
and(
eq(electiveSelections.courseId, courseId),
eq(electiveSelections.status, "selected"),
),
);
return rows.length;
}
}
export const electivesRepository = new ElectivesRepository();

View File

@@ -0,0 +1,57 @@
import {
mysqlTable,
varchar,
timestamp,
text,
int,
index,
} from "drizzle-orm/mysql-core";
// 选修课目录:教师/管理员发布的可选修课程
export const electiveCourses = mysqlTable(
"content_elective_courses",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
subjectId: varchar("subject_id", { length: 32 }).notNull(),
teacherId: varchar("teacher_id", { length: 32 }).notNull(),
capacity: int("capacity").notNull().default(30),
enrolledCount: int("enrolled_count").notNull().default(0),
status: varchar("status", { length: 32 }).notNull().default("open"),
description: text("description"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
subjectIdx: index("idx_elective_subject").on(table.subjectId),
teacherIdx: index("idx_elective_teacher").on(table.teacherId),
statusIdx: index("idx_elective_status").on(table.status),
}),
);
// 学生选课记录
export const electiveSelections = mysqlTable(
"content_elective_selections",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
studentId: varchar("student_id", { length: 32 }).notNull(),
courseId: varchar("course_id", { length: 32 }).notNull(),
status: varchar("status", { length: 32 }).notNull().default("selected"),
selectedAt: timestamp("selected_at").notNull().defaultNow(),
droppedAt: timestamp("dropped_at"),
},
(table) => ({
studentIdx: index("idx_selection_student").on(table.studentId),
courseIdx: index("idx_selection_course").on(table.courseId),
studentCourseIdx: index("idx_selection_student_course").on(
table.studentId,
table.courseId,
),
statusIdx: index("idx_selection_status").on(table.status),
}),
);
export type ElectiveCourse = typeof electiveCourses.$inferSelect;
export type NewElectiveCourse = typeof electiveCourses.$inferInsert;
export type ElectiveSelection = typeof electiveSelections.$inferSelect;
export type NewElectiveSelection = typeof electiveSelections.$inferInsert;

View File

@@ -0,0 +1,134 @@
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { electivesRepository } from "./electives.repository.js";
import type {
ElectiveCourse,
ElectiveSelection,
NewElectiveCourse,
NewElectiveSelection,
} from "./electives.schema.js";
import {
NotFoundError,
ValidationError,
ConflictError,
} from "../shared/errors/application-error.js";
export interface CreateElectiveCourseInput {
title: string;
subjectId: string;
teacherId: string;
capacity?: number;
description?: string;
}
export interface ListAvailableCoursesInput {
subjectId?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class ElectivesService {
async createCourse(
input: CreateElectiveCourseInput,
): Promise<{ id: string }> {
const id = createId();
const record: NewElectiveCourse = {
id,
title: input.title,
subjectId: input.subjectId,
teacherId: input.teacherId,
capacity: input.capacity ?? 30,
enrolledCount: 0,
status: "open",
description: input.description ?? null,
};
await electivesRepository.createCourse(record);
return { id };
}
async listAvailableCourses(
query: ListAvailableCoursesInput,
): Promise<ElectiveCourse[]> {
return electivesRepository.findCourses(query);
}
async getCourseById(id: string): Promise<ElectiveCourse> {
const course = await electivesRepository.findCourseById(id);
if (!course) {
throw new NotFoundError("ElectiveCourse", id);
}
return course;
}
async listSelectionsByStudent(
studentId: string,
status?: string,
): Promise<ElectiveSelection[]> {
return electivesRepository.findSelectionsByStudent(studentId, status);
}
async selectCourse(
studentId: string,
courseId: string,
): Promise<{ id: string }> {
const course = await this.getCourseById(courseId);
if (course.status !== "open") {
throw new ValidationError(
`Course is not open for selection: ${courseId}`,
);
}
const existing = await electivesRepository.findActiveSelection(
studentId,
courseId,
);
if (existing) {
throw new ConflictError(
`Student ${studentId} has already selected course ${courseId}`,
);
}
const enrolled = await electivesRepository.countEnrolled(courseId);
if (enrolled >= course.capacity) {
throw new ValidationError(
`Course ${courseId} is full (capacity=${course.capacity})`,
);
}
const id = createId();
const record: NewElectiveSelection = {
id,
studentId,
courseId,
status: "selected",
};
await electivesRepository.createSelection(record);
await electivesRepository.updateCourse(courseId, {
enrolledCount: enrolled + 1,
});
return { id };
}
async dropCourse(studentId: string, courseId: string): Promise<void> {
const existing = await electivesRepository.findActiveSelection(
studentId,
courseId,
);
if (!existing) {
throw new NotFoundError("ElectiveSelection", `${studentId}/${courseId}`);
}
await electivesRepository.updateSelection(existing.id, {
status: "dropped",
droppedAt: new Date(),
});
const enrolled = await electivesRepository.countEnrolled(courseId);
await electivesRepository.updateCourse(courseId, {
enrolledCount: Math.max(0, enrolled - 1),
});
}
}