feat(core-edu): graphql subgraph with dataloader for exam/homework/grade/class

- GraphQLModule: Apollo Federation 2 at /graphql

- Exam/Homework/Grade/ClassInfo @key with @ResolveReference using DataLoader

- RouterAuthGuard: validate Router-Authorization header (ADR-036)

- batchFind methods added to exams/homework/grades/classes repositories

- Domain modules export repositories for GraphqlModule injection
This commit is contained in:
SpecialX
2026-07-15 00:18:30 +08:00
parent 6bed673d9f
commit 9ff7a61ee2
17 changed files with 1558 additions and 32 deletions

View File

@@ -12,25 +12,31 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.4.0", "@apollo/subgraph": "^2.2.3",
"@nestjs/core": "^10.4.0", "@edu/shared-ts": "workspace:*",
"@nestjs/platform-express": "^10.4.0",
"drizzle-orm": "^0.31.0",
"mysql2": "^3.11.0",
"kafkajs": "^2.2.4",
"@grpc/grpc-js": "^1.12.0", "@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13", "@grpc/proto-loader": "^0.7.13",
"pino": "^9.4.0", "@nestjs/apollo": "^12.2.0",
"prom-client": "^15.1.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/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0", "@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0", "@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"zod": "^3.23.0", "@opentelemetry/sdk-node": "^0.53.0",
"uuid": "^10.0.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", "redis": "^4.7.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0" "rxjs": "^7.8.0",
"uuid": "^10.0.0",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",

View File

