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:
84
services/content/src/electives/electives.controller.ts
Normal file
84
services/content/src/electives/electives.controller.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
42
services/content/src/electives/electives.dto.ts
Normal file
42
services/content/src/electives/electives.dto.ts
Normal 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>;
|
||||
10
services/content/src/electives/electives.module.ts
Normal file
10
services/content/src/electives/electives.module.ts
Normal 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 {}
|
||||
118
services/content/src/electives/electives.repository.ts
Normal file
118
services/content/src/electives/electives.repository.ts
Normal 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();
|
||||
57
services/content/src/electives/electives.schema.ts
Normal file
57
services/content/src/electives/electives.schema.ts
Normal 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;
|
||||
134
services/content/src/electives/electives.service.ts
Normal file
134
services/content/src/electives/electives.service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user