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;
|
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[]> {
|
async findByClassId(classId: string): Promise<Exam[]> {
|
||||||
return db.select().from(exams).where(eq(exams.classId, classId));
|
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));
|
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> {
|
async update(id: string, data: Partial<NewExam>): Promise<void> {
|
||||||
await db.update(exams).set(data).where(eq(exams.id, id));
|
await db.update(exams).set(data).where(eq(exams.id, id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export class GradesRepository {
|
|||||||
return result;
|
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[]> {
|
async findByStudentId(studentId: string): Promise<Grade[]> {
|
||||||
return db.select().from(grades).where(eq(grades.studentId, studentId));
|
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));
|
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[]> {
|
async findByExamId(examId: string): Promise<Grade[]> {
|
||||||
return db.select().from(grades).where(eq(grades.examId, examId));
|
return db.select().from(grades).where(eq(grades.examId, examId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
ClassResolver,
|
ClassResolver,
|
||||||
StudentInfoResolver,
|
StudentInfoResolver,
|
||||||
} from "./resolvers/class.resolver.js";
|
} from "./resolvers/class.resolver.js";
|
||||||
|
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
|
||||||
import { DataLoaderService } from "./dataloader.service.js";
|
import { DataLoaderService } from "./dataloader.service.js";
|
||||||
import { getRedisClient } from "../config/redis.js";
|
import { getRedisClient } from "../config/redis.js";
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ import { getRedisClient } from "../config/redis.js";
|
|||||||
GradeResolver,
|
GradeResolver,
|
||||||
ClassResolver,
|
ClassResolver,
|
||||||
StudentInfoResolver,
|
StudentInfoResolver,
|
||||||
|
DataScopeResolver,
|
||||||
DataLoaderService,
|
DataLoaderService,
|
||||||
{
|
{
|
||||||
// 提供 Redis 实例供后续 ScopeToken / 缓存场景使用
|
// 提供 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