diff --git a/services/core-edu/src/exams/exams.repository.ts b/services/core-edu/src/exams/exams.repository.ts index 913811a..0a20e28 100644 --- a/services/core-edu/src/exams/exams.repository.ts +++ b/services/core-edu/src/exams/exams.repository.ts @@ -22,6 +22,14 @@ export class ExamsRepository { return result; } + /** + * 查询全部考试(v2.1 M4 DataScope "ALL" 全量可见场景,管理员) + * 限制 1000 条避免内存溢出 + */ + async findAll(): Promise { + return db.select().from(exams).limit(1000); + } + async findByClassId(classId: string): Promise { return db.select().from(exams).where(eq(exams.classId, classId)); } @@ -34,6 +42,15 @@ export class ExamsRepository { return db.select().from(exams).where(inArray(exams.id, ids)); } + /** + * 按多个班级 ID 批量查询考试(v2.1 M4 DataScope @requires) + * 用于 ScopeToken 解析后的可见范围查询 + */ + async findByClassIds(classIds: string[]): Promise { + if (classIds.length === 0) return []; + return db.select().from(exams).where(inArray(exams.classId, classIds)); + } + async update(id: string, data: Partial): Promise { await db.update(exams).set(data).where(eq(exams.id, id)); } diff --git a/services/core-edu/src/grades/grades.repository.ts b/services/core-edu/src/grades/grades.repository.ts index 165edf0..47dd5ae 100644 --- a/services/core-edu/src/grades/grades.repository.ts +++ b/services/core-edu/src/grades/grades.repository.ts @@ -18,6 +18,14 @@ export class GradesRepository { return result; } + /** + * 查询全部成绩(v2.1 M4 DataScope "ALL" 全量可见场景,管理员) + * 限制 1000 条避免内存溢出 + */ + async findAll(): Promise { + return db.select().from(grades).limit(1000); + } + async findByStudentId(studentId: string): Promise { return db.select().from(grades).where(eq(grades.studentId, studentId)); } @@ -30,6 +38,18 @@ export class GradesRepository { return db.select().from(grades).where(inArray(grades.id, ids)); } + /** + * 按多个学生 ID 批量查询成绩(v2.1 M4 DataScope @requires) + * 用于 ScopeToken 解析后的可见范围查询 + */ + async findByStudentIds(studentIds: string[]): Promise { + if (studentIds.length === 0) return []; + return db + .select() + .from(grades) + .where(inArray(grades.studentId, studentIds)); + } + async findByExamId(examId: string): Promise { return db.select().from(grades).where(eq(grades.examId, examId)); } diff --git a/services/core-edu/src/graphql/graphql.module.ts b/services/core-edu/src/graphql/graphql.module.ts index 03d54b8..621ee6c 100644 --- a/services/core-edu/src/graphql/graphql.module.ts +++ b/services/core-edu/src/graphql/graphql.module.ts @@ -27,6 +27,7 @@ import { ClassResolver, StudentInfoResolver, } from "./resolvers/class.resolver.js"; +import { DataScopeResolver } from "./resolvers/datascope.resolver.js"; import { DataLoaderService } from "./dataloader.service.js"; import { getRedisClient } from "../config/redis.js"; @@ -63,6 +64,7 @@ import { getRedisClient } from "../config/redis.js"; GradeResolver, ClassResolver, StudentInfoResolver, + DataScopeResolver, DataLoaderService, { // 提供 Redis 实例供后续 ScopeToken / 缓存场景使用 diff --git a/services/core-edu/src/graphql/resolvers/datascope.resolver.ts b/services/core-edu/src/graphql/resolvers/datascope.resolver.ts new file mode 100644 index 0000000..9ccad0f --- /dev/null +++ b/services/core-edu/src/graphql/resolvers/datascope.resolver.ts @@ -0,0 +1,339 @@ +/** + * core-edu DataScope Resolver(v2.1 M4 / ADR-024 / ADR-041) + * + * 通过 Apollo Federation 2 @requires 指令在子图间解析可见范围。 + * + * 流程: + * 1. Apollo Router 调用 iam 子图 dataScope(userId) → 得到 classScopeToken / studentScopeToken + * 2. Router 通过 @requires 将 token 传给 core-edu 子图 + * 3. core-edu 从 Redis sMembers 获取实际 ID 列表 + * 4. 按可见 ID 列表查询成绩 / 考试 + * + * ScopeToken 优化(ADR-041):不传全量 ID 数组,传极短 token。 + * - "ALL":全量可见(管理员),跳过 WHERE IN 过滤 + * - "usr:{userId}:cls_scope":Redis Set 引用 + */ +import { Inject, Logger } from "@nestjs/common"; +import { + Resolver, + ResolveField, + Parent, + ObjectType, + Field, + Directive, + ID, +} from "@nestjs/graphql"; +import type { RedisClientType } from "redis"; +import { ExamsRepository } from "../../exams/exams.repository.js"; +import { GradesRepository } from "../../grades/grades.repository.js"; + +/** + * ScopedGrade 引用类型(仅用于 DataScope 返回) + * 独立命名避免与 grade.resolver.ts 的 Grade 类型冲突 + */ +@ObjectType("ScopedGrade") +@Directive(`@key(fields: "id")`) +class ScopedGrade { + @Field(() => ID) + id!: string; + + @Field() + studentId!: string; + + @Field({ nullable: true }) + examId: string | null = null; + + @Field({ nullable: true }) + homeworkId: string | null = null; + + @Field() + score!: string; + + @Field() + totalScore!: string; + + @Field({ nullable: true }) + feedback: string | null = null; + + @Field() + gradedBy!: string; + + @Field() + schoolId!: string; + + @Field({ nullable: true }) + idempotencyKey: string | null = null; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +/** + * ScopedExam 引用类型(用于 DataScope 返回) + */ +@ObjectType("ScopedExam") +@Directive(`@key(fields: "id")`) +class ScopedExam { + @Field(() => ID) + id!: string; + + @Field() + classId!: string; + + @Field() + subjectId!: string; + + @Field() + title!: string; + + @Field({ nullable: true }) + description: string | null = null; + + @Field() + examDate!: string; + + @Field() + duration!: number; + + @Field() + totalScore!: string; + + @Field() + status!: string; + + @Field() + statusChangedAt!: string; + + @Field({ nullable: true }) + statusChangedBy: string | null = null; + + @Field() + schoolId!: string; + + @Field() + createdBy!: string; + + @Field({ nullable: true }) + archivedAt: string | null = null; + + @Field() + createdAt!: string; + + @Field() + updatedAt!: string; +} + +/** + * UserDataScope 扩展类型(@extends iam 子图的 UserDataScope) + * + * iam 持有 userId / classScopeToken / studentScopeToken(@external) + * core-edu 新增 visibleGrades / visibleExams(@requires) + */ +@ObjectType() +@Directive(`@extends`) +@Directive(`@key(fields: "userId")`) +export class UserDataScope { + @Field(() => ID) + @Directive(`@external`) + userId!: string; + + @Field() + @Directive(`@external`) + classScopeToken!: string; + + @Field() + @Directive(`@external`) + studentScopeToken!: string; + + /** + * 当前用户可见的成绩列表(@requires studentScopeToken) + */ + @Field(() => [ScopedGrade]) + @Directive(`@requires(fields: "studentScopeToken")`) + visibleGrades!: ScopedGrade[]; + + /** + * 当前用户可见的考试列表(@requires classScopeToken) + */ + @Field(() => [ScopedExam]) + @Directive(`@requires(fields: "classScopeToken")`) + visibleExams!: ScopedExam[]; +} + +/** + * 可见范围解析结果 + */ +interface ScopeResolution { + /** null 表示全量可见("ALL"),数组表示限定范围 */ + ids: string[] | null; +} + +/** + * 成绩行类型(从 repository 返回的原始行) + */ +interface GradeRow { + id: string; + studentId: string; + examId: string | null; + homeworkId: string | null; + score: string; + totalScore: string; + feedback: string | null; + gradedBy: string; + schoolId: string; + idempotencyKey: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * 考试行类型(从 repository 返回的原始行) + */ +interface ExamRow { + id: string; + classId: string; + subjectId: string; + title: string; + description: string | null; + examDate: Date; + duration: number; + totalScore: string; + status: string; + statusChangedAt: Date; + statusChangedBy: string | null; + schoolId: string; + createdBy: string; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +@Resolver(() => UserDataScope) +export class DataScopeResolver { + private readonly logger = new Logger(DataScopeResolver.name); + + constructor( + private readonly examsRepository: ExamsRepository, + private readonly gradesRepository: GradesRepository, + @Inject("REDIS_CLIENT") private readonly redis: RedisClientType | null, + ) {} + + /** + * visibleGrades:通过 studentScopeToken 解析可见学生 ID,查询成绩 + * + * Router 通过 @requires 将 studentScopeToken 传入 parent。 + */ + @ResolveField(() => [ScopedGrade]) + async visibleGrades( + @Parent() parent: { userId: string; studentScopeToken: string }, + ): Promise { + const resolution = await this.resolveScope(parent.studentScopeToken); + if (resolution === null) { + this.logger.warn( + `ScopeToken expired or invalid for user ${parent.userId}`, + ); + return []; + } + + const grades = + resolution.ids === null + ? await this.gradesRepository.findAll() + : await this.gradesRepository.findByStudentIds(resolution.ids); + + return grades.map((g) => this.toScopedGrade(g)); + } + + /** + * visibleExams:通过 classScopeToken 解析可见班级 ID,查询考试 + */ + @ResolveField(() => [ScopedExam]) + async visibleExams( + @Parent() parent: { userId: string; classScopeToken: string }, + ): Promise { + const resolution = await this.resolveScope(parent.classScopeToken); + if (resolution === null) { + this.logger.warn( + `ScopeToken expired or invalid for user ${parent.userId}`, + ); + return []; + } + + const exams = + resolution.ids === null + ? await this.examsRepository.findAll() + : await this.examsRepository.findByClassIds(resolution.ids); + + return exams.map((e) => this.toScopedExam(e)); + } + + /** + * 解析 ScopeToken 为实际 ID 列表 + * + * @returns ScopeResolution(ids: null=全量, 数组=限定);null=token 无效 + */ + private async resolveScope(token: string): Promise { + if (token === "ALL") { + return { ids: null }; + } + + if (!this.redis || !this.redis.isOpen) { + this.logger.error("Redis not available for ScopeToken resolution"); + return null; + } + + try { + const redisKey = `scope:${token}`; + const ids = await this.redis.sMembers(redisKey); + if (ids.length === 0) { + return null; + } + return { ids }; + } catch (err) { + this.logger.error( + `Redis sMembers failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + + private toScopedGrade(g: GradeRow): ScopedGrade { + return { + id: g.id, + studentId: g.studentId, + examId: g.examId, + homeworkId: g.homeworkId, + score: g.score, + totalScore: g.totalScore, + feedback: g.feedback, + gradedBy: g.gradedBy, + schoolId: g.schoolId, + idempotencyKey: g.idempotencyKey, + createdAt: g.createdAt.toISOString(), + updatedAt: g.updatedAt.toISOString(), + }; + } + + private toScopedExam(e: ExamRow): ScopedExam { + return { + id: e.id, + classId: e.classId, + subjectId: e.subjectId, + title: e.title, + description: e.description, + examDate: e.examDate.toISOString(), + duration: e.duration, + totalScore: e.totalScore, + status: e.status, + statusChangedAt: e.statusChangedAt.toISOString(), + statusChangedBy: e.statusChangedBy, + schoolId: e.schoolId, + createdBy: e.createdBy, + archivedAt: e.archivedAt ? e.archivedAt.toISOString() : null, + createdAt: e.createdAt.toISOString(), + updatedAt: e.updatedAt.toISOString(), + }; + } +}