feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -14,6 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/shared-ts": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";

View File

@@ -1,17 +1,16 @@
import pino from 'pino';
import { env } from '../../config/env.js';
import { pino } from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
// 修复pino 默认字段选项为 `base`,而非 `defaultFields`
base: {
service: 'classes',
version: '0.1.0',
service: "classes",
version: "0.1.0",
},
transport:
env.NODE_ENV === 'development'
env.NODE_ENV === "development"
? {
target: 'pino-pretty',
target: "pino-pretty",
options: { colorize: true },
}
: undefined,

View File

@@ -0,0 +1,74 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
type PluginRegistry {
pluginId: ID!
category: String!
version: String!
displayName: String!
description: String!
isBuiltin: Boolean!
isActive: Boolean!
}
type LayoutTemplateGql {
layoutId: ID!
displayName: String!
description: String
isActive: Boolean!
}
type UserLayoutOverrideGql {
userId: ID!
activeLayout: String!
updatedAt: String!
}
type PluginConfigLayoutGql {
layoutId: ID!
displayName: String!
description: String!
availableSlots: [String!]!
layoutSchemaJson: String!
}
type PluginConfigSlotGql {
slotName: String!
navItems: [String!]!
}
type PluginConfigPlacementGql {
pluginId: ID!
slot: String!
sortOrder: Int!
sizeJson: String!
propsJson: String!
isVisible: Boolean!
}
type PluginConfigRegistryItemGql {
pluginId: ID!
category: String!
version: String!
displayName: String!
description: String!
requiredRoles: [String!]!
isBuiltin: Boolean!
isActive: Boolean!
}
type PluginConfigResponseGql {
activeLayout: PluginConfigLayoutGql
slots: [PluginConfigSlotGql!]!
plugins: [PluginConfigPlacementGql!]!
registry: [PluginConfigRegistryItemGql!]!
}
type Query {
plugin(pluginId: ID!): PluginRegistry
plugins: [PluginRegistry!]!
layoutTemplates: [LayoutTemplateGql!]!
userLayoutOverride(userId: ID!): UserLayoutOverrideGql
pluginConfig(userId: ID!, role: String = "student"): PluginConfigResponseGql!
}

View File

@@ -13,7 +13,7 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { ConfigModule } from "../config-config/config.module.js";
@@ -27,7 +27,7 @@ import { DataLoaderService } from "./dataloader.service.js";
imports: [
ConfigModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";

View File

@@ -14,6 +14,14 @@ export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
// GraphQL 上下文GqlContextType无 HTTP req/res需单独处理。
// NestJS ContextType 类型未包含 'graphql'(由 @nestjs/graphql 扩展),
// 需断言为 string 比较。
if ((host.getType() as string) === "graphql") {
this.handleGraphQlError(exception);
return;
}
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
@@ -83,4 +91,25 @@ export class GlobalErrorFilter implements ExceptionFilter {
}
return exception.message;
}
/**
* GraphQL 错误处理NestJS Apollo 会自动把 resolver 抛出的异常包装为
* GraphQL error 响应,无需 filter 手动写 response。这里仅记录日志
* 原始异常会继续传播给 Apollo Server 的默认格式化器。
*/
private handleGraphQlError(exception: unknown): void {
if (exception instanceof ApplicationError) {
this.logger.warn(`[GraphQL] ${exception.code}: ${exception.message}`);
} else if (exception instanceof ZodError) {
this.logger.warn(`[GraphQL] Validation error: ${exception.message}`);
} else if (exception instanceof Error) {
this.logger.error(
`[GraphQL] Unhandled: ${exception.message}`,
exception.stack,
);
} else {
this.logger.error(`[GraphQL] Unknown exception: ${String(exception)}`);
}
// 不抛出,让 Apollo Server 的默认错误格式化器处理响应
}
}

View File

