From f7e52b5b7ff77fd819e47a46156447452ad42685 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:47:40 +0800 Subject: [PATCH] docs(portal-shell): update README to v1.1 with data layer and GraphQL hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版本 1.0 -> 1.1,日期 2026-07-17 - 新增 §13 数据访问层与 GraphQL 安全栈(6 子节) - 更新 §5/§9.6/§10/§11/§12/附录 A/B/C - 修正 004 §16.5 测试数(admin 31->4,sidebar 5->9) - arch:scan 通过(TS 20 模块/4803 符号) --- apps/portal-shell/README.md | 1208 +++++++++++++++++ .../004_architecture_impact_map.md | 12 +- 2 files changed, 1214 insertions(+), 6 deletions(-) create mode 100644 apps/portal-shell/README.md diff --git a/apps/portal-shell/README.md b/apps/portal-shell/README.md new file mode 100644 index 0000000..5f97c31 --- /dev/null +++ b/apps/portal-shell/README.md @@ -0,0 +1,1208 @@ +# portal-shell 模块架构文档 + +> 版本:1.1 +> 日期:2026-07-17 +> 状态:已落地(v2.1 M8-M12 完成 + 2026-07-17 数据抽象与 GraphQL 加固 M1-M4 完成) +> 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘) +> 关联文档: +> +> - [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md) §1.2/§3/§4/§11.7/§16 +> - [portal-shell 仪表盘 spec](../../docs/superpowers/specs/2026-07-14-portal-shell-widget-dashboard-design.md) v2.1 +> - [portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) v1.0 +> - [项目规则](../../.trae/rules/project_rules.md) §3.10 设计令牌、§14 多 AI 协作 +> - [UI 设计系统](../../docs/standards/ui-design-system.md) +> - [known-issues §1.11/§2.17](../../docs/troubleshooting/known-issues.md) +> - [GraphQL @auth 审计报告](../../docs/security/graphql-auth-audit-2026-07.md) + +--- + +## 目录 + +1. [引言与目标](#1-引言与目标) +2. [架构约束](#2-架构约束) +3. [系统范围与上下文(C4 L1)](#3-系统范围与上下文c4-l1) +4. [解决方案策略](#4-解决方案策略) +5. [构建块视图(C4 L2/L3)](#5-构建块视图c4-l2l3) +6. [插件目录与分类](#6-插件目录与分类) +7. [运行时视图(关键场景)](#7-运行时视图关键场景) +8. [部署视图](#8-部署视图) +9. [横切概念](#9-横切概念) +10. [架构决策记录(ADR 索引)](#10-架构决策记录adr-索引) +11. [质量要求与验收](#11-质量要求与验收) +12. [风险、技术债与演进路线](#12-风险技术债与演进路线) +13. [数据访问层与 GraphQL 安全栈(v1.1 新增)](#13-数据访问层与-graphql-安全栈v11-新增) +14. [术语表](#14-术语表) + +--- + +## 1. 引言与目标 + +### 1.1 模块定位 + +portal-shell 是 Edu 平台 v2.1 架构重设计后的**唯一前端入口**,以单 Next.js App Router 容器替代 v1.0 的 4 个独立 portal(teacher / student / parent / admin)+ Module Federation 微前端方案。它承载教师、学生、家长、管理员四类角色的全部教学场景 UI,通过 **Micro-kernel + 插件化** 机制实现功能扩展。 + +- **服务端口**:4010(HTTP) +- **技术栈**:TypeScript 5.6 + Next.js 14 App Router + React 18 + Tailwind + Zustand + SWR + Apollo Client +- **架构风格**:Modular Monolith(单体)+ Micro-kernel(微内核插件) +- **部署形态**:单 Docker 容器(`output: "standalone"`) +- **上游依赖**:api-gateway(JWT 校验 + 反向代理)、apollo-router(GraphQL 联邦入口) +- **下游契约**:通过 apollo-router 查询 7 个业务子图(iam / config-service / core-edu / content / msg / data-ana / ai) + +### 1.2 设计目标 + +| # | 目标 | 衡量标准 | +| --- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| G1 | **单服务部署**:一个 Dockerfile、一个容器,无 MF 远程加载 | 容器数 = 1;无运行时远程 bundle | +| G2 | **配置驱动可见性**:admin 改配置 → 用户刷新生效,无需重新部署 | 配置变更感知延迟 ≤ 5 分钟(SWR refreshInterval) | +| G3 | **首屏秒开**:RSC 服务端预取 Config + initialData,消除 CSR 瀑布流 | LCP < 2s(本地 Docker) | +| G4 | **插件强隔离**:插件间禁止直接 import,仅通过 URL/Zustand 共享状态 | ESLint `no-restricted-imports` 强制;arch:scan 违规检测 | +| G5 | **设计系统一致**:所有插件使用 `@edu/ui-tokens`,禁硬编码颜色/字体/字号 | ESLint `no-restricted-syntax` + `design-tokens/no-hardcoded-fonts` 零违规 | +| G6 | **按需加载**:dynamic import 懒加载,首屏只加载可见 slot 插件 | 首屏 JS bundle ≤ 300KB(gzip) | +| G7 | **跨角色复用**:universal 插件按 role 渲染不同视图,一套代码服务多角色 | universal 插件复用率 100% | + +### 1.3 非目标 + +- 不重写后端微服务、BFF、网关 +- 不实现插件拖拽编辑器(canvas 模板预留接口,MVP 不做拖拽) +- MVP 不实现第三方插件上传(二期,预留 iframe + postMessage 沙箱方案) +- 不实现插件市场在线商店(二期仅支持本地 ZIP 上传) + +--- + +## 2. 架构约束 + +### 2.1 全局约束(来自项目规则) + +| 约束 | 来源 | portal-shell 落地方式 | +| ------------------------------- | ------------------- | ----------------------------------------------------------------- | +| 禁硬编码颜色(`#hex`) | project_rules §3.10 | ESLint `no-restricted-syntax` + Tailwind `bg-*` 类 | +| 禁硬编码字体(`'Inter'`) | project_rules §3.10 | `next/font/google` + CSS 变量 `--font-family-*` | +| 禁 Tailwind 任意值(`w-[Npx]`) | project_rules §3.10 | 映射到 `--space-*` 或 Tailwind 默认阶梯 | +| 函数返回值显式标注 | project_rules §3.4 | 所有插件 `React.ReactElement` / `Promise` | +| 禁 `any` / `as` 断言 | project_rules §3.4 | `unknown` + 类型守卫;测试外不写 `as` | +| Controller 权限装饰器 | project_rules §3.8 | portal-shell 无 Controller,权限由 api-gateway 注入 `x-user-role` | +| ESM `.js` 后缀导入 | project_rules §3.4 | `next.config.js` webpack `extensionAlias` 映射 | + +### 2.2 模块边界(多 AI 协作) + +- **只修改**:`apps/portal-shell/` 目录 +- **只读引用**: + - `packages/shared-ts/src/contracts/`(plugin.ts / layout.ts / plugin-store.ts / plugin-context.ts) + - `packages/ui-components/`、`packages/ui-tokens/`、`packages/hooks/` + - `packages/shared-proto/proto/`(仅查询字段命名对齐) +- **跨模块契约**:通过 apollo-router GraphQL 子图(不直接调 gRPC,不直接访问业务服务 DB) +- **禁止**: + - 直接 import 其他 `apps/*` 或 `services/*` 的源码 + - 直接访问 MySQL / Redis / Kafka + - 在插件间直接 import(必须通过 URL Search Params 或 Zustand Store 共享状态) + +### 2.3 通信约束 + +| 调用方 → 被调用方 | 协议 | 场景 | +| ------------------------------- | -------------- | ------------------------------------------------------------- | +| portal-shell → api-gateway | HTTP 反向代理 | `/api/v1/*` 透传(JWT 校验 + 注入 `x-user-id`/`x-user-role`) | +| portal-shell → apollo-router | HTTP / GraphQL | 所有业务查询与配置查询(M8 验收点) | +| portal-shell → realtime-gateway | SSE | 通知推送(默认) | +| portal-shell → realtime-gateway | WebSocket | 监考双向场景(仅此场景用 WS) | + +**禁止**:portal-shell 直连业务服务 gRPC 或 DB。 + +--- + +## 3. 系统范围与上下文(C4 L1) + +### 3.1 Context 图 + +```mermaid +graph TB + subgraph Users["用户"] + Teacher["教师"] + Student["学生"] + Parent["家长"] + Admin["管理员"] + end + + subgraph PortalShell["portal-shell :4010"] + Shell["插件化仪表盘
Modular Monolith + Micro-kernel"] + end + + subgraph Upstream["上游服务"] + Gateway["api-gateway :8080
JWT 校验 + 反向代理"] + Router["apollo-router :3000
GraphQL 联邦入口"] + Realtime["realtime-gateway :8081
SSE 推送"] + end + + subgraph Subgraphs["业务子图(经 Router 聚合)"] + IAM["iam"] + Config["config-service"] + CoreEdu["core-edu"] + Content["content"] + Msg["msg"] + DataAna["data-ana"] + AI["ai"] + end + + Teacher --> Shell + Student --> Shell + Parent --> Shell + Admin --> Shell + + Shell -->|HTTP /api/v1/*| Gateway + Shell -->|GraphQL 查询| Router + Shell -->|SSE 通知| Realtime + + Gateway -->|JWT 校验 + 注入头| Shell + Router --> IAM + Router --> Config + Router --> CoreEdu + Router --> Content + Router --> Msg + Router --> DataAna + Router --> AI +``` + +### 3.2 外部契约 + +| 契约 | 提供方 | 消费方式 | 说明 | +| -------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------- | ---------------------------- | +| `pluginConfig(userId, role)` | config-service 子图 | RSC 服务端查询 + SWR 客户端静默刷新 | 三层合并后的插件配置 | +| `pluginRegistry` / `rolePluginMapping` / `layoutTemplates` / `roleLayoutDefault` | config-service 子图 | admin 配置面板(plugin-manager 插件) | admin CRUD | +| `resetUserLayoutOverride(userId)` | config-service 子图 | admin 重置用户布局 | mutation | +| 业务查询(grades / homework / schedule 等) | core-edu / content / msg 等子图 | 各 widget 插件 `useWidgetQuery` | 经 apollo-router 自动路由 | +| `x-user-id` / `x-user-role` 请求头 | api-gateway | RSC `headers()` 读取 | JWT 校验后注入 | +| JWT | iam 签发 → api-gateway 校验 | Apollo Client `Authorization: Bearer` | localStorage + cookie 双通道 | + +--- + +## 4. 解决方案策略 + +### 4.1 核心策略:Micro-kernel + Plugin Registry + +``` +┌──────────────────────────────────────────────────────────┐ +│ Shell(微内核,纯宿主,无业务逻辑) │ +│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ +│ │ LayoutMgr │ │ SlotRend │ │ PluginLoader │ │ +│ │ 5 模板 │→ │ 按slot分发 │→ │ dynamic import │ │ +│ └────────────┘ └────────────┘ │ + ErrorBoundary │ │ +│ └────────────────────┘ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ +│ │ Registry │ │ PropsMerge │ │ PluginStore │ │ +│ │ 31 插件 │ │ 三层合并 │ │ Zustand UI 状态 │ │ +│ └────────────┘ └────────────┘ └────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + ↑ PluginProps 契约 ↑ +┌──────────────────────────────────────────────────────────┐ +│ Widgets(31 内置插件,按 7 分类组织) │ +│ universal / sidebar / topbar / teacher / student / ... │ +└──────────────────────────────────────────────────────────┘ +``` + +**关键决策**: + +1. **Shell 是纯宿主**:不含任何业务逻辑,只负责 Layout 框架、Config 下发、Registry 查表、Loader 挂载 +2. **插件即卡片**:所有功能单元统一为"插件",无"卡片"与"插件"之分 +3. **编译时 Registry + 运行时 Config**:Registry 是 `plugin_id → dynamic import` 静态映射(编译时登记),Config 是"当前用户在哪些 slot 渲染哪些插件"的运行时配置(DB 三层合并) +4. **RSC 服务端预取**:Shell 在 Server Component 中完成 Config 拉取 + initialData 预取,随 HTML 直出 +5. **dynamic import 懒加载**:插件按需加载,首屏只加载可见 slot 的插件 +6. **强隔离**:插件间禁止直接 import,通过 URL Search Params(可分享)+ Zustand(纯 UI)共享状态 + +### 4.2 数据流分层 + +``` +URL Search Params ← 可分享、可前进后退的全局上下文(classId/childId/termId/view) +Zustand Store ← 纯 UI、不可分享(theme/locale/sidebarCollapsed) +插件内部 useState ← 插件私有状态(表单临时值、弹窗开关) +PluginProps.props ← 三层合并后的配置 props(系统默认 < 角色默认 < 用户调整) +PluginProps.initialData ← RSC 服务端预取的初始数据(SWR fallbackData) +``` + +### 4.3 配置刷新策略 + +抛弃 Kafka + WebSocket 推送链路(对低频布局变更属于过度设计),改用 **SWR 静默后台刷新**: + +- `revalidateOnFocus: true`:用户切回 Tab 时静默刷新 +- `refreshInterval: 300_000`:每 5 分钟轮询 +- `dedupingInterval: 60_000`:1 分钟内去重 +- 检测到配置变化时回调上层 Toast 提示"发现新布局配置,刷新后生效" + +--- + +## 5. 构建块视图(C4 L2/L3) + +### 5.1 Container 图(C4 L2) + +```mermaid +graph TB + subgraph App["apps/portal-shell(单 Next.js 容器)"] + subgraph AppRouter["app/(Next.js App Router)"] + Layout["layout.tsx
RootLayout + 字体"] + RootPage["page.tsx
重定向 /shell"] + ShellPage["shell/[[...route]]/page.tsx
RSC 入口"] + HealthAPI["api/health/route.ts"] + ReadyAPI["api/ready/route.ts"] + end + + subgraph ShellCore["shell/(微内核)"] + Shell["Shell.tsx"] + ClientShell["ClientShell.tsx"] + LayoutMgr["LayoutManager.tsx
5 Layout 模板"] + SlotRend["SlotRenderer.tsx"] + Loader["PluginLoader.tsx
+ ErrorBoundary"] + Registry["Registry.tsx
31 插件"] + Lifecycle["PluginLifecycle.ts"] + Store["PluginStore.ts
Zustand"] + Merger["PropsMerger.ts
三层合并"] + end + + subgraph Lib["lib/(数据抽象)"] + Apollo["apollo-client.ts
APQ + sha256"] + ConfigFetch["config-fetcher.ts"] + UseConfig["usePluginConfig.ts
SWR 静默刷新"] + UseQuery["useWidgetQuery.ts"] + UseMut["useWidgetMutation.ts"] + Types["types.ts"] + end + + subgraph ApiLayer["lib/api/(v1.1 数据访问层)"] + ApiDomain[".ts ×7
parent/teacher/admin/student/
universal/sidebar/topbar"] + ApiOps["operations/*.graphql.ts
51 DocumentNode"] + ApiTypes["operations/types.ts
codegen 生成"] + ApiErrors["errors.ts
ApiError 归一化"] + end + + subgraph Providers["providers/"] + ApolloProv["ApolloProvider.tsx"] + AuthProv["AuthProvider.tsx"] + ThemeProv["ThemeI18nProvider.tsx"] + end + + subgraph Widgets["widgets/(31 内置插件)"] + Universal["universal/
7 插件"] + Sidebar["sidebar/
4 插件"] + Topbar["topbar/
4 插件"] + Teacher["teacher/
4 插件"] + Student["student/
4 插件"] + Parent["parent/
2 插件"] + Admin["admin/
6 插件"] + end + end + + ShellPage -->|RSC 调用| ConfigFetch + ShellPage -->|props| ClientShell + ClientShell --> Shell + Shell --> LayoutMgr + LayoutMgr --> SlotRend + SlotRend --> Registry + SlotRend --> Loader + Loader --> Widgets + ClientShell --> UseConfig + UseConfig --> Apollo + Widgets --> ApiDomain + ApiDomain --> ApiOps + ApiDomain --> UseQuery + ApiDomain --> UseMut + ApiOps --> ApiTypes + UseQuery --> Apollo + UseMut --> Apollo +``` + +### 5.2 组件职责矩阵 + +| 层 | 文件 | 职责 | 关键契约 | +| -------------- | ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------ | +| **app/** | `shell/[[...route]]/page.tsx` | RSC 入口,服务端拉 Config + 预取 initialData | `fetchPluginConfig(userId, role)` | +| **app/** | `layout.tsx` | RootLayout,挂载 `next/font/google` 字体变量 | `--font-inter` / `--font-fraunces` / `--font-jetbrains-mono` | +| **app/** | `page.tsx` | 根路径重定向到 `/shell` | `redirect("/shell")` | +| **shell/** | `Shell.tsx` | 微内核入口,选择 LayoutManager 模板 | `config.activeLayout.layoutId` | +| **shell/** | `ClientShell.tsx` | 客户端入口,挂载 Providers + SWR 配置刷新 | `usePluginConfig(initialConfig)` | +| **shell/** | `LayoutManager.tsx` | 5 种 Layout 模板渲染器 | classic / focus / split / triple / canvas | +| **shell/** | `SlotRenderer.tsx` | 按 slot 过滤+排序+查表+注入 PluginProps | `PluginPlacement[]` | +| **shell/** | `PluginLoader.tsx` | dynamic loading 骨架 + ErrorBoundary 隔离 | `PluginSkeleton` 5 变体 | +| **shell/** | `Registry.tsx` | 编译时登记 31 内置插件 | `plugin_id → { Component, metadata }` | +| **shell/** | `PluginLifecycle.ts` | 版本兼容性 + 激活路径 + 可渲染判断 | `checkVersionCompatibility` / `isPluginRenderable` | +| **shell/** | `PluginStore.ts` | Zustand 全局 UI 状态 | theme / locale / sidebarCollapsed | +| **shell/** | `PropsMerger.ts` | 三层 props 深合并 | `mergeProps(sys, role, user)` | +| **lib/** | `apollo-client.ts` | Apollo Client 单例(客户端)+ 工厂(服务端)+ APQ 链 | `getApolloClient()` / `createApolloClient()`,`createPersistedQueryLink({ sha256 })` | +| **lib/** | `config-fetcher.ts` | RSC 服务端拉 Config,含三级降级 | apollo-router → config-service 直连 → 空默认 | +| **lib/** | `usePluginConfig.ts` | SWR 静默刷新配置 + 变更检测回调 | `revalidateOnFocus` + `refreshInterval: 300_000` | +| **lib/** | `useWidgetQuery.ts` | 统一 GraphQL 查询 Hook,支持 fallbackData | `useQuery` + `skip` + `pollInterval` | +| **lib/** | `useWidgetMutation.ts` | 统一 GraphQL 变更 Hook | `useMutation` + `errorPolicy: "all"` | +| **lib/** | `types.ts` | 共享类型契约 | `PluginProps` / `PluginManifest` / `PluginConfigResponse` | +| **lib/api/** | `.ts ×7` | 语义化函数 API 层(v1.1) | `useMyChildren()` / `saveLessonPlan()` 等,禁止 widget 直接写 gql | +| **lib/api/** | `operations/*.graphql.ts` | gql DocumentNode 集中存放(v1.1) | 51 个 query/mutation 常量 | +| **lib/api/** | `operations/types.ts` | codegen 生成的 TS 类型(v1.1) | 从 7 子图 schema.graphql 生成 | +| **lib/api/** | `errors.ts` | ApiError 归一化(v1.1) | `toApiError(graphQLErrors)` 统一错误模型 | +| **providers/** | `ApolloProvider.tsx` | 注入 Apollo Client 单例 | `getApolloClient(readToken)` | +| **providers/** | `AuthProvider.tsx` | 提供 `useAuth()`,从 RSC props 下发用户身份 | `AuthUser` context | +| **providers/** | `ThemeI18nProvider.tsx` | 主题类名同步到 `` + 简易 i18n | `usePluginStore` theme/locale | +| **widgets/** | `*/index.tsx` | 插件入口,接收 `PluginProps`,default export | `PluginProps` 契约 | +| **widgets/** | `*/plugin.manifest.ts` | 插件元数据声明 | `manifestMeta: Omit` | + +### 5.3 Layout 模板清单 + +| layoutId | 显示名 | 布局 | 可用 slots | 适用场景 | +| --------- | -------- | ------------------------------------------------- | ---------------------------- | -------------- | +| `classic` | 经典三栏 | TopBar + SideNav + Main | top / side / main | 默认(仪表盘) | +| `focus` | 聚焦 | TopBar + 全宽 Main | top / main | 备课/编辑器 | +| `split` | 双栏 | TopBar + 左右等分 Main | top / main-left / main-right | 对比/批改 | +| `triple` | 三栏内容 | TopBar + SideNav + Main + RightRail | top / side / main / right | 数据分析 | +| `canvas` | 自由画布 | TopBar + 自由摆放(MVP 按 grid 排列,不实现拖拽) | top / canvas-grid | 个性化 | + +### 5.4 三层配置模型 + +**优先级**:用户覆盖 > 角色模板 > 系统默认 + +``` +Layer 1: 系统默认(plugin_registry 表) + ← 开发者在 plugin.manifest.ts 声明 defaultProps + ← 构建时由 sync-builtin-plugins.ts 同步到 DB + +Layer 2: 角色模板(role_plugin_mapping 表) + ← admin 通过 plugin-manager 插件配置角色可用插件集 + 角色级 props + +Layer 3: 用户覆盖(user_layout_override 表) + ← 用户切换 Layout、调整插件位置、隐藏插件、调整 props + ← 仅限角色可用集内操作 +``` + +**Props 合并算法**(`PropsMerger.mergeProps`): + +- 普通对象递归合并(深合并) +- 数组、原始值后者覆盖前者 +- `null` / `undefined` 跳过 + +--- + +## 6. 插件目录与分类 + +### 6.1 内置插件全览(31 个) + +| 分类 | 数量 | 插入 slot | 跨角色 | 插件清单 | +| ----------- | ---- | --------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `universal` | 7 | main-* | 是 | grades-widget / homework-widget / schedule-widget / attendance-widget / exams-widget / notifications-widget / announcements-widget | +| `sidebar` | 4 | side | 部分 | class-selector / child-selector / term-switcher / quick-actions | +| `topbar` | 4 | top | 是 | notification-bell / user-menu / global-search / locale-switcher | +| `teacher` | 4 | main | 否 | lesson-plan-editor / question-bank / textbook-manager / scheduling-rules | +| `student` | 4 | main | 否 | error-book / learning-path / elective-selector / ai-tutor | +| `parent` | 2 | main | 否 | child-overview / leave-approval | +| `admin` | 6 | main | 否 | user-management / rbac-manager / plugin-manager / school-settings / audit-logs / invitation-codes | + +### 6.2 插件契约(PluginProps) + +```typescript +interface PluginProps> { + instanceId: string; // 插件实例 ID(同插件多实例时区分) + role: "admin" | "teacher" | "student" | "parent"; + user: { + id: string; + name: string; + email: string; + dataScope: string; // IAM DataScope 6 级 + }; + slot: { + name: string; // 'main' | 'side' | 'top' | 'main-left' | ... + layoutId: string; // 'classic' | 'focus' | ... + size?: { colSpan: number; rowSpan: number }; + }; + props: TProps; // 三层合并后的最终 props + initialData?: unknown; // RSC 服务端预取的初始数据 +} +``` + +### 6.3 插件 Manifest 契约 + +每个插件通过 `plugin.manifest.ts` 声明元数据: + +```typescript +export const manifestMeta: Omit = { + pluginId: "grades-widget", + version: "0.1.0", + requiredShellVersion: "^1.0.0", // semver range,Shell 启动时校验 + metadata: { + displayName: "成绩", + description: "查看班级成绩", + category: "universal", + requiredRoles: ["teacher", "student", "parent"], + defaultSlot: "main", + defaultSize: { colSpan: 2, rowSpan: 1 }, + defaultProps: { limit: 20 }, + propsSchema: { + // JSON Schema,admin 配置面板自动渲染表单 + type: "object", + properties: { + limit: { type: "number", description: "显示条数" }, + }, + }, + }, +}; +``` + +### 6.4 插件生命周期 + +| 阶段 | 内置插件 | 第三方插件(二期) | +| ------------- | ------------------------------ | ------------------------------------- | +| `registered` | 编译时登记到 Registry | 安装时登记到 DB(plugin_packages 表) | +| `enabled` | admin 通过 config-service 启用 | admin 启用 | +| `loaded` | dynamic import 加载 | iframe + postMessage 沙箱加载 | +| `active` | 渲染并挂载 | 渲染并挂载 | +| `disabled` | admin 禁用,不渲染 | admin 禁用,不渲染 | +| `uninstalled` | 不可卸载(内置) | admin 卸载,删除包 | + +--- + +## 7. 运行时视图(关键场景) + +### 7.1 场景一:首屏加载(RSC 服务端预取) + +**目标**:消除 CSR 瀑布流,实现仪表盘"秒开" + +```mermaid +sequenceDiagram + participant U as 用户浏览器 + participant N as Next.js RSC + participant R as apollo-router + participant C as config-service + participant S as 业务子图 + + U->>N: GET /shell + N->>N: headers() 读取 x-user-id / x-user-role + N->>R: query pluginConfig(userId, role) + R->>C: 路由到 config-service 子图 + C-->>R: 三层合并后的 PluginConfigResponse + R-->>N: Config(activeLayout + plugins[]) + N->>N: PropsMerger 解析 propsJson / sizeJson + N->>R: 并发预取各插件 initialData(Promise.all) + R->>S: 路由到对应子图 + S-->>R: 插件初始数据 + R-->>N: initialData[] + N-->>U: HTML 直出(含 Config + initialData) + U->>U: 水合 → dynamic import 插件组件 + U->>U: 插件用 initialData 作为 SWR fallbackData 渲染 +``` + +**对比 v2.0 CSR 瀑布流**: + +``` +v2.0(4 层串行,LCP 差): + HTML 骨架 → 水合 → 拉 Config → import 插件 → 插件拉数据 → 渲染 + 总耗时 = SSR + 水合 + Config RTT + import RTT + BFF RTT + +v2.1 RSC 预取(2 层并行,LCP 秒开): + 服务端:RSC 拉 Config + 并发预取 initialData → HTML 直出 + 客户端:水合 → dynamic import → 用 initialData 渲染 + 总耗时 = max(Config RTT, BFF RTT) + 水合 + import RTT +``` + +### 7.2 场景二:配置变更生效(SWR 静默刷新) + +```mermaid +sequenceDiagram + participant A as Admin + participant PM as plugin-manager 插件 + participant R as apollo-router + participant C as config-service + participant U as 用户浏览器 + participant SWR as SWR 缓存 + + A->>PM: 修改角色插件映射 + PM->>R: mutation updateRolePluginMapping + R->>C: 路由到 config-service + C-->>R: 更新成功 + R-->>PM: 返回结果 + + Note over U,SWR: 用户侧(异步感知) + U->>U: 切回 Tab(revalidateOnFocus)
或 5 分钟轮询触发 + SWR->>R: query pluginConfig(userId, role) + R->>C: 路由到 config-service + C-->>R: 新配置 + R-->>SWR: 返回新配置 + SWR->>SWR: hasConfigChanged(prev, next) 检测变化 + SWR->>U: 回调 onChanged → Toast 提示 + U->>U: 用户点击"刷新" → window.location.reload() + U->>N: 重新走 RSC 预取流程,加载新配置 +``` + +### 7.3 场景三:跨插件状态共享(URL 驱动) + +**示例**:class-selector 切换班级 → grades-widget 自动响应 + +```mermaid +sequenceDiagram + participant CS as class-selector 插件 + participant URL as URL Search Params + participant GW as grades-widget 插件 + participant R as apollo-router + + CS->>CS: 用户选择班级 "三年二班" + CS->>URL: router.push('?classId=cls-123') + Note over URL: URL 变更触发 React 重渲染 + URL->>GW: useSearchParams() 返回新 classId + GW->>GW: useWidgetQuery(GET_GRADES, { classId }) + GW->>R: query grades(classId: "cls-123") + R-->>GW: 返回成绩数据 + GW->>GW: 重新渲染表格 +``` + +**收益**: + +- 无 EventBus 发布订阅黑盒 +- URL 可分享(用户可复制链接定位特定班级视图) +- 浏览器前进后退天然支持 +- React DevTools 可追踪状态变更 + +### 7.4 场景四:插件加载失败(ErrorBoundary 隔离) + +```mermaid +graph TB + A[SlotRenderer 渲染插件] --> B{dynamic import 成功?} + B -- 是 --> C[组件渲染] + B -- 否 --> D[PluginErrorBoundary 捕获] + D --> E[PluginErrorFallback 显示] + E --> F[显示错误图标 + instanceId] + E --> G[重试按钮] + G --> H[重置 hasError=false 重新加载] + + I[其他插件] -.->|不受影响| J[正常渲染] +``` + +**关键约束**:单个插件失败不影响其他插件,ErrorBoundary 隔离作用域。 + +--- + +## 8. 部署视图 + +### 8.1 容器化 + +```mermaid +graph LR + subgraph Build["构建阶段"] + Src["源码 apps/portal-shell/"] + Docker["Dockerfile
multi-stage"] + Img["本地镜像
(不推送 registry)"] + end + + subgraph Runtime["运行时(Docker Compose)"] + Container["portal-shell 容器 :4010"] + Next["Next.js standalone server"] + end + + subgraph Deps["依赖容器"] + Gateway["api-gateway :8080"] + Router["apollo-router :3000"] + Realtime["realtime-gateway :8081"] + end + + Src --> Docker + Docker --> Img + Img --> Container + Container --> Next + Next -->|HTTP /api/v1/*| Gateway + Next -->|GraphQL| Router + Next -->|SSE| Realtime +``` + +### 8.2 Dockerfile 要点 + +- **基础镜像**:`node:22-alpine`(CI 预拉,见 `infra/docker-compose.tools.yml`) +- **构建模式**:`output: "standalone"`(Next.js 内置,自动追踪依赖,产物在 `.next/standalone/`) +- **多阶段构建**:`deps` → `builder` → `runner` +- **依赖处理**: + - `COPY tsconfig.base.json*` 确保 TS 类型解析 + - `transpilePackages: ["@edu/ui-components", "@edu/ui-tokens", "@edu/hooks", "@edu/shared-ts"]` 编译 workspace 包 + - `pnpm install --no-frozen-lockfile` 解决 workspace 符号链接问题 +- **端口**:4010 +- **健康检查**:`/api/health`(liveness)+ `/api/ready`(readiness) + +### 8.3 环境变量 + +| 变量 | 用途 | 默认值 | +| ------------------------------- | ------------------------------------------------------- | ------------------------------- | +| `NEXT_PUBLIC_APOLLO_ROUTER_URL` | apollo-router GraphQL 端点(客户端) | `http://localhost:3000/graphql` | +| `APOLLO_ROUTER_URL` | apollo-router GraphQL 端点(服务端 RSC) | 同上 | +| `API_GATEWAY_URL` | api-gateway 反向代理目标 | `http://localhost:8080` | +| `CONFIG_SERVICE_URL` | 开发态降级:直连 config-service GraphQL(生产不应设置) | 未设置 | +| `NEXT_PUBLIC_DEV_MODE` | 开发模式:绕过 JWT,接受 `dev-token` + 预定义角色 | `false` | + +### 8.4 CI/CD + +- **流水线**:`.github/workflows/ci.yml` 的 `quality-ts` job +- **触发**:分支 push / PR(质量检查)、合并到 main(部署) +- **质量门禁**:`pnpm lint` + `tsc --noEmit` + `vitest run` + `next build` +- **部署**:`docker compose up -d --build`(no-push 本地构建模式,见 project_rules §15) +- **回滚**:`git revert + push` 或 `workflow_dispatch` 指定 `commit_sha` + +--- + +## 9. 横切概念 + +### 9.1 设计令牌(强制) + +**三层令牌模型**(project_rules §3.10): + +| Layer | 用途 | 位置 | 业务代码引用 | +| ----------------- | ----------------------- | ------------------------------------------- | ---------------------------------- | +| L1 Primitive | 原始色板/字号/间距/阴影 | `packages/ui-tokens/src/primitive.css` | ❌ 禁止直接引用 | +| L2 Semantic | 语义令牌(light/dark) | `packages/ui-tokens/src/semantic-*.css` | ✅ 唯一引用入口(`hsl(var(--*))`) | +| L3 Tailwind Theme | 暴露为 Tailwind 类 | `packages/ui-tokens/src/tailwind-theme.css` | ✅ `bg-*` / `text-*` / `font-*` | + +**ESLint 强制约束**: + +- `no-restricted-syntax`:禁止 `#hex` 字面量 +- `design-tokens/no-hardcoded-fonts`:禁止 `'Inter'` / `'Fraunces'` 字面量 +- 白名单:`primitive.css`、`email-channel`、`manifest.ts` + +**portal-shell 落地**: + +- 字体通过 `next/font/google` self-host,CSS 变量暴露为 `--font-inter` / `--font-fraunces` / `--font-jetbrains-mono` +- 所有插件使用 Tailwind 类(`bg-surface` / `text-ink` / `border-rule` / `rounded-card` 等) +- `tailwind.config.js` 映射 semantic 令牌到 Tailwind 类 + +### 9.2 字体策略 + +| 字体族 | 用途 | CSS 变量 | Tailwind 类 | +| -------------- | --------------------- | --------------------- | ------------ | +| Inter | UI 文本(sans-serif) | `--font-family-sans` | `font-sans` | +| Fraunces | 主标题/正文(serif) | `--font-family-serif` | `font-serif` | +| JetBrains Mono | 代码/等宽 | `--font-family-mono` | `font-mono` | + +### 9.3 纸感编辑器设计风格 + +参考 `docs/standards/ui-design-system.md`: + +- **背景**:纸感 `hsl(var(--paper))` / `hsl(var(--surface))` +- **圆角**:`var(--radius-card)` / `var(--radius-button)` +- **间距**:`var(--space-xs)` ~ `var(--space-xl)` +- **插件容器**:`
` +- **插件标题**:`text-heading-3 text-ink` +- **加载态**:`` +- **错误态**:`` + +### 9.4 可观测性 + +| 维度 | 实现 | 端点 | +| -------- | --------------------------------------------------------- | ---------------------------------- | +| 健康检查 | liveness + readiness | `/api/health` + `/api/ready` | +| 错误日志 | `console.error` + `PluginErrorBoundary componentDidCatch` | 浏览器控制台 | +| 性能 | Next.js 内置 Web Vitals(可接 OTel) | Next.js 自动采集 | +| 请求追踪 | Apollo Client 自动携带 traceparent | 经 api-gateway 注入 `X-Request-Id` | + +### 9.5 国际化(MVP) + +- **locale 管理**:`PluginStore.locale`(`zh-CN` / `en`) +- **locale 切换**:`locale-switcher` 插件(topbar) +- **翻译函数**:`useT()` 从 `ThemeI18nProvider` 导出 +- **翻译范围**:MVP 仅覆盖 Shell 框架文案(toggleSidebar / layout / empty),插件文案暂硬编码中文(二期接 next-intl) + +### 9.6 安全 + +| 维度 | 实现 | +| -------------------------------- | -------------------------------------------------------------------------------------------------------- | +| JWT 注入 | Apollo Client `setContext` 从 localStorage 读取,注入 `Authorization: Bearer` | +| Cookie | `credentials: "include"` 携带 httpOnly cookie | +| XSS 防护 | React 自动转义 + 禁止 `dangerouslySetInnerHTML` | +| CSRF | 依赖 SameSite=Strict cookie(api-gateway 配置) | +| 开发模式 | `NEXT_PUBLIC_DEV_MODE=true` 绕过 JWT,仅本地开发 | +| **APQ(v1.1)** | `createPersistedQueryLink({ sha256 })` 前端只发 query hash,env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭 | +| **PQ Manifest(v1.1)** | `public/pq-manifest.json` 51 query 的 `sha256 → query` 白名单,由 `scripts/generate-pq-manifest.ts` 生成 | +| **Router 强制 manifest(v1.1)** | `APOLLO_REQUIRE_PQ_MANIFEST=true` 时 router 拒绝未知 hash,entrypoint.sh 启动前校验文件存在性 | +| **深度/成本/批量限制(v1.1)** | apollo-router `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5` | +| **Introspection 控制(v1.1)** | `APOLLO_ROUTER_INTROSPECTION=false` 生产关闭 schema 内省 | +| **Router-Authorization(v1.1)** | 子图 `RouterAuthGuard` 校验 header,拒绝非 Router 的直接 GraphQL 请求 | +| **Resolver 字段级权限(v1.1)** | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点 | + +--- + +## 10. 架构决策记录(ADR 索引) + +portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为索引: + +| ADR | 决策 | 状态 | 关联 | +| ------- | ------------------------------------------------------- | --------- | ------- | +| ADR-033 | portal-shell 单容器 Modular Monolith 替代 4 portal + MF | ✅ 已落地 | M8-M10 | +| ADR-023 | apollo-router 替代 3 BFF 手写聚合 | ✅ 已落地 | M2/M9 | +| ADR-026 | config-service 从 iam 拆分,三层配置合并 | ✅ 已落地 | M3 | +| ADR-029 | SSE 优先替代 WebSocket 单向推送 | ✅ 已落地 | M7 | +| ADR-034 | Redis Pub/Sub 作为推送背板 | ✅ 已落地 | M7 | +| ADR-036 | Router-Authorization 信任凭证 | ✅ 已落地 | M1 | +| ADR-041 | ScopeToken 优化大规模 ID 列表 | ✅ 已落地 | M4 | +| ADR-042 | portal-shell 前端数据访问四层分层(2026-07-17) | ✅ 已落地 | v1.1 M1 | +| ADR-043 | PQ Manifest + APQ 安全加固(2026-07-17) | ✅ 已落地 | v1.1 M3 | + +### 10.1 模块级决策(未单独编号) + +| 决策 | 理由 | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| **dynamic import 替代 Module Federation** | 单体部署,无运行时远程加载,研发与运维复杂度最低 | +| **URL Search Params + Zustand 替代 EventBus** | 数据流向清晰、支持 React DevTools、URL 可分享、浏览器前进后退天然支持 | +| **SWR 静默刷新替代 Kafka+WebSocket 推送** | 低频布局变更无需实时推送,SWR 5 分钟轮询 + 切回 Tab 触发足够 | +| **RSC 服务端预取替代客户端拉取** | 消除 4 层 CSR 瀑布流,HTML 直出 Config + initialData,LCP 秒开 | +| **三层 props 合并(系统 < 角色 < 用户)** | 配置驱动可见性,admin 改配置无需重新部署 | +| **编译时 Registry + 运行时 Config 分离** | Registry 是静态映射(plugin_id → lazy 组件),Config 是动态配置(DB 三层合并) | +| **ErrorBoundary 隔离单插件失败** | 单个插件加载/渲染失败不影响其他插件 | +| **MVP 不实现第三方插件沙箱** | 单体应用直接 script 注入不信任代码等于交出主站权限,二期用 iframe + postMessage | +| **4 层数据访问分层(v1.1)** | Widget 内联 gql 字面量暴露 schema、难审计、难重构;抽取到 lib/api/ 四层架构集中管理(ADR-042) | +| **APQ + PQ Manifest(v1.1)** | 前端只发 query hash 防止 schema 探测;Router 白名单 manifest 拒绝未知 hash 任意查询(ADR-043) | +| **Router 深度/成本/批量限制(v1.1)** | 防止深度嵌套 / 高成本 / 批量查询 DoS,配置 max_depth=10 / max_cost=1000 / max_batch_size=5 | +| **Resolver 字段级 @RequirePermission(v1.1)** | 防止越权访问字段,每个 Resolver 必须声明权限点;50 resolver 审计后补齐 19 个 TS 守卫 | + +--- + +## 11. 质量要求与验收 + +### 11.1 功能验收 + +- [x] 5 种 Layout 模板可切换,slot 正确渲染 +- [x] 31 个内置插件全部加载正常(7 universal + 4 sidebar + 4 topbar + 4 teacher + 4 student + 2 parent + 6 admin) +- [x] admin 可通过 plugin-manager 插件配置角色插件集合(registry / mapping / layout / user 四 Tab) +- [x] admin 可配置角色默认 Layout 模板 +- [x] admin 可配置插件默认 props(JSON 编辑) +- [x] admin 可重置用户自定义布局 +- [x] admin 改配置 → SWR 静默刷新检测到变化 → Toast 提示用户刷新生效 +- [x] 插件加载失败显示错误兜底,不影响其他插件(PluginErrorBoundary) +- [x] 跨插件状态共享正常(class-selector 切换班级 → grades-widget 自动响应) +- [x] 插件 props 三层合并正确(PropsMerger) +- [x] RSC 服务端预取正常:首屏 HTML 直出 Config + initialData +- [x] useWidgetQuery 自动经 Apollo Client 路由到 apollo-router +- [x] URL Search Params 可分享、可前进后退 +- [x] config-fetcher 三级降级:apollo-router → config-service 直连 → 空默认 + +### 11.2 非功能验收 + +- [x] `pnpm run lint` + `pnpm run typecheck` 零错误 +- [x] `pnpm run test` 95/95 通过(v1.1:lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例) +- [x] 0 处 widget 内联 gql 字面量(v1.1 强制,arch:scan 违规检测) +- [x] 所有插件遵守设计令牌(ESLint 强制,无硬编码颜色/字体) +- [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制(v1.1) +- [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission(v1.1) +- [x] arch.db 更新,004 文档同步 +- [ ] Shell 首屏 LCP < 2s(需真实环境压测验证) +- [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证) +- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量,admin domain 仅 4 用例待补,插件组件测试待补) +- [ ] E2E 测试(tests/e2e/portal-shell.spec.ts,待补) +- [ ] 视觉回归测试(5 种 layout 截图,待补) +- [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env(v1.1 follow-up) + +### 11.3 测试矩阵 + +| 测试类型 | 范围 | 文件 | 状态 | +| -------- | ------------------------------------------- | --------------------------------------------- | ----------------------- | +| 单元测试 | PluginLifecycle 纯函数 | `src/shell/__tests__/PluginLifecycle.test.ts` | ✅ 12 用例 | +| 单元测试 | Registry 插件注册 | `src/shell/__tests__/Registry.test.ts` | ✅ 6 用例 | +| 单元测试 | plugin-context URL 上下文 | `src/lib/__tests__/plugin-context.test.ts` | ✅ 12 用例 | +| 单元测试 | lib/api universal domain | `src/lib/api/__tests__/universal.test.ts` | ✅ 6 用例(v1.1) | +| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例(v1.1) | +| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例(v1.1) | +| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例(v1.1) | +| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例(v1.1) | +| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例(v1.1) | +| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(v1.1,待补) | +| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例(v1.1) | +| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 | +| 单元测试 | 各插件组件渲染 | 待补 | 🚧 | +| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | `tests/e2e/portal-shell.spec.ts` | ⏳ | +| E2E | admin 改配置 → 用户刷新生效 | `tests/e2e/plugin-config.spec.ts` | ⏳ | +| E2E | apollo-router 拒绝 11 层嵌套查询 | `tests/e2e/graphql-depth-limit.spec.ts` | ⏳(v1.1) | +| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳(v1.1) | +| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ | + +--- + +## 12. 风险、技术债与演进路线 + +### 12.1 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +| -------------------------------- | -------------------- | ---------------------------------------------------------------------------------- | +| 插件数量增长导致首屏 bundle 过大 | 首屏加载慢 | dynamic import 按需加载 + IntersectionObserver 滚动加载 + 首屏只加载可见 slot | +| 单体架构插件间隐式耦合 | 维护困难 | ESLint 禁止跨 widgets 目录 import + arch:scan 违规检测 + 强制 URL/Zustand 共享状态 | +| 单角色专属功能受 props 契约约束 | 复杂功能实现受限 | PluginProps 设计灵活(initialData + props 任意 JSON);复杂功能在插件内部自行组织 | +| config-service 配置查询压力 | RSC 每次请求查询多表 | 复用 config-service Redis 缓存 + RSC `cache()` 去重 + SWR 客户端轮询自然刷新 | +| 插件 props 三层合并逻辑复杂 | props 不一致 | PropsMerger 集中实现 + 单元测试覆盖(待补) | +| admin 配置面板 propsSchema 复杂 | 表单体验差 | MVP 用 JSON textarea 编辑,二期接 react-jsonschema-form | +| 二期第三方插件沙箱 | 安全风险 | iframe + postMessage(最安全),禁止 same-origin,BFF 请求由 Shell 代理 | + +### 12.2 技术债 + +| # | 技术债 | 优先级 | 计划 | +| ---- | ---------------------------------------------------------------------------- | ------ | ------------------------------------------------- | +| TD-1 | PluginLifecycle 版本校验仅 major,未引入 semver 库 | 低 | MVP 够用,二期按需引入 `semver` | +| TD-2 | ThemeI18nProvider 仅覆盖 Shell 框架文案,插件文案硬编码中文 | 中 | 二期接 next-intl,提取到 messages/{en,zh-CN}.json | +| TD-3 | PropsMerger 单元测试待补 | 中 | 补充深合并 + 数组覆盖 + null 跳过用例 | +| TD-4 | 插件组件单元测试待补(仅 Registry 与 Lifecycle 有测试) | 中 | 补充各插件渲染 + loading + error 用例 | +| TD-5 | E2E 测试与视觉回归测试待补 | 高 | 接 Playwright + 5 种 layout 截图 | +| TD-6 | `prefetchPluginData` 服务端并发预取未实现(仅拉 Config,未预取 initialData) | 中 | RSC 中按 pluginId 分发预取,传入 fallbackData | +| TD-7 | canvas layout 仅按 grid 排列,未实现拖拽 | 低 | MVP 预留接口,二期按需实现 | +| TD-8 | 第三方插件沙箱未实现 | 低 | 二期 iframe + postMessage | + +### 12.3 演进路线 + +| 阶段 | 内容 | 状态 | +| ---------- | -------------------------------------------------------- | --------------------- | +| M8 | portal-shell 接入 apollo-router(RSC 预取 Config) | ✅ 完成 | +| M9 | 旧 BFF 下线(teacher/student/parent-bff) | ✅ 完成 | +| M10 | 旧 portal 下线(teacher/student/parent/admin-portal) | ✅ 完成 | +| v1.1 M1 | lib/api 四层架构 + 31 widget 迁移 | ✅ 完成(2026-07-17) | +| v1.1 M3 | GraphQL 安全加固(APQ + PQ Manifest + router limits) | ✅ 完成(2026-07-17) | +| v1.1 M4 | Resolver @RequirePermission 审计 + 补齐 | ✅ 完成(2026-07-17) | +| v1.1 FU-1 | 4 个 TS 子图 AuthMiddleware 覆盖 /graphql 路径 | ⏳ Follow-up | +| v1.1 FU-2 | Python 子图(data-ana/ai)补 @RequirePermission 基础设施 | ⏳ Follow-up | +| v1.1 FU-3 | admin domain 测试用例补齐(当前仅 4 用例) | ⏳ Follow-up | +| P1(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 | +| P2(二期) | 插件市场在线商店 | ⏳ 规划 | +| P3(二期) | canvas 拖拽编辑器 | ⏳ 规划 | +| P4(二期) | next-intl 完整 i18n | ⏳ 规划 | +| P5(二期) | 视觉回归测试自动化 | ⏳ 规划 | + +--- + +## 13. 数据访问层与 GraphQL 安全栈(v1.1 新增) + +> 来源:[portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) v1.0 +> 关联 ADR:ADR-042(前端数据访问四层分层)、ADR-043(PQ Manifest + APQ 安全加固) +> 关联 004:§11.7 portal-shell 前端数据访问层 + GraphQL 安全栈 + +### 13.1 问题背景 + +v1.0 portal-shell 的 widget 直接内联 `gql\`...\`` 字面量查询,存在三个核心问题: + +1. **Schema 泄露**:前端 bundle 包含明文 GraphQL 查询,攻击者可通过 DevTools 构造任意查询探测 schema +2. **查询碎片化**:31 个 widget 各自维护 gql 字符串,难审计、难重构、难统一优化 +3. **缺抽象**:widget 直接 import Apollo hooks,业务逻辑与传输层耦合 + +### 13.2 解决方案:4 层数据访问分层(ADR-042) + +```mermaid +graph TD + subgraph Widget["Widget 层(UI)"] + W[widgets/*.tsx
只关心渲染] + end + subgraph API["API 层(语义化函数)"] + A1[lib/api/parent.ts] + A2[lib/api/teacher.ts] + A3[lib/api/admin.ts] + A4[lib/api/student.ts] + A5[lib/api/universal.ts] + A6[lib/api/sidebar.ts] + A7[lib/api/topbar.ts] + end + subgraph Ops["Operations 层(gql 文档集中)"] + O[lib/api/operations/*.graphql.ts
51 个 DocumentNode 常量] + end + subgraph Hook["Hook 层(Apollo 封装)"] + H1[useWidgetQuery] + H2[useWidgetMutation] + end + subgraph Codegen["类型生成"] + C[graphql-codegen
7 子图 schema.graphql → types.ts] + end + W --> A1 & A2 & A3 & A4 & A5 & A6 & A7 + A1 & A2 & A3 & A4 & A5 & A6 & A7 --> O + A1 & A2 & A3 & A4 & A5 & A6 & A7 --> H1 & H2 + O --> C +``` + +**层职责矩阵**: + +| 层 | 位置 | 职责 | 禁止 | +| ---------- | --------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | +| Widget | `src/widgets/*.tsx` | UI 渲染、用户交互 | 内联 gql 字面量、直接 import Apollo hooks | +| API | `src/lib/api/.ts` | 语义化函数(`useMyChildren()` / `saveLessonPlan()` 等) | 写 gql 字符串、直接操作 Apollo cache | +| Operations | `src/lib/api/operations/*.graphql.ts` | gql DocumentNode 常量集中存放(51 个 query/mutation) | 包含业务逻辑 | +| Hook | `src/lib/useWidgetQuery/useWidgetMutation.ts` | Apollo useQuery/useMutation 封装 + ApiError 归一化 | 引用具体 domain | + +**7 个 domain API 文件**: + +| 文件 | Widget 数 | 主要场景 | +| -------------- | --------- | ---------------------------------------------------------------------------------- | +| `universal.ts` | 7 | 公告 / 通知 / 课表 / 作业 / 考试 / 成绩 / 考勤(多角色复用) | +| `sidebar.ts` | 3 | 当前用户 / 我的班级 / 学期列表(侧边栏) | +| `topbar.ts` | 2 | 搜索 / 通知铃铛(顶栏,`useNotificationBell` 限 N 条) | +| `teacher.ts` | 5 | 教材 / 教案 / 题目 / 课表规则 / 教案保存 / 课表更新 | +| `student.ts` | 5 | AI Tutor / 选修课 / 错题本 / 学习路径 / 错题掌握标记 | +| `parent.ts` | 3 | 我的子女 / 请假审批 / 请假驳回 | +| `admin.ts` | 11 | 用户 / 角色 / 权限 / 学校 / 插件注册 / 角色插件映射 / 布局模板 / 邀请码 / 审计日志 | + +**类型生成(graphql-codegen)**: + +- 配置:`apps/portal-shell/codegen.yml` +- schema 来源:7 个子图的 `services//src/graphql/generated/schema.graphql` +- 生成产物:`src/lib/api/operations/types.ts`(仅类型,无运行时代码) +- 关键配置:`skipDocumentsValidation: true`(避免子图未启动时 codegen 失败) +- schema 归一化:`scripts/normalize-schema.ts` 移除 federation 指令(`@key` / `@requires` / `@extends`)防止 codegen 误解析 + +### 13.3 GraphQL 安全栈(ADR-043) + +```mermaid +graph LR + subgraph FE["portal-shell(前端)"] + APQ[createPersistedQueryLink
sha256 query → hash] + end + subgraph Router["apollo-router :3000"] + Manifest[pq-manifest.json
hash → query 白名单] + Limits[limits.max_depth=10
max_cost=1000
max_batch_size=5] + Intro[introspection
环境变量控制] + end + subgraph Sub["子图 /graphql"] + Guard[RouterAuthGuard
+ @RequirePermission] + end + APQ -->|只发 hash| Manifest + Manifest -->|未知 hash 拒绝| APQ + Manifest --> Limits + Limits --> Intro + Intro --> Guard +``` + +**安全机制矩阵**: + +| 机制 | 位置 | 防御目标 | 配置 | +| ---------------------------------- | -------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | +| APQ(Automatic Persisted Queries) | `apps/portal-shell/src/lib/apollo-client.ts` | 前端只发 query hash,不发明文 query | `createPersistedQueryLink({ sha256 })`,env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭 | +| PQ Manifest | `apps/portal-shell/public/pq-manifest.json` | Router 仅解析白名单 hash,拒绝未知 hash 任意查询 | 51 个 query 的 `sha256 → query` 映射,由 `scripts/generate-pq-manifest.ts` 生成 | +| Router 强制 manifest | `infra/apollo-router/router.yaml` | 生产模式(`require_manifest: true`)拒绝未注册查询 | env `APOLLO_REQUIRE_PQ_MANIFEST=true`,entrypoint.sh 启动前检查文件存在性 | +| 深度限制 | `router.yaml` `limits.max_depth=10` | 防止深度嵌套查询 DoS | 11 层嵌套被 router 拒绝(`QUERY_DEPTH_EXCEEDED`) | +| 成本限制 | `router.yaml` `limits.max_cost=1000` | 防止高成本查询 DoS | 按字段复杂度评分累加 | +| 批量限制 | `router.yaml` `limits.max_batch_size=5` | 防止批量查询 DoS | 单次请求最多 5 个 query | +| Introspection 控制 | `router.yaml` `supergraph.introspection` | 生产关闭 introspection 防止 schema 泄露 | env `APOLLO_ROUTER_INTROSPECTION=false`(开发默认 true) | +| Router-Authorization 信任 | 子图 `RouterAuthGuard`(见 §5.5) | 防止绕过 Router 直接访问子图 | 子图校验 `Router-Authorization` header,拒绝非 Router 请求(ADR-036) | +| Resolver 字段级权限 | 子图 `@RequirePermission()` 装饰器 | 防止越权访问字段 | 每个 Resolver 必须声明权限点(见 §3.8) | + +### 13.4 PQ Manifest 生成流程 + +1. `pnpm --filter @edu/portal-shell run codegen` → 从 7 子图 schema 生成 TS 类型 +2. `pnpm --filter @edu/portal-shell run generate-pq-manifest` → 遍历 `lib/api/operations/index.ts` 中所有 DocumentNode,`print(doc)` 后 `sha256(query)` 生成映射,写入 `public/pq-manifest.json` +3. `prebuild` 钩子自动串联 codegen + generate-pq-manifest +4. Docker compose 挂载 `pq-manifest.json` 到 apollo-router `/etc/apollo-router/pq-manifest.json:ro` +5. apollo-router entrypoint.sh 启动前校验 manifest 存在性(`require_manifest=true` 时缺失即 exit 1) + +### 13.5 Resolver @RequirePermission 审计(M4) + +> 详见:[docs/security/graphql-auth-audit-2026-07.md](../../docs/security/graphql-auth-audit-2026-07.md) + +**已审计范围**:50 个 resolver(35 TS + 15 Python) + +| 状态 | 数量 | 说明 | +| ------------------------- | ---- | -------------------------------------------------------------------------------------- | +| 已有 `@RequirePermission` | 37 | 18 原有 + 19 M4 补齐 | +| 缺失守卫 | 13 | 4 个 TS(`/graphql` 路径未覆盖,由 RouterAuthGuard 兜底)+ 9 个 Python(待补基础设施) | + +**Follow-up(不阻断 M3 验收)**: + +- 4 个 TS 子图(iam / core-edu / content / msg)的 `AuthMiddleware` 仅覆盖 REST 路径,未覆盖 `/graphql`(由 `RouterAuthGuard` 兜底,仍建议补齐字段级守卫) +- Python 子图(data-ana / ai)缺 `@RequirePermission` 基础设施,需补 Strawberry / Ariadne 中间件 + +### 13.6 常用命令 + +```bash +# 类型生成(从 7 子图 schema 生成 TS 类型) +pnpm --filter @edu/portal-shell run codegen + +# PQ Manifest 生成 +pnpm --filter @edu/portal-shell run generate-pq-manifest + +# 生产构建(prebuild 自动串联 codegen + generate-pq-manifest) +pnpm --filter @edu/portal-shell run build + +# 验证 0 处内联 gql 字面量 +pnpm --filter @edu/portal-shell run lint + +# 安全栈测试 +npx vitest run src/lib/api/__tests__/security.test.ts +``` + +--- + +## 14. 术语表 + +| 术语 | 定义 | +| ------------------------------ | --------------------------------------------------------------------------------------------------- | +| **Shell** | 微内核宿主,渲染 Layout 框架 + Slots + PluginLoader,不含业务逻辑 | +| **Registry** | 编译时登记的插件清单,`plugin_id → dynamic import 组件` 静态映射 | +| **Config** | 运行时配置 JSON,决定当前用户在哪些 Slots 渲染哪些插件(DB 三层合并) | +| **PluginProps** | 插件契约,Shell 与插件之间的唯一交互接口 | +| **Slot** | Layout 模板预定义的插件放置区域(top / side / main / main-left / main-right / right / canvas-grid) | +| **Layout 模板** | 5 种内置布局(classic / focus / split / triple / canvas) | +| **三层配置** | 系统默认(plugin_registry) < 角色模板(role_plugin_mapping) < 用户覆盖(user_layout_override) | +| **三层 props 合并** | 系统默认 defaultProps < 角色默认 widgetProps < 用户调整 plugin_placements[].props | +| **RSC** | React Server Component,Next.js App Router 的服务端组件,可异步获取数据 | +| **dynamic import** | `next/dynamic` 懒加载,`ssr: false` 仅客户端渲染 | +| **SWR** | stale-while-revalidate 数据请求库,支持静默后台刷新 | +| **Zustand** | React 全局状态管理库,替代 EventBus | +| **apollo-router** | Apollo Federation 聚合层,替代 3 BFF 手写聚合 | +| **config-service** | 从 iam 拆分的配置服务,管理插件配置 + 布局 + 用户偏好 | +| **DataScope** | IAM 6 级数据范围(school / grade / class / subject / student / self) | +| **ScopeToken** | 大规模 ID 列表的轻量令牌(Redis 存储),替代 GraphQL 联邦全数组传递 | +| **Modular Monolith** | 单体应用 + 模块化组织,介于单进程与微服务之间 | +| **Micro-kernel** | 微内核架构,核心仅含基础框架,功能以插件形式扩展 | +| **APQ**(v1.1) | Automatic Persisted Queries,前端只发 query hash(sha256),不发明文 query | +| **PQ Manifest**(v1.1) | sha256(query) → query 文本的白名单 JSON,apollo-router 据此解析未知 hash | +| **4 层数据访问分层**(v1.1) | Widget → API → Operations → Hook,废弃 widget 内联 gql 字面量(ADR-042) | +| **@RequirePermission**(v1.1) | NestJS GraphQL Resolver 字段级权限装饰器,每个 Resolver 必须声明权限点 | +| **RouterAuthGuard**(v1.1) | 子图 Guard,校验 `Router-Authorization` header,拒绝非 Router 的直接 GraphQL 请求 | + +--- + +## 附录 A:文件清单 + +``` +apps/portal-shell/ +├─ src/ +│ ├─ app/ +│ │ ├─ api/ +│ │ │ ├─ health/route.ts # liveness +│ │ │ └─ ready/route.ts # readiness +│ │ ├─ shell/[[...route]]/page.tsx # RSC 入口 +│ │ ├─ globals.css # 全局样式 + Tailwind +│ │ ├─ layout.tsx # RootLayout + 字体 +│ │ └─ page.tsx # 重定向 /shell +│ ├─ lib/ +│ │ ├─ __tests__/plugin-context.test.ts +│ │ ├─ api/ # v1.1 数据访问层 +│ │ │ ├─ __tests__/ +│ │ │ │ ├─ admin.test.tsx # 4 用例(待补) +│ │ │ │ ├─ parent.test.tsx # 11 用例 +│ │ │ │ ├─ security.test.ts # 10 用例(PQ Manifest + APQ + 深度限制) +│ │ │ │ ├─ sidebar.test.tsx # 9 用例 +│ │ │ │ ├─ student.test.tsx # 9 用例 +│ │ │ │ ├─ teacher.test.tsx # 7 用例 +│ │ │ │ ├─ topbar.test.tsx # 9 用例 +│ │ │ │ └─ universal.test.ts # 6 用例 +│ │ │ ├─ operations/ +│ │ │ │ ├─ *.graphql.ts # 51 个 DocumentNode 常量 +│ │ │ │ ├─ index.ts # barrel +│ │ │ │ └─ types.ts # codegen 生成 +│ │ │ ├─ admin.ts # 11 widget API +│ │ │ ├─ errors.ts # ApiError 归一化 +│ │ │ ├─ internal.ts # 内部工具 +│ │ │ ├─ parent.ts # 3 widget API +│ │ │ ├─ sidebar.ts # 3 widget API +│ │ │ ├─ student.ts # 5 widget API +│ │ │ ├─ teacher.ts # 5 widget API +│ │ │ ├─ topbar.ts # 2 widget API +│ │ │ ├─ types.ts # 共享类型 +│ │ │ └─ universal.ts # 7 widget API +│ │ ├─ apollo-client.ts # Apollo Client 工厂 + 单例 + APQ 链 +│ │ ├─ config-fetcher.ts # RSC 服务端拉 Config(三级降级) +│ │ ├─ types.ts # 共享类型契约 +│ │ ├─ usePluginConfig.ts # SWR 静默刷新 +│ │ ├─ useWidgetQuery.ts # 统一查询 Hook +│ │ └─ useWidgetMutation.ts # 统一变更 Hook +│ ├─ providers/ +│ │ ├─ ApolloProvider.tsx +│ │ ├─ AuthProvider.tsx +│ │ └─ ThemeI18nProvider.tsx +│ ├─ shell/ +│ │ ├─ __tests__/ +│ │ │ ├─ PluginLifecycle.test.ts # 12 用例 +│ │ │ └─ Registry.test.ts # 6 用例 +│ │ ├─ ClientShell.tsx # 客户端入口 +│ │ ├─ LayoutManager.tsx # 5 Layout 模板 +│ │ ├─ PluginLifecycle.ts # 生命周期管理 +│ │ ├─ PluginLoader.tsx # 骨架 + ErrorBoundary +│ │ ├─ PluginStore.ts # Zustand 全局状态 +│ │ ├─ PropsMerger.ts # 三层合并 +│ │ ├─ Registry.tsx # 31 插件注册表 +│ │ ├─ Shell.tsx # 微内核入口 +│ │ └─ SlotRenderer.tsx # slot 渲染器 +│ ├─ styles/ +│ │ └─ tokens.css # 设计令牌映射 +│ └─ widgets/ +│ ├─ admin/ # 6 插件 +│ │ ├─ audit-logs/ +│ │ ├─ invitation-codes/ +│ │ ├─ plugin-manager/ +│ │ ├─ rbac-manager/ +│ │ ├─ school-settings/ +│ │ └─ user-management/ +│ ├─ parent/ # 2 插件 +│ │ ├─ child-overview/ +│ │ └─ leave-approval/ +│ ├─ sidebar/ # 4 插件 +│ │ ├─ child-selector/ +│ │ ├─ class-selector/ +│ │ ├─ quick-actions/ +│ │ └─ term-switcher/ +│ ├─ student/ # 4 插件 +│ │ ├─ ai-tutor/ +│ │ ├─ elective-selector/ +│ │ ├─ error-book/ +│ │ └─ learning-path/ +│ ├─ teacher/ # 4 插件 +│ │ ├─ lesson-plan-editor/ +│ │ ├─ question-bank/ +│ │ ├─ scheduling-rules/ +│ │ └─ textbook-manager/ +│ ├─ topbar/ # 4 插件 +│ │ ├─ global-search/ +│ │ ├─ locale-switcher/ +│ │ ├─ notification-bell/ +│ │ └─ user-menu/ +│ └─ universal/ # 7 插件 +│ ├─ announcements-widget/ +│ ├─ attendance-widget/ +│ ├─ exams-widget/ +│ ├─ grades-widget/ +│ ├─ homework-widget/ +│ ├─ notifications-widget/ +│ └─ schedule-widget/ +├─ .env.example +├─ .eslintrc.tokens.js # 设计令牌 ESLint 规则 +├─ codegen.yml # v1.1 graphql-codegen 配置 +├─ Dockerfile # standalone 构建 +├─ eslint.config.js +├─ next.config.js # transpilePackages + 反向代理 +├─ package.json +├─ postcss.config.js +├─ public/ +│ └─ pq-manifest.json # v1.1 PQ Manifest(51 query hash → query 白名单) +├─ scripts/ +│ ├─ generate-pq-manifest.ts # v1.1 PQ Manifest 生成脚本 +│ └─ normalize-schema.ts # v1.1 schema 归一化(移除 federation 指令) +├─ tailwind.config.js +├─ tsconfig.json # paths 别名 +├─ vitest.config.ts # jsdom + 别名 +└─ README.md # 本文件 +``` + +## 附录 B:常用命令 + +```bash +# 开发 +pnpm --filter @edu/portal-shell run dev # 启动 dev server :4010 +pnpm --filter @edu/portal-shell run build # 生产构建(prebuild 自动 codegen + generate-pq-manifest) +pnpm --filter @edu/portal-shell run start # 生产启动 + +# 质量校验 +pnpm --filter @edu/portal-shell run lint # ESLint +pnpm --filter @edu/portal-shell run lint:tokens # 设计令牌专项 +pnpm --filter @edu/portal-shell run typecheck # tsc --noEmit +pnpm --filter @edu/portal-shell run test # vitest run(95 用例) + +# v1.1 数据层与安全栈 +pnpm --filter @edu/portal-shell run codegen # graphql-codegen 生成 TS 类型 +pnpm --filter @edu/portal-shell run generate-pq-manifest # 生成 public/pq-manifest.json +npx vitest run src/lib/api/__tests__/security.test.ts # 安全栈测试(10 用例) + +# 架构扫描 +pnpm run arch:scan # 更新 arch.db +pnpm run arch:query -- module-deps # 查模块依赖 +``` + +## 附录 C:关联文档索引 + +| 文档 | 用途 | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md) | 架构设计意图唯一源(§11.7 数据访问层 + 安全栈、§16.5 子阶段) | +| [portal-shell 仪表盘 spec](../../docs/superpowers/specs/2026-07-14-portal-shell-widget-dashboard-design.md) | 模块设计源(v1.0) | +| [portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) | v1.1 数据访问层 + 安全栈设计源 | +| [portal-shell 数据抽象与 GraphQL 加固 plan](../../docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md) | v1.1 20-task 实施 plan | +| [GraphQL @auth 审计报告](../../docs/security/graphql-auth-audit-2026-07.md) | 50 resolver @auth 审计(M4) | +| [项目规则](../../.trae/rules/project_rules.md) | 强制约束 | +| [UI 设计系统](../../docs/standards/ui-design-system.md) | 纸感编辑器设计风格 | +| [known-issues §1.11/§2.17](../../docs/troubleshooting/known-issues.md) | Apollo Router + portal-shell 已知问题速查 | +| [local-stack runbook](../../docs/runbooks/local-stack.md) | 本地运维手册 | +| [端口分配](../../infra/port-allocation.md) | 端口唯一源 | + +--- + +> **本文件是 portal-shell 模块的架构文档(v1.1,2026-07-17),遵循 arc42 模板结构 + C4 模型可视化。后续代码变更须按 [项目规则 §1](../../.trae/rules/project_rules.md) 同步更新本文件 + 运行 `pnpm run arch:scan` 更新 arch.db。** diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 741f059..0b64a2d 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -2150,12 +2150,12 @@ graph LR > 来源:[spec v1.0](../superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) + [plan](../superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md) > 关联 ADR:ADR-042(前端数据访问四层分层)、ADR-043(PQ Manifest + APQ 安全加固) -| 子阶段 | 内容 | 退出标准 | 状态 | -| ------ | ------------------------------------------------------------ | --------------------------------------------------------- | ------- | -| M1 | lib/api 四层架构 + 7 domain 迁移 + 31 widget 全量切换 | 0 处内联 gql 字面量、95/95 测试通过 | ✅ 完成 | -| M2 | 迁移完整性验证(typecheck + lint + test 全绿) | 0 error / 0 warning / 85 测试通过(M1 收尾) | ✅ 完成 | -| M3 | GraphQL 安全加固(APQ + PQ Manifest + router limits + 测试) | apollo-router 启用 manifest + 深度/成本限制 + 10 安全测试 | ✅ 完成 | -| M4 | Resolver @RequirePermission 审计 + 补齐 | 50 resolver 审计完成、19 TS resolver 补齐守卫 | ✅ 完成 | +| 子阶段 | 内容 | 退出标准 | 状态 | +| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------ | ------- | +| M1 | lib/api 四层架构 + 7 domain 迁移 + 31 widget 全量切换 | 0 处内联 gql 字面量、55 domain 测试通过 | ✅ 完成 | +| M2 | 迁移完整性验证(typecheck + lint + test 全绿) | 0 error / 0 warning / 95 测试通过(30 原有 + 55 domain + 10 安全) | ✅ 完成 | +| M3 | GraphQL 安全加固(APQ + PQ Manifest + router limits + 测试) | apollo-router 启用 manifest + 深度/成本限制 + 10 安全测试 | ✅ 完成 | +| M4 | Resolver @RequirePermission 审计 + 补齐 | 50 resolver 审计完成、19 TS resolver 补齐守卫 | ✅ 完成 | **Follow-up(不阻断 M3 验收)**: