feat(msg): graphql subgraph with dataloader for notification/template
- GraphQLModule: Apollo Federation 2 at /graphql - Notification/NotificationTemplate @key with @ResolveReference - RouterAuthGuard: validate Router-Authorization header (ADR-036) - batchFindByIds added to notifications and templates repositories
This commit is contained in:
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -775,6 +775,12 @@ importers:
|
|||||||
|
|
||||||
services/content:
|
services/content:
|
||||||
dependencies:
|
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':
|
'@elastic/elasticsearch':
|
||||||
specifier: ^8.13.0
|
specifier: ^8.13.0
|
||||||
version: 8.19.2
|
version: 8.19.2
|
||||||
@@ -784,12 +790,18 @@ importers:
|
|||||||
'@grpc/proto-loader':
|
'@grpc/proto-loader':
|
||||||
specifier: ^0.7.13
|
specifier: ^0.7.13
|
||||||
version: 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':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@nestjs/microservices':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@paralleldrive/cuid2':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
dataloader:
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.2.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.31.0
|
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)
|
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:
|
kafkajs:
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -860,18 +878,30 @@ importers:
|
|||||||
|
|
||||||
services/core-edu:
|
services/core-edu:
|
||||||
dependencies:
|
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':
|
'@grpc/grpc-js':
|
||||||
specifier: ^1.12.0
|
specifier: ^1.12.0
|
||||||
version: 1.14.4
|
version: 1.14.4
|
||||||
'@grpc/proto-loader':
|
'@grpc/proto-loader':
|
||||||
specifier: ^0.7.13
|
specifier: ^0.7.13
|
||||||
version: 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':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@opentelemetry/sdk-node':
|
||||||
specifier: ^0.53.0
|
specifier: ^0.53.0
|
||||||
version: 0.53.0(@opentelemetry/api@1.9.1)
|
version: 0.53.0(@opentelemetry/api@1.9.1)
|
||||||
|
dataloader:
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.2.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.31.0
|
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)
|
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:
|
kafkajs:
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -1063,6 +1099,9 @@ importers:
|
|||||||
|
|
||||||
services/msg:
|
services/msg:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@apollo/subgraph':
|
||||||
|
specifier: ^2.2.3
|
||||||
|
version: 2.14.2(graphql@16.14.2)
|
||||||
'@edu/shared-ts':
|
'@edu/shared-ts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-ts
|
version: link:../../packages/shared-ts
|
||||||
@@ -1075,12 +1114,18 @@ importers:
|
|||||||
'@grpc/proto-loader':
|
'@grpc/proto-loader':
|
||||||
specifier: ^0.7.13
|
specifier: ^0.7.13
|
||||||
version: 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':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.0
|
specifier: ^10.4.0
|
||||||
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@nestjs/microservices':
|
||||||
specifier: ^10.4.0
|
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)
|
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':
|
'@paralleldrive/cuid2':
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
dataloader:
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.2.3
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.31.0
|
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)
|
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:
|
grpc-reflection-js:
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.0(@grpc/grpc-js@1.14.4)
|
version: 0.3.0(@grpc/grpc-js@1.14.4)
|
||||||
|
|||||||
@@ -34,7 +34,12 @@
|
|||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/sdk-node": "^0.55.0",
|
"@opentelemetry/sdk-node": "^0.55.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^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": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { HealthModule } from "./shared/health/health.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 { KafkaConsumerService } from "./shared/kafka/kafka.consumer.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 服务根模块。
|
* AppModule —— msg 服务根模块。
|
||||||
@@ -37,9 +39,11 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
|
|||||||
AnnouncementsModule,
|
AnnouncementsModule,
|
||||||
GrpcModule,
|
GrpcModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
GraphqlModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RouterAuthGuard },
|
||||||
LifecycleService,
|
LifecycleService,
|
||||||
KafkaConsumerService,
|
KafkaConsumerService,
|
||||||
],
|
],
|
||||||
|
|||||||
103
services/msg/src/graphql/dataloader.service.ts
Normal file
103
services/msg/src/graphql/dataloader.service.ts
Normal file
@@ -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<string, NotificationEntity | null> {
|
||||||
|
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<string, TemplateEntity | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
services/msg/src/graphql/generated/schema.graphql
Normal file
222
services/msg/src/graphql/generated/schema.graphql
Normal file
@@ -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!]!
|
||||||
|
}
|
||||||
59
services/msg/src/graphql/graphql.module.ts
Normal file
59
services/msg/src/graphql/graphql.module.ts
Normal file
@@ -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<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: [
|
||||||
|
NotificationResolver,
|
||||||
|
TemplateResolver,
|
||||||
|
DataLoaderService,
|
||||||
|
{
|
||||||
|
// 提供 Redis 实例供后续 ScopeToken 等场景使用
|
||||||
|
provide: "REDIS_CLIENT",
|
||||||
|
useFactory: () => getRedis(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [DataLoaderService],
|
||||||
|
})
|
||||||
|
export class GraphqlModule {}
|
||||||
112
services/msg/src/graphql/resolvers/notification.resolver.ts
Normal file
112
services/msg/src/graphql/resolvers/notification.resolver.ts
Normal file
@@ -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<NotificationEntity | null> {
|
||||||
|
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<NotificationEntity[]> {
|
||||||
|
const { items } = await listByUser(userId, { page: 1, pageSize: 50 });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
services/msg/src/graphql/resolvers/template.resolver.ts
Normal file
89
services/msg/src/graphql/resolvers/template.resolver.ts
Normal file
@@ -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<TemplateEntity | null> {
|
||||||
|
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<TemplateEntity | null> {
|
||||||
|
return this.loader.templateLoader.load(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
services/msg/src/graphql/router-auth.guard.ts
Normal file
34
services/msg/src/graphql/router-auth.guard.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,15 @@ export async function findById(id: string): Promise<Notification | undefined> {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询通知(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
export async function batchFindByIds(ids: string[]): Promise<Notification[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const db = getDb();
|
||||||
|
return db.select().from(notifications).where(inArray(notifications.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
export async function findByEventId(
|
export async function findByEventId(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
): Promise<Notification | undefined> {
|
): Promise<Notification | undefined> {
|
||||||
|
|||||||
@@ -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 { createId } from "@paralleldrive/cuid2";
|
||||||
import { getDb } from "../config/database.js";
|
import { getDb } from "../config/database.js";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,20 @@ export async function findById(
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询模板(DataLoader @key 解析器使用,ADR-035)
|
||||||
|
*/
|
||||||
|
export async function batchFindByIds(
|
||||||
|
ids: string[],
|
||||||
|
): Promise<NotificationTemplate[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const db = getDb();
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(notificationTemplates)
|
||||||
|
.where(inArray(notificationTemplates.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
export async function findByCode(
|
export async function findByCode(
|
||||||
code: string,
|
code: string,
|
||||||
locale?: string,
|
locale?: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user