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"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<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> {
|
||||
await db.update(exams).set(data).where(eq(exams.id, id));
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<Grade[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return db.select().from(grades).where(inArray(grades.id, ids));
|
||||
}
|
||||
|
||||
async findByExamId(examId: string): Promise<Grade[]> {
|
||||
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 { 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 {}
|
||||
|
||||
@@ -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<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> {
|
||||
await db.update(homework).set(data).where(eq(homework.id, id));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user