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:
@@ -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 = 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/<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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// Mutation(hook 形态,因为 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` | 前端发 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 显示 `<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 依赖 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 层契约
|
||||
Reference in New Issue
Block a user