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:
@@ -12,10 +12,14 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apollo/subgraph": "^2.2.3",
|
||||||
|
"@edu/shared-ts": "workspace:*",
|
||||||
"@grpc/grpc-js": "^1.12.0",
|
"@grpc/grpc-js": "^1.12.0",
|
||||||
"@grpc/proto-loader": "^0.7.13",
|
"@grpc/proto-loader": "^0.7.13",
|
||||||
|
"@nestjs/apollo": "^12.2.0",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
|
"@nestjs/graphql": "^12.2.0",
|
||||||
"@nestjs/microservices": "^10.4.0",
|
"@nestjs/microservices": "^10.4.0",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
@@ -24,7 +28,9 @@
|
|||||||
"@opentelemetry/sdk-node": "^0.55.0",
|
"@opentelemetry/sdk-node": "^0.55.0",
|
||||||
"@elastic/elasticsearch": "^8.13.0",
|
"@elastic/elasticsearch": "^8.13.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
"dataloader": "^2.2.2",
|
||||||
"drizzle-orm": "^0.31.0",
|
"drizzle-orm": "^0.31.0",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
"neo4j-driver": "^5.23.0",
|
"neo4j-driver": "^5.23.0",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { HealthModule } from "./shared/health/health.module.js";
|
|||||||
import { OutboxModule } from "./shared/outbox/outbox.module.js";
|
import { OutboxModule } from "./shared/outbox/outbox.module.js";
|
||||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||||
|
import { GraphqlModule } from "./graphql/graphql.module.js";
|
||||||
|
import { RouterAuthGuard } from "./graphql/router-auth.guard.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,9 +27,11 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
|||||||
GrpcModule,
|
GrpcModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
OutboxModule,
|
OutboxModule,
|
||||||
|
GraphqlModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RouterAuthGuard },
|
||||||
LifecycleService,
|
LifecycleService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ChaptersController } from "./chapters.controller.js";
|
import { ChaptersController } from "./chapters.controller.js";
|
||||||
import { ChaptersService } from "./chapters.service.js";
|
import { ChaptersService } from "./chapters.service.js";
|
||||||
|
import { ChaptersRepository } from "./chapters.repository.js";
|
||||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
controllers: [ChaptersController],
|
controllers: [ChaptersController],
|
||||||
providers: [ChaptersService],
|
providers: [ChaptersService, ChaptersRepository],
|
||||||
exports: [ChaptersService],
|
exports: [ChaptersService, ChaptersRepository],
|
||||||
})
|
})
|
||||||
export class ChaptersModule {}
|
export class ChaptersModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js";
|
import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js";
|
||||||
|
|
||||||
@@ -12,6 +12,14 @@ export class ChaptersRepository {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
async batchFind(ids: string[]): Promise<Chapter[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return getDb().select().from(chapters).where(inArray(chapters.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
|
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
|
||||||
return getDb()
|
return getDb()
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
166
services/content/src/graphql/dataloader.service.ts
Normal file
166
services/content/src/graphql/dataloader.service.ts
Normal 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 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
414
services/content/src/graphql/generated/schema.graphql
Normal file
414
services/content/src/graphql/generated/schema.graphql
Normal 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
|
||||||
|
}
|
||||||
63
services/content/src/graphql/graphql.module.ts
Normal file
63
services/content/src/graphql/graphql.module.ts
Normal file
@@ -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<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 {}
|
||||||
80
services/content/src/graphql/resolvers/chapter.resolver.ts
Normal file
80
services/content/src/graphql/resolvers/chapter.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
services/content/src/graphql/resolvers/question.resolver.ts
Normal file
92
services/content/src/graphql/resolvers/question.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
services/content/src/graphql/resolvers/textbook.resolver.ts
Normal file
84
services/content/src/graphql/resolvers/textbook.resolver.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
services/content/src/graphql/router-auth.guard.ts
Normal file
34
services/content/src/graphql/router-auth.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
KnowledgeGraphController,
|
KnowledgeGraphController,
|
||||||
} from "./knowledge-points.controller.js";
|
} from "./knowledge-points.controller.js";
|
||||||
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
||||||
|
import { KnowledgePointsRepository } from "./knowledge-points.repository.js";
|
||||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
controllers: [KnowledgePointsController, KnowledgeGraphController],
|
controllers: [KnowledgePointsController, KnowledgeGraphController],
|
||||||
providers: [KnowledgePointsService],
|
providers: [KnowledgePointsService, KnowledgePointsRepository],
|
||||||
exports: [KnowledgePointsService],
|
exports: [KnowledgePointsService, KnowledgePointsRepository],
|
||||||
})
|
})
|
||||||
export class KnowledgePointsModule {}
|
export class KnowledgePointsModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
knowledgePoints,
|
knowledgePoints,
|
||||||
@@ -16,6 +16,17 @@ export class KnowledgePointsRepository {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
async batchFind(ids: string[]): Promise<KnowledgePoint[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return getDb()
|
||||||
|
.select()
|
||||||
|
.from(knowledgePoints)
|
||||||
|
.where(inArray(knowledgePoints.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
|
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
|
||||||
return getDb()
|
return getDb()
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { QuestionsController } from "./questions.controller.js";
|
import { QuestionsController } from "./questions.controller.js";
|
||||||
import { QuestionsService } from "./questions.service.js";
|
import { QuestionsService } from "./questions.service.js";
|
||||||
|
import { QuestionsRepository } from "./questions.repository.js";
|
||||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
controllers: [QuestionsController],
|
controllers: [QuestionsController],
|
||||||
providers: [QuestionsService],
|
providers: [QuestionsService, QuestionsRepository],
|
||||||
exports: [QuestionsService],
|
exports: [QuestionsService, QuestionsRepository],
|
||||||
})
|
})
|
||||||
export class QuestionsModule {}
|
export class QuestionsModule {}
|
||||||
|
|||||||
@@ -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 { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
questions,
|
questions,
|
||||||
@@ -16,6 +16,14 @@ export class QuestionsRepository {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
async batchFind(ids: string[]): Promise<Question[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return getDb().select().from(questions).where(inArray(questions.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
|
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
|
||||||
return getDb()
|
return getDb()
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { TextbooksController } from "./textbooks.controller.js";
|
import { TextbooksController } from "./textbooks.controller.js";
|
||||||
import { TextbooksService } from "./textbooks.service.js";
|
import { TextbooksService } from "./textbooks.service.js";
|
||||||
|
import { TextbooksRepository } from "./textbooks.repository.js";
|
||||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
controllers: [TextbooksController],
|
controllers: [TextbooksController],
|
||||||
providers: [TextbooksService],
|
providers: [TextbooksService, TextbooksRepository],
|
||||||
exports: [TextbooksService],
|
exports: [TextbooksService, TextbooksRepository],
|
||||||
})
|
})
|
||||||
export class TextbooksModule {}
|
export class TextbooksModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
textbooks,
|
textbooks,
|
||||||
@@ -16,6 +16,14 @@ export class TextbooksRepository {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
async batchFind(ids: string[]): Promise<Textbook[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return getDb().select().from(textbooks).where(inArray(textbooks.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
async find(query?: {
|
async find(query?: {
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
gradeId?: string;
|
gradeId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user