From 6bed673d9fafc6d687e3a5f70007ff425e3d225f Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:48:16 +0800 Subject: [PATCH] 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 --- services/iam/package.json | 5 + services/iam/src/app.module.ts | 12 +- .../iam/src/graphql/dataloader.service.ts | 97 +++++++++ .../iam/src/graphql/generated/schema.graphql | 187 ++++++++++++++++++ services/iam/src/graphql/graphql.module.ts | 59 ++++++ .../graphql/resolvers/datascope.resolver.ts | 107 ++++++++++ .../src/graphql/resolvers/role.resolver.ts | 54 +++++ .../src/graphql/resolvers/user.resolver.ts | 69 +++++++ services/iam/src/graphql/router-auth.guard.ts | 34 ++++ services/iam/src/iam/iam.module.ts | 8 +- services/iam/src/iam/iam.repository.ts | 9 + services/iam/src/iam/iam.service.ts | 59 ++++++ 12 files changed, 694 insertions(+), 6 deletions(-) create mode 100644 services/iam/src/graphql/dataloader.service.ts create mode 100644 services/iam/src/graphql/generated/schema.graphql create mode 100644 services/iam/src/graphql/graphql.module.ts create mode 100644 services/iam/src/graphql/resolvers/datascope.resolver.ts create mode 100644 services/iam/src/graphql/resolvers/role.resolver.ts create mode 100644 services/iam/src/graphql/resolvers/user.resolver.ts create mode 100644 services/iam/src/graphql/router-auth.guard.ts diff --git a/services/iam/package.json b/services/iam/package.json index ae05819..da46413 100644 --- a/services/iam/package.json +++ b/services/iam/package.json @@ -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", diff --git a/services/iam/src/app.module.ts b/services/iam/src/app.module.ts index e5f3131..b7d5439 100644 --- a/services/iam/src/app.module.ts +++ b/services/iam/src/app.module.ts @@ -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 { // 连接 Kafka producer(Outbox 投递前置依赖) + // v2.1 注:OutboxPublisher 轮询线程将被废弃(M8),由 Debezium 接管投递 + // 此处保留 Kafka producer 连接用于其他场景(如直接发事件) try { await connectKafkaProducer(); this.logger.log("Kafka producer connected"); diff --git a/services/iam/src/graphql/dataloader.service.ts b/services/iam/src/graphql/dataloader.service.ts new file mode 100644 index 0000000..8da6814 --- /dev/null +++ b/services/iam/src/graphql/dataloader.service.ts @@ -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 | null = + null; + private roleLoaderInstance: DataLoader | null = + null; + + constructor(private readonly iamRepository: IamRepository) {} + + /** User @key 解析器 DataLoader */ + get userLoader(): DataLoader { + if (!this.userLoaderInstance) { + this.userLoaderInstance = new DataLoader( + 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 { + if (!this.roleLoaderInstance) { + this.roleLoaderInstance = new DataLoader( + 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; + } +} diff --git a/services/iam/src/graphql/generated/schema.graphql b/services/iam/src/graphql/generated/schema.graphql new file mode 100644 index 0000000..f10a285 --- /dev/null +++ b/services/iam/src/graphql/generated/schema.graphql @@ -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 +} diff --git a/services/iam/src/graphql/graphql.module.ts b/services/iam/src/graphql/graphql.module.ts new file mode 100644 index 0000000..d858fc7 --- /dev/null +++ b/services/iam/src/graphql/graphql.module.ts @@ -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({ + 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: [ + UserResolver, + RoleResolver, + DataScopeResolver, + DataLoaderService, + { + // 提供 Redis 实例供 DataScopeResolver 的 ScopeTokenService 使用 + provide: "REDIS_CLIENT", + useFactory: () => getRedis(), + }, + ], + exports: [DataLoaderService], +}) +export class GraphqlModule {} diff --git a/services/iam/src/graphql/resolvers/datascope.resolver.ts b/services/iam/src/graphql/resolvers/datascope.resolver.ts new file mode 100644 index 0000000..189ddad --- /dev/null +++ b/services/iam/src/graphql/resolvers/datascope.resolver.ts @@ -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 { + // 权限校验:仅自身或管理员可查询 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, + }; + } +} diff --git a/services/iam/src/graphql/resolvers/role.resolver.ts b/services/iam/src/graphql/resolvers/role.resolver.ts new file mode 100644 index 0000000..6e3560e --- /dev/null +++ b/services/iam/src/graphql/resolvers/role.resolver.ts @@ -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 { + return this.loader.roleLoader.load(ref.roleId); + } + + @Query(() => Role, { nullable: true }) + async role( + @Args("roleId", { type: () => ID }) roleId: string, + ): Promise { + return this.loader.roleLoader.load(roleId); + } +} diff --git a/services/iam/src/graphql/resolvers/user.resolver.ts b/services/iam/src/graphql/resolvers/user.resolver.ts new file mode 100644 index 0000000..22fcabd --- /dev/null +++ b/services/iam/src/graphql/resolvers/user.resolver.ts @@ -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 { + 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 { + return this.loader.userLoader.load(userId); + } +} diff --git a/services/iam/src/graphql/router-auth.guard.ts b/services/iam/src/graphql/router-auth.guard.ts new file mode 100644 index 0000000..dd27898 --- /dev/null +++ b/services/iam/src/graphql/router-auth.guard.ts @@ -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); + } +} diff --git a/services/iam/src/iam/iam.module.ts b/services/iam/src/iam/iam.module.ts index ffebb43..ac28f68 100644 --- a/services/iam/src/iam/iam.module.ts +++ b/services/iam/src/iam/iam.module.ts @@ -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 {} diff --git a/services/iam/src/iam/iam.repository.ts b/services/iam/src/iam/iam.repository.ts index 24b880c..4d8edb6 100644 --- a/services/iam/src/iam/iam.repository.ts +++ b/services/iam/src/iam/iam.repository.ts @@ -96,6 +96,15 @@ export class IamRepository { return result.map((r) => r.iam_roles); } + /** + * 批量查询角色(DataLoader @key 解析器使用,ADR-035) + */ + async batchFindRoles(ids: string[]): Promise { + if (ids.length === 0) return []; + const db = getDb(); + return db.select().from(roles).where(inArray(roles.id, ids)); + } + async getAllRoles(): Promise { const db = getDb(); return db.select().from(roles); diff --git a/services/iam/src/iam/iam.service.ts b/services/iam/src/iam/iam.service.ts index 3ba5e62..dc92c25 100644 --- a/services/iam/src/iam/iam.service.ts +++ b/services/iam/src/iam/iam.service.ts @@ -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 { + 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 { + 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 { const viewports = await this.repository.getUserViewports(userId); const permissions = await this.getEffectivePermissions(userId);