diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa6b19..60b28d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,12 @@ importers: services/content: dependencies: + '@apollo/subgraph': + specifier: ^2.2.3 + version: 2.14.2(graphql@16.14.2) + '@edu/shared-ts': + specifier: workspace:* + version: link:../../packages/shared-ts '@elastic/elasticsearch': specifier: ^8.13.0 version: 8.19.2 @@ -784,12 +790,18 @@ importers: '@grpc/proto-loader': specifier: ^0.7.13 version: 0.7.13 + '@nestjs/apollo': + specifier: ^12.2.0 + version: 12.2.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.14.2))(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2))(graphql@16.14.2) '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.0 + version: 12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2) '@nestjs/microservices': specifier: ^10.4.0 version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -811,9 +823,15 @@ importers: '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 + dataloader: + specifier: ^2.2.2 + version: 2.2.3 drizzle-orm: specifier: ^0.31.0 version: 0.31.0(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@types/react@18.3.31)(better-sqlite3@11.3.0)(mysql2@3.22.6(@types/node@22.20.1))(react@18.3.0) + graphql: + specifier: ^16.9.0 + version: 16.14.2 kafkajs: specifier: ^2.2.4 version: 2.2.4 @@ -860,18 +878,30 @@ importers: services/core-edu: dependencies: + '@apollo/subgraph': + specifier: ^2.2.3 + version: 2.14.2(graphql@16.14.2) + '@edu/shared-ts': + specifier: workspace:* + version: link:../../packages/shared-ts '@grpc/grpc-js': specifier: ^1.12.0 version: 1.14.4 '@grpc/proto-loader': specifier: ^0.7.13 version: 0.7.13 + '@nestjs/apollo': + specifier: ^12.2.0 + version: 12.2.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.14.2))(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2))(graphql@16.14.2) '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.0 + version: 12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2) '@nestjs/platform-express': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) @@ -887,9 +917,15 @@ importers: '@opentelemetry/sdk-node': specifier: ^0.53.0 version: 0.53.0(@opentelemetry/api@1.9.1) + dataloader: + specifier: ^2.2.2 + version: 2.2.3 drizzle-orm: specifier: ^0.31.0 version: 0.31.0(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@types/react@18.3.31)(better-sqlite3@11.3.0)(mysql2@3.22.6(@types/node@22.20.1))(react@18.3.0) + graphql: + specifier: ^16.9.0 + version: 16.14.2 kafkajs: specifier: ^2.2.4 version: 2.2.4 @@ -1063,6 +1099,9 @@ importers: services/msg: dependencies: + '@apollo/subgraph': + specifier: ^2.2.3 + version: 2.14.2(graphql@16.14.2) '@edu/shared-ts': specifier: workspace:* version: link:../../packages/shared-ts @@ -1075,12 +1114,18 @@ importers: '@grpc/proto-loader': specifier: ^0.7.13 version: 0.7.13 + '@nestjs/apollo': + specifier: ^12.2.0 + version: 12.2.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.14.2))(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2))(graphql@16.14.2) '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.0 + version: 12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2) '@nestjs/microservices': specifier: ^10.4.0 version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -1102,9 +1147,15 @@ importers: '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 + dataloader: + specifier: ^2.2.2 + version: 2.2.3 drizzle-orm: specifier: ^0.31.0 version: 0.31.0(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@types/react@18.3.31)(better-sqlite3@11.3.0)(mysql2@3.22.6(@types/node@22.20.1))(react@18.3.0) + graphql: + specifier: ^16.9.0 + version: 16.14.2 grpc-reflection-js: specifier: ^0.3.0 version: 0.3.0(@grpc/grpc-js@1.14.4) diff --git a/services/msg/package.json b/services/msg/package.json index f637e9b..a295d3a 100644 --- a/services/msg/package.json +++ b/services/msg/package.json @@ -34,7 +34,12 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-node": "^0.55.0", "@opentelemetry/exporter-trace-otlp-http": "^0.55.0", - "@opentelemetry/auto-instrumentations-node": "^0.55.0" + "@opentelemetry/auto-instrumentations-node": "^0.55.0", + "@apollo/subgraph": "^2.2.3", + "@nestjs/apollo": "^12.2.0", + "@nestjs/graphql": "^12.2.0", + "dataloader": "^2.2.2", + "graphql": "^16.9.0" }, "devDependencies": { "@nestjs/cli": "^10.4.0", diff --git a/services/msg/src/app.module.ts b/services/msg/src/app.module.ts index d00846f..4bb4c1f 100644 --- a/services/msg/src/app.module.ts +++ b/services/msg/src/app.module.ts @@ -9,6 +9,8 @@ import { HealthModule } from "./shared/health/health.module.js"; import { PermissionGuard } from "./middleware/permission.guard.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js"; +import { GraphqlModule } from "./graphql/graphql.module.js"; +import { RouterAuthGuard } from "./graphql/router-auth.guard.js"; /** * AppModule —— msg 服务根模块。 @@ -37,9 +39,11 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js"; AnnouncementsModule, GrpcModule, HealthModule, + GraphqlModule, ], providers: [ { provide: APP_GUARD, useClass: PermissionGuard }, + { provide: APP_GUARD, useClass: RouterAuthGuard }, LifecycleService, KafkaConsumerService, ], diff --git a/services/msg/src/graphql/dataloader.service.ts b/services/msg/src/graphql/dataloader.service.ts new file mode 100644 index 0000000..8e53ef8 --- /dev/null +++ b/services/msg/src/graphql/dataloader.service.ts @@ -0,0 +1,103 @@ +/** + * msg DataLoader 服务(v2.1 M1 / ADR-035) + * + * 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。 + * + * 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。 + * 通过 REQUEST scope 注入,确保不同请求不共享缓存。 + * + * 注:msg 仓库为函数式(非类),DataLoader 直接 import batch 函数调用, + * 这是函数式仓库对 iam 类仓库 DI 注入的等价适配。 + */ +import { Injectable, Scope } from "@nestjs/common"; +import DataLoader from "dataloader"; +import { batchFindByIds as batchFindNotifications } from "../notifications/notifications.repository.js"; +import { batchFindByIds as batchFindTemplates } from "../templates/templates.repository.js"; + +/** + * Notification Entity(GraphQL 输出类型) + * 对应 msg.msg_notifications 表 + */ +export interface NotificationEntity { + id: string; + userId: string; + type: string; + title: string; + content: string; + channel: string; + isRead: boolean; + status: string; + createdAt: Date; + updatedAt: Date; + relatedEntityType: string | null; + relatedEntityId: string | null; + groupId: string | null; + senderId: string | null; + templateId: string | null; + eventId: string | null; + readAt: Date | null; +} + +/** + * NotificationTemplate Entity(GraphQL 输出类型) + * 对应 msg.msg_notification_templates 表 + */ +export interface TemplateEntity { + id: string; + code: string; + type: string; + titleTemplate: string; + contentTemplate: string; + defaultChannels: string[]; + variables: string[]; + locale: string; + status: string; + createdAt: Date; + updatedAt: Date; +} + +@Injectable({ scope: Scope.REQUEST }) +export class DataLoaderService { + private notificationLoaderInstance: DataLoader< + string, + NotificationEntity | null + > | null = null; + private templateLoaderInstance: DataLoader< + string, + TemplateEntity | null + > | null = null; + + /** Notification @key 解析器 DataLoader */ + get notificationLoader(): DataLoader { + if (!this.notificationLoaderInstance) { + this.notificationLoaderInstance = new DataLoader< + string, + NotificationEntity | null + >(async (ids) => { + const notifications = await batchFindNotifications([...ids]); + const map = new Map( + notifications.map((n) => [n.id, n satisfies NotificationEntity]), + ); + return ids.map((id) => map.get(id) ?? null); + }); + } + return this.notificationLoaderInstance; + } + + /** Template @key 解析器 DataLoader */ + get templateLoader(): DataLoader { + if (!this.templateLoaderInstance) { + this.templateLoaderInstance = new DataLoader< + string, + TemplateEntity | null + >(async (ids) => { + const templates = await batchFindTemplates([...ids]); + const map = new Map( + templates.map((t) => [t.id, t satisfies TemplateEntity]), + ); + return ids.map((id) => map.get(id) ?? null); + }); + } + return this.templateLoaderInstance; + } +} diff --git a/services/msg/src/graphql/generated/schema.graphql b/services/msg/src/graphql/generated/schema.graphql new file mode 100644 index 0000000..fbad185 --- /dev/null +++ b/services/msg/src/graphql/generated/schema.graphql @@ -0,0 +1,222 @@ +# 自动生成的 GraphQL Federation 子图(v2.1 M0) +# 源文件:msg.proto +# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql + +extend type Query + +type Notification @key(fields: "id") { + id: String + user_id: String + type: String + title: String + content: String + channel: String + is_read: Boolean + created_at: String + status: String + related_entity_type: String + related_entity_id: String + group_id: String + sender_id: String + template_id: String + event_id: String + read_at: String + updated_at: String +} + +type MarkAsReadRequest @key(fields: "id") { + id: String + user_id: String +} + +type NotificationPreference @key(fields: "id") { + id: String + user_id: String + type: String + channels: String + frequency_limit: Int + quiet_hours_start: String + quiet_hours_end: String + quiet_hours_timezone: String + enabled: Boolean + created_at: String + updated_at: String +} + +type NotificationTemplate @key(fields: "id") { + id: String + code: String + type: String + title_template: String + content_template: String + default_channels: String + variables: String + locale: String + status: String + created_at: String + updated_at: String +} + +type GetTemplateRequest @key(fields: "id") { + id: String +} + +type UpdateTemplateRequest @key(fields: "id") { + id: String + title_template: String + content_template: String + default_channels: String + variables: String + status: String +} + +type DeleteTemplateRequest @key(fields: "id") { + id: String +} + +input SendNotificationRequestInput { + user_id: String + type: String + title: String + content: String + channel: String + related_entity_type: String + related_entity_id: String + group_id: String + sender_id: String + template_id: String + event_id: String +} + +input BatchSendNotificationRequestInput { + items: SendNotificationRequest + group_id: String +} + +input BatchSendNotificationResponseInput { + ids: String + failed: BatchSendFailure +} + +type BatchSendFailure { + user_id: String + error: String +} + +input ListNotificationsRequestInput { + user_id: String + only_unread: Boolean + type: String + page: Int + page_size: Int +} + +input ListNotificationsResponseInput { + notifications: Notification + total: Int +} + +input GetUnreadCountRequestInput { + user_id: String +} + +input GetUnreadCountResponseInput { + count: Int +} + +input BatchMarkAsReadRequestInput { + ids: String + user_id: String +} + +input MarkAllAsReadRequestInput { + user_id: String + before: String +} + +input SearchNotificationsRequestInput { + user_id: String + query: String + type: String + page: Int + page_size: Int +} + +input SearchNotificationsResponseInput { + notifications: Notification + total: Int +} + +input RecallNotificationRequestInput { + group_id: String + reason: String +} + +input RecallNotificationResponseInput { + recalled_count: Int +} + +input GetPreferencesRequestInput { + user_id: String +} + +input GetPreferencesResponseInput { + preferences: NotificationPreference +} + +input UpdatePreferencesRequestInput { + user_id: String + preferences: NotificationPreference +} + +input CreateTemplateRequestInput { + code: String + type: String + title_template: String + content_template: String + default_channels: String + variables: String + locale: String +} + +input ListTemplatesRequestInput { + type: String + status: String +} + +input ListTemplatesResponseInput { + templates: NotificationTemplate +} + +input RenderTemplateRequestInput { + code: String + locale: String +} + +type RenderedNotification { + title: String + content: String +} + +type Empty { +} + +extend type Query { + notifications: [ListNotificationsResponse!]! +} + +extend type Query { + unreadCount: GetUnreadCountResponse +} + +extend type Query { + preferences: GetPreferencesResponse +} + +extend type Query { + template: NotificationTemplate +} + +extend type Query { + templates: [ListTemplatesResponse!]! +} diff --git a/services/msg/src/graphql/graphql.module.ts b/services/msg/src/graphql/graphql.module.ts new file mode 100644 index 0000000..3a1a9f1 --- /dev/null +++ b/services/msg/src/graphql/graphql.module.ts @@ -0,0 +1,59 @@ +/** + * msg GraphQL 子图模块(v2.1 M1) + * + * Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。 + * + * 强制约束: + * - @key 解析器必须使用 DataLoader(ADR-035) + * - RouterAuthGuard 校验 Router-Authorization Header(ADR-036) + * - 外部 GraphQL + 内部 gRPC 边界(ADR-037) + * + * 注:msg 仓库为函数式(export function),DataLoaderService 通过 + * 直接 import 调用 batch 函数,等价于 iam 的 IamRepository DI 注入。 + */ +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 { NotificationResolver } from "./resolvers/notification.resolver.js"; +import { TemplateResolver } from "./resolvers/template.resolver.js"; +import { DataLoaderService } from "./dataloader.service.js"; +import { getRedis } from "../shared/redis/redis.client.js"; + +@Module({ + imports: [ + 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: [ + NotificationResolver, + TemplateResolver, + DataLoaderService, + { + // 提供 Redis 实例供后续 ScopeToken 等场景使用 + provide: "REDIS_CLIENT", + useFactory: () => getRedis(), + }, + ], + exports: [DataLoaderService], +}) +export class GraphqlModule {} diff --git a/services/msg/src/graphql/resolvers/notification.resolver.ts b/services/msg/src/graphql/resolvers/notification.resolver.ts new file mode 100644 index 0000000..0877ee6 --- /dev/null +++ b/services/msg/src/graphql/resolvers/notification.resolver.ts @@ -0,0 +1,112 @@ +/** + * msg Notification Resolver(v2.1 M1) + * + * Apollo Federation 子图:Notification 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 NotificationEntity, +} from "../dataloader.service.js"; +import { listByUser } from "../../notifications/notifications.repository.js"; + +/** + * Notification ObjectType(Federation @key) + * 对应 msg.msg_notifications 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class Notification { + @Field(() => ID) + id!: string; + + @Field() + userId!: string; + + @Field() + type!: string; + + @Field() + title!: string; + + @Field() + content!: string; + + @Field() + channel!: string; + + @Field() + isRead!: boolean; + + @Field() + status!: string; + + @Field() + createdAt!: Date; + + @Field() + updatedAt!: Date; + + @Field({ nullable: true }) + relatedEntityType: string | null = null; + + @Field({ nullable: true }) + relatedEntityId: string | null = null; + + @Field({ nullable: true }) + groupId: string | null = null; + + @Field({ nullable: true }) + senderId: string | null = null; + + @Field({ nullable: true }) + templateId: string | null = null; + + @Field({ nullable: true }) + eventId: string | null = null; + + @Field({ nullable: true }) + readAt: Date | null = null; +} + +@Resolver(() => Notification) +export class NotificationResolver { + constructor(private readonly loader: DataLoaderService) {} + + /** + * Federation Reference Resolver + * + * 当其他子图通过 @key 引用 Notification 时,Router 调用此方法解析。 + * 使用 DataLoader 批量加载,消除 N+1 查询(ADR-035)。 + */ + @ResolveReference() + async resolveReference(ref: { + id: string; + }): Promise { + return this.loader.notificationLoader.load(ref.id); + } + + /** + * Query: notifications(userId) → [Notification] + * 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝(ADR-036) + */ + @Query(() => [Notification]) + async notifications( + @Args("userId", { type: () => ID }) userId: string, + ): Promise { + const { items } = await listByUser(userId, { page: 1, pageSize: 50 }); + return items; + } +} diff --git a/services/msg/src/graphql/resolvers/template.resolver.ts b/services/msg/src/graphql/resolvers/template.resolver.ts new file mode 100644 index 0000000..526233f --- /dev/null +++ b/services/msg/src/graphql/resolvers/template.resolver.ts @@ -0,0 +1,89 @@ +/** + * msg NotificationTemplate Resolver(v2.1 M1) + * + * Apollo Federation 子图:NotificationTemplate Entity + * - @key(fields: "id") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { + DataLoaderService, + type TemplateEntity, +} from "../dataloader.service.js"; + +/** + * NotificationTemplate ObjectType(Federation @key) + * 对应 msg.msg_notification_templates 表 + */ +@ObjectType() +@Directive(`@key(fields: "id")`) +export class NotificationTemplate { + @Field(() => ID) + id!: string; + + @Field() + code!: string; + + @Field() + type!: string; + + @Field() + titleTemplate!: string; + + @Field() + contentTemplate!: string; + + @Field(() => [String]) + defaultChannels!: string[]; + + @Field(() => [String]) + variables!: string[]; + + @Field() + locale!: string; + + @Field() + status!: string; + + @Field() + createdAt!: Date; + + @Field() + updatedAt!: Date; +} + +@Resolver(() => NotificationTemplate) +export class TemplateResolver { + constructor(private readonly loader: DataLoaderService) {} + + /** + * Federation Reference Resolver + * + * 当其他子图通过 @key 引用 NotificationTemplate 时,Router 调用此方法解析。 + * 使用 DataLoader 批量加载,消除 N+1 查询(ADR-035)。 + */ + @ResolveReference() + async resolveReference(ref: { id: string }): Promise { + return this.loader.templateLoader.load(ref.id); + } + + /** + * Query: template(id) → NotificationTemplate + * 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝(ADR-036) + */ + @Query(() => NotificationTemplate, { nullable: true }) + async template( + @Args("id", { type: () => ID }) id: string, + ): Promise { + return this.loader.templateLoader.load(id); + } +} diff --git a/services/msg/src/graphql/router-auth.guard.ts b/services/msg/src/graphql/router-auth.guard.ts new file mode 100644 index 0000000..8b20e04 --- /dev/null +++ b/services/msg/src/graphql/router-auth.guard.ts @@ -0,0 +1,34 @@ +/** + * msg 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/msg/src/notifications/notifications.repository.ts b/services/msg/src/notifications/notifications.repository.ts index 8271bfc..558811a 100644 --- a/services/msg/src/notifications/notifications.repository.ts +++ b/services/msg/src/notifications/notifications.repository.ts @@ -39,6 +39,15 @@ export async function findById(id: string): Promise { return row; } +/** + * 批量查询通知(DataLoader @key 解析器使用,ADR-035) + */ +export async function batchFindByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const db = getDb(); + return db.select().from(notifications).where(inArray(notifications.id, ids)); +} + export async function findByEventId( eventId: string, ): Promise { diff --git a/services/msg/src/templates/templates.repository.ts b/services/msg/src/templates/templates.repository.ts index 4e7417a..36b5131 100644 --- a/services/msg/src/templates/templates.repository.ts +++ b/services/msg/src/templates/templates.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, type SQL } from "drizzle-orm"; +import { and, eq, inArray, type SQL } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { getDb } from "../config/database.js"; import { @@ -35,6 +35,20 @@ export async function findById( return row; } +/** + * 批量查询模板(DataLoader @key 解析器使用,ADR-035) + */ +export async function batchFindByIds( + ids: string[], +): Promise { + if (ids.length === 0) return []; + const db = getDb(); + return db + .select() + .from(notificationTemplates) + .where(inArray(notificationTemplates.id, ids)); +} + export async function findByCode( code: string, locale?: string,