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

1632 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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.tsbarrel先空**
```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.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` 中新增:
```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 仍引用原来的 gqlM2 才切换)。这样 codegen 可以先生成类型。
- [ ] **Step 1: universal.graphql.ts7 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/<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**
```typescript
// 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**
```bash
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 通用):**
```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 <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 文件**
```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<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 ChildInfo``interface 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**
```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 }) => (
<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**
```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 实例
*
* 启用 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**
```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<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 中新增:
```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 hashvalue 是 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}
# 生产仅允许 POSTcsrf.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<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**
```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/<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**
```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 启用 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**
```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 已包含:
- 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