# 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