feat(shared-ts): federation shared utilities for apollo subgraphs
- RouterAuthGuard: validate Router-Authorization header (ADR-036) - DataLoader factory: request-scoped batching (ADR-035) - ScopeTokenService: Redis-backed scope token (ADR-041) - GraphqlContext: build context from HTTP headers - FederationExceptionFilter: HTTP-to-GraphQL error mapping
This commit is contained in:
@@ -11,6 +11,10 @@
|
|||||||
"./bff": {
|
"./bff": {
|
||||||
"types": "./dist/bff/index.d.ts",
|
"types": "./dist/bff/index.d.ts",
|
||||||
"default": "./dist/bff/index.js"
|
"default": "./dist/bff/index.js"
|
||||||
|
},
|
||||||
|
"./federation": {
|
||||||
|
"types": "./dist/federation/index.d.ts",
|
||||||
|
"default": "./dist/federation/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,8 +28,11 @@
|
|||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.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",
|
||||||
"graphql-yoga": "^5.7.0",
|
"graphql-yoga": "^5.7.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"prom-client": "^15.1.0",
|
"prom-client": "^15.1.0",
|
||||||
|
|||||||
60
packages/shared-ts/src/federation/context.ts
Normal file
60
packages/shared-ts/src/federation/context.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* GraphQL Context - Apollo Router 下传的请求上下文
|
||||||
|
*
|
||||||
|
* Apollo Router 通过 headers 传递以下信息到子图:
|
||||||
|
* - authorization: JWT token(透传)
|
||||||
|
* - x-user-id: 已认证用户 ID
|
||||||
|
* - x-user-role: 用户角色
|
||||||
|
* - x-dataScope: 数据范围(ALL / SCHOOL / GRADE / CLASS / STUDENT)
|
||||||
|
* - x-request-id: 请求追踪 ID
|
||||||
|
*
|
||||||
|
* 子图从 HTTP headers 提取后构造 GraphqlContext,注入 Resolver。
|
||||||
|
*/
|
||||||
|
export type DataScope = "ALL" | "SCHOOL" | "GRADE" | "CLASS" | "STUDENT";
|
||||||
|
|
||||||
|
export interface GraphqlContextInput {
|
||||||
|
userId?: string;
|
||||||
|
userRole?: string;
|
||||||
|
dataScope?: DataScope;
|
||||||
|
requestId?: string;
|
||||||
|
jwt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphqlContext {
|
||||||
|
readonly userId?: string;
|
||||||
|
readonly userRole?: string;
|
||||||
|
readonly dataScope: DataScope;
|
||||||
|
readonly requestId: string;
|
||||||
|
readonly jwt?: string;
|
||||||
|
|
||||||
|
constructor(input: GraphqlContextInput) {
|
||||||
|
this.userId = input.userId;
|
||||||
|
this.userRole = input.userRole;
|
||||||
|
this.dataScope = input.dataScope ?? "ALL";
|
||||||
|
this.requestId = input.requestId ?? "unknown";
|
||||||
|
this.jwt = input.jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否管理员(全量可见) */
|
||||||
|
get isAdmin(): boolean {
|
||||||
|
return this.dataScope === "ALL" || this.userRole === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已认证 */
|
||||||
|
get isAuthenticated(): boolean {
|
||||||
|
return !!this.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 HTTP headers 构造(NestJS @Context 提取) */
|
||||||
|
static fromHeaders(
|
||||||
|
headers: Record<string, string | undefined>,
|
||||||
|
): GraphqlContext {
|
||||||
|
return new GraphqlContext({
|
||||||
|
userId: headers["x-user-id"],
|
||||||
|
userRole: headers["x-user-role"],
|
||||||
|
dataScope: headers["x-datapscope"] as DataScope | undefined,
|
||||||
|
requestId: headers["x-request-id"],
|
||||||
|
jwt: headers["authorization"]?.replace(/^Bearer\s+/i, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/shared-ts/src/federation/dataloader.factory.ts
Normal file
44
packages/shared-ts/src/federation/dataloader.factory.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* DataLoader 工厂 - @key Reference Resolver 强制请求合并
|
||||||
|
*
|
||||||
|
* 强制约束(v2.1 §3.6 / ADR-035):
|
||||||
|
* 所有子图的 @key 解析器(Reference Resolver)必须使用 DataLoader
|
||||||
|
* 进行请求合并(Batching),禁止 N+1 查询。
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* ```typescript
|
||||||
|
* const examLoader = createDataLoader<string, Exam>(async (examIds) => {
|
||||||
|
* const exams = await examRepo.findByIds([...examIds]);
|
||||||
|
* const map = new Map(exams.map((e) => [e.examId, e]));
|
||||||
|
* return examIds.map((id) => map.get(id) ?? new Error(`Exam ${id} not found`));
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @ResolveReference()
|
||||||
|
* resolveReference(ref: { examId: string }): Promise<Exam> {
|
||||||
|
* return this.examLoader.load(ref.examId);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
import DataLoader from "dataloader";
|
||||||
|
|
||||||
|
export type BatchLoadFn<K, V> = (
|
||||||
|
keys: ReadonlyArray<K>,
|
||||||
|
) => Promise<Array<V | Error>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 DataLoader 实例
|
||||||
|
*
|
||||||
|
* 工厂模式确保每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
|
||||||
|
* 在 NestJS 中通过REQUEST scope provider或GraphQL Context注入。
|
||||||
|
*/
|
||||||
|
export function createDataLoader<K, V>(
|
||||||
|
batchLoadFn: BatchLoadFn<K, V>,
|
||||||
|
): DataLoader<K, V, string> {
|
||||||
|
return new DataLoader<K, V, string>(batchLoadFn, {
|
||||||
|
// 使用 JSON.stringify 作为 cacheKeyFn,支持对象/数组 key
|
||||||
|
cacheKeyFn: (key: K): string =>
|
||||||
|
typeof key === "string" ? key : JSON.stringify(key),
|
||||||
|
// 请求级缓存,不共享
|
||||||
|
cache: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
86
packages/shared-ts/src/federation/exception.filter.ts
Normal file
86
packages/shared-ts/src/federation/exception.filter.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* FederationExceptionFilter - 联邦层错误格式化
|
||||||
|
*
|
||||||
|
* Apollo Router 期望子图返回结构化 GraphQL error,包含 extensions.code。
|
||||||
|
* 此 filter 将 NestJS 异常转为符合 Apollo Federation 规范的格式。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { GraphQLError } from "graphql";
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class FederationExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(FederationExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, _host: ArgumentsHost): void {
|
||||||
|
// GraphQL 错误已经格式化,直接抛出
|
||||||
|
if (exception instanceof GraphQLError) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 异常映射为 GraphQL error
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const response = exception.getResponse();
|
||||||
|
const message =
|
||||||
|
typeof response === "string"
|
||||||
|
? response
|
||||||
|
: ((response as { message?: string }).message ?? exception.message);
|
||||||
|
|
||||||
|
const code = this.mapStatusToCode(status);
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`GraphQL exception: ${code} ${message} (status=${status})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new GraphQLError(message, {
|
||||||
|
extensions: {
|
||||||
|
code,
|
||||||
|
status,
|
||||||
|
...(typeof response === "object" && response !== null
|
||||||
|
? (response as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知异常
|
||||||
|
this.logger.error(
|
||||||
|
`Unhandled GraphQL exception: ${
|
||||||
|
exception instanceof Error ? exception.stack : String(exception)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new GraphQLError("Internal server error", {
|
||||||
|
extensions: {
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStatusToCode(status: number): string {
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
return "BAD_REQUEST";
|
||||||
|
case 401:
|
||||||
|
return "UNAUTHENTICATED";
|
||||||
|
case 403:
|
||||||
|
return "FORBIDDEN";
|
||||||
|
case 404:
|
||||||
|
return "NOT_FOUND";
|
||||||
|
case 409:
|
||||||
|
return "CONFLICT";
|
||||||
|
case 422:
|
||||||
|
return "UNPROCESSABLE_ENTITY";
|
||||||
|
case 429:
|
||||||
|
return "TOO_MANY_REQUESTS";
|
||||||
|
default:
|
||||||
|
return status >= 500 ? "INTERNAL_SERVER_ERROR" : "UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/shared-ts/src/federation/index.ts
Normal file
28
packages/shared-ts/src/federation/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Apollo Federation 共享工具包(v2.1)
|
||||||
|
*
|
||||||
|
* 提供:
|
||||||
|
* - RouterAuthGuard:校验 Apollo Router 信任凭证,拒绝非 Router 的直接 GraphQL 请求
|
||||||
|
* - DataLoader 工厂:@key Reference Resolver 强制使用 DataLoader 请求合并
|
||||||
|
* - ScopeToken:大规模 ID 列表优化,Redis Set 引用
|
||||||
|
* - GraphQL Context:统一上下文类型
|
||||||
|
* - FederationExceptionFilter:联邦层错误格式化
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
RouterAuthGuard,
|
||||||
|
ROUTER_AUTH_HEADER,
|
||||||
|
type RouterAuthConfig,
|
||||||
|
} from "./router-auth.guard.js";
|
||||||
|
export { createDataLoader, type BatchLoadFn } from "./dataloader.factory.js";
|
||||||
|
export {
|
||||||
|
ScopeTokenService,
|
||||||
|
SCOPE_TOKEN_TTL_SECONDS,
|
||||||
|
type ScopeToken,
|
||||||
|
} from "./scope-token.js";
|
||||||
|
export {
|
||||||
|
GraphqlContext,
|
||||||
|
type GraphqlContextInput,
|
||||||
|
type DataScope,
|
||||||
|
} from "./context.js";
|
||||||
|
export { FederationExceptionFilter } from "./exception.filter.js";
|
||||||
76
packages/shared-ts/src/federation/router-auth.guard.ts
Normal file
76
packages/shared-ts/src/federation/router-auth.guard.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* RouterAuthGuard - Apollo Router 信任凭证校验
|
||||||
|
*
|
||||||
|
* 强制约束(v2.1 §3.7 / ADR-036):
|
||||||
|
* Apollo Router 请求子图时必须携带 Router-Authorization Header,
|
||||||
|
* 各服务 NestJS Guard 拦截并校验,拒绝任何非 Router 发起的 GraphQL 请求。
|
||||||
|
*
|
||||||
|
* 部署模式:
|
||||||
|
* - 生产:ROUTER_AUTH_SECRET 环境变量配置共享密钥
|
||||||
|
* - 开发:DEV_MODE=true 时跳过校验(仅限本地)
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
export const ROUTER_AUTH_HEADER = "router-authorization";
|
||||||
|
|
||||||
|
export interface RouterAuthConfig {
|
||||||
|
/** 共享密钥(生产环境由 Secret 注入) */
|
||||||
|
secret: string;
|
||||||
|
/** 开发模式跳过校验 */
|
||||||
|
devMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RouterAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(RouterAuthGuard.name);
|
||||||
|
private readonly config: RouterAuthConfig;
|
||||||
|
|
||||||
|
constructor(config: RouterAuthConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(ctx: ExecutionContext): boolean {
|
||||||
|
if (this.config.devMode === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = ctx.switchToHttp().getRequest<{
|
||||||
|
headers: Record<string, string | undefined>;
|
||||||
|
url: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 健康检查端点豁免
|
||||||
|
if (req.url?.startsWith("/health") || req.url?.startsWith("/ready")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routerAuth = req.headers[ROUTER_AUTH_HEADER];
|
||||||
|
const expected = this.config.secret;
|
||||||
|
|
||||||
|
if (!expected) {
|
||||||
|
this.logger.error(
|
||||||
|
`${ROUTER_AUTH_HEADER} secret not configured (ROUTER_AUTH_SECRET env required)`,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Router authorization not configured on server",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!routerAuth || routerAuth !== expected) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Direct GraphQL access denied (path=${req.url}); must go through Apollo Router`,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Direct GraphQL access denied; must go through Apollo Router",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
packages/shared-ts/src/federation/scope-token.ts
Normal file
105
packages/shared-ts/src/federation/scope-token.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ScopeToken - 大规模 ID 列表优化
|
||||||
|
*
|
||||||
|
* 强制约束(v2.1 §5.2 / ADR-041):
|
||||||
|
* 不直接在 GraphQL 联邦中传递全量 ID 数组,而是传递一个轻量的 ScopeToken。
|
||||||
|
* iam 子图计算出 visibleClassIds 后,存入 Redis Set,生成并返回极短的 scopeToken。
|
||||||
|
* Router 仅通过 @requires 传递 scopeToken。
|
||||||
|
* core-edu 拿到 scopeToken 后,从 Redis SMEMBERS 获取实际 ID 数组。
|
||||||
|
*
|
||||||
|
* Token 格式:`usr:{userId}:cls_scope`(或 `usr:{userId}:stu_scope` 等)
|
||||||
|
* Redis Key:`scope:{token}`
|
||||||
|
* Redis 类型:SET
|
||||||
|
* TTL:5 分钟(SCOPE_TOKEN_TTL_SECONDS)
|
||||||
|
*/
|
||||||
|
import type { Redis } from "ioredis";
|
||||||
|
|
||||||
|
export const SCOPE_TOKEN_TTL_SECONDS = 300; // 5 分钟
|
||||||
|
|
||||||
|
export type ScopeToken = string & { readonly __brand: "ScopeToken" };
|
||||||
|
|
||||||
|
export type ScopeKind = "cls_scope" | "stu_scope" | "sub_scope" | "notif_scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScopeToken 服务
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 生成 token(基于 userId + kind)
|
||||||
|
* - 将 ID 列表存入 Redis Set(带 TTL)
|
||||||
|
* - 读取 token 对应的 ID 列表(SMEMBERS)
|
||||||
|
* - 支持 "ALL" 特殊值(跳过 Redis,全量可见)
|
||||||
|
*/
|
||||||
|
export class ScopeTokenService {
|
||||||
|
constructor(private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成并存储 ScopeToken
|
||||||
|
*
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @param kind Scope 类型
|
||||||
|
* @param ids 可见 ID 列表
|
||||||
|
* @returns ScopeToken(如 "usr:123:cls_scope")或 "ALL"(ids 为空表示全量可见)
|
||||||
|
*/
|
||||||
|
async issueScopeToken(
|
||||||
|
userId: string,
|
||||||
|
kind: ScopeKind,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<ScopeToken | "ALL"> {
|
||||||
|
// 空列表在业务上通常表示"全量可见"(管理员),返回 "ALL" 特殊值
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return "ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = `usr:${userId}:${kind}` as ScopeToken;
|
||||||
|
const redisKey = `scope:${token}`;
|
||||||
|
|
||||||
|
// 用 pipeline 减少 RTT
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
pipeline.del(redisKey);
|
||||||
|
pipeline.sadd(redisKey, ...ids);
|
||||||
|
pipeline.expire(redisKey, SCOPE_TOKEN_TTL_SECONDS);
|
||||||
|
await pipeline.exec();
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 ScopeToken 为实际 ID 列表
|
||||||
|
*
|
||||||
|
* @param token ScopeToken 或 "ALL"
|
||||||
|
* @returns ID 列表;"ALL" 返回 null(表示无过滤)
|
||||||
|
*/
|
||||||
|
async resolveScopeToken(token: ScopeToken | "ALL"): Promise<string[] | null> {
|
||||||
|
if (token === "ALL") {
|
||||||
|
return null; // null 表示全量可见,调用方应跳过 WHERE IN 过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisKey = `scope:${token}`;
|
||||||
|
const ids = await this.redis.smembers(redisKey);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
// token 过期或不存在,调用方应触发重新签发(401/403 由 Resolver 处理)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 token TTL(访问时续期)
|
||||||
|
*/
|
||||||
|
async refreshTtl(token: ScopeToken): Promise<void> {
|
||||||
|
if (token === "ALL") return;
|
||||||
|
const redisKey = `scope:${token}`;
|
||||||
|
await this.redis.expire(redisKey, SCOPE_TOKEN_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销 token(用户权限变更时)
|
||||||
|
*/
|
||||||
|
async revokeToken(userId: string, kind: ScopeKind): Promise<void> {
|
||||||
|
const token = `usr:${userId}:${kind}` as ScopeToken;
|
||||||
|
const redisKey = `scope:${token}`;
|
||||||
|
await this.redis.del(redisKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
671
pnpm-lock.yaml
generated
671
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ packages:
|
|||||||
|
|
||||||
# pnpm 11+ build scripts 白名单(从 package.json 的 pnpm.onlyBuiltDependencies 迁移)
|
# pnpm 11+ build scripts 白名单(从 package.json 的 pnpm.onlyBuiltDependencies 迁移)
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@apollo/protobufjs': set this to true or false
|
||||||
'@module-federation/nextjs-mf': true
|
'@module-federation/nextjs-mf': true
|
||||||
'@nestjs/core': true
|
'@nestjs/core': true
|
||||||
'@sentry/cli': true
|
'@sentry/cli': true
|
||||||
|
|||||||
Reference in New Issue
Block a user