From 125f7ec54cb3beef26475977252d7dfe54b4ed75 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:38:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20RBAC=E6=9D=83=E9=99=90=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=87=8D=E6=9E=84=20+=20UI=E7=BB=84=E4=BB=B6=E6=8B=86?= =?UTF-8?q?=E5=88=86=20+=20=E6=B5=8B=E8=AF=95=E4=BF=AE=E5=A4=8D=20+=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割 --- .trae/rules/project_rules.md | 41 + docs/architecture/001_project_overview.md | 241 +++ docs/architecture/002_rbac_refactoring.md | 975 +++++++++++ docs/architecture/003_ui_refactoring_plan.md | 407 +++++ .../004_architecture_impact_map.md | 682 ++++++++ docs/architecture/005_architecture_data.json | 623 +++++++ .../architecture/006_k12_feature_checklist.md | 205 +++ docs/architecture/007_gap_audit_report.md | 277 +++ package-lock.json | 789 +++++++++ package.json | 10 +- src/app/(dashboard)/dashboard/page.tsx | 15 +- src/app/(dashboard)/layout.tsx | 5 +- src/app/(dashboard)/profile/page.tsx | 7 +- src/app/(dashboard)/settings/page.tsx | 11 +- .../(dashboard)/teacher/exams/all/page.tsx | 3 + .../homework/assignments/create/page.tsx | 4 +- src/app/globals.css | 14 + src/app/layout.tsx | 9 +- src/auth.ts | 28 +- .../exams/mock-data.ts => mocks/exam-data.ts} | 2 +- .../mock-data.ts => mocks/question-data.ts} | 2 +- src/modules/classes/actions.ts | 1114 ++++++------ src/modules/dashboard/data-access.ts | 73 +- src/modules/exams/actions.ts | 783 +++++---- src/modules/exams/components/exam-actions.tsx | 9 +- .../exams/components/exam-ai-generator.tsx | 223 +++ .../exams/components/exam-basic-info-form.tsx | 190 ++ .../exams/components/exam-form-types.test.ts | 46 + .../exams/components/exam-form-types.ts | 133 ++ src/modules/exams/components/exam-form.tsx | 1537 +---------------- .../exams/components/exam-mode-selector.tsx | 85 + .../exams/components/exam-preview-dialog.tsx | 185 ++ .../exam-preview-question-editor.tsx | 173 ++ .../exams/components/exam-preview-utils.ts | 205 +++ .../components/question-options-editor.tsx | 130 ++ .../question-sub-questions-editor.tsx | 137 ++ src/modules/exams/data-access.ts | 52 +- src/modules/exams/hooks/use-exam-preview.ts | 295 ++++ src/modules/homework/actions.ts | 112 +- src/modules/homework/data-access.ts | 140 +- src/modules/layout/components/app-sidebar.tsx | 30 +- src/modules/layout/config/navigation.ts | 24 +- src/modules/questions/actions.ts | 107 +- src/modules/textbooks/actions.ts | 62 +- .../textbooks/components/knowledge-graph.tsx | 181 ++ .../components/knowledge-point-dialogs.tsx | 148 ++ .../components/knowledge-point-list.tsx | 107 ++ .../components/textbook-content-panel.tsx | 170 ++ .../textbooks/components/textbook-reader.tsx | 789 ++------- .../hooks/use-knowledge-point-actions.ts | 121 ++ .../textbooks/hooks/use-text-selection.ts | 57 + src/next-auth.d.ts | 9 +- src/proxy.ts | 97 +- src/shared/components/onboarding-gate.tsx | 19 +- src/shared/db/schema.ts | 9 + src/shared/hooks/index.ts | 5 + src/shared/hooks/use-action-with-toast.ts | 22 + src/shared/hooks/use-debounce.test.ts | 28 + src/shared/hooks/use-debounce.ts | 14 + src/shared/hooks/use-local-storage.test.ts | 35 + src/shared/hooks/use-local-storage.ts | 30 + src/shared/hooks/use-media-query.ts | 14 + src/shared/hooks/use-permission.ts | 28 + src/shared/lib/auth-guard.ts | 133 ++ src/shared/lib/permissions.ts | 130 ++ src/shared/lib/utils.test.ts | 39 + src/shared/lib/utils.ts | 6 +- src/shared/types/action-state.test.ts | 33 + src/shared/types/permissions.ts | 68 + tailwind.config.ts | 62 - tests/integration/dashboard-routing.test.ts | 38 +- tests/integration/homework-actions.test.ts | 56 +- .../homework-create-assignment.test.ts | 65 +- tests/integration/proxy-guard.test.ts | 43 +- vitest.unit.config.ts | 18 + 75 files changed, 9480 insertions(+), 3289 deletions(-) create mode 100644 .trae/rules/project_rules.md create mode 100644 docs/architecture/001_project_overview.md create mode 100644 docs/architecture/002_rbac_refactoring.md create mode 100644 docs/architecture/003_ui_refactoring_plan.md create mode 100644 docs/architecture/004_architecture_impact_map.md create mode 100644 docs/architecture/005_architecture_data.json create mode 100644 docs/architecture/006_k12_feature_checklist.md create mode 100644 docs/architecture/007_gap_audit_report.md rename src/{modules/exams/mock-data.ts => mocks/exam-data.ts} (97%) rename src/{modules/questions/mock-data.ts => mocks/question-data.ts} (98%) create mode 100644 src/modules/exams/components/exam-ai-generator.tsx create mode 100644 src/modules/exams/components/exam-basic-info-form.tsx create mode 100644 src/modules/exams/components/exam-form-types.test.ts create mode 100644 src/modules/exams/components/exam-form-types.ts create mode 100644 src/modules/exams/components/exam-mode-selector.tsx create mode 100644 src/modules/exams/components/exam-preview-dialog.tsx create mode 100644 src/modules/exams/components/exam-preview-question-editor.tsx create mode 100644 src/modules/exams/components/exam-preview-utils.ts create mode 100644 src/modules/exams/components/question-options-editor.tsx create mode 100644 src/modules/exams/components/question-sub-questions-editor.tsx create mode 100644 src/modules/exams/hooks/use-exam-preview.ts create mode 100644 src/modules/textbooks/components/knowledge-graph.tsx create mode 100644 src/modules/textbooks/components/knowledge-point-dialogs.tsx create mode 100644 src/modules/textbooks/components/knowledge-point-list.tsx create mode 100644 src/modules/textbooks/components/textbook-content-panel.tsx create mode 100644 src/modules/textbooks/hooks/use-knowledge-point-actions.ts create mode 100644 src/modules/textbooks/hooks/use-text-selection.ts create mode 100644 src/shared/hooks/index.ts create mode 100644 src/shared/hooks/use-action-with-toast.ts create mode 100644 src/shared/hooks/use-debounce.test.ts create mode 100644 src/shared/hooks/use-debounce.ts create mode 100644 src/shared/hooks/use-local-storage.test.ts create mode 100644 src/shared/hooks/use-local-storage.ts create mode 100644 src/shared/hooks/use-media-query.ts create mode 100644 src/shared/hooks/use-permission.ts create mode 100644 src/shared/lib/auth-guard.ts create mode 100644 src/shared/lib/permissions.ts create mode 100644 src/shared/lib/utils.test.ts create mode 100644 src/shared/types/action-state.test.ts create mode 100644 src/shared/types/permissions.ts create mode 100644 vitest.unit.config.ts diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md new file mode 100644 index 0000000..096e5b0 --- /dev/null +++ b/.trae/rules/project_rules.md @@ -0,0 +1,41 @@ +# 项目规则 + +## 架构图优先规则 + +**任何任务开始前,必须先查阅架构影响地图,通过图定位代码和模块。** + +1. **先图后码**:执行任何分析、修改、搜索任务时,首先阅读 `docs/architecture/004_architecture_impact_map.md` 或 `docs/architecture/005_architecture_data.json`,从图中定位目标模块、函数、依赖关系,再按图索骥读取源码 +2. **图未覆盖则先补图**:如果发现项目中存在架构图未记录的模块、函数、表、路由等,**必须优先完善架构图信息**,然后再继续后续工作 +3. **改码必同步图**:对源码的任何修改完成后,必须同步更新 004 和 005 两个架构文档 + +### 架构文档清单 + +| 文档 | 用途 | +|------|------| +| `docs/architecture/004_architecture_impact_map.md` | 人类可读的架构影响地图 | +| `docs/architecture/005_architecture_data.json` | AI 友好格式的结构化数据 | +| `docs/architecture/006_k12_feature_checklist.md` | 标准功能模块清单 | +| `docs/architecture/007_gap_audit_report.md` | 差距审计报告 | + +### 需要同步图的场景 + +- 新增/删除/重命名导出函数、组件、Hook、类型 +- 修改函数签名(参数、返回类型) +- 修改权限点(Permissions 常量)或角色-权限映射 +- 新增/删除数据库表 +- 新增/删除路由页面或 API 路由 +- 修改模块间依赖关系 +- 新增模块 + +### 同步方式 + +- 修改 Markdown 文档中对应的模块章节 +- 修改 JSON 文档中对应的节点(`modules.*.exports`、`permissions`、`dependencyMatrix`、`routes`、`dbTables` 等) +- 确保两个文档内容一致 + +## 代码质量规则 + +- 每次修改后运行 `npm run lint` 和 `npx tsc --noEmit` 确保零错误 +- Server Action 必须使用 `requirePermission()` 进行权限校验 +- 前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()` +- 单文件不超过 300 行 diff --git a/docs/architecture/001_project_overview.md b/docs/architecture/001_project_overview.md new file mode 100644 index 0000000..8eb9bb7 --- /dev/null +++ b/docs/architecture/001_project_overview.md @@ -0,0 +1,241 @@ +# Next_Edu 项目架构分析 + +> 本文档基于源码逆向分析生成,未参考任何已有文档。 + +--- + +## 一、项目定位与目标 + +**Next_Edu** 是一个面向 K12 场景的**智慧教务管理系统**,旨在将传统学校的教务流程数字化、智能化。 + +核心目标: +1. **多角色协同**:管理员、教师、学生、家长四种角色在同一平台协作,各角色有独立的仪表盘和功能入口 +2. **AI 赋能教学**:集成大语言模型(智谱/OpenAI/Gemini),支持 AI 自动生成试卷、AI 重写题目、AI 对话辅导 +3. **全流程闭环**:从教材管理 → 题库建设 → 试卷组卷 → 考试/作业发布 → 学生作答 → 教师批改 → 数据分析,覆盖教学评估全链路 +4. **知识体系结构化**:教材章节与知识点树形关联,知识点与题目双向链接,支撑精准教学 + +--- + +## 二、技术栈 + +| 层次 | 技术选型 | 版本 | +|------|---------|------| +| 框架 | Next.js (App Router) | 16 | +| 语言 | TypeScript (strict) | 5 | +| 前端 | React | 19 | +| 样式 | Tailwind CSS + shadcn/ui | v4 | +| 状态 | Zustand + nuqs (URL) + React Hook Form | — | +| 数据库 | MySQL | — | +| ORM | Drizzle ORM | 0.45 | +| 认证 | NextAuth v5 (JWT strategy) | beta | +| 校验 | Zod | 4 | +| AI | OpenAI SDK (多 provider) | — | +| 富文本 | TipTap | 3 | +| 图表 | Recharts | 3 | +| 测试 | Vitest + Playwright | — | +| 部署 | Docker (standalone output) | — | + +--- + +## 三、系统角色与权限模型 + +### 角色定义 + +| 角色 | 路由前缀 | 核心职责 | +|------|---------|---------| +| **Admin** | `/admin/*` | 学校管理、年级/班级/部门配置、用户管理、全局设置 | +| **Teacher** | `/teacher/*` | 教材管理、题库建设、试卷组卷、作业发布与批改、班级管理 | +| **Student** | `/student/*` | 课程学习、作业作答、教材阅读、课表查看 | +| **Parent** | `/parent/*` | 子女学情查看、缴费、消息沟通 | + +### 权限控制机制 + +``` +请求 → NextAuth Middleware (proxy.ts) + → 检查 session 是否存在 → 无则重定向 /login + → 解析 JWT 中的 role 字段 + → 按路由前缀校验角色匹配 + → 不匹配则重定向到角色首页 +``` + +- **角色解析**:`grade_head` 和 `teaching_head` 映射为 `teacher`;多角色用户取优先级 `admin > teacher > parent > student` +- **用户-角色**:多对多关系(`users_to_roles` 表),支持一人多角色 +- **导航隔离**:`NAV_CONFIG` 按角色定义侧边栏菜单,不同角色看到完全不同的功能入口 + +--- + +## 四、数据模型 + +### 核心实体关系 + +``` +School ──1:N──→ Grade ──1:N──→ Class ──1:N──→ ClassEnrollment ←──N:1── User(student) + │ │ + │ ├── ClassSubjectTeacher (班级-科目-教师) + │ └── ClassSchedule (课表) + │ + ├── Department + ├── Classroom + └── AcademicYear + +User ──M:N──→ Role (RBAC) + +Textbook ──1:N──→ Chapter (树形嵌套) ──1:N──→ KnowledgePoint (树形嵌套) + │ +Question ──M:N──→ KnowledgePoint │ + │ │ + ├── 支持类型: single_choice / multiple_choice │ + │ / text / judgment / composite │ + └── 支持无限嵌套 (parentId 自引用) │ + │ +Exam ──M:N──→ Question (exam_questions) │ + ├── ExamSubmission ──1:N──→ SubmissionAnswer │ + └── structure (JSON 层级结构) │ + │ +HomeworkAssignment ──M:N──→ Question │ + ├── sourceExamId → Exam (作业源自试卷) │ + ├── HomeworkAssignmentTarget (指定学生) │ + └── HomeworkSubmission ──1:N──→ HomeworkAnswer │ + │ +AIProvider (zhipu / openai / gemini / custom) │ + └── apiKeyEncrypted (AES 加密存储) │ +``` + +### 数据库规模 + +- **20+ 张表**,覆盖用户认证、RBAC、学校管理、教学资源、考试系统、作业系统、AI 配置 +- **ID 策略**:CUID2(`@paralleldrive/cuid2`),128 位 varchar +- **索引策略**:所有外键和查询字段均有索引,支持级联删除 + +--- + +## 五、架构设计 + +### 分层架构 + +``` +┌─────────────────────────────────────────────────┐ +│ App Router (src/app/) │ +│ ├── Route Groups: (auth) / (dashboard) │ +│ ├── Server Components (默认) │ +│ ├── loading.tsx (Suspense 边界) │ +│ └── error.tsx (错误边界) │ +├─────────────────────────────────────────────────┤ +│ Feature Modules (src/modules/) │ +│ ├── auth/ 认证模块 │ +│ ├── exams/ 考试模块 │ +│ ├── homework/ 作业模块 │ +│ ├── questions/ 题库模块 │ +│ ├── textbooks/ 教材模块 │ +│ ├── classes/ 班级模块 │ +│ ├── school/ 学校管理模块 │ +│ ├── dashboard/ 仪表盘模块 │ +│ ├── layout/ 布局与导航 │ +│ ├── settings/ 设置模块 │ +│ └── users/ 用户管理模块 │ +│ 每个模块: │ +│ ├── components/ UI 组件 │ +│ ├── hooks/ 自定义 Hook │ +│ ├── actions.ts Server Actions │ +│ ├── data-access.ts 数据访问层 │ +│ └── types.ts 类型定义 │ +├─────────────────────────────────────────────────┤ +│ Shared (src/shared/) │ +│ ├── components/ui/ shadcn/ui 基础组件 (30+) │ +│ ├── hooks/ 通用 Hook │ +│ ├── lib/ 工具函数 │ +│ ├── db/ 数据库 (schema/relations) │ +│ └── types/ 公共类型 │ +├─────────────────────────────────────────────────┤ +│ API Routes (src/app/api/) │ +│ ├── auth/[...nextauth] NextAuth 端点 │ +│ ├── ai/chat AI 对话 │ +│ └── onboarding/* 用户引导 │ +└─────────────────────────────────────────────────┘ +``` + +### 数据流 + +``` +用户操作 → Client Component + → Server Action (actions.ts) + → 数据访问层 (data-access.ts) + → Drizzle ORM (db/index.ts) + → MySQL + +Server Action 返回 → ActionState + → { success, message, data?, errors? } + → Client 端 toast 反馈 +``` + +### AI 集成架构 + +``` +教师操作 → AI Provider 选择 (支持多 provider) + → API Key AES 加密存储 (ai_providers 表) + → Server Action 调用 AI Pipeline + ├── 试卷生成: 输入源文本 → AI 解析 → 结构化题目 + ├── 题目重写: 输入原题 + 指令 → AI 重写 → 更新题目 + └── AI 对话: /api/ai/chat → 流式响应 + → PQueue 并发控制 (最多 3 个后台任务) +``` + +--- + +## 六、核心业务流程 + +### 1. 试卷组卷流程 + +``` +手动模式: 选择科目/年级 → 填写考试信息 → 创建草稿 → 进入试卷构建器 → 从题库选题 +AI 模式: 选择 AI Provider → 粘贴试卷源文本 → AI 解析生成 → 预览/编辑 → 确认创建 +``` + +### 2. 作业发布与批改流程 + +``` +教师: 从已有试卷创建作业 → 选择目标学生 → 设置截止时间 → 发布 +学生: 查看作业列表 → 作答 → 提交 +教师: 查看提交列表 → 批改评分 → 填写反馈 +``` + +### 3. 教材与知识体系流程 + +``` +教师: 创建教材 → 添加章节(树形) → 编写内容(Markdown/富文本) + → 从文本选中创建知识点 → 知识点自动关联章节 + → 从知识点创建题目 → 题目关联知识点 +学生: 选择章节阅读 → 知识点高亮链接 → 查看知识图谱 +``` + +### 4. 用户注册与引导流程 + +``` +注册 → 默认 student 角色 → OnboardingGate 检查 + → 未引导: 强制填写角色/学校/班级信息 + → 已引导: 进入角色仪表盘 +``` + +--- + +## 七、部署与运维 + +- **构建模式**: `output: "standalone"` — 适配 Docker 容器化部署 +- **数据库迁移**: Drizzle Kit (`db:generate` / `db:migrate`) +- **数据填充**: `db:seed` 脚本 + `@faker-js/faker` +- **CI/CD**: `.gitea/workflows/ci.yml` — Gitea Actions +- **测试**: 单元测试 (Vitest) + 集成测试 + E2E (Playwright) + +--- + +## 八、项目规模统计 + +| 维度 | 数量 | +|------|------| +| 数据库表 | 20+ | +| 业务模块 | 11 | +| UI 基础组件 | 30+ | +| 路由页面 | 30+ | +| Server Actions | 50+ | +| API 路由 | 4 | +| 用户角色 | 4 (admin/teacher/student/parent) | diff --git a/docs/architecture/002_rbac_refactoring.md b/docs/architecture/002_rbac_refactoring.md new file mode 100644 index 0000000..296ae11 --- /dev/null +++ b/docs/architecture/002_rbac_refactoring.md @@ -0,0 +1,975 @@ +# 企业级权限体系重构方案 + +> 基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件 + +--- + +## 一、问题诊断 + +### 1.1 安全隐患 + +#### 隐患 A:考试模块 Server Action 零鉴权 + +[exams/actions.ts:721-723](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L721-L723) 中的 `getCurrentUser()` 是一个硬编码存根: + +```typescript +async function getCurrentUser() { + return { id: "user_teacher_math", role: "teacher" } +} +``` + +**后果**:任何已登录用户(包括 student/parent)都可以调用 `deleteExamAction`、`updateExamAction`、`duplicateExamAction`,删除或修改任意考试。攻击路径: + +1. 学生 A 登录系统,获取合法 session +2. 直接调用 `deleteExamAction`,传入任意 `examId` +3. Server Action 不校验调用者身份,直接执行 `db.delete(exams).where(eq(exams.id, examId))` +4. 考试被删除 + +#### 隐患 B:越权修改他人考试 + +即使修复了 `getCurrentUser()`,`updateExamAction` 和 `deleteExamAction` 仍不检查资源归属: + +```typescript +// updateExamAction — 任何 teacher 可修改任何考试 +await db.update(exams).set(updateData).where(eq(exams.id, examId)) +// 缺少: AND creatorId = currentUserId +``` + +教师 B 可以修改教师 A 创建的考试,只需知道 examId。 + +#### 隐患 C:班级管理部分接口无鉴权 + +[classes/actions.ts](file:///e:/Desktop/CICD/src/modules/classes/actions.ts) 中: +- `enrollStudentByEmailAction` — 无任何 auth 检查 +- `setStudentEnrollmentStatusAction` — 无任何 auth 检查 +- `createClassScheduleItemAction` — 无任何 auth 检查 +- `deleteClassScheduleItemAction` — 无任何 auth 检查 +- `ensureClassInvitationCodeAction` — 无任何 auth 检查 +- `regenerateClassInvitationCodeAction` — 无任何 auth 检查 + +任何已登录用户都可以操作任意班级的学生和课表。 + +#### 隐患 D:作业批改无资源归属校验 + +`gradeHomeworkSubmissionAction` 只调用 `ensureTeacher()`,确认调用者是教师身份,但不检查该教师是否有权批改此作业。任何教师可批改任何班级的作业。 + +### 1.2 扩展性问题 + +#### 问题 A:角色硬编码散布全栈 + +前端 14 处 `role === "xxx"` 硬编码,后端各模块各自实现鉴权逻辑: + +| 位置 | 模式 | +|------|------| +| `proxy.ts` | `if (role === "admin")` | +| `onboarding-gate.tsx` | `if (role === "admin")` / `role === "teacher"` / `role === "student"` | +| `dashboard/page.tsx` | `if (role === "admin") redirect(...)` | +| `settings/page.tsx` | `if (role === "admin") return ` | +| `homework/actions.ts` | `ensureTeacher()` / `ensureStudent()` | +| `classes/actions.ts` | `session.user.role !== "admin"` | + +新增角色(如 `teaching_head`、`grade_head`)需要改动所有这些位置。 + +#### 问题 B:多角色用户被强制取一个 + +[auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 `role` 字段。一个同时是 `teacher` + `grade_head` 的用户,在 JWT 中只保留 `teacher`,导致年级管理功能不可用。 + +#### 问题 C:数据权限无统一抽象 + +`homework/actions.ts` 手动实现了"教师只能为自己班级的作业发布任务"的逻辑(30+ 行),`classes/actions.ts` 手动实现了"年级主任只能管理自己年级"的逻辑(20+ 行)。每个模块各自实现,无法复用。 + +### 1.3 可维护性问题 + +#### 问题 A:鉴权逻辑与业务逻辑耦合 + +每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。 + +#### 问题 B:前端条件渲染依赖角色字符串 + +```tsx +// 当前:硬编码角色判断 +{role === "teacher" ? : } + +// 无法支持:年级主任看到额外的管理入口 +``` + +--- + +## 二、企业级权限模型设计 + +### 2.1 权限模型:RBAC + 数据权限 + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ User │────→│ UserRole │←────│ Role │ +│ │ M:N │ (多角色) │ │ (角色定义) │ +└─────────────┘ └──────────────┘ └──────┬──────┘ + │ M:N + ┌──────┴──────┐ + │ RolePermission│ + └──────┬──────┘ + │ N:1 + ┌──────┴──────┐ + │ Permission │ + │ (权限点) │ + └─────────────┘ +``` + +### 2.2 权限点定义 + +权限点采用 `resource:action` 命名规范: + +```typescript +// src/shared/types/permissions.ts + +export const Permissions = { + // 考试 + EXAM_CREATE: "exam:create", + EXAM_READ: "exam:read", + EXAM_UPDATE: "exam:update", + EXAM_DELETE: "exam:delete", + EXAM_DUPLICATE: "exam:duplicate", + EXAM_PUBLISH: "exam:publish", + EXAM_AI_GENERATE: "exam:ai_generate", + + // 作业 + HOMEWORK_CREATE: "homework:create", + HOMEWORK_GRADE: "homework:grade", + HOMEWORK_SUBMIT: "homework:submit", + + // 题库 + QUESTION_CREATE: "question:create", + QUESTION_READ: "question:read", + QUESTION_UPDATE: "question:update", + QUESTION_DELETE: "question:delete", + + // 教材 + TEXTBOOK_CREATE: "textbook:create", + TEXTBOOK_READ: "textbook:read", + TEXTBOOK_UPDATE: "textbook:update", + TEXTBOOK_DELETE: "textbook:delete", + + // 班级 + CLASS_CREATE: "class:create", + CLASS_READ: "class:read", + CLASS_UPDATE: "class:update", + CLASS_DELETE: "class:delete", + CLASS_ENROLL: "class:enroll", + CLASS_SCHEDULE: "class:schedule", + + // 学校管理 + SCHOOL_MANAGE: "school:manage", + GRADE_MANAGE: "grade:manage", + USER_MANAGE: "user:manage", + + // AI + AI_CHAT: "ai:chat", + AI_CONFIGURE: "ai:configure", + + // 设置 + SETTINGS_ADMIN: "settings:admin", +} as const + +export type Permission = typeof Permissions[keyof typeof Permissions] +``` + +### 2.3 角色-权限映射 + +```typescript +// src/shared/lib/permissions.ts + +import { Permissions, type Permission } from "@/shared/types/permissions" + +export const ROLE_PERMISSIONS: Record = { + admin: [ + Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, + Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, + Permissions.EXAM_AI_GENERATE, + Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, + Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, + Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, + Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, + Permissions.TEXTBOOK_UPDATE, Permissions.TEXTBOOK_DELETE, + Permissions.CLASS_CREATE, Permissions.CLASS_READ, + Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE, + Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE, + Permissions.SCHOOL_MANAGE, Permissions.GRADE_MANAGE, + Permissions.USER_MANAGE, + Permissions.AI_CHAT, Permissions.AI_CONFIGURE, + Permissions.SETTINGS_ADMIN, + ], + teacher: [ + Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, + Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, + Permissions.EXAM_AI_GENERATE, + Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, + Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, + Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, + Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, + Permissions.TEXTBOOK_UPDATE, + Permissions.CLASS_READ, Permissions.CLASS_ENROLL, + Permissions.CLASS_SCHEDULE, + Permissions.AI_CHAT, + ], + student: [ + Permissions.EXAM_READ, + Permissions.HOMEWORK_SUBMIT, + Permissions.QUESTION_READ, + Permissions.TEXTBOOK_READ, + Permissions.CLASS_READ, + Permissions.AI_CHAT, + ], + parent: [ + Permissions.EXAM_READ, + Permissions.TEXTBOOK_READ, + Permissions.CLASS_READ, + ], + // 可扩展:年级主任、教研组长等 + grade_head: [ + Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, + Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH, + Permissions.EXAM_AI_GENERATE, + Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE, + Permissions.QUESTION_CREATE, Permissions.QUESTION_READ, + Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE, + Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ, + Permissions.TEXTBOOK_UPDATE, + Permissions.CLASS_CREATE, Permissions.CLASS_READ, + Permissions.CLASS_UPDATE, Permissions.CLASS_ENROLL, + Permissions.CLASS_SCHEDULE, + Permissions.GRADE_MANAGE, + Permissions.AI_CHAT, + ], +} +``` + +### 2.4 数据权限策略 + +数据权限通过 `DataScope` 定义,在 data-access 层自动注入过滤条件: + +```typescript +// src/shared/types/permissions.ts + +export type DataScope = + | { type: "all" } // admin: 全量 + | { type: "owned" } // 仅自己创建的 + | { type: "class_members" } // 所在班级的成员数据 + | { type: "grade_managed"; gradeIds: string[] } // 管理的年级 + | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } // 教学的班级(可限定科目) + | { type: "children"; childrenIds: string[] } // 家长:子女数据 + +export interface AuthContext { + userId: string + roles: string[] // 所有角色,不再只取一个 + permissions: Permission[] // 合并所有角色的权限(去重) + dataScope: DataScope // 数据权限范围 +} +``` + +### 2.5 数据库变更 + +**新增一张表**(最小变更,不修改现有表结构): + +```sql +CREATE TABLE role_permissions ( + role_id VARCHAR(128) NOT NULL, + permission VARCHAR(100) NOT NULL, + PRIMARY KEY (role_id, permission), + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + INDEX idx_role_permissions_role (role_id) +); +``` + +Drizzle schema: + +```typescript +// 追加到 src/shared/db/schema.ts + +export const rolePermissions = mysqlTable("role_permissions", { + roleId: varchar("role_id", { length: 128 }).notNull() + .references(() => roles.id, { onDelete: "cascade" }), + permission: varchar("permission", { length: 100 }).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.roleId, table.permission] }), + roleIdIdx: index("role_permissions_role_idx").on(table.roleId), +})) +``` + +**不修改现有表**。`ROLE_PERMISSIONS` 常量作为初始 seed 数据,运行时优先从数据库读取(支持动态调整),数据库无记录时 fallback 到常量。 + +--- + +## 三、实现方案 + +### 3.1 NextAuth Session 携带权限信息 + +#### 修改 JWT 和 Session 类型 + +```typescript +// src/next-auth.d.ts + +declare module "next-auth" { + interface Session { + user: DefaultSession["user"] & { + id: string + roles: string[] // 所有角色 + permissions: string[] // 合并后的权限列表 + } + } +} + +declare module "next-auth/jwt" { + interface JWT { + id: string + roles: string[] + permissions: string[] + } +} +``` + +#### 修改 auth.ts callbacks + +```typescript +// src/auth.ts — 关键改动 + +import { ROLE_PERMISSIONS } from "@/shared/lib/permissions" + +function resolvePermissions(roleNames: string[]): string[] { + const set = new Set() + for (const name of roleNames) { + const perms = ROLE_PERMISSIONS[name] ?? [] + for (const p of perms) set.add(p) + } + return Array.from(set) +} + +export const authConfig = { + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id + // 查询用户所有角色 + const userRoles = await db + .select({ name: roles.name }) + .from(usersToRoles) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(eq(usersToRoles.userId, user.id)) + + const roleNames = userRoles.map(r => r.name) + token.roles = roleNames + token.permissions = resolvePermissions(roleNames) + } + return token + }, + async session({ session, token }) { + session.user.id = token.id + session.user.roles = token.roles + session.user.permissions = token.permissions + return session + }, + }, +} +``` + +### 3.2 `requirePermission()` 服务端权限断言 + +```typescript +// src/shared/lib/auth-guard.ts + +import { auth } from "@/auth" +import { ROLE_PERMISSIONS } from "@/shared/lib/permissions" +import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions" +import { db } from "@/shared/db" +import { users, usersToRoles, roles, classes, classEnrollments, classSubjectTeachers, grades } from "@/shared/db/schema" +import { eq, and, inArray } from "drizzle-orm" + +class PermissionDeniedError extends Error { + constructor(permission: string) { + super(`Permission denied: ${permission}`) + this.name = "PermissionDeniedError" + } +} + +/** + * 获取当前用户的完整认证上下文 + */ +export async function getAuthContext(): Promise { + const session = await auth() + const userId = session?.user?.id + if (!userId) throw new PermissionDeniedError("auth_required") + + const roles = session.user.roles ?? [] + const permissions = session.user.permissions ?? [] + const dataScope = await resolveDataScope(userId, roles) + + return { userId, roles, permissions, dataScope } +} + +/** + * 断言当前用户拥有指定权限 + */ +export async function requirePermission(permission: Permission): Promise { + const ctx = await getAuthContext() + if (!ctx.permissions.includes(permission)) { + throw new PermissionDeniedError(permission) + } + return ctx +} + +/** + * 断言当前用户拥有指定权限,并返回上下文(不抛异常版本) + */ +export async function checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }> { + const ctx = await getAuthContext() + return { allowed: ctx.permissions.includes(permission), ctx } +} + +/** + * 根据角色解析数据权限范围 + */ +async function resolveDataScope(userId: string, roleNames: string[]): Promise { + if (roleNames.includes("admin")) { + return { type: "all" } + } + + // 年级主任 + if (roleNames.includes("grade_head")) { + const managedGrades = await db + .select({ id: grades.id }) + .from(grades) + .where( + and( + eq(grades.gradeHeadId, userId), + // 或 teachingHeadId + ) + ) + if (managedGrades.length > 0) { + return { type: "grade_managed", gradeIds: managedGrades.map(g => g.id) } + } + } + + // 教师 + if (roleNames.includes("teacher")) { + const taughtClasses = await db + .select({ classId: classes.id, subjectId: classSubjectTeachers.subjectId }) + .from(classes) + .leftJoin(classSubjectTeachers, eq(classSubjectTeachers.classId, classes.id)) + .where(eq(classes.teacherId, userId)) + + const classIds = [...new Set(taughtClasses.map(c => c.classId))] + const subjectIds = taughtClasses + .map(c => c.subjectId) + .filter((s): s is string => s !== null) + + return { type: "class_taught", classIds, subjectIds: subjectIds.length > 0 ? subjectIds : undefined } + } + + // 学生 + if (roleNames.includes("student")) { + return { type: "class_members" } + } + + // 家长 + if (roleNames.includes("parent")) { + // TODO: 查询关联子女 + return { type: "children", childrenIds: [] } + } + + return { type: "owned" } +} + +/** + * 在 data-access 层使用:根据 DataScope 生成查询过滤条件 + */ +export function applyDataScope( + scope: DataScope, + options: { + creatorIdField?: any // 如 exams.creatorId + classIdField?: any // 如 classes.id + gradeIdField?: any // 如 grades.id + studentIdField?: any // 如 classEnrollments.studentId + } +): any[] { + const conditions: any[] = [] + + switch (scope.type) { + case "all": + break // 无过滤 + case "owned": + if (options.creatorIdField) { + conditions.push(eq(options.creatorIdField, /* currentUserId */)) + } + break + case "class_taught": + if (options.classIdField && scope.classIds.length > 0) { + conditions.push(inArray(options.classIdField, scope.classIds)) + } + break + case "grade_managed": + if (options.gradeIdField && scope.gradeIds.length > 0) { + conditions.push(inArray(options.gradeIdField, scope.gradeIds)) + } + break + case "class_members": + // 需要子查询:学生所在班级 + break + } + + return conditions +} + +export { PermissionDeniedError } +``` + +### 3.3 `usePermission()` 前端 Hook + +```typescript +// src/shared/hooks/use-permission.ts + +"use client" + +import { useSession } from "next-auth/react" +import type { Permission } from "@/shared/types/permissions" + +export function usePermission() { + const { data: session } = useSession() + const permissions = session?.user?.permissions ?? [] + const roles = session?.user?.roles ?? [] + + const hasPermission = (permission: Permission): boolean => { + return permissions.includes(permission) + } + + const hasAnyPermission = (...perms: Permission[]): boolean => { + return perms.some(p => permissions.includes(p)) + } + + const hasAllPermissions = (...perms: Permission[]): boolean => { + return perms.every(p => permissions.includes(p)) + } + + const hasRole = (role: string): boolean => { + return roles.includes(role) + } + + return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole } +} +``` + +### 3.4 Server Action 集成模式 + +#### 改造前(exams/actions.ts) + +```typescript +// 零鉴权,getCurrentUser() 返回硬编码值 +export async function deleteExamAction(prevState, formData) { + const { examId } = parsed.data + await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试 +} +``` + +#### 改造后 + +```typescript +import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +export async function deleteExamAction(prevState, formData) { + try { + const ctx = await requirePermission(Permissions.EXAM_DELETE) + const { examId } = parsed.data + + // 数据权限:非 admin 只能删自己的考试 + if (ctx.dataScope.type !== "all") { + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, examId), + columns: { creatorId: true }, + }) + if (!exam || exam.creatorId !== ctx.userId) { + return failState("You can only delete your own exams") + } + } + + await db.delete(exams).where(eq(exams.id, examId)) + } catch (e) { + if (e instanceof PermissionDeniedError) return failState(e.message) + throw e + } +} +``` + +### 3.5 data-access 层数据权限过滤 + +```typescript +// src/modules/exams/data-access.ts — 改造后 + +import { applyDataScope } from "@/shared/lib/auth-guard" +import type { DataScope } from "@/shared/types/permissions" + +export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => { + const conditions = [] + + // 原有筛选条件 + if (params.q) { /* ... */ } + if (params.status && params.status !== "all") { /* ... */ } + + // 数据权限过滤 + const scopeConditions = applyDataScope(params.scope, { + creatorIdField: exams.creatorId, + gradeIdField: exams.gradeId, + }) + conditions.push(...scopeConditions) + + const data = await db.query.exams.findMany({ + where: conditions.length ? and(...conditions) : undefined, + // ... + }) +}) +``` + +### 3.6 Middleware 改造 + +```typescript +// src/proxy.ts — 改造后 + +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { getToken } from "next-auth/jwt" + +// 路由 → 所需权限映射 +const ROUTE_PERMISSIONS: Record = { + "/admin": "school:manage", + "/teacher": "exam:read", // teacher 区域最低权限 + "/student": "exam:read", // student 区域最低权限 + "/parent": "exam:read", // parent 区域最低权限 +} + +// API 路由 → 所需权限映射 +const API_PERMISSIONS: Record = { + "/api/ai/chat": "ai:chat", + "/api/onboarding": "auth_required", // 特殊标记:仅需登录 +} + +export async function middleware(request: NextRequest) { + const token = await getToken({ req: request }) + + if (!token) { + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("callbackUrl", request.url) + return NextResponse.redirect(loginUrl) + } + + const { pathname } = request.nextUrl + const permissions: string[] = (token.permissions as string[]) ?? [] + const roles: string[] = (token.roles as string[]) ?? [] + + // 检查 API 路由权限 + for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) { + if (pathname.startsWith(prefix)) { + if (requiredPerm === "auth_required") break // 仅需登录 + if (!permissions.includes(requiredPerm)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + break + } + } + + // 检查页面路由权限 + for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) { + if (pathname.startsWith(prefix)) { + if (!permissions.includes(requiredPerm)) { + // 无权限 → 重定向到角色首页 + const defaultPath = resolveDefaultPath(roles) + return NextResponse.redirect(new URL(defaultPath, request.url)) + } + break + } + } + + return NextResponse.next() +} + +function resolveDefaultPath(roles: string[]): string { + if (roles.includes("admin")) return "/admin/dashboard" + if (roles.includes("teacher")) return "/teacher/dashboard" + if (roles.includes("student")) return "/student/dashboard" + if (roles.includes("parent")) return "/parent/dashboard" + return "/dashboard" +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +} +``` + +### 3.7 前端组件改造 + +#### 改造前 + +```tsx +// 硬编码角色判断 +{role === "teacher" ? : } +``` + +#### 改造后 + +```tsx +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" + +function DashboardPage() { + const { hasPermission, hasRole } = usePermission() + + return ( + <> + {hasPermission(Permissions.HOMEWORK_GRADE) && } + {hasPermission(Permissions.EXAM_CREATE) && } + {hasPermission(Permissions.SCHOOL_MANAGE) && } + + ) +} +``` + +--- + +## 四、渐进式重构路线图 + +### Phase 1:基础设施(1-2 天) + +1. 创建 `src/shared/types/permissions.ts` — 权限点定义 +2. 创建 `src/shared/lib/permissions.ts` — 角色-权限映射 +3. 创建 `src/shared/lib/auth-guard.ts` — `requirePermission()` / `getAuthContext()` +4. 创建 `src/shared/hooks/use-permission.ts` — 前端 Hook +5. 修改 `src/next-auth.d.ts` — Session/JWT 类型扩展 +6. 修改 `src/auth.ts` — JWT/Session callbacks 注入权限 +7. 新增 `role_permissions` 数据库表 + seed 脚本 + +### Phase 2:修复高危模块(2-3 天) + +优先修复零鉴权和越权漏洞: + +1. **exams/actions.ts** — 替换 `getCurrentUser()` 存根,所有 Action 加 `requirePermission()` +2. **classes/actions.ts** — 无鉴权接口补全,统一使用 `requirePermission()` +3. **homework/actions.ts** — 用 `requirePermission()` 替换 `ensureTeacher()`/`ensureStudent()` +4. **questions/actions.ts** — 补全鉴权 +5. **textbooks/actions.ts** — 补全鉴权 + +### Phase 3:数据权限集成(2-3 天) + +1. 各模块 `data-access.ts` 接受 `DataScope` 参数 +2. `getExams()` / `getHomeworkAssignments()` 等查询函数自动过滤 +3. 页面级 Server Component 传入 `AuthContext` + +### Phase 4:Middleware 升级(1 天) + +1. `proxy.ts` 从角色路由匹配 → 权限点路由匹配 +2. API 路由增加权限检查 + +### Phase 5:前端改造(2-3 天) + +1. 逐步替换 `role === "xxx"` 为 `hasPermission()` +2. 侧边栏导航从 `NAV_CONFIG[role]` → `NAV_CONFIG.filter(item => hasPermission(item.permission))` +3. 条件渲染统一使用 `usePermission()` + +--- + +## 五、考试模块改造对比 + +### 5.1 Server Actions 改造 + +#### 改造前 — createExamAction + +```typescript +export async function createExamAction(prevState, formData) { + // ... 解析表单 ... + try { + const user = await getCurrentUser() // 硬编码存根! + await persistExamDraft({ + examId: context.examId, + creatorId: user?.id ?? "user_teacher_math", // 硬编码 fallback! + // ... + }) + } catch (error) { + return failState("Database error") + } +} +``` + +#### 改造后 — createExamAction + +```typescript +export async function createExamAction(prevState, formData) { + let ctx: AuthContext + try { + ctx = await requirePermission(Permissions.EXAM_CREATE) + } catch (e) { + if (e instanceof PermissionDeniedError) return failState(e.message) + throw e + } + + // ... 解析表单 ... + + try { + await persistExamDraft({ + examId: context.examId, + creatorId: ctx.userId, // 真实用户 ID + // ... + }) + } catch (error) { + return failState("Database error") + } +} +``` + +#### 改造前 — deleteExamAction + +```typescript +export async function deleteExamAction(prevState, formData) { + const { examId } = parsed.data + // 无任何鉴权!任何人可删任何考试! + await db.delete(exams).where(eq(exams.id, examId)) +} +``` + +#### 改造后 — deleteExamAction + +```typescript +export async function deleteExamAction(prevState, formData) { + let ctx: AuthContext + try { + ctx = await requirePermission(Permissions.EXAM_DELETE) + } catch (e) { + if (e instanceof PermissionDeniedError) return failState(e.message) + throw e + } + + const { examId } = parsed.data + + // 数据权限:非 admin 只能删自己创建的考试 + if (ctx.dataScope.type !== "all") { + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, examId), + columns: { creatorId: true }, + }) + if (!exam) return failState("Exam not found") + if (exam.creatorId !== ctx.userId) { + return failState("You can only delete your own exams") + } + } + + await db.delete(exams).where(eq(exams.id, examId)) + revalidatePath("/teacher/exams/all") + return successState(examId, "Exam deleted") +} +``` + +### 5.2 Data-Access 层改造 + +#### 改造前 — getExams + +```typescript +export const getExams = cache(async (params: GetExamsParams) => { + // 任何调用者都能看到所有考试 + const data = await db.query.exams.findMany({ + where: conditions.length ? and(...conditions) : undefined, + }) +}) +``` + +#### 改造后 — getExams + +```typescript +export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => { + const conditions = [] + + // 原有筛选 + if (params.q) conditions.push(or(like(exams.title, search), like(exams.description, search))) + if (params.status && params.status !== "all") conditions.push(eq(exams.status, params.status)) + + // 数据权限自动过滤 + switch (params.scope.type) { + case "all": + break + case "owned": + conditions.push(eq(exams.creatorId, /* userId from caller */)) + break + case "class_taught": { + // 教师能看到自己班级关联的年级的考试 + if (params.scope.classIds.length > 0) { + // 通过 class → grade 关联查询 + const teacherGradeIds = await db + .selectDistinct({ gradeId: classes.gradeId }) + .from(classes) + .where(inArray(classes.id, params.scope.classIds)) + if (teacherGradeIds.length > 0) { + conditions.push(inArray(exams.gradeId, teacherGradeIds.map(g => g.gradeId))) + } + } + break + } + case "grade_managed": + if (params.scope.gradeIds.length > 0) { + conditions.push(inArray(exams.gradeId, params.scope.gradeIds)) + } + break + case "class_members": { + // 学生看到自己年级的已发布考试 + // 需要子查询获取学生所在班级的年级 + break + } + } + + const data = await db.query.exams.findMany({ + where: conditions.length ? and(...conditions) : undefined, + }) +}) +``` + +### 5.3 前端改造 + +#### 改造前 — exam-actions.tsx + +```tsx +// 仅通过路由守卫保护,组件内无权限判断 + +``` + +#### 改造后 — exam-actions.tsx + +```tsx +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" + +function ExamActions({ examId }) { + const { hasPermission } = usePermission() + + return ( + <> + {hasPermission(Permissions.EXAM_UPDATE) && ( + + )} + {hasPermission(Permissions.EXAM_DELETE) && ( + + )} + {hasPermission(Permissions.EXAM_DUPLICATE) && ( + + )} + + ) +} +``` + +--- + +## 六、验收标准 + +- [ ] 所有 Server Action 都有 `requirePermission()` 调用 +- [ ] `getCurrentUser()` 硬编码存根已移除 +- [ ] 前端零 `role === "xxx"` 硬编码 +- [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验 +- [ ] `classes/actions.ts` 所有接口有鉴权 +- [ ] `homework/actions.ts` 批改操作有资源归属校验 +- [ ] Middleware 基于权限点而非角色字符串拦截 +- [ ] 多角色用户可同时使用所有角色的功能 +- [ ] `role_permissions` 表有 seed 数据 +- [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed,无需改动业务代码 diff --git a/docs/architecture/003_ui_refactoring_plan.md b/docs/architecture/003_ui_refactoring_plan.md new file mode 100644 index 0000000..055fff6 --- /dev/null +++ b/docs/architecture/003_ui_refactoring_plan.md @@ -0,0 +1,407 @@ +# UI 代码结构重构计划 + +> 基于 2026-06-16 架构审核,整体评分 7.2/10 +> 目标:补齐工程细节短板,达到企业级标准(8.5+) + +--- + +## 审核评分总览 + +| 维度 | 评分 | 状态 | +|------|------|------| +| 目录结构分层 | 8/10 | 良好 | +| 组件设计 | 6.5/10 | 需重构 | +| 复用与抽象 | 6/10 | 需重构 | +| 样式组织 | 8.5/10 | 优秀 | +| 可维护性 | 7/10 | 一般 | +| 性能相关 | 7/10 | 一般 | +| 测试与质量保障 | 5.5/10 | 需重构 | +| 安全性 | 7.5/10 | 良好 | + +--- + +## P0 — 紧急(阻塞级质量问题) + +### P0-1 拆分巨型组件 + +**问题:** exam-form.tsx(1623 行)、textbook-reader.tsx(744 行)严重违反单一职责,难以维护和测试。 + +#### exam-form.tsx 拆分方案 + +``` +src/modules/exams/components/ + exam-form.tsx ← 保留为容器组件(~200 行),组合子组件 + exam-basic-info-form.tsx ← 考试基本信息(名称、科目、时间) + exam-ai-generator.tsx ← AI 生成试卷功能 + exam-structure-editor.tsx ← 试卷结构编辑 + exam-question-selector.tsx ← 题目选择器 + exam-preview-panel.tsx ← 试卷预览面板 + exam-form-actions.tsx ← 表单操作按钮组 +``` + +#### textbook-reader.tsx 拆分方案 + +``` +src/modules/textbooks/components/ + textbook-reader.tsx ← 保留为容器组件(~150 行) + textbook-content-panel.tsx ← 内容阅读面板(Markdown 渲染) + knowledge-point-list.tsx ← 知识点列表 + knowledge-graph.tsx ← 知识图谱可视化 + knowledge-point-dialogs.tsx ← 创建/编辑知识点 Dialog + textbook-editor-panel.tsx ← 编辑模式面板 + use-text-selection.ts ← 文本选择逻辑 Hook + use-knowledge-point-actions.ts ← 知识点操作逻辑 Hook +``` + +**验收标准:** +- 单文件不超过 300 行 +- 每个子组件职责单一,可独立测试 +- 容器组件仅负责组合和状态分发 + +--- + +### P0-2 修复无障碍(a11y)缺陷 + +**问题:** 全项目仅 7 处 `aria-label`,但 180 处 `onClick`;存在 `
` 非语义化标签。 + +#### 修复清单 + +1. **图标按钮补 aria-label** + - 搜索所有 `` 为语义化标签** + - textbook-reader.tsx:434 — 知识点卡片 → ` + +
+ ( + + Source Exam Text + +