Files
Edu/apps/portal-shell/README.md
SpecialX f7e52b5b7f docs(portal-shell): update README to v1.1 with data layer and GraphQL hardening
- 版本 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 符号)
2026-07-17 13:47:40 +08:00

1209 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 个独立 portalteacher / student / parent / admin+ Module Federation 微前端方案。它承载教师、学生、家长、管理员四类角色的全部教学场景 UI通过 **Micro-kernel + 插件化** 机制实现功能扩展。
- **服务端口**4010HTTP
- **技术栈**TypeScript 5.6 + Next.js 14 App Router + React 18 + Tailwind + Zustand + SWR + Apollo Client
- **架构风格**Modular Monolith单体+ Micro-kernel微内核插件
- **部署形态**:单 Docker 容器(`output: "standalone"`
- **上游依赖**api-gatewayJWT 校验 + 反向代理、apollo-routerGraphQL 联邦入口)
- **下游契约**:通过 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 ≤ 300KBgzip |
| 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<T>` |
| 禁 `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["插件化仪表盘<br/>Modular Monolith + Micro-kernel"]
end
subgraph Upstream["上游服务"]
Gateway["api-gateway :8080<br/>JWT 校验 + 反向代理"]
Router["apollo-router :3000<br/>GraphQL 联邦入口"]
Realtime["realtime-gateway :8081<br/>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 契约 ↑
┌──────────────────────────────────────────────────────────┐
│ Widgets31 内置插件,按 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<br/>RootLayout + 字体"]
RootPage["page.tsx<br/>重定向 /shell"]
ShellPage["shell/[[...route]]/page.tsx<br/>RSC 入口"]
HealthAPI["api/health/route.ts"]
ReadyAPI["api/ready/route.ts"]
end
subgraph ShellCore["shell/(微内核)"]
Shell["Shell.tsx"]
ClientShell["ClientShell.tsx"]
LayoutMgr["LayoutManager.tsx<br/>5 Layout 模板"]
SlotRend["SlotRenderer.tsx"]
Loader["PluginLoader.tsx<br/>+ ErrorBoundary"]
Registry["Registry.tsx<br/>31 插件"]
Lifecycle["PluginLifecycle.ts"]
Store["PluginStore.ts<br/>Zustand"]
Merger["PropsMerger.ts<br/>三层合并"]
end
subgraph Lib["lib/(数据抽象)"]
Apollo["apollo-client.ts<br/>APQ + sha256"]
ConfigFetch["config-fetcher.ts"]
UseConfig["usePluginConfig.ts<br/>SWR 静默刷新"]
UseQuery["useWidgetQuery.ts"]
UseMut["useWidgetMutation.ts"]
Types["types.ts"]
end
subgraph ApiLayer["lib/api/v1.1 数据访问层)"]
ApiDomain["<domain>.ts ×7<br/>parent/teacher/admin/student/<br/>universal/sidebar/topbar"]
ApiOps["operations/*.graphql.ts<br/>51 DocumentNode"]
ApiTypes["operations/types.ts<br/>codegen 生成"]
ApiErrors["errors.ts<br/>ApiError 归一化"]
end
subgraph Providers["providers/"]
ApolloProv["ApolloProvider.tsx"]
AuthProv["AuthProvider.tsx"]
ThemeProv["ThemeI18nProvider.tsx"]
end
subgraph Widgets["widgets/31 内置插件)"]
Universal["universal/<br/>7 插件"]
Sidebar["sidebar/<br/>4 插件"]
Topbar["topbar/<br/>4 插件"]
Teacher["teacher/<br/>4 插件"]
Student["student/<br/>4 插件"]
Parent["parent/<br/>2 插件"]
Admin["admin/<br/>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/** | `<domain>.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` | 主题类名同步到 `<html>` + 简易 i18n | `usePluginStore` theme/locale |
| **widgets/** | `*/index.tsx` | 插件入口,接收 `PluginProps`default export | `PluginProps` 契约 |
| **widgets/** | `*/plugin.manifest.ts` | 插件元数据声明 | `manifestMeta: Omit<PluginManifest, "Component">` |
### 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<TProps = Record<string, unknown>> {
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<PluginManifest, "Component"> = {
pluginId: "grades-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0", // semver rangeShell 启动时校验
metadata: {
displayName: "成绩",
description: "查看班级成绩",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
// JSON Schemaadmin 配置面板自动渲染表单
type: "object",
properties: {
limit: { type: "number", description: "显示条数" },
},
},
},
};
```
### 6.4 插件生命周期
| 阶段 | 内置插件 | 第三方插件(二期) |
| ------------- | ------------------------------ | ------------------------------------- |
| `registered` | 编译时登记到 Registry | 安装时登记到 DBplugin_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: ConfigactiveLayout + plugins[]
N->>N: PropsMerger 解析 propsJson / sizeJson
N->>R: 并发预取各插件 initialDataPromise.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.04 层串行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: 切回 TabrevalidateOnFocus<br/>或 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<br/>multi-stage"]
Img["本地镜像<br/>(不推送 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-hostCSS 变量暴露为 `--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)`
- **插件容器**`<section className="rounded-card border border-rule bg-surface p-md">`
- **插件标题**`text-heading-3 text-ink`
- **加载态**`<PluginSkeleton variant="card|list|chart|stats|table" />`
- **错误态**`<PluginErrorFallback instanceId={...} onRetry={...} />`
### 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 cookieapi-gateway 配置) |
| 开发模式 | `NEXT_PUBLIC_DEV_MODE=true` 绕过 JWT仅本地开发 |
| **APQv1.1** | `createPersistedQueryLink({ sha256 })` 前端只发 query hashenv `NEXT_PUBLIC_APOLLO_APQ=false` 关闭 |
| **PQ Manifestv1.1** | `public/pq-manifest.json` 51 query 的 `sha256 → query` 白名单,由 `scripts/generate-pq-manifest.ts` 生成 |
| **Router 强制 manifestv1.1** | `APOLLO_REQUIRE_PQ_MANIFEST=true` 时 router 拒绝未知 hashentrypoint.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-Authorizationv1.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 + initialDataLCP 秒开 |
| **三层 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 Manifestv1.1** | 前端只发 query hash 防止 schema 探测Router 白名单 manifest 拒绝未知 hash 任意查询ADR-043 |
| **Router 深度/成本/批量限制v1.1** | 防止深度嵌套 / 高成本 / 批量查询 DoS配置 max_depth=10 / max_cost=1000 / max_batch_size=5 |
| **Resolver 字段级 @RequirePermissionv1.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 可配置插件默认 propsJSON 编辑)
- [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.1lib/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 Python19 个 TS resolver 补齐 @RequirePermissionv1.1
- [x] arch.db 更新004 文档同步
- [ ] Shell 首屏 LCP < 2s需真实环境压测验证
- [ ] 插件加载耗时 < 500msdynamic 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 写入部署 envv1.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-originBFF 请求由 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-routerRSC 预取 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
> 关联 ADRADR-042前端数据访问四层分层、ADR-043PQ 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<br/>只关心渲染]
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<br/>51 个 DocumentNode 常量]
end
subgraph Hook["Hook 层Apollo 封装)"]
H1[useWidgetQuery]
H2[useWidgetMutation]
end
subgraph Codegen["类型生成"]
C[graphql-codegen<br/>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/<domain>.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/<svc>/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<br/>sha256 query → hash]
end
subgraph Router["apollo-router :3000"]
Manifest[pq-manifest.json<br/>hash → query 白名单]
Limits[limits.max_depth=10<br/>max_cost=1000<br/>max_batch_size=5]
Intro[introspection<br/>环境变量控制]
end
subgraph Sub["子图 /graphql"]
Guard[RouterAuthGuard<br/>+ @RequirePermission]
end
APQ -->|只发 hash| Manifest
Manifest -->|未知 hash 拒绝| APQ
Manifest --> Limits
Limits --> Intro
Intro --> Guard
```
**安全机制矩阵**
| 机制 | 位置 | 防御目标 | 配置 |
| ---------------------------------- | -------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
| APQAutomatic 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 个 resolver35 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 ComponentNext.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 hashsha256不发明文 query |
| **PQ Manifest**v1.1 | sha256(query) → query 文本的白名单 JSONapollo-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 Manifest51 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 run95 用例)
# 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.12026-07-17遵循 arc42 模板结构 + C4 模型可视化。后续代码变更须按 [项目规则 §1](../../.trae/rules/project_rules.md) 同步更新本文件 + 运行 `pnpm run arch:scan` 更新 arch.db。**