diff --git a/services/core-edu/package.json b/services/core-edu/package.json index e4c9bb3..2797f71 100644 --- a/services/core-edu/package.json +++ b/services/core-edu/package.json @@ -12,25 +12,31 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@nestjs/common": "^10.4.0", - "@nestjs/core": "^10.4.0", - "@nestjs/platform-express": "^10.4.0", - "drizzle-orm": "^0.31.0", - "mysql2": "^3.11.0", - "kafkajs": "^2.2.4", + "@apollo/subgraph": "^2.2.3", + "@edu/shared-ts": "workspace:*", "@grpc/grpc-js": "^1.12.0", "@grpc/proto-loader": "^0.7.13", - "pino": "^9.4.0", - "prom-client": "^15.1.0", + "@nestjs/apollo": "^12.2.0", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/graphql": "^12.2.0", + "@nestjs/platform-express": "^10.4.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.50.0", - "@opentelemetry/sdk-node": "^0.53.0", "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", - "zod": "^3.23.0", - "uuid": "^10.0.0", + "@opentelemetry/sdk-node": "^0.53.0", + "dataloader": "^2.2.2", + "drizzle-orm": "^0.31.0", + "graphql": "^16.9.0", + "kafkajs": "^2.2.4", + "mysql2": "^3.11.0", + "pino": "^9.4.0", + "prom-client": "^15.1.0", "redis": "^4.7.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.0" + "rxjs": "^7.8.0", + "uuid": "^10.0.0", + "zod": "^3.23.0" }, "devDependencies": { "@nestjs/cli": "^10.4.0", diff --git a/services/core-edu/src/app.module.ts b/services/core-edu/src/app.module.ts index 1496b06..86fc776 100644 --- a/services/core-edu/src/app.module.ts +++ b/services/core-edu/src/app.module.ts @@ -14,7 +14,21 @@ import { HealthModule } from "./shared/health/health.module.js"; import { PermissionGuard } from "./middleware/permission.guard.js"; import { AuthMiddleware } from "./middleware/auth.middleware.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; +import { GraphqlModule } from "./graphql/graphql.module.js"; +import { RouterAuthGuard } from "./graphql/router-auth.guard.js"; +/** + * core-edu 根模块(v2.1)。 + * + * 装配: + * - 业务域模块(Exams / Homework / Grades / Attendance / Classes / Scheduling / LeaveRequests / Dashboard / Admin) + * - IamConsumerModule(消费 iam 事件) + * - HealthModule(健康检查) + * - GraphqlModule(Apollo Federation 子图,v2.1 新增) + * - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份) + * - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存) + * - RouterAuthGuard(APP_GUARD,仅 /graphql 端点生效,ADR-036) + */ @Module({ imports: [ ExamsModule, @@ -29,9 +43,11 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; AdminModule, IamConsumerModule, HealthModule, + GraphqlModule, ], providers: [ { provide: APP_GUARD, useClass: PermissionGuard }, + { provide: APP_GUARD, useClass: RouterAuthGuard }, AuthMiddleware, LifecycleService, ], diff --git a/services/core-edu/src/classes/classes.module.ts b/services/core-edu/src/classes/classes.module.ts index 60bcde8..a0fcff6 100644 --- a/services/core-edu/src/classes/classes.module.ts +++ b/services/core-edu/src/classes/classes.module.ts @@ -1,10 +1,12 @@ import { Module } from "@nestjs/common"; import { ClassesController } from "./classes.controller.js"; import { ClassesService } from "./classes.service.js"; +import { ClassesRepository } from "./classes.repository.js"; @Module({ controllers: [ClassesController], - providers: [ClassesService], - exports: [ClassesService], + providers: [ClassesService, ClassesRepository], + // 导出 ClassesRepository 供 GraphqlModule 的 DataLoader 注入(v2.1 ADR-035) + exports: [ClassesService, ClassesRepository], }) export class ClassesModule {} diff --git a/services/core-edu/src/exams/exams.module.ts b/services/core-edu/src/exams/exams.module.ts index f5477ae..470b8bb 100644 --- a/services/core-edu/src/exams/exams.module.ts +++ b/services/core-edu/src/exams/exams.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; -import { ExamsController } from './exams.controller.js'; -import { ExamsService } from './exams.service.js'; +import { Module } from "@nestjs/common"; +import { ExamsController } from "./exams.controller.js"; +import { ExamsService } from "./exams.service.js"; +import { ExamsRepository } from "./exams.repository.js"; @Module({ controllers: [ExamsController], - providers: [ExamsService], - exports: [ExamsService], + providers: [ExamsService, ExamsRepository], + // 导出 ExamsRepository 供 GraphqlModule 的 DataLoader 注入(v2.1 ADR-035) + exports: [ExamsService, ExamsRepository], }) export class ExamsModule {} diff --git a/services/core-edu/src/exams/exams.repository.ts b/services/core-edu/src/exams/exams.repository.ts index 3044d49..913811a 100644 --- a/services/core-edu/src/exams/exams.repository.ts +++ b/services/core-edu/src/exams/exams.repository.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { db } from "../config/database.js"; import { exams, @@ -26,6 +26,14 @@ export class ExamsRepository { return db.select().from(exams).where(eq(exams.classId, classId)); } + /** + * 批量查询考试(DataLoader @key 解析器使用,ADR-035) + */ + async batchFindExams(ids: string[]): Promise { + if (ids.length === 0) return []; + return db.select().from(exams).where(inArray(exams.id, ids)); + } + async update(id: string, data: Partial): Promise { await db.update(exams).set(data).where(eq(exams.id, id)); } diff --git a/services/core-edu/src/grades/grades.module.ts b/services/core-edu/src/grades/grades.module.ts index ef416c7..e19dca1 100644 --- a/services/core-edu/src/grades/grades.module.ts +++ b/services/core-edu/src/grades/grades.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; -import { GradesController } from './grades.controller.js'; -import { GradesService } from './grades.service.js'; +import { Module } from "@nestjs/common"; +import { GradesController } from "./grades.controller.js"; +import { GradesService } from "./grades.service.js"; +import { GradesRepository } from "./grades.repository.js"; @Module({ controllers: [GradesController], - providers: [GradesService], - exports: [GradesService], + providers: [GradesService, GradesRepository], + // 导出 GradesRepository 供 GraphqlModule 的 DataLoader 注入(v2.1 ADR-035) + exports: [GradesService, GradesRepository], }) export class GradesModule {} diff --git a/services/core-edu/src/grades/grades.repository.ts b/services/core-edu/src/grades/grades.repository.ts index 332453a..165edf0 100644 --- a/services/core-edu/src/grades/grades.repository.ts +++ b/services/core-edu/src/grades/grades.repository.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "../config/database.js"; import { grades, @@ -22,6 +22,14 @@ export class GradesRepository { return db.select().from(grades).where(eq(grades.studentId, studentId)); } + /** + * 批量查询成绩(DataLoader @key 解析器使用,ADR-035) + */ + async batchFindGrades(ids: string[]): Promise { + if (ids.length === 0) return []; + return db.select().from(grades).where(inArray(grades.id, ids)); + } + async findByExamId(examId: string): Promise { return db.select().from(grades).where(eq(grades.examId, examId)); } diff --git a/services/core-edu/src/graphql/dataloader.service.ts b/services/core-edu/src/graphql/dataloader.service.ts new file mode 100644 index 0000000..5b5aef3 --- /dev/null +++ b/services/core-edu/src/graphql/dataloader.service.ts @@ -0,0 +1,268 @@ +/** + * core-edu DataLoader 服务(v2.1 M1 / ADR-035) + * + * 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。 + * + * 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。 + * 通过 REQUEST scope 注入,确保不同请求不共享缓存。 + * + * 覆盖实体:Exam / Homework / Grade / ClassInfo。 + * 日期字段统一序列化为 ISO 字符串(与子图 schema 一致)。 + */ +import { Injectable, Scope } from "@nestjs/common"; +import DataLoader from "dataloader"; +import { createDataLoader } from "@edu/shared-ts/federation"; +import { ExamsRepository } from "../exams/exams.repository.js"; +import { HomeworkRepository } from "../homework/homework.repository.js"; +import { GradesRepository } from "../grades/grades.repository.js"; +import { ClassesRepository } from "../classes/classes.repository.js"; + +/** + * Exam Entity(GraphQL 输出类型) + * 对应 core_edu_exams 表,日期字段序列化为 ISO 字符串 + */ +export interface ExamEntity { + id: string; + classId: string; + subjectId: string; + title: string; + description: string | null; + examDate: string; + duration: number; + totalScore: string; + status: string; + statusChangedAt: string; + statusChangedBy: string | null; + schoolId: string; + createdBy: string; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Homework Entity(GraphQL 输出类型) + * 对应 core_edu_homework 表 + */ +export interface HomeworkEntity { + id: string; + classId: string; + subjectId: string; + title: string; + description: string | null; + dueDate: string; + gracePeriod: number; + status: string; + schoolId: string; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +/** + * Grade Entity(GraphQL 输出类型) + * 对应 core_edu_grades 表 + */ +export interface GradeEntity { + id: string; + studentId: string; + examId: string | null; + homeworkId: string | null; + score: string; + totalScore: string; + feedback: string | null; + gradedBy: string; + schoolId: string; + idempotencyKey: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * ClassInfo Entity(GraphQL 输出类型) + * 对应 classes 表 + */ +export interface ClassEntity { + id: string; + name: string; + gradeId: string; + headTeacherId: string | null; + description: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * StudentInfo Entity(GraphQL 输出类型) + * 学生身份由 iam 子图持有,core-edu 仅声明实体用于跨子图引用 + */ +export interface StudentEntity { + id: string; + name: string; + classId: string; +} + +@Injectable({ scope: Scope.REQUEST }) +export class DataLoaderService { + private examLoaderInstance: DataLoader< + string, + ExamEntity | null, + string + > | null = null; + private homeworkLoaderInstance: DataLoader< + string, + HomeworkEntity | null, + string + > | null = null; + private gradeLoaderInstance: DataLoader< + string, + GradeEntity | null, + string + > | null = null; + private classLoaderInstance: DataLoader< + string, + ClassEntity | null, + string + > | null = null; + + constructor( + private readonly examsRepository: ExamsRepository, + private readonly homeworkRepository: HomeworkRepository, + private readonly gradesRepository: GradesRepository, + private readonly classesRepository: ClassesRepository, + ) {} + + /** Exam @key 解析器 DataLoader */ + get examLoader(): DataLoader { + if (!this.examLoaderInstance) { + this.examLoaderInstance = createDataLoader( + async (examIds) => { + const exams = await this.examsRepository.batchFindExams([...examIds]); + const map = new Map( + exams.map((e) => [ + e.id, + { + id: e.id, + classId: e.classId, + subjectId: e.subjectId, + title: e.title, + description: e.description, + examDate: e.examDate.toISOString(), + duration: e.duration, + totalScore: e.totalScore, + status: e.status, + statusChangedAt: e.statusChangedAt.toISOString(), + statusChangedBy: e.statusChangedBy, + schoolId: e.schoolId, + createdBy: e.createdBy, + archivedAt: e.archivedAt ? e.archivedAt.toISOString() : null, + createdAt: e.createdAt.toISOString(), + updatedAt: e.updatedAt.toISOString(), + } satisfies ExamEntity, + ]), + ); + return examIds.map((id) => map.get(id) ?? null); + }, + ); + } + return this.examLoaderInstance; + } + + /** Homework @key 解析器 DataLoader */ + get homeworkLoader(): DataLoader { + if (!this.homeworkLoaderInstance) { + this.homeworkLoaderInstance = createDataLoader< + string, + HomeworkEntity | null + >(async (homeworkIds) => { + const homeworkList = await this.homeworkRepository.batchFindHomework([ + ...homeworkIds, + ]); + const map = new Map( + homeworkList.map((h) => [ + h.id, + { + id: h.id, + classId: h.classId, + subjectId: h.subjectId, + title: h.title, + description: h.description, + dueDate: h.dueDate.toISOString(), + gracePeriod: h.gracePeriod, + status: h.status, + schoolId: h.schoolId, + createdBy: h.createdBy, + createdAt: h.createdAt.toISOString(), + updatedAt: h.updatedAt.toISOString(), + } satisfies HomeworkEntity, + ]), + ); + return homeworkIds.map((id) => map.get(id) ?? null); + }); + } + return this.homeworkLoaderInstance; + } + + /** Grade @key 解析器 DataLoader */ + get gradeLoader(): DataLoader { + if (!this.gradeLoaderInstance) { + this.gradeLoaderInstance = createDataLoader( + async (gradeIds) => { + const gradesList = await this.gradesRepository.batchFindGrades([ + ...gradeIds, + ]); + const map = new Map( + gradesList.map((g) => [ + g.id, + { + id: g.id, + studentId: g.studentId, + examId: g.examId, + homeworkId: g.homeworkId, + score: g.score, + totalScore: g.totalScore, + feedback: g.feedback, + gradedBy: g.gradedBy, + schoolId: g.schoolId, + idempotencyKey: g.idempotencyKey, + createdAt: g.createdAt.toISOString(), + updatedAt: g.updatedAt.toISOString(), + } satisfies GradeEntity, + ]), + ); + return gradeIds.map((id) => map.get(id) ?? null); + }, + ); + } + return this.gradeLoaderInstance; + } + + /** ClassInfo @key 解析器 DataLoader */ + get classLoader(): DataLoader { + if (!this.classLoaderInstance) { + this.classLoaderInstance = createDataLoader( + async (classIds) => { + const classesList = await this.classesRepository.findByIds([ + ...classIds, + ]); + const map = new Map( + classesList.map((c) => [ + c.id, + { + id: c.id, + name: c.name, + gradeId: c.gradeId, + headTeacherId: c.headTeacherId, + description: c.description, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + } satisfies ClassEntity, + ]), + ); + return classIds.map((id) => map.get(id) ?? null); + }, + ); + } + return this.classLoaderInstance; + } +} diff --git a/services/core-edu/src/graphql/generated/schema.graphql b/services/core-edu/src/graphql/generated/schema.graphql new file mode 100644 index 0000000..0c35f3c --- /dev/null +++ b/services/core-edu/src/graphql/generated/schema.graphql @@ -0,0 +1,724 @@ +# 自动生成的 GraphQL Federation 子图(v2.1 M0) +# 源文件:core_edu.proto +# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql + +extend type Query + +type Exam @key(fields: "id") { + id: String + class_id: String + subject_id: String + title: String + description: String + exam_date: String + duration: Int + total_score: String + status: String + status_changed_at: String + status_changed_by: String + school_id: String + created_by: String + archived_at: String + created_at: String + updated_at: String +} + +type ExamQuestion @key(fields: "id") { + id: String + exam_id: String + question_id: String + order: Int + score: String + question_type: String +} + +type ExamSubmission @key(fields: "id") { + id: String + exam_id: String + student_id: String + status: String + submitted_at: String + graded_at: String + graded_by: String + total_score: String +} + +type CreateExamResponse @key(fields: "id") { + id: String +} + +type GetExamRequest @key(fields: "id") { + id: String +} + +type UpdateExamRequest @key(fields: "id") { + id: String + title: String + description: String + exam_date: String + duration: Int + total_score: String + status: String +} + +type DeleteExamRequest @key(fields: "id") { + id: String +} + +type PublishExamRequest @key(fields: "id") { + id: String + published_by: String +} + +type Homework @key(fields: "id") { + id: String + class_id: String + subject_id: String + title: String + description: String + due_date: String + grace_period: Int + status: String + school_id: String + created_by: String + created_at: String + updated_at: String +} + +type HomeworkSubmission @key(fields: "id") { + id: String + homework_id: String + student_id: String + status: String + submitted_at: String + graded_at: String + graded_by: String + total_score: String + feedback: String +} + +type AssignHomeworkResponse @key(fields: "id") { + id: String +} + +type GetHomeworkRequest @key(fields: "id") { + id: String +} + +type Grade @key(fields: "id") { + id: String + student_id: String + exam_id: String + homework_id: String + score: String + total_score: String + feedback: String + graded_by: String + school_id: String + idempotency_key: String + created_at: String + updated_at: String +} + +type RecordGradeResponse @key(fields: "id") { + id: String +} + +type GetGradeRequest @key(fields: "id") { + id: String +} + +type UpdateGradeRequest @key(fields: "id") { + id: String + score: String + feedback: String + updated_by: String +} + +type ClassInfo @key(fields: "id") { + id: String + name: String + grade_id: String + head_teacher_id: String + description: String + created_at: String + updated_at: String +} + +type StudentInfo @key(fields: "id") { + id: String + name: String + class_id: String +} + +type GetClassRequest @key(fields: "id") { + id: String +} + +type Attendance @key(fields: "id") { + id: String + schedule_id: String + student_id: String + status: String + remark: String + recorded_by: String + school_id: String + created_at: String + updated_at: String +} + +type RecordAttendanceResponse @key(fields: "id") { + id: String +} + +type GetAttendanceRequest @key(fields: "id") { + id: String +} + +type ScheduleSlotInfo @key(fields: "id") { + id: String + course_id: String + course_name: String + teacher_id: String + class_id: String + room_id: String + start_time: String + end_time: String + subject_id: String +} + +type LeaveRequest @key(fields: "id") { + id: String + student_id: String + class_id: String + leave_type: String + start_date: String + end_date: String + reason: String + status: String + submitted_by: String + reviewed_by: String + review_comment: String + school_id: String + created_at: String + updated_at: String +} + +type CreateLeaveRequestResponse @key(fields: "id") { + id: String +} + +type CancelLeaveRequestRequest @key(fields: "id") { + id: String + cancelled_by: String +} + +type SchoolInfo @key(fields: "id") { + id: String + name: String + address: String + principal_id: String + created_at: String +} + +type GradeLevelInfo @key(fields: "id") { + id: String + name: String + school_id: String + order: Int +} + +type DepartmentInfo @key(fields: "id") { + id: String + name: String + school_id: String + head_id: String + created_at: String +} + +type AcademicYearInfo @key(fields: "id") { + id: String + name: String + school_id: String + start_date: String + end_date: String + is_current: Boolean +} + +input CreateExamRequestInput { + class_id: String + subject_id: String + title: String + description: String + exam_date: String + duration: Int + total_score: String + school_id: String + created_by: String +} + +input ListExamsByClassRequestInput { + class_id: String +} + +input ListExamsResponseInput { + exams: Exam +} + +input UpdateExamResponseInput { + success: Boolean +} + +input DeleteExamResponseInput { + success: Boolean +} + +input PublishExamResponseInput { + success: Boolean +} + +input SubmitExamRequestInput { + exam_id: String + student_id: String + answers: AnswerInput +} + +type AnswerInput { + question_id: String + answer: String +} + +input SubmitExamResponseInput { + submission_id: String +} + +input GradeExamRequestInput { + exam_id: String + submission_id: String + scores: ScoreInput + graded_by: String +} + +type ScoreInput { + question_id: String + score: String + teacher_comment: String +} + +input GradeExamResponseInput { + success: Boolean + total_score: String +} + +input AssignHomeworkRequestInput { + class_id: String + subject_id: String + title: String + description: String + due_date: String + grace_period: Int + school_id: String + created_by: String +} + +input ListHomeworkByClassRequestInput { + class_id: String +} + +input ListHomeworkResponseInput { + homework: Homework +} + +input SubmitHomeworkRequestInput { + homework_id: String + student_id: String + answers: AnswerInput +} + +input SubmitHomeworkResponseInput { + submission_id: String +} + +input GradeHomeworkRequestInput { + homework_id: String + submission_id: String + scores: ScoreInput + feedback: String + graded_by: String +} + +input GradeHomeworkResponseInput { + success: Boolean + total_score: String +} + +input RecordGradeRequestInput { + student_id: String + exam_id: String + homework_id: String + score: String + total_score: String + feedback: String + graded_by: String + school_id: String + idempotency_key: String +} + +input ListGradesByStudentRequestInput { + student_id: String +} + +input ListGradesByExamRequestInput { + exam_id: String +} + +input ListGradesByHomeworkRequestInput { + homework_id: String +} + +input ListGradesResponseInput { + grades: Grade +} + +input UpdateGradeResponseInput { + success: Boolean +} + +input GetClassesByTeacherRequestInput { + teacher_id: String +} + +input GetClassesByTeacherResponseInput { + classes: ClassInfo +} + +input BatchGetClassesRequestInput { + ids: String +} + +input BatchGetClassesResponseInput { + classes: ClassInfo +} + +input ListStudentsByClassRequestInput { + class_id: String +} + +input ListStudentsByClassResponseInput { + students: StudentInfo +} + +input RecordAttendanceRequestInput { + schedule_id: String + student_id: String + status: String + remark: String + recorded_by: String + school_id: String +} + +input ListAttendanceByStudentRequestInput { + student_id: String +} + +input ListAttendanceByClassRequestInput { + class_id: String +} + +input ListAttendanceResponseInput { + attendance: Attendance +} + +input SaveExamDraftRequestInput { + exam_id: String + student_id: String + answers: AnswerInput +} + +input SaveExamDraftResponseInput { + draft_id: String +} + +input RecordExamViolationRequestInput { + exam_id: String + student_id: String + violation_type: String + detail: String + severity: Int +} + +input RecordExamViolationResponseInput { + violation_id: String +} + +input ExtendExamRequestInput { + exam_id: String + extension_seconds: Int + extended_by: String +} + +input ExtendExamResponseInput { + success: Boolean + new_duration: Int +} + +input ForceSubmitExamRequestInput { + exam_id: String + student_ids: String + forced_by: String +} + +input ForceSubmitExamResponseInput { + affected_count: Int +} + +type QuestionOrderInput { + question_id: String + order: Int +} + +input ReorderExamQuestionsRequestInput { + exam_id: String + orders: QuestionOrderInput + reordered_by: String +} + +input ReorderExamQuestionsResponseInput { + success: Boolean +} + +input GetReportCardRequestInput { + student_id: String + term_id: String +} + +type ReportCard { + student_id: String + term_id: String + entries: ReportCardEntry + overall_grade: String + class_rank: String + created_at: String +} + +type ReportCardEntry { + subject_id: String + subject_name: String + exam_score: String + exam_total: String + homework_score: String + homework_total: String + final_score: String + grade_level: String + teacher_comment: String +} + +input GetScheduleByStudentRequestInput { + student_id: String + week_start: String +} + +input GetScheduleByStudentResponseInput { + slots: ScheduleSlotInfo +} + +input ListLeaveRequestsByStudentRequestInput { + student_id: String + status: String +} + +input ListLeaveRequestsResponseInput { + leave_requests: LeaveRequest +} + +input CreateLeaveRequestRequestInput { + student_id: String + class_id: String + leave_type: String + start_date: String + end_date: String + reason: String + submitted_by: String + school_id: String +} + +input CancelLeaveRequestResponseInput { + success: Boolean +} + +input GetDashboardRequestInput { + teacher_id: String +} + +type DashboardData { + teacher_id: String + total_classes: Int + total_students: Int + pending_homework: Int + upcoming_exams: Int + ungraded_submissions: Int + classes: DashboardClassCard + upcoming_exam_list: DashboardExamCard + generated_at: String +} + +type DashboardClassCard { + class_id: String + class_name: String + student_count: Int +} + +type DashboardExamCard { + exam_id: String + title: String + exam_date: String + class_id: String + class_name: String +} + +input GetClassPerformanceRequestInput { + class_id: String + subject_id: String +} + +type ClassPerformance { + class_id: String + class_name: String + student_count: Int + average_score: String + highest_score: String + lowest_score: String + median_score: String + subjects: ClassPerformanceSubject + generated_at: String +} + +type ClassPerformanceSubject { + subject_id: String + subject_name: String + average_score: String + student_count: Int +} + +input ListSchoolsRequestInput { +} + +input ListSchoolsResponseInput { + schools: SchoolInfo +} + +input ListGradeLevelsRequestInput { + school_id: String +} + +input ListGradeLevelsResponseInput { + grade_levels: GradeLevelInfo +} + +input ListDepartmentsRequestInput { + school_id: String +} + +input ListDepartmentsResponseInput { + departments: DepartmentInfo +} + +input ListAcademicYearsRequestInput { + school_id: String +} + +input ListAcademicYearsResponseInput { + academic_years: AcademicYearInfo +} + +extend type Query { + exam: Exam +} + +extend type Query { + examsByClass: [ListExamsResponse!]! +} + +extend type Query { + homework: Homework +} + +extend type Query { + homeworkByClass: [ListHomeworkResponse!]! +} + +extend type Query { + grade: Grade +} + +extend type Query { + gradesByStudent: [ListGradesResponse!]! +} + +extend type Query { + gradesByExam: [ListGradesResponse!]! +} + +extend type Query { + gradesByHomework: [ListGradesResponse!]! +} + +extend type Query { + reportCard: ReportCard +} + +extend type Query { + class: ClassInfo +} + +extend type Query { + classesByTeacher: GetClassesByTeacherResponse +} + +extend type Query { + studentsByClass: [ListStudentsByClassResponse!]! +} + +extend type Query { + attendance: Attendance +} + +extend type Query { + attendanceByStudent: [ListAttendanceResponse!]! +} + +extend type Query { + attendanceByClass: [ListAttendanceResponse!]! +} + +extend type Query { + scheduleByStudent: GetScheduleByStudentResponse +} + +extend type Query { + leaveRequestsByStudent: [ListLeaveRequestsResponse!]! +} + +extend type Query { + dashboard: DashboardData +} + +extend type Query { + classPerformance: ClassPerformance +} + +extend type Query { + schools: [ListSchoolsResponse!]! +} + +extend type Query { + gradeLevels: [ListGradeLevelsResponse!]! +} + +extend type Query { + departments: [ListDepartmentsResponse!]! +} + +extend type Query { + academicYears: [ListAcademicYearsResponse!]! +} diff --git a/services/core-edu/src/graphql/graphql.module.ts b/services/core-edu/src/graphql/graphql.module.ts new file mode 100644 index 0000000..03d54b8 --- /dev/null +++ b/services/core-edu/src/graphql/graphql.module.ts @@ -0,0 +1,75 @@ +/** + * core-edu GraphQL 子图模块(v2.1 M1) + * + * Apollo Federation 2 子图,承担原 teacher-bff 的聚合职责(教育域部分)。 + * + * 强制约束: + * - @key 解析器必须使用 DataLoader(ADR-035) + * - RouterAuthGuard 校验 Router-Authorization Header(ADR-036) + * - 外部 GraphQL + 内部 gRPC 边界(ADR-037) + * + * 导入 ExamsModule / HomeworkModule / GradesModule / ClassesModule 以注入 + * 各 Repository 供 DataLoader 批量加载(NestJS 模块封装要求)。 + */ +import { Module } from "@nestjs/common"; +import { GraphQLModule } from "@nestjs/graphql"; +import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; +import { join } from "node:path"; +import { GraphqlContext } from "@edu/shared-ts/federation"; +import { ExamsModule } from "../exams/exams.module.js"; +import { HomeworkModule } from "../homework/homework.module.js"; +import { GradesModule } from "../grades/grades.module.js"; +import { ClassesModule } from "../classes/classes.module.js"; +import { ExamResolver } from "./resolvers/exam.resolver.js"; +import { HomeworkResolver } from "./resolvers/homework.resolver.js"; +import { GradeResolver } from "./resolvers/grade.resolver.js"; +import { + ClassResolver, + StudentInfoResolver, +} from "./resolvers/class.resolver.js"; +import { DataLoaderService } from "./dataloader.service.js"; +import { getRedisClient } from "../config/redis.js"; + +@Module({ + imports: [ + ExamsModule, + HomeworkModule, + GradesModule, + ClassesModule, + GraphQLModule.forRoot({ + driver: ApolloDriver, + // Federation 2 子图 + autoSchemaFile: { + path: join(process.cwd(), "src/graphql/generated/schema.graphql"), + federation: 2, + }, + // /graphql 端点(Apollo Router 访问入口) + path: "/graphql", + // 禁用 playground(生产环境通过 Router 访问) + playground: process.env.NODE_ENV === "development", + introspection: process.env.NODE_ENV === "development", + // Context 从 HTTP headers 构造 + context: (ctx: { + req: { headers: Record }; + }) => ({ + req: ctx.req, + graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers), + }), + }), + ], + providers: [ + ExamResolver, + HomeworkResolver, + GradeResolver, + ClassResolver, + StudentInfoResolver, + DataLoaderService, + { + // 提供 Redis 实例供后续 ScopeToken / 缓存场景使用 + provide: "REDIS_CLIENT", + useFactory: () => getRedisClient(), + }, + ], + exports: [DataLoaderService], +}) +export class GraphqlModule {} diff --git a/services/core-edu/src/graphql/resolvers/class.resolver.ts b/services/core-edu/src/graphql/resolvers/class.resolver.ts new file mode 100644 index 0000000..b543948 --- /dev/null +++ b/services/core-edu/src/graphql/resolvers/class.resolver.ts @@ -0,0 +1,108 @@ +/** + * core-edu Class Resolver(v2.1 M1) + * + * Apollo Federation 子图:ClassInfo / StudentInfo Entity + * - @key(fields: "id") 支持跨子图引用 + * - ClassInfo @ResolveReference 使用 DataLoader 批量加载(ADR-035) + * - StudentInfo 实体身份由 iam 子图持有,core-edu 仅声明类型用于跨子图引用 + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { + DataLoaderService, + type ClassEntity, + type StudentEntity, +} from "../dataloader.service.js"; + +/** + * ClassInfo ObjectType(Federation @key) + * 对应 classes 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class ClassInfo { + @Field(() => ID) + id!: string; + + @Field() + name!: string; + + @Field() + gradeId!: string; + + @Field({ nullable: true }) + headTeacherId: string | null = null; + + @Field({ nullable: true }) + description: string | null = null; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +/** + * StudentInfo ObjectType(Federation @key) + * 学生身份由 iam 子图解析(User 实体),core-edu 声明此类型用于跨子图引用 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class StudentInfo { + @Field(() => ID) + id!: string; + + @Field() + name!: string; + + @Field() + classId!: string; +} + +@Resolver(() => ClassInfo) +export class ClassResolver { + constructor(private readonly loader: DataLoaderService) {} + + @ResolveReference() + async resolveReference(ref: { id: string }): Promise { + return this.loader.classLoader.load(ref.id); + } + + @Query(() => ClassInfo, { nullable: true }) + async classInfo( + @Args("id", { type: () => ID }) id: string, + ): Promise { + return this.loader.classLoader.load(id); + } +} + +/** + * StudentInfo Resolver + * + * core-edu 不持有学生身份数据,@key 解析返回 representation, + * 实际字段(name 等)由 iam 子图提供。 + */ +@Resolver(() => StudentInfo) +export class StudentInfoResolver { + @ResolveReference() + async resolveReference(ref: { + id: string; + name?: string; + classId?: string; + }): Promise { + return { + id: ref.id, + name: ref.name ?? "", + classId: ref.classId ?? "", + }; + } +} diff --git a/services/core-edu/src/graphql/resolvers/exam.resolver.ts b/services/core-edu/src/graphql/resolvers/exam.resolver.ts new file mode 100644 index 0000000..5336a9b --- /dev/null +++ b/services/core-edu/src/graphql/resolvers/exam.resolver.ts @@ -0,0 +1,102 @@ +/** + * core-edu Exam Resolver(v2.1 M1) + * + * Apollo Federation 子图:Exam Entity + * - @key(fields: "id") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + * - Query 入口供 Apollo Router 直接查询 + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { DataLoaderService, type ExamEntity } from "../dataloader.service.js"; + +/** + * Exam ObjectType(Federation @key) + * 对应 core_edu_exams 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class Exam { + @Field(() => ID) + id!: string; + + @Field() + classId!: string; + + @Field() + subjectId!: string; + + @Field() + title!: string; + + @Field({ nullable: true }) + description: string | null = null; + + @Field() + examDate!: string; + + @Field() + duration!: number; + + @Field() + totalScore!: string; + + @Field() + status!: string; + + @Field() + statusChangedAt!: string; + + @Field({ nullable: true }) + statusChangedBy: string | null = null; + + @Field() + schoolId!: string; + + @Field() + createdBy!: string; + + @Field({ nullable: true }) + archivedAt: string | null = null; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +@Resolver(() => Exam) +export class ExamResolver { + constructor(private readonly loader: DataLoaderService) {} + + /** + * Federation Reference Resolver + * + * 当其他子图通过 @key 引用 Exam 时,Router 调用此方法解析。 + * 使用 DataLoader 批量加载,消除 N+1 查询(ADR-035)。 + */ + @ResolveReference() + async resolveReference(ref: { id: string }): Promise { + return this.loader.examLoader.load(ref.id); + } + + /** + * Query: exam(id) → Exam + * 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝(ADR-036) + */ + @Query(() => Exam, { nullable: true }) + async exam( + @Args("id", { type: () => ID }) id: string, + ): Promise { + return this.loader.examLoader.load(id); + } +} diff --git a/services/core-edu/src/graphql/resolvers/grade.resolver.ts b/services/core-edu/src/graphql/resolvers/grade.resolver.ts new file mode 100644 index 0000000..132e231 --- /dev/null +++ b/services/core-edu/src/graphql/resolvers/grade.resolver.ts @@ -0,0 +1,79 @@ +/** + * core-edu Grade Resolver(v2.1 M1) + * + * Apollo Federation 子图:Grade Entity + * - @key(fields: "id") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { DataLoaderService, type GradeEntity } from "../dataloader.service.js"; + +/** + * Grade ObjectType(Federation @key) + * 对应 core_edu_grades 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class Grade { + @Field(() => ID) + id!: string; + + @Field() + studentId!: string; + + @Field({ nullable: true }) + examId: string | null = null; + + @Field({ nullable: true }) + homeworkId: string | null = null; + + @Field() + score!: string; + + @Field() + totalScore!: string; + + @Field({ nullable: true }) + feedback: string | null = null; + + @Field() + gradedBy!: string; + + @Field() + schoolId!: string; + + @Field({ nullable: true }) + idempotencyKey: string | null = null; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +@Resolver(() => Grade) +export class GradeResolver { + constructor(private readonly loader: DataLoaderService) {} + + @ResolveReference() + async resolveReference(ref: { id: string }): Promise { + return this.loader.gradeLoader.load(ref.id); + } + + @Query(() => Grade, { nullable: true }) + async grade( + @Args("id", { type: () => ID }) id: string, + ): Promise { + return this.loader.gradeLoader.load(id); + } +} diff --git a/services/core-edu/src/graphql/resolvers/homework.resolver.ts b/services/core-edu/src/graphql/resolvers/homework.resolver.ts new file mode 100644 index 0000000..1b06162 --- /dev/null +++ b/services/core-edu/src/graphql/resolvers/homework.resolver.ts @@ -0,0 +1,82 @@ +/** + * core-edu Homework Resolver(v2.1 M1) + * + * Apollo Federation 子图:Homework Entity + * - @key(fields: "id") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { + DataLoaderService, + type HomeworkEntity, +} from "../dataloader.service.js"; + +/** + * Homework ObjectType(Federation @key) + * 对应 core_edu_homework 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class Homework { + @Field(() => ID) + id!: string; + + @Field() + classId!: string; + + @Field() + subjectId!: string; + + @Field() + title!: string; + + @Field({ nullable: true }) + description: string | null = null; + + @Field() + dueDate!: string; + + @Field() + gracePeriod!: number; + + @Field() + status!: string; + + @Field() + schoolId!: string; + + @Field() + createdBy!: string; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +@Resolver(() => Homework) +export class HomeworkResolver { + constructor(private readonly loader: DataLoaderService) {} + + @ResolveReference() + async resolveReference(ref: { id: string }): Promise { + return this.loader.homeworkLoader.load(ref.id); + } + + @Query(() => Homework, { nullable: true }) + async homework( + @Args("id", { type: () => ID }) id: string, + ): Promise { + return this.loader.homeworkLoader.load(id); + } +} diff --git a/services/core-edu/src/graphql/router-auth.guard.ts b/services/core-edu/src/graphql/router-auth.guard.ts new file mode 100644 index 0000000..d8e5032 --- /dev/null +++ b/services/core-edu/src/graphql/router-auth.guard.ts @@ -0,0 +1,34 @@ +/** + * core-edu RouterAuthGuard 包装(v2.1 ADR-036) + * + * 仅作用于 /graphql 端点,REST 路由放行(已有 PermissionGuard)。 + */ +import { Injectable, ExecutionContext } from "@nestjs/common"; +import { + RouterAuthGuard as BaseRouterAuthGuard, + type RouterAuthConfig, +} from "@edu/shared-ts/federation"; + +@Injectable() +export class RouterAuthGuard extends BaseRouterAuthGuard { + constructor() { + const config: RouterAuthConfig = { + secret: process.env.ROUTER_AUTH_SECRET ?? "", + devMode: process.env.DEV_MODE === "true", + }; + super(config); + } + + override canActivate(ctx: ExecutionContext): boolean { + const req = ctx.switchToHttp().getRequest<{ + url: string; + }>(); + + // 仅 GraphQL 端点需要校验,REST 路由放行 + if (!req.url?.startsWith("/graphql")) { + return true; + } + + return super.canActivate(ctx); + } +} diff --git a/services/core-edu/src/homework/homework.module.ts b/services/core-edu/src/homework/homework.module.ts index 985198f..c9ca17b 100644 --- a/services/core-edu/src/homework/homework.module.ts +++ b/services/core-edu/src/homework/homework.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; -import { HomeworkController } from './homework.controller.js'; -import { HomeworkService } from './homework.service.js'; +import { Module } from "@nestjs/common"; +import { HomeworkController } from "./homework.controller.js"; +import { HomeworkService } from "./homework.service.js"; +import { HomeworkRepository } from "./homework.repository.js"; @Module({ controllers: [HomeworkController], - providers: [HomeworkService], - exports: [HomeworkService], + providers: [HomeworkService, HomeworkRepository], + // 导出 HomeworkRepository 供 GraphqlModule 的 DataLoader 注入(v2.1 ADR-035) + exports: [HomeworkService, HomeworkRepository], }) export class HomeworkModule {} diff --git a/services/core-edu/src/homework/homework.repository.ts b/services/core-edu/src/homework/homework.repository.ts index 2ee9545..3a708ed 100644 --- a/services/core-edu/src/homework/homework.repository.ts +++ b/services/core-edu/src/homework/homework.repository.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { db } from "../config/database.js"; import { homework, @@ -26,6 +26,14 @@ export class HomeworkRepository { return db.select().from(homework).where(eq(homework.classId, classId)); } + /** + * 批量查询作业(DataLoader @key 解析器使用,ADR-035) + */ + async batchFindHomework(ids: string[]): Promise { + if (ids.length === 0) return []; + return db.select().from(homework).where(inArray(homework.id, ids)); + } + async update(id: string, data: Partial): Promise { await db.update(homework).set(data).where(eq(homework.id, id)); }