# 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 层契约