- Plan: 20 tasks across M1-M4 phases - Spec: fix useWidgetMutation destructure (object, not array)
48 KiB
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 后必跑):
- 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
// 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.ts(barrel,先空)
// 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.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 中新增:
"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.ts 与 operations.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.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)
// 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 执行:
- Read
apps/portal-shell/src/widgets/<category>/<name>/index.tsx - 复制
const XXX = gql\...`` 内容 - 粘贴到对应 operations 文件,重命名为
XXX_DOC - 重复直到 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 PASS(widget 仍用老 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"
M2:31 插件全量迁移
M2 总览: 按 7 个 domain 分 7 个 commit,每个 commit 完成该 domain 的 API 文件 + 所有 widget 改造。M2 全部完成后统一跑测试。
M2 通用步骤(每个 domain 重复):
- 创建
lib/api/<domain>.ts,写语义化函数 - 改造该 domain 下所有 widget 的
index.tsx,删除 gql/interface/手写类型,改调 API 函数 - 跑 typecheck + lint + test
- 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 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 文件
// 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
// 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 },run抛ApiError -
Step 2: 逐个改造 6 个 widget
按 Task 4 Step 2-3 模式,对每个 admin widget:
- 删除 gql / interface / 手写类型
- 新增
import { useXxx } from "@/lib/api" - 改用 API 函数
- 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 实例
*
* 启用 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
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-manifestscript) -
Modify:
apps/portal-shell/next.config.js(prebuild 钩子) -
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 hash,value 是 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/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 末尾追加:
# 持久化查询(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 段追加:
# 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.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 的请求:
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:
- 读取源码
- 在
@Query()/@Mutation()装饰器上方追加@RequirePermission('xxx:yyy') - 确认权限点已在
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 typecheckpnpm --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 个 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
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 启用 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
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 已包含:
- 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