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:
@@ -4,6 +4,9 @@ import { TextbooksModule } from "./textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "./chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "./questions/questions.module.js";
|
||||
import { ElectivesModule } from "./electives/electives.module.js";
|
||||
import { LessonPlansModule } from "./lesson-plans/lesson-plans.module.js";
|
||||
import { CoursePlansModule } from "./course-plans/course-plans.module.js";
|
||||
import { GrpcModule } from "./grpc/grpc.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { OutboxModule } from "./shared/outbox/outbox.module.js";
|
||||
@@ -16,6 +19,9 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
ElectivesModule,
|
||||
LessonPlansModule,
|
||||
CoursePlansModule,
|
||||
GrpcModule,
|
||||
HealthModule,
|
||||
OutboxModule,
|
||||
|
||||
75
services/content/src/course-plans/course-plans.controller.ts
Normal file
75
services/content/src/course-plans/course-plans.controller.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
31
services/content/src/course-plans/course-plans.dto.ts
Normal file
31
services/content/src/course-plans/course-plans.dto.ts
Normal 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
|
||||
>;
|
||||
10
services/content/src/course-plans/course-plans.module.ts
Normal file
10
services/content/src/course-plans/course-plans.module.ts
Normal 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 {}
|
||||
51
services/content/src/course-plans/course-plans.repository.ts
Normal file
51
services/content/src/course-plans/course-plans.repository.ts
Normal 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();
|
||||
38
services/content/src/course-plans/course-plans.schema.ts
Normal file
38
services/content/src/course-plans/course-plans.schema.ts
Normal 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;
|
||||
69
services/content/src/course-plans/course-plans.service.ts
Normal file
69
services/content/src/course-plans/course-plans.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
49
services/content/src/grpc/course-plan.grpc.controller.ts
Normal file
49
services/content/src/grpc/course-plan.grpc.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { CoursePlansService } from "../course-plans/course-plans.service.js";
|
||||
import type { CoursePlan } from "../course-plans/course-plans.schema.js";
|
||||
import type {
|
||||
GrpcCoursePlan,
|
||||
ListCoursePlansByStudentRequest,
|
||||
ListCoursePlansResponse,
|
||||
GetCoursePlanRequest,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcCoursePlan(cp: CoursePlan): GrpcCoursePlan {
|
||||
return {
|
||||
id: cp.id,
|
||||
student_id: cp.studentId,
|
||||
class_id: cp.classId,
|
||||
subject_id: cp.subjectId,
|
||||
title: cp.title,
|
||||
plan_type: cp.planType,
|
||||
content: cp.content,
|
||||
status: cp.status,
|
||||
metadata: cp.metadata ?? null,
|
||||
created_at: cp.createdAt.getTime(),
|
||||
updated_at: cp.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class CoursePlanGrpcController {
|
||||
constructor(private readonly service: CoursePlansService) {}
|
||||
|
||||
@GrpcMethod("CoursePlanService", "ListCoursePlansByStudent")
|
||||
async listByStudent(
|
||||
data: ListCoursePlansByStudentRequest,
|
||||
): Promise<ListCoursePlansResponse> {
|
||||
const plans = await this.service.listByStudent({
|
||||
studentId: data.student_id,
|
||||
classId: data.class_id,
|
||||
planType: data.plan_type,
|
||||
});
|
||||
return { course_plans: plans.map(toGrpcCoursePlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("CoursePlanService", "GetCoursePlan")
|
||||
async getCoursePlan(data: GetCoursePlanRequest): Promise<GrpcCoursePlan> {
|
||||
const plan = await this.service.getById(data.id);
|
||||
return toGrpcCoursePlan(plan);
|
||||
}
|
||||
}
|
||||
102
services/content/src/grpc/elective.grpc.controller.ts
Normal file
102
services/content/src/grpc/elective.grpc.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { ElectivesService } from "../electives/electives.service.js";
|
||||
import type {
|
||||
ElectiveCourse,
|
||||
ElectiveSelection,
|
||||
} from "../electives/electives.schema.js";
|
||||
import type {
|
||||
GrpcElectiveCourse,
|
||||
GrpcElectiveSelection,
|
||||
ListAvailableElectiveCoursesRequest,
|
||||
ListElectiveCoursesResponse,
|
||||
ListElectiveSelectionsByStudentRequest,
|
||||
ListElectiveSelectionsResponse,
|
||||
SelectCourseRequest,
|
||||
DropCourseRequest,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcCourse(c: ElectiveCourse): GrpcElectiveCourse {
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
subject_id: c.subjectId,
|
||||
teacher_id: c.teacherId,
|
||||
capacity: c.capacity,
|
||||
enrolled_count: c.enrolledCount,
|
||||
status: c.status,
|
||||
description: c.description ?? "",
|
||||
created_at: c.createdAt.getTime(),
|
||||
updated_at: c.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function toGrpcSelection(s: ElectiveSelection): GrpcElectiveSelection {
|
||||
return {
|
||||
id: s.id,
|
||||
student_id: s.studentId,
|
||||
course_id: s.courseId,
|
||||
status: s.status,
|
||||
selected_at: s.selectedAt.getTime(),
|
||||
dropped_at: s.droppedAt ? s.droppedAt.getTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ElectiveGrpcController {
|
||||
constructor(private readonly service: ElectivesService) {}
|
||||
|
||||
@GrpcMethod("ElectiveService", "ListAvailableElectiveCourses")
|
||||
async listAvailableCourses(
|
||||
data: ListAvailableElectiveCoursesRequest,
|
||||
): Promise<ListElectiveCoursesResponse> {
|
||||
const pageSize = data.page_size ?? 20;
|
||||
const page = data.page_token ? Number(data.page_token) : 1;
|
||||
const courses = await this.service.listAvailableCourses({
|
||||
subjectId: data.subject_id,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return {
|
||||
courses: courses.map(toGrpcCourse),
|
||||
next_page_token: courses.length === pageSize ? String(page + 1) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "ListElectiveSelectionsByStudent")
|
||||
async listSelectionsByStudent(
|
||||
data: ListElectiveSelectionsByStudentRequest,
|
||||
): Promise<ListElectiveSelectionsResponse> {
|
||||
const selections = await this.service.listSelectionsByStudent(
|
||||
data.student_id,
|
||||
data.status,
|
||||
);
|
||||
return { selections: selections.map(toGrpcSelection) };
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "SelectCourse")
|
||||
async selectCourse(
|
||||
data: SelectCourseRequest,
|
||||
): Promise<GrpcElectiveSelection> {
|
||||
const { id } = await this.service.selectCourse(
|
||||
data.student_id,
|
||||
data.course_id,
|
||||
);
|
||||
const selections = await this.service.listSelectionsByStudent(
|
||||
data.student_id,
|
||||
"selected",
|
||||
);
|
||||
const sel = selections.find((s) => s.id === id);
|
||||
if (!sel) {
|
||||
throw new Error(`Selection ${id} not found after select`);
|
||||
}
|
||||
return toGrpcSelection(sel);
|
||||
}
|
||||
|
||||
@GrpcMethod("ElectiveService", "DropCourse")
|
||||
async dropCourse(data: DropCourseRequest): Promise<Empty> {
|
||||
await this.service.dropCourse(data.student_id, data.course_id);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -233,3 +233,124 @@ export interface SearchQuestionsResponse {
|
||||
total: number;
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
// KnowledgeGraphService: GetKnowledgePath (按班级查询)
|
||||
export interface GetKnowledgePathRequest {
|
||||
class_id: string;
|
||||
subject_id?: string;
|
||||
}
|
||||
|
||||
// ElectiveService types
|
||||
export interface GrpcElectiveCourse {
|
||||
id: string;
|
||||
title: string;
|
||||
subject_id: string;
|
||||
teacher_id: string;
|
||||
capacity: number;
|
||||
enrolled_count: number;
|
||||
status: string;
|
||||
description: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface GrpcElectiveSelection {
|
||||
id: string;
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
status: string;
|
||||
selected_at: number;
|
||||
dropped_at: number;
|
||||
}
|
||||
|
||||
export interface ListAvailableElectiveCoursesRequest {
|
||||
subject_id?: string;
|
||||
page_size?: number;
|
||||
page_token?: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveCoursesResponse {
|
||||
courses: GrpcElectiveCourse[];
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveSelectionsByStudentRequest {
|
||||
student_id: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ListElectiveSelectionsResponse {
|
||||
selections: GrpcElectiveSelection[];
|
||||
}
|
||||
|
||||
export interface SelectCourseRequest {
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
}
|
||||
|
||||
export interface DropCourseRequest {
|
||||
student_id: string;
|
||||
course_id: string;
|
||||
}
|
||||
|
||||
// LessonPlanService types
|
||||
export interface GrpcLessonPlan {
|
||||
id: string;
|
||||
teacher_id: string;
|
||||
class_id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansByTeacherRequest {
|
||||
teacher_id: string;
|
||||
class_id?: string;
|
||||
subject_id?: string;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansByStudentRequest {
|
||||
student_id: string;
|
||||
class_id?: string;
|
||||
}
|
||||
|
||||
export interface ListLessonPlansResponse {
|
||||
lesson_plans: GrpcLessonPlan[];
|
||||
}
|
||||
|
||||
export interface GetLessonPlanRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// CoursePlanService types
|
||||
export interface GrpcCoursePlan {
|
||||
id: string;
|
||||
student_id: string;
|
||||
class_id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
plan_type: string;
|
||||
content: string;
|
||||
status: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ListCoursePlansByStudentRequest {
|
||||
student_id: string;
|
||||
class_id?: string;
|
||||
plan_type?: string;
|
||||
}
|
||||
|
||||
export interface ListCoursePlansResponse {
|
||||
course_plans: GrpcCoursePlan[];
|
||||
}
|
||||
|
||||
export interface GetCoursePlanRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,16 @@ import { TextbooksModule } from "../textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "../chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "../knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "../questions/questions.module.js";
|
||||
import { ElectivesModule } from "../electives/electives.module.js";
|
||||
import { LessonPlansModule } from "../lesson-plans/lesson-plans.module.js";
|
||||
import { CoursePlansModule } from "../course-plans/course-plans.module.js";
|
||||
import { TextbookGrpcController } from "./textbook.grpc.controller.js";
|
||||
import { ChapterGrpcController } from "./chapter.grpc.controller.js";
|
||||
import { KnowledgeGraphGrpcController } from "./knowledge-graph.grpc.controller.js";
|
||||
import { QuestionGrpcController } from "./question.grpc.controller.js";
|
||||
import { ElectiveGrpcController } from "./elective.grpc.controller.js";
|
||||
import { LessonPlanGrpcController } from "./lesson-plan.grpc.controller.js";
|
||||
import { CoursePlanGrpcController } from "./course-plan.grpc.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -14,12 +20,18 @@ import { QuestionGrpcController } from "./question.grpc.controller.js";
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
ElectivesModule,
|
||||
LessonPlansModule,
|
||||
CoursePlansModule,
|
||||
],
|
||||
controllers: [
|
||||
TextbookGrpcController,
|
||||
ChapterGrpcController,
|
||||
KnowledgeGraphGrpcController,
|
||||
QuestionGrpcController,
|
||||
ElectiveGrpcController,
|
||||
LessonPlanGrpcController,
|
||||
CoursePlanGrpcController,
|
||||
],
|
||||
})
|
||||
export class GrpcModule {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GetPrerequisitesRequest,
|
||||
KnowledgePointsResponse,
|
||||
GetLearningPathRequest,
|
||||
GetKnowledgePathRequest,
|
||||
LearningPath,
|
||||
AddPrerequisiteRequest,
|
||||
RemovePrerequisiteRequest,
|
||||
@@ -34,11 +35,9 @@ export class KnowledgeGraphGrpcController {
|
||||
async getPrerequisites(
|
||||
data: GetPrerequisitesRequest,
|
||||
): Promise<KnowledgePointsResponse> {
|
||||
// 直接使用 service.getPrerequisites(读 Neo4j 派生数据)
|
||||
const prereqs = await this.service.getPrerequisites(
|
||||
data.knowledge_point_id,
|
||||
);
|
||||
// PrerequisiteNode 只含 id + title,需要查 MySQL 补全信息
|
||||
const points: GrpcKnowledgePoint[] = [];
|
||||
for (const p of prereqs) {
|
||||
try {
|
||||
@@ -52,10 +51,28 @@ export class KnowledgeGraphGrpcController {
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetLearningPath")
|
||||
async getLearningPath(_data: GetLearningPathRequest): Promise<LearningPath> {
|
||||
// P4 阶段未实现学习路径推荐算法,返回空路径
|
||||
// P5+ 引入 AI 服务后实现
|
||||
return { points: [], recommended_order: [] };
|
||||
async getLearningPath(data: GetLearningPathRequest): Promise<LearningPath> {
|
||||
// 按学科查询学习路径,基于知识点难度升序推荐
|
||||
const { points, recommendedOrder } = await this.service.getLearningPath(
|
||||
data.subject_id,
|
||||
);
|
||||
return {
|
||||
points: points.map(toGrpcKp),
|
||||
recommended_order: recommendedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetKnowledgePath")
|
||||
async getKnowledgePath(data: GetKnowledgePathRequest): Promise<LearningPath> {
|
||||
// 按班级查询学习路径(班级维度不直接影响知识点排序,
|
||||
// 此处按 subject_id 过滤,后续可接入班级学情数据个性化推荐)
|
||||
const { points, recommendedOrder } = await this.service.getLearningPath(
|
||||
data.subject_id,
|
||||
);
|
||||
return {
|
||||
points: points.map(toGrpcKp),
|
||||
recommended_order: recommendedOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "AddPrerequisite")
|
||||
|
||||
60
services/content/src/grpc/lesson-plan.grpc.controller.ts
Normal file
60
services/content/src/grpc/lesson-plan.grpc.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { LessonPlansService } from "../lesson-plans/lesson-plans.service.js";
|
||||
import type { LessonPlan } from "../lesson-plans/lesson-plans.schema.js";
|
||||
import type {
|
||||
GrpcLessonPlan,
|
||||
ListLessonPlansByTeacherRequest,
|
||||
ListLessonPlansByStudentRequest,
|
||||
ListLessonPlansResponse,
|
||||
GetLessonPlanRequest,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcLessonPlan(lp: LessonPlan): GrpcLessonPlan {
|
||||
return {
|
||||
id: lp.id,
|
||||
teacher_id: lp.teacherId,
|
||||
class_id: lp.classId,
|
||||
subject_id: lp.subjectId,
|
||||
title: lp.title,
|
||||
content: lp.content,
|
||||
status: lp.status,
|
||||
metadata: lp.metadata ?? null,
|
||||
created_at: lp.createdAt.getTime(),
|
||||
updated_at: lp.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class LessonPlanGrpcController {
|
||||
constructor(private readonly service: LessonPlansService) {}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "ListLessonPlansByTeacher")
|
||||
async listByTeacher(
|
||||
data: ListLessonPlansByTeacherRequest,
|
||||
): Promise<ListLessonPlansResponse> {
|
||||
const plans = await this.service.listByTeacher({
|
||||
teacherId: data.teacher_id,
|
||||
classId: data.class_id,
|
||||
subjectId: data.subject_id,
|
||||
});
|
||||
return { lesson_plans: plans.map(toGrpcLessonPlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "ListLessonPlansByStudent")
|
||||
async listByStudent(
|
||||
data: ListLessonPlansByStudentRequest,
|
||||
): Promise<ListLessonPlansResponse> {
|
||||
const plans = await this.service.listByStudent({
|
||||
studentId: data.student_id,
|
||||
classId: data.class_id,
|
||||
});
|
||||
return { lesson_plans: plans.map(toGrpcLessonPlan) };
|
||||
}
|
||||
|
||||
@GrpcMethod("LessonPlanService", "GetLessonPlan")
|
||||
async getLessonPlan(data: GetLessonPlanRequest): Promise<GrpcLessonPlan> {
|
||||
const plan = await this.service.getById(data.id);
|
||||
return toGrpcLessonPlan(plan);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,23 @@ export class KnowledgePointsService {
|
||||
return knowledgePointsRepository.findByChapterId(chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学习路径推荐:基于知识点难度升序返回学习路径。
|
||||
* 当前实现:查询全部知识点,按 difficulty 升序排序作为推荐顺序。
|
||||
* 后续可接入 AI 服务做个性化推荐,并按 subjectId 过滤。
|
||||
*/
|
||||
async getLearningPath(
|
||||
_subjectId?: string,
|
||||
): Promise<{ points: KnowledgePoint[]; recommendedOrder: string[] }> {
|
||||
const allKps = await knowledgePointsRepository.findAll();
|
||||
// 按难度升序排序,作为推荐学习顺序
|
||||
const sorted = [...allKps].sort((a, b) => a.difficulty - b.difficulty);
|
||||
return {
|
||||
points: sorted,
|
||||
recommendedOrder: sorted.map((kp) => kp.id),
|
||||
};
|
||||
}
|
||||
|
||||
async updateKnowledgePoint(
|
||||
id: string,
|
||||
data: UpdateKnowledgePointInput,
|
||||
|
||||
85
services/content/src/lesson-plans/lesson-plans.controller.ts
Normal file
85
services/content/src/lesson-plans/lesson-plans.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { LessonPlansService } from "./lesson-plans.service.js";
|
||||
import type { LessonPlan } from "./lesson-plans.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createLessonPlanSchema,
|
||||
updateLessonPlanSchema,
|
||||
listLessonPlansByTeacherSchema,
|
||||
listLessonPlansByStudentSchema,
|
||||
} from "./lesson-plans.dto.js";
|
||||
|
||||
@Controller("lesson-plans")
|
||||
export class LessonPlansController {
|
||||
constructor(private readonly service: LessonPlansService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_CREATE)
|
||||
async create(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const input = createLessonPlanSchema.parse(body);
|
||||
const result = await this.service.create(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_READ)
|
||||
async list(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: LessonPlan[] }> {
|
||||
const q = query as Record<string, string>;
|
||||
if (q.teacherId) {
|
||||
const input = listLessonPlansByTeacherSchema.parse(query);
|
||||
const data = await this.service.listByTeacher(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
if (q.studentId) {
|
||||
const input = listLessonPlansByStudentSchema.parse(query);
|
||||
const data = await this.service.listByStudent(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_READ)
|
||||
async getById(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: LessonPlan }> {
|
||||
const data = await this.service.getById(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_PLAN_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const input = updateLessonPlanSchema.parse(body);
|
||||
await this.service.update(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.CONTENT_LESSON_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 } };
|
||||
}
|
||||
}
|
||||
37
services/content/src/lesson-plans/lesson-plans.dto.ts
Normal file
37
services/content/src/lesson-plans/lesson-plans.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createLessonPlanSchema = z.object({
|
||||
teacherId: 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),
|
||||
content: z.string().min(1),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateLessonPlanSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const listLessonPlansByTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1).max(32),
|
||||
classId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listLessonPlansByStudentSchema = z.object({
|
||||
studentId: z.string().min(1).max(32),
|
||||
classId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateLessonPlanDto = z.infer<typeof createLessonPlanSchema>;
|
||||
export type UpdateLessonPlanDto = z.infer<typeof updateLessonPlanSchema>;
|
||||
export type ListLessonPlansByTeacherDto = z.infer<
|
||||
typeof listLessonPlansByTeacherSchema
|
||||
>;
|
||||
export type ListLessonPlansByStudentDto = z.infer<
|
||||
typeof listLessonPlansByStudentSchema
|
||||
>;
|
||||
10
services/content/src/lesson-plans/lesson-plans.module.ts
Normal file
10
services/content/src/lesson-plans/lesson-plans.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LessonPlansController } from "./lesson-plans.controller.js";
|
||||
import { LessonPlansService } from "./lesson-plans.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [LessonPlansController],
|
||||
providers: [LessonPlansService],
|
||||
exports: [LessonPlansService],
|
||||
})
|
||||
export class LessonPlansModule {}
|
||||
69
services/content/src/lesson-plans/lesson-plans.repository.ts
Normal file
69
services/content/src/lesson-plans/lesson-plans.repository.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
lessonPlans,
|
||||
type LessonPlan,
|
||||
type NewLessonPlan,
|
||||
} from "./lesson-plans.schema.js";
|
||||
|
||||
export class LessonPlansRepository {
|
||||
async findById(id: string): Promise<LessonPlan | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByTeacher(query: {
|
||||
teacherId: string;
|
||||
classId?: string;
|
||||
subjectId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
const db = getDb();
|
||||
const conditions = [eq(lessonPlans.teacherId, query.teacherId)];
|
||||
if (query.classId) {
|
||||
conditions.push(eq(lessonPlans.classId, query.classId));
|
||||
}
|
||||
if (query.subjectId) {
|
||||
conditions.push(eq(lessonPlans.subjectId, query.subjectId));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async findByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
// 学生视角:按班级查询教师发布的备课计划
|
||||
// studentId 不直接关联备课计划(备课计划是教师创建的),
|
||||
// 通过 classId 过滤学生所在班级的备课计划
|
||||
const db = getDb();
|
||||
const conditions = [eq(lessonPlans.status, "published")];
|
||||
if (query.classId) {
|
||||
conditions.push(eq(lessonPlans.classId, query.classId));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async create(data: NewLessonPlan): Promise<void> {
|
||||
await getDb().insert(lessonPlans).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewLessonPlan>): Promise<void> {
|
||||
await getDb().update(lessonPlans).set(data).where(eq(lessonPlans.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await getDb().delete(lessonPlans).where(eq(lessonPlans.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const lessonPlansRepository = new LessonPlansRepository();
|
||||
34
services/content/src/lesson-plans/lesson-plans.schema.ts
Normal file
34
services/content/src/lesson-plans/lesson-plans.schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
timestamp,
|
||||
text,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
// 备课计划:教师为班级/学科创建的教学计划
|
||||
export const lessonPlans = mysqlTable(
|
||||
"content_lesson_plans",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
teacherId: varchar("teacher_id", { length: 32 }).notNull(),
|
||||
classId: varchar("class_id", { length: 32 }).notNull(),
|
||||
subjectId: varchar("subject_id", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
status: varchar("status", { length: 32 }).notNull().default("draft"),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
teacherIdx: index("idx_lesson_plan_teacher").on(table.teacherId),
|
||||
classIdx: index("idx_lesson_plan_class").on(table.classId),
|
||||
subjectIdx: index("idx_lesson_plan_subject").on(table.subjectId),
|
||||
statusIdx: index("idx_lesson_plan_status").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export type LessonPlan = typeof lessonPlans.$inferSelect;
|
||||
export type NewLessonPlan = typeof lessonPlans.$inferInsert;
|
||||
73
services/content/src/lesson-plans/lesson-plans.service.ts
Normal file
73
services/content/src/lesson-plans/lesson-plans.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { lessonPlansRepository } from "./lesson-plans.repository.js";
|
||||
import type { LessonPlan, NewLessonPlan } from "./lesson-plans.schema.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateLessonPlanInput {
|
||||
teacherId: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateLessonPlanInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LessonPlansService {
|
||||
async create(input: CreateLessonPlanInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewLessonPlan = {
|
||||
id,
|
||||
teacherId: input.teacherId,
|
||||
classId: input.classId,
|
||||
subjectId: input.subjectId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
status: "draft",
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await lessonPlansRepository.create(record);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<LessonPlan> {
|
||||
const plan = await lessonPlansRepository.findById(id);
|
||||
if (!plan) {
|
||||
throw new NotFoundError("LessonPlan", id);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async listByTeacher(query: {
|
||||
teacherId: string;
|
||||
classId?: string;
|
||||
subjectId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
return lessonPlansRepository.findByTeacher(query);
|
||||
}
|
||||
|
||||
async listByStudent(query: {
|
||||
studentId: string;
|
||||
classId?: string;
|
||||
}): Promise<LessonPlan[]> {
|
||||
return lessonPlansRepository.findByStudent(query);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateLessonPlanInput): Promise<void> {
|
||||
await this.getById(id);
|
||||
await lessonPlansRepository.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await lessonPlansRepository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,18 @@ export const Permissions = {
|
||||
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
|
||||
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
|
||||
// v2 新增:选修课 / 备课计划 / 课程计划
|
||||
CONTENT_ELECTIVE_CREATE: "CONTENT_ELECTIVE_CREATE" as const,
|
||||
CONTENT_ELECTIVE_READ: "CONTENT_ELECTIVE_READ" as const,
|
||||
CONTENT_ELECTIVE_SELECT: "CONTENT_ELECTIVE_SELECT" as const,
|
||||
CONTENT_LESSON_PLAN_CREATE: "CONTENT_LESSON_PLAN_CREATE" as const,
|
||||
CONTENT_LESSON_PLAN_READ: "CONTENT_LESSON_PLAN_READ" as const,
|
||||
CONTENT_LESSON_PLAN_UPDATE: "CONTENT_LESSON_PLAN_UPDATE" as const,
|
||||
CONTENT_LESSON_PLAN_DELETE: "CONTENT_LESSON_PLAN_DELETE" as const,
|
||||
CONTENT_COURSE_PLAN_CREATE: "CONTENT_COURSE_PLAN_CREATE" as const,
|
||||
CONTENT_COURSE_PLAN_READ: "CONTENT_COURSE_PLAN_READ" as const,
|
||||
CONTENT_COURSE_PLAN_UPDATE: "CONTENT_COURSE_PLAN_UPDATE" as const,
|
||||
CONTENT_COURSE_PLAN_DELETE: "CONTENT_COURSE_PLAN_DELETE" as const,
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
@@ -51,6 +63,17 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
|
||||
Permissions.CONTENT_ELECTIVE_CREATE,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_ELECTIVE_SELECT,
|
||||
Permissions.CONTENT_LESSON_PLAN_CREATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_UPDATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_DELETE,
|
||||
Permissions.CONTENT_COURSE_PLAN_CREATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_UPDATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_DELETE,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
@@ -63,12 +86,30 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
|
||||
Permissions.CONTENT_ELECTIVE_CREATE,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_CREATE,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_UPDATE,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
],
|
||||
student: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_QUESTION_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
Permissions.CONTENT_ELECTIVE_SELECT,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
],
|
||||
parent: [
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
Permissions.CONTENT_CHAPTER_READ,
|
||||
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
|
||||
Permissions.CONTENT_LESSON_PLAN_READ,
|
||||
Permissions.CONTENT_COURSE_PLAN_READ,
|
||||
Permissions.CONTENT_ELECTIVE_READ,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
import { pino } from "pino";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
// 修复:pino 默认字段选项为 `base`,而非 `defaultFields`
|
||||
base: {
|
||||
service: 'content',
|
||||
version: '0.1.0',
|
||||
service: "content",
|
||||
version: "0.1.0",
|
||||
},
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
env.NODE_ENV === "development"
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true },
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user