@@ -14,7 +14,21 @@ import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js"; import { PermissionGuard } from "./middleware/permission.guard.js";
import { AuthMiddleware } from "./middleware/auth.middleware.js"; import { AuthMiddleware } from "./middleware/auth.middleware.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.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健康检查
* - GraphqlModuleApollo Federation 子图v2.1 新增)
* - AuthMiddleware从 Gateway 注入的 x-user-* 头部解析用户身份)
* - PermissionGuardAPP_GUARDDB 驱动 + Redis 缓存)
* - RouterAuthGuardAPP_GUARD仅 /graphql 端点生效ADR-036
*/
@Module({ @Module({
imports: [ imports: [
ExamsModule, ExamsModule,
@@ -29,9 +43,11 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
AdminModule, AdminModule,
IamConsumerModule, IamConsumerModule,
HealthModule, HealthModule,
GraphqlModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: PermissionGuard }, { provide: APP_GUARD, useClass: PermissionGuard },
{ provide: APP_GUARD, useClass: RouterAuthGuard },
AuthMiddleware, AuthMiddleware,
LifecycleService, LifecycleService,
], ],

View File

@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ClassesController } from "./classes.controller.js"; import { ClassesController } from "./classes.controller.js";
import { ClassesService } from "./classes.service.js"; import { ClassesService } from "./classes.service.js";
import { ClassesRepository } from "./classes.repository.js";
@Module({ @Module({
controllers: [ClassesController], controllers: [ClassesController],
providers: [ClassesService], providers: [ClassesService, ClassesRepository],
exports: [ClassesService], // 导出 ClassesRepository 供 GraphqlModule 的 DataLoader 注入v2.1 ADR-035
exports: [ClassesService, ClassesRepository],
}) })
export class ClassesModule {} export class ClassesModule {}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { ExamsController } from './exams.controller.js'; import { ExamsController } from "./exams.controller.js";
import { ExamsService } from './exams.service.js'; import { ExamsService } from "./exams.service.js";
import { ExamsRepository } from "./exams.repository.js";
@Module({ @Module({
controllers: [ExamsController], controllers: [ExamsController],
providers: [ExamsService], providers: [ExamsService, ExamsRepository],
exports: [ExamsService], // 导出 ExamsRepository 供 GraphqlModule 的 DataLoader 注入v2.1 ADR-035
exports: [ExamsService, ExamsRepository],
}) })
export class ExamsModule {} export class ExamsModule {}

View File

@@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { import {
exams, exams,
@@ -26,6 +26,14 @@ export class ExamsRepository {
return db.select().from(exams).where(eq(exams.classId, classId)); return db.select().from(exams).where(eq(exams.classId, classId));
} }
/**
* 批量查询考试DataLoader @key 解析器使用ADR-035
*/
async batchFindExams(ids: string[]): Promise<Exam[]> {
if (ids.length === 0) return [];
return db.select().from(exams).where(inArray(exams.id, ids));
}
async update(id: string, data: Partial<NewExam>): Promise<void> { async update(id: string, data: Partial<NewExam>): Promise<void> {
await db.update(exams).set(data).where(eq(exams.id, id)); await db.update(exams).set(data).where(eq(exams.id, id));
} }

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { GradesController } from './grades.controller.js'; import { GradesController } from "./grades.controller.js";
import { GradesService } from './grades.service.js'; import { GradesService } from "./grades.service.js";
import { GradesRepository } from "./grades.repository.js";
@Module({ @Module({
controllers: [GradesController], controllers: [GradesController],
providers: [GradesService], providers: [GradesService, GradesRepository],
exports: [GradesService], // 导出 GradesRepository 供 GraphqlModule 的 DataLoader 注入v2.1 ADR-035
exports: [GradesService, GradesRepository],
}) })
export class GradesModule {} export class GradesModule {}

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { import {
grades, grades,
@@ -22,6 +22,14 @@ export class GradesRepository {
return db.select().from(grades).where(eq(grades.studentId, studentId)); return db.select().from(grades).where(eq(grades.studentId, studentId));
} }
/**
* 批量查询成绩DataLoader @key 解析器使用ADR-035
*/
async batchFindGrades(ids: string[]): Promise<Grade[]> {
if (ids.length === 0) return [];
return db.select().from(grades).where(inArray(grades.id, ids));
}
async findByExamId(examId: string): Promise<Grade[]> { async findByExamId(examId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.examId, examId)); return db.select().from(grades).where(eq(grades.examId, examId));
} }

View File

@@ -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 EntityGraphQL 输出类型)
* 对应 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 EntityGraphQL 输出类型)
* 对应 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 EntityGraphQL 输出类型)
* 对应 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 EntityGraphQL 输出类型)
* 对应 classes 表
*/
export interface ClassEntity {
id: string;
name: string;
gradeId: string;
headTeacherId: string | null;
description: string | null;
createdAt: string;
updatedAt: string;
}
/**
* StudentInfo EntityGraphQL 输出类型)
* 学生身份由 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<string, ExamEntity | null, string> {
if (!this.examLoaderInstance) {
this.examLoaderInstance = createDataLoader<string, ExamEntity | null>(
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<string, HomeworkEntity | null, string> {
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<string, GradeEntity | null, string> {
if (!this.gradeLoaderInstance) {
this.gradeLoaderInstance = createDataLoader<string, GradeEntity | null>(
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<string, ClassEntity | null, string> {
if (!this.classLoaderInstance) {
this.classLoaderInstance = createDataLoader<string, ClassEntity | null>(
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;
}
}

View File

@@ -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!]!
}

View File

@@ -0,0 +1,75 @@
/**
* core-edu GraphQL 子图模块v2.1 M1
*
* Apollo Federation 2 子图,承担原 teacher-bff 的聚合职责(教育域部分)。
*
* 强制约束:
* - @key 解析器必须使用 DataLoaderADR-035
* - RouterAuthGuard 校验 Router-Authorization HeaderADR-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<ApolloDriverConfig>({
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<string, string | undefined> };
}) => ({
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 {}

View File

@@ -0,0 +1,108 @@
/**
* core-edu Class Resolverv2.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 ObjectTypeFederation @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 ObjectTypeFederation @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<ClassEntity | null> {
return this.loader.classLoader.load(ref.id);
}
@Query(() => ClassInfo, { nullable: true })
async classInfo(
@Args("id", { type: () => ID }) id: string,
): Promise<ClassEntity | null> {
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<StudentEntity> {
return {
id: ref.id,
name: ref.name ?? "",
classId: ref.classId ?? "",
};
}
}

View File

@@ -0,0 +1,102 @@
/**
* core-edu Exam Resolverv2.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 ObjectTypeFederation @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<ExamEntity | null> {
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<ExamEntity | null> {
return this.loader.examLoader.load(id);
}
}

View File

@@ -0,0 +1,79 @@
/**
* core-edu Grade Resolverv2.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 ObjectTypeFederation @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<GradeEntity | null> {
return this.loader.gradeLoader.load(ref.id);
}
@Query(() => Grade, { nullable: true })
async grade(
@Args("id", { type: () => ID }) id: string,
): Promise<GradeEntity | null> {
return this.loader.gradeLoader.load(id);
}
}

View File

@@ -0,0 +1,82 @@
/**
* core-edu Homework Resolverv2.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 ObjectTypeFederation @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<HomeworkEntity | null> {
return this.loader.homeworkLoader.load(ref.id);
}
@Query(() => Homework, { nullable: true })
async homework(
@Args("id", { type: () => ID }) id: string,
): Promise<HomeworkEntity | null> {
return this.loader.homeworkLoader.load(id);
}
}

View File

@@ -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);
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { HomeworkController } from './homework.controller.js'; import { HomeworkController } from "./homework.controller.js";
import { HomeworkService } from './homework.service.js'; import { HomeworkService } from "./homework.service.js";
import { HomeworkRepository } from "./homework.repository.js";
@Module({ @Module({
controllers: [HomeworkController], controllers: [HomeworkController],
providers: [HomeworkService], providers: [HomeworkService, HomeworkRepository],
exports: [HomeworkService], // 导出 HomeworkRepository 供 GraphqlModule 的 DataLoader 注入v2.1 ADR-035
exports: [HomeworkService, HomeworkRepository],
}) })
export class HomeworkModule {} export class HomeworkModule {}

View File

@@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { import {
homework, homework,
@@ -26,6 +26,14 @@ export class HomeworkRepository {
return db.select().from(homework).where(eq(homework.classId, classId)); return db.select().from(homework).where(eq(homework.classId, classId));
} }
/**
* 批量查询作业DataLoader @key 解析器使用ADR-035
*/
async batchFindHomework(ids: string[]): Promise<Homework[]> {
if (ids.length === 0) return [];
return db.select().from(homework).where(inArray(homework.id, ids));
}
async update(id: string, data: Partial<NewHomework>): Promise<void> { async update(id: string, data: Partial<NewHomework>): Promise<void> {
await db.update(homework).set(data).where(eq(homework.id, id)); await db.update(homework).set(data).where(eq(homework.id, id));
} }