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,75 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import { CoursePlansService } from "./course-plans.service.js";
import type { CoursePlan } from "./course-plans.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createCoursePlanSchema,
updateCoursePlanSchema,
listCoursePlansByStudentSchema,
} from "./course-plans.dto.js";
@Controller("course-plans")
export class CoursePlansController {
constructor(private readonly service: CoursePlansService) {}
@Post()
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_CREATE)
async create(
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const input = createCoursePlanSchema.parse(body);
const result = await this.service.create(input);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_READ)
async list(
@Query() query: unknown,
): Promise<{ success: true; data: CoursePlan[] }> {
const input = listCoursePlansByStudentSchema.parse(query);
const data = await this.service.listByStudent(input);
return { success: true, data };
}
@Get(":id")
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: CoursePlan }> {
const data = await this.service.getById(id);
return { success: true, data };
}
@Put(":id")
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_UPDATE)
async update(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
const input = updateCoursePlanSchema.parse(body);
await this.service.update(id, input);
return { success: true, data: { success: true } };
}
@Delete(":id")
@RequirePermission(Permissions.CONTENT_COURSE_PLAN_DELETE)
async remove(
@Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.delete(id);
return { success: true, data: { success: true } };
}
}

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
export const createCoursePlanSchema = z.object({
studentId: z.string().min(1).max(32),
classId: z.string().min(1).max(32),
subjectId: z.string().min(1).max(32),
title: z.string().min(1).max(255),
planType: z.string().min(1).max(32).optional().default("default"),
content: z.string().min(1),
metadata: z.record(z.unknown()).nullish(),
});
export const updateCoursePlanSchema = z.object({
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
planType: z.string().min(1).max(32).optional(),
status: z.enum(["active", "archived"]).optional(),
metadata: z.record(z.unknown()).nullish(),
});
export const listCoursePlansByStudentSchema = z.object({
studentId: z.string().min(1).max(32),
classId: z.string().optional(),
planType: z.string().optional(),
});
export type CreateCoursePlanDto = z.infer<typeof createCoursePlanSchema>;
export type UpdateCoursePlanDto = z.infer<typeof updateCoursePlanSchema>;
export type ListCoursePlansByStudentDto = z.infer<
typeof listCoursePlansByStudentSchema
>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { CoursePlansController } from "./course-plans.controller.js";
import { CoursePlansService } from "./course-plans.service.js";
@Module({
controllers: [CoursePlansController],
providers: [CoursePlansService],
exports: [CoursePlansService],
})
export class CoursePlansModule {}

View File

@@ -0,0 +1,51 @@
import { eq, and } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
coursePlans,
type CoursePlan,
type NewCoursePlan,
} from "./course-plans.schema.js";
export class CoursePlansRepository {
async findById(id: string): Promise<CoursePlan | undefined> {
const [result] = await getDb()
.select()
.from(coursePlans)
.where(eq(coursePlans.id, id))
.limit(1);
return result;
}
async findByStudent(query: {
studentId: string;
classId?: string;
planType?: string;
}): Promise<CoursePlan[]> {
const db = getDb();
const conditions = [eq(coursePlans.studentId, query.studentId)];
if (query.classId) {
conditions.push(eq(coursePlans.classId, query.classId));
}
if (query.planType) {
conditions.push(eq(coursePlans.planType, query.planType));
}
return db
.select()
.from(coursePlans)
.where(and(...conditions));
}
async create(data: NewCoursePlan): Promise<void> {
await getDb().insert(coursePlans).values(data);
}
async update(id: string, data: Partial<NewCoursePlan>): Promise<void> {
await getDb().update(coursePlans).set(data).where(eq(coursePlans.id, id));
}
async delete(id: string): Promise<void> {
await getDb().delete(coursePlans).where(eq(coursePlans.id, id));
}
}
export const coursePlansRepository = new CoursePlansRepository();

View File

@@ -0,0 +1,38 @@
import {
mysqlTable,
varchar,
timestamp,
text,
json,
index,
} from "drizzle-orm/mysql-core";
// 课程计划:学生的个性化课程计划(按学科/类型)
export const coursePlans = mysqlTable(
"content_course_plans",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
studentId: varchar("student_id", { length: 32 }).notNull(),
classId: varchar("class_id", { length: 32 }).notNull(),
subjectId: varchar("subject_id", { length: 32 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
planType: varchar("plan_type", { length: 32 }).notNull().default("default"),
content: text("content").notNull(),
status: varchar("status", { length: 32 }).notNull().default("active"),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
studentIdx: index("idx_course_plan_student").on(table.studentId),
classIdx: index("idx_course_plan_class").on(table.classId),
studentTypeIdx: index("idx_course_plan_student_type").on(
table.studentId,
table.planType,
),
statusIdx: index("idx_course_plan_status").on(table.status),
}),
);
export type CoursePlan = typeof coursePlans.$inferSelect;
export type NewCoursePlan = typeof coursePlans.$inferInsert;

View File

@@ -0,0 +1,69 @@
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { coursePlansRepository } from "./course-plans.repository.js";
import type { CoursePlan, NewCoursePlan } from "./course-plans.schema.js";
import { NotFoundError } from "../shared/errors/application-error.js";
export interface CreateCoursePlanInput {
studentId: string;
classId: string;
subjectId: string;
title: string;
planType?: string;
content: string;
metadata?: Record<string, unknown> | null;
}
export interface UpdateCoursePlanInput {
title?: string;
content?: string;
planType?: string;
status?: string;
metadata?: Record<string, unknown> | null;
}
@Injectable()
export class CoursePlansService {
async create(input: CreateCoursePlanInput): Promise<{ id: string }> {
const id = createId();
const record: NewCoursePlan = {
id,
studentId: input.studentId,
classId: input.classId,
subjectId: input.subjectId,
title: input.title,
planType: input.planType ?? "default",
content: input.content,
status: "active",
metadata: input.metadata ?? null,
};
await coursePlansRepository.create(record);
return { id };
}
async getById(id: string): Promise<CoursePlan> {
const plan = await coursePlansRepository.findById(id);
if (!plan) {
throw new NotFoundError("CoursePlan", id);
}
return plan;
}
async listByStudent(query: {
studentId: string;
classId?: string;
planType?: string;
}): Promise<CoursePlan[]> {
return coursePlansRepository.findByStudent(query);
}
async update(id: string, data: UpdateCoursePlanInput): Promise<void> {
await this.getById(id);
await coursePlansRepository.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await coursePlansRepository.delete(id);
}
}