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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 / 缓存场景使用
|
||||
|
||||
339
services/core-edu/src/graphql/resolvers/datascope.resolver.ts
Normal file
339
services/core-edu/src/graphql/resolvers/datascope.resolver.ts
Normal file
@@ -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<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 ScopeResolution(ids: 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user