- Plan: 20 tasks across M1-M4 phases - Spec: fix useWidgetMutation destructure (object, not array)
1632 lines
48 KiB
Markdown
1632 lines
48 KiB
Markdown
# 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.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/<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 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/<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 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<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 实例
|
||
*
|
||
* 启用 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<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 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<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 个 domain(universal/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
|
||
- 字段级 @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<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
|