feat(content): graphql subgraph with dataloader for textbook/chapter/kp/question

- GraphQLModule: Apollo Federation 2 at /graphql

- Textbook/Chapter/KnowledgePoint/Question @key with @ResolveReference

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

- batchFind methods added to 4 repositories

- Domain modules export repositories for GraphqlModule injection
This commit is contained in:
SpecialX
2026-07-15 00:18:57 +08:00
parent 9ff7a61ee2
commit d59c4e585f
18 changed files with 1073 additions and 12 deletions

View File

@@ -0,0 +1,166 @@
/**
* content DataLoader 服务v2.1 M1 / ADR-035
*
* 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。
*
* 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
* 通过 REQUEST scope 注入,确保不同请求不共享缓存。
*/
import { Injectable, Scope } from "@nestjs/common";
import DataLoader from "dataloader";
import { createDataLoader } from "@edu/shared-ts/federation";
import { TextbooksRepository } from "../textbooks/textbooks.repository.js";
import { ChaptersRepository } from "../chapters/chapters.repository.js";
import { KnowledgePointsRepository } from "../knowledge-points/knowledge-points.repository.js";
import { QuestionsRepository } from "../questions/questions.repository.js";
/**
* Textbook EntityGraphQL 输出类型)
* 对应 content_textbooks 表
*/
export interface TextbookEntity {
id: string;
title: string;
subjectId: string;
gradeId: string;
version: string;
status: string;
tenantId: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Chapter EntityGraphQL 输出类型)
* 对应 content_chapters 表
*/
export interface ChapterEntity {
id: string;
textbookId: string;
title: string;
order: number;
parentId: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
}
/**
* KnowledgePoint EntityGraphQL 输出类型)
* 对应 content_knowledge_points 表
*/
export interface KnowledgePointEntity {
id: string;
chapterId: string;
title: string;
description: string | null;
difficulty: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Question EntityGraphQL 输出类型)
* 对应 content_questions 表
*/
export interface QuestionEntity {
id: string;
knowledgePointId: string;
type: string;
content: string;
answer: string;
explanation: string | null;
difficulty: number;
status: string;
source: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderService {
private textbookLoaderInstance: DataLoader<
string,
TextbookEntity | null
> | null = null;
private chapterLoaderInstance: DataLoader<
string,
ChapterEntity | null
> | null = null;
private knowledgePointLoaderInstance: DataLoader<
string,
KnowledgePointEntity | null
> | null = null;
private questionLoaderInstance: DataLoader<
string,
QuestionEntity | null
> | null = null;
constructor(
private readonly textbooksRepository: TextbooksRepository,
private readonly chaptersRepository: ChaptersRepository,
private readonly knowledgePointsRepository: KnowledgePointsRepository,
private readonly questionsRepository: QuestionsRepository,
) {}
/** Textbook @key 解析器 DataLoader */
get textbookLoader(): DataLoader<string, TextbookEntity | null> {
if (!this.textbookLoaderInstance) {
this.textbookLoaderInstance = createDataLoader<
string,
TextbookEntity | null
>(async (ids) => {
const rows = await this.textbooksRepository.batchFind([...ids]);
const map = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => map.get(id) ?? null);
});
}
return this.textbookLoaderInstance;
}
/** Chapter @key 解析器 DataLoader */
get chapterLoader(): DataLoader<string, ChapterEntity | null> {
if (!this.chapterLoaderInstance) {
this.chapterLoaderInstance = createDataLoader<
string,
ChapterEntity | null
>(async (ids) => {
const rows = await this.chaptersRepository.batchFind([...ids]);
const map = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => map.get(id) ?? null);
});
}
return this.chapterLoaderInstance;
}
/** KnowledgePoint @key 解析器 DataLoader */
get knowledgePointLoader(): DataLoader<string, KnowledgePointEntity | null> {
if (!this.knowledgePointLoaderInstance) {
this.knowledgePointLoaderInstance = createDataLoader<
string,
KnowledgePointEntity | null
>(async (ids) => {
const rows = await this.knowledgePointsRepository.batchFind([...ids]);
const map = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => map.get(id) ?? null);
});
}
return this.knowledgePointLoaderInstance;
}
/** Question @key 解析器 DataLoader */
get questionLoader(): DataLoader<string, QuestionEntity | null> {
if (!this.questionLoaderInstance) {
this.questionLoaderInstance = createDataLoader<
string,
QuestionEntity | null
>(async (ids) => {
const rows = await this.questionsRepository.batchFind([...ids]);
const map = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => map.get(id) ?? null);
});
}
return this.questionLoaderInstance;
}
}

View File

