Files
Edu/docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.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

48 KiB
Raw Permalink Blame History

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

Branch: feat/architecture-v2.1(当前分支,不开新分支)

Quality Gates每 task 后必跑):

  • TSpnpm --filter @edu/portal-shell run typecheck
  • Lintpnpm --filter @edu/portal-shell run lint
  • Testpnpm --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

// 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
// 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<T> {
  items: T[];
  total: number;
}

export interface UseQueryResult<TData> {
  data: TData | undefined;
  loading: boolean;
  error: unknown;
  refetch: () => Promise<unknown>;
}

export interface UseMutationResult {
  loading: boolean;
  error: unknown;
}
  • Step 3: 创建 internal.ts
// 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.tsbarrel先空
// 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
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 配置

// apps/portal-shell/codegen.ts
/**
 * graphql-codegen 配置
 *
 * schema 来源:本地 SDL 文件services/*/src/graphql/generated/schema.graphql
 * 避免开发时强依赖 apollo-router 运行。
 *
 * 产物:
 * - __generated__/types.ts:所有 GraphQL 类型
 * - __generated__/operations.tsTypedDocumentNode(类型化文档)
 *
 * 关联: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 中新增:

"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch"

devDependencies 中新增:

"@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.tsoperations.ts(内容可能为空或仅含基础类型)

  • Step 7: 跑 typecheck 验证

Run: pnpm --filter @edu/portal-shell run typecheck Expected: PASS

  • Step 8: Commit
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.tsbarrel
  • Delete: apps/portal-shell/src/lib/api/operations/.gitkeep

说明: 此 task 仅抽取 gql 字面量到 operations 文件widget 仍引用原来的 gqlM2 才切换)。这样 codegen 可以先生成类型。

  • Step 1: universal.graphql.ts7 widget
