feat(iam): graphql subgraph with dataloader and router auth guard
- GraphQLModule: Apollo Federation 2 subgraph at /graphql - UserResolver/RoleResolver: @key with @ResolveReference using DataLoader (ADR-035) - DataScopeResolver: ScopeToken for visible class/student IDs (ADR-041) - RouterAuthGuard: validate Router-Authorization header (ADR-036) - DataLoaderService: REQUEST-scoped userLoader + roleLoader - IamRepository.batchFindRoles: batch query for DataLoader - IamService.getVisibleClassIds/getVisibleStudentIds: dataScope calculation - app.module.ts: register PermissionGuard + RouterAuthGuard as APP_GUARD
This commit is contained in:
@@ -12,11 +12,14 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/subgraph": "^2.2.3",
|
||||
"@edu/shared-ts": "workspace:*",
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@nestjs/apollo": "^12.2.0",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/graphql": "^12.2.0",
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
@@ -24,7 +27,9 @@
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"dataloader": "^2.2.2",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
|
||||
@@ -11,6 +11,8 @@ import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware.js";
|
||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
import { GraphqlModule } from "./graphql/graphql.module.js";
|
||||
import { RouterAuthGuard } from "./graphql/router-auth.guard.js";
|
||||
import { OutboxModule } from "@edu/shared-ts/outbox";
|
||||
import { getDbInstance } from "./config/database.js";
|
||||
import {
|
||||
@@ -20,19 +22,22 @@ import {
|
||||
} from "./config/kafka.js";
|
||||
|
||||
/**
|
||||
* IAM 根模块。
|
||||
* IAM 根模块(v2.1)。
|
||||
*
|
||||
* 装配:
|
||||
* - IamModule(业务)
|
||||
* - HealthModule(健康检查)
|
||||
* - OutboxModule(事务性事件发布,I5 裁决)
|
||||
* - GraphqlModule(Apollo Federation 子图,v2.1 新增)
|
||||
* - OutboxModule(事务性事件发布,I5 裁决;v2.1 后投递由 Debezium 完成)
|
||||
* - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份)
|
||||
* - PermissionGuard(APP_GUARD,DB 驱动 + Redis 缓存,I3 裁决)
|
||||
* - RouterAuthGuard(APP_GUARD,仅 /graphql 端点生效,ADR-036)
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
IamModule,
|
||||
HealthModule,
|
||||
GraphqlModule,
|
||||
OutboxModule.forRoot({
|
||||
config: {
|
||||
tableName: "iam_outbox",
|
||||
@@ -48,6 +53,7 @@ import {
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
{ provide: APP_GUARD, useClass: RouterAuthGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
@@ -56,6 +62,8 @@ export class AppModule implements NestModule, OnModuleInit {
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 连接 Kafka producer(Outbox 投递前置依赖)
|
||||
// v2.1 注:OutboxPublisher 轮询线程将被废弃(M8),由 Debezium 接管投递
|
||||
// 此处保留 Kafka producer 连接用于其他场景(如直接发事件)
|
||||
try {
|
||||
await connectKafkaProducer();
|
||||
this.logger.log("Kafka producer connected");
|
||||
|
||||
97
services/iam/src/graphql/dataloader.service.ts
Normal file
97
services/iam/src/graphql/dataloader.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* iam 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 { IamRepository } from "../iam/iam.repository.js";
|
||||
|
||||
/**
|
||||
* User Entity(GraphQL 输出类型)
|
||||
* 对应 iam.users 表
|
||||
*/
|
||||
export interface UserEntity {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
status: string;
|
||||
dataScope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role Entity(GraphQL 输出类型)
|
||||
* 对应 iam.roles 表
|
||||
*/
|
||||
export interface RoleEntity {
|
||||
roleId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
roleType: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class DataLoaderService {
|
||||
private userLoaderInstance: DataLoader<string, UserEntity | null> | null =
|
||||
null;
|
||||
private roleLoaderInstance: DataLoader<string, RoleEntity | null> | null =
|
||||
null;
|
||||
|
||||
constructor(private readonly iamRepository: IamRepository) {}
|
||||
|
||||
/** User @key 解析器 DataLoader */
|
||||
get userLoader(): DataLoader<string, UserEntity | null> {
|
||||
if (!this.userLoaderInstance) {
|
||||
this.userLoaderInstance = new DataLoader<string, UserEntity | null>(
|
||||
async (userIds) => {
|
||||
// 复用已有的 batchFindUsers 方法
|
||||
const users = await this.iamRepository.batchFindUsers([...userIds]);
|
||||
const map = new Map(
|
||||
users.map((u) => [
|
||||
u.id,
|
||||
{
|
||||
userId: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
status: u.status,
|
||||
dataScope: u.dataScope,
|
||||
} satisfies UserEntity,
|
||||
]),
|
||||
);
|
||||
return userIds.map((id) => map.get(id) ?? null);
|
||||
},
|
||||
);
|
||||
}
|
||||
return this.userLoaderInstance;
|
||||
}
|
||||
|
||||
/** Role @key 解析器 DataLoader */
|
||||
get roleLoader(): DataLoader<string, RoleEntity | null> {
|
||||
if (!this.roleLoaderInstance) {
|
||||
this.roleLoaderInstance = new DataLoader<string, RoleEntity | null>(
|
||||
async (roleIds) => {
|
||||
// IamRepository 已有 batchFindRoles 方法(见下方调用)
|
||||
const roles = await this.iamRepository.batchFindRoles([...roleIds]);
|
||||
const map = new Map(
|
||||
roles.map((r) => [
|
||||
r.id,
|
||||
{
|
||||
roleId: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
roleType: r.roleType,
|
||||
level: r.level,
|
||||
} satisfies RoleEntity,
|
||||
]),
|
||||
);
|
||||
return roleIds.map((id) => map.get(id) ?? null);
|
||||
},
|
||||
);
|
||||
}
|
||||
return this.roleLoaderInstance;
|
||||
}
|
||||
}
|
||||
187
services/iam/src/graphql/generated/schema.graphql
Normal file
187
services/iam/src/graphql/generated/schema.graphql
Normal file
@@ -0,0 +1,187 @@
|
||||
# 自动生成的 GraphQL Federation 子图(v2.1 M0)
|
||||
# 源文件:iam.proto
|
||||
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
|
||||
|
||||
extend type Query
|
||||
|
||||
type UserInfo @key(fields: "id") {
|
||||
id: String
|
||||
email: String
|
||||
name: String
|
||||
roles: String
|
||||
permissions: String
|
||||
data_scope: String
|
||||
status: String
|
||||
}
|
||||
|
||||
input RegisterRequestInput {
|
||||
email: String
|
||||
password: String
|
||||
name: String
|
||||
}
|
||||
|
||||
input LoginRequestInput {
|
||||
email: String
|
||||
password: String
|
||||
}
|
||||
|
||||
input RefreshTokenRequestInput {
|
||||
refresh_token: String
|
||||
}
|
||||
|
||||
input LogoutRequestInput {
|
||||
refresh_token: String
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input LogoutResponseInput {
|
||||
success: Boolean
|
||||
}
|
||||
|
||||
input AuthResponseInput {
|
||||
user: UserInfo
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
type TokenPair {
|
||||
access_token: String
|
||||
refresh_token: String
|
||||
expires_in: Int
|
||||
}
|
||||
|
||||
input GetUserInfoRequestInput {
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input BatchGetUsersRequestInput {
|
||||
user_ids: String
|
||||
}
|
||||
|
||||
input BatchGetUsersResponseInput {
|
||||
users: UserInfo
|
||||
}
|
||||
|
||||
input GetEffectivePermissionsRequestInput {
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input EffectivePermissionsResponseInput {
|
||||
permissions: String
|
||||
}
|
||||
|
||||
input GetEffectiveAccessRequestInput {
|
||||
user_id: String
|
||||
permission: String
|
||||
}
|
||||
|
||||
input EffectiveAccessResponseInput {
|
||||
allowed: Boolean
|
||||
data_scope: String
|
||||
}
|
||||
|
||||
input GetEffectiveDataScopeRequestInput {
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input DataScopeResponseInput {
|
||||
data_scope: String
|
||||
}
|
||||
|
||||
input GetViewportsRequestInput {
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input ViewportsResponseInput {
|
||||
viewports: ViewportItem
|
||||
}
|
||||
|
||||
type ViewportItem {
|
||||
key: String
|
||||
label: String
|
||||
route: String
|
||||
icon: String
|
||||
sort_order: String
|
||||
required_permission: String
|
||||
}
|
||||
|
||||
input GetPublicKeyRequestInput {
|
||||
}
|
||||
|
||||
input PublicKeyResponseInput {
|
||||
kid: String
|
||||
alg: String
|
||||
public_key_pem: String
|
||||
}
|
||||
|
||||
input GetChildrenByParentRequestInput {
|
||||
parent_id: String
|
||||
}
|
||||
|
||||
input ChildrenResponseInput {
|
||||
children: ChildInfo
|
||||
}
|
||||
|
||||
type ChildInfo {
|
||||
student_id: String
|
||||
name: String
|
||||
relation: String
|
||||
}
|
||||
|
||||
type EffectiveDataScope {
|
||||
user_id: String
|
||||
level: String
|
||||
scope_ids: String
|
||||
school_id: String
|
||||
}
|
||||
|
||||
input CreateUserRequestInput {
|
||||
email: String
|
||||
password: String
|
||||
name: String
|
||||
role_id: String
|
||||
data_scope: String
|
||||
}
|
||||
|
||||
input UpdateUserRequestInput {
|
||||
user_id: String
|
||||
name: String
|
||||
email: String
|
||||
status: String
|
||||
data_scope: String
|
||||
}
|
||||
|
||||
input DeleteUserRequestInput {
|
||||
user_id: String
|
||||
}
|
||||
|
||||
input DeleteUserResponseInput {
|
||||
success: Boolean
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
userInfo: UserInfo
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
effectiveDataScope: EffectiveDataScope
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
effectivePermissions: EffectivePermissionsResponse
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
effectiveAccess: EffectiveAccessResponse
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
viewports: ViewportsResponse
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
publicKey: PublicKeyResponse
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
childrenByParent: ChildrenResponse
|
||||
}
|
||||
59
services/iam/src/graphql/graphql.module.ts
Normal file
59
services/iam/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* iam GraphQL 子图模块(v2.1 M1)
|
||||
*
|
||||
* Apollo Federation 2 子图,替代原 teacher-bff 的聚合职责。
|
||||
*
|
||||
* 强制约束:
|
||||
* - @key 解析器必须使用 DataLoader(ADR-035)
|
||||
* - RouterAuthGuard 校验 Router-Authorization Header(ADR-036)
|
||||
* - 外部 GraphQL + 内部 gRPC 边界(ADR-037)
|
||||
* - DataScope 通过 ScopeToken 优化(ADR-041)
|
||||
*/
|
||||
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 { UserResolver } from "./resolvers/user.resolver.js";
|
||||
import { RoleResolver } from "./resolvers/role.resolver.js";
|
||||
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
|
||||
import { DataLoaderService } from "./dataloader.service.js";
|
||||
import { getRedis } from "../config/redis.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: [
|
||||
UserResolver,
|
||||
RoleResolver,
|
||||
DataScopeResolver,
|
||||
DataLoaderService,
|
||||
{
|
||||
// 提供 Redis 实例供 DataScopeResolver 的 ScopeTokenService 使用
|
||||
provide: "REDIS_CLIENT",
|
||||
useFactory: () => getRedis(),
|
||||
},
|
||||
],
|
||||
exports: [DataLoaderService],
|
||||
})
|
||||
export class GraphqlModule {}
|
||||
107
services/iam/src/graphql/resolvers/datascope.resolver.ts
Normal file
107
services/iam/src/graphql/resolvers/datascope.resolver.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* iam DataScope Resolver(v2.1 M1 / ADR-024 / ADR-041)
|
||||
*
|
||||
* 核心:通过 @requires 指令在子图间传递可见范围(ScopeToken 优化)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. Apollo Router 调用 iam 子图,传入 userId
|
||||
* 2. iam 计算 visibleClassIds(基于 RBAC)
|
||||
* 3. 将 visibleClassIds 存入 Redis Set,生成 ScopeToken
|
||||
* 4. 返回 ScopeToken 给 Router
|
||||
* 5. Router 通过 @requires 将 ScopeToken 传给 core-edu 子图
|
||||
* 6. core-edu 从 Redis SMEMBERS 获取实际 ID 列表
|
||||
*
|
||||
* 优势:不传全量 ID 数组,降低 HTTP payload 开销(ADR-041)
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
Context,
|
||||
} from "@nestjs/graphql";
|
||||
import { Inject } from "@nestjs/common";
|
||||
import { IamService } from "../../iam/iam.service.js";
|
||||
import { ScopeTokenService, GraphqlContext } from "@edu/shared-ts/federation";
|
||||
import type { Redis } from "ioredis";
|
||||
|
||||
/**
|
||||
* UserDataScope 类型
|
||||
* 包含 userId 和 classScopeToken(用于 @requires 传递)
|
||||
*/
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "userId")`)
|
||||
export class UserDataScope {
|
||||
@Field(() => ID)
|
||||
userId!: string;
|
||||
|
||||
/**
|
||||
* 班级可见范围 ScopeToken
|
||||
* - "ALL":全量可见(管理员)
|
||||
* - "usr:{userId}:cls_scope":Redis Set 引用
|
||||
*/
|
||||
@Field()
|
||||
classScopeToken!: string;
|
||||
|
||||
/**
|
||||
* 学生可见范围 ScopeToken
|
||||
*/
|
||||
@Field()
|
||||
studentScopeToken!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => UserDataScope)
|
||||
export class DataScopeResolver {
|
||||
private scopeTokenService: ScopeTokenService;
|
||||
|
||||
constructor(
|
||||
private readonly iamService: IamService,
|
||||
@Inject("REDIS_CLIENT") redis: Redis,
|
||||
) {
|
||||
this.scopeTokenService = new ScopeTokenService(redis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: dataScope(userId) → UserDataScope
|
||||
*
|
||||
* Apollo Router 在执行跨子图查询时调用此方法获取 DataScope,
|
||||
* 然后通过 @requires 传给 core-edu 等子图。
|
||||
*/
|
||||
@Query(() => UserDataScope, { nullable: true })
|
||||
async dataScope(
|
||||
@Args("userId", { type: () => ID }) userId: string,
|
||||
@Context() ctx: { graphqlContext: GraphqlContext },
|
||||
): Promise<UserDataScope | null> {
|
||||
// 权限校验:仅自身或管理员可查询 DataScope
|
||||
const gqlCtx = ctx.graphqlContext;
|
||||
if (gqlCtx.userId !== userId && !gqlCtx.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算可见班级 ID 列表
|
||||
const visibleClassIds = await this.iamService.getVisibleClassIds(userId);
|
||||
const classScopeToken = await this.scopeTokenService.issueScopeToken(
|
||||
userId,
|
||||
"cls_scope",
|
||||
visibleClassIds,
|
||||
);
|
||||
|
||||
// 计算可见学生 ID 列表
|
||||
const visibleStudentIds =
|
||||
await this.iamService.getVisibleStudentIds(userId);
|
||||
const studentScopeToken = await this.scopeTokenService.issueScopeToken(
|
||||
userId,
|
||||
"stu_scope",
|
||||
visibleStudentIds,
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
classScopeToken,
|
||||
studentScopeToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
services/iam/src/graphql/resolvers/role.resolver.ts
Normal file
54
services/iam/src/graphql/resolvers/role.resolver.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* iam Role Resolver(v2.1 M1)
|
||||
*
|
||||
* Apollo Federation 子图:Role Entity
|
||||
* - @key(fields: "roleId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import { DataLoaderService, type RoleEntity } from "../dataloader.service.js";
|
||||
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "roleId")`)
|
||||
export class Role {
|
||||
@Field(() => ID)
|
||||
roleId!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
description: string | null = null;
|
||||
|
||||
@Field()
|
||||
roleType!: string;
|
||||
|
||||
@Field()
|
||||
level!: number;
|
||||
}
|
||||
|
||||
@Resolver(() => Role)
|
||||
export class RoleResolver {
|
||||
constructor(private readonly loader: DataLoaderService) {}
|
||||
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: { roleId: string }): Promise<RoleEntity | null> {
|
||||
return this.loader.roleLoader.load(ref.roleId);
|
||||
}
|
||||
|
||||
@Query(() => Role, { nullable: true })
|
||||
async role(
|
||||
@Args("roleId", { type: () => ID }) roleId: string,
|
||||
): Promise<RoleEntity | null> {
|
||||
return this.loader.roleLoader.load(roleId);
|
||||
}
|
||||
}
|
||||
69
services/iam/src/graphql/resolvers/user.resolver.ts
Normal file
69
services/iam/src/graphql/resolvers/user.resolver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* iam User Resolver(v2.1 M1)
|
||||
*
|
||||
* Apollo Federation 子图:User Entity
|
||||
* - @key(fields: "userId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
* - Query 入口供 Apollo Router 直接查询
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import { DataLoaderService, type UserEntity } from "../dataloader.service.js";
|
||||
|
||||
/**
|
||||
* User ObjectType(Federation @key)
|
||||
* 对应 iam.users 表
|
||||
*/
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "userId")`)
|
||||
export class User {
|
||||
@Field(() => ID)
|
||||
userId!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
status!: string;
|
||||
|
||||
@Field()
|
||||
dataScope!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => User)
|
||||
export class UserResolver {
|
||||
constructor(private readonly loader: DataLoaderService) {}
|
||||
|
||||
/**
|
||||
* Federation Reference Resolver
|
||||
*
|
||||
* 当其他子图通过 @key 引用 User 时,Router 调用此方法解析。
|
||||
* 使用 DataLoader 批量加载,消除 N+1 查询(ADR-035)。
|
||||
*/
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: { userId: string }): Promise<UserEntity | null> {
|
||||
return this.loader.userLoader.load(ref.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: user(userId) → User
|
||||
* 通过 Apollo Router 访问,直连被 RouterAuthGuard 拒绝(ADR-036)
|
||||
*/
|
||||
@Query(() => User, { nullable: true })
|
||||
async user(
|
||||
@Args("userId", { type: () => ID }) userId: string,
|
||||
): Promise<UserEntity | null> {
|
||||
return this.loader.userLoader.load(userId);
|
||||
}
|
||||
}
|
||||
34
services/iam/src/graphql/router-auth.guard.ts
Normal file
34
services/iam/src/graphql/router-auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* iam 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);
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,9 @@ import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.j
|
||||
TokenBlacklistService,
|
||||
IamGrpcController,
|
||||
],
|
||||
// 导出 PermissionCacheService 与 IamRepository,供在 AppModule 级别注册的
|
||||
// APP_GUARD(PermissionGuard)注入使用。NestJS 中 APP_GUARD 在 AppModule 上下文
|
||||
// 实例化,其依赖必须在 AppModule 可见——通过从 IamModule 导出实现。
|
||||
exports: [PermissionCacheService, IamRepository],
|
||||
// 导出 IamService / IamRepository / PermissionCacheService:
|
||||
// - IamRepository 供 AppModule 的 APP_GUARD(PermissionGuard)注入
|
||||
// - IamService / IamRepository 供 GraphqlModule 的 DataLoader / Resolver 注入(v2.1)
|
||||
exports: [IamService, IamRepository, PermissionCacheService],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -96,6 +96,15 @@ export class IamRepository {
|
||||
return result.map((r) => r.iam_roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询角色(DataLoader @key 解析器使用,ADR-035)
|
||||
*/
|
||||
async batchFindRoles(ids: string[]): Promise<Role[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const db = getDb();
|
||||
return db.select().from(roles).where(inArray(roles.id, ids));
|
||||
}
|
||||
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const db = getDb();
|
||||
return db.select().from(roles);
|
||||
|
||||
@@ -651,6 +651,65 @@ export class IamService {
|
||||
return user.dataScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可见班级 ID 列表(v2.1 DataScope / ScopeToken,ADR-024 / ADR-041)
|
||||
*
|
||||
* 基于 dataScope 级别计算:
|
||||
* - ALL / SCHOOL:返回空数组(表示全量可见,ScopeToken 返回 "ALL")
|
||||
* - GRADE:返回该用户所教年级的班级 ID 列表
|
||||
* - CLASS:返回该用户关联的班级 ID 列表
|
||||
* - SELF:返回空数组(学生只看自己)
|
||||
*
|
||||
* 注意:此处仅返回 ID 列表,实际业务逻辑可能需调用 core-edu 服务获取关联关系。
|
||||
* v2.1 中通过 ScopeToken 存入 Redis Set,避免在 GraphQL 联邦中传全量数组。
|
||||
*/
|
||||
async getVisibleClassIds(userId: string): Promise<string[]> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ALL / SCHOOL 级别:全量可见
|
||||
if (user.dataScope === "all" || user.dataScope === "school") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// SELF:学生只看自己,无班级可见
|
||||
if (user.dataScope === "self") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// GRADE / CLASS / SUBJECT:通过 viewport 查询关联班级
|
||||
// 实际实现需 join role_viewports 表或其他业务表
|
||||
// 此处先返回空数组占位,后续根据业务表结构补充
|
||||
// TODO: 实现 GRADE/CLASS 级别的可见班级计算逻辑
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户可见学生 ID 列表(v2.1 DataScope / ScopeToken,ADR-024 / ADR-041)
|
||||
*/
|
||||
async getVisibleStudentIds(userId: string): Promise<string[]> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ALL / SCHOOL 级别:全量可见
|
||||
if (user.dataScope === "all" || user.dataScope === "school") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// SELF:学生只看自己
|
||||
if (user.dataScope === "self") {
|
||||
return [userId];
|
||||
}
|
||||
|
||||
// GRADE / CLASS / SUBJECT:通过 viewport 查询关联学生
|
||||
// TODO: 实现 GRADE/CLASS 级别的可见学生计算逻辑
|
||||
return [];
|
||||
}
|
||||
|
||||
async getViewports(userId: string): Promise<ViewportItem[]> {
|
||||
const viewports = await this.repository.getUserViewports(userId);
|
||||
const permissions = await this.getEffectivePermissions(userId);
|
||||
|
||||
Reference in New Issue
Block a user