@@ -0,0 +1,414 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件content.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
extend type Query
type Textbook @key(fields: "id") {
id: String
title: String
subject_id: String
grade_id: String
version: String
status: String
tenant_id: String
metadata: Struct
created_at: String
updated_at: String
}
type Chapter @key(fields: "id") {
id: String
textbook_id: String
title: String
order: Int
parent_id: String
status: String
created_at: String
updated_at: String
}
type KnowledgePoint @key(fields: "id") {
id: String
chapter_id: String
title: String
description: String
difficulty: Int
metadata: Struct
created_at: String
updated_at: String
}
type Question @key(fields: "id") {
id: String
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
explanation: String
difficulty: Int
status: String
source: String
created_by: String
metadata: Struct
created_at: String
updated_at: String
}
type GetTextbookRequest @key(fields: "id") {
id: String
}
type UpdateTextbookRequest @key(fields: "id") {
id: String
title: String
status: String
metadata: Struct
}
type DeleteTextbookRequest @key(fields: "id") {
id: String
}
type GetChapterRequest @key(fields: "id") {
id: String
}
type UpdateChapterRequest @key(fields: "id") {
id: String
title: String
order: Int
status: String
}
type DeleteChapterRequest @key(fields: "id") {
id: String
}
type GetQuestionRequest @key(fields: "id") {
id: String
}
type UpdateQuestionRequest @key(fields: "id") {
id: String
content: String
answer: String
status: String
options: Struct
explanation: String
difficulty: Int
}
type DeleteQuestionRequest @key(fields: "id") {
id: String
}
type PublishQuestionRequest @key(fields: "id") {
id: String
}
type ElectiveCourse @key(fields: "id") {
id: String
title: String
subject_id: String
teacher_id: String
capacity: Int
enrolled_count: Int
status: String
description: String
created_at: String
updated_at: String
}
type ElectiveSelection @key(fields: "id") {
id: String
student_id: String
course_id: String
status: String
selected_at: String
dropped_at: String
}
type LessonPlan @key(fields: "id") {
id: String
teacher_id: String
class_id: String
subject_id: String
title: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetLessonPlanRequest @key(fields: "id") {
id: String
}
type CoursePlan @key(fields: "id") {
id: String
student_id: String
class_id: String
subject_id: String
title: String
plan_type: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetCoursePlanRequest @key(fields: "id") {
id: String
}
type Empty {
}
input CreateTextbookRequestInput {
title: String
subject_id: String
grade_id: String
version: String
metadata: Struct
}
input ListTextbooksRequestInput {
subject_id: String
grade_id: String
page_token: String
page_size: Int
}
input ListTextbooksResponseInput {
textbooks: Textbook
next_page_token: String
}
input CreateChapterRequestInput {
textbook_id: String
title: String
order: Int
parent_id: String
}
input ListChaptersRequestInput {
textbook_id: String
parent_id: String
}
input ListChaptersResponseInput {
chapters: Chapter
}
input GetPrerequisitesRequestInput {
knowledge_point_id: String
depth: Int
}
input KnowledgePointsResponseInput {
points: KnowledgePoint
}
input GetLearningPathRequestInput {
student_id: String
subject_id: String
}
type LearningPath {
points: KnowledgePoint
recommended_order: String
}
input AddPrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input RemovePrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input CreateQuestionRequestInput {
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
explanation: String
difficulty: Int
source: String
created_by: String
metadata: Struct
}
input BatchCreateQuestionsRequestInput {
questions: CreateQuestionRequest
}
input BatchCreateQuestionsResponseInput {
ids: String
failed: BatchCreateFailure
}
type BatchCreateFailure {
index: Int
error: String
}
input ListQuestionsRequestInput {
knowledge_point_id: String
type: String
difficulty: Int
status: String
page_token: String
page_size: Int
}
input ListQuestionsResponseInput {
questions: Question
next_page_token: String
}
input SearchQuestionsRequestInput {
q: String
type: String
difficulty: Int
knowledge_point_id: String
page_token: String
page_size: Int
}
input SearchQuestionsResponseInput {
questions: Question
total: Int
next_page_token: String
}
input GetKnowledgePathRequestInput {
class_id: String
subject_id: String
}
input ListAvailableElectiveCoursesRequestInput {
subject_id: String
page_size: Int
page_token: String
}
input ListElectiveCoursesResponseInput {
courses: ElectiveCourse
next_page_token: String
}
input ListElectiveSelectionsByStudentRequestInput {
student_id: String
status: String
}
input ListElectiveSelectionsResponseInput {
selections: ElectiveSelection
}
input SelectCourseRequestInput {
student_id: String
course_id: String
}
input DropCourseRequestInput {
student_id: String
course_id: String
}
input ListLessonPlansByTeacherRequestInput {
teacher_id: String
class_id: String
subject_id: String
}
input ListLessonPlansByStudentRequestInput {
student_id: String
class_id: String
}
input ListLessonPlansResponseInput {
lesson_plans: LessonPlan
}
input ListCoursePlansByStudentRequestInput {
student_id: String
class_id: String
plan_type: String
}
input ListCoursePlansResponseInput {
course_plans: CoursePlan
}
extend type Query {
textbook: Textbook
}
extend type Query {
textbooks: [ListTextbooksResponse!]!
}
extend type Query {
chapter: Chapter
}
extend type Query {
chapters: [ListChaptersResponse!]!
}
extend type Query {
prerequisites: KnowledgePointsResponse
}
extend type Query {
learningPath: LearningPath
}
extend type Query {
knowledgePath: LearningPath
}
extend type Query {
question: Question
}
extend type Query {
questions: [ListQuestionsResponse!]!
}
extend type Query {
availableElectiveCourses: [ListElectiveCoursesResponse!]!
}
extend type Query {
electiveSelectionsByStudent: [ListElectiveSelectionsResponse!]!
}
extend type Query {
lessonPlansByTeacher: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlansByStudent: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlan: LessonPlan
}
extend type Query {
coursePlansByStudent: [ListCoursePlansResponse!]!
}
extend type Query {
coursePlan: CoursePlan
}

View File

@@ -0,0 +1,63 @@
/**
* content GraphQL 子图模块v2.1 M1
*
* Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。
*
* 强制约束:
* - @key 解析器必须使用 DataLoaderADR-035
* - RouterAuthGuard 校验 Router-Authorization HeaderADR-036
* - 外部 GraphQL + 内部 gRPC 边界ADR-037
*/
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 { TextbooksModule } from "../textbooks/textbooks.module.js";
import { ChaptersModule } from "../chapters/chapters.module.js";
import { KnowledgePointsModule } from "../knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "../questions/questions.module.js";
import { TextbookResolver } from "./resolvers/textbook.resolver.js";
import { ChapterResolver } from "./resolvers/chapter.resolver.js";
import { KnowledgePointResolver } from "./resolvers/knowledge-point.resolver.js";
import { QuestionResolver } from "./resolvers/question.resolver.js";
import { DataLoaderService } from "./dataloader.service.js";
@Module({
imports: [
// 导入领域模块以注入 Repository@key DataLoader 批量加载)
TextbooksModule,
ChaptersModule,
KnowledgePointsModule,
QuestionsModule,
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: [
TextbookResolver,
ChapterResolver,
KnowledgePointResolver,
QuestionResolver,
DataLoaderService,
],
exports: [DataLoaderService],
})
export class GraphqlModule {}

View File

@@ -0,0 +1,80 @@
/**
* content Chapter Resolverv2.1 M1
*
* Apollo Federation 子图Chapter Entity
* - @key(fields: "id") 支持跨子图引用
* - @ResolveReference 使用 DataLoader 批量加载ADR-035
*/
import {
Resolver,
Query,
Args,
ID,
ResolveReference,
ObjectType,
Field,
Directive,
} from "@nestjs/graphql";
import {
DataLoaderService,
type ChapterEntity,
} from "../dataloader.service.js";
/**
* Chapter ObjectTypeFederation @key
* 对应 content_chapters 表
*/
@ObjectType()
@Directive(`@key(fields: "id")`)
export class Chapter {
@Field(() => ID)
id!: string;
@Field()
textbookId!: string;
@Field()
title!: string;
@Field()
order!: number;
@Field({ nullable: true })
parentId: string | null = null;
@Field()
status!: string;
@Field()
createdAt!: Date;
@Field()
updatedAt!: Date;
}
@Resolver(() => Chapter)
export class ChapterResolver {
constructor(private readonly loader: DataLoaderService) {}
/**
* Federation Reference Resolver
*
* 当其他子图通过 @key 引用 Chapter 时Router 调用此方法解析。
* 使用 DataLoader 批量加载,消除 N+1 查询ADR-035
*/
@ResolveReference()
async resolveReference(ref: { id: string }): Promise<ChapterEntity | null> {
return this.loader.chapterLoader.load(ref.id);
}
/**
* Query: chapter(id) → Chapter
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝ADR-036
*/
@Query(() => Chapter, { nullable: true })
async chapter(
@Args("id", { type: () => ID }) id: string,
): Promise<ChapterEntity | null> {
return this.loader.chapterLoader.load(id);
}
}

View File

@@ -0,0 +1,79 @@
/**
* content KnowledgePoint Resolverv2.1 M1
*
* Apollo Federation 子图KnowledgePoint Entity
* - @key(fields: "id") 支持跨子图引用
* - @ResolveReference 使用 DataLoader 批量加载ADR-035
*/
import {
Resolver,
Query,
Args,
ID,
ResolveReference,
ObjectType,
Field,
Directive,
} from "@nestjs/graphql";
import {
DataLoaderService,
type KnowledgePointEntity,
} from "../dataloader.service.js";
/**
* KnowledgePoint ObjectTypeFederation @key
* 对应 content_knowledge_points 表
*/
@ObjectType()
@Directive(`@key(fields: "id")`)
export class KnowledgePoint {
@Field(() => ID)
id!: string;
@Field()
chapterId!: string;
@Field()
title!: string;
@Field({ nullable: true })
description: string | null = null;
@Field()
difficulty!: number;
@Field()
createdAt!: Date;
@Field()
updatedAt!: Date;
}
@Resolver(() => KnowledgePoint)
export class KnowledgePointResolver {
constructor(private readonly loader: DataLoaderService) {}
/**
* Federation Reference Resolver
*
* 当其他子图通过 @key 引用 KnowledgePoint 时Router 调用此方法解析。
* 使用 DataLoader 批量加载,消除 N+1 查询ADR-035
*/
@ResolveReference()
async resolveReference(ref: {
id: string;
}): Promise<KnowledgePointEntity | null> {
return this.loader.knowledgePointLoader.load(ref.id);
}
/**
* Query: knowledgePoint(id) → KnowledgePoint
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝ADR-036
*/
@Query(() => KnowledgePoint, { nullable: true })
async knowledgePoint(
@Args("id", { type: () => ID }) id: string,
): Promise<KnowledgePointEntity | null> {
return this.loader.knowledgePointLoader.load(id);
}
}

View File

@@ -0,0 +1,92 @@
/**
* content Question Resolverv2.1 M1
*
* Apollo Federation 子图Question Entity
* - @key(fields: "id") 支持跨子图引用
* - @ResolveReference 使用 DataLoader 批量加载ADR-035
*/
import {
Resolver,
Query,
Args,
ID,
ResolveReference,
ObjectType,
Field,
Directive,
} from "@nestjs/graphql";
import {
DataLoaderService,
type QuestionEntity,
} from "../dataloader.service.js";
/**
* Question ObjectTypeFederation @key
* 对应 content_questions 表
*/
@ObjectType()
@Directive(`@key(fields: "id")`)
export class Question {
@Field(() => ID)
id!: string;
@Field()
knowledgePointId!: string;
@Field()
type!: string;
@Field()
content!: string;
@Field()
answer!: string;
@Field({ nullable: true })
explanation: string | null = null;
@Field()
difficulty!: number;
@Field()
status!: string;
@Field()
source!: string;
@Field()
createdBy!: string;
@Field()
createdAt!: Date;
@Field()
updatedAt!: Date;
}
@Resolver(() => Question)
export class QuestionResolver {
constructor(private readonly loader: DataLoaderService) {}
/**
* Federation Reference Resolver
*
* 当其他子图通过 @key 引用 Question 时Router 调用此方法解析。
* 使用 DataLoader 批量加载,消除 N+1 查询ADR-035
*/
@ResolveReference()
async resolveReference(ref: { id: string }): Promise<QuestionEntity | null> {
return this.loader.questionLoader.load(ref.id);
}
/**
* Query: question(id) → Question
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝ADR-036
*/
@Query(() => Question, { nullable: true })
async question(
@Args("id", { type: () => ID }) id: string,
): Promise<QuestionEntity | null> {
return this.loader.questionLoader.load(id);
}
}

View File

@@ -0,0 +1,84 @@
/**
* content Textbook Resolverv2.1 M1
*
* Apollo Federation 子图Textbook 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 TextbookEntity,
} from "../dataloader.service.js";
/**
* Textbook ObjectTypeFederation @key
* 对应 content_textbooks 表
*/
@ObjectType()
@Directive(`@key(fields: "id")`)
export class Textbook {
@Field(() => ID)
id!: string;
@Field()
title!: string;
@Field()
subjectId!: string;
@Field()
gradeId!: string;
@Field()
version!: string;
@Field()
status!: string;
@Field({ nullable: true })
tenantId: string | null = null;
@Field()
createdAt!: Date;
@Field()
updatedAt!: Date;
}
@Resolver(() => Textbook)
export class TextbookResolver {
constructor(private readonly loader: DataLoaderService) {}
/**
* Federation Reference Resolver
*
* 当其他子图通过 @key 引用 Textbook 时Router 调用此方法解析。
* 使用 DataLoader 批量加载,消除 N+1 查询ADR-035
*/
@ResolveReference()
async resolveReference(ref: { id: string }): Promise<TextbookEntity | null> {
return this.loader.textbookLoader.load(ref.id);
}
/**
* Query: textbook(id) → Textbook
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝ADR-036
*/
@Query(() => Textbook, { nullable: true })
async textbook(
@Args("id", { type: () => ID }) id: string,
): Promise<TextbookEntity | null> {
return this.loader.textbookLoader.load(id);
}
}

View File

@@ -0,0 +1,34 @@
/**
* content 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);
}
}