Files
Edu/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md
SpecialX 117c89396d docs(portal-shell): add implementation plan for data abstraction & GraphQL hardening
- Plan: 20 tasks across M1-M4 phases

- Spec: fix useWidgetMutation destructure (object, not array)
2026-07-17 12:03:03 +08:00

37 KiB
Raw Blame History

Portal Shell 数据抽象层与 GraphQL 安全加固设计

版本v1.0 日期2026-07-17 状态:待评审 关联:


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

由此引发四类问题:

  1. Schema 耦合widget 直接知道后端字段名与嵌套结构,后端 schema 变更需修改前端组件
  2. 查询重复/碎片化:相同领域查询散落在多个 widget无集中管理
  3. 跨域聚合在前端myChildren 一次查询跨 iam + core-edu + msg 三个子图,聚合责任落在前端
  4. 缺少数据抽象层widget 既写 UI 又写数据获取,职责混淆,平均 150 行/文件

同时存在未显式暴露但真实存在的安全缺口:

  1. 生产环境明文 queryDevTools 可抓到完整 query 文本,攻击者可构造任意查询探测 schema
  2. 无查询深度/复杂度限制:可构造 user { parent { user { parent {...} } } } 递归攻击或 first: 1000000 放大攻击
  3. 字段级 @auth 覆盖率未知:敏感字段(auditLogs.detailsuser.emailauditLog.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. 生产环境零明文 queryApollo 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 函数签名规范

// 查询:返回领域模型(非 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(): {
  run: (input: CreateInvitationCodeInput) => Promise<InvitationCode>;
  loading: boolean;
  error: ApolloError | undefined;
} {
  const {
    run: rawRun,
    loading,
    error,
  } = useWidgetMutation<
    CreateInvitationCodeMutation,
    CreateInvitationCodeMutationVariables
  >(CREATE_INVITATION_CODE_DOC);

  const run = async (
    input: CreateInvitationCodeInput,
  ): Promise<InvitationCode> => {
    const data = await rawRun({ input });
    if (!data?.createInvitationCode) {
      throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR");
    }
    return data.createInvitationCode;
  };

  return { run, loading, error };
}

规范要点

  • 查询返回领域模型ChildSummary[]),不是 GraphQL 原始响应({ myChildren: [...] }),避免 schema 形状泄漏
  • Mutation 包装为 hook返回 { run, loading, error } 对象(与 useWidgetMutation 返回形态一致);runApiErrorwidget 在事件 handler 中 try/catch
  • 命名:查询与 Mutation 都用 use 前缀(useParentChildrenuseCreateInvitationCodeuseApproveLeave),保持 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.tsTypedDocumentNode<TData, TVars> 类型化的 DocumentNode

生成时机

  • 开发:pnpm run codegen:watch 监听 operations/*.graphql.ts 变更
  • CIpnpm 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 前端发 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.1auditLogs(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

格式:

| 子图     | 字段              | 当前守卫                         | 期望守卫                                 | 状态    |
| -------- | ----------------- | -------------------------------- | ---------------------------------------- | ------- |
| iam      | users             | @RequirePermission('user:read')  | 同                                       | ✅      |
| iam      | auditLogs.details | 无                               | @RequirePermission('audit:read:details') | ❌ 补齐 |
| core-edu | grades            | @RequirePermission('grade:read') | 同                                       | ✅      |

补齐策略

  • 仅修改 NestJS resolver 的装饰器,不动 schema 文件结构
  • 装饰器缺失的字段补 @RequirePermission()
  • 敏感字段(detailsipemailphone)补字段级 @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 函数抛 ApiErrorwidget 在 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
  • 断言:返回值形状、错误归一化、参数传递
// 示例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 codegenschema 变更触发通知
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 层契约