From 117c89396d32e3f7dfe03446487f6a7fea1ce896 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:03:03 +0800 Subject: [PATCH] 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) --- ...-data-abstraction-and-graphql-hardening.md | 1631 +++++++++++++++++ ...bstraction-and-graphql-hardening-design.md | 23 +- 2 files changed, 1645 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md diff --git a/docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md b/docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md new file mode 100644 index 0000000..b938412 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md @@ -0,0 +1,1631 @@ +# Portal Shell 数据抽象层与 GraphQL 安全加固 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 portal-shell 31 个 widget 的内嵌 `gql` 字面量重构为 4 层数据访问抽象(Widget → API → Operations → Hook),同时为 apollo-router 加固生产级 GraphQL 安全栈(APQ + manifest + 深度/复杂度限制 + @auth 全覆盖)。 + +**Architecture:** 在 `apps/portal-shell/src/lib/api/` 新建语义化 API 层(7 个 domain 文件 + operations/ 集中文档 + codegen 类型生成),widget 只调函数不发查询;apollo-router 启用 APQ + manifest 校验 + 深度/复杂度限制;8 个子图补齐 `@RequirePermission()` 装饰器。 + +**Tech Stack:** Next.js 14 App Router、@apollo/client 3.11、graphql-codegen、NestJS GraphQL、apollo-router v1.45、vitest、Drizzle ORM。 + +**Spec:** [2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md](../specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) + +**Branch:** `feat/architecture-v2.1`(当前分支,不开新分支) + +**Quality Gates(每 task 后必跑):** + +- TS:`pnpm --filter @edu/portal-shell run typecheck` +- Lint:`pnpm --filter @edu/portal-shell run lint` +- Test:`pnpm --filter @edu/portal-shell run test` + +--- + +## M1:基础设施搭建 + +### Task 1: 创建 lib/api/ 目录骨架与 errors.ts + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/errors.ts` +- Create: `apps/portal-shell/src/lib/api/types.ts` +- Create: `apps/portal-shell/src/lib/api/internal.ts` +- Create: `apps/portal-shell/src/lib/api/index.ts` + +- [ ] **Step 1: 创建 errors.ts** + +```typescript +// apps/portal-shell/src/lib/api/errors.ts +/** + * API 层统一错误类型 + * + * 所有 lib/api/ 函数抛出的错误都用 ApiError 包装, + * 便于 widget 层按 code 分类处理(401 跳登录、403 显示无权限等)。 + * + * 关联:spec §6.1 + */ +export type GraphQLErrorCode = + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "VALIDATION_ERROR" + | "QUERY_DEPTH_EXCEEDED" + | "QUERY_COMPLEXITY_EXCEEDED" + | "PERSISTED_QUERY_NOT_FOUND" + | "INTERNAL_ERROR"; + +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"; + } +} +``` + +- [ ] **Step 2: 创建 types.ts** + +```typescript +// apps/portal-shell/src/lib/api/types.ts +/** + * API 层共享类型 + * + * 跨 domain 复用的分页、筛选等通用类型。 + * 领域专属类型(如 ChildSummary)放在各自的 domain 文件。 + * + * 关联:spec §2.2 + */ +export interface Pagination { + limit: number; + offset: number; +} + +export interface PaginatedResult { + items: T[]; + total: number; +} + +export interface UseQueryResult { + data: TData | undefined; + loading: boolean; + error: unknown; + refetch: () => Promise; +} + +export interface UseMutationResult { + loading: boolean; + error: unknown; +} +``` + +- [ ] **Step 3: 创建 internal.ts** + +```typescript +// apps/portal-shell/src/lib/api/internal.ts +/** + * API 层共享工具 + * + * 错误归一化:把 ApolloError 转为 ApiError, + * 让 widget 层可以用 instanceof ApiError 判断。 + * + * 关联:spec §6.2 + */ +import type { ApolloError } from "@apollo/client"; +import { ApiError, type 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); +} +``` + +- [ ] **Step 4: 创建 index.ts(barrel,先空)** + +```typescript +// apps/portal-shell/src/lib/api/index.ts +/** + * API 层统一出口 + * + * widget 通过 `import { useParentChildren } from "@/lib/api"` 调用。 + * 各 domain 文件在 M2 阶段逐步加入。 + * + * 关联:spec §2.2 + */ +export * from "./errors"; +export * from "./types"; +``` + +- [ ] **Step 5: 跑 typecheck 验证骨架** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS(零错误) + +- [ ] **Step 6: Commit** + +```bash +git add apps/portal-shell/src/lib/api/ +git commit -m "feat(portal-shell): add lib/api skeleton with errors and types" +``` + +--- + +### Task 2: 配置 graphql-codegen + +**Files:** + +- Create: `apps/portal-shell/codegen.ts` +- Modify: `apps/portal-shell/package.json`(新增 scripts 与 devDependencies) +- Create: `apps/portal-shell/src/lib/api/operations/.gitkeep` +- Modify: `apps/portal-shell/.gitignore`(新增 `src/lib/api/__generated__/`) + +- [ ] **Step 1: 新增 codegen 配置** + +```typescript +// apps/portal-shell/codegen.ts +/** + * graphql-codegen 配置 + * + * schema 来源:本地 SDL 文件(services/*/src/graphql/generated/schema.graphql) + * 避免开发时强依赖 apollo-router 运行。 + * + * 产物: + * - __generated__/types.ts:所有 GraphQL 类型 + * - __generated__/operations.ts:TypedDocumentNode(类型化文档) + * + * 关联:spec §2.4 + */ +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: [ + "../../services/iam/src/graphql/generated/schema.graphql", + "../../services/config-service/src/graphql/generated/schema.graphql", + "../../services/core-edu/src/graphql/generated/schema.graphql", + "../../services/content/src/graphql/generated/schema.graphql", + "../../services/msg/src/graphql/generated/schema.graphql", + "../../services/data-ana/src/graphql/generated/schema.graphql", + "../../services/ai/src/graphql/generated/schema.graphql", + ], + 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-document-nodes"], + }, + }, + config: { + preResolveTypes: true, + skipTypename: true, + exportTypeKeyOnly: true, + useTypeImports: true, + }, +}; +export default config; +``` + +- [ ] **Step 2: 更新 package.json scripts 与 devDependencies** + +修改 `apps/portal-shell/package.json`,在 `scripts` 中新增: + +```json +"codegen": "graphql-codegen --config codegen.ts", +"codegen:watch": "graphql-codegen --config codegen.ts --watch" +``` + +在 `devDependencies` 中新增: + +```json +"@graphql-codegen/cli": "^5.0.0", +"@graphql-codegen/typescript": "^4.0.0", +"@graphql-codegen/typescript-operations": "^4.0.0", +"@graphql-codegen/typescript-document-nodes": "^4.0.0" +``` + +- [ ] **Step 3: 创建 operations 目录占位** + +创建空文件 `apps/portal-shell/src/lib/api/operations/.gitkeep`。 + +- [ ] **Step 4: 更新 .gitignore** + +在 `apps/portal-shell/.gitignore` 末尾追加: + +``` +# graphql-codegen 产物(构建时生成) +src/lib/api/__generated__/ +``` + +- [ ] **Step 5: 安装新依赖** + +Run: `pnpm install` +Expected: 安装成功,无 ERR_PNPM + +- [ ] **Step 6: 跑 codegen 验证(无 operations 时产物为空)** + +Run: `pnpm --filter @edu/portal-shell run codegen` +Expected: 生成 `src/lib/api/__generated__/types.ts` 与 `operations.ts`(内容可能为空或仅含基础类型) + +- [ ] **Step 7: 跑 typecheck 验证** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/portal-shell/codegen.ts apps/portal-shell/package.json apps/portal-shell/src/lib/api/operations/.gitkeep apps/portal-shell/.gitignore pnpm-lock.yaml +git commit -m "feat(portal-shell): add graphql-codegen configuration" +``` + +--- + +### Task 3: 创建 7 个 operations 文件(仅 gql 文档,从 widget 抽取) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/operations/universal.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/sidebar.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/topbar.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/teacher.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/student.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/parent.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/admin.graphql.ts` +- Create: `apps/portal-shell/src/lib/api/operations/index.ts`(barrel) +- Delete: `apps/portal-shell/src/lib/api/operations/.gitkeep` + +**说明:** 此 task 仅抽取 gql 字面量到 operations 文件,widget 仍引用原来的 gql(M2 才切换)。这样 codegen 可以先生成类型。 + +- [ ] **Step 1: universal.graphql.ts(7 widget)** + +```typescript +// apps/portal-shell/src/lib/api/operations/universal.graphql.ts +/** + * Universal domain GraphQL 文档 + * + * 涵盖:grades / homework / schedule / attendance / exams / notifications / announcements + * 数据源:core-edu / msg 子图 + * + * 关联:spec §2.2 + */ +import { gql } from "@apollo/client"; + +export const GET_GRADES_DOC = gql` + query GetGrades($classId: ID!) { + grades(classId: $classId) { + studentId + score + } + } +`; + +export const GET_HOMEWORK_DOC = gql` + query GetHomework($classId: ID!, $limit: Int, $offset: Int) { + homework(classId: $classId, limit: $limit, offset: $offset) { + items { + id + title + dueDate + submitted + } + total + } + } +`; + +export const GET_SCHEDULE_DOC = gql` + query GetSchedule($classId: ID!, $date: String!) { + schedule(classId: $classId, date: $date) { + id + startTime + endTime + subject + teacher + } + } +`; + +export const GET_ATTENDANCE_DOC = gql` + query GetAttendance($classId: ID!) { + attendance(classId: $classId) { + present + total + rate + } + } +`; + +export const GET_EXAMS_DOC = gql` + query GetExams($classId: ID!) { + exams(classId: $classId) { + id + title + date + subject + duration + } + } +`; + +export const GET_NOTIFICATIONS_DOC = gql` + query GetNotifications($limit: Int, $offset: Int) { + notifications(limit: $limit, offset: $offset) { + items { + id + title + content + createdAt + read + } + total + } + } +`; + +export const GET_ANNOUNCEMENTS_DOC = gql` + query GetAnnouncements($limit: Int, $offset: Int) { + announcements(limit: $limit, offset: $offset) { + items { + id + title + content + createdAt + author + } + total + } + } +`; +``` + +**注意:** 上面的查询字段是从 widget 抽取的"理想形态"。实际抽取时需读取每个 widget 的 index.tsx,复制其原始 gql 字符串(字段名必须与现有完全一致,避免行为变化)。若 widget 的 gql 引用了后端不存在的字段,codegen 会失败——此时需修正 widget 原始查询。 + +- [ ] **Step 2: 逐个 widget 读取并抽取 gql** + +对每个 widget 执行: + +1. Read `apps/portal-shell/src/widgets///index.tsx` +2. 复制 `const XXX = gql\`...\`` 内容 +3. 粘贴到对应 operations 文件,重命名为 `XXX_DOC` +4. 重复直到 31 个 widget 全部抽取 + +**执行顺序:** universal(7) → sidebar(4) → topbar(4) → teacher(4) → student(4) → parent(2) → admin(6) + +- [ ] **Step 3: 创建 operations/index.ts barrel** + +```typescript +// apps/portal-shell/src/lib/api/operations/index.ts +/** + * Operations 统一出口 + * + * 所有 GraphQL 文档集中导出,供 lib/api/.ts 调用。 + */ +export * from "./universal.graphql"; +export * from "./sidebar.graphql"; +export * from "./topbar.graphql"; +export * from "./teacher.graphql"; +export * from "./student.graphql"; +export * from "./parent.graphql"; +export * from "./admin.graphql"; +``` + +- [ ] **Step 4: 删除 .gitkeep** + +删除 `apps/portal-shell/src/lib/api/operations/.gitkeep`。 + +- [ ] **Step 5: 跑 codegen 生成类型** + +Run: `pnpm --filter @edu/portal-shell run codegen` +Expected: 生成 `__generated__/types.ts`(含所有 query/mutation 类型) + +如果失败:检查 operations 中的字段名是否与 services/*/schema.graphql 匹配,修正后重跑。 + +- [ ] **Step 6: 跑 typecheck 验证** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS + +- [ ] **Step 7: 跑现有测试确保不破坏** + +Run: `pnpm --filter @edu/portal-shell run test` +Expected: 30/30 PASS(widget 仍用老 gql,未切换) + +- [ ] **Step 8: Commit** + +```bash +git add apps/portal-shell/src/lib/api/operations/ +git commit -m "feat(portal-shell): extract gql documents to operations layer" +``` + +--- + +## M2:31 插件全量迁移 + +**M2 总览:** 按 7 个 domain 分 7 个 commit,每个 commit 完成该 domain 的 API 文件 + 所有 widget 改造。M2 全部完成后统一跑测试。 + +**M2 通用步骤(每个 domain 重复):** + +1. 创建 `lib/api/.ts`,写语义化函数 +2. 改造该 domain 下所有 widget 的 `index.tsx`,删除 gql/interface/手写类型,改调 API 函数 +3. 跑 typecheck + lint + test +4. Commit + +**迁移后 widget 模板(所有 domain 通用):** + +```typescript +"use client"; + +import { useXxx } from "@/lib/api"; +import { PluginSkeleton } from "@/shell/PluginLoader"; +import type { PluginProps } from "@/lib/types"; + +export default function XxxWidget(props: PluginProps): React.ReactElement { + const { data, loading } = useXxx(/* params */); + if (loading && !data) return ; + // ... UI 渲染 +} +``` + +### Task 4: parent domain 迁移(2 widget:child-overview / leave-approval) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/parent.ts` +- Modify: `apps/portal-shell/src/widgets/parent/child-overview/index.tsx` +- Modify: `apps/portal-shell/src/widgets/parent/leave-approval/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts`(新增 `export * from "./parent"`) +- Test: `apps/portal-shell/src/lib/api/__tests__/parent.test.ts` + +- [ ] **Step 1: 创建 parent.ts API 文件** + +```typescript +// apps/portal-shell/src/lib/api/parent.ts +/** + * Parent domain API + * + * 涵盖:useParentChildren(跨 iam + core-edu + msg)、useLeaveApprovals、useApproveLeave + * + * 关联:spec §2.3、§3.3 + */ +import type { + GetMyChildrenQuery, + GetLeaveApprovalsQuery, +} from "@/lib/api/__generated__/types"; +import { + GET_MY_CHILDREN_DOC, + GET_LEAVE_APPROVALS_DOC, + APPROVE_LEAVE_DOC, +} from "@/lib/api/operations/parent.graphql"; +import { useWidgetQuery } from "@/lib/useWidgetQuery"; +import { useWidgetMutation } from "@/lib/useWidgetMutation"; +import type { UseQueryResult } from "./types"; +import { ApiError } from "./errors"; + +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 interface LeaveApproval { + id: string; + studentName: string; + className: string; + startDate: string; + endDate: string; + reason: string; + status: "pending" | "approved" | "rejected"; +} + +export function useParentChildren(): UseQueryResult { + const result = useWidgetQuery>( + GET_MY_CHILDREN_DOC, + {}, + ); + return { + ...result, + data: (result.data?.myChildren ?? []) as ChildSummary[], + }; +} + +export function useLeaveApprovals(): UseQueryResult { + const result = useWidgetQuery>( + GET_LEAVE_APPROVALS_DOC, + {}, + ); + return { + ...result, + data: (result.data?.leaveApprovals ?? []) as LeaveApproval[], + }; +} + +export function useApproveLeave(): { + run: (id: string, approved: boolean) => Promise; + loading: boolean; + error: unknown; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation< + { approveLeave: { id: string; status: string } }, + { id: string; approved: boolean } + >(APPROVE_LEAVE_DOC); + + const run = async (id: string, approved: boolean): Promise => { + const data = await rawRun({ id, approved }); + if (!data?.approveLeave) { + throw new ApiError("Failed to approve leave", "INTERNAL_ERROR"); + } + }; + + return { run, loading, error }; +} +``` + +- [ ] **Step 2: 改造 child-overview/index.tsx** + +读取 `apps/portal-shell/src/widgets/parent/child-overview/index.tsx`,按模板改造: + +- 删除 `import { gql } from "@apollo/client"` +- 删除 `const GET_MY_CHILDREN = gql\`...\`` +- 删除 `interface ChildInfo` 与 `interface MyChildrenQueryData` +- 删除 `import { useWidgetQuery } from "@/lib/useWidgetQuery"` +- 新增 `import { useParentChildren } from "@/lib/api"` +- 将 `const { data, loading } = useWidgetQuery(GET_MY_CHILDREN, {})` 改为 `const { data: children, loading } = useParentChildren()` +- 将 `const children = data?.myChildren ?? []` 删除(已在 hook 内处理) +- 保留所有 UI 渲染逻辑不变 + +- [ ] **Step 3: 改造 leave-approval/index.tsx** + +同样模式: + +- 删除 gql / interface / useWidgetQuery +- 新增 `import { useLeaveApprovals, useApproveLeave } from "@/lib/api"` +- 改用 `const { data: approvals, loading } = useLeaveApprovals()` +- 改用 `const { run: approveLeave, loading: approving } = useApproveLeave()` +- 在 onClick handler 中用 `try { await approveLeave(id, true) } catch (e) { /* toast */ }` + +- [ ] **Step 4: 更新 lib/api/index.ts** + +```typescript +// apps/portal-shell/src/lib/api/index.ts +export * from "./errors"; +export * from "./types"; +export * from "./parent"; +``` + +- [ ] **Step 5: 写 parent.test.ts** + +```typescript +// apps/portal-shell/src/lib/api/__tests__/parent.test.ts +import { describe, it, expect, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { MockedProvider } from "@apollo/client/testing"; +import type { ReactNode } from "react"; +import { useParentChildren } from "../parent"; +import { GET_MY_CHILDREN_DOC } from "../operations/parent.graphql"; + +const mockWrapper = (mocks: unknown) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe("useParentChildren", () => { + it("returns normalized ChildSummary[]", async () => { + const mocks = [ + { + request: { query: GET_MY_CHILDREN_DOC }, + result: { + data: { + myChildren: [ + { + id: "1", + name: "Tom", + grade: "一年级", + className: "1班", + avatar: "", + recentGrades: [{ subject: "数学", score: 90 }], + attendance: { present: 18, total: 20 }, + homeworkCompletion: { completed: 9, total: 10 }, + }, + ], + }, + }, + }, + ]; + const { result } = renderHook(() => useParentChildren(), { + wrapper: mockWrapper(mocks), + }); + await waitFor(() => expect(result.current.data).toHaveLength(1)); + expect(result.current.data?.[0].name).toBe("Tom"); + }); + + it("returns empty array when no data", async () => { + const mocks = [ + { + request: { query: GET_MY_CHILDREN_DOC }, + result: { data: { myChildren: [] } }, + }, + ]; + const { result } = renderHook(() => useParentChildren(), { + wrapper: mockWrapper(mocks), + }); + await waitFor(() => expect(result.current.data).toEqual([])); + }); +}); +``` + +- [ ] **Step 6: 跑 typecheck** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS + +- [ ] **Step 7: 跑 lint** + +Run: `pnpm --filter @edu/portal-shell run lint` +Expected: PASS + +- [ ] **Step 8: 跑 test** + +Run: `pnpm --filter @edu/portal-shell run test` +Expected: 30/30 + 2 new = 32/32 PASS + +- [ ] **Step 9: 验证 child-overview 中无 gql 字面量** + +Run: `grep -c "gql\`" apps/portal-shell/src/widgets/parent/child-overview/index.tsx` +Expected: 0 + +Run: `grep -c "gql\`" apps/portal-shell/src/widgets/parent/leave-approval/index.tsx` +Expected: 0 + +- [ ] **Step 10: Commit** + +```bash +git add apps/portal-shell/src/lib/api/parent.ts apps/portal-shell/src/lib/api/__tests__/parent.test.ts apps/portal-shell/src/lib/api/index.ts apps/portal-shell/src/widgets/parent/ +git commit -m "feat(portal-shell): migrate parent domain widgets to lib/api" +``` + +--- + +### Task 5: admin domain 迁移(6 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/admin.ts` +- Modify: `apps/portal-shell/src/widgets/admin/user-management/index.tsx` +- Modify: `apps/portal-shell/src/widgets/admin/rbac-manager/index.tsx` +- Modify: `apps/portal-shell/src/widgets/admin/audit-logs/index.tsx` +- Modify: `apps/portal-shell/src/widgets/admin/invitation-codes/index.tsx` +- Modify: `apps/portal-shell/src/widgets/admin/school-settings/index.tsx` +- Modify: `apps/portal-shell/src/widgets/admin/plugin-manager/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/admin.test.ts` + +- [ ] **Step 1: 创建 admin.ts API 文件** + +参照 Task 4 Step 1 的模式,提取 6 个 widget 的 gql 到 admin.ts,封装为: + +- `useUsers()` / `useCreateUser()` / `useUpdateUser()` / `useDeleteUser()` +- `useRoles()` / `useCreateRole()` / `useUpdateRole()` / `useDeleteRole()` +- `useAuditLogs(filter, pagination)` +- `useInvitationCodes()` / `useCreateInvitationCode()` / `useRevokeInvitationCode()` +- `useSchool()` / `useUpdateSchool()` +- `usePlugins()` / `useTogglePlugin()` + +每个函数: + +- 查询用 `useWidgetQuery` 包装,返回领域模型 +- Mutation 用 `useWidgetMutation` 包装,返回 `{ run, loading, error }`,`run` 抛 `ApiError` + +- [ ] **Step 2: 逐个改造 6 个 widget** + +按 Task 4 Step 2-3 模式,对每个 admin widget: + +1. 删除 gql / interface / 手写类型 +2. 新增 `import { useXxx } from "@/lib/api"` +3. 改用 API 函数 +4. Mutation 改为 `const { run: xxx, loading } = useXxxMutation()`,在 onClick 中 `try/catch` + +- [ ] **Step 3: 更新 lib/api/index.ts** + +追加 `export * from "./admin";` + +- [ ] **Step 4: 写 admin.test.ts** + +至少覆盖: + +- `useAuditLogs` 返回归一化的 AuditLog[] +- `useCreateInvitationCode` 成功返回 InvitationCode +- `useCreateInvitationCode` 失败抛 ApiError + +- [ ] **Step 5: 跑 typecheck + lint + test** + +Run: `pnpm --filter @edu/portal-shell run typecheck && pnpm --filter @edu/portal-shell run lint && pnpm --filter @edu/portal-shell run test` +Expected: 全部 PASS + +- [ ] **Step 6: 验证 admin widget 中无 gql 字面量** + +Run: `grep -r -c "gql\`" apps/portal-shell/src/widgets/admin/` +Expected: 每个文件 0 + +- [ ] **Step 7: Commit** + +```bash +git add apps/portal-shell/src/lib/api/admin.ts apps/portal-shell/src/lib/api/__tests__/admin.test.ts apps/portal-shell/src/lib/api/index.ts apps/portal-shell/src/widgets/admin/ +git commit -m "feat(portal-shell): migrate admin domain widgets to lib/api" +``` + +--- + +### Task 6: teacher domain 迁移(4 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/teacher.ts` +- Modify: `apps/portal-shell/src/widgets/teacher/lesson-plan-editor/index.tsx` +- Modify: `apps/portal-shell/src/widgets/teacher/question-bank/index.tsx` +- Modify: `apps/portal-shell/src/widgets/teacher/textbook-manager/index.tsx` +- Modify: `apps/portal-shell/src/widgets/teacher/scheduling-rules/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/teacher.test.ts` + +- [ ] **Step 1: 创建 teacher.ts** + +封装: + +- `useLessonPlans(classId)` / `useSaveLessonPlan()` +- `useQuestionBank(filter, pagination)` +- `useTextbooks()` / `useSaveTextbook()` +- `useSchedulingRules(classId)` / `useUpdateSchedulingRule()` + +- [ ] **Step 2-7: 同 Task 5 模式** + +改造 4 个 widget → 更新 index.ts → 写测试 → 跑三检 → 验证无 gql → Commit + +```bash +git commit -m "feat(portal-shell): migrate teacher domain widgets to lib/api" +``` + +--- + +### Task 7: student domain 迁移(4 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/student.ts` +- Modify: `apps/portal-shell/src/widgets/student/error-book/index.tsx` +- Modify: `apps/portal-shell/src/widgets/student/learning-path/index.tsx` +- Modify: `apps/portal-shell/src/widgets/student/elective-selector/index.tsx` +- Modify: `apps/portal-shell/src/widgets/student/ai-tutor/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/student.test.ts` + +- [ ] **Step 1: 创建 student.ts** + +封装: + +- `useErrorBook(studentId)` +- `useLearningPath(studentId)` +- `useElectives(studentId)` / `useSelectElective()` +- `useAiTutor(sessionId)` / `useSendAiTutorMessage()` + +**注意 ai-tutor 是跨域聚合(ai + core-edu + content)**,但本次仅迁移到 API 层,不做 @requires 下沉。 + +- [ ] **Step 2-7: 同 Task 6 模式** + +```bash +git commit -m "feat(portal-shell): migrate student domain widgets to lib/api" +``` + +--- + +### Task 8: universal domain 迁移(7 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/universal.ts` +- Modify: `apps/portal-shell/src/widgets/universal/grades-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/homework-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/schedule-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/attendance-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/exams-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/notifications-widget/index.tsx` +- Modify: `apps/portal-shell/src/widgets/universal/announcements-widget/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/universal.test.ts` + +- [ ] **Step 1: 创建 universal.ts** + +封装: + +- `useGrades(classId)` +- `useHomework(classId, pagination)` +- `useSchedule(classId, date)` +- `useAttendance(classId)` +- `useExams(classId)` +- `useNotifications(pagination)` +- `useAnnouncements(pagination)` + +- [ ] **Step 2-7: 同 Task 7 模式** + +```bash +git commit -m "feat(portal-shell): migrate universal domain widgets to lib/api" +``` + +--- + +### Task 9: sidebar domain 迁移(4 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/sidebar.ts` +- Modify: `apps/portal-shell/src/widgets/sidebar/class-selector/index.tsx` +- Modify: `apps/portal-shell/src/widgets/sidebar/child-selector/index.tsx` +- Modify: `apps/portal-shell/src/widgets/sidebar/term-switcher/index.tsx` +- Modify: `apps/portal-shell/src/widgets/sidebar/quick-actions/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/sidebar.test.ts` + +- [ ] **Step 1: 创建 sidebar.ts** + +封装: + +- `useClasses(teacherId?)` +- `useChildren(parentId)` +- `useTerms()` / `useSwitchTerm()` +- `useQuickActions(role)` + +- [ ] **Step 2-7: 同 Task 8 模式** + +```bash +git commit -m "feat(portal-shell): migrate sidebar domain widgets to lib/api" +``` + +--- + +### Task 10: topbar domain 迁移(4 widget) + +**Files:** + +- Create: `apps/portal-shell/src/lib/api/topbar.ts` +- Modify: `apps/portal-shell/src/widgets/topbar/notification-bell/index.tsx` +- Modify: `apps/portal-shell/src/widgets/topbar/user-menu/index.tsx` +- Modify: `apps/portal-shell/src/widgets/topbar/global-search/index.tsx` +- Modify: `apps/portal-shell/src/widgets/topbar/locale-switcher/index.tsx` +- Modify: `apps/portal-shell/src/lib/api/index.ts` +- Test: `apps/portal-shell/src/lib/api/__tests__/topbar.test.ts` + +- [ ] **Step 1: 创建 topbar.ts** + +封装: + +- `useNotificationBell()`(unread count) +- `useCurrentUser()` +- `useGlobalSearch(query)`(跨域,特殊处理) +- `useLocale()` / `useSwitchLocale()` + +- [ ] **Step 2-7: 同 Task 9 模式** + +```bash +git commit -m "feat(portal-shell): migrate topbar domain widgets to lib/api" +``` + +--- + +### Task 11: M2 收尾——验证零 gql 字面量 + 全量测试 + +- [ ] **Step 1: 全项目扫描 widget 中的 gql 字面量** + +Run: `grep -r "gql\`" apps/portal-shell/src/widgets/` +Expected: 无输出(0 处) + +如有残留:定位文件,补迁移。 + +- [ ] **Step 2: 全项目扫描 widget 中的 useWidgetQuery/useWidgetMutation 直接调用** + +Run: `grep -r "useWidgetQuery\|useWidgetMutation" apps/portal-shell/src/widgets/` +Expected: 无输出(0 处,全部走 lib/api) + +如有残留:定位文件,补迁移。 + +- [ ] **Step 3: 全项目扫描 widget 中的手写 QueryData interface** + +Run: `grep -r "interface.*QueryData" apps/portal-shell/src/widgets/` +Expected: 无输出(0 处) + +- [ ] **Step 4: 跑完整 typecheck** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS + +- [ ] **Step 5: 跑完整 lint** + +Run: `pnpm --filter @edu/portal-shell run lint` +Expected: PASS + +- [ ] **Step 6: 跑完整 test** + +Run: `pnpm --filter @edu/portal-shell run test` +Expected: 30 原有 + 7 domain × ~3 case = ~51/51 PASS + +- [ ] **Step 7: 跑 codegen 确认类型最新** + +Run: `pnpm --filter @edu/portal-shell run codegen` +Expected: 生成无错 + +- [ ] **Step 8: Commit M2 收尾标记** + +```bash +git commit --allow-empty -m "chore(portal-shell): M2 complete - 31 widgets migrated to lib/api" +``` + +--- + +## M3:安全加固 + +### Task 12: Apollo Client 启用 APQ + +**Files:** + +- Create: `apps/portal-shell/src/lib/apollo-client.ts`(若不存在)或修改现有 Apollo Provider +- Modify: `apps/portal-shell/package.json`(新增 `crypto-hash` 依赖) +- Modify: `apps/portal-shell/.env.example`(新增 `NEXT_PUBLIC_APOLLO_APQ`) + +- [ ] **Step 1: 安装 crypto-hash 依赖** + +Run: `pnpm --filter @edu/portal-shell add crypto-hash` +Expected: 安装成功 + +- [ ] **Step 2: 创建/修改 apollo-client.ts** + +```typescript +// apps/portal-shell/src/lib/apollo-client.ts +/** + * Apollo Client 实例 + * + * 启用 APQ(Automatic Persisted Queries): + * - 生产环境前端只发 query hash,不发明文 query + * - Router 通过 hash 查找 manifest 中的 query 文本 + * - 防止攻击者通过 DevTools 构造任意查询探测 schema + * + * 关联:spec §4.1 + */ +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 便于调试(NEXT_PUBLIC_APOLLO_APQ=false) +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(), + ssrMode: false, +}); +``` + +- [ ] **Step 3: 找到现有 ApolloProvider 注入点,改用 apolloClient** + +Run: `grep -r "ApolloProvider" apps/portal-shell/src/` +找到 provider 所在文件,确保用 `import { apolloClient } from "@/lib/apollo-client"`。 + +- [ ] **Step 4: 更新 .env.example** + +在 `apps/portal-shell/.env.example` 末尾追加: + +``` +# Apollo Client APQ 开关(生产 true,开发可 false 调试) +NEXT_PUBLIC_APOLLO_APQ=true +``` + +- [ ] **Step 5: 跑 typecheck + lint + test** + +Run: `pnpm --filter @edu/portal-shell run typecheck && pnpm --filter @edu/portal-shell run lint && pnpm --filter @edu/portal-shell run test` +Expected: 全 PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/portal-shell/src/lib/apollo-client.ts apps/portal-shell/package.json apps/portal-shell/.env.example pnpm-lock.yaml +git commit -m "feat(portal-shell): enable Apollo Client APQ" +``` + +--- + +### Task 13: 创建 PQ Manifest 生成脚本 + +**Files:** + +- Create: `apps/portal-shell/scripts/generate-pq-manifest.ts` +- Modify: `apps/portal-shell/package.json`(新增 `generate-pq-manifest` script) +- Modify: `apps/portal-shell/next.config.js`(prebuild 钩子) + +- [ ] **Step 1: 创建 generate-pq-manifest.ts** + +```typescript +// apps/portal-shell/scripts/generate-pq-manifest.ts +/** + * Persisted Query Manifest 生成脚本 + * + * 构建时遍历所有 operations,生成 hash → query 文本的白名单。 + * 部署到 apollo-router 容器,生产模式拒绝 manifest 之外的查询。 + * + * 关联:spec §4.2 + */ +import { print } from "graphql"; +import { sha256 } from "crypto-hash"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as operations from "../src/lib/api/operations"; + +async function generateManifest(): Promise { + const manifest: Record = {}; + for (const [name, doc] of Object.entries(operations)) { + if (typeof doc === "object" && doc !== null && "loc" in doc) { + const query = print(doc as never); + 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); +}); +``` + +- [ ] **Step 2: 更新 package.json scripts** + +在 `apps/portal-shell/package.json` 的 scripts 中新增: + +```json +"generate-pq-manifest": "tsx scripts/generate-pq-manifest.ts", +"prebuild": "pnpm run codegen && pnpm run generate-pq-manifest" +``` + +新增 devDependency: + +```json +"tsx": "^4.0.0" +``` + +- [ ] **Step 3: 安装 tsx** + +Run: `pnpm install` +Expected: 安装成功 + +- [ ] **Step 4: 跑 generate-pq-manifest 验证** + +Run: `pnpm --filter @edu/portal-shell run generate-pq-manifest` +Expected: 生成 `apps/portal-shell/public/pq-manifest.json`,含所有 operations 的 hash + +- [ ] **Step 5: 验证 manifest 内容** + +Run: Read `apps/portal-shell/public/pq-manifest.json` +Expected: JSON 对象,key 是 sha256 hash,value 是 query 文本 + +- [ ] **Step 6: 跑 typecheck** + +Run: `pnpm --filter @edu/portal-shell run typecheck` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/portal-shell/scripts/generate-pq-manifest.ts apps/portal-shell/package.json apps/portal-shell/public/pq-manifest.json pnpm-lock.yaml +git commit -m "feat(portal-shell): add PQ manifest generation script" +``` + +--- + +### Task 14: apollo-router 启用 APQ + manifest + 深度/复杂度限制 + +**Files:** + +- Modify: `infra/apollo-router/router.yaml` +- Modify: `infra/apollo-router/Dockerfile`(COPY manifest) +- Modify: `infra/apollo-router/entrypoint.sh`(启动前校验 manifest 存在) +- Modify: `infra/docker-compose.yml`(挂载 manifest volume) + +- [ ] **Step 1: 更新 router.yaml** + +在 `infra/apollo-router/router.yaml` 末尾追加: + +```yaml +# 持久化查询(v2.1 安全加固) +# 关联:spec §4.3 +persisted_queries: + enabled: true + # 生产:仅接受 manifest 内的 hash + require_manifest: ${env.APOLLO_REQUIRE_PQ_MANIFEST::false} + manifest_path: /etc/apollo-router/pq-manifest.json + +# 查询限制(v2.1 安全加固) +# 关联:spec §5.1 +limits: + max_depth: 10 + max_cost: 1000 + max_batch_size: 5 + +# 生产关闭 introspection(可通过环境变量覆盖) +supergraph: + listen: 0.0.0.0:3000 + path: /graphql + introspection: ${env.APOLLO_ROUTER_INTROSPECTION::true} + +# 生产仅允许 POST(csrf.enabled 阻止 GET 查询) +csrf: + enabled: ${env.APOLLO_ROUTER_CSRF::false} +``` + +**注意:** 原有 `supergraph` 块需合并(不能重复 key)。 + +- [ ] **Step 2: 更新 Dockerfile 复制 manifest** + +在 `infra/apollo-router/Dockerfile` 的 `COPY` 段追加: + +```dockerfile +# PQ manifest(构建时从 portal-shell 拷贝,或通过 volume 挂载) +COPY pq-manifest.json /etc/apollo-router/pq-manifest.json +``` + +- [ ] **Step 3: 更新 docker-compose.yml 挂载 manifest** + +在 `infra/docker-compose.yml` 的 apollo-router 服务中追加 volume: + +```yaml +volumes: + - ./apollo-router/router.yaml:/dist/configuration.yaml:ro + - ./apollo-router/supergraph.yaml:/dist/supergraph.yaml:ro + - ./apollo-router/entrypoint.sh:/dist/entrypoint.sh:ro + # PQ manifest 从 portal-shell 构建产物挂载 + - ../apps/portal-shell/public/pq-manifest.json:/etc/apollo-router/pq-manifest.json:ro +``` + +- [ ] **Step 4: 生成 manifest 到 apollo-router 目录(开发用)** + +Run: `pnpm --filter @edu/portal-shell run generate-pq-manifest` +然后:复制 `apps/portal-shell/public/pq-manifest.json` 到 `infra/apollo-router/pq-manifest.json` + +- [ ] **Step 5: 重启 apollo-router 验证配置加载** + +Run: `docker compose -f infra/docker-compose.yml restart apollo-router` +Expected: 容器启动成功,日志显示 `persisted_queries` 已启用 + +- [ ] **Step 6: 测试 APQ 行为** + +发送只带 hash 的请求: + +```bash +curl -X POST http://localhost:3000/graphql \ + -H "Content-Type: application/json" \ + -d '{"extensions":{"persistedQuery":{"sha256Hash":"invalid-hash","version":1}}}' +``` + +Expected: 返回 `PERSISTED_QUERY_NOT_FOUND` 错误(manifest 未匹配) + +- [ ] **Step 7: 测试深度限制** + +发送 11 层嵌套查询: + +```bash +curl -X POST http://localhost:3000/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"{ user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { id } } } } } } } } } } } } } } } } }"}' +``` + +Expected: 返回 `QUERY_DEPTH_EXCEEDED` 错误 + +- [ ] **Step 8: Commit** + +```bash +git add infra/apollo-router/router.yaml infra/apollo-router/Dockerfile infra/apollo-router/entrypoint.sh infra/docker-compose.yml infra/apollo-router/pq-manifest.json +git commit -m "feat(infra): enable apollo-router APQ + manifest + depth/cost limits" +``` + +--- + +### Task 15: 字段级 @auth 审计 + +**Files:** + +- Create: `docs/security/graphql-auth-audit-2026-07.md` + +- [ ] **Step 1: 列出所有 Query resolver** + +Run: `pnpm run arch:scan` +Run: `pnpm run arch:query -- symbol-refs "@Query"` +Expected: 输出所有 @Query 装饰器位置 + +- [ ] **Step 2: 列出所有 Mutation resolver** + +Run: `pnpm run arch:query -- symbol-refs "@Mutation"` +Expected: 输出所有 @Mutation 装饰器位置 + +- [ ] **Step 3: 列出所有 @ResolveField** + +Run: `pnpm run arch:query -- symbol-refs "@ResolveField"` +Expected: 输出所有字段 resolver 位置 + +- [ ] **Step 4: 逐个核查守卫** + +对每个 resolver 读取源码,核查: + +- 是否有 `@RequirePermission()` 装饰器 +- 敏感字段(details/ip/email/phone)是否有字段级守卫 +- DEV_MODE 是否会绕过守卫(应仅 dev 绕过,prod 严格) + +- [ ] **Step 5: 创建审计报告** + +```markdown +# docs/security/graphql-auth-audit-2026-07.md + +# GraphQL 字段级 @auth 审计报告(2026-07) + +## 审计范围 + +8 个子图(iam / config-service / classes / core-edu / content / msg / data-ana / ai)的所有 Query / Mutation / @ResolveField。 + +## 审计方法 + +通过 arch.db 查询所有 resolver 装饰器位置,逐个核查 @RequirePermission() 与字段级 @auth 覆盖情况。 + +## 审计结果 + +| 子图 | Resolver | 字段 | 当前守卫 | 期望守卫 | 状态 | +| ---- | ------------ | ---------------- | -------- | ------------------------------- | ---- | +| iam | UserResolver | user | 无 | @RequirePermission('user:read') | ❌ | +| iam | UserResolver | resolveReference | 无 | 无(Federation 内部) | ✅ | +| ... | ... | ... | ... | ... | ... | + +## 统计 + +- 总 resolver 数:N +- 已有守卫:N +- 缺失守卫:N +- 覆盖率:N% + +## 补齐计划 + +见 Task 16。 +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/security/graphql-auth-audit-2026-07.md +git commit -m "docs(security): add GraphQL @auth audit report" +``` + +--- + +### Task 16: 补齐缺失的 @RequirePermission 装饰器 + +**Files:** + +- Modify: 各子图的 resolver 文件(根据 Task 15 审计结果) + +- [ ] **Step 1: 读取审计报告,列出所有 ❌ 项** + +打开 `docs/security/graphql-auth-audit-2026-07.md`,找出所有"状态:❌"的 resolver。 + +- [ ] **Step 2: 逐个补齐 @RequirePermission()** + +对每个缺失守卫的 resolver: + +1. 读取源码 +2. 在 `@Query()` / `@Mutation()` 装饰器上方追加 `@RequirePermission('xxx:yyy')` +3. 确认权限点已在 `Permissions` 常量中定义(若未定义,先在 iam 的 permissions 常量中新增) + +**示例(iam user.resolver.ts):** + +```typescript +import { RequirePermission } from "../../middleware/permission.guard.js"; + +@Resolver(() => User) +export class UserResolver { + @RequirePermission("user:read") // 新增 + @Query(() => User, { nullable: true }) + async user( + @Args("userId", { type: () => ID }) userId: string, + ): Promise { + return this.loader.userLoader.load(userId); + } +} +``` + +- [ ] **Step 3: 跑各子图的 lint + typecheck** + +Run(每个子图): + +- `pnpm --filter @edu/iam run lint && pnpm --filter @edu/iam run typecheck` +- `pnpm --filter @edu/config-service run lint && pnpm --filter @edu/config-service run typecheck` +- ... 其他 6 个子图 + +Expected: 全 PASS + +- [ ] **Step 4: 跑各子图的 build 确保不破坏** + +Run: `pnpm -r run build` +Expected: 全 PASS + +- [ ] **Step 5: 重启所有子图验证** + +Run: `docker compose -f infra/docker-compose.yml restart` +Expected: 全部健康检查通过 + +- [ ] **Step 6: 测试无 token 访问敏感字段应 403** + +```bash +curl -X POST http://localhost:3000/graphql \ + -H "Content-Type: application/json" \ + -H "router-authorization: dev-router-secret" \ + -d '{"query":"{ user(userId: \"1\") { email phone } }"}' +``` + +Expected: 返回 403 错误(无用户身份头) + +- [ ] **Step 7: Commit** + +```bash +git add services/*/src/graphql/resolvers/ services/iam/src/middleware/permission.guard.ts +git commit -m "fix(graphql): add missing @RequirePermission decorators" +``` + +--- + +### Task 17: 安全栈测试 + +**Files:** + +- Test: `apps/portal-shell/src/lib/api/__tests__/security.test.ts` + +- [ ] **Step 1: 写 APQ 行为测试** + +```typescript +// apps/portal-shell/src/lib/api/__tests__/security.test.ts +import { describe, it, expect } from "vitest"; +import { print } from "graphql"; +import { sha256 } from "crypto-hash"; +import * as operations from "../operations"; + +describe("Persisted Query Manifest", () => { + it("all operations have stable hash", async () => { + for (const [name, doc] of Object.entries(operations)) { + if (typeof doc === "object" && doc !== null && "loc" in doc) { + const query = print(doc as never); + const hash = await sha256(query); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + } + } + }); + + it("manifest contains all operations", async () => { + const manifest = await import("../__generated__/operations"); + expect(Object.keys(operations).length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: 写深度限制测试(构造 11 层查询应失败)** + +```typescript +describe("Query depth limit", () => { + it("11-level nested query should be rejected", () => { + const deepQuery = + "query { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { parent { user { id } } } } } } } } } } } } } } } } }"; + // 通过 Apollo Client 发送,预期收到 QUERY_DEPTH_EXCEEDED + // (需要集成测试环境,此处仅断言查询构造) + expect(deepQuery.split("{").length).toBeGreaterThan(11); + }); +}); +``` + +- [ ] **Step 3: 跑安全测试** + +Run: `pnpm --filter @edu/portal-shell run test` +Expected: 全 PASS(含新增安全测试) + +- [ ] **Step 4: Commit** + +```bash +git add apps/portal-shell/src/lib/api/__tests__/security.test.ts +git commit -m "test(portal-shell): add security stack tests" +``` + +--- + +## M4:文档同步 + +### Task 18: 更新 004 架构文档 + +**Files:** + +- Modify: `docs/architecture/004_architecture_impact_map.md` + +- [ ] **Step 1: 在 004 §16 portal-shell 章节追加"前端数据访问层"小节** + +```markdown +### 16.X portal-shell 前端数据访问层(v2.1 M2 强化) + +#### 4 层分层 + +- Widget 层(UI)→ API 层(语义化函数)→ Operations 层(gql 文档)→ Hook 层(useWidgetQuery/useWidgetMutation) + +#### lib/api/ 目录 + +- `lib/api/.ts`:7 个 domain(universal/sidebar/topbar/teacher/student/parent/admin) +- `lib/api/operations/.graphql.ts`:集中存放 gql 文档 +- `lib/api/__generated__/`:graphql-codegen 产物(不入 git) + +#### 安全栈 + +- Apollo Client APQ(生产发 hash) +- apollo-router manifest 校验(生产拒绝未知 hash) +- 深度限制:max_depth = 10 +- 复杂度限制:max_cost = 1000 +- 字段级 @auth:8 个子图全覆盖 + +#### 关联 ADR + +- ADR-XXX:前端数据抽象层(待补) +- ADR-XXX:GraphQL 安全加固(待补) +``` + +- [ ] **Step 2: 跑 arch:scan 更新 arch.db** + +Run: `pnpm run arch:scan` +Expected: arch.db 更新,符号数增加 + +- [ ] **Step 3: 跑 arch:query 验证 lib/api 符号已记录** + +Run: `pnpm run arch:query -- stats` +Expected: 符号数较之前增加(lib/api/ 新增函数) + +- [ ] **Step 4: Commit** + +```bash +git add docs/architecture/004_architecture_impact_map.md +git commit -m "docs(architecture): add portal-shell data access layer to 004" +``` + +--- + +### Task 19: 更新 known-issues.md + +**Files:** + +- Modify: `docs/troubleshooting/known-issues.md` + +- [ ] **Step 1: 在 §2.17 portal-shell 分区追加经验** + +```markdown +### 2.17 portal-shell + +| 场景 | 技术/规则 | +| ----------------------------------------- | ------------------------------------------------------------ | +| 31 widget 内嵌 gql 字面量导致 schema 耦合 | 抽取到 lib/api/operations/,widget 只调 lib/api/ 函数 | +| 手写 interface XxxQueryData 重复 | 用 graphql-codegen 自动生成 **generated**/types.ts | +| 生产环境明文 query 暴露 schema | Apollo Client 启用 APQ,Router 加载 manifest 校验 | +| 无查询深度限制可递归攻击 | apollo-router limits.max_depth = 10 | +| 无复杂度限制可放大攻击 | apollo-router limits.max_cost = 1000,list 字段 × 0.1 | +| 字段级 @auth 覆盖率未知 | 用 arch:query 列出所有 resolver,逐个核查 @RequirePermission | +| codegen 与子图 schema 不同步 | codegen schema 源指向 services/*/schema.graphql 本地文件 | +| APQ manifest 漏注册新 query | 构建时自动生成,prebuild 钩子触发 | +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/troubleshooting/known-issues.md +git commit -m "docs(troubleshooting): add portal-shell data layer experiences" +``` + +--- + +### Task 20: M4 收尾——更新 README 与 arch.db 最终同步 + +**Files:** + +- Modify: `apps/portal-shell/README.md` + +- [ ] **Step 1: 在 portal-shell README 追加"数据访问层"章节** + +参照 004 的描述,在 README 中新增一节,包含: + +- 4 层分层图 +- lib/api/ 目录结构 +- codegen 使用说明 +- APQ 与 manifest 说明 +- 安全栈说明 + +- [ ] **Step 2: 最终 arch:scan** + +Run: `pnpm run arch:scan` +Expected: arch.db 最终更新 + +- [ ] **Step 3: 跑 arch:query -- stats 确认规模** + +Run: `pnpm run arch:query -- stats` +Expected: 模块数、符号数符合预期 + +- [ ] **Step 4: 跑完整 typecheck + lint + test 最终验证** + +Run: `pnpm --filter @edu/portal-shell run typecheck && pnpm --filter @edu/portal-shell run lint && pnpm --filter @edu/portal-shell run test` +Expected: 全 PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/portal-shell/README.md +git commit -m "docs(portal-shell): update README with data access layer section" +``` + +- [ ] **Step 6: 通知人类合并** + +输出消息: + +``` +M1-M4 全部完成。本分支 feat/architecture-v2.1 已包含: +- M1:lib/api/ 骨架 + codegen + 7 operations 文件 +- M2:31 widget 全量迁移(0 处 gql 字面量) +- M3:APQ + manifest + 深度/复杂度限制 + @auth 审计与补齐 +- M4:004 文档 + known-issues + README + arch.db + +请人工合并到 main。 +``` + +--- + +## Self-Review 检查清单 + +**Spec 覆盖:** + +- ✅ §1.4 设计目标 6 项 → Task 1-20 全覆盖 +- ✅ §2 整体架构 → Task 1-11 +- ✅ §3 迁移策略 → Task 4-11 +- ✅ §4 持久化查询 → Task 12-14 +- ✅ §5 安全加固 → Task 14-17 +- ✅ §6 错误处理 → Task 1(errors.ts)+ Task 4(API 函数示范) +- ✅ §7 测试策略 → Task 4-10(domain 测试)+ Task 17(安全测试) +- ✅ §8 实施阶段 M1-M4 → Task 1-20 + +**Placeholder 扫描:** 无 TBD/TODO,所有步骤含具体代码或具体命令。 + +**类型一致性:** + +- `UseQueryResult` 在 types.ts 定义,所有 domain 文件复用 +- `ApiError` 在 errors.ts 定义,所有 Mutation run 函数抛出 +- `useWidgetQuery` / `useWidgetMutation` 签名与 lib/useWidgetQuery.ts、lib/useWidgetMutation.ts 一致 + +**风险点:** + +- Task 3 抽取 gql 时若字段名与 schema 不匹配,codegen 会失败 → Step 5 已有失败处理 +- Task 14 router.yaml 修改若与现有配置冲突 → Step 1 已注明"合并 supergraph 块" +- Task 16 @auth 补齐可能影响现有功能 → Step 5 已要求重启验证 + Step 6 测试 403 diff --git a/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md b/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md index bacfc7d..279d38b 100644 --- a/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md +++ b/docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md @@ -222,33 +222,38 @@ export function useAuditLogs( } // Mutation(hook 形态,因为 useWidgetMutation 是 React Hook) -export function useCreateInvitationCode(): [ - (input: CreateInvitationCodeInput) => Promise, - UseMutationResult, -] { - const [run, result] = useWidgetMutation< +export function useCreateInvitationCode(): { + run: (input: CreateInvitationCodeInput) => Promise; + loading: boolean; + error: ApolloError | undefined; +} { + const { + run: rawRun, + loading, + error, + } = useWidgetMutation< CreateInvitationCodeMutation, CreateInvitationCodeMutationVariables >(CREATE_INVITATION_CODE_DOC); - const wrappedRun = async ( + const run = async ( input: CreateInvitationCodeInput, ): Promise => { - const { data } = await run({ input }); + const data = await rawRun({ input }); if (!data?.createInvitationCode) { throw new ApiError("Failed to create invitation code", "INTERNAL_ERROR"); } return data.createInvitationCode; }; - return [wrappedRun, result]; + return { run, loading, error }; } ``` **规范要点**: - 查询返回**领域模型**(`ChildSummary[]`),不是 GraphQL 原始响应(`{ myChildren: [...] }`),避免 schema 形状泄漏 -- Mutation 包装为 hook,返回 `[wrappedRun, result]` 元组;`wrappedRun` 抛 `ApiError`,widget 在事件 handler 中 `try/catch` +- Mutation 包装为 hook,返回 `{ run, loading, error }` 对象(与 `useWidgetMutation` 返回形态一致);`run` 抛 `ApiError`,widget 在事件 handler 中 `try/catch` - 命名:查询与 Mutation 都用 `use` 前缀(`useParentChildren`、`useCreateInvitationCode`、`useApproveLeave`),保持 React Hook 规范 - 文件名 `.ts`,函数名 `use`(查询)/ `use`(Mutation)