feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档 涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、 §6 部署、§10 P0-P3 验收清单 - 新增 middleware.ts:认证 + 路由门禁 httpOnly cookie edu_session(JWT)读取 DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production && NEXT_PUBLIC_DEV_MODE=true) 生产模式 jose JWKS RS256 验签(iss/aud 校验) 路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头 /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden - 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false 防止生产环境误开 DEV_MODE 合成身份 - 新增 app/api/auth/login/route.ts + logout/route.ts 登录走 api-gateway /v1/iam/login 设置 httpOnly + Secure + SameSite=Strict cookie - 新增 app/api/graphql/route.ts:同域 GraphQL 代理 转发到 apollo-router,注入 router-authorization 头 - 新增 app/login/page.tsx + login-form.tsx zod 表单校验,next 参数支持 - 新增 app/shell/forbidden/page.tsx:403 页面 - 更新 route-permissions.ts:补全 P0 路由权限映射 - 更新 permission-bitmap.ts(shared-ts):位图编码/解码 - 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理 - 更新 config-fetcher.ts:config-service 直连降级 - 更新 ApolloProvider.tsx:SSR/RSC 兼容 - 更新 eslint.config.js:design-tokens/no-hardcoded-fonts 白名单调整
This commit is contained in:
15
apps/portal-shell/.gitignore
vendored
15
apps/portal-shell/.gitignore
vendored
@@ -1,2 +1,17 @@
|
||||
# graphql-codegen 产物(构建时生成)
|
||||
src/lib/api/__generated__/
|
||||
|
||||
# 本地环境变量(应永远在本地,不入库;根 .gitignore 已覆盖,此处冗余声明)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# TypeScript 增量构建缓存(286KB,不应入库;根 .gitignore 已 *.tsbuildinfo 覆盖)
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Next.js 构建产物
|
||||
.next/
|
||||
out/
|
||||
|
||||
# 测试覆盖率
|
||||
coverage/
|
||||
|
||||
960
apps/portal-shell/ARCHITECTURE.md
Normal file
960
apps/portal-shell/ARCHITECTURE.md
Normal file
@@ -0,0 +1,960 @@
|
||||
# portal-shell 前端架构总纲(v3.0)
|
||||
|
||||
> 版本:3.0
|
||||
> 日期:2026-07-20
|
||||
> 状态:**架构审计完成 + 重设计方案定稿,待实施**
|
||||
> 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
|
||||
>
|
||||
> 关联文档(按效力排序):
|
||||
>
|
||||
> 1. **本文件**(v3.0 总纲:审计结论 + 目标架构 + 路线图 + 工作规范)
|
||||
> 2. [README.md](./README.md)(v2.0 模块文档:微内核仪表盘子系统的详细设计,仅其"插件仪表盘"部分继续有效)
|
||||
> 3. [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)(后端架构唯一源:§5.4 视口四层模型、§11.7 前端数据层)
|
||||
> 4. [项目规则](../../.trae/rules/project_rules.md)(强制约束:§3.10 设计令牌、§4 安全、§14 多 AI 协作)
|
||||
> 5. [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md)(CICD → Edu 迁移背景)
|
||||
>
|
||||
> **效力声明**:README v2.0 中与本文件冲突的表述(完成度声明、设计方向、"旧 portal 已下线"等)以本文件为准;`docs/standards/ui-design-system.md`(v1.0,面向已废弃的 4 微前端方案)自本文件发布之日起**废止**,其有效内容已并入本文件 §8;`docs/architecture/0020_portal_shell_architecture.md`(v1.0)为历史评审稿,仅作背景参考。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [现状审计(2026-07-20 快照)](#1-现状审计2026-07-20-快照)
|
||||
2. [问题根因分析](#2-问题根因分析)
|
||||
3. [目标架构 v3.0](#3-目标架构-v30)
|
||||
4. [认证与身份链](#4-认证与身份链)
|
||||
5. [数据层架构](#5-数据层架构)
|
||||
6. [安全架构(安全边际)](#6-安全架构安全边际)
|
||||
7. [信息架构与页面体系](#7-信息架构与页面体系)
|
||||
8. [前端设计规范(强制)](#8-前端设计规范强制)
|
||||
9. [页面迁移总表(~140 页)](#9-页面迁移总表140-页)
|
||||
10. [实施路线图(P0–P6)](#10-实施路线图p0p6)
|
||||
11. [后续 AI 工作规范(强制)](#11-后续-ai-工作规范强制)
|
||||
12. [风险登记册](#12-风险登记册)
|
||||
13. [附录](#13-附录)
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状审计(2026-07-20 快照)
|
||||
|
||||
> 审计方法:全部结论均可复现。每项发现标注证据(文件路径 + 行号 / 可执行命令)。审计环境:Windows 11 + Node 24,分支 `feat/architecture-v2.1`,工作区干净。
|
||||
|
||||
### 1.1 验收声明 vs 实测结果
|
||||
|
||||
README v2.0 声称"P0–P4 全部验证通过、功能验收 23 项全部 [x]"。实测:**构建级声明属实,功能级声明不成立**——现有门禁只验证"骨架不塌",从不验证"功能可用"。
|
||||
|
||||
| README 声明 | 实测结果 | 结论 |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `typecheck` 0 错误 | `node node_modules/typescript/bin/tsc --noEmit` 无输出退出 | ✅ 属实 |
|
||||
| `vitest` 206/206 通过 | 实测 19 文件 206 用例全部通过(8.12s) | ✅ 属实 |
|
||||
| `build` 6 路由生成成功 | 实测成功:`/`、`/_not-found`、`/api/health`、`/api/log`、`/api/ready`、`/shell/[[...route]]` | ✅ 属实——但**全应用只有 1 条业务路由** |
|
||||
| "31 个内置插件全部加载正常" | 31 插件全部注册并能渲染(30 REAL + 1 PARTIAL,无 STUB/MOCK);但其 GraphQL 查询**严格匹配 0/50**(见 §1.3-F2),运行时全部返回错误/空数据 | ⚠️ 代码真实,数据全断 |
|
||||
| "31 个 widget 旧纸感令牌全部迁移(1104 次替换,零违规)" | `src/widgets/` 仍残留 **271 处**旧令牌类(`text-heading-*`/`mt-sm`/`py-xs`/`p-md` 等),这些类在 Tailwind v4 主题中**不存在**,渲染时无样式 | ❌ **不属实** |
|
||||
| "M10 旧 portal 下线完成" | 4 个旧 portal 完整保留在 `apps/`(teacher 143 文件 / student 118 / parent 190 / admin 84),含全部页面源码 | ❌ **不属实**(未删 ≠ 已下线;但也未迁移) |
|
||||
| "三层安全边界 L1/L2/L3 已落地" | `checkRoutePermission` 仅被**测试文件**引用;无 `middleware.ts`;`shell/[[...route]]/page.tsx` 不调用任何权限检查。**L1/L2 在生产请求路径上是死代码** | ❌ 代码存在但从未接线 |
|
||||
| "admin 改配置 → 用户刷新生效" | 无后端时 `fetchPluginConfig` 返回**空插件集**,仪表盘渲染"暂无可见插件";本地无种子数据/无 mock,**开发态开箱即为空壳** | ❌ 链路存在,起点即断 |
|
||||
| ESLint 强制插件隔离与 notify 封装(`no-restricted-imports`) | `eslint.config.js` 仅有 hex 颜色 + 字体名两条规则;**无任何 import 限制规则** | ❌ 不属实 |
|
||||
|
||||
### 1.2 一句话诊断
|
||||
|
||||
**portal-shell 当前是一个"质量门禁全绿、但没有任何可用页面"的空壳**:微内核仪表盘骨架(Shell/Registry/SlotRenderer/PluginBoundary/数据层封装)工程质量合格,但它之上**既没有登录认证链,也没有一个真实业务页面**;31 个卡片插件查询的是后端并不存在的 GraphQL 字段;距离 CICD 原版(35 模块、4 端完整页面体系)的功能差距约为 **140 个页面**。
|
||||
|
||||
### 1.3 十大断裂点(按严重度排序,证据化)
|
||||
|
||||
#### F1.【致命】没有任何真实业务页面 —— "前端不可用"的第一根因
|
||||
|
||||
- 全应用仅 1 条业务路由:`src/app/shell/[[...route]]/page.tsx`(catch-all)。`ShellPage` **不读取 route 参数**(`src/app/shell/[[...route]]/page.tsx:26-45`),`/shell/teacher/grades` 与 `/shell/admin/users` 渲染完全相同的内容;业务区分被压缩到 query params(`?classId`/`?termId` 等)。
|
||||
- `route-permissions.ts` 声明的 24 条路由(如 `/shell/admin/users`、`/shell/teacher/lesson-plans`)在文件系统中**一条都不存在**——权限表保护的是幽灵路由(该表从 CICD 项目平移,本意给网关 middleware 用,Next.js 侧从未接线)。
|
||||
- 对照资产:CICD 原版 **155 个页面**(auth 4 + onboarding 1 + dashboard 公共 8 + admin 41 + teacher 54 + student 25 + parent 14 + management/grade 5,含 register/privacy/terms);Edu 旧 4 portal 共 **140 个 page.tsx**(teacher 56 / student 36 / parent 24 / admin 24)已在仓库内但从未接入 portal-shell(旧 portal 本身缺 management/grade 与 register/onboarding 体系,属缺口待补)。
|
||||
|
||||
#### F2.【致命】GraphQL 契约前后端全面脱节 —— 50 个操作严格匹配 0 个
|
||||
|
||||
- 前端 `operations/*.graphql.ts` 定义 **50 个操作**(32 query + 18 mutation;README 写 51,其中 GetMyChildrenOverview 改名去重后为 50)。
|
||||
- 合并 schema(`src/lib/api/__generated__/combined-schema.graphql`,700 行)仅有 **38 个 Query 字段,且完全没有 Mutation 类型**。
|
||||
- **严格匹配数:0/50**。唯一 root 字段名命中的是 `GetLayoutTemplates`→`layoutTemplates`,但其选择的 `availableSlots` 字段在 `LayoutTemplateGql` 上不存在,仍然校验失败。
|
||||
- 按域不匹配统计:**admin 19/19、teacher 6/6、student 8/8、parent 4/4、sidebar 3/3、topbar 3/3、universal 7/7,全部不匹配**。典型样例:
|
||||
- 列表查询不存在:`grades(classId)`/`homeworks(classId)`/`exams(classId)` → 后端仅有 `grade(id)`/`homework(id)`/`exam(id)` 单查;`schedule`/`attendance`/`announcements`/`myClasses`/`terms`/`myChildren`/`users`/`roles`/`permissions`/`auditLogs`/`invitationCodes`/`school`/`lessonPlans`/`schedulingRules`/`myLearningPath`/`electiveCourses`/`aiTutorSessions`/`leaveRequests`/`search` 在 schema 中**全部不存在**。
|
||||
- 形状不符:`me{id,name,email,role}` → iam `User` 实为 `userId/email/name/status/dataScope`;`exams` 选 `name/subject/maxScore` → `Exam` 实为 `title/totalScore`;`notifications(limit,offset)` 期望 `{items,total}` 包装 → msg 实为 `notifications(userId: ID!)` 平铺数组;`errorBookItems` 底层为 snake_case(`question_id` 等)。
|
||||
- **18 个 mutation 全部无契约**(schema 无 Mutation 类型):SaveLessonPlan/ApproveLeave/RejectLeave/UpdateUserStatus/UpdateUserRole/UpdateRolePermissions/CreateInvitationCode/RevokeInvitationCode/UpdateSchool/SaveSchedulingRule/EnrollCourse/DropCourse/MarkErrorMastered/SendAiTutorMessage 及 plugin-manager 的 4 个配置 mutation。
|
||||
- **config-service resolver 实测**(唯一声称支撑全架构配置的后端):仅实现 `plugin/plugins/layoutTemplates/userLayoutOverride/pluginConfig` 5 个 Query;**plugin-manager 面板依赖的 `rolePluginMapping`/`roleLayoutDefault` 及全部 update\*/reset mutation 在 schema 与 resolver 双层均未实现**——也就是说,即使后端全栈启动,"admin 改配置"这个插件架构的旗舰功能也**无法保存**。
|
||||
- 唯一验证通过的查询:仪表盘启动用的 `pluginConfig`(不经 operations/,内联于 `config-fetcher.ts:24-58`;`PluginConfigLayoutGql` 含 `availableSlots`/`layoutSchemaJson`,形状吻合)——这就是"后端就绪时仪表盘能出框架、但所有卡片空转"的原因。
|
||||
- 编译期防线自毁:`codegen.yml:49-56` 注释自述"forward-looking spec fields … not yet present in services subgraph SDL … 44 个 'Cannot query field X' 错误",被迫 `skipDocumentsValidation: true`;同时 codegen 未配置 `typescript-operations`,**没有生成任何 per-operation 类型**,lib/api 的返回类型全部手写——编译期 0 报错,运行期全报错。
|
||||
- 后端真正可用但**没有任何 widget 使用**的资产:data-ana 的 4 个仪表盘聚合查询(`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard`)+ `warnings`/`mastery*`/`diagnosticReports`/`errorBook*` + config-service 5 个配置查询 + iam `user/role/dataScope` + core-edu/content 按 id 单查。
|
||||
- 无 GraphQL schema 的服务:`classes`、`student-bff`、`teacher-bff`、`parent-bff`、`push-gateway`(`api-gateway` 为 router 除外)——`classes` 域查询当前**无处落地**。
|
||||
|
||||
#### F3.【致命】认证与身份链断裂 —— 没有登录页,角色写死 teacher
|
||||
|
||||
- 无登录/注册页面;`src/app/page.tsx` 直接 `redirect("/shell")`。
|
||||
- `ShellPage` 从 `x-user-id`/`x-user-role` 请求头取身份,**缺失时默认 `dev-user` / `teacher`**(`page.tsx:28-31`)——任何人直达 portal-shell 都是"教师"。
|
||||
- Apollo Client 期望从 `localStorage["edu_token"]` 读 JWT(`ApolloProvider.tsx:18-27`),但**全应用没有任何代码写入这个 token**(没有登录流程)→ 所有 GraphQL 请求永远匿名。
|
||||
- 浏览器直连 apollo-router :3000,**绕过 api-gateway**(JWT 校验在 gateway,而 gateway 不在浏览器→router 路径上)。
|
||||
|
||||
#### F4.【严重】安全边界是"纸面合规" —— L1/L2 从未接入请求路径
|
||||
|
||||
- 无 `middleware.ts`;`checkRoutePermission`/`batchCheckRoutePermission` 仅被 `*.test.ts` 引用(grep 实测 60+ 处引用全部在 `__tests__`)。
|
||||
- 后果:`/shell/admin/users` 对 teacher/student/parent 角色**直接放行**;唯一的真实过滤发生在 config-service(L3),而它过滤的只是"仪表盘上显示哪些卡片"。
|
||||
- 权限位图 `PERMISSION_BITMAP_ORDER` 中 **`GRADE_READ` 重复定义两次**(`permission-bitmap.ts:62` 与 `:79`)——按"顺序不可变"铁律属数据缺陷,趁未签发真实 JWT 前必须去重。
|
||||
- `infra/docker-compose.yml` 的 portal-shell 服务(含生产 profile)环境变量写入 **`NEXT_PUBLIC_DEV_MODE: "true"`**——生产部署也以 dev 模式运行,等于永久绕过认证。
|
||||
- `infra/apollo-router/router.yaml` 硬编码 `router-authorization: dev-router-secret`(明文入库)。
|
||||
|
||||
#### F5.【严重】设计系统三方冲突 + 令牌断裂
|
||||
|
||||
- **三个互相矛盾的"权威"**:`docs/standards/ui-design-system.md`(纸感米白 + Fraunces/Inter/JetBrains Mono 三字体 + 学术深蓝,面向已废弃的 4 微前端方案);README v2.0(shadcn 标准 + Inter 单字体);实际实现(`packages/ui-tokens` = shadcn zinc 默认色板)。
|
||||
- 31 个 widget 残留 **271 处**旧令牌类(grep:`text-heading-3`/`mt-sm`/`py-xs`/`p-md`/`space-y-xs` 等),这些类在 `@theme` 中**没有定义** → 相关间距/字号渲染为零值,widget 视觉上是坏的。
|
||||
- 另有批量出现的 `border border` 重复类(如 `grades-widget/index.tsx:32,42`)。
|
||||
- `src/styles/tokens.css`(README 附录自称"旧,待 P1 移除")仍在源码树中。
|
||||
|
||||
#### F6.【严重】31 个插件 ≈ 31 张信息卡片,不是功能
|
||||
|
||||
- 全量审计结论(31/31):**30 REAL + 1 PARTIAL,0 STUB / 0 MOCK**——插件代码是"真"的,问题是**薄**且数据断供。平均 139 行/个,形态为"一张卡片 + 列表/表单局部"。
|
||||
- 典型缺陷(审计实测):
|
||||
- `question-bank`(唯一 PARTIAL):新建题目**不接 mutation**,仅 push 进本地 state(`local-${Date.now()}`),刷新即失。
|
||||
- `lesson-plan-editor`:仅 标题/目标/内容/资源 四字段表单(216 行);对照 CICD 同功能:lesson-preparation 是全仓最大模块(113 文件:结构树 + 纸面编辑器 + AI 建议 + 版本/审核/发布 + xyflow 画布);错误处理 `catch { /* toast */ }` 空吞(违反 notify 强制)。
|
||||
- `quick-actions`:导航目标 `/homework/new`、`/schedule`、`/grades` 等**全部不存在**(缺 `/shell` 前缀且无对应路由),点击即掉进 catch-all 仪表盘。
|
||||
- `global-search`:跳转目标 `/students/:id` 等不存在;`user-menu` 无登出项;`locale-switcher` 只切本地 state 不加载任何 i18n;`ai-tutor` 切会话不加载历史;`notifications-widget` 分页 offset 固定 0。
|
||||
- 对比 CICD 同功能页面量级:考试模块 11 页(all/create/new/[id]/build/edit-rich/analytics/proctoring/grading)、作业模块含扫描阅卷、成绩模块 5 页、教案模块 7 页。
|
||||
- 插件间无页面级导航目标(没有可跳转的详情页),"点击卡片进详情"无从谈起。
|
||||
|
||||
#### F7.【严重】开发体验不可用 —— 无后端 = 空壳
|
||||
|
||||
- `fetchPluginConfig` 三级降级的终点是 `getDefaultConfig()`:**`plugins: []`**(`config-fetcher.ts:193-212`)→ 无后端/无种子数据时,仪表盘渲染空壳"暂无可见插件"。
|
||||
- 仓库内**没有**本地 mock/seed/MSW 任何一条让前端独立可运行的路径;旧 portal 里的 MSW handlers(`teacher-portal/src/mocks/handlers-p*.ts`,覆盖 P4/P5/P7 场景)未被复用。
|
||||
|
||||
#### F8.【中】路由权限命名双轨制混乱
|
||||
|
||||
- README 示例用点号权限(`grade.read`),实际 `PERMISSION_BITMAP_ORDER` 用 `<RESOURCE>_<ACTION>` 大写下划线(`GRADE_READ`);widget manifest 的 `requiredPermissions` 字段 31 个插件**无一使用**(L2 插件级门禁名存实亡)。
|
||||
|
||||
#### F9.【中】基础设施与文档脱节
|
||||
|
||||
- README §6.1 说 7 domain API 文件 "Widget 数" 与 §13.2 表格不一致(topbar 4 插件 vs 2 API 的统计口径混乱,实际 admin.ts 有 20 个 hook);§11.3 测试矩阵与附录 B "95 用例" 自相矛盾(实际 206)。
|
||||
- `src/shell/Registry.tsx` 头注释写"内置插件共 28 个…admin(3)",实际注册 31 个(admin 6)——代码对、注释陈旧。
|
||||
- `docs/standards/ui-design-system.md` 全文面向"4 个微前端 + Module Federation"——该架构已被 ADR-033 废弃。
|
||||
- `MIGRATION_GUIDE.md`/根 README 仍写"前端:Next.js + Module Federation(4 微前端)"。
|
||||
|
||||
#### F10.【低】工程细节
|
||||
|
||||
- `next.config.js` 同时保留 Turbopack 与 webpack 双份配置(注释已说明,可接受)。
|
||||
- `.env.local` 入库(含本地配置,虽无密钥,但 `.env.example` 已存在时应 gitignore)。
|
||||
- `tsconfig.tsbuildinfo`(286KB 构建缓存)入库。
|
||||
|
||||
### 1.4 可复用资产盘点(不要把婴儿和洗澡水一起倒掉)
|
||||
|
||||
| 资产 | 位置 | 状态 | 处置 |
|
||||
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 微内核仪表盘骨架(Shell/LayoutManager/SlotRenderer/Registry/PluginBoundary/PluginLifecycle/PropsMerger) | `src/shell/` + `src/shared/components/plugin-boundary.tsx` | ✅ 工程质量合格,测试覆盖 | **保留**,收缩为"角色首页仪表盘"专用 |
|
||||
| 数据层封装(apollo-client/useWidgetQuery/useWidgetMutation/ApiError) | `src/lib/` | ✅ 合格 | **保留**,改走同域代理(§5.2) |
|
||||
| lib/api 7 domain + 50 operations | `src/lib/api/` | ⚠️ 结构合格;**50 操作严格匹配 0 个**,类型全部手写 | **保留结构**,按真实 schema 逐域重写操作并恢复 codegen 全量类型(§5.3) |
|
||||
| 三级错误边界 + useErrorReport + notify | `src/shared/` + `packages/hooks/` | ✅ 合格 | **保留**,扩展到页面级 |
|
||||
| 权限位图 + route-permissions 表 | `packages/shared-ts/` + `src/shared/lib/` | ⚠️ 未接线 + GRADE_READ 重复 | **接线到 middleware**,位图去重 |
|
||||
| shadcn 令牌三层 + ui-components 19 件 | `packages/ui-tokens/` + `packages/ui-components/` | ✅ 合格 | **保留为唯一设计系统** |
|
||||
| **4 个旧 portal 的 140 个页面实现** | `apps/{teacher,student,parent,admin}-portal/` | ⚠️ 完整但栈不同(urql + next-intl + MSW + 纸感令牌) | **迁移源**:逐页移植 + 换数据层 + 换令牌 |
|
||||
| 旧 portal 的 MSW handlers(P4/P5/P7 全覆盖) | `apps/teacher-portal/src/mocks/` 等 | ✅ 可用 | **迁移为 portal-shell 开发兜底层** |
|
||||
| next-intl messages(zh-CN/en) | 旧 portal `src/messages/` | ✅ 可用 | **随页面迁移** |
|
||||
| PQ Manifest + APQ + Router limits 安全栈 | `public/pq-manifest.json` + `infra/apollo-router/router.yaml` | ✅ 链路完整 | **保留**,manifest 随操作清单更新重新生成 |
|
||||
| 测试基座(vitest + 19 文件 206 用例) | `src/**/__tests__/` | ✅ 合格 | **保留**,新增页面级测试 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 问题根因分析
|
||||
|
||||
为什么"质量门禁全绿"的系统会完全不可用?后续工作必须避免重蹈覆辙,四条根因:
|
||||
|
||||
1. **验收指标错位**:门禁只覆盖 typecheck/lint/单测/build 这些"骨架指标",从未把"页面数、契约匹配率、关键用户流 E2E"纳入验收。于是"206 测试全过"与"没有一个可用页面"同时成立。**对策**:§10 的每个阶段退出标准必须包含功能指标 + 可复现证据。
|
||||
2. **契约逆向编写**:前端 operations 按 spec 文档"超前"编写(50 个操作按设计意图而非后端实现,**0 个通过严格校验**),后端子图只落地了 38 个只读查询、0 个 mutation。`skipDocumentsValidation: true` + 不生成 per-operation 类型,让这种脱节编译期不可见。**对策**:§5.3 契约纪律——operations 只允许引用真实 schema 字段,mock 数据走 MSW 而不是"假契约"。
|
||||
3. **范围误判**:v2.1 把"统一前端"收缩成"统一仪表盘",默认了"页面以后再说",但没有文档记录这个范围缺口,README 反而把仪表盘骨架的完成写成了整个前端的完成。**对策**:本文件 §7/§9 把页面体系定义为 portal-shell 的一等公民职责。
|
||||
4. **文档激励扭曲**:多处"已完成"声明与实测不符(令牌迁移、旧 portal 下线、安全边界),说明验收只看了 PR 描述没看运行态。**对策**:§11.6 文档同步纪律——验收声明必须附可复现命令输出,Reviewer 运行命令复核。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构 v3.0
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
| # | 原则 | 含义 |
|
||||
| --- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| P1 | **页面一等公民** | 业务功能落在真实 Next.js 路由页面上(可寻址、可分享、可深链);插件仪表盘只承担"角色首页聚合" |
|
||||
| P2 | **契约真实** | 前端 GraphQL 操作只允许引用后端真实 schema;后端未就绪的功能用 MSW mock 数据,禁止"假契约" |
|
||||
| P3 | **fail-closed** | 无身份 → 登录页;无权限 → 403 页;配置缺失 → 内置默认(而非空壳);错误 → 显式兜底(而非静默) |
|
||||
| P4 | **单一设计系统** | shadcn 标准令牌(@edu/ui-tokens)为唯一视觉权威;任何页面/插件不引入第二套令牌 |
|
||||
| P5 | **微内核收敛** | Shell 插件系统收缩为仪表盘专用机制,不再承载"整个应用"的野心;新增功能默认建页面而非建插件 |
|
||||
| P6 | **迁移优先于重写** | 140 个旧页面是迁移源而非重写对象;移植时换数据层(urql→Apollo hooks)、换令牌(纸感→shadcn)、保留业务交互逻辑 |
|
||||
| P7 | **证据化验收** | 每个里程碑以可复现命令 + 运行态截图/录屏验收,禁止"声明即完成" |
|
||||
|
||||
### 3.2 总体结构(混合路由模型)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ 浏览器 │
|
||||
└──────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────▼─────────────────────────────────────────────────┐
|
||||
│ portal-shell :4010(Next.js 16 App Router 单容器) │
|
||||
│ │
|
||||
│ middleware.ts(新) │
|
||||
│ ├─ 公共路径放行(/login、/api/health、静态资源) │
|
||||
│ ├─ JWT cookie 校验 → 注入 x-user-id/x-user-role/x-perms │
|
||||
│ └─ checkRoutePermission(L1 角色 + L2 权限点,fail-closed) │
|
||||
│ │
|
||||
│ /login 登录页(新) │
|
||||
│ /shell/forbidden 403 页(新) │
|
||||
│ /shell ───────────────────── ┐ │
|
||||
│ layout.tsx(新框架) │ AppFrame:TopBar + Sidebar │
|
||||
│ ├─ page.tsx(catch-all) │ + 导航菜单(按角色/权限过滤) │
|
||||
│ │ = 角色仪表盘 │ │
|
||||
│ │ (微内核插件区,保留) │ │
|
||||
│ ├─ teacher/exams/page.tsx │ 真实业务页面(迁移) │
|
||||
│ ├─ teacher/exams/[id]/… │ │
|
||||
│ ├─ student/my-grades/… ┘ │
|
||||
│ └─ … ~140 页面 │
|
||||
│ │
|
||||
│ /api/graphql(新) GraphQL 同域代理 │
|
||||
│ └─ 从 httpOnly cookie 取 JWT → Authorization → apollo-router │
|
||||
│ /api/auth/login(新) 登录代理:调 iam → Set-Cookie │
|
||||
│ /api/health /api/ready /api/log 保留 │
|
||||
└──────────────┬─────────────────────────────────────────────────┘
|
||||
│ 仅两条出站:
|
||||
├─ /api/graphql → apollo-router :3000(业务查询,APQ)
|
||||
└─ /api/v1/* → api-gateway :8080(REST:认证/上传/SSE 等)
|
||||
```
|
||||
|
||||
**关键决策(v3.0 ADR 摘要,详见 §3.4):**
|
||||
|
||||
- **V3-A1 混合路由模型**:真实页面 + 保留仪表盘微内核。废除"一切皆卡片、单 catch-all 承载全站"的极端;catch-all 仅保留给仪表盘(可选路由参数如 `?layout=`),新页面一律走显式路由。
|
||||
- **V3-A2 认证链闭环**:登录页 + `/api/auth/login` 代理 + httpOnly cookie + middleware 校验 + `/api/graphql` 代理。**删除 localStorage JWT 方案**。
|
||||
- **V3-A3 门禁接线**:`checkRoutePermission` 移入 `middleware.ts` 真实执行;新增路由必须在 `route-permissions.ts` 登记(CI 校验路由表与文件系统一致性)。
|
||||
- **V3-A4 契约纪律**:operations 与真实 schema 强绑定;codegen 恢复 `typescript-operations` 全量类型;`skipDocumentsValidation` 随后端补齐分域关闭(§5.3)。
|
||||
- **V3-A5 设计系统单源**:shadcn 标准(zinc)为唯一方向,废止纸感全局方案;备课编辑器二期可用模块命名空间(`--lp-*`)局部纸感化。
|
||||
- **V3-A6 接入 next-intl**:复用旧 portal 的 messages,页面迁移不丢失已翻译文案。
|
||||
- **V3-A7 MSW 开发兜底**:`NEXT_PUBLIC_MSW=1` 时启用,前端无后端可开发/演示/跑 E2E;生产构建永不包含。
|
||||
- **V3-A8 验收门禁改革**:CI 增加契约匹配率检查、路由表一致性检查、页面计数、Playwright E2E(P6)。
|
||||
|
||||
### 3.3 与现有架构的关系(保留什么、改变什么)
|
||||
|
||||
| 现有(v2.0) | v3.0 处置 | 理由 |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| 单 catch-all `/shell/[[...route]]` 承载一切 | **收缩**:仅渲染角色仪表盘;新增显式路由优先于 catch-all(Next.js 路由规则天然支持) | 页面可寻址、RSC 预取、loading/error 按路由生效 |
|
||||
| LayoutManager 5 模板(classic/focus/split/triple/canvas) | **保留**为仪表盘区布局;页面区用固定 AppFrame(TopBar+Sidebar+Main) | 模板化价值在仪表盘;页面需要稳定框架 |
|
||||
| 31 个 widget 插件 | **保留骨架**,内容逐步替换/下线:与页面功能重复的卡片改为"页面入口卡 + 关键摘要",数据接通真实 schema | 卡片应该是页面的摘要与入口,不是功能的全部 |
|
||||
| config-service 三层插件配置 | **保留**,职责收缩为"仪表盘 + 导航可见性"配置 | 页面不再依赖插件配置,配置挂了不影响页面 |
|
||||
| SWR 配置静默刷新 | **保留**于仪表盘 | 低频配置变更无需推送 |
|
||||
| URL Search Params 跨插件共享 | **保留**并扩展为页面间状态契约(classId/childId/termId) | 已验证有效 |
|
||||
| RSC 流式渲染(use() + Suspense) | **保留**并推广到页面级(页面 RSC 预取 + 流式注入) | 收益真实 |
|
||||
| 三级错误边界 | **保留**,扩展:Route(页面 error.tsx)→ Section(页面区块)→ Widget(卡片) | 与页面体系对齐 |
|
||||
|
||||
### 3.4 v3.0 ADR 全文
|
||||
|
||||
#### V3-A1:混合路由模型(真实页面 + 仪表盘微内核)
|
||||
|
||||
- **背景**:v2.0 用单 catch-all + 插件配置承载全站,导致页面不可寻址(F1)、权限表空转(F4)、功能止步于卡片(F6)。
|
||||
- **决策**:业务功能落在显式 Next.js 路由页面(`/shell/<role>/<module>/...`),共享 `AppFrame` 布局;插件系统收缩为 `/shell` 角色首页的聚合仪表盘。
|
||||
- **取舍**:放弃"admin 可配置一切页面"的幻想——页面是代码、受版本控制、可 code review;admin 可配置的收缩为"导航可见性 + 仪表盘卡片 + 卡片 props"。这换来:真实 URL、浏览器前进后退、RSC 流式预取、路由级 loading/error、中间件门禁——全部是 Next.js 原生能力,不再自造。
|
||||
- **插件与页面的关系**:卡片 = 页面的摘要 + 入口。点击卡片 → `router.push` 到对应页面。universal 插件按 role 渲染不同摘要视图的机制保留。
|
||||
|
||||
#### V3-A2:认证链闭环 + 删除 localStorage JWT
|
||||
|
||||
- **背景**:F3(无登录页、localStorage token 无人写入、默认角色 teacher)。
|
||||
- **决策**:
|
||||
1. `/login` 页面(迁移自旧 portal login,换 shadcn 令牌)。
|
||||
2. `/api/auth/login` Route Handler:转发凭证到 iam(经 api-gateway),成功后把 JWT 写入 **httpOnly + Secure + SameSite=Strict cookie**(project_rules §4);JS 永远接触不到 token。
|
||||
3. `middleware.ts`:读取 cookie → 校验(开发态 decode 验签跳过,生产用 iam JWKS RS256 验签)→ 注入 `x-user-id`/`x-user-role`/`x-user-permissions` 请求头供 RSC 读取;无 token → redirect `/login?next=<pathname>`。
|
||||
4. `/api/graphql` Route Handler:Apollo Client 的 HttpLink 指向同域 `/api/graphql`;Handler 从 cookie 取 token 加 `Authorization: Bearer` 转发 apollo-router,响应原样回传(含 APQ hash 请求)。
|
||||
- **取舍**:多一跳同域代理(<5ms 本地开销),换来 token 全程不出 httpOnly cookie——消除 XSS 窃取凭证面;同时修复"浏览器绕过 api-gateway"问题(代理在服务端调 router,与 gateway 同信任域)。
|
||||
- **DEV_MODE**:仅本地开发;提供 `dev-token` 合成身份(middleware 生成 x-user-* 头)。生产环境变量缺失/为 true 时启动直接拒绝(`instrumentation.ts` 启动校验)。
|
||||
|
||||
#### V3-A3:路由门禁 middleware 接线 + 权限模型修正
|
||||
|
||||
- **背景**:F4(checkRoutePermission 死代码)、F8(命名双轨)。
|
||||
- **决策**:
|
||||
1. `middleware.ts` 对每个 `/shell/**` 请求执行 `checkRoutePermission(pathname, bitmap, role)`;拒绝 → `/shell/forbidden`(403 页,新)。
|
||||
2. `route-permissions.ts` 四表保留,**新增/移动路由必须同步登记**;CI 运行一致性脚本:扫描 `src/app/**/page.tsx` 路由 ⇄ 权限表双向核对,未登记即失败。
|
||||
3. 权限点唯一来源 `PERMISSION_BITMAP_ORDER`;README 的点号示例作废;31 个 manifest 补登 `requiredPermissions`(L2 插件级,config-service 过滤用)。
|
||||
4. `GRADE_READ` 重复项去重:保留首次出现位(索引 26),删除第二次(索引 31 处),该位标记 `_RESERVED_31` 占位永不复用;趁系统未签发真实 JWT,现在就改(P0)。
|
||||
- **边界说明**:middleware 是 L1/L2 的**第一道**防线(性能/体验),不是唯一防线——resolver 级 `@RequirePermission`(后端)仍是权威。前端门禁防"走错门",后端门禁防"恶意请求"。
|
||||
|
||||
#### V3-A4:GraphQL 契约纪律
|
||||
|
||||
- **背景**:F2(50 操作 vs 后端 38 查询/0 mutation,严格匹配 0/50)。
|
||||
- **决策**:
|
||||
1. **操作清单真实化**:`operations/*.graphql.ts` 只允许引用 `combined-schema.graphql` 真实存在的字段。每新增一个页面,先确认 schema 有所需字段;没有 → 走"契约工单"(§11.4)推动后端补齐,**同时**用 MSW mock 让页面先行。
|
||||
2. **codegen 恢复强校验**:分域关闭 `skipDocumentsValidation`(哪个域后端补齐了就关哪个域),恢复 `typescript-operations` 生成操作级类型,**删除 lib/api 手写 inline 类型**(漂移源)。
|
||||
3. **schema 同步自动化**:`scripts/normalize-schema.ts` 保留;新增 CI 步骤:子图 SDL 变更 → codegen diff 检查,operations 引用不存在字段即失败。
|
||||
4. **PQ Manifest 随构建更新**:`prebuild` 已串联,保留。
|
||||
- **后端已就绪可立即使用的资产**:config-service 全量配置查询;data-ana 的 4 个 `*Dashboard` 聚合查询 + `warnings`/`mastery*`/`diagnosticReports`/`errorBook*`;iam 的 `user/role/dataScope`;core-edu/content 的按 id 单查。**仪表盘首页应优先改用这些真实查询**(P1 任务)。
|
||||
|
||||
#### V3-A5:设计系统单源决议
|
||||
|
||||
- **背景**:F5(三方冲突 + 271 处断裂类)。
|
||||
- **决策**:
|
||||
1. **shadcn 标准令牌(@edu/ui-tokens,zinc 色板)为唯一设计系统**,全局适用(dashboard + 全部页面 + 登录页)。
|
||||
2. `docs/standards/ui-design-system.md` 废止;其仍有价值的内容并入本文件 §8(布局/组件/动效/a11y 规范,按 shadcn 令牌重写)。
|
||||
3. 纸感编辑器方向(Fraunces 主文 + 米白纸面)**降级为二期备课编辑器模块命名空间**(`--lp-*`),仅 lesson-plan 工作台内部使用;现在不做。
|
||||
4. 31 widget 令牌清债(271 处)列入 P1:机械替换(`text-heading-3`→`text-lg font-semibold`、`mt-sm`→`mt-2`、`py-xs`→`py-1`、`px-sm`→`px-2`、`space-y-xs`→`space-y-1`、`p-md`→`p-4`、`gap-xs`→`gap-1`、`border border`→`border`),arch:scan 增加"未知 Tailwind 类"检测规则。
|
||||
- **理由**:CICD 是已验证的 UX 基线,shadcn 生态(组件、文档、AI 协作默契度)最成熟;纸感全局化在旧 portal 实践中与 shadcn 组件冲突成本高,收敛为局部模块更现实。
|
||||
|
||||
#### V3-A6:next-intl 正式接入
|
||||
|
||||
- **背景**:旧页面用 `useTranslations`,messages 资产完整;portal-shell 现有自造 `useT()` 只有 3 条文案。
|
||||
- **决策**:接入 next-intl(App Router 模式),`messages/zh-CN.json` + `messages/en.json` 从旧 portal 合并迁移;`ThemeI18nProvider` 删除自造 i18n,保留主题切换;新代码文案一律走 `useTranslations`,禁止硬编码中文字符串(ESLint 规则 P1 后启用,迁移期 warn)。
|
||||
- **取舍**:增加一个依赖与少量配置,换取不重写全部文案 + 与 CICD/旧 portal 一致的 i18n 习惯。
|
||||
|
||||
#### V3-A7:MSW 开发兜底层
|
||||
|
||||
- **背景**:F7(无后端 = 空壳)。
|
||||
- **决策**:迁移旧 portal 的 MSW handlers 为 portal-shell 的 `src/mocks/`(按 domain 分文件);`NEXT_PUBLIC_MSW=1` 时 browser worker 拦截 `/api/graphql` 与 `/api/v1/*`;`src/instrumentation.ts`(或 middleware 旁路)在 dev + MSW 开启时给 RSC 侧也注入 mock fetcher。**生产构建通过 env 条件 import 保证 mocks 零字节进 bundle**。
|
||||
- **双重收益**:前端不依赖后端就绪即可开发页面;Playwright E2E 直接复用 MSW 场景数据,测试稳定。
|
||||
|
||||
#### V3-A8:验收门禁改革
|
||||
|
||||
- **背景**:根因 1(验收指标错位)。
|
||||
- **决策**:CI 质量门禁从 4 项扩为 8 项:`lint` + `typecheck` + `vitest` + `build` + **契约校验**(codegen diff)+ **路由表一致性** + **页面计数回归**(pages 数不得下降)+ **E2E 冒烟**(P6 起:登录 → 仪表盘 → 每角色 1 条核心流)。每个阶段的 README/本文件验收勾选必须附**命令输出或截图链接**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 认证与身份链
|
||||
|
||||
### 4.1 目标流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 浏览器
|
||||
participant PS as portal-shell
|
||||
participant GW as api-gateway :8080
|
||||
participant IAM as iam
|
||||
participant RT as apollo-router :3000
|
||||
|
||||
U->>PS: POST /api/auth/login {email, password}
|
||||
PS->>GW: POST /api/v1/iam/auth/login
|
||||
GW->>IAM: 验证凭证
|
||||
IAM-->>GW: JWT (RS256) + user + permissions bitmap
|
||||
GW-->>PS: 200 {token, user}
|
||||
PS-->>U: Set-Cookie: edu_session=<JWT>; httpOnly; Secure; SameSite=Strict
|
||||
Note over U: JS 永远拿不到 token
|
||||
|
||||
U->>PS: GET /shell/teacher/exams
|
||||
PS->>PS: middleware: 校验 cookie → 注入 x-user-* 头 → checkRoutePermission
|
||||
alt 无/坏 token
|
||||
PS-->>U: 302 /login?next=/shell/teacher/exams
|
||||
else 无权限
|
||||
PS-->>U: 302 /shell/forbidden
|
||||
else 放行
|
||||
PS->>PS: RSC 读 x-user-* 头渲染页面
|
||||
end
|
||||
|
||||
U->>PS: POST /api/graphql (Apollo, APQ hash)
|
||||
PS->>PS: 从 cookie 取 token
|
||||
PS->>RT: POST /graphql + Authorization: Bearer <JWT>
|
||||
RT-->>PS: data
|
||||
PS-->>U: data
|
||||
```
|
||||
|
||||
### 4.2 组件清单
|
||||
|
||||
| 组件 | 文件 | 职责 |
|
||||
| ------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| 登录页 | `src/app/login/page.tsx` | 表单 → `/api/auth/login`;已登录跳转 next;迁移自旧 portal,换 shadcn 令牌 |
|
||||
| 登录代理 | `src/app/api/auth/login/route.ts` | 调 gateway iam 登录 → Set-Cookie;失败归一化错误;限流透传 |
|
||||
| 登出 | `src/app/api/auth/logout/route.ts` | 清 cookie + 调 iam 注销 |
|
||||
| 中间件 | `src/middleware.ts` | 公共路径白名单 → cookie 校验 → 注入身份头 → `checkRoutePermission` → 重定向 |
|
||||
| GraphQL 代理 | `src/app/api/graphql/route.ts` | cookie→Bearer 转发;APQ 透传;错误归一化;`cache: "no-store"` |
|
||||
| RSC 身份工具 | `src/lib/auth/server.ts` | `getServerIdentity(): Promise<{userId, role, permissions}>`(读 middleware 注入的头,缺失即抛 → error boundary 跳登录) |
|
||||
| DEV_MODE 守卫 | `src/instrumentation.ts` | `NODE_ENV=production && NEXT_PUBLIC_DEV_MODE=true` → 启动即抛错 |
|
||||
| 403 页 | `src/app/shell/forbidden/page.tsx` | 说明 + 返回仪表盘链接 |
|
||||
|
||||
### 4.3 会话细节决策
|
||||
|
||||
- **cookie 名**:`edu_session`;`Path=/`;`Max-Age` 与 JWT exp 对齐;`Secure` 仅生产(本地 http 开发豁免,用 env 控制)。
|
||||
- **验签**:middleware 用 `jose` 的 `createRemoteJWKSet(iam JWKS endpoint)` 验 RS256;JWKS 端点不可达时 **fail-closed**(跳登录),dev 模式降级为仅 decode(日志警告)。
|
||||
- **权限位图来源**:登录响应中 iam 返回 permissions → login route 计算 base36 位图 → 写入第二个非 httpOnly cookie `edu_perms`(JS 可读,用于客户端按钮级显隐;**仅为 UX,不作为安全依据**——安全判断一律走后端 resolver + middleware 的 httpOnly 通道)。middleware 同样把位图注入 `x-user-permissions` 头。
|
||||
- **角色切换/多角色**:iam 返回单主角色(与现 `x-user-role` 对齐);多角色支持属后端二期,前端不预留。
|
||||
- **旧 portal 的 `lib/auth.ts`(localStorage 方案)**:迁移时**删除**,不按原样搬运。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据层架构
|
||||
|
||||
### 5.1 分层(保留 v2.0 四层,修正两端)
|
||||
|
||||
```
|
||||
页面/插件(UI)
|
||||
→ lib/api/<domain>.ts(语义化 hooks:useExams(classId))
|
||||
→ lib/api/operations/*.graphql.ts(gql 文档,真实 schema 子集)
|
||||
→ lib/useWidgetQuery / useWidgetMutation(Apollo 封装 + ApiError)
|
||||
→ Apollo Client(APQ link)→ /api/graphql(同域代理)
|
||||
→ apollo-router :3000 → 子图
|
||||
```
|
||||
|
||||
- **RSC 服务端**:页面 RSC 用 `createApolloClient()`(已有)经内网直连 router(服务端不经代理,直接带 middleware 解析出的身份头);客户端组件经 `/api/graphql` 代理。
|
||||
- **SWR/轮询**:仪表盘配置保留 SWR;业务数据默认 Apollo `fetchPolicy: "cache-first"`,需要实时性的(通知铃铛)用 `pollInterval`;SSE 通知二期经 realtime-gateway。
|
||||
|
||||
### 5.2 GraphQL 同域代理(/api/graphql)
|
||||
|
||||
- 单文件 Route Handler,`POST` only;透传 body(APQ hash 或 query);注入 `Authorization`;响应状态/JSON 原样返回。
|
||||
- 不缓存(`export const dynamic = "force-dynamic"`)。
|
||||
- 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`,客户端 `ApiError` 统一。
|
||||
- 保留直连开关:`APOLLO_ROUTER_URL`(服务端 RSC 用),客户端永远只用 `/api/graphql`。
|
||||
|
||||
### 5.3 契约纪律(frontend ⇄ backend)
|
||||
|
||||
1. **schema 唯一源**:`src/lib/api/__generated__/combined-schema.graphql` 由 `scripts/normalize-schema.ts` 从 `services/*/src/graphql/generated/schema.graphql` 生成;**后端 SDL 变更后必须重跑 codegen**,CI 做 diff 检查。
|
||||
2. **operations 真实性**:每个 operation 引用字段必须存在于 combined schema;恢复 `typescript-operations` 生成类型;lib/api 的手写 interface 逐步删除(P1 起按域推进:config → data-ana → core-edu → content → msg → iam)。
|
||||
3. **后端未就绪的功能**:页面允许先上,数据走 MSW(`src/mocks/`),并在页面头部注释 `@contract-pending: <工单号>`;契约工单(§11.4)登记后端待补字段,**每周同步一次状态**。
|
||||
4. **禁止事项**:禁止在 widget/页面内联 gql;禁止手写与 schema 冲突的类型;禁止为迁就前端瞎改 normalize 脚本(当前对 ai 子图的 String 改写保留,单独登记 TD)。
|
||||
|
||||
### 5.4 MSW 兜底层
|
||||
|
||||
- 位置:`src/mocks/`(`browser.ts`/`server.ts`/`handlers/<domain>.ts`),自旧 portal `src/mocks/handlers-p*.ts` 迁移并按 50+ 操作重组。
|
||||
- 启用:`NEXT_PUBLIC_MSW=1`(仅 dev/test);`src/app/layout.tsx` 条件 `import("@/mocks/browser")`(动态 import,production env 下该 import 永不执行 → tree-shaken)。
|
||||
- 数据原则:使用与种子用户一致的语义数据(teacher2@edu.test 的班级/学生/成绩),数据量足够展示分页/空态/超长文本三种边界。
|
||||
- E2E 复用:Playwright webServer 以 `NEXT_PUBLIC_MSW=1` 启动,用例不依赖真实后端。
|
||||
|
||||
### 5.5 后端已就绪查询的立即利用(P1 必做)
|
||||
|
||||
| 真实查询(schema 存在且 resolver 已实现) | 用于 | 注意 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `pluginConfig(userId, role)` / `plugins` / `plugin(pluginId)` / `userLayoutOverride(userId)` | 仪表盘配置(已接,保留) | ✅ 全字段吻合 |
|
||||
| `layoutTemplates` | admin 模板列表 | ⚠️ 仅 `layoutId/displayName/description/isActive`,**无 `availableSlots`**——前端选择集必须裁剪 |
|
||||
| `teacherDashboard` / `studentDashboard` / `parentDashboard` / `adminDashboard` | **角色首页仪表盘主数据源**(替换现有卡片的假契约查询) | ✅,底层字段 snake_case 需在 lib/api 层映射 |
|
||||
| `warnings` / `masterySummary` / `studentMastery` / `masteryDistribution` | 学情预警卡片 | ✅,同上 |
|
||||
| `diagnosticReports` / `errorBookItems` / `errorBookStats` | 诊断/错题卡片与页面 | ✅,字段形状与现 widget 期望不同,需重写 hook |
|
||||
| `user(userId)` / `role(roleId)` / `dataScope(userId)` | 用户菜单、权限显隐 | ⚠️ `User` 字段为 `userId/email/name/status/dataScope`(无 `id`/`role`) |
|
||||
| `exam(id)` / `homework(id)` / `grade(id)` / `classInfo(id)` / `textbook(id)` / `question(id)` / `chapter(id)` / `knowledgePoint(id)` | 详情页首版(列表点击带 id 进入时可真实查询) | ⚠️ 字段名需按真实类型重写(如 `Exam.title/totalScore` 而非 `name/maxScore`) |
|
||||
| `notifications(userId)` | 通知中心首版 | ⚠️ 平铺数组,无分页包装 |
|
||||
| `lessonPlanStatus` | ai 子图唯一查询 | 仅状态轮询可用 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全架构(安全边际)
|
||||
|
||||
### 6.1 威胁模型与防线矩阵
|
||||
|
||||
| 威胁 | 防线 | 位置 | 现状 | v3.0 处置 |
|
||||
| ----------------------------------- | ----------------------------------------------------- | --------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- |
|
||||
| 未认证访问业务页 | middleware cookie 校验 | `src/middleware.ts` | ❌ 不存在 | P0 实现,fail-closed |
|
||||
| 越权访问路由(teacher 进 admin 页) | L1 角色 + L2 权限点 | middleware + route-permissions 表 | ❌ 死代码 | P0 接线 + CI 一致性 |
|
||||
| 越权访问数据 | resolver `@RequirePermission` + DataScope | 后端子图 | ✅(审计过 50 resolver) | 保持;前端不兜底也不替代 |
|
||||
| 凭证被 XSS 窃取 | httpOnly cookie,JS 零接触 | `/api/auth/login` | ❌ localStorage 方案 | P0 替换 |
|
||||
| GraphQL 任意查询探测 | APQ + PQ Manifest 白名单 | Apollo link + router | ✅ 链路完整(`require_manifest` 生产需开) | 保持;部署 env 列入 P6 检查单 |
|
||||
| 深度/成本/批量 DoS | router limits | `router.yaml` | ✅ 已配 | 保持 |
|
||||
| 绕过 router 直调子图 | RouterAuthGuard + `router-authorization` | 子图 | ⚠️ 密钥明文 `dev-router-secret` 入库 | P6 换 env 注入;生产 secret 轮换 |
|
||||
| CSRF | SameSite=Strict cookie + 自定义头要求 | cookie + 代理 | ⚠️ 依赖 cookie 属性 | `/api/graphql` 要求 `content-type: application/json`(预检),登录接口 SameSite=Strict |
|
||||
| 生产 DEV_MODE 误开 | 启动自检 | `instrumentation.ts` | ❌ compose 生产 profile 写 true | P0:compose 改 false + 启动校验抛错 |
|
||||
| 错误上报刷爆 | sessionStorage digest 节流 | `useErrorReport` | ✅ 已实现 | 保持 |
|
||||
| XSS 富文本 | 禁 `dangerouslySetInnerHTML`(grep 实测 src 内 0 处) | ESLint + 规范 | ✅ | 保持规则;富文本编辑器(二期)必须 DOMPurify |
|
||||
| 敏感信息入仓 | `.env.local` 已入库(无密钥) | 仓库 | ⚠️ | P0:移出 + gitignore 补规则 |
|
||||
|
||||
### 6.2 三层安全边界(接线后真实形态)
|
||||
|
||||
| 层 | 机制 | 执行点 | 失败行为 |
|
||||
| ----------- | ------------------------------------------------------------ | -------------------------------------------------- | ------------------------- |
|
||||
| L1 角色门禁 | `requiredRoles` | **middleware**(每个 /shell/** 请求) | 302 → `/shell/forbidden` |
|
||||
| L2 权限点 | `requiredPermissions`/`anyOfPermissions`(AND/OR,位图校验) | **middleware**(路由级)+ config-service(卡片级) | 302 → 403 页 / 卡片不渲染 |
|
||||
| L3 数据范围 | DataScope 6 级 | 后端 resolver(权威);前端仅 UX 显隐 | 后端拒绝 / 空态 |
|
||||
|
||||
**边界纪律(防"安全边际"幻觉)**:
|
||||
|
||||
1. 前端门禁是**体验层**,后端 resolver 是**权威层**;任何"前端已挡"不得成为后端不加 `@RequirePermission` 的理由。
|
||||
2. `edu_perms` 可读 cookie 只用于按钮显隐;**禁止**用它做任何数据请求的放行判断。
|
||||
3. middleware 校验失败一律 fail-closed;`checkRoutePermission` 未匹配的路由**默认拒绝**(当前实现默认放行,P0 改为:/shell/** 下未登记路由默认拒绝,公共路径显式白名单)。
|
||||
4. JWT 过期统一处理:`/api/graphql` 收到 401 → 清 cookie → 返回 `{ errors: [{extensions:{code:"UNAUTHENTICATED"}}] }`;客户端 Apollo errorLink 拦截 → `location.href = "/login"`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 信息架构与页面体系
|
||||
|
||||
### 7.1 路由树(目标态)
|
||||
|
||||
```
|
||||
/login 登录
|
||||
/shell/forbidden 403
|
||||
/shell 角色仪表盘(按 x-user-role 分发渲染对应仪表盘)
|
||||
/shell/teacher/… 教师功能区(~50 页,见 §9.1)
|
||||
/shell/student/… 学生功能区(~34 页,见 §9.2)
|
||||
/shell/parent/… 家长功能区(~21 页,见 §9.3)
|
||||
/shell/admin/… 管理功能区(~22 页,见 §9.4)
|
||||
/shell/settings 通用设置(全角色)
|
||||
/shell/notifications 通知中心(全角色)
|
||||
```
|
||||
|
||||
- URL 前缀 `/shell/<role>/` 与 route-permissions 表语义对齐(PREFIX 表按前缀批量保护)。
|
||||
- 仪表盘 `/shell` 按角色渲染不同插件集(现有机制),同时是各功能卡的入口枢纽。
|
||||
- 跨角色同构页面(如 notifications/settings)放共享路径,权限表按角色开放。
|
||||
|
||||
### 7.2 AppFrame(页面框架)
|
||||
|
||||
`src/app/shell/layout.tsx`(新):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ TopBar:global-search · notification-bell │
|
||||
│ locale-switcher · user-menu │
|
||||
├──────────┬───────────────────────────────────┤
|
||||
│ Sidebar │ Main({children}) │
|
||||
│ 导航菜单 │ 页面区(显式路由页面) │
|
||||
│ (按角色/ │ 或仪表盘(/shell) │
|
||||
│ 权限过滤)│ │
|
||||
│ + 上下文 │ │
|
||||
│ 选择器 │ │
|
||||
└──────────┴───────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **TopBar**:复用现有 4 个 topbar 插件(它们本就是全站级组件),不再走插件配置(改为静态挂载 + 权限显隐),仪表盘配置不再能"关掉导航"导致页面失联。
|
||||
- **Sidebar 导航菜单(新组件)**:导航项 = 静态注册表 `src/shared/lib/navigation.ts`(label/i18n key/icon/href/requiredRoles/requiredPermissions),渲染前用 `batchCheckRoutePermission` 过滤(对应 004 §5.4 视口 L1);当前路由高亮;收纳现有 4 个 sidebar 插件(class-selector/child-selector/term-switcher/quick-actions)于菜单下方"上下文区"。
|
||||
- **Main**:仪表盘路由渲染微内核仪表盘;业务路由渲染页面。页面内可用 `DashboardSection`/`SectionErrorBoundary` 组织区块。
|
||||
- **RSC 流式**:layout 保持轻量(导航静态),页面各自 RSC 预取 + Suspense;现有 ClientShell 流式机制迁移为页面级工具(`src/lib/streaming.tsx` 导出 `StreamedPage` 封装,避免每页重写 Provider/Suspense 样板)。
|
||||
|
||||
### 7.3 页面四种类型与模板(新页面必须套模板)
|
||||
|
||||
| 类型 | 适用 | 结构骨架 | 示例 |
|
||||
| ------------ | ---------------------- | ------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| **列表页** | 集合浏览 + 筛选 + 分页 | PageHeader + FilterBar + DataTable/列表 + Pagination + 空态/骨架/错误三态 | exams、homework、users |
|
||||
| **详情页** | 单实体查看 + 关联操作 | PageHeader(标题+操作) + 信息区 + Tabs/分区 + 关联列表 | exams/[id]、textbooks/[id] |
|
||||
| **表单页** | 创建/编辑 | PageHeader + 表单(react-hook-form + zod)+ 提交/取消 + 错误摘要 | exams/new、lesson-plans/new |
|
||||
| **工作台页** | 多栏协同复杂任务 | 三栏(树/画布/属性)复合组件(Workbench 模式) | lesson-plans/[id]/edit、exams/[id]/build |
|
||||
|
||||
模板代码位置:`src/shared/components/page-templates/{list-page,detail-page,form-page,workbench-page}.tsx`(P1 建)。**新页面一律 `import` 模板而非手排布局**——保证 140 页视觉/交互一致,也是 AI 批量迁移时的统一落点。
|
||||
|
||||
### 7.4 页面级数据获取模式(统一)
|
||||
|
||||
```tsx
|
||||
// src/app/shell/teacher/exams/page.tsx(RSC 示例骨架)
|
||||
export default async function ExamsPage(): Promise<React.ReactElement> {
|
||||
const { userId, role } = await getServerIdentity();
|
||||
const client = createApolloClient();
|
||||
const dataPromise = client.query({
|
||||
query: GET_EXAMS_DOC,
|
||||
variables: {/* … */},
|
||||
});
|
||||
return (
|
||||
<ListPageShell titleKey="exams.title">
|
||||
{/* Promise 直传客户端组件,use() 消费(沿用现有流式模式) */}
|
||||
<ExamsList dataPromise={dataPromise} />
|
||||
</ListPageShell>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- 列表/详情页:RSC 预取首屏 → `use()` 流式注入 → 客户端交互(筛选/翻页)走 Apollo hooks。
|
||||
- 表单页:客户端组件 + `useWidgetMutation`;成功后 `router.refresh()`。
|
||||
- 每页必须实现三态:`loading.tsx`(骨架,复用 PluginSkeleton 变体)/ `error.tsx`(Route 级边界,已有模式)/ 空态(`empty-state.tsx`)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端设计规范(强制)
|
||||
|
||||
> 本章是 portal-shell 唯一的视觉/交互权威,废止 `docs/standards/ui-design-system.md`。所有新页面/插件/组件必须遵守;迁移页面时按本章改造。违反本节任意"铁律"的 PR 不得合入。
|
||||
|
||||
### 8.1 设计令牌(三层,唯一来源)
|
||||
|
||||
| 层 | 位置 | 业务可用性 |
|
||||
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| L1 Primitive | `packages/ui-tokens/src/primitive.css` | ❌ 禁止直接引用 |
|
||||
| L2 Semantic | `packages/ui-tokens/src/semantic-{light,dark}.css` | ✅ 经 `hsl(var(--*))` |
|
||||
| L3 Tailwind | `packages/ui-tokens/src/tailwind-theme.css`(`@theme inline`) | ✅ 首选:`bg-background`/`text-foreground`/`bg-card`/`border` 等 |
|
||||
|
||||
**铁律(ESLint + code review 双重执行)**:
|
||||
|
||||
1. 禁 `#hex` 字面量(现有规则保留)。
|
||||
2. 禁字体名字面量(现有规则保留)。
|
||||
3. 禁 `font-size: Npx`;字号用 Tailwind 阶梯(`text-xs/sm/base/lg/xl/2xl`)或 `text-size-*`。
|
||||
4. 禁 Tailwind 任意值(`w-[137px]`);间距用默认阶梯(`p-2/p-4/p-6/gap-2/gap-4`)或 `--space-*`。
|
||||
5. **禁引入第二套令牌/第二套 CSS 变量体系**(纸感 `--paper-*`/`--ink-*` 一律不得回潮;迁移旧页面时必须清除)。
|
||||
6. 暗色主题:仅经 `.dark` class + 语义令牌响应;禁组件内写死亮色值。
|
||||
7. 圆角:`rounded-xl`(卡)/`rounded-md`(控件)/`rounded-full`(头像徽章);阴影克制:浮层 `shadow-sm`,卡片默认无阴影。
|
||||
|
||||
### 8.2 字体与排版
|
||||
|
||||
- **单一字体族 Inter**(`next/font` 已挂载 `--font-inter` → `font-sans`);数字密集表格列用 `font-mono`(Tailwind 默认等宽)右对齐。
|
||||
- 标题层级:页面标题 `text-2xl font-semibold`;区块标题 `text-lg font-semibold`;卡片标题 `text-base font-medium`;正文 `text-sm`;辅助 `text-xs text-muted-foreground`。
|
||||
- 行高:正文 `leading-relaxed`;标题 `leading-tight`。
|
||||
- 中文界面默认 `lang="zh-CN"`(已配置);i18n 文案经 next-intl。
|
||||
|
||||
### 8.3 组件使用
|
||||
|
||||
- **基础组件一律来自** `src/shared/components/ui/*`(shadcn 标准件)与 `packages/ui-components/*`(data-table/form/modal/chart/calendar 等 19 件);缺组件先扩库再使用,**禁止页面内手写第三套按钮/输入框**。
|
||||
- 业务复合件:`page-templates` 四件套(§7.3)+ `page-header`/`stat-card`/`stats-grid`/`empty-state`/`filter-bar`(已有)。
|
||||
- Toast 一律 `notify.*`(`src/shared/lib/notify.ts`);**禁** `import { toast } from "sonner"`(P0 补 ESLint `no-restricted-imports` 真正落地);**禁空 catch**(现有 `catch { /* toast */ }` 反例必须改)。
|
||||
- 图标:`lucide-react` 单一来源;图标按钮必须 `aria-label`。
|
||||
- 表格:数据列数字 `font-mono` 右对齐;行分隔 `divide-y divide-border`;禁大面积色块。
|
||||
|
||||
### 8.4 布局与间距
|
||||
|
||||
- 页面容器:`px-6 py-6`(桌面),最大宽度不设限(工作台型产品),列表页可 `max-w-7xl`。
|
||||
- 区块间距 `space-y-6`;卡片内边距 `p-4` 或 `p-6`。
|
||||
- 三栏工作台仅在"编辑类"页面使用(备课/组卷),比例 260px / 1fr / 380px(Workbench 模板内置)。
|
||||
|
||||
### 8.5 动效
|
||||
|
||||
- 交互反馈 ≤ 200ms;页面过渡 ≤ 300ms;遵守 `prefers-reduced-motion`(globals.css 已有)。
|
||||
- 加载一律骨架屏(PluginSkeleton 五变体 / 页面 loading.tsx),**禁居中 spinner 长转**。
|
||||
- 禁装饰性持续动画;hover 反馈用 `bg-muted`/`bg-accent`,禁 `scale-105`。
|
||||
|
||||
### 8.6 可访问性(WCAG AA)
|
||||
|
||||
- 对比度:正文 ≥ 4.5:1(zinc 令牌天然满足,禁自调浅色)。
|
||||
- 所有交互元素可 Tab 聚焦;`focus-visible:ring-2 focus-visible:ring-ring`。
|
||||
- 语义化标签(`nav/main/aside/table`);表单控件必须 `<label>` 或 `aria-label`;动态更新区 `aria-live="polite"`(骨架组件已带,页面复用即可)。
|
||||
- 右键/下拉菜单支持键盘(Radix 组件默认满足,勿破坏)。
|
||||
|
||||
### 8.7 文案与 i18n
|
||||
|
||||
- 一律 `useTranslations`;命名空间按页面域(`exams.*`/`homework.*`/`common.*`)。
|
||||
- 占位/空态文案必须给出"下一步行动"(如"暂无考试 → 新建考试"按钮),禁裸"暂无数据"。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面迁移总表(~140 页)
|
||||
|
||||
> 功能全景基准:CICD 原版 **155 页**(Next.js 16 + Server Actions + Drizzle,无 GraphQL;auth 4 + onboarding 1 + dashboard 公共 8 + admin 41 + teacher 54 + student 25 + parent 14 + management/grade 5)。
|
||||
> 迁移源:仓库内 4 个旧 portal(`apps/*-portal/`,共 140 个 page.tsx),其页面实现完整但栈为 urql + next-intl + MSW + 纸感令牌。
|
||||
> 缺口说明:旧 portal 未覆盖 CICD 的 management/grade 年级组维度(5 页)与 register/privacy/terms/onboarding(4+ 页)——这两块列为**新增(N)**,排在对应批次末尾。
|
||||
> 迁移动作统一定义:**M = 迁移改造**(移植交互逻辑 + 换 Apollo 数据层 + 换 shadcn 令牌 + 套页面模板);**N = 新建**(旧版没有);**R = 替换**(现有 widget 升级为页面)。
|
||||
> 批次即 §10 路线图的阶段:B1=先行骨架(P1)、B2=教师(P2)、B3=学生(P3)、B4=家长(P4)、B5=管理(P5)。
|
||||
> 契约列:✅=schema 已有(注意字段形状差异,见 §5.5);🟡=部分(单查有/列表无);❌=schema 无,需契约工单 + MSW 先行。
|
||||
|
||||
### 9.1 教师域(teacher-portal 56 页 → /shell/teacher/*,B1+B2)
|
||||
|
||||
| 源路由(teacher-portal) | 目标路由 | 动作 | 契约 | 批次 |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------- | ---------------------------------- | ----- |
|
||||
| `login` | `/login` | M(全站唯一登录页) | ✅ | B1 |
|
||||
| `(app)/dashboard` | `/shell`(教师仪表盘) | R(仪表盘改接 `teacherDashboard` 真实查询) | ✅ | B1 |
|
||||
| `(app)/exams` | `/shell/teacher/exams` | M 列表页 | 🟡 | B2 |
|
||||
| `(app)/exams/new` | `/shell/teacher/exams/new` | M 表单页 | ❌ mutation | B2 |
|
||||
| `(app)/exams/[id]` | `/shell/teacher/exams/[id]` | M 详情页 | ✅ `exam(id)` | B2 |
|
||||
| `(app)/exams/[id]/build` | `/shell/teacher/exams/[id]/build` | M 工作台页 | ❌ | B2 |
|
||||
| `(app)/exams/[id]/edit-rich` | `/shell/teacher/exams/[id]/edit` | M 工作台页 | ❌ | B2 |
|
||||
| `(app)/exams/[id]/analytics` | `/shell/teacher/exams/[id]/analytics` | M 详情页(图表) | 🟡 `assignmentAnalysis` | B2 |
|
||||
| `(app)/exams/[id]/proctoring` | `/shell/teacher/exams/[id]/proctoring` | M(二期,WS) | ❌ | B2 末 |
|
||||
| `(app)/homework`、`/new`、`/[id]`、`/submissions`(共 7 页) | `/shell/teacher/homework/*`(同构 7 页) | M | 🟡/❌ | B2 |
|
||||
| `(app)/grades`、`/entry`、`/analytics`、`/stats`、`/report-card`(5 页) | `/shell/teacher/grades/*`(5 页) | M | 🟡/❌ | B2 |
|
||||
| `(app)/lesson-plans`、`/new`、`/library`、`/calendar`、`/heatmap`、`/[planId]/edit`(6 页) | `/shell/teacher/lesson-plans/*`(6 页) | M(edit 为工作台页) | ❌ | B2 |
|
||||
| `(app)/questions` | `/shell/teacher/questions` | M 列表页 | 🟡 `question(id)` | B2 |
|
||||
| `(app)/textbooks`、`/[id]`(2 页) | `/shell/teacher/textbooks/*`(2 页) | M | 🟡 `textbook(id)` | B2 |
|
||||
| `(app)/attendance`、`/sheet`、`/report`、`/stats`(4 页) | `/shell/teacher/attendance/*`(4 页) | M | ❌ | B2 |
|
||||
| `(app)/classes`、`/[id]`、`/schedule`(3 页) | `/shell/teacher/classes/*`(3 页) | M | 🟡 `classInfo(id)` | B2 |
|
||||
| `(app)/students` | `/shell/teacher/students` | M | ❌ | B2 |
|
||||
| `(app)/course-plans`、`/[id]`(2 页) | `/shell/teacher/course-plans/*`(2 页) | M | ❌ | B2 |
|
||||
| `(app)/elective`、`/create`、`/[id]/edit`(3 页) | `/shell/teacher/elective/*`(3 页) | M | ❌ | B2 |
|
||||
| `(app)/error-book` | `/shell/teacher/error-book` | M | ✅ `errorBookItems/Stats` | B2 |
|
||||
| `(app)/diagnostic`、`/class/[classId]`(2 页) | `/shell/teacher/diagnostic/*`(2 页) | M | ✅ `diagnosticReports` | B2 |
|
||||
| `(app)/analytics`、`/[studentId]`(2 页) | `/shell/teacher/analytics/*`(2 页) | M | 🟡 data-ana 多查询 | B2 |
|
||||
| `(app)/ai-assist`、`/ai-lesson-plan`、`/ai-report`(3 页) | `/shell/teacher/ai/*`(3 页) | M | ❌(ai 子图仅 `lessonPlanStatus`) | B2 末 |
|
||||
| `(app)/knowledge-graph` | `/shell/teacher/knowledge-graph` | M | 🟡 `knowledgePoint(id)` | B2 末 |
|
||||
| `(app)/practice` | `/shell/teacher/practice` | M | ❌ | B2 |
|
||||
| `(app)/schedule-changes` | `/shell/teacher/schedule-changes` | M | ❌ | B2 |
|
||||
| `(app)/leave` | `/shell/teacher/leave` | M | ❌ | B2 |
|
||||
| `(app)/notifications` | `/shell/notifications`(共享) | M | ✅ `notifications(userId)` | B1 |
|
||||
| `(app)/settings` | `/shell/settings`(共享) | M | ✅ | B1 |
|
||||
|
||||
### 9.2 学生域(student-portal 36 页 → /shell/student/*,B3)
|
||||
|
||||
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||
| ----------------------------------------------- | ------------------------------------------- | ---------------------- | ------------------------------------------------------- | ----- |
|
||||
| `dashboard`、`/trend`、`/weakness` | `/shell`(学生仪表盘)+ 2 详情页 | R + M | ✅ `studentDashboard`/`learningTrend`/`studentWeakness` | B3 |
|
||||
| `my-grades`、`/report-card` | `/shell/student/grades/*`(2 页) | M | ❌(列表) | B3 |
|
||||
| `my-exams`、`/[id]/result`、`/[id]/take` | `/shell/student/exams/*`(3 页) | M(take 为作答工作台) | 🟡/❌ | B3 |
|
||||
| `my-homework`、`/[id]/submit`、`/[id]/analysis` | `/shell/student/homework/*`(3 页) | M | 🟡/❌ | B3 |
|
||||
| `schedule` | `/shell/student/schedule` | M | ❌ | B3 |
|
||||
| `my-attendance` | `/shell/student/attendance` | M | ❌ | B3 |
|
||||
| `my-classes` | `/shell/student/classes` | M | ❌ `myClasses` | B3 |
|
||||
| `courses`、`/[id]` | `/shell/student/courses/*`(2 页) | M | ❌ | B3 |
|
||||
| `course-plans`、`/[id]` | `/shell/student/course-plans/*`(2 页) | M | ❌ | B3 |
|
||||
| `lesson-plans`、`/[id]/view` | `/shell/student/lesson-plans/*`(2 页) | M | ❌ | B3 |
|
||||
| `textbooks`、`/[id]/chapters` | `/shell/student/textbooks/*`(2 页) | M | 🟡 | B3 |
|
||||
| `error-book` | `/shell/student/error-book` | M | ✅ | B3 |
|
||||
| `learning`、`learning-path` | `/shell/student/learning`、`/learning-path` | M | ❌ | B3 |
|
||||
| `practice`、`/[sessionId]` | `/shell/student/practice/*`(2 页) | M | ❌ | B3 |
|
||||
| `elective`、`/[id]` | `/shell/student/elective/*`(2 页) | M | ❌ | B3 |
|
||||
| `ai-tutor` | `/shell/student/ai-tutor` | M | ❌ | B3 末 |
|
||||
| `announcements`、`/[id]` | `/shell/announcements/*`(共享 2 页) | M | ❌(列表) | B3 |
|
||||
| `messages` | `/shell/messages` | M | ❌ | B3 末 |
|
||||
| `notifications`、`settings` | 共享路由 | M | ✅ | B1/B3 |
|
||||
| `leave` | `/shell/student/leave` | M | ❌ | B3 |
|
||||
|
||||
### 9.3 家长域(parent-portal 24 页 → /shell/parent/*,B4)
|
||||
|
||||
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||
| --------------------------------------------------- | -------------------------------------- | ----- | -------------------- | ---- |
|
||||
| `parent/dashboard`、`/trend`、`/weakness` | `/shell`(家长仪表盘)+ 2 页 | R + M | ✅ `parentDashboard` | B4 |
|
||||
| `parent/children/[studentId]` | `/shell/parent/children/[studentId]` | M | ❌ `myChildren` | B4 |
|
||||
| `parent/grades`、`/report-card` | `/shell/parent/grades/*`(2 页) | M | ❌ | B4 |
|
||||
| `parent/exams`、`/[id]/result` | `/shell/parent/exams/*`(2 页) | M | 🟡/❌ | B4 |
|
||||
| `parent/homework` | `/shell/parent/homework` | M | ❌ | B4 |
|
||||
| `parent/attendance` | `/shell/parent/attendance` | M | ❌ | B4 |
|
||||
| `parent/classes` | `/shell/parent/classes` | M | ❌ | B4 |
|
||||
| `parent/course-plans`、`/[id]` | `/shell/parent/course-plans/*`(2 页) | M | ❌ | B4 |
|
||||
| `parent/lesson-plans`、`/[planId]/view` | `/shell/parent/lesson-plans/*`(2 页) | M | ❌ | B4 |
|
||||
| `parent/error-book` | `/shell/parent/error-book` | M | ✅ | B4 |
|
||||
| `parent/diagnostic` | `/shell/parent/diagnostic` | M | ✅ | B4 |
|
||||
| `parent/learning-path` | `/shell/parent/learning-path` | M | ❌ | B4 |
|
||||
| `parent/practice` | `/shell/parent/practice` | M | ❌ | B4 |
|
||||
| `parent/elective` | `/shell/parent/elective` | M | ❌ | B4 |
|
||||
| `parent/leave` | `/shell/parent/leave` | M | ❌ | B4 |
|
||||
| `parent/notifications`、`/settings`、`/preferences` | 共享 + `/shell/parent/preferences` | M | ✅/❌ | B4 |
|
||||
|
||||
### 9.4 管理域(admin-portal 24 页 → /shell/admin/*,B5)
|
||||
|
||||
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||
| ---------------------------------- | -------------------------------------------- | --------------------------------- | ------------------- | ---- |
|
||||
| `admin/dashboard` | `/shell`(管理仪表盘) | R | ✅ `adminDashboard` | B5 |
|
||||
| `admin/users` | `/shell/admin/users` | R(现有 widget 升级整页) | ❌ `users` 列表 | B5 |
|
||||
| `admin/roles`、`admin/permissions` | `/shell/admin/roles`、`/permissions`(2 页) | R/M | ❌ | B5 |
|
||||
| `admin/audit-logs` + 3 子页 | `/shell/admin/audit-logs/*`(4 页) | M | ❌ | B5 |
|
||||
| `admin/invitation-codes` | `/shell/admin/invitation-codes` | R | ❌ | B5 |
|
||||
| `admin/school` + 5 子页 | `/shell/admin/school/*`(6 页) | M | ❌ | B5 |
|
||||
| `admin/classes` | `/shell/admin/classes` | M | ❌ | B5 |
|
||||
| `admin/students`、`admin/teachers` | `/shell/admin/students`、`/teachers`(2 页) | M | ❌ | B5 |
|
||||
| `admin/organization` | `/shell/admin/organization` | M | ❌ | B5 |
|
||||
| `admin/announcements` | `/shell/admin/announcements` | M | ❌ | B5 |
|
||||
| `admin/files` | `/shell/admin/files` | M | ❌ | B5 |
|
||||
| `admin/ai-settings` | `/shell/admin/ai-settings` | M | ❌ | B5 |
|
||||
| `admin/system` | `/shell/admin/system` | M | ❌ | B5 |
|
||||
| `admin/viewports` | `/shell/admin/viewports` | M(对齐 004 §5.4 视口配置) | ❌ | B5 |
|
||||
| —(新)插件管理 | `/shell/admin/plugins` | R(现有 plugin-manager 升级整页) | ✅ config-service | B5 |
|
||||
|
||||
### 9.5 计数与节奏
|
||||
|
||||
| 域 | 页面数 | 契约已就绪(✅/🟡) | 批次 |
|
||||
| -------------------------------------------------------------------------------------------------- | -------- | ------------------- | ------------- |
|
||||
| 共享(login/notifications/settings/announcements/messages/forbidden) | ~7 | 4 | B1/B3 |
|
||||
| 教师 | ~50 | ~14 | B1+B2 |
|
||||
| 学生 | ~34 | ~8 | B3 |
|
||||
| 家长 | ~21 | ~5 | B4 |
|
||||
| 管理 | ~22 | ~3 | B5 |
|
||||
| 缺口新增(management/grade 5 页 + register/onboarding/privacy/terms ~5 页,CICD 有而旧 portal 无) | ~10 | 0 | B2/B5 末(N) |
|
||||
| **合计** | **~144** | **~34** | — |
|
||||
|
||||
**节奏原则**:契约就绪页先行;❌ 页用 MSW 先上 UI,契约工单跟踪后端补齐后切换真实查询(切换 = 改 hook 的 fetcher 指向,页面不动)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施路线图(P0–P6)
|
||||
|
||||
> 每阶段列出:目标 / 范围 / 验收标准(可复现命令 + 运行态证据)。阶段内任务按 §11 规范拆给多 AI 并行。**未完成上一阶段退出标准不得进入下一阶段。**
|
||||
|
||||
### P0 · 地基止血(1 周)
|
||||
|
||||
**目标**:消除"空壳即不可用 + 认证裸奔"两大致命伤。
|
||||
|
||||
| # | 任务 | 验收 |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| P0-1 | `/login` 页 + `/api/auth/login | logout`+`edu_session` httpOnly cookie | 本地起 iam/gateway,错误密码报错、正确密码进 /shell;cookie 在 DevTools Application 可见且 httpOnly |
|
||||
| P0-2 | `src/middleware.ts`:cookie 校验 + 身份头注入 + `checkRoutePermission` 接线 + `/shell/forbidden` | 无 cookie 访问 /shell/** → 302 /login;student 访问 /shell/admin/users → 302 /shell/forbidden(截图) |
|
||||
| P0-3 | `/api/graphql` 代理 + Apollo Client 指向同域 + 删除 localStorage token 方案 | DevTools Network 面板无 `localhost:3000` 直连;`grep -r "localStorage" src/providers` 无 token 读取 |
|
||||
| P0-4 | 配置兜底改进:`getDefaultConfig()` 返回**内置默认仪表盘插件集**(按角色静态定义),config-service 不可用时仪表盘仍有内容 | 停掉 config-service 与 router,启动前端,/shell 显示默认卡片而非"暂无可见插件"(截图) |
|
||||
| P0-5 | DEV_MODE 生产守卫:`instrumentation.ts` 启动校验 + `docker-compose.yml` 生产 profile 改 `NEXT_PUBLIC_DEV_MODE: "false"` | `NODE_ENV=production NEXT_PUBLIC_DEV_MODE=true pnpm start` 启动即报错退出 |
|
||||
| P0-6 | 权限位图修正:`GRADE_READ` 去重(保留索引位,二见位改 `_RESERVED_`)+ 单测更新 | `vitest run permission-bitmap` 通过;`validateRoutePermissionConfigs()` 返回空 |
|
||||
| P0-7 | ESLint 补齐:`no-restricted-imports`(禁 widget 跨目录 import、禁直 import sonner、禁页面绕过 lib/api 直 import @apollo/client) | `pnpm lint` 对预埋反例报错(附输出) |
|
||||
| P0-8 | 仓库卫生:`.env.local` 移出版本控制 + `.gitignore` 补 `tsconfig.tsbuildinfo` | `git status` 干净 |
|
||||
|
||||
### P1 · 框架与数据源接通(1–2 周)
|
||||
|
||||
**目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。
|
||||
|
||||
| # | 任务 | 验收 |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) |
|
||||
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈,4 角色仪表盘显示真实聚合数据(截图);widget 假契约查询(grades/homeworks/schedule/attendance/exams/announcements)全部下线或改造 |
|
||||
| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) |
|
||||
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 |
|
||||
| P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks |
|
||||
| P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 |
|
||||
| P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 |
|
||||
| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) |
|
||||
|
||||
### P2 · 教师域页面(2–3 周,可与 P3 部分并行)
|
||||
|
||||
- 范围:§9.1 全表(~50 页)。顺序建议:exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。
|
||||
- 每模块退出:列表/详情/表单页齐 + 三态 + MSW 场景 + 单测(数据变换函数)+ 页面计数更新。
|
||||
- 契约工单:exams CRUD mutation、homework CRUD、grades 录入/列表、lessonPlans CRUD、attendance、classes 列表等,随模块开工即登记。
|
||||
|
||||
### P3 · 学生域页面(1.5–2 周)
|
||||
|
||||
- 范围:§9.2 全表(~34 页)。先行:dashboard 详情(trend/weakness 已 ✅)→ error-book(✅)→ grades/exams/homework 只读 → exams/take 作答台 → practice → ai-tutor。
|
||||
|
||||
### P4 · 家长域页面(1–1.5 周)
|
||||
|
||||
- 范围:§9.3 全表(~21 页)。多为只读视图,复用学生域组件;重点:`myChildren` 契约 + child-overview 聚合。
|
||||
|
||||
### P5 · 管理域页面(1.5–2 周)
|
||||
|
||||
- 范围:§9.4 全表(~22 页)。先行:users(❌ 列表契约优先推)→ roles/permissions → audit-logs → school 体系 → invitation-codes → plugins(✅)→ viewports(对齐 004 §5.4)。
|
||||
- 收尾:旧 4 portal 目录归档(`apps/_archive/`)或删除(决策点:待全量验收后执行,单独 PR)。
|
||||
|
||||
### P6 · 硬化与验收(1–2 周)
|
||||
|
||||
- Playwright E2E:登录 → 各角色核心流 1 条(教师建考试/学生交作业/家长看成绩/管理员建用户)+ 三级错误边界 + 门禁越权。
|
||||
- 视觉回归:5 布局 + 每域代表页截图基线。
|
||||
- 性能:首屏 JS ≤ 300KB(gzip)、LCP < 2s(本地 Docker 压测,附报告)。
|
||||
- 生产检查单:`APOLLO_REQUIRE_PQ_MANIFEST=true`、`APOLLO_ROUTER_INTROSPECTION=false`、`NEXT_PUBLIC_DEV_MODE=false`、`ROUTER_AUTH_SECRET` env 化、CSP 头(`next.config.js headers()`)、`/api/log` 换生产端点。
|
||||
- 文档:README v3.0 重写(本文件内容沉淀回模块 README + 本文件标记"已并入 README")。
|
||||
|
||||
---
|
||||
|
||||
## 11. 后续 AI 工作规范(强制)
|
||||
|
||||
> 本章是给**所有后续在本模块工作的 AI/人**的操作规程。多 AI 协作同时遵守 project_rules §14(模块单一负责制:本模块只改 `apps/portal-shell/`,跨模块变更按 §14.4 顺序)。
|
||||
|
||||
### 11.1 开工前必读(按序)
|
||||
|
||||
1. 本文件(v3.0 总纲)
|
||||
2. `project_rules.md` §3.4/§3.10/§4/§14
|
||||
3. 所接任务的页面源(旧 portal 对应 page.tsx)与目标模板(§7.3)
|
||||
|
||||
### 11.2 目录与文件约定
|
||||
|
||||
```
|
||||
src/
|
||||
├─ app/
|
||||
│ ├─ login/page.tsx # 登录
|
||||
│ ├─ shell/
|
||||
│ │ ├─ layout.tsx # AppFrame(唯一框架)
|
||||
│ │ ├─ forbidden/page.tsx # 403
|
||||
│ │ ├─ page.tsx # 角色仪表盘(catch-all 保留于仪表盘内部可选)
|
||||
│ │ ├─ <role>/<module>/page.tsx # 业务页面(显式路由)
|
||||
│ │ └─ <role>/<module>/{loading,error}.tsx
|
||||
│ └─ api/{graphql,auth/login,auth/logout,health,ready,log}/route.ts
|
||||
├─ middleware.ts # 认证 + 门禁(唯一入口)
|
||||
├─ features/<domain>/ # 页面私有实现(组件/hooks/类型),按域组织
|
||||
│ └─ exams/{exams-list.tsx, exam-detail.tsx, use-exam-filters.ts}
|
||||
├─ widgets/ # 仅仪表盘卡片(存量,冻结新增——新功能建页面不建插件)
|
||||
├─ shell/ # 微内核(存量,冻结)
|
||||
├─ lib/{api,apollo-client,auth} # 数据层/认证工具
|
||||
├─ mocks/ # MSW(dev/test only)
|
||||
├─ shared/{components,lib} # 共享 UI 与工具
|
||||
└─ messages/{zh-CN,en}.json # i18n
|
||||
```
|
||||
|
||||
- **页面文件瘦身**:`page.tsx` 只做 RSC 预取 + 组装,实现代码放 `features/<domain>/`;单文件 ≤ 300 行(超出即拆分)。
|
||||
- **features 域清单**:exams / homework / grades / lesson-plans / questions / textbooks / attendance / classes / students / course-plans / elective / error-book / diagnostic / analytics / practice / schedule-changes / leave / ai / notifications / settings / users / rbac / audit / school / invitations / plugins / organization / files / system / viewports / children / messages / announcements。新增域先在本文件 §9 登记。
|
||||
|
||||
### 11.3 每页硬性清单(DoD)
|
||||
|
||||
- [ ] 显式路由 + `route-permissions.ts` 已登记(CI 一致性通过)
|
||||
- [ ] 套页面模板(§7.3),未手排布局
|
||||
- [ ] `loading.tsx` + `error.tsx` + 空态三态齐全
|
||||
- [ ] 数据走 `lib/api` hooks;无内联 gql;无手写与 schema 冲突类型
|
||||
- [ ] schema 未就绪的查询走 MSW + 文件头 `@contract-pending: <工单>` 注释 + §11.4 登记
|
||||
- [ ] 文案走 `useTranslations`;无硬编码中文(迁移期 lint warn,P2 末转 error)
|
||||
- [ ] 无禁用类/字面量(§8.1 铁律);`pnpm lint` + `lint:tokens` 通过
|
||||
- [ ] 交互错误有 `notify.error`;无空 catch
|
||||
- [ ] 数据变换/权限判断等纯函数有 vitest 单测
|
||||
- [ ] i18n 两份 messages 同步更新
|
||||
- [ ] 提交信息:`feat(portal-shell): <module> <page> migration`(Conventional Commits)
|
||||
|
||||
### 11.4 契约工单流程(前端缺 schema 字段时)
|
||||
|
||||
1. 在 `docs/architecture/issues/contracts/<service>_contract.md` 追加需求(字段名/类型/权限点/使用页面)。
|
||||
2. 本文件 §9 对应行"契约"列标注工单号。
|
||||
3. 页面用 MSW 先行;后端落地后:重跑 normalize + codegen → 该域关 `skipDocumentsValidation` → 切换 fetcher → 删 mock → 工单关闭。
|
||||
4. **禁止**为绕过校验把查询写得与 schema 不符后开启 skip。
|
||||
|
||||
### 11.5 多 AI 并行分工建议
|
||||
|
||||
| AI 角色 | 负责 | 边界 |
|
||||
| -------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| 框架 AI | P0/P1(middleware/代理/AppFrame/模板/MSW/i18n) | 独占 `src/{app/shell/layout.tsx,middleware.ts,shared/,lib/,mocks/}` |
|
||||
| 教师域 AI | §9.1 | 独占 `src/app/shell/teacher/**` + `src/features/{exams,homework,grades,lesson-plans,...}` |
|
||||
| 学生域 AI | §9.2 | 独占 `src/app/shell/student/**` + 对应 features |
|
||||
| 家长域 AI | §9.3 | 独占 `src/app/shell/parent/**` + 对应 features |
|
||||
| 管理域 AI | §9.4 | 独占 `src/app/shell/admin/**` + 对应 features |
|
||||
| 公共区(login/notifications/settings) | 框架 AI | 防止撞车 |
|
||||
|
||||
- 跨域共享组件必须先提"共享申请"(改 `src/shared/` 或 `packages/ui-components/`),由框架 AI 评审合入;**禁止**各自复制粘贴。
|
||||
- 分支:每域一个 feature 分支(`feat/portal-shell-<domain>`),按 project_rules §14.3。
|
||||
|
||||
### 11.6 文档与验收纪律
|
||||
|
||||
- 每个 PR 更新:本文件 §9 对应行状态 +(如涉及)README 对应章节。
|
||||
- 验收勾选必须附**可复现命令输出**(typecheck/lint/test/build/截图),禁止"已完成"裸声明——本文件 §1.1 的对照表即为反面教材。
|
||||
- 每阶段结束运行 `pnpm run arch:scan` 更新 arch.db,并同步 004 相关章节。
|
||||
|
||||
### 11.7 禁止事项(红线)
|
||||
|
||||
1. 禁止新增 widget 插件实现业务功能(冻结 `src/widgets/` 新增;新功能 = 新页面)。
|
||||
2. 禁止恢复 localStorage 存 token;禁止 JS 读取 `edu_session`。
|
||||
3. 禁止在页面/widget 内联 gql 或绕过 `lib/api` 直接 `useQuery`。
|
||||
4. 禁止引入第二套设计令牌 / 第二套组件库 / 第二套 toast。
|
||||
5. 禁止"默认放行"式的权限代码(未匹配路由必须拒绝)。
|
||||
6. 禁止跳过 `@contract-pending` 登记直接写假查询。
|
||||
7. 禁止删除/弱化现有测试来让门禁变绿。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险登记册
|
||||
|
||||
| # | 风险 | 等级 | 缓解 |
|
||||
| --- | ---------------------------------------------- | ---- | ----------------------------------------------------------------------------------- |
|
||||
| R1 | 后端契约补齐速度成为页面切换瓶颈(~100 页 ❌) | 高 | MSW 先行解耦;契约工单周同步;优先推 mutations 框架(后端 0 mutation 是系统性缺口) |
|
||||
| R2 | 140 页迁移量大,AI 并行产出不一致 | 高 | 模板四件套强制 + 共享组件申请制 + 每模块 DoD 清单 + CI 一致性脚本 |
|
||||
| R3 | 旧页面纸感令牌清理遗漏导致视觉混杂 | 中 | 迁移 DoD 含令牌检查;arch:scan 未知类检测;P6 视觉回归 |
|
||||
| R4 | middleware 验签引入 JWKS 可用性依赖 | 中 | JWKS 缓存(5min)+ 故障 fail-closed + 启动自检 |
|
||||
| R5 | next-intl 与 RSC 流式模式冲突 | 低 | P1 先做技术验证(一个页面跑通再推广) |
|
||||
| R6 | MSW 数据与真实 schema 漂移 | 中 | mock 数据类型从 codegen types 导入;契约切换时删 mock |
|
||||
| R7 | 旧 portal 删除时发现有未迁移边角功能 | 低 | P5 删除前做路由 diff 清单评审;先归档后删除 |
|
||||
| R8 | config-service 单点故障影响仪表盘 | 低 | P0-4 内置默认配置已兜底;页面不依赖插件配置 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 附录
|
||||
|
||||
### 13.1 命令手册
|
||||
|
||||
```bash
|
||||
# 开发(无后端)
|
||||
NEXT_PUBLIC_MSW=1 NEXT_PUBLIC_DEV_MODE=true node node_modules/next/dist/bin/next dev -p 4010
|
||||
|
||||
# 质量门禁(CI 全量)
|
||||
node node_modules/typescript/bin/tsc --noEmit # typecheck
|
||||
node node_modules/.bin/eslint src # lint(或 pnpm lint)
|
||||
node node_modules/vitest/vitest.mjs run # 单测
|
||||
node node_modules/next/dist/bin/next build # 构建
|
||||
node node_modules/.bin/eslint -c .eslintrc.tokens.js src # 令牌专项
|
||||
|
||||
# 契约
|
||||
node_modules/.bin/tsx scripts/normalize-schema.ts && node_modules/.bin/graphql-codegen --config codegen.yml
|
||||
node_modules/.bin/tsx scripts/generate-pq-manifest.ts
|
||||
|
||||
# 一致性校验(P1 起)
|
||||
node scripts/check-route-permissions.mjs # 路由表 ⇄ 文件系统
|
||||
```
|
||||
|
||||
### 13.2 关键文件速查
|
||||
|
||||
| 主题 | 文件 |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 微内核仪表盘 | `src/shell/{Shell,ClientShell,LayoutManager,SlotRenderer,Registry,PropsMerger,PluginLifecycle,PluginStore}.tsx?` |
|
||||
| 数据层 | `src/lib/{apollo-client,useWidgetQuery,useWidgetMutation,config-fetcher,usePluginConfig}.ts` + `src/lib/api/` |
|
||||
| 安全 | `src/shared/lib/route-permissions.ts` + `packages/shared-ts/src/permission-bitmap.ts` +(新)`src/middleware.ts` |
|
||||
| 错误处理 | `src/shared/components/{plugin,route,section}-boundary.tsx` + `packages/hooks/src/use-error-report.ts` |
|
||||
| 设计令牌 | `packages/ui-tokens/src/{primitive,semantic-light,semantic-dark,tailwind-theme}.css` |
|
||||
| 组件库 | `src/shared/components/ui/*` + `packages/ui-components/src/*` |
|
||||
| 迁移源 | `apps/{teacher,student,parent,admin}-portal/src/app/**/page.tsx` + `apps/teacher-portal/src/mocks/handlers-p*.ts` |
|
||||
| 后端 schema 源 | `services/*/src/graphql/generated/schema.graphql` → `src/lib/api/__generated__/combined-schema.graphql` |
|
||||
|
||||
### 13.3 参考文档
|
||||
|
||||
| 文档 | 用途 |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| [README.md](./README.md) v2.0 | 微内核仪表盘子系统详细设计(保留有效部分) |
|
||||
| [004 §5.4](../../docs/architecture/004_architecture_impact_map.md) | 视口四层模型(导航/路由/组件/数据) |
|
||||
| [004 §11.7](../../docs/architecture/004_architecture_impact_map.md) | 前端数据层 + GraphQL 安全栈设计意图 |
|
||||
| [GraphQL @auth 审计](../../docs/security/graphql-auth-audit-2026-07.md) | 后端 resolver 权限现状 |
|
||||
| [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | CICD → Edu 迁移背景与资产清单 |
|
||||
| [多 AI 协作规范](../../docs/standards/multi-ai-collaboration.md) | 并行工作规则 |
|
||||
|
||||
### 13.4 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
| ------------------- | ------------------------------------------------------------------- |
|
||||
| AppFrame | 全站页面框架(TopBar + Sidebar + Main),`src/app/shell/layout.tsx` |
|
||||
| 微内核仪表盘 | `/shell` 角色首页的插件聚合区(v2.0 遗产,保留收缩) |
|
||||
| 页面模板四件套 | list / detail / form / workbench 四种页面骨架(§7.3) |
|
||||
| 契约工单 | 前端缺 schema 字段时的后端需求登记单(§11.4) |
|
||||
| MSW 兜底层 | `src/mocks/` 开发态模拟后端(§5.4) |
|
||||
| `@contract-pending` | 页面头注释,标记数据来自 MSW 待契约切换 |
|
||||
| fail-closed | 无身份/无权限/服务不可用时一律拒绝并显式提示,禁止静默放行 |
|
||||
| 视口四层 | 004 §5.4:导航 L1 / 路由 L2 / 组件 L3 / 数据 L4 |
|
||||
|
||||
---
|
||||
|
||||
> **本文件自发布之日起为 portal-shell 前端工作唯一权威。任何"已完成"声明必须能以 §1.1 同等严格度复现。下一阶段开工前,先完成 P0 并回填本文件 §9/§10 状态。**
|
||||
@@ -1,5 +1,7 @@
|
||||
# portal-shell 模块架构文档
|
||||
|
||||
> **⚠️ 重要(2026-07-20)**:本文件 v2.0 的多处完成度声明与实测不符(详见审计)。**portal-shell 前端工作的唯一权威文档已迁移至 [ARCHITECTURE.md](./ARCHITECTURE.md)(v3.0 总纲:现状审计 + 目标架构 + 页面迁移路线图 + 工作规范)**。本文件仅其"微内核仪表盘子系统"设计部分继续有效,凡与本文件冲突处以 ARCHITECTURE.md 为准。
|
||||
|
||||
> 版本:2.0
|
||||
> 日期:2026-07-17
|
||||
> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过)
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
* - 禁止 #hex 颜色字面量
|
||||
* - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量
|
||||
*
|
||||
* 关联:project_rules §3.10、portal-shell spec §7
|
||||
* P0-7:补 no-restricted-imports(ARCHITECTURE.md §3.4 V3-A3 / §8.3 铁律)
|
||||
* - 禁 widget/页面 直接 import sonner(统一走 @/shared/lib/notify)
|
||||
* - 禁 widget/页面 绕过 lib/api 直接 import @apollo/client
|
||||
* - 禁 widget 跨目录 import 其他 widget(插件隔离)
|
||||
*
|
||||
* 关联:project_rules §3.10、portal-shell ARCHITECTURE.md §8.3、§11.7
|
||||
*/
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
@@ -66,6 +71,56 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
|
||||
// P0-7:no-restricted-imports 强制(ARCHITECTURE.md §8.3 / §11.7 红线)
|
||||
// - 禁直接 import sonner(统一走 @/shared/lib/notify 封装)
|
||||
// - 禁页面/widget 绕过 lib/api 直接 import @apollo/client
|
||||
// - 禁 widget 跨目录 import 其他 widget(插件隔离铁律)
|
||||
// 白名单:
|
||||
// - notify 封装本体(src/shared/lib/notify.ts)+ 其测试(__tests__/notify.test.ts)+ Toaster 容器(src/shared/components/ui/sonner.tsx)
|
||||
// - 数据层(src/lib/apollo-client.ts、useWidgetQuery.ts、useWidgetMutation.ts、config-fetcher.ts、src/lib/api/**、src/providers/ApolloProvider.tsx)
|
||||
// - widget 注册中心(src/shell/Registry.tsx)必须 import 各 widget 的 plugin.manifest,是唯一例外
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: [
|
||||
"src/shared/lib/notify.ts",
|
||||
"src/shared/lib/__tests__/notify.test.ts",
|
||||
"src/shared/components/ui/sonner.tsx",
|
||||
"src/lib/apollo-client.ts",
|
||||
"src/lib/useWidgetQuery.ts",
|
||||
"src/lib/useWidgetMutation.ts",
|
||||
"src/lib/config-fetcher.ts",
|
||||
"src/lib/api/**",
|
||||
"src/providers/ApolloProvider.tsx",
|
||||
"src/shell/Registry.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "sonner",
|
||||
message:
|
||||
"禁止直接 import sonner,统一使用 @/shared/lib/notify(ARCHITECTURE.md §8.3)",
|
||||
},
|
||||
{
|
||||
name: "@apollo/client",
|
||||
message:
|
||||
"禁止绕过 lib/api 直接 import @apollo/client,使用 useWidgetQuery/useWidgetMutation(ARCHITECTURE.md §11.7)",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ["@/widgets/*", "../widgets/*", "../../widgets/*"],
|
||||
message:
|
||||
"禁止 widget 跨目录 import 其他 widget(插件隔离铁律,ARCHITECTURE.md §11.7)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 白名单:令牌原始定义、PWA manifest
|
||||
{
|
||||
files: ["**/primitive.css", "**/manifest.ts"],
|
||||
|
||||
2
apps/portal-shell/next-env.d.ts
vendored
2
apps/portal-shell/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-hash": "^4.0.1",
|
||||
"graphql": "^16.8.0",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.0.10",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -45,6 +46,7 @@
|
||||
"swr": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 登录代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/login { email, password }
|
||||
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
|
||||
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
|
||||
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
|
||||
* → 返回 { user } 给前端(不返回 token,JS 永不接触 token)
|
||||
*
|
||||
* 安全(§4.3):
|
||||
* - cookie 名 `edu_session`:HttpOnly + Secure(生产) + SameSite=Strict + Path=/
|
||||
* - Max-Age 与 iam 返回的 expiresIn 对齐
|
||||
* - 失败归一化错误:401 / 429 / 5xx 分别处理
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface IamLoginResponse {
|
||||
success: true;
|
||||
data: { user: UserInfo; tokens: TokenPair };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析角色字符串为 portal-shell 4 角色之一。
|
||||
* iam 返回 roles[],取主角色。
|
||||
*/
|
||||
function pickPrimaryRole(roles: string[]): Role {
|
||||
for (const r of roles) {
|
||||
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return "teacher";
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
|
||||
*/
|
||||
function filterKnownPermissions(perms: string[]): string[] {
|
||||
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
|
||||
return perms.filter((p) => known.has(p));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// ── 1. 解析与校验请求体 ──
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_BODY", message: "Request body must be JSON" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "INVALID_INPUT",
|
||||
message: "Email and password are required",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 调用 iam 登录 ──
|
||||
let iamResponse: Response;
|
||||
try {
|
||||
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// 透传客户端 IP 与 UA 用于审计
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(parsed.data),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error(
|
||||
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "IAM_UNREACHABLE",
|
||||
message: "Authentication service unavailable",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. 处理 iam 响应 ──
|
||||
if (iamResponse.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (iamResponse.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
if (!iamResponse.ok) {
|
||||
// 其他错误(403 账户锁定 / 5xx)
|
||||
let message = "登录失败";
|
||||
try {
|
||||
const errJson = (await iamResponse.json()) as { message?: string };
|
||||
if (errJson.message) message = errJson.message;
|
||||
} catch {
|
||||
// 忽略 JSON 解析失败
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_ERROR", message },
|
||||
{ status: iamResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. 提取 token 与 user ──
|
||||
let iamData: IamLoginResponse;
|
||||
try {
|
||||
iamData = (await iamResponse.json()) as IamLoginResponse;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { user, tokens } = iamData.data;
|
||||
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
|
||||
return NextResponse.json(
|
||||
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. 计算 cookie 值 ──
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
|
||||
const knownPerms = filterKnownPermissions(user.permissions ?? []);
|
||||
const permsBitmap = encodePermissionsBitmap(knownPerms);
|
||||
|
||||
// ── 6. 构建响应(不返回 token 给前端) ──
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: pickPrimaryRole(user.roles),
|
||||
permissions: knownPerms,
|
||||
dataScope: user.dataScope,
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
// 设置 Set-Cookie 头(多 cookie 用逗号分隔,NextResponse.cookies 更稳)
|
||||
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, permsBitmap, {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 登出代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3)
|
||||
*
|
||||
* 流程:
|
||||
* Browser POST /api/auth/logout(携带 edu_session cookie)
|
||||
* → 本 Route Handler 从 cookie 取 access token
|
||||
* → 清除 edu_session + edu_perms cookie(无论 iam 是否成功)
|
||||
* → best-effort 调 iam /api/v1/iam/logout(带 Authorization)使 refresh token 失效
|
||||
* → 返回 { success: true },前端跳转 /login
|
||||
*
|
||||
* 容错策略:
|
||||
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie(用户体验优先:本地登出必成功)
|
||||
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除(path=/api/v1/iam,本代理无法跨 path 清)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const GATEWAY_URL =
|
||||
process.env.API_GATEWAY_URL ||
|
||||
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||
"http://localhost:8080";
|
||||
|
||||
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
|
||||
|
||||
/**
|
||||
* 解析 cookie 头中的指定 cookie。
|
||||
*/
|
||||
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
// ── 1. best-effort 调 iam logout(使服务端 refresh token 失效) ──
|
||||
if (token) {
|
||||
try {
|
||||
await fetch(IAM_LOGOUT_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(req.headers.get("x-forwarded-for")
|
||||
? {
|
||||
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
|
||||
}
|
||||
: {}),
|
||||
...(req.headers.get("user-agent")
|
||||
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
// iam 不可达 → 静默,本地登出仍然完成
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.warn(
|
||||
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. 清除 edu_session + edu_perms cookie(无论 iam 是否成功) ──
|
||||
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||
response.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set(PERMS_COOKIE, "", {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* GraphQL 同域代理(P0-3,ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2)
|
||||
*
|
||||
* 浏览器 Apollo Client 一律走同域 `/api/graphql`:
|
||||
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从 httpOnly cookie `edu_session` 取 JWT,注入 `Authorization: Bearer`
|
||||
* 2. 透传 body(APQ hash 或 query)与 Apollo 相关头
|
||||
* 3. 响应 status / JSON 原样回传,不缓存
|
||||
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
|
||||
*
|
||||
* 安全收益(§4.2):
|
||||
* - JWT 全程不出 httpOnly cookie,消除 XSS 窃取凭证面
|
||||
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router)
|
||||
*
|
||||
* 验收命令:
|
||||
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
|
||||
const UPSTREAM_URL =
|
||||
process.env.APOLLO_ROUTER_URL ||
|
||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||
"http://localhost:3000/graphql";
|
||||
|
||||
/**
|
||||
* 从 Cookie 头解析指定 cookie 值。
|
||||
*/
|
||||
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
|
||||
// 透传 body(APQ hash 请求或完整 query),不解析不修改
|
||||
const body = await req.text();
|
||||
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
||||
Accept: req.headers.get("accept") ?? "application/json",
|
||||
// Apollo Persisted Query 协议头透传
|
||||
"X-APQ": req.headers.get("x-apq") ?? "1",
|
||||
// 服务端追踪:透传客户端 X-Request-Id(若有)
|
||||
...(req.headers.get("x-request-id")
|
||||
? { "X-Request-Id": req.headers.get("x-request-id") as string }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// 注入 Authorization(若 cookie 中有 JWT)
|
||||
if (token) {
|
||||
upstreamHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(UPSTREAM_URL, {
|
||||
method: "POST",
|
||||
headers: upstreamHeaders,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseText = await upstream.text();
|
||||
return new NextResponse(responseText, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
// 不缓存:GraphQL 响应可能因身份/变量而异
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown upstream error";
|
||||
console.error(
|
||||
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: "UPSTREAM_UNAVAILABLE",
|
||||
extensions: {
|
||||
code: "UPSTREAM_UNAVAILABLE",
|
||||
reason: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
|
||||
* Apollo Router 自身的 health 在 :8088/health。
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
proxy: "/api/graphql",
|
||||
upstream: UPSTREAM_URL,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 登录表单(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3)
|
||||
*
|
||||
* 客户端组件,shadcn 令牌登录表单。
|
||||
* - 提交:POST /api/auth/login { email, password }
|
||||
* - 成功:router.push(next || "/shell")
|
||||
* - 失败:notify.error 显示归一化错误
|
||||
* - DEV_MODE:显示提示横幅 + 一键填充 dev 凭证按钮
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
|
||||
*/
|
||||
import { useState, useTransition, type FormEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { GraduationCap, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { notify } from "@/shared/lib/notify";
|
||||
|
||||
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||
|
||||
interface LoginSuccessResponse {
|
||||
success: true;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
dataScope: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
|
||||
|
||||
/**
|
||||
* 把后端返回的错误码映射为中文文案。
|
||||
*/
|
||||
function mapLoginError(errorCode: string, fallback: string): string {
|
||||
switch (errorCode) {
|
||||
case "INVALID_BODY":
|
||||
case "INVALID_INPUT":
|
||||
return "请输入有效的邮箱和密码";
|
||||
case "INVALID_CREDENTIALS":
|
||||
return "邮箱或密码错误";
|
||||
case "RATE_LIMITED":
|
||||
return "登录尝试过于频繁,请稍后再试";
|
||||
case "IAM_UNREACHABLE":
|
||||
case "IAM_BAD_RESPONSE":
|
||||
return "登录服务暂不可用,请稍后再试";
|
||||
case "IAM_ERROR":
|
||||
return fallback || "登录失败,请重试";
|
||||
default:
|
||||
return fallback || "登录失败,请重试";
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginForm(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
if (isPending) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const next = searchParams.get("next") || "/shell";
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
notify.error("网络错误,请检查网络连接后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
let data: LoginResponse;
|
||||
try {
|
||||
data = (await response.json()) as LoginResponse;
|
||||
} catch {
|
||||
notify.error("登录服务返回数据异常");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok || !("success" in data)) {
|
||||
const errorResp = data as LoginErrorResponse;
|
||||
notify.error(mapLoginError(errorResp.error, errorResp.message));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
|
||||
// 用 router.push 而非 location.href,保留 SPA 体验;cookie 已由 Set-Cookie 写入
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function fillDevCredentials(): void {
|
||||
setEmail("dev@edu.local");
|
||||
setPassword("dev-password");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
|
||||
<GraduationCap className="size-6" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
|
||||
<p className="text-muted-foreground text-sm">K12 智慧教务平台</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">登录</CardTitle>
|
||||
<CardDescription>使用邮箱与密码登录你的账号</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
邮箱
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@school.edu.cn"
|
||||
aria-label="邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
aria-label="密码"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !email || !password}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
登录中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="size-4" aria-hidden="true" />
|
||||
登录
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{DEV_MODE ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={fillDevCredentials}
|
||||
disabled={isPending}
|
||||
>
|
||||
开发模式:填充 dev 凭证
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-xs">
|
||||
登录即表示同意 Edu Portal 使用条款与隐私政策
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/portal-shell/src/app/login/page.tsx
Normal file
31
apps/portal-shell/src/app/login/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 登录页(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1)
|
||||
*
|
||||
* 路由:/login
|
||||
*
|
||||
* 职责:
|
||||
* - RSC 入口:检测已登录(cookie edu_session 存在)→ redirect /shell
|
||||
* - 渲染 <LoginForm /> 客户端组件
|
||||
*
|
||||
* 已登录检测策略:
|
||||
* - middleware 将 /login 列入 PUBLIC_ROUTES,不强制身份校验
|
||||
* - 本页面 RSC 通过 cookies() 读 edu_session cookie(仅看存在性,不验签——验签由 middleware 负责)
|
||||
* - 存在 cookie 视为已登录,直接 redirect /shell,避免登录页"闪现"
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LoginPage(): Promise<React.ReactElement> {
|
||||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get("edu_session");
|
||||
if (session?.value) {
|
||||
redirect("/shell");
|
||||
}
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -5,30 +5,36 @@ import type { Role } from "@/lib/types";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
||||
* Shell 入口(RSC Server Component,v3.0 P0-2 fail-closed + 流式渲染)
|
||||
*
|
||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
||||
* 数据流(ARCHITECTURE.md §3.4 V3-A2/A3、§5.5):
|
||||
* ① 从 middleware 注入的请求头获取 userId / role(middleware 已校验 cookie + 权限)
|
||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||
*
|
||||
* 流式渲染分层(README v2.0 §5.3):
|
||||
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
||||
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
||||
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
||||
* fail-closed(P0-2,§11.7 红线 #5):
|
||||
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
|
||||
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx,禁止默认 teacher
|
||||
*
|
||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
||||
*
|
||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/A3、§5.5、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ShellPage(): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId =
|
||||
headerList.get("x-user-id") ||
|
||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
||||
const userId = headerList.get("x-user-id");
|
||||
const roleHeader = headerList.get("x-user-role");
|
||||
|
||||
// fail-closed:middleware 必须注入身份头,缺失即异常(不再默认 teacher)
|
||||
if (!userId || !roleHeader) {
|
||||
throw new Error(
|
||||
"[portal-shell] ShellPage missing identity headers " +
|
||||
"(middleware must inject x-user-id / x-user-role). " +
|
||||
"If middleware is configured, this indicates a routing misconfiguration.",
|
||||
);
|
||||
}
|
||||
|
||||
const role = roleHeader as Role;
|
||||
|
||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||
|
||||
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldX } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* 403 Forbidden 页(P0-2,ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2)
|
||||
*
|
||||
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
|
||||
* 页面渲染:
|
||||
* - 友好提示 + 图标 + 返回仪表盘链接
|
||||
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
|
||||
* - 支持查询参数 reason=no_config | missing_role | missing_permission(仅 UX 提示)
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
|
||||
*/
|
||||
export default async function ForbiddenPage(): Promise<React.ReactElement> {
|
||||
const headerList = await headers();
|
||||
const userId = headerList.get("x-user-id") ?? "unknown";
|
||||
const role = headerList.get("x-user-role") ?? "unknown";
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldX className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold leading-tight">
|
||||
403 · 无权访问此页面
|
||||
</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
你的账号没有访问该页面的权限。如果认为这是错误,请联系管理员调整角色或权限。
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用户:<span className="font-mono">{userId}</span>
|
||||
{role !== "unknown" && (
|
||||
<>
|
||||
{" · "}角色:<span className="font-mono">{role}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex gap-3" aria-label="操作">
|
||||
<Link
|
||||
href="/shell"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
返回仪表盘
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
切换账号登录
|
||||
</Link>
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
38
apps/portal-shell/src/instrumentation.ts
Normal file
38
apps/portal-shell/src/instrumentation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook(启动期守卫)
|
||||
*
|
||||
* 关联:ARCHITECTURE.md §3.4 V3-A2 / §6.1 / §10 P0-5
|
||||
*
|
||||
* 在生产环境(NODE_ENV=production)下,DEV_MODE 必须为 false;
|
||||
* 若 NEXT_PUBLIC_DEV_MODE=true(或非 "false")则启动即抛错退出,
|
||||
* 防止"生产永久绕过认证"(见 §1.3-F4 / §6.1)。
|
||||
*
|
||||
* 验收命令:
|
||||
* NODE_ENV=production NEXT_PUBLIC_DEV_MODE=true pnpm start
|
||||
* → 进程退出,stderr 输出包含 "NEXT_PUBLIC_DEV_MODE must be false in production"
|
||||
*
|
||||
* 开发态(NODE_ENV !== "production")放行,由 middleware 提供 dev-token 合成身份。
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
const nodeEnv = process.env.NODE_ENV ?? "";
|
||||
const devModeRaw = process.env.NEXT_PUBLIC_DEV_MODE ?? "";
|
||||
|
||||
if (nodeEnv !== "production") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅生产环境校验;DEV_MODE 必须显式为 "false"(或等价 falsy)
|
||||
const devModeEnabled =
|
||||
devModeRaw !== "false" && devModeRaw !== "0" && devModeRaw !== "";
|
||||
|
||||
if (devModeEnabled) {
|
||||
const msg =
|
||||
"[portal-shell] FATAL: NEXT_PUBLIC_DEV_MODE must be false in production " +
|
||||
`(got NEXT_PUBLIC_DEV_MODE=${JSON.stringify(devModeRaw)}). ` +
|
||||
"Aborting startup to prevent authentication bypass " +
|
||||
"(ARCHITECTURE.md §10 P0-5).";
|
||||
console.error(msg);
|
||||
// 非零退出码,触发进程管理器/容器重启策略
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Apollo Client(v2.1 M8 验收点)
|
||||
*
|
||||
* 所有 portal-shell 查询走 apollo-router(GraphQL 联邦入口):
|
||||
* portal-shell → apollo-router :3000/graphql → 各子图(iam/core-edu/content/msg/data-ana/ai/config-service)
|
||||
* Apollo Client(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §5.1 / §5.2)
|
||||
*
|
||||
* 双端使用:
|
||||
* - 服务端(RSC):createApolloClient() 每次请求新建实例,ssrMode=true
|
||||
* - 客户端:getApolloClient() 单例,复用 InMemoryCache
|
||||
* - 客户端:HttpLink 指向同域 `/api/graphql`,JWT 由 httpOnly cookie 经代理注入
|
||||
* - 服务端(RSC):直连 `APOLLO_ROUTER_URL`(内网,由 middleware 注入身份头)
|
||||
*
|
||||
* APQ(Automatic Persisted Queries,v2.1 M3 安全加固):
|
||||
* - 生产环境前端只发 query hash(sha256),不发明文 query
|
||||
@@ -14,17 +11,29 @@
|
||||
* - 防止攻击者通过 DevTools 构造任意查询探测 schema
|
||||
* - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试
|
||||
*
|
||||
* 关联:portal-shell spec §4.1 APQ、§5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准
|
||||
* 安全(V3-A2):
|
||||
* - 客户端不再注入 Authorization 头(token 全程不出 httpOnly cookie)
|
||||
* - 客户端不再读 localStorage.edu_token(方案已废止)
|
||||
* - 客户端 credentials:"include" 让 cookie 流向同域 /api/graphql
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§5.2
|
||||
*/
|
||||
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
|
||||
import { sha256 } from "crypto-hash";
|
||||
|
||||
const APOLLO_ROUTER_URL =
|
||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||
process.env.APOLLO_ROUTER_URL ||
|
||||
"http://localhost:3000/graphql";
|
||||
/**
|
||||
* 服务端 RSC 直连 apollo-router URL(仅服务端可用,浏览器走同域代理)。
|
||||
* 不挂 NEXT_PUBLIC_ 前缀 → 不打包进客户端 bundle。
|
||||
*/
|
||||
const SERVER_APOLLO_ROUTER_URL =
|
||||
process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql";
|
||||
|
||||
/**
|
||||
* 客户端同域代理路径(V3-A2):浏览器只发同域请求,
|
||||
* 由 /api/graphql Route Handler 取 httpOnly cookie 中的 JWT 并转发。
|
||||
*/
|
||||
const CLIENT_PROXY_URL = "/api/graphql";
|
||||
|
||||
// 开发模式可关闭 APQ 便于调试(NEXT_PUBLIC_APOLLO_APQ=false)
|
||||
// 生产环境默认启用(未设置或设置为 true 均启用)
|
||||
@@ -33,42 +42,39 @@ const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false";
|
||||
/**
|
||||
* 创建 Apollo Client 实例。
|
||||
*
|
||||
* @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取)
|
||||
* @param options 可选:
|
||||
* - serverSide: true 表示服务端 RSC 模式(直连 router),false/省略表示客户端(走 /api/graphql)
|
||||
*
|
||||
* 客户端不再接受 getAuthToken 参数:JWT 已迁至 httpOnly cookie,
|
||||
* JS 永远拿不到 token(ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)。
|
||||
*/
|
||||
export function createApolloClient(
|
||||
getAuthToken?: () => string | null,
|
||||
options: { serverSide?: boolean } = {},
|
||||
): ApolloClient<unknown> {
|
||||
const isServer = options.serverSide ?? typeof window === "undefined";
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: APOLLO_ROUTER_URL,
|
||||
credentials: "include",
|
||||
uri: isServer ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
|
||||
// 客户端:同域请求,cookie 自动随行;服务端:直连 router 不需要 cookie
|
||||
credentials: isServer ? "omit" : "include",
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const token = getAuthToken?.() ?? null;
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Link 链顺序:authLink → pqLink → httpLink
|
||||
// - authLink 注入 Authorization 头
|
||||
// Link 链顺序:pqLink → httpLink
|
||||
// - pqLink 将 query 替换为 hash(启用时)
|
||||
// - httpLink 发送请求
|
||||
// - httpLink 发送请求(含 cookie)
|
||||
// 客户端不再需要 authLink(token 注入由 /api/graphql 代理负责)
|
||||
const link = APQ_ENABLED
|
||||
? from([authLink, createPersistedQueryLink({ sha256 }), httpLink])
|
||||
: from([authLink, httpLink]);
|
||||
? from([createPersistedQueryLink({ sha256 }), httpLink])
|
||||
: from([httpLink]);
|
||||
|
||||
return new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache(),
|
||||
ssrMode: typeof window === "undefined",
|
||||
ssrMode: isServer,
|
||||
defaultOptions: {
|
||||
query: {
|
||||
errorPolicy: "all",
|
||||
fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first",
|
||||
fetchPolicy: isServer ? "no-cache" : "cache-first",
|
||||
},
|
||||
watchQuery: {
|
||||
errorPolicy: "all",
|
||||
@@ -81,15 +87,15 @@ let clientSingleton: ApolloClient<unknown> | null = null;
|
||||
|
||||
/**
|
||||
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
|
||||
*
|
||||
* 注意:不再接受 getAuthToken 参数(V3-A2 移除 localStorage 方案)。
|
||||
*/
|
||||
export function getApolloClient(
|
||||
getAuthToken?: () => string | null,
|
||||
): ApolloClient<unknown> {
|
||||
export function getApolloClient(): ApolloClient<unknown> {
|
||||
if (typeof window === "undefined") {
|
||||
return createApolloClient(getAuthToken);
|
||||
return createApolloClient({ serverSide: true });
|
||||
}
|
||||
if (!clientSingleton) {
|
||||
clientSingleton = createApolloClient(getAuthToken);
|
||||
clientSingleton = createApolloClient({ serverSide: false });
|
||||
}
|
||||
return clientSingleton;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
*/
|
||||
import { gql } from "@apollo/client";
|
||||
import { createApolloClient } from "./apollo-client";
|
||||
import type { PluginConfigResponse, Role } from "./types";
|
||||
import type {
|
||||
LayoutTemplateInfo,
|
||||
PluginConfigResponse,
|
||||
PluginPlacement,
|
||||
PluginRegistryItem,
|
||||
Role,
|
||||
SlotConfig,
|
||||
} from "./types";
|
||||
|
||||
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
|
||||
export const GET_PLUGIN_CONFIG = gql`
|
||||
@@ -124,9 +131,11 @@ export async function fetchPluginConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最终兜底:空默认配置
|
||||
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
|
||||
return getDefaultConfig();
|
||||
// 3. 最终兜底:内置默认配置(按角色静态定义,P0-4)
|
||||
console.warn(
|
||||
`[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`,
|
||||
);
|
||||
return getDefaultConfig(role);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,25 +197,196 @@ async function fetchPluginConfigDirect(
|
||||
|
||||
/**
|
||||
* 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。
|
||||
* 保证 Shell 始终可渲染,不因下游不可用而白屏。
|
||||
*
|
||||
* P0-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集,
|
||||
* 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。
|
||||
*
|
||||
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录):
|
||||
* - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu)
|
||||
* - sidebar:teacher → class-selector + term-switcher + quick-actions;
|
||||
* student → term-switcher + quick-actions;
|
||||
* parent → child-selector + term-switcher + quick-actions;
|
||||
* admin → 无 sidebar 上下文
|
||||
* - main:universal 7 件按角色过滤 + 角色专属 widget
|
||||
*
|
||||
* @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致)
|
||||
*/
|
||||
export function getDefaultConfig(): PluginConfigResponse {
|
||||
export function getDefaultConfig(role?: Role): PluginConfigResponse {
|
||||
const effectiveRole: Role = role ?? "teacher";
|
||||
|
||||
const layout: LayoutTemplateInfo = {
|
||||
layoutId: "classic",
|
||||
displayName: "经典三栏",
|
||||
description: "TopBar + SideNav + Main",
|
||||
availableSlots: ["top", "side", "main"],
|
||||
layoutSchemaJson: JSON.stringify({
|
||||
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
||||
}),
|
||||
};
|
||||
|
||||
const slots: SlotConfig[] = [
|
||||
{ slotName: "top", navItems: [] },
|
||||
{ slotName: "side", navItems: [] },
|
||||
{ slotName: "main", navItems: [] },
|
||||
];
|
||||
|
||||
// ── top slot(全角色共享:通知铃 / 全局搜索 / 语言切换 / 用户菜单) ──
|
||||
const topPlugins: PluginPlacement[] = [
|
||||
placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }),
|
||||
placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }),
|
||||
];
|
||||
|
||||
// ── side slot(按角色裁剪) ──
|
||||
const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map(
|
||||
(id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }),
|
||||
);
|
||||
|
||||
// ── main slot(universal 按角色 + 角色专属) ──
|
||||
const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map(
|
||||
(id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }),
|
||||
);
|
||||
|
||||
// ── registry(按角色聚合所有可见插件的元信息) ──
|
||||
const registry: PluginRegistryItem[] = [
|
||||
...topPlugins,
|
||||
...sidePlugins,
|
||||
...mainPlugins,
|
||||
].map((p) => registryItem(p.pluginId, effectiveRole));
|
||||
|
||||
return {
|
||||
activeLayout: {
|
||||
layoutId: "classic",
|
||||
displayName: "经典三栏",
|
||||
description: "TopBar + SideNav + Main",
|
||||
availableSlots: ["top", "side", "main"],
|
||||
layoutSchemaJson: JSON.stringify({
|
||||
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
||||
}),
|
||||
},
|
||||
slots: [
|
||||
{ slotName: "top", navItems: [] },
|
||||
{ slotName: "side", navItems: [] },
|
||||
{ slotName: "main", navItems: [] },
|
||||
],
|
||||
plugins: [],
|
||||
registry: [],
|
||||
activeLayout: layout,
|
||||
slots,
|
||||
plugins: [...topPlugins, ...sidePlugins, ...mainPlugins],
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
/** side slot 角色默认(按渲染顺序) */
|
||||
const SIDE_DEFAULTS: Record<Role, string[]> = {
|
||||
teacher: ["class-selector", "term-switcher", "quick-actions"],
|
||||
student: ["term-switcher", "quick-actions"],
|
||||
parent: ["child-selector", "term-switcher", "quick-actions"],
|
||||
admin: [],
|
||||
};
|
||||
|
||||
/** main slot 角色默认(universal + 角色专属,按渲染顺序) */
|
||||
const MAIN_DEFAULTS: Record<Role, string[]> = {
|
||||
teacher: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"lesson-plan-editor",
|
||||
"question-bank",
|
||||
"textbook-manager",
|
||||
"scheduling-rules",
|
||||
],
|
||||
student: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"error-book",
|
||||
"learning-path",
|
||||
"elective-selector",
|
||||
"ai-tutor",
|
||||
],
|
||||
parent: [
|
||||
"schedule-widget",
|
||||
"grades-widget",
|
||||
"homework-widget",
|
||||
"exams-widget",
|
||||
"attendance-widget",
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"child-overview",
|
||||
"leave-approval",
|
||||
],
|
||||
admin: [
|
||||
"announcements-widget",
|
||||
"notifications-widget",
|
||||
"user-management",
|
||||
"rbac-manager",
|
||||
"plugin-manager",
|
||||
"school-settings",
|
||||
"audit-logs",
|
||||
"invitation-codes",
|
||||
],
|
||||
};
|
||||
|
||||
/** 简易 PluginPlacement 构造器 */
|
||||
function placement(
|
||||
pluginId: string,
|
||||
slot: string,
|
||||
sortOrder: number,
|
||||
size: { colSpan: number; rowSpan: number },
|
||||
): PluginPlacement {
|
||||
return {
|
||||
pluginId,
|
||||
slot,
|
||||
sortOrder,
|
||||
sizeJson: JSON.stringify(size),
|
||||
propsJson: "{}",
|
||||
isVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */
|
||||
function registryItem(pluginId: string, role: Role): PluginRegistryItem {
|
||||
// category 由 pluginId 前缀目录决定,与 src/widgets/<category>/ 对齐
|
||||
const category = inferCategory(pluginId);
|
||||
return {
|
||||
pluginId,
|
||||
category,
|
||||
version: "0.1.0",
|
||||
displayName: pluginId,
|
||||
description: `Built-in ${category} plugin (default config fallback)`,
|
||||
requiredRoles: [role],
|
||||
isBuiltin: true,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 由 pluginId 推断 category(与目录结构 src/widgets/<category>/ 对齐) */
|
||||
function inferCategory(pluginId: string): string {
|
||||
if (pluginId.endsWith("-widget")) return "universal";
|
||||
if (
|
||||
pluginId === "notification-bell" ||
|
||||
pluginId === "global-search" ||
|
||||
pluginId === "locale-switcher" ||
|
||||
pluginId === "user-menu"
|
||||
)
|
||||
return "topbar";
|
||||
if (
|
||||
pluginId === "class-selector" ||
|
||||
pluginId === "child-selector" ||
|
||||
pluginId === "term-switcher" ||
|
||||
pluginId === "quick-actions"
|
||||
)
|
||||
return "sidebar";
|
||||
if (
|
||||
pluginId === "lesson-plan-editor" ||
|
||||
pluginId === "question-bank" ||
|
||||
pluginId === "textbook-manager" ||
|
||||
pluginId === "scheduling-rules"
|
||||
)
|
||||
return "teacher";
|
||||
if (
|
||||
pluginId === "error-book" ||
|
||||
pluginId === "learning-path" ||
|
||||
pluginId === "elective-selector" ||
|
||||
pluginId === "ai-tutor"
|
||||
)
|
||||
return "student";
|
||||
if (pluginId === "child-overview" || pluginId === "leave-approval")
|
||||
return "parent";
|
||||
return "admin";
|
||||
}
|
||||
|
||||
275
apps/portal-shell/src/middleware.ts
Normal file
275
apps/portal-shell/src/middleware.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* portal-shell 中间件:认证 + 路由门禁(P0-2,ARCHITECTURE.md §3.4 V3-A2/A3 / §4 / §6)
|
||||
*
|
||||
* 职责链(每个 /shell/** 请求):
|
||||
* 1. 公共路径白名单 → 直接放行
|
||||
* 2. 读取 httpOnly cookie `edu_session`(JWT)
|
||||
* - 无 cookie / 无效 → 302 /login?next=<pathname>
|
||||
* 3. 验证 JWT(dev 模式 decode-only;生产模式 jose JWKS RS256)
|
||||
* 4. 注入请求头 x-user-id / x-user-role / x-user-permissions(供 RSC 读取)
|
||||
* 5. 调 checkRoutePermission → 拒绝 → 302 /shell/forbidden
|
||||
*
|
||||
* DEV_MODE 合成身份(§3.4 V3-A2):
|
||||
* - 仅当 NODE_ENV !== "production" && NEXT_PUBLIC_DEV_MODE === "true" 时启用
|
||||
* - 无 cookie 时合成 dev-user / teacher / 全权限位图
|
||||
* - 生产环境若 DEV_MODE=true 由 instrumentation.ts 拒绝启动
|
||||
*
|
||||
* JWKS(§4.3):
|
||||
* - 端点:`IAM_JWKS_URI` 或默认 `http://api-gateway:8080/v1/iam/.well-known/jwks.json`
|
||||
* - 缓存 5min(jose 内置);不可达 → fail-closed 跳登录
|
||||
*
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/V3-A3、§4、§6.2、§11.7
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||
import {
|
||||
checkRoutePermission,
|
||||
PUBLIC_ROUTES,
|
||||
} from "@/shared/lib/route-permissions";
|
||||
import {
|
||||
encodePermissionsBitmap,
|
||||
PERMISSION_BITMAP_ORDER,
|
||||
} from "@edu/shared-ts/permission-bitmap";
|
||||
import type { Role } from "@edu/shared-ts/contracts";
|
||||
|
||||
export const config = {
|
||||
// 拦截 /shell/** 与 /api/** 与 /(首页);不拦截 Next.js 静态资源
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|robots.txt|.*\\.png$|.*\\.svg$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
const SESSION_COOKIE = "edu_session";
|
||||
const PERMS_COOKIE = "edu_perms";
|
||||
|
||||
const DEV_MODE =
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||
|
||||
const IAM_JWKS_URI =
|
||||
process.env.IAM_JWKS_URI ||
|
||||
process.env.NEXT_PUBLIC_IAM_JWKS_URI ||
|
||||
"http://api-gateway:8080/v1/iam/.well-known/jwks.json";
|
||||
|
||||
const JWT_ISSUER = process.env.JWT_ISSUER || "edu.iam";
|
||||
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || "edu-portal";
|
||||
|
||||
// jose JWKS 远程集合(自动缓存 5min)
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
function getJwks(): ReturnType<typeof createRemoteJWKSet> {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL(IAM_JWKS_URI));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
role: Role;
|
||||
perms?: string[];
|
||||
// iam 自定义 claims
|
||||
"https://edu.cn/role"?: Role;
|
||||
"https://edu.cn/permissions"?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 cookie 头中的指定 cookie。
|
||||
*/
|
||||
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT 并返回身份信息。
|
||||
* - dev 模式:仅 decode(不验签)
|
||||
* - 生产模式:jose JWKS RS256 验签 + iss/aud 校验
|
||||
*/
|
||||
async function verifySession(
|
||||
token: string,
|
||||
): Promise<{ userId: string; role: Role; perms: string[] } | null> {
|
||||
// dev 模式:仅 decode(用于本地开发,无密钥环境)
|
||||
if (DEV_MODE) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split(".")[1] ?? "", "base64").toString("utf-8"),
|
||||
) as JwtPayload;
|
||||
return {
|
||||
userId: payload.sub ?? "dev-user",
|
||||
role: payload.role ?? payload["https://edu.cn/role"] ?? "teacher",
|
||||
perms:
|
||||
payload.perms ??
|
||||
payload["https://edu.cn/permissions"] ??
|
||||
// dev 默认放全权限(仅本地)
|
||||
PERMISSION_BITMAP_ORDER.filter((p) => !p.startsWith("_RESERVED_")),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 生产模式:jose 验签
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(), {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
const p = payload as unknown as JwtPayload;
|
||||
return {
|
||||
userId: p.sub ?? "",
|
||||
role: p.role ?? p["https://edu.cn/role"] ?? "teacher",
|
||||
perms: p.perms ?? p["https://edu.cn/permissions"] ?? [],
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof joseErrors.JWKSNoMatchingKey) {
|
||||
// JWKS 可达但无匹配密钥 → 视为无效 token
|
||||
return null;
|
||||
}
|
||||
// JWKS 不可达(网络错误)→ fail-closed
|
||||
console.warn(
|
||||
`[portal-shell] middleware JWT verify failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成 dev-token 身份(仅 DEV_MODE=true 时使用)。
|
||||
* 提供本地无后端可用的最小可运行身份。
|
||||
*/
|
||||
function devIdentity(): {
|
||||
userId: string;
|
||||
role: Role;
|
||||
perms: string[];
|
||||
bitmap: string;
|
||||
} {
|
||||
const perms = PERMISSION_BITMAP_ORDER.filter(
|
||||
(p) => !p.startsWith("_RESERVED_"),
|
||||
);
|
||||
return {
|
||||
userId: "dev-user",
|
||||
role: "teacher",
|
||||
perms,
|
||||
bitmap: encodePermissionsBitmap(perms),
|
||||
};
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const { pathname, search } = req.nextUrl;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
// ── 0. 公共路径白名单:直接放行 ──
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── 1. 读取 cookie 中的 JWT ──
|
||||
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||
|
||||
// ── 2. 无 cookie 处理 ──
|
||||
if (!token) {
|
||||
if (DEV_MODE) {
|
||||
// dev 模式合成身份,继续走权限检查(让本地体验"未登录也有 teacher 身份")
|
||||
const identity = devIdentity();
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: injectIdentityHeaders(
|
||||
req,
|
||||
identity.userId,
|
||||
identity.role,
|
||||
identity.bitmap,
|
||||
),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
// 生产模式 → 跳登录
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// ── 3. 验证 JWT ──
|
||||
const verified = await verifySession(token);
|
||||
if (!verified) {
|
||||
if (DEV_MODE) {
|
||||
// dev 模式 JWT 失效也合成身份(避免本地开发卡死)
|
||||
const identity = devIdentity();
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: injectIdentityHeaders(
|
||||
req,
|
||||
identity.userId,
|
||||
identity.role,
|
||||
identity.bitmap,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 生产模式 → 清 cookie + 跳登录
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
response.cookies.delete(PERMS_COOKIE);
|
||||
return response;
|
||||
}
|
||||
|
||||
// ── 4. 计算权限位图(优先读 edu_perms cookie,回退到 JWT 内 perms 计算) ──
|
||||
const permsBitmap =
|
||||
readCookie(cookieHeader, PERMS_COOKIE) ||
|
||||
encodePermissionsBitmap(verified.perms);
|
||||
|
||||
// ── 5. 注入身份头(供 RSC headers() 读取) ──
|
||||
const requestHeaders = injectIdentityHeaders(
|
||||
req,
|
||||
verified.userId,
|
||||
verified.role,
|
||||
permsBitmap,
|
||||
);
|
||||
|
||||
// ── 6. 路由门禁(仅对 /shell/** 强制执行;其他路径已放行或交由后端) ──
|
||||
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||
const result = checkRoutePermission(pathname, permsBitmap, verified.role);
|
||||
if (!result.allowed) {
|
||||
const forbiddenUrl = new URL("/shell/forbidden", req.url);
|
||||
// 附带拒绝原因作为查询参数(页面可显示,但不暴露内部细节)
|
||||
if (result.reason) {
|
||||
forbiddenUrl.searchParams.set("reason", result.reason);
|
||||
}
|
||||
// 仍然注入身份头(forbidden 页可能需要显示用户名)
|
||||
return NextResponse.redirect(forbiddenUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next({
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造身份头注入的 Headers 对象。
|
||||
* RSC 通过 `await headers()` 读取这些值。
|
||||
*/
|
||||
function injectIdentityHeaders(
|
||||
req: NextRequest,
|
||||
userId: string,
|
||||
role: Role,
|
||||
permsBitmap: string,
|
||||
): Headers {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-user-id", userId);
|
||||
requestHeaders.set("x-user-role", role);
|
||||
requestHeaders.set("x-user-permissions", permsBitmap);
|
||||
return requestHeaders;
|
||||
}
|
||||
@@ -1,37 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ApolloProvider(v2.1 M8)
|
||||
* ApolloProvider(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)
|
||||
*
|
||||
* 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client
|
||||
* 查询 apollo-router(M8 验收点:portal-shell 查询走 Router)。
|
||||
* 查询同域 /api/graphql 代理(V3-A2:浏览器不再直连 apollo-router)。
|
||||
*
|
||||
* Token 注入:从 localStorage 读取 JWT(对齐 teacher-portal F12 约定),
|
||||
* cookie 凭证通过 credentials:"include" 一并发送。
|
||||
* 凭证传递(V3-A2):
|
||||
* - 客户端 HttpLink credentials:"include",同域 cookie 自动随行
|
||||
* - JWT 全程在 httpOnly cookie `edu_session` 中,JS 永不接触
|
||||
* - 已删除 localStorage["edu_token"] 读取(方案废止)
|
||||
*
|
||||
* 关联:portal-shell spec §5.6、M8 验收标准
|
||||
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§11.7
|
||||
*/
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client";
|
||||
import { getApolloClient } from "@/lib/apollo-client";
|
||||
|
||||
const TOKEN_KEY = "edu_token";
|
||||
|
||||
function readToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApolloProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const client = useMemo(() => getApolloClient(readToken), []);
|
||||
// getApolloClient 不再接受 getAuthToken 参数(token 已迁至 httpOnly cookie)
|
||||
const client = useMemo(() => getApolloClient(), []);
|
||||
return (
|
||||
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
|
||||
);
|
||||
|
||||
@@ -156,15 +156,24 @@ describe("permission-bitmap", () => {
|
||||
expect(isValidPermission("")).toBe(false);
|
||||
});
|
||||
|
||||
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
|
||||
it("_RESERVED_* 占位返回 false(P0-6 防误用铁律)", () => {
|
||||
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||
});
|
||||
|
||||
it("PERMISSION_BITMAP_ORDER 全部合法(_RESERVED_* 占位除外)", () => {
|
||||
for (const perm of PERMISSION_BITMAP_ORDER) {
|
||||
expect(isValidPermission(perm)).toBe(true);
|
||||
if (perm.startsWith("_RESERVED_")) {
|
||||
// 占位不应视为合法权限点
|
||||
expect(isValidPermission(perm)).toBe(false);
|
||||
} else {
|
||||
expect(isValidPermission(perm)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("全量编解码压力测试", () => {
|
||||
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
|
||||
it("全部权限点编码后解码应还原(含 _RESERVED_31 占位,P0-6 去重后无 GRADE_READ 重复)", () => {
|
||||
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||
const encoded = encodePermissionsBitmap(allPerms);
|
||||
const decoded = decodePermissionsBitmap(encoded);
|
||||
@@ -177,5 +186,19 @@ describe("permission-bitmap", () => {
|
||||
// 67 bit → base36 约 14 字符
|
||||
expect(encoded.length).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("GRADE_READ 在 PERMISSION_BITMAP_ORDER 中仅出现一次(P0-6 去重铁律)", () => {
|
||||
const occurrences = PERMISSION_BITMAP_ORDER.filter(
|
||||
(p) => p === "GRADE_READ",
|
||||
).length;
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
|
||||
it("_RESERVED_31 占位存在且永不作为有效权限点(P0-6 占位铁律)", () => {
|
||||
// 占位存在于 ORDER(保留 bit 位语义)
|
||||
expect(PERMISSION_BITMAP_ORDER).toContain("_RESERVED_31");
|
||||
// 但 isValidPermission 不应将其视为合法权限点(防误用)
|
||||
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,14 +267,44 @@ export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
||||
*
|
||||
* 用于 /api/* 路径的权限校验。
|
||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||
*
|
||||
* P0-2(ARCHITECTURE.md §6.2 §3.4 V3-A3):新增 auth/graphql 公开端点。
|
||||
*/
|
||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||
// 错误上报端点:所有登录用户可访问
|
||||
// 错误上报端点:所有登录用户可访问(空 config 表示登录即可)
|
||||
"/api/log": {},
|
||||
// 健康检查:公开
|
||||
"/api/healthz": {},
|
||||
"/api/health": {},
|
||||
"/api/ready": {},
|
||||
// 认证端点:公开(未登录也要能调登录接口)
|
||||
"/api/auth/login": {},
|
||||
"/api/auth/logout": {},
|
||||
// GraphQL 同域代理:登录即可(细粒度由后端 resolver 把关)
|
||||
"/api/graphql": {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 5. 公共路由白名单(P0-2,ARCHITECTURE.md §6.2 §3.4 V3-A3 / §11.7 红线 #5)
|
||||
*
|
||||
* 这些路由允许匿名访问(登录前/无身份也能访问)。
|
||||
* middleware 对白名单路由跳过身份校验,直接放行。
|
||||
*
|
||||
* 注意:白名单外的路由,未登录访问 → middleware 重定向到 /login。
|
||||
*/
|
||||
export const PUBLIC_ROUTES: readonly string[] = [
|
||||
"/",
|
||||
"/login",
|
||||
"/shell/forbidden",
|
||||
"/api/health",
|
||||
"/api/healthz",
|
||||
"/api/ready",
|
||||
"/api/log",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/graphql",
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验权限配置的合法性(开发时辅助)
|
||||
*
|
||||
@@ -343,6 +373,11 @@ export function checkRoutePermission(
|
||||
userBitmap: string,
|
||||
userRole: Role,
|
||||
): RoutePermissionResult {
|
||||
// 0. 公共路由白名单优先(含 /shell/forbidden 自身,避免循环重定向)
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
return { allowed: true, matchedPath: "PUBLIC" };
|
||||
}
|
||||
|
||||
// 1. 匹配精确路由
|
||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||
if (exactConfig) {
|
||||
@@ -368,14 +403,23 @@ export function checkRoutePermission(
|
||||
if (apiConfig) {
|
||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||
}
|
||||
// 未配置的 API 路由默认拒绝
|
||||
// 未配置的 API 路由默认拒绝(fail-closed,§11.7 红线 #5)
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
||||
// 5. /shell/** 下未登记路由 → 默认拒绝(fail-closed,§3.4 V3-A3 / §11.7 红线 #5)
|
||||
// 防止"幽灵路由"绕过门禁;新增路由必须显式登记到 EXACT/PREFIX/DASHBOARD 表
|
||||
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "no_config",
|
||||
};
|
||||
}
|
||||
|
||||
// 6. 其他未匹配路由(如 /favicon.ico / 静态资源)默认放行
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user