@@ -1,414 +1,63 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件content.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type Textbook @key(fields: "id") {
id: String
title: String
subject_id: String
grade_id: String
version: String
status: String
tenant_id: String
metadata: Struct
created_at: String
updated_at: String
type Textbook {
id: ID!
title: String!
subjectId: String!
gradeId: String!
version: String!
status: String!
tenantId: String
createdAt: DateTime!
updatedAt: DateTime!
}
type Chapter @key(fields: "id") {
id: String
textbook_id: String
title: String
order: Int
parent_id: String
status: String
created_at: String
updated_at: String
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type Chapter {
id: ID!
textbookId: String!
title: String!
order: Float!
parentId: String
status: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type KnowledgePoint @key(fields: "id") {
id: String
chapter_id: String
title: String
type KnowledgePoint {
id: ID!
chapterId: String!
title: String!
description: String
difficulty: Int
metadata: Struct
created_at: String
updated_at: String
difficulty: Float!
createdAt: DateTime!
updatedAt: DateTime!
}
type Question @key(fields: "id") {
id: String
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
type Question {
id: ID!
knowledgePointId: String!
type: String!
content: String!
answer: String!
explanation: String
difficulty: Int
status: String
source: String
created_by: String
metadata: Struct
created_at: String
updated_at: String
}
type GetTextbookRequest @key(fields: "id") {
id: String
}
type UpdateTextbookRequest @key(fields: "id") {
id: String
title: String
status: String
metadata: Struct
}
type DeleteTextbookRequest @key(fields: "id") {
id: String
}
type GetChapterRequest @key(fields: "id") {
id: String
}
type UpdateChapterRequest @key(fields: "id") {
id: String
title: String
order: Int
status: String
}
type DeleteChapterRequest @key(fields: "id") {
id: String
}
type GetQuestionRequest @key(fields: "id") {
id: String
}
type UpdateQuestionRequest @key(fields: "id") {
id: String
content: String
answer: String
status: String
options: Struct
explanation: String
difficulty: Int
}
type DeleteQuestionRequest @key(fields: "id") {
id: String
}
type PublishQuestionRequest @key(fields: "id") {
id: String
}
type ElectiveCourse @key(fields: "id") {
id: String
title: String
subject_id: String
teacher_id: String
capacity: Int
enrolled_count: Int
status: String
description: String
created_at: String
updated_at: String
}
type ElectiveSelection @key(fields: "id") {
id: String
student_id: String
course_id: String
status: String
selected_at: String
dropped_at: String
}
type LessonPlan @key(fields: "id") {
id: String
teacher_id: String
class_id: String
subject_id: String
title: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetLessonPlanRequest @key(fields: "id") {
id: String
}
type CoursePlan @key(fields: "id") {
id: String
student_id: String
class_id: String
subject_id: String
title: String
plan_type: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetCoursePlanRequest @key(fields: "id") {
id: String
}
type Empty {
}
input CreateTextbookRequestInput {
title: String
subject_id: String
grade_id: String
version: String
metadata: Struct
}
input ListTextbooksRequestInput {
subject_id: String
grade_id: String
page_token: String
page_size: Int
}
input ListTextbooksResponseInput {
textbooks: Textbook
next_page_token: String
}
input CreateChapterRequestInput {
textbook_id: String
title: String
order: Int
parent_id: String
}
input ListChaptersRequestInput {
textbook_id: String
parent_id: String
}
input ListChaptersResponseInput {
chapters: Chapter
}
input GetPrerequisitesRequestInput {
knowledge_point_id: String
depth: Int
}
input KnowledgePointsResponseInput {
points: KnowledgePoint
}
input GetLearningPathRequestInput {
student_id: String
subject_id: String
}
type LearningPath {
points: KnowledgePoint
recommended_order: String
}
input AddPrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input RemovePrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input CreateQuestionRequestInput {
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
explanation: String
difficulty: Int
source: String
created_by: String
metadata: Struct
}
input BatchCreateQuestionsRequestInput {
questions: CreateQuestionRequest
}
input BatchCreateQuestionsResponseInput {
ids: String
failed: BatchCreateFailure
}
type BatchCreateFailure {
index: Int
error: String
}
input ListQuestionsRequestInput {
knowledge_point_id: String
type: String
difficulty: Int
status: String
page_token: String
page_size: Int
}
input ListQuestionsResponseInput {
questions: Question
next_page_token: String
}
input SearchQuestionsRequestInput {
q: String
type: String
difficulty: Int
knowledge_point_id: String
page_token: String
page_size: Int
}
input SearchQuestionsResponseInput {
questions: Question
total: Int
next_page_token: String
}
input GetKnowledgePathRequestInput {
class_id: String
subject_id: String
}
input ListAvailableElectiveCoursesRequestInput {
subject_id: String
page_size: Int
page_token: String
}
input ListElectiveCoursesResponseInput {
courses: ElectiveCourse
next_page_token: String
}
input ListElectiveSelectionsByStudentRequestInput {
student_id: String
status: String
}
input ListElectiveSelectionsResponseInput {
selections: ElectiveSelection
}
input SelectCourseRequestInput {
student_id: String
course_id: String
}
input DropCourseRequestInput {
student_id: String
course_id: String
}
input ListLessonPlansByTeacherRequestInput {
teacher_id: String
class_id: String
subject_id: String
}
input ListLessonPlansByStudentRequestInput {
student_id: String
class_id: String
}
input ListLessonPlansResponseInput {
lesson_plans: LessonPlan
}
input ListCoursePlansByStudentRequestInput {
student_id: String
class_id: String
plan_type: String
}
input ListCoursePlansResponseInput {
course_plans: CoursePlan
}
extend type Query {
textbook: Textbook
}
extend type Query {
textbooks: [ListTextbooksResponse!]!
}
extend type Query {
chapter: Chapter
}
extend type Query {
chapters: [ListChaptersResponse!]!
}
extend type Query {
prerequisites: KnowledgePointsResponse
}
extend type Query {
learningPath: LearningPath
}
extend type Query {
knowledgePath: LearningPath
}
extend type Query {
question: Question
}
extend type Query {
questions: [ListQuestionsResponse!]!
}
extend type Query {
availableElectiveCourses: [ListElectiveCoursesResponse!]!
}
extend type Query {
electiveSelectionsByStudent: [ListElectiveSelectionsResponse!]!
}
extend type Query {
lessonPlansByTeacher: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlansByStudent: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlan: LessonPlan
}
extend type Query {
coursePlansByStudent: [ListCoursePlansResponse!]!
}
extend type Query {
coursePlan: CoursePlan
}
difficulty: Float!
status: String!
source: String!
createdBy: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
textbook(id: ID!): Textbook
chapter(id: ID!): Chapter
knowledgePoint(id: ID!): KnowledgePoint
question(id: ID!): Question
}

View File

@@ -10,7 +10,7 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { TextbooksModule } from "../textbooks/textbooks.module.js";
@@ -31,7 +31,7 @@ import { DataLoaderService } from "./dataloader.service.js";
KnowledgePointsModule,
QuestionsModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";

View File

@@ -1,724 +1,69 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件core_edu.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type Exam @key(fields: "id") {
id: String
class_id: String
subject_id: String
title: String
type Exam {
id: ID!
classId: String!
subjectId: String!
title: String!
description: String
exam_date: String
duration: Int
total_score: String
status: String
status_changed_at: String
status_changed_by: String
school_id: String
created_by: String
archived_at: String
created_at: String
updated_at: String
examDate: String!
duration: Float!
totalScore: String!
status: String!
statusChangedAt: String!
statusChangedBy: String
schoolId: String!
createdBy: String!
archivedAt: String
createdAt: String!
updatedAt: String!
}
type ExamQuestion @key(fields: "id") {
id: String
exam_id: String
question_id: String
order: Int
score: String
question_type: String
}
type ExamSubmission @key(fields: "id") {
id: String
exam_id: String
student_id: String
status: String
submitted_at: String
graded_at: String
graded_by: String
total_score: String
}
type CreateExamResponse @key(fields: "id") {
id: String
}
type GetExamRequest @key(fields: "id") {
id: String
}
type UpdateExamRequest @key(fields: "id") {
id: String
title: String
type Homework {
id: ID!
classId: String!
subjectId: String!
title: String!
description: String
exam_date: String
duration: Int
total_score: String
status: String
dueDate: String!
gracePeriod: Float!
status: String!
schoolId: String!
createdBy: String!
createdAt: String!
updatedAt: String!
}
type DeleteExamRequest @key(fields: "id") {
id: String
}
type PublishExamRequest @key(fields: "id") {
id: String
published_by: String
}
type Homework @key(fields: "id") {
id: String
class_id: String
subject_id: String
title: String
description: String
due_date: String
grace_period: Int
status: String
school_id: String
created_by: String
created_at: String
updated_at: String
}
type HomeworkSubmission @key(fields: "id") {
id: String
homework_id: String
student_id: String
status: String
submitted_at: String
graded_at: String
graded_by: String
total_score: String
type Grade {
id: ID!
studentId: String!
examId: String
homeworkId: String
score: String!
totalScore: String!
feedback: String
gradedBy: String!
schoolId: String!
idempotencyKey: String
createdAt: String!
updatedAt: String!
}
type AssignHomeworkResponse @key(fields: "id") {
id: String
}
type GetHomeworkRequest @key(fields: "id") {
id: String
}
type Grade @key(fields: "id") {
id: String
student_id: String
exam_id: String
homework_id: String
score: String
total_score: String
feedback: String
graded_by: String
school_id: String
idempotency_key: String
created_at: String
updated_at: String
}
type RecordGradeResponse @key(fields: "id") {
id: String
}
type GetGradeRequest @key(fields: "id") {
id: String
}
type UpdateGradeRequest @key(fields: "id") {
id: String
score: String
feedback: String
updated_by: String
}
type ClassInfo @key(fields: "id") {
id: String
name: String
grade_id: String
head_teacher_id: String
type ClassInfo {
id: ID!
name: String!
gradeId: String!
headTeacherId: String
description: String
created_at: String
updated_at: String
createdAt: String!
updatedAt: String!
}
type StudentInfo @key(fields: "id") {
id: String
name: String
class_id: String
}
type GetClassRequest @key(fields: "id") {
id: String
}
type Attendance @key(fields: "id") {
id: String
schedule_id: String
student_id: String
status: String
remark: String
recorded_by: String
school_id: String
created_at: String
updated_at: String
}
type RecordAttendanceResponse @key(fields: "id") {
id: String
}
type GetAttendanceRequest @key(fields: "id") {
id: String
}
type ScheduleSlotInfo @key(fields: "id") {
id: String
course_id: String
course_name: String
teacher_id: String
class_id: String
room_id: String
start_time: String
end_time: String
subject_id: String
}
type LeaveRequest @key(fields: "id") {
id: String
student_id: String
class_id: String
leave_type: String
start_date: String
end_date: String
reason: String
status: String
submitted_by: String
reviewed_by: String
review_comment: String
school_id: String
created_at: String
updated_at: String
}
type CreateLeaveRequestResponse @key(fields: "id") {
id: String
}
type CancelLeaveRequestRequest @key(fields: "id") {
id: String
cancelled_by: String
}
type SchoolInfo @key(fields: "id") {
id: String
name: String
address: String
principal_id: String
created_at: String
}
type GradeLevelInfo @key(fields: "id") {
id: String
name: String
school_id: String
order: Int
}
type DepartmentInfo @key(fields: "id") {
id: String
name: String
school_id: String
head_id: String
created_at: String
}
type AcademicYearInfo @key(fields: "id") {
id: String
name: String
school_id: String
start_date: String
end_date: String
is_current: Boolean
}
input CreateExamRequestInput {
class_id: String
subject_id: String
title: String
description: String
exam_date: String
duration: Int
total_score: String
school_id: String
created_by: String
}
input ListExamsByClassRequestInput {
class_id: String
}
input ListExamsResponseInput {
exams: Exam
}
input UpdateExamResponseInput {
success: Boolean
}
input DeleteExamResponseInput {
success: Boolean
}
input PublishExamResponseInput {
success: Boolean
}
input SubmitExamRequestInput {
exam_id: String
student_id: String
answers: AnswerInput
}
type AnswerInput {
question_id: String
answer: String
}
input SubmitExamResponseInput {
submission_id: String
}
input GradeExamRequestInput {
exam_id: String
submission_id: String
scores: ScoreInput
graded_by: String
}
type ScoreInput {
question_id: String
score: String
teacher_comment: String
}
input GradeExamResponseInput {
success: Boolean
total_score: String
}
input AssignHomeworkRequestInput {
class_id: String
subject_id: String
title: String
description: String
due_date: String
grace_period: Int
school_id: String
created_by: String
}
input ListHomeworkByClassRequestInput {
class_id: String
}
input ListHomeworkResponseInput {
homework: Homework
}
input SubmitHomeworkRequestInput {
homework_id: String
student_id: String
answers: AnswerInput
}
input SubmitHomeworkResponseInput {
submission_id: String
}
input GradeHomeworkRequestInput {
homework_id: String
submission_id: String
scores: ScoreInput
feedback: String
graded_by: String
}
input GradeHomeworkResponseInput {
success: Boolean
total_score: String
}
input RecordGradeRequestInput {
student_id: String
exam_id: String
homework_id: String
score: String
total_score: String
feedback: String
graded_by: String
school_id: String
idempotency_key: String
}
input ListGradesByStudentRequestInput {
student_id: String
}
input ListGradesByExamRequestInput {
exam_id: String
}
input ListGradesByHomeworkRequestInput {
homework_id: String
}
input ListGradesResponseInput {
grades: Grade
}
input UpdateGradeResponseInput {
success: Boolean
}
input GetClassesByTeacherRequestInput {
teacher_id: String
}
input GetClassesByTeacherResponseInput {
classes: ClassInfo
}
input BatchGetClassesRequestInput {
ids: String
}
input BatchGetClassesResponseInput {
classes: ClassInfo
}
input ListStudentsByClassRequestInput {
class_id: String
}
input ListStudentsByClassResponseInput {
students: StudentInfo
}
input RecordAttendanceRequestInput {
schedule_id: String
student_id: String
status: String
remark: String
recorded_by: String
school_id: String
}
input ListAttendanceByStudentRequestInput {
student_id: String
}
input ListAttendanceByClassRequestInput {
class_id: String
}
input ListAttendanceResponseInput {
attendance: Attendance
}
input SaveExamDraftRequestInput {
exam_id: String
student_id: String
answers: AnswerInput
}
input SaveExamDraftResponseInput {
draft_id: String
}
input RecordExamViolationRequestInput {
exam_id: String
student_id: String
violation_type: String
detail: String
severity: Int
}
input RecordExamViolationResponseInput {
violation_id: String
}
input ExtendExamRequestInput {
exam_id: String
extension_seconds: Int
extended_by: String
}
input ExtendExamResponseInput {
success: Boolean
new_duration: Int
}
input ForceSubmitExamRequestInput {
exam_id: String
student_ids: String
forced_by: String
}
input ForceSubmitExamResponseInput {
affected_count: Int
}
type QuestionOrderInput {
question_id: String
order: Int
}
input ReorderExamQuestionsRequestInput {
exam_id: String
orders: QuestionOrderInput
reordered_by: String
}
input ReorderExamQuestionsResponseInput {
success: Boolean
}
input GetReportCardRequestInput {
student_id: String
term_id: String
}
type ReportCard {
student_id: String
term_id: String
entries: ReportCardEntry
overall_grade: String
class_rank: String
created_at: String
}
type ReportCardEntry {
subject_id: String
subject_name: String
exam_score: String
exam_total: String
homework_score: String
homework_total: String
final_score: String
grade_level: String
teacher_comment: String
}
input GetScheduleByStudentRequestInput {
student_id: String
week_start: String
}
input GetScheduleByStudentResponseInput {
slots: ScheduleSlotInfo
}
input ListLeaveRequestsByStudentRequestInput {
student_id: String
status: String
}
input ListLeaveRequestsResponseInput {
leave_requests: LeaveRequest
}
input CreateLeaveRequestRequestInput {
student_id: String
class_id: String
leave_type: String
start_date: String
end_date: String
reason: String
submitted_by: String
school_id: String
}
input CancelLeaveRequestResponseInput {
success: Boolean
}
input GetDashboardRequestInput {
teacher_id: String
}
type DashboardData {
teacher_id: String
total_classes: Int
total_students: Int
pending_homework: Int
upcoming_exams: Int
ungraded_submissions: Int
classes: DashboardClassCard
upcoming_exam_list: DashboardExamCard
generated_at: String
}
type DashboardClassCard {
class_id: String
class_name: String
student_count: Int
}
type DashboardExamCard {
exam_id: String
title: String
exam_date: String
class_id: String
class_name: String
}
input GetClassPerformanceRequestInput {
class_id: String
subject_id: String
}
type ClassPerformance {
class_id: String
class_name: String
student_count: Int
average_score: String
highest_score: String
lowest_score: String
median_score: String
subjects: ClassPerformanceSubject
generated_at: String
}
type ClassPerformanceSubject {
subject_id: String
subject_name: String
average_score: String
student_count: Int
}
input ListSchoolsRequestInput {
}
input ListSchoolsResponseInput {
schools: SchoolInfo
}
input ListGradeLevelsRequestInput {
school_id: String
}
input ListGradeLevelsResponseInput {
grade_levels: GradeLevelInfo
}
input ListDepartmentsRequestInput {
school_id: String
}
input ListDepartmentsResponseInput {
departments: DepartmentInfo
}
input ListAcademicYearsRequestInput {
school_id: String
}
input ListAcademicYearsResponseInput {
academic_years: AcademicYearInfo
}
extend type Query {
exam: Exam
}
extend type Query {
examsByClass: [ListExamsResponse!]!
}
extend type Query {
homework: Homework
}
extend type Query {
homeworkByClass: [ListHomeworkResponse!]!
}
extend type Query {
grade: Grade
}
extend type Query {
gradesByStudent: [ListGradesResponse!]!
}
extend type Query {
gradesByExam: [ListGradesResponse!]!
}
extend type Query {
gradesByHomework: [ListGradesResponse!]!
}
extend type Query {
reportCard: ReportCard
}
extend type Query {
class: ClassInfo
}
extend type Query {
classesByTeacher: GetClassesByTeacherResponse
}
extend type Query {
studentsByClass: [ListStudentsByClassResponse!]!
}
extend type Query {
attendance: Attendance
}
extend type Query {
attendanceByStudent: [ListAttendanceResponse!]!
}
extend type Query {
attendanceByClass: [ListAttendanceResponse!]!
}
extend type Query {
scheduleByStudent: GetScheduleByStudentResponse
}
extend type Query {
leaveRequestsByStudent: [ListLeaveRequestsResponse!]!
}
extend type Query {
dashboard: DashboardData
}
extend type Query {
classPerformance: ClassPerformance
}
extend type Query {
schools: [ListSchoolsResponse!]!
}
extend type Query {
gradeLevels: [ListGradeLevelsResponse!]!
}
extend type Query {
departments: [ListDepartmentsResponse!]!
}
extend type Query {
academicYears: [ListAcademicYearsResponse!]!
}
type Query {
exam(id: ID!): Exam
homework(id: ID!): Homework
grade(id: ID!): Grade
classInfo(id: ID!): ClassInfo
}

View File

@@ -13,7 +13,7 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { ExamsModule } from "../exams/exams.module.js";
@@ -23,11 +23,7 @@ import { ClassesModule } from "../classes/classes.module.js";
import { ExamResolver } from "./resolvers/exam.resolver.js";
import { HomeworkResolver } from "./resolvers/homework.resolver.js";
import { GradeResolver } from "./resolvers/grade.resolver.js";
import {
ClassResolver,
StudentInfoResolver,
} from "./resolvers/class.resolver.js";
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
import { ClassResolver } from "./resolvers/class.resolver.js";
import { DataLoaderService } from "./dataloader.service.js";
import { getRedisClient } from "../config/redis.js";
@@ -38,7 +34,7 @@ import { getRedisClient } from "../config/redis.js";
GradesModule,
ClassesModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),
@@ -63,8 +59,6 @@ import { getRedisClient } from "../config/redis.js";
HomeworkResolver,
GradeResolver,
ClassResolver,
StudentInfoResolver,
DataScopeResolver,
DataLoaderService,
{
// 提供 Redis 实例供后续 ScopeToken / 缓存场景使用

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.js";

View File

@@ -13,6 +13,14 @@ export interface AuthenticatedRequest extends Request {
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
// DEV_MODE 跳过鉴权(开发态允许 apollo-router 内省 / GraphQL playground
if (process.env.DEV_MODE === "true") {
req.userId = (req.headers["x-user-id"] as string) ?? "dev-user-001";
req.userRoles = [];
next();
return;
}
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];

View File

@@ -1,187 +1,31 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件iam.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type UserInfo @key(fields: "id") {
id: String
email: String
name: String
roles: String
permissions: String
data_scope: String
status: String
type User {
userId: ID!
email: String!
name: String!
status: String!
dataScope: String!
}
input RegisterRequestInput {
email: String
password: String
name: String
type Role {
roleId: ID!
name: String!
description: String
roleType: String!
level: Float!
}
input LoginRequestInput {
email: String
password: String
type UserDataScope {
userId: ID!
classScopeToken: String!
studentScopeToken: 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
}
type Query {
user(userId: ID!): User
role(roleId: ID!): Role
dataScope(userId: ID!): UserDataScope
}

View File

@@ -11,9 +11,10 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { IamModule } from "../iam/iam.module.js";
import { UserResolver } from "./resolvers/user.resolver.js";
import { RoleResolver } from "./resolvers/role.resolver.js";
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
@@ -22,8 +23,9 @@ import { getRedis } from "../config/redis.js";
@Module({
imports: [
IamModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";

View File

@@ -1,222 +1,47 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件msg.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type Notification @key(fields: "id") {
id: String
user_id: String
type: String
title: String
content: String
channel: String
is_read: Boolean
created_at: String
status: String
related_entity_type: String
related_entity_id: String
group_id: String
sender_id: String
template_id: String
event_id: String
read_at: String
updated_at: String
type Notification {
id: ID!
userId: String!
type: String!
title: String!
content: String!
channel: String!
isRead: Boolean!
status: String!
createdAt: DateTime!
updatedAt: DateTime!
relatedEntityType: String
relatedEntityId: String
groupId: String
senderId: String
templateId: String
eventId: String
readAt: DateTime
}
type MarkAsReadRequest @key(fields: "id") {
id: String
user_id: String
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type NotificationTemplate {
id: ID!
code: String!
type: String!
titleTemplate: String!
contentTemplate: String!
defaultChannels: [String!]!
variables: [String!]!
locale: String!
status: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type NotificationPreference @key(fields: "id") {
id: String
user_id: String
type: String
channels: String
frequency_limit: Int
quiet_hours_start: String
quiet_hours_end: String
quiet_hours_timezone: String
enabled: Boolean
created_at: String
updated_at: String
}
type NotificationTemplate @key(fields: "id") {
id: String
code: String
type: String
title_template: String
content_template: String
default_channels: String
variables: String
locale: String
status: String
created_at: String
updated_at: String
}
type GetTemplateRequest @key(fields: "id") {
id: String
}
type UpdateTemplateRequest @key(fields: "id") {
id: String
title_template: String
content_template: String
default_channels: String
variables: String
status: String
}
type DeleteTemplateRequest @key(fields: "id") {
id: String
}
input SendNotificationRequestInput {
user_id: String
type: String
title: String
content: String
channel: String
related_entity_type: String
related_entity_id: String
group_id: String
sender_id: String
template_id: String
event_id: String
}
input BatchSendNotificationRequestInput {
items: SendNotificationRequest
group_id: String
}
input BatchSendNotificationResponseInput {
ids: String
failed: BatchSendFailure
}
type BatchSendFailure {
user_id: String
error: String
}
input ListNotificationsRequestInput {
user_id: String
only_unread: Boolean
type: String
page: Int
page_size: Int
}
input ListNotificationsResponseInput {
notifications: Notification
total: Int
}
input GetUnreadCountRequestInput {
user_id: String
}
input GetUnreadCountResponseInput {
count: Int
}
input BatchMarkAsReadRequestInput {
ids: String
user_id: String
}
input MarkAllAsReadRequestInput {
user_id: String
before: String
}
input SearchNotificationsRequestInput {
user_id: String
query: String
type: String
page: Int
page_size: Int
}
input SearchNotificationsResponseInput {
notifications: Notification
total: Int
}
input RecallNotificationRequestInput {
group_id: String
reason: String
}
input RecallNotificationResponseInput {
recalled_count: Int
}
input GetPreferencesRequestInput {
user_id: String
}
input GetPreferencesResponseInput {
preferences: NotificationPreference
}
input UpdatePreferencesRequestInput {
user_id: String
preferences: NotificationPreference
}
input CreateTemplateRequestInput {
code: String
type: String
title_template: String
content_template: String
default_channels: String
variables: String
locale: String
}
input ListTemplatesRequestInput {
type: String
status: String
}
input ListTemplatesResponseInput {
templates: NotificationTemplate
}
input RenderTemplateRequestInput {
code: String
locale: String
}
type RenderedNotification {
title: String
content: String
}
type Empty {
}
extend type Query {
notifications: [ListNotificationsResponse!]!
}
extend type Query {
unreadCount: GetUnreadCountResponse
}
extend type Query {
preferences: GetPreferencesResponse
}
extend type Query {
template: NotificationTemplate
}
extend type Query {
templates: [ListTemplatesResponse!]!
}
type Query {
notifications(userId: ID!): [Notification!]!
template(id: ID!): NotificationTemplate
}

View File

@@ -13,7 +13,7 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { NotificationResolver } from "./resolvers/notification.resolver.js";
@@ -24,7 +24,7 @@ import { getRedis } from "../shared/redis/redis.client.js";
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";