docs(portal-shell): add data abstraction and GraphQL hardening spec

设计 portal-shell 数据抽象层与 GraphQL 安全加固方案:

- 4 层数据访问分层(Widget -> API -> Operations -> Hook)

- 31 个 widget 全量迁移到 lib/api/ 抽象层

- graphql-codegen 集成,消除手写类型

- Apollo Router 持久化查询(APQ + manifest)防查询探测

- 深度/复杂度限制(max_depth=10, max_cost=1000)

- 8 个子图字段级 @auth 审计与补齐

关联:portal-shell spec v2.1、004 §16、project_rules §3.8/§4
This commit is contained in:
SpecialX
2026-07-17 11:56:35 +08:00
parent 80e6468d62
commit 135efa5ee5

View File

@@ -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/<category>/<name>/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<string, never>
>(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 = 10max_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/<category>/<name>/index.tsx │
│ 职责渲染、交互、URL 状态 │
│ 禁止gql 字面量、Apollo Client 直接调用、手写 QueryData 类型 │
└─────────────────────────────────────────────────────────────┘
↓ 调用
┌─────────────────────────────────────────────────────────────┐
│ API 层(语义化函数) │
│ src/lib/api/<domain>.ts │
│ 职责:领域语义封装、错误归一化、返回领域模型 │
│ 导出useParentChildren(), useAuditLogs(filter), ... │
└─────────────────────────────────────────────────────────────┘
↓ 调用
┌─────────────────────────────────────────────────────────────┐
│ Operations 层GraphQL 文档) │
│ src/lib/api/operations/<domain>.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<ChildSummary[]> {
const result = useWidgetQuery<GetMyChildrenQuery, Record<string, never>>(
GET_MY_CHILDREN_DOC,
{},
);
return {
...result,
data: result.data?.myChildren ?? [],
};
}
// 带参数的查询
export function useAuditLogs(
filter: AuditLogFilter,
pagination: { limit: number; offset: number },
): UseQueryResult<AuditLog[]> {
const result = useWidgetQuery<GetAuditLogsQuery, GetAuditLogsQueryVariables>(
GET_AUDIT_LOGS_DOC,
{ filter, ...pagination },
);
return {
...result,
data: result.data?.auditLogs.items ?? [],
};
}
// Mutationhook 形态,因为 useWidgetMutation 是 React Hook
export function useCreateInvitationCode(): [
(input: CreateInvitationCodeInput) => Promise<InvitationCode>,
UseMutationResult,
] {
const [run, result] = useWidgetMutation<
CreateInvitationCodeMutation,
CreateInvitationCodeMutationVariables
>(CREATE_INVITATION_CODE_DOC);
const wrappedRun = async (
input: CreateInvitationCodeInput,
): Promise<InvitationCode> => {
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 规范
- 文件名 `<domain>.ts`,函数名 `use<Entity>`(查询)/ `use<Action><Entity>`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<TData, TVars>` 类型化的 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<ChildSummary[]> {
const result = useWidgetQuery<GetMyChildrenQuery, Record<string, never>>(
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<void> {
const manifest: Record<string, string> = {};
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` | 前端发 hashRouter 接受未知 hash 回退明文,关闭 introspection |
| 生产 | `true` | `true` | `false` | 前端发 hashRouter 拒绝 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 显示 `<ErrorState />` 组件(已有)
- 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 依赖 M2manifest 需要全部 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 分 commit7 个 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 QueriesApollo 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 层契约