// 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/<category>/<name>/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
// apps/portal-shell/src/lib/api/operations/index.ts
/**
 * Operations 统一出口
 *
 * 所有 GraphQL 文档集中导出,供 lib/api/<domain>.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 PASSwidget 仍用老 gql未切换

  • Step 8: Commit
git add apps/portal-shell/src/lib/api/operations/
git commit -m "feat(portal-shell): extract gql documents to operations layer"

M231 插件全量迁移

M2 总览: 按 7 个 domain 分 7 个 commit每个 commit 完成该 domain 的 API 文件 + 所有 widget 改造。M2 全部完成后统一跑测试。

M2 通用步骤(每个 domain 重复):

  1. 创建 lib/api/<domain>.ts,写语义化函数
  2. 改造该 domain 下所有 widget 的 index.tsx,删除 gql/interface/手写类型,改调 API 函数
  3. 跑 typecheck + lint + test
  4. Commit

迁移后 widget 模板(所有 domain 通用):

"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 <PluginSkeleton variant="..." />;
  // ... UI 渲染
}

Task 4: parent domain 迁移2 widgetchild-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 文件

// 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<ChildSummary[]> {
  const result = useWidgetQuery<GetMyChildrenQuery, Record<string, never>>(
    GET_MY_CHILDREN_DOC,
    {},
  );
  return {
    ...result,
    data: (result.data?.myChildren ?? []) as ChildSummary[],
  };
}

export function useLeaveApprovals(): UseQueryResult<LeaveApproval[]> {
  const result = useWidgetQuery<GetLeaveApprovalsQuery, Record<string, never>>(
    GET_LEAVE_APPROVALS_DOC,
    {},
  );
  return {
    ...result,
    data: (result.data?.leaveApprovals ?? []) as LeaveApproval[],
  };
}

export function useApproveLeave(): {
  run: (id: string, approved: boolean) => Promise<void>;
  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<void> => {
    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 ChildInfointerface MyChildrenQueryData

  • 删除 import { useWidgetQuery } from "@/lib/useWidgetQuery"

  • 新增 import { useParentChildren } from "@/lib/api"

  • const { data, loading } = useWidgetQuery<MyChildrenQueryData, ...>(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

// apps/portal-shell/src/lib/api/index.ts
export * from "./errors";
export * from "./types";
export * from "./parent";
  • Step 5: 写 parent.test.ts
// 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 }) => (
    <MockedProvider mocks={mocks as never}>{children}</MockedProvider>
  );
};

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
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 }runApiError

  • 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
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

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 模式
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 模式

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 模式

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 模式

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 收尾标记
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
// apps/portal-shell/src/lib/apollo-client.ts
/**
 * Apollo Client 实例
 *
 * 启用 APQAutomatic 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
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.jsprebuild 钩子)

  • Step 1: 创建 generate-pq-manifest.ts

// 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<void> {
  const manifest: Record<string, string> = {};
  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 中新增:

"generate-pq-manifest": "tsx scripts/generate-pq-manifest.ts",
"prebuild": "pnpm run codegen && pnpm run generate-pq-manifest"

新增 devDependency

"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 hashvalue 是 query 文本

  • Step 6: 跑 typecheck

Run: pnpm --filter @edu/portal-shell run typecheck Expected: PASS

  • Step 7: Commit
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/DockerfileCOPY manifest

  • Modify: infra/apollo-router/entrypoint.sh(启动前校验 manifest 存在)

  • Modify: infra/docker-compose.yml(挂载 manifest volume

  • Step 1: 更新 router.yaml

infra/apollo-router/router.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}

# 生产仅允许 POSTcsrf.enabled 阻止 GET 查询)
csrf:
  enabled: ${env.APOLLO_ROUTER_CSRF::false}

注意: 原有 supergraph 块需合并(不能重复 key

  • Step 2: 更新 Dockerfile 复制 manifest

infra/apollo-router/DockerfileCOPY 段追加:

# 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

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.jsoninfra/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 的请求:

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 层嵌套查询:

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
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: 创建审计报告

# 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
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

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<UserEntity | null> {
    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
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
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 行为测试

// 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 层查询应失败)
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
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 章节追加"前端数据访问层"小节

### 16.X portal-shell 前端数据访问层v2.1 M2 强化)

#### 4 层分层

- Widget 层UI→ API 层(语义化函数)→ Operations 层gql 文档)→ Hook 层useWidgetQuery/useWidgetMutation

#### lib/api/ 目录

- `lib/api/<domain>.ts`7 个 domainuniversal/sidebar/topbar/teacher/student/parent/admin
- `lib/api/operations/<domain>.graphql.ts`:集中存放 gql 文档
- `lib/api/__generated__/`graphql-codegen 产物(不入 git

#### 安全栈

- Apollo Client APQ生产发 hash
- apollo-router manifest 校验(生产拒绝未知 hash
- 深度限制max_depth = 10
- 复杂度限制max_cost = 1000
- 字段级 @auth8 个子图全覆盖

#### 关联 ADR

- ADR-XXX前端数据抽象层待补
- ADR-XXXGraphQL 安全加固(待补)
  • 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
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 分区追加经验

### 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 启用 APQRouter 加载 manifest 校验            |
| 无查询深度限制可递归攻击                  | apollo-router limits.max_depth = 10                          |
| 无复杂度限制可放大攻击                    | apollo-router limits.max_cost = 1000list 字段 × 0.1        |
| 字段级 @auth 覆盖率未知                   | 用 arch:query 列出所有 resolver逐个核查 @RequirePermission |
| codegen 与子图 schema 不同步              | codegen schema 源指向 services/*/schema.graphql 本地文件     |
| APQ manifest 漏注册新 query               | 构建时自动生成prebuild 钩子触发                            |
  • Step 2: Commit
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
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 已包含:
- M1lib/api/ 骨架 + codegen + 7 operations 文件
- M231 widget 全量迁移0 处 gql 字面量)
- M3APQ + manifest + 深度/复杂度限制 + @auth 审计与补齐
- M4004 文档 + 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 1errors.ts+ Task 4API 函数示范)
  • §7 测试策略 → Task 4-10domain 测试)+ Task 17安全测试
  • §8 实施阶段 M1-M4 → Task 1-20

Placeholder 扫描: 无 TBD/TODO所有步骤含具体代码或具体命令。

类型一致性:

  • UseQueryResult<TData> 在 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