From d59c4e585f25b2bb674d30864001c0e79e91a28f Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:18:57 +0800 Subject: [PATCH] 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 --- services/content/package.json | 6 + services/content/src/app.module.ts | 4 + .../content/src/chapters/chapters.module.ts | 5 +- .../src/chapters/chapters.repository.ts | 10 +- .../content/src/graphql/dataloader.service.ts | 166 +++++++ .../src/graphql/generated/schema.graphql | 414 ++++++++++++++++++ .../content/src/graphql/graphql.module.ts | 63 +++ .../src/graphql/resolvers/chapter.resolver.ts | 80 ++++ .../resolvers/knowledge-point.resolver.ts | 79 ++++ .../graphql/resolvers/question.resolver.ts | 92 ++++ .../graphql/resolvers/textbook.resolver.ts | 84 ++++ .../content/src/graphql/router-auth.guard.ts | 34 ++ .../knowledge-points.module.ts | 5 +- .../knowledge-points.repository.ts | 13 +- .../content/src/questions/questions.module.ts | 5 +- .../src/questions/questions.repository.ts | 10 +- .../content/src/textbooks/textbooks.module.ts | 5 +- .../src/textbooks/textbooks.repository.ts | 10 +- 18 files changed, 1073 insertions(+), 12 deletions(-) create mode 100644 services/content/src/graphql/dataloader.service.ts create mode 100644 services/content/src/graphql/generated/schema.graphql create mode 100644 services/content/src/graphql/graphql.module.ts create mode 100644 services/content/src/graphql/resolvers/chapter.resolver.ts create mode 100644 services/content/src/graphql/resolvers/knowledge-point.resolver.ts create mode 100644 services/content/src/graphql/resolvers/question.resolver.ts create mode 100644 services/content/src/graphql/resolvers/textbook.resolver.ts create mode 100644 services/content/src/graphql/router-auth.guard.ts diff --git a/services/content/package.json b/services/content/package.json index 7461f33..b7645f9 100644 --- a/services/content/package.json +++ b/services/content/package.json @@ -12,10 +12,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@apollo/subgraph": "^2.2.3", + "@edu/shared-ts": "workspace:*", "@grpc/grpc-js": "^1.12.0", "@grpc/proto-loader": "^0.7.13", + "@nestjs/apollo": "^12.2.0", "@nestjs/common": "^10.4.0", "@nestjs/core": "^10.4.0", + "@nestjs/graphql": "^12.2.0", "@nestjs/microservices": "^10.4.0", "@nestjs/platform-express": "^10.4.0", "@opentelemetry/api": "^1.9.0", @@ -24,7 +28,9 @@ "@opentelemetry/sdk-node": "^0.55.0", "@elastic/elasticsearch": "^8.13.0", "@paralleldrive/cuid2": "^2.2.2", + "dataloader": "^2.2.2", "drizzle-orm": "^0.31.0", + "graphql": "^16.9.0", "kafkajs": "^2.2.4", "mysql2": "^3.11.0", "neo4j-driver": "^5.23.0", diff --git a/services/content/src/app.module.ts b/services/content/src/app.module.ts index a2ee1ed..01de4f4 100644 --- a/services/content/src/app.module.ts +++ b/services/content/src/app.module.ts @@ -12,6 +12,8 @@ import { HealthModule } from "./shared/health/health.module.js"; import { OutboxModule } from "./shared/outbox/outbox.module.js"; import { PermissionGuard } from "./middleware/permission.guard.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; +import { GraphqlModule } from "./graphql/graphql.module.js"; +import { RouterAuthGuard } from "./graphql/router-auth.guard.js"; @Module({ imports: [ @@ -25,9 +27,11 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; GrpcModule, HealthModule, OutboxModule, + GraphqlModule, ], providers: [ { provide: APP_GUARD, useClass: PermissionGuard }, + { provide: APP_GUARD, useClass: RouterAuthGuard }, LifecycleService, ], }) diff --git a/services/content/src/chapters/chapters.module.ts b/services/content/src/chapters/chapters.module.ts index d7e2fd1..f41ddaa 100644 --- a/services/content/src/chapters/chapters.module.ts +++ b/services/content/src/chapters/chapters.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { ChaptersController } from "./chapters.controller.js"; import { ChaptersService } from "./chapters.service.js"; +import { ChaptersRepository } from "./chapters.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; @Module({ imports: [OutboxModule], controllers: [ChaptersController], - providers: [ChaptersService], - exports: [ChaptersService], + providers: [ChaptersService, ChaptersRepository], + exports: [ChaptersService, ChaptersRepository], }) export class ChaptersModule {} diff --git a/services/content/src/chapters/chapters.repository.ts b/services/content/src/chapters/chapters.repository.ts index 3ec9c81..2a46cda 100644 --- a/services/content/src/chapters/chapters.repository.ts +++ b/services/content/src/chapters/chapters.repository.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { getDb } from "../config/database.js"; import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js"; @@ -12,6 +12,14 @@ export class ChaptersRepository { return result; } + /** + * 批量查询(DataLoader @key 解析器使用,ADR-035) + */ + async batchFind(ids: string[]): Promise { + if (ids.length === 0) return []; + return getDb().select().from(chapters).where(inArray(chapters.id, ids)); + } + async findByTextbookId(textbookId: string): Promise { return getDb() .select() diff --git a/services/content/src/graphql/dataloader.service.ts b/services/content/src/graphql/dataloader.service.ts new file mode 100644 index 0000000..bca1468 --- /dev/null +++ b/services/content/src/graphql/dataloader.service.ts @@ -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 Entity(GraphQL 输出类型) + * 对应 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 Entity(GraphQL 输出类型) + * 对应 content_chapters 表 + */ +export interface ChapterEntity { + id: string; + textbookId: string; + title: string; + order: number; + parentId: string | null; + status: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * KnowledgePoint Entity(GraphQL 输出类型) + * 对应 content_knowledge_points 表 + */ +export interface KnowledgePointEntity { + id: string; + chapterId: string; + title: string; + description: string | null; + difficulty: number; + createdAt: Date; + updatedAt: Date; +} + +/** + * Question Entity(GraphQL 输出类型) + * 对应 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/services/content/src/graphql/generated/schema.graphql b/services/content/src/graphql/generated/schema.graphql new file mode 100644 index 0000000..6e707da --- /dev/null +++ b/services/content/src/graphql/generated/schema.graphql @@ -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 +} diff --git a/services/content/src/graphql/graphql.module.ts b/services/content/src/graphql/graphql.module.ts new file mode 100644 index 0000000..52eb66a --- /dev/null +++ b/services/content/src/graphql/graphql.module.ts @@ -0,0 +1,63 @@ +/** + * content GraphQL 子图模块(v2.1 M1) + * + * Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。 + * + * 强制约束: + * - @key 解析器必须使用 DataLoader(ADR-035) + * - RouterAuthGuard 校验 Router-Authorization Header(ADR-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({ + driver: ApolloDriver, + // Federation 2 子图 + autoSchemaFile: { + path: join(process.cwd(), "src/graphql/generated/schema.graphql"), + federation: 2, + }, + // /graphql 端点(Apollo Router 访问入口) + path: "/graphql", + // 禁用 playground(生产环境通过 Router 访问) + playground: process.env.NODE_ENV === "development", + introspection: process.env.NODE_ENV === "development", + // Context 从 HTTP headers 构造 + context: (ctx: { + req: { headers: Record }; + }) => ({ + req: ctx.req, + graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers), + }), + }), + ], + providers: [ + TextbookResolver, + ChapterResolver, + KnowledgePointResolver, + QuestionResolver, + DataLoaderService, + ], + exports: [DataLoaderService], +}) +export class GraphqlModule {} diff --git a/services/content/src/graphql/resolvers/chapter.resolver.ts b/services/content/src/graphql/resolvers/chapter.resolver.ts new file mode 100644 index 0000000..5c4fa82 --- /dev/null +++ b/services/content/src/graphql/resolvers/chapter.resolver.ts @@ -0,0 +1,80 @@ +/** + * content Chapter Resolver(v2.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 ObjectType(Federation @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 { + 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 { + return this.loader.chapterLoader.load(id); + } +} diff --git a/services/content/src/graphql/resolvers/knowledge-point.resolver.ts b/services/content/src/graphql/resolvers/knowledge-point.resolver.ts new file mode 100644 index 0000000..93508fe --- /dev/null +++ b/services/content/src/graphql/resolvers/knowledge-point.resolver.ts @@ -0,0 +1,79 @@ +/** + * content KnowledgePoint Resolver(v2.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 ObjectType(Federation @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 { + 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 { + return this.loader.knowledgePointLoader.load(id); + } +} diff --git a/services/content/src/graphql/resolvers/question.resolver.ts b/services/content/src/graphql/resolvers/question.resolver.ts new file mode 100644 index 0000000..d477932 --- /dev/null +++ b/services/content/src/graphql/resolvers/question.resolver.ts @@ -0,0 +1,92 @@ +/** + * content Question Resolver(v2.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 ObjectType(Federation @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 { + 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 { + return this.loader.questionLoader.load(id); + } +} diff --git a/services/content/src/graphql/resolvers/textbook.resolver.ts b/services/content/src/graphql/resolvers/textbook.resolver.ts new file mode 100644 index 0000000..99c59cf --- /dev/null +++ b/services/content/src/graphql/resolvers/textbook.resolver.ts @@ -0,0 +1,84 @@ +/** + * content Textbook Resolver(v2.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 ObjectType(Federation @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 { + 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 { + return this.loader.textbookLoader.load(id); + } +} diff --git a/services/content/src/graphql/router-auth.guard.ts b/services/content/src/graphql/router-auth.guard.ts new file mode 100644 index 0000000..5c46430 --- /dev/null +++ b/services/content/src/graphql/router-auth.guard.ts @@ -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); + } +} diff --git a/services/content/src/knowledge-points/knowledge-points.module.ts b/services/content/src/knowledge-points/knowledge-points.module.ts index 690cb6c..5800ae5 100644 --- a/services/content/src/knowledge-points/knowledge-points.module.ts +++ b/services/content/src/knowledge-points/knowledge-points.module.ts @@ -4,12 +4,13 @@ import { KnowledgeGraphController, } from "./knowledge-points.controller.js"; import { KnowledgePointsService } from "./knowledge-points.service.js"; +import { KnowledgePointsRepository } from "./knowledge-points.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; @Module({ imports: [OutboxModule], controllers: [KnowledgePointsController, KnowledgeGraphController], - providers: [KnowledgePointsService], - exports: [KnowledgePointsService], + providers: [KnowledgePointsService, KnowledgePointsRepository], + exports: [KnowledgePointsService, KnowledgePointsRepository], }) export class KnowledgePointsModule {} diff --git a/services/content/src/knowledge-points/knowledge-points.repository.ts b/services/content/src/knowledge-points/knowledge-points.repository.ts index d5ec4b3..2ac56b0 100644 --- a/services/content/src/knowledge-points/knowledge-points.repository.ts +++ b/services/content/src/knowledge-points/knowledge-points.repository.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { getDb } from "../config/database.js"; import { knowledgePoints, @@ -16,6 +16,17 @@ export class KnowledgePointsRepository { return result; } + /** + * 批量查询(DataLoader @key 解析器使用,ADR-035) + */ + async batchFind(ids: string[]): Promise { + if (ids.length === 0) return []; + return getDb() + .select() + .from(knowledgePoints) + .where(inArray(knowledgePoints.id, ids)); + } + async findByChapterId(chapterId: string): Promise { return getDb() .select() diff --git a/services/content/src/questions/questions.module.ts b/services/content/src/questions/questions.module.ts index d61683d..93c5c5c 100644 --- a/services/content/src/questions/questions.module.ts +++ b/services/content/src/questions/questions.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { QuestionsController } from "./questions.controller.js"; import { QuestionsService } from "./questions.service.js"; +import { QuestionsRepository } from "./questions.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; @Module({ imports: [OutboxModule], controllers: [QuestionsController], - providers: [QuestionsService], - exports: [QuestionsService], + providers: [QuestionsService, QuestionsRepository], + exports: [QuestionsService, QuestionsRepository], }) export class QuestionsModule {} diff --git a/services/content/src/questions/questions.repository.ts b/services/content/src/questions/questions.repository.ts index 753274f..98bb067 100644 --- a/services/content/src/questions/questions.repository.ts +++ b/services/content/src/questions/questions.repository.ts @@ -1,4 +1,4 @@ -import { eq, like, or, and, count } from "drizzle-orm"; +import { eq, like, or, and, count, inArray } from "drizzle-orm"; import { getDb } from "../config/database.js"; import { questions, @@ -16,6 +16,14 @@ export class QuestionsRepository { return result; } + /** + * 批量查询(DataLoader @key 解析器使用,ADR-035) + */ + async batchFind(ids: string[]): Promise { + if (ids.length === 0) return []; + return getDb().select().from(questions).where(inArray(questions.id, ids)); + } + async findByKnowledgePointId(knowledgePointId: string): Promise { return getDb() .select() diff --git a/services/content/src/textbooks/textbooks.module.ts b/services/content/src/textbooks/textbooks.module.ts index 7dd7e0d..7d53c27 100644 --- a/services/content/src/textbooks/textbooks.module.ts +++ b/services/content/src/textbooks/textbooks.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common"; import { TextbooksController } from "./textbooks.controller.js"; import { TextbooksService } from "./textbooks.service.js"; +import { TextbooksRepository } from "./textbooks.repository.js"; import { OutboxModule } from "../shared/outbox/outbox.module.js"; @Module({ imports: [OutboxModule], controllers: [TextbooksController], - providers: [TextbooksService], - exports: [TextbooksService], + providers: [TextbooksService, TextbooksRepository], + exports: [TextbooksService, TextbooksRepository], }) export class TextbooksModule {} diff --git a/services/content/src/textbooks/textbooks.repository.ts b/services/content/src/textbooks/textbooks.repository.ts index 75dc822..660538a 100644 --- a/services/content/src/textbooks/textbooks.repository.ts +++ b/services/content/src/textbooks/textbooks.repository.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { getDb } from "../config/database.js"; import { textbooks, @@ -16,6 +16,14 @@ export class TextbooksRepository { return result; } + /** + * 批量查询(DataLoader @key 解析器使用,ADR-035) + */ + async batchFind(ids: string[]): Promise { + if (ids.length === 0) return []; + return getDb().select().from(textbooks).where(inArray(textbooks.id, ids)); + } + async find(query?: { subjectId?: string; gradeId?: string;