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:
@@ -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",
|
||||||
|
|||||||
@@ -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(健康检查)
|
||||||
|
* - GraphqlModule(Apollo Federation 子图,v2.1 新增)
|
||||||
|
* - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份)
|
||||||
|
* - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存)
|
||||||
|
* - RouterAuthGuard(APP_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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
268
services/core-edu/src/graphql/dataloader.service.ts
Normal file
268
services/core-edu/src/graphql/dataloader.service.ts
Normal 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 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
724
services/core-edu/src/graphql/generated/schema.graphql
Normal file
724
services/core-edu/src/graphql/generated/schema.graphql
Normal 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!]!
|
||||||
|
}
|
||||||
75
services/core-edu/src/graphql/graphql.module.ts
Normal file
75
services/core-edu/src/graphql/graphql.module.ts
Normal file
@@ -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<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 {}
|
||||||
108
services/core-edu/src/graphql/resolvers/class.resolver.ts
Normal file
108
services/core-edu/src/graphql/resolvers/class.resolver.ts
Normal file
@@ -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<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 ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
102
services/core-edu/src/graphql/resolvers/exam.resolver.ts
Normal file
102
services/core-edu/src/graphql/resolvers/exam.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
services/core-edu/src/graphql/resolvers/grade.resolver.ts
Normal file
79
services/core-edu/src/graphql/resolvers/grade.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
services/core-edu/src/graphql/resolvers/homework.resolver.ts
Normal file
82
services/core-edu/src/graphql/resolvers/homework.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
services/core-edu/src/graphql/router-auth.guard.ts
Normal file
34
services/core-edu/src/graphql/router-auth.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user