设计 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
37 KiB
Portal Shell 数据抽象层与 GraphQL 安全加固设计
版本:v1.0 日期:2026-07-17 状态:待评审 关联:
- Portal Shell 插件化仪表盘设计 v2.1
- 004 架构影响地图 §16 portal-shell
- 0010 架构蓝图 §4 数据流
- 项目规则 §3.8 Controller 规范、§4 安全规范
1. 背景与目标
1.1 问题诊断
portal-shell v2.1 已完成 31 个内置插件的迁移,但所有插件在 widgets/<category>/<name>/index.tsx 中直接内嵌 gql 模板字符串调用 apollo-router:
// 当前形态(每个 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。
由此引发四类问题:
- Schema 耦合:widget 直接知道后端字段名与嵌套结构,后端 schema 变更需修改前端组件
- 查询重复/碎片化:相同领域查询散落在多个 widget,无集中管理
- 跨域聚合在前端:
myChildren一次查询跨 iam + core-edu + msg 三个子图,聚合责任落在前端 - 缺少数据抽象层:widget 既写 UI 又写数据获取,职责混淆,平均 150 行/文件
同时存在未显式暴露但真实存在的安全缺口:
- 生产环境明文 query:DevTools 可抓到完整 query 文本,攻击者可构造任意查询探测 schema
- 无查询深度/复杂度限制:可构造
user { parent { user { parent {...} } } }递归攻击或first: 1000000放大攻击 - 字段级 @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 安全栈",一次解决架构耦合与安全缺口两个维度:
- 零
gql字面量:31 个 widget 全部迁移到lib/api/抽象层,widget 只调函数不发查询 - 零手写类型:所有 TypeScript 类型由
graphql-codegen生成 - 生产环境零明文 query:Apollo Router 启用 APQ + manifest 校验,生产模式拒绝 manifest 之外的查询
- 深度/复杂度限制:max_depth = 10,max_cost = 1000
- 字段级 @auth 全覆盖:审计 7 个子图,敏感字段补齐守卫
- 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 函数签名规范
// 查询:返回领域模型(非 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
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 为例:
// 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 端配置
// 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(生产用):
// 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 配置
# 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
审计方法:
- 用
pnpm run arch:query -- symbol-refs "@ResolveField"列出所有字段 resolver - 用
pnpm run arch:query -- symbol-refs "@Query"列出所有 Query - 用
pnpm run arch:query -- symbol-refs "@Mutation"列出所有 Mutation - 逐个核查是否有
@RequirePermission()或字段级@auth指令
审计输出:docs/security/graphql-auth-audit-2026-07.md
格式:
| 子图 | 字段 | 当前守卫 | 期望守卫 | 状态 |
| -------- | ----------------- | -------------------------------- | ---------------------------------------- | ------- |
| 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 统一错误类型
// 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 层错误归一化
// 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 一个测试文件,覆盖所有导出函数
- 用
MockedProvidermock Apollo Client - 断言:返回值形状、错误归一化、参数传递
// 示例: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 范围)
- 跨域聚合下沉:将
myChildren等 3 个跨域查询用 @requires 指令下沉到 apollo-router - fragment 共享:观察 API 层稳定后的查询重复程度,决定是否引入 GraphQL fragment
- 第三方插件 SDK:基于
lib/api/暴露的语义化函数,设计第三方插件契约 - 性能优化:批合并、缓存策略、预取策略
- 查询埋点:在 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 层契约