feat(core-edu): dataspcope @requires with scopetoken resolution

- new DataScopeResolver extends iam UserDataScope via @extends
- visibleGrades @requires(studentScopeToken) + visibleExams @requires(classScopeToken)
- resolve ScopeToken via Redis sMembers to actual ID list (ADR-041)
- add findByStudentIds/findByClassIds/findAll to repositories
- ALL token = full visibility (admin), skip WHERE IN filter

Implements M4 of v2.1 migration plan.
This commit is contained in:
SpecialX
2026-07-15 02:20:15 +08:00
parent 1a5fa78fa6
commit 1423a0b8eb
4 changed files with 378 additions and 0 deletions

View File

@@ -22,6 +22,14 @@ export class ExamsRepository {
return result;
}
/**
* 查询全部考试v2.1 M4 DataScope "ALL" 全量可见场景,管理员)
* 限制 1000 条避免内存溢出
*/
async findAll(): Promise<Exam[]> {
return db.select().from(exams).limit(1000);
}
async findByClassId(classId: string): Promise<Exam[]> {
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<Exam[]> {
if (classIds.length === 0) return [];
return db.select().from(exams).where(inArray(exams.classId, classIds));
}
async update(id: string, data: Partial<NewExam>): Promise<void> {
await db.update(exams).set(data).where(eq(exams.id, id));
}

View File

@@ -18,6 +18,14 @@ export class GradesRepository {
return result;
}
/**
* 查询全部成绩v2.1 M4 DataScope "ALL" 全量可见场景,管理员)
* 限制 1000 条避免内存溢出
*/
async findAll(): Promise<Grade[]> {
return db.select().from(grades).limit(1000);
}
async findByStudentId(studentId: string): Promise<Grade[]> {
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<Grade[]> {
if (studentIds.length === 0) return [];
return db
.select()
.from(grades)
.where(inArray(grades.studentId, studentIds));
}
async findByExamId(examId: string): Promise<Grade[]> {
return db.select().from(grades).where(eq(grades.examId, examId));
}

View File

@@ -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 / 缓存场景使用

View File

@@ -0,0 +1,339 @@
/**
* core-edu DataScope Resolverv2.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<ScopedGrade[]> {
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<ScopedExam[]> {
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 ScopeResolutionids: null=全量, 数组=限定null=token 无效
*/
private async resolveScope(token: string): Promise<ScopeResolution | null> {
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(),
};
}
}