diff --git a/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md b/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md new file mode 100644 index 0000000..bacfc7d --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md @@ -0,0 +1,819 @@ +# Portal Shell 数据抽象层与 GraphQL 安全加固设计 + +> 版本:v1.0 +> 日期:2026-07-17 +> 状态:待评审 +> 关联: +> +> - [Portal Shell 插件化仪表盘设计 v2.1](./2026-07-14-portal-shell-widget-dashboard-design.md) +> - [004 架构影响地图](../../architecture/004_architecture_impact_map.md) §16 portal-shell +> - [0010 架构蓝图](../../architecture/0010_architecture.md) §4 数据流 +> - [项目规则](../../.trae/rules/project_rules.md) §3.8 Controller 规范、§4 安全规范 + +--- + +## 1. 背景与目标 + +### 1.1 问题诊断 + +portal-shell v2.1 已完成 31 个内置插件的迁移,但所有插件在 `widgets///index.tsx` 中**直接内嵌 `gql` 模板字符串**调用 apollo-router: + +```typescript +// 当前形态(每个 widget 都这样写) +const GET_MY_CHILDREN = gql` + query GetMyChildren { + myChildren { + id + name + grade + className + avatar + recentGrades { + subject + score + } + attendance { + present + total + } + homeworkCompletion { + completed + total + } + } + } +`; + +interface MyChildrenQueryData { + myChildren: ChildInfo[]; +} // 手写类型 + +export default function ChildOverview() { + const { data, loading } = useWidgetQuery< + MyChildrenQueryData, + Record + >(GET_MY_CHILDREN, {}); + // ... UI +} +``` + +全项目扫描:**29 个 widget 文件,92 处 `gql` 字面量,92 处手写 `interface XxxQueryData`**。 + +由此引发四类问题: + +1. **Schema 耦合**:widget 直接知道后端字段名与嵌套结构,后端 schema 变更需修改前端组件 +2. **查询重复/碎片化**:相同领域查询散落在多个 widget,无集中管理 +3. **跨域聚合在前端**:`myChildren` 一次查询跨 iam + core-edu + msg 三个子图,聚合责任落在前端 +4. **缺少数据抽象层**:widget 既写 UI 又写数据获取,职责混淆,平均 150 行/文件 + +同时存在**未显式暴露但真实存在**的安全缺口: + +5. **生产环境明文 query**:DevTools 可抓到完整 query 文本,攻击者可构造任意查询探测 schema +6. **无查询深度/复杂度限制**:可构造 `user { parent { user { parent {...} } } }` 递归攻击或 `first: 1000000` 放大攻击 +7. **字段级 @auth 覆盖率未知**:敏感字段(`auditLogs.details`、`user.email`、`auditLog.ip`)是否都有角色守卫未审计 + +### 1.2 国际业界对比 + +| 模式 | 谁在用 | 查询字符串位置 | 抽象程度 | +| ---------------------------- | --------------------------- | --------------------------------- | --------------------------- | +| Inline gql in component | 小型项目、demo | 散落在每个组件 | 无抽象(portal-shell 现状) | +| Co-located documents + hooks | Apollo 官方推荐中大型项目 | `*.graphql` 文件按域聚合 | 文档层抽象 | +| Typed SDK / API client | 大型企业(GitHub、Shopify) | 后端 codegen 出 SDK | 完全屏蔽 GraphQL | +| Relay-style fragments | Meta、Medium | Fragment 容器 + 编译时 hoisting | 数据需求就近声明 | +| BFF / Router 聚合 | 微服务架构 | 前端发粗粒度查询,BFF/Router 拆分 | 跨域聚合下沉 | + +portal-shell 当前已是**微服务 + Apollo Federation** 架构,却在前端层用了"最朴素形态"。架构-代码不匹配是核心矛盾:**架构越复杂,前端的抽象责任应该越轻**,而当前正好相反。 + +### 1.3 安全澄清 + +用户的三个直觉担忧澄清: + +- **"暴露数据库"**:GraphQL schema ≠ 数据库 schema。前端看到的是对外契约,不是 DB 表结构。但 DevTools 可抓到完整 query 文本,确实暴露"前端能用哪些字段"。搬到 `lib/api/` **不能**降低暴露面,**只能靠持久化查询**让生产环境只发 hash。 +- **"串改"**:GraphQL 允许客户端任意构造查询,防线在 Router 层(持久化查询 manifest + 复杂度限制 + @auth)。与查询写在哪无关。 +- **"注入"**:GraphQL 参数化(通过 `variables` 传递)+ 后端 ORM 参数化查询(Drizzle 默认),注入面基本为零。 + +### 1.4 设计目标 + +把 portal-shell 的"组件内嵌 gql 字符串 + Apollo Client 薄包装"重构为"语义化 API 层 + 生产级 GraphQL 安全栈",一次解决架构耦合与安全缺口两个维度: + +1. **零 `gql` 字面量**:31 个 widget 全部迁移到 `lib/api/` 抽象层,widget 只调函数不发查询 +2. **零手写类型**:所有 TypeScript 类型由 `graphql-codegen` 生成 +3. **生产环境零明文 query**:Apollo Router 启用 APQ + manifest 校验,生产模式拒绝 manifest 之外的查询 +4. **深度/复杂度限制**:max_depth = 10,max_cost = 1000 +5. **字段级 @auth 全覆盖**:审计 7 个子图,敏感字段补齐守卫 +6. **widget 平均行数下降**:从 ~150 行降至 ≤80 行,UI 为主 + +### 1.5 非目标 + +- ❌ 跨域聚合下沉到 Router(@requires 指令方案,独立 spec 处理) +- ❌ 子图 schema 重构(`myChildren` 的字段归属调整) +- ❌ 性能优化(缓存策略、批合并等,独立 spec) +- ❌ 移除 `useWidgetQuery` / `useWidgetMutation`(保留作为底层 Hook,新 API 层在其之上) +- ❌ 第三方插件 SDK 设计(未来插件契约 spec 再做) +- ❌ 引入 Relay(重写成本太高,Apollo 已够用) +- ❌ fragment 共享(先看实际重复程度,YAGNI) + +--- + +## 2. 整体架构 + +### 2.1 四层分层 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Widget 层(UI) │ +│ src/widgets///index.tsx │ +│ 职责:渲染、交互、URL 状态 │ +│ 禁止:gql 字面量、Apollo Client 直接调用、手写 QueryData 类型 │ +└─────────────────────────────────────────────────────────────┘ + ↓ 调用 +┌─────────────────────────────────────────────────────────────┐ +│ API 层(语义化函数) │ +│ src/lib/api/.ts │ +│ 职责:领域语义封装、错误归一化、返回领域模型 │ +│ 导出:useParentChildren(), useAuditLogs(filter), ... │ +└─────────────────────────────────────────────────────────────┘ + ↓ 调用 +┌─────────────────────────────────────────────────────────────┐ +│ Operations 层(GraphQL 文档) │ +│ src/lib/api/operations/.graphql.ts │ +│ 职责:集中存放 gql 文档、按 domain 分文件 │ +│ 导出:GET_MY_CHILDREN_DOC, GET_AUDIT_LOGS_DOC, ... │ +└─────────────────────────────────────────────────────────────┘ + ↓ 调用 +┌─────────────────────────────────────────────────────────────┐ +│ Hook 层(保留,不动) │ +│ src/lib/useWidgetQuery.ts / useWidgetMutation.ts │ +│ 职责:Apollo Client 包装、缓存策略、SWR 刷新 │ +└─────────────────────────────────────────────────────────────┘ + ↓ HTTPS(生产发 hash,开发发明文) +┌─────────────────────────────────────────────────────────────┐ +│ apollo-router(:3000) │ +│ - APQ + manifest 校验 │ +│ - 深度/复杂度限制 │ +│ - 子图分发 │ +└─────────────────────────────────────────────────────────────┘ + ↓ gRPC / Federation + 8 个业务子图(iam / config-service / classes / + core-edu / content / msg / data-ana / ai) +``` + +**关键原则**:依赖单向向下,widget 不知道 GraphQL 字段名,API 层不知道 UI 细节,Hook 层保持现状作为底层抽象。 + +### 2.2 lib/api/ 目录结构 + +``` +apps/portal-shell/src/lib/api/ +├─ index.ts # 统一出口(barrel),按 domain re-export +├─ operations/ # GraphQL 文档集中存放 +│ ├─ parent.graphql.ts # myChildren / leaveApproval +│ ├─ admin.graphql.ts # users / roles / auditLogs / invitationCodes / school / plugins +│ ├─ teacher.graphql.ts # lessonPlan / questionBank / textbook / schedulingRules +│ ├─ student.graphql.ts # errorBook / learningPath / elective / aiTutor +│ ├─ universal.graphql.ts # grades / homework / schedule / attendance / exams / notifications / announcements +│ ├─ sidebar.graphql.ts # classSelector / childSelector / termSwitcher / quickActions +│ └─ topbar.graphql.ts # notificationBell / userMenu / globalSearch / localeSwitcher +├─ parent.ts # useParentChildren, useLeaveApprovals, useApproveLeave +├─ admin.ts # useUsers, useAuditLogs, useCreateInvitationCode, ... +├─ teacher.ts # useLessonPlans, useSaveLessonPlan, useQuestionBank, ... +├─ student.ts # useErrorBook, useLearningPath, ... +├─ universal.ts # useGrades, useHomework, useSchedule, ... +├─ sidebar.ts # useClasses, useChildren, useTerms, ... +├─ topbar.ts # useNotifications, useCurrentUser, ... +├─ internal.ts # 共享工具(normalizeError、类型定义) +├─ errors.ts # ApiError 类与 GraphQLErrorCode 枚举 +├─ types.ts # 共享类型(Pagination、Filter 等) +└─ __tests__/ # API 层单测 + ├─ parent.test.ts + ├─ admin.test.ts + └─ ... +``` + +**domain 划分原则**:与 Registry.tsx 的 7 个分类(universal/sidebar/topbar/teacher/student/parent/admin)一一对应,便于查找。 + +### 2.3 API 函数签名规范 + +```typescript +// 查询:返回领域模型(非 GraphQL 原始响应形状) +export function useParentChildren(): UseQueryResult { + const result = useWidgetQuery>( + GET_MY_CHILDREN_DOC, + {}, + ); + return { + ...result, + data: result.data?.myChildren ?? [], + }; +} + +// 带参数的查询 +export function useAuditLogs( + filter: AuditLogFilter, + pagination: { limit: number; offset: number }, +): UseQueryResult { + const result = useWidgetQuery( + GET_AUDIT_LOGS_DOC, + { filter, ...pagination }, + ); + return { + ...result, + data: result.data?.auditLogs.items ?? [], + }; +} + +// Mutation(hook 形态,因为 useWidgetMutation 是 React Hook) +export function useCreateInvitationCode(): [ + (input: CreateInvitationCodeInput) => Promise, + UseMutationResult, +] { + const [run, result] = useWidgetMutation< + CreateInvitationCodeMutation, + CreateInvitationCodeMutationVariables + >(CREATE_INVITATION_CODE_DOC); + + const wrappedRun = async ( + input: CreateInvitationCodeInput, + ): Promise => { + const { data } = await run({ input }); + if (!data?.createInvitationCode) { + throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR"); + } + return data.createInvitationCode; + }; + + return [wrappedRun, result]; +} +``` + +**规范要点**: + +- 查询返回**领域模型**(`ChildSummary[]`),不是 GraphQL 原始响应(`{ myChildren: [...] }`),避免 schema 形状泄漏 +- Mutation 包装为 hook,返回 `[wrappedRun, result]` 元组;`wrappedRun` 抛 `ApiError`,widget 在事件 handler 中 `try/catch` +- 命名:查询与 Mutation 都用 `use` 前缀(`useParentChildren`、`useCreateInvitationCode`、`useApproveLeave`),保持 React Hook 规范 +- 文件名 `.ts`,函数名 `use`(查询)/ `use`(Mutation) + +### 2.4 graphql-codegen 集成 + +**配置文件**:`apps/portal-shell/codegen.ts` + +```typescript +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: "http://localhost:3000/graphql", // apollo-router + documents: "src/lib/api/operations/**/*.graphql.ts", + generates: { + "src/lib/api/__generated__/types.ts": { + plugins: ["typescript", "typescript-operations"], + }, + "src/lib/api/__generated__/operations.ts": { + plugins: ["typescript-react-apollo"], + }, + }, + config: { + withHooks: false, // 不生成 hooks(已有 useWidgetQuery) + withComponent: false, + preResolveTypes: true, + skipTypename: true, + exportTypeKeyOnly: true, + }, +}; +export default config; +``` + +**生成产物**: + +- `__generated__/types.ts` — 所有 GraphQL 类型(替代手写的 `interface XxxQueryData`) +- `__generated__/operations.ts` — `TypedDocumentNode` 类型化的 DocumentNode + +**生成时机**: + +- 开发:`pnpm run codegen:watch` 监听 `operations/*.graphql.ts` 变更 +- CI:`pnpm run codegen` 在 typecheck 前执行,确保 `__generated__/` 最新 +- 不提交 `__generated__/` 到 git(加入 .gitignore) + +### 2.5 与现有代码的关系 + +| 现有 | 处置 | +| -------------------------------------- | ----------------------------------------------------------- | +| `useWidgetQuery` / `useWidgetMutation` | 保留,API 层在其上封装,不改签名 | +| `PluginProps` / `PluginManifest` | 不动 | +| `Registry.tsx` | 不动 | +| 31 个 widget 的 `index.tsx` | 全量重构:删除 `gql` 字面量与手写类型,改调 `lib/api/` 函数 | +| `.env.local` | 新增 `NEXT_PUBLIC_APOLLO_APQ=true`(开发可关) | +| `next.config.js` | 新增 `codegen` 时机(prebuild) | + +--- + +## 3. 迁移策略(全量迁移,不保留老代码) + +### 3.1 迁移原则 + +- **一次性全量迁移**:31 个 widget 在 M2 阶段单 PR 完成,不保留老代码共存期 +- **删除所有 `gql` 字面量**:迁移后 widget 中不得出现 `gql` 标签 +- **删除所有手写 `interface XxxQueryData`**:由 codegen 生成的类型替代 +- **删除 widget 对 `useWidgetQuery`/`useWidgetMutation` 的直接调用**:改通过 `lib/api/` 间接使用 +- **不合并到 main 直到 M2 完成**:避免半迁移状态 +- **全量迁移前在分支上跑完整测试套件**:30 个现有测试 + 新增 API 层测试必须全绿 + +### 3.2 单个 widget 迁移模板(5 步) + +以 `child-overview` 为例: + +```typescript +// Step 1: 抽取 GraphQL 文档到 operations/parent.graphql.ts +import { gql } from "@apollo/client"; +export const GET_MY_CHILDREN_DOC = gql` + query GetMyChildren { + myChildren { + id + name + grade + className + avatar + recentGrades { + subject + score + } + attendance { + present + total + } + homeworkCompletion { + completed + total + } + } + } +`; + +// Step 2: codegen 生成类型(自动,无需手写) +// 文件:src/lib/api/__generated__/types.ts +// export type GetMyChildrenQuery = { myChildren: Array<{ id: string, name: string, ... }> }; + +// Step 3: lib/api/parent.ts 写语义函数 +import type { GetMyChildrenQuery } from "@/lib/api/__generated__/types"; +import { GET_MY_CHILDREN_DOC } from "@/lib/api/operations/parent.graphql"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; + +export interface ChildSummary { + id: string; + name: string; + grade: string; + className: string; + avatar: string; + recentGrades: Array<{ subject: string; score: number }>; + attendance: { present: number; total: number }; + homeworkCompletion: { completed: number; total: number }; +} + +export function useParentChildren(): UseQueryResult { + const result = useWidgetQuery>( + GET_MY_CHILDREN_DOC, + {}, + ); + return { + ...result, + data: (result.data?.myChildren ?? []) as ChildSummary[], + }; +} + +// Step 4: widget/index.tsx 改造(150 行 → 60 行) +import { useParentChildren } from "@/lib/api"; + +export default function ChildOverview(_props: PluginProps): React.ReactElement { + const { data: children, loading } = useParentChildren(); + // ... 只剩 UI 渲染逻辑 +} + +// Step 5: 删除 widget 中原有的 gql、interface、类型断言 +``` + +### 3.3 31 个 widget 全量迁移清单 + +| 批次 | widget | domain | 子图 | 备注 | +| ---- | -------------------- | --------- | ------------------- | --------------------- | +| 全量 | grades-widget | universal | core-edu | 单查询 | +| 全量 | homework-widget | universal | core-edu | 查询+分页 | +| 全量 | schedule-widget | universal | core-edu | 单查询 | +| 全量 | attendance-widget | universal | core-edu | 单查询 | +| 全量 | exams-widget | universal | core-edu | 单查询 | +| 全量 | notifications-widget | universal | msg | 查询+分页 | +| 全量 | announcements-widget | universal | msg | 查询+分页 | +| 全量 | class-selector | sidebar | classes | 单查询 | +| 全量 | child-selector | sidebar | iam | 单查询 | +| 全量 | term-switcher | sidebar | config-service | 单查询 | +| 全量 | quick-actions | sidebar | iam | 单查询 | +| 全量 | notification-bell | topbar | msg | 单查询 | +| 全量 | user-menu | topbar | iam | 单查询 | +| 全量 | global-search | topbar | 全域 | 特殊处理 | +| 全量 | locale-switcher | topbar | config-service | 单查询 | +| 全量 | lesson-plan-editor | teacher | content | 查询+mutation | +| 全量 | question-bank | teacher | content | 查询+分页 | +| 全量 | textbook-manager | teacher | content | 查询+mutation | +| 全量 | scheduling-rules | teacher | classes | 查询+mutation | +| 全量 | error-book | student | core-edu | 单查询 | +| 全量 | learning-path | student | ai | 单查询 | +| 全量 | elective-selector | student | classes | 单查询 | +| 全量 | ai-tutor | student | ai+core-edu+content | 跨域聚合 | +| 全量 | child-overview | parent | iam+core-edu+msg | 跨域聚合 | +| 全量 | leave-approval | parent | iam+msg | 跨域聚合 | +| 全量 | user-management | admin | iam | 查询+mutation | +| 全量 | rbac-manager | admin | iam | 查询+mutation(敏感) | +| 全量 | audit-logs | admin | iam | 查询+分页 | +| 全量 | invitation-codes | admin | iam | 查询+mutation | +| 全量 | school-settings | admin | config-service | 查询+mutation | +| 全量 | plugin-manager | admin | config-service | 查询+mutation | + +**3 个跨域聚合插件**(child-overview / leave-approval / ai-tutor)本次仅迁移到 API 层,**不做 @requires 下沉**(属下一 spec 范围)。 + +--- + +## 4. 持久化查询实施 + +### 4.1 Apollo Client 端配置 + +```typescript +// apps/portal-shell/src/lib/apollo-client.ts +import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client"; +import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; +import { sha256 } from "crypto-hash"; + +const httpLink = new HttpLink({ uri: "/api/graphql" }); + +// 开发模式可关闭 APQ 便于调试 +const enableApq = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false"; + +const link = enableApq + ? createPersistedQueryLink({ sha256 }).concat(httpLink) + : httpLink; + +export const apolloClient = new ApolloClient({ + link, + cache: new InMemoryCache(), +}); +``` + +### 4.2 Persisted Query Manifest 生成 + +**构建期生成 manifest**(生产用): + +```typescript +// apps/portal-shell/scripts/generate-pq-manifest.ts +import { print } from "graphql"; +import { sha256 } from "crypto-hash"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { operations } from "@/lib/api/operations"; + +async function generateManifest(): Promise { + const manifest: Record = {}; + for (const [name, doc] of Object.entries(operations)) { + const query = print(doc); + const hash = await sha256(query); + manifest[hash] = query; + } + const outPath = path.resolve(process.cwd(), "public/pq-manifest.json"); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2)); + console.log( + `✓ PQ manifest generated: ${Object.keys(manifest).length} queries`, + ); +} + +generateManifest().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +**接入流程**: + +- `pnpm --filter @edu/portal-shell run build` 前自动执行 `generate-pq-manifest` +- manifest 部署到 apollo-router 容器的 `/etc/apollo-router/pq-manifest.json` +- Router 配置加载该 manifest,生产模式拒绝 manifest 之外的查询 + +### 4.3 apollo-router 配置 + +```yaml +# infra/apollo-router/router.yaml +persisted_queries: + enabled: true + # 生产:仅接受 manifest 内的 hash + require_manifest: ${env.APOLLO_REQUIRE_PQ_MANIFEST::false} + manifest_path: /etc/apollo-router/pq-manifest.json + +limits: + max_depth: 10 + max_cost: 1000 + cost_multiplicator: + # 基础字段 = 1 + default: 1 + # 列表字段按 first 参数计费 + list_field: 0.1 + +sandbox: + enabled: ${env.APOLLO_ROUTER_SANDBOX::true} + listen: 0.0.0.0:3000 + +# 生产关闭 introspection +supergraph: + introspection: ${env.APOLLO_ROUTER_INTROSPECTION::true} + +# 生产仅允许 POST +csrf: + enabled: true +``` + +### 4.4 环境变量矩阵 + +| 环境 | `NEXT_PUBLIC_APOLLO_APQ` | `APOLLO_REQUIRE_PQ_MANIFEST` | `APOLLO_ROUTER_INTROSPECTION` | 效果 | +| -------- | ------------------------ | ---------------------------- | ----------------------------- | ---------------------------------------------------------------- | +| 本地 dev | `false` | `false` | `true` | 前端发明文,Router 接受任意查询(调试方便) | +| 测试环境 | `true` | `false` | `false` | 前端发 hash,Router 接受未知 hash 回退明文,关闭 introspection | +| 生产 | `true` | `true` | `false` | 前端发 hash,Router 拒绝 manifest 之外的查询,关闭 introspection | + +--- + +## 5. GraphQL 安全加固 + +### 5.1 深度/复杂度限制 + +**深度限制 max_depth = 10**: + +- 当前最深查询 4 层(`myChildren.recentGrades.subject`) +- 留 6 层余量给未来扩展 +- 超过返回 `QUERY_DEPTH_EXCEEDED` 错误 + +**复杂度限制 max_cost = 1000**: + +- 计费规则: + - 标量字段:0 + - 对象字段:1 + - 列表字段:`first × 0.1`(`auditLogs(first: 100)` = 10 cost) + - 嵌套列表:累乘(极少见,触发即警告) +- 超过返回 `QUERY_COMPLEXITY_EXCEEDED` 错误 + +### 5.2 字段级 @auth 审计 + +**审计范围**:8 个子图的所有 Query/Mutation/Field resolver + +**审计方法**: + +1. 用 `pnpm run arch:query -- symbol-refs "@ResolveField"` 列出所有字段 resolver +2. 用 `pnpm run arch:query -- symbol-refs "@Query"` 列出所有 Query +3. 用 `pnpm run arch:query -- symbol-refs "@Mutation"` 列出所有 Mutation +4. 逐个核查是否有 `@RequirePermission()` 或字段级 `@auth` 指令 + +**审计输出**:`docs/security/graphql-auth-audit-2026-07.md` + +格式: + +```markdown +| 子图 | 字段 | 当前守卫 | 期望守卫 | 状态 | +| -------- | ----------------- | -------------------------------- | ---------------------------------------- | ------- | +| iam | users | @RequirePermission('user:read') | 同 | ✅ | +| iam | auditLogs.details | 无 | @RequirePermission('audit:read:details') | ❌ 补齐 | +| core-edu | grades | @RequirePermission('grade:read') | 同 | ✅ | +``` + +**补齐策略**: + +- 仅修改 NestJS resolver 的装饰器,不动 schema 文件结构 +- 装饰器缺失的字段补 `@RequirePermission()` +- 敏感字段(`details`、`ip`、`email`、`phone`)补字段级 `@auth(requires: 'xxx')` + +### 5.3 其他加固 + +| 项 | 措施 | 配置位置 | +| ------------- | --------------------------- | --------------------------------------- | +| Introspection | 生产关闭 | `router.yaml: supergraph.introspection` | +| GET 方法查询 | 生产关闭(仅允许 POST) | `router.yaml: csrf.enabled` | +| 批查询 | 限制 batch size ≤ 5 | `router.yaml: limits.max_batch_size` | +| 文件上传 | 单文件 ≤ 10MB(如未来需要) | apollo-router upload plugin | + +--- + +## 6. 错误处理 + +### 6.1 统一错误类型 + +```typescript +// apps/portal-shell/src/lib/api/errors.ts +export class ApiError extends Error { + constructor( + message: string, + public readonly code: GraphQLErrorCode, + public readonly statusCode: number = 500, + public readonly fields?: string[], + ) { + super(message); + this.name = "ApiError"; + } +} + +export type GraphQLErrorCode = + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "VALIDATION_ERROR" + | "QUERY_DEPTH_EXCEEDED" + | "QUERY_COMPLEXITY_EXCEEDED" + | "PERSISTED_QUERY_NOT_FOUND" + | "INTERNAL_ERROR"; +``` + +### 6.2 API 层错误归一化 + +```typescript +// lib/api/internal.ts +import { ApolloError } from "@apollo/client"; +import { ApiError, GraphQLErrorCode } from "./errors"; + +export function normalizeError(error: ApolloError): ApiError { + const gqlError = error.graphQLErrors?.[0]; + const code = (gqlError?.extensions?.code ?? + "INTERNAL_ERROR") as GraphQLErrorCode; + const statusCode = (gqlError?.extensions?.statusCode ?? 500) as number; + return new ApiError(gqlError?.message ?? error.message, code, statusCode); +} +``` + +### 6.3 Widget 层错误处理 + +- 查询类:`useWidgetQuery` 已返回 `{ error }`,widget 显示 `` 组件(已有) +- Mutation 类:API 层返回的 `run` 函数抛 `ApiError`,widget 在 onClick/onChange 等事件 handler 中用 `try/catch` 捕获并显示 toast +- 边界场景: + - 网络断开 → `NetworkError`,显示重试按钮 + - 401 → 自动跳登录(已有逻辑) + - 403 → 显示"无权限"提示 + - 持久化查询未命中 → 自动回退明文(Apollo Client 内置) + +### 6.4 不做的事 + +- ❌ 不引入全局 error boundary(已有) +- ❌ 不重试机制(已有 SWR 刷新) +- ❌ 不做错误埋点(已有 observability) + +--- + +## 7. 测试策略 + +### 7.1 测试金字塔 + +``` + ┌─────────────┐ + │ E2E (5) │ 关键用户流(登录→看孩子→审批) + └─────────────┘ + ┌───────────────────┐ + │ Integration (15) │ API 函数 + Apollo Mock + └───────────────────┘ + ┌───────────────────────────┐ + │ Unit (40+ existing) │ Widget 渲染、API 函数纯逻辑 + └───────────────────────────┘ +``` + +### 7.2 新增测试 + +**API 层单测(lib/api/**tests**/)**: + +- 每个 domain 一个测试文件,覆盖所有导出函数 +- 用 `MockedProvider` mock Apollo Client +- 断言:返回值形状、错误归一化、参数传递 + +```typescript +// 示例:parent.test.ts +it("useParentChildren returns normalized ChildSummary[]", async () => { + const mocks = [ + { + request: { query: GET_MY_CHILDREN_DOC }, + result: { data: { myChildren: [{ id: "1", name: "Tom", ... }] } }, + }, + ]; + renderHook(() => useParentChildren(), { wrapper: mockWrapper(mocks) }); + // ... await waitFor(() => expect(result.current.data).toHaveLength(1)) +}); +``` + +**Widget 集成测试(迁移后保留)**: + +- 现有 30 个测试用例不破坏 +- 每个 widget 增加一个"API 层 mock"集成测试 + +**安全栈测试**: + +- APQ 行为测试:hash 命中、未命中回退、manifest 拒绝 +- 深度限制测试:构造 11 层查询应被拒 +- 复杂度限制测试:构造 `first: 100000` 应被拒 +- @auth 测试:无 token 访问敏感字段应 403 + +### 7.3 测试覆盖率目标 + +- API 层:≥ 90% +- Widget 层:维持现状(≥ 70%) +- 安全栈:100%(关键路径) + +--- + +## 8. 实施阶段 + +### 8.1 阶段划分 + +``` +M1: 基础设施搭建 ──── M2: 31 插件全量迁移 ──── M3: 安全加固 ──── M4: 文档同步 +``` + +### 8.2 各阶段交付物 + +| 阶段 | 范围 | 交付物 | 验收 | +| ------ | --------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------- | +| **M1** | 基础设施 | `lib/api/` 骨架、codegen 配置、errors.ts、operations/ 7 个 domain 文件(仅 gql 文档)、单测脚手架 | typecheck 通过、空骨架可跑 | +| **M2** | 31 插件全量迁移 | 7 个 domain API 文件、31 个 widget 改造、删除所有老 gql 字面量、APQ Client 配置 | 31 插件测试通过、`gql` 字面量数 = 0 | +| **M3** | 安全加固 | apollo-router router.yaml、PQ manifest 生成脚本、@auth 审计与补齐、深度/复杂度限制、安全测试 | 生产 PQ 生效、深度/复杂度测试通过 | +| **M4** | 文档同步 | 004 新增"前端数据访问层"章节、known-issues §2.17 追加经验、security 审计报告、arch.db 更新 | 文档 review | + +### 8.3 关键依赖与并行性 + +- M1 必须先完成(其他阶段依赖骨架) +- M2 依赖 M1,单 PR 完成 +- M3 依赖 M2(manifest 需要全部 operations 文件就绪) +- M4 在 M3 完成后 + +### 8.4 风险与缓解 + +| 风险 | 缓解 | +| ------------------------------- | --------------------------------------------------------------------- | +| codegen 与 NestJS schema 不同步 | CI 在 typecheck 前强制执行 `pnpm run codegen`,schema 变更触发通知 | +| APQ manifest 漏注册新 query | manifest 由构建时自动生成,不手动维护 | +| 全量迁移触发隐藏 bug | M2 完成前不合并 main,分支跑完整测试套件(30 现有 + 新增 API 层测试) | +| @auth 补齐影响现有功能 | 先在测试环境部署,跑全量 E2E 再上生产 | +| persisted query 在 SSR 失效 | Apollo Client 配置 `ssrMode: false` + 客户端 hydrate(已有) | +| M2 单 PR 体积过大 | 提交按 domain 分 commit(7 个 commit),便于 review 与回滚 | + +--- + +## 9. 验收指标 + +| 维度 | 当前 | 目标 | +| ----------------------------- | ------- | -------------------- | +| 组件中 `gql` 字面量 | 92 处 | 0 处 | +| 手写 `interface XxxQueryData` | 92 处 | 0 处(全 codegen) | +| 插件平均行数 | ~150 行 | ≤ 80 行(UI 为主) | +| 生产环境明文 query | 92 处 | 0 处(全 hash) | +| 敏感字段 @auth 覆盖率 | 未知 | 100%(审计后补齐) | +| 查询复杂度上限 | 无 | 单 query ≤ 1000 cost | +| 查询深度上限 | 无 | max_depth = 10 | +| API 层测试覆盖率 | 0% | ≥ 90% | +| 现有 30 个测试 | 30/30 | 30/30(不破坏) | + +--- + +## 10. 关联约束 + +### 10.1 项目规则遵循 + +- §3.4 TS 规则:禁 `any`、禁 `as`(除 `unknown` 转换)、显式返回类型、`import type` +- §3.8 Controller 规范:每个 Query/Mutation 必须有 `@RequirePermission()` +- §3.10 设计令牌规范:新增代码不引入硬编码颜色/字体/字号 +- §4 安全规范:JWT 校验、CORS 白名单、限流 +- §14.2 模块边界:仅改 portal-shell + apollo-router 配置 + 子图 @auth 装饰器级修改 + +### 10.2 与 v2.1 架构原则对齐 + +- "DataScope 跨服务数据需求必须使用 @requires 运行时解析" → 本 spec 不做(属下一 spec),但本 spec 的 API 层为未来 @requires 下沉做好准备 +- "外部查询必须且只能通过 Apollo Router 聚合 GraphQL" → 本 spec 强化此约束(持久化查询) +- "BFF 层不得包含手动聚合代码" → 本 spec 在 widget 层引入 API 抽象,与 BFF 层职责对齐 + +--- + +## 11. 后续规划(不在本 spec 范围) + +1. **跨域聚合下沉**:将 `myChildren` 等 3 个跨域查询用 @requires 指令下沉到 apollo-router +2. **fragment 共享**:观察 API 层稳定后的查询重复程度,决定是否引入 GraphQL fragment +3. **第三方插件 SDK**:基于 `lib/api/` 暴露的语义化函数,设计第三方插件契约 +4. **性能优化**:批合并、缓存策略、预取策略 +5. **查询埋点**:在 API 层注入 metrics,监控查询性能与失败率 + +--- + +## 12. 术语表 + +| 术语 | 含义 | +| ------------------- | --------------------------------------------------------------------- | +| APQ | Automatic Persisted Queries,Apollo Client 自动持久化查询机制 | +| manifest | persisted query hash → query 文本的白名单映射 | +| @auth | NestJS 字段级权限装饰器 | +| @requires | Apollo Federation 指令,子图声明跨域字段依赖 | +| domain | 业务领域划分(universal/sidebar/topbar/teacher/student/parent/admin) | +| operations | GraphQL 查询文档集合(gql 字符串) | +| codegen | graphql-codegen,根据 schema 与 operations 自动生成 TypeScript 类型 | +| `useWidgetQuery` | portal-shell 现有的 Apollo Client 查询包装 Hook(保留) | +| `useWidgetMutation` | portal-shell 现有的 Apollo Client mutation 包装 Hook(保留) | + +--- + +**本 spec 完成后,portal-shell 将具备:** + +- 工程整洁的 4 层数据访问分层(Widget → API → Operations → Hook) +- 生产级 GraphQL 安全栈(APQ + manifest + 深度/复杂度限制 + @auth 全覆盖) +- 与 v2.1 微服务架构原则对齐的前端抽象层 +- 为未来 @requires 下沉做好准备的 API 层契约