docs(docs): add portal-shell data layer and GraphQL hardening known-issues

§1.11 Apollo Router 新增 4 条全局经验:
- PQ Manifest + require_manifest 配置
- limits 深度/成本/批量限制
- introspection 环境变量控制
- entrypoint.sh manifest 校验

§2.17 portal-shell 新增 15 条模块经验:
- Widget 内联 gql 废弃 + 4 层数据访问分层
- graphql-codegen skipDocumentsValidation
- useNotifications 命名冲突
- APQ + PQ Manifest 生成
- Windows ESM 动态 import 路径
- apollo-router PQ manifest 挂载 + 安全限制
- Resolver @RequirePermission 字段级守卫
- TS interface 不满足 Record 约束
- parent.test.tsx 可选链
- PowerShell heredoc / commitlint 限制
This commit is contained in:
SpecialX
2026-07-17 13:39:16 +08:00
parent bbb43a210f
commit f8db1bfe80

View File

@@ -36,21 +36,26 @@
### 1.2 Docker Compose 基础设施
| 场景 | 技术/规则 |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
| 全量启动内存不足 | 按 `profiles` 分阶段启用full/kafka/cdc/analytics/graph/search/config/observability |
| 每服务 mem_limit | 避免单服务吃满内存MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
| 按阶段启用容器 | P1 仅 MySQL+RedisP3 加 Kafka+ZookeeperP4 加 Debezium+CH+Neo4jP5 加 ESP6 加 Consul+Istio |
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
| healthcheck | MySQL 用 `mysqladmin ping`Redis 用 `redis-cli ping` |
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
| Kafka 双 listener | INSIDE (kafka:29092) 容器间互访 + OUTSIDE (localhost:9092) 主机访问,避免 Debezium 拿到 localhost metadata 后切回连不上 |
| ClickHouse 远程访问 | 默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP |
| Debezium Connect 镜像源 | daocloud 禁用 debezium/*,用 `quay.io/debezium/connect:2.7` 替代 |
| Debezium 跨网络访问 MySQL | MySQL 容器在 edu-minimal_default 时,`docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达 |
| CDC 注册 connector | POST `:8083/connectors`,配置 `topic.prefix`/`database.include.list`/`schema.history.internal.kafka.topic` |
| 场景 | 技术/规则 |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 日常开发启动 | 用 `docker-compose.minimal.yml` 仅起 MySQL+Redis |
| 全量启动内存不足 | 按 `profiles` 分阶段启用full/kafka/cdc/analytics/graph/search/config/observability |
| 每服务 mem_limit | 避免单服务吃满内存MySQL 512m、Redis 128m、Kafka 512m、ClickHouse 1g |
| 按阶段启用容器 | P1 仅 MySQL+RedisP3 加 Kafka+ZookeeperP4 加 Debezium+CH+Neo4jP5 加 ESP6 加 Consul+Istio |
| MySQL 初始化 | `init-sql/01-init.sql` 挂载到 `/docker-entrypoint-initdb.d:ro` |
| healthcheck | MySQL 用 `mysqladmin ping`Redis 用 `redis-cli ping` |
| Windows 下卷挂载 | init-sql 用绝对路径或确保相对路径正确 |
| 容器名固定 | `container_name: edu-mysql` 便于服务连接配置 |
| Kafka 双 listener | INSIDE (kafka:29092) 容器间互访 + OUTSIDE (localhost:9092) 主机访问,避免 Debezium 拿到 localhost metadata 后切回连不上 |
| ClickHouse 远程访问 | 默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP |
| Debezium Connect 镜像源 | daocloud 禁用 debezium/*,用 `quay.io/debezium/connect:2.7` 替代 |
| Debezium 跨网络访问 MySQL | MySQL 容器在 edu-minimal_default 时,`docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达 |
| CDC 注册 connector | POST `:8083/connectors`,配置 `topic.prefix`/`database.include.list`/`schema.history.internal.kafka.topic` |
| 混合部署模式 | 基础设施用 Docker Compose应用服务本地 `pnpm exec nest start` 运行,避免 Docker 构建应用镜像失败pnpm 11 ERR_PNPM_IGNORED_BUILDS |
| Docker Compose 显式启动服务 | 用 `docker compose --profile p3 --profile p5 up -d <svc1> <svc2> ...` 显式指定服务名,避免触发需构建镜像的 app 服务config-service/apollo-router/portal-shell |
| PowerShell + docker compose stderr | `ErrorActionPreference="Stop"` 会把 docker compose 输出到 stderr 的进度信息当错误终止脚本;调用前临时设为 "Continue" |
| Temporal auto-setup 镜像 | `temporalio/auto-setup` 默认用 Cassandra配 PostgreSQL 需设置 `DB=postgres`(非 `DB_PLUGIN`),否则报 `CASSANDRA_SEEDS env must be set` |
| stop-all 脚本 gRPC 端口 | 关闭脚本需清理 gRPC 端口50052-50059否则 nest start 报 EADDRINUSE |
### 1.3 protobuf + buf 契约
@@ -67,23 +72,28 @@
### 1.4 NestJS 服务开发TS
| 场景 | 技术/规则 |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| 服务标准结构 | `src/{main.ts,app.module.ts,config,middleware,<domain>,shared,generated}` 黄金模板复制 |
| 三层配置 | env环境变量< yaml业务参数< 配置中心P6 Consul每服务启动时合并 |
| 环境变量校验 | Zod schema 校验 process.env失败立即抛错终止启动 |
| ApplicationError 基类 | 统一错误体系ValidationError(400)/NotFoundError(404)/PermissionDeniedError(403)/ConflictError(409) 等 |
| 响应信封 | `{ success: boolean, data?: T, error?: { code, message, details, traceId } }` 统一跨服务 |
| 权限校验 | `requirePermission(perm)` 装饰器/中间件admin 角色拥有全部权限 |
| DataScope 过滤 | 6 级all/grade_managed/class_taught/class_members/children/ownedrepository 下推 WHERE 条件 |
| 结构化日志 | pino + `traceId`/`spanId`/`userId`/`service` 字段,禁止 `console.*` |
| Metrics endpoint | prom-client 暴露 `/metrics`QPS/延迟/错误率) |
| OpenTelemetry trace | OTel SDK 初始化W3C TraceContext header 跨服务传播 |
| 健康检查 | `/health` endpoint 返回 `{ status, service, version }` |
| 优雅关闭 | SIGTERM → app.close() → shutdownTracing() → process.exit(0) |
| i18n 错误码映射 | 错误码 → i18n key 映射表,后端返回 key + 参数,前端翻译 |
| Drizzle ORM | 参数化查询,禁止字符串拼接 SQL |
| Zod 输入校验 | controller 层 `schema.safeParse(body)`,失败抛 ValidationError |
| 场景 | 技术/规则 |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 服务标准结构 | `src/{main.ts,app.module.ts,config,middleware,<domain>,shared,generated}` 黄金模板复制 |
| 三层配置 | env环境变量< yaml业务参数< 配置中心P6 Consul每服务启动时合并 |
| 环境变量校验 | Zod schema 校验 process.env失败立即抛错终止启动 |
| ApplicationError 基类 | 统一错误体系ValidationError(400)/NotFoundError(404)/PermissionDeniedError(403)/ConflictError(409) 等 |
| 响应信封 | `{ success: boolean, data?: T, error?: { code, message, details, traceId } }` 统一跨服务 |
| 权限校验 | `requirePermission(perm)` 装饰器/中间件admin 角色拥有全部权限 |
| DataScope 过滤 | 6 级all/grade_managed/class_taught/class_members/children/ownedrepository 下推 WHERE 条件 |
| 结构化日志 | pino + `traceId`/`spanId`/`userId`/`service` 字段,禁止 `console.*` |
| Metrics endpoint | prom-client 暴露 `/metrics`QPS/延迟/错误率) |
| OpenTelemetry trace | OTel SDK 初始化W3C TraceContext header 跨服务传播 |
| 健康检查 | `/health` endpoint 返回 `{ status, service, version }` |
| 优雅关闭 | SIGTERM → app.close() → shutdownTracing() → process.exit(0) |
| i18n 错误码映射 | 错误码 → i18n key 映射表,后端返回 key + 参数,前端翻译 |
| Drizzle ORM | 参数化查询,禁止字符串拼接 SQL |
| Zod 输入校验 | controller 层 `schema.safeParse(body)`,失败抛 ValidationError |
| env-loader 解决环境变量丢失 | PowerShell → pnpm → nest 子进程链中 `.env` 变量丢失;`@edu/shared-ts/env-loader` 在 main.ts 顶部副作用导入,自动从 monorepo 根加载 `.env` |
| pino ESM 命名导入 | ESM 模式下必须 `import { pino } from "pino"`(命名导入),不能用 `import pino from 'pino'`(默认导入无 call signatures |
| GraphQL @Field nullable 显式类型 | `@Field({ nullable: true })``string \| null` 无法自动推断类型,必须显式 `@Field(() => String, { nullable: true })` |
| NestJS Federation @extends 限制 | `@extends` + `@external` + `@requires` 组合在 NestJS schema builder 中会报 "defined in resolvers, but not in schema"DataScopeResolver 需用其他方式实现或临时移除 |
| GraphqlModule DI 跨模块 | Resolver 注入其他模块 Service 时GraphqlModule 的 imports 必须显式导入该模块(如 IamModule仅 providers 不够 |
### 1.5 Go Gateway 开发
@@ -198,6 +208,30 @@
| 用户仲裁 vs coord 仲裁优先级 | 用户仲裁U1-U4优先级高于 coord 仲裁coord 裁决与用户仲裁冲突时(如 §2.5 GraphQL vs U3以用户仲裁为准提请 coord 修正其裁决以保持一致 |
| 整改清单整体可执行性判断 | 若整改清单责任方错配占比超阈值(本次 71%17/24 项),则整改清单整体不可直接执行,必须等 coord 重新发布修正版整改清单后方可推进 |
### 1.11 Apollo Router 配置v1.45.0
> Router 作为唯一外部 GraphQL 入口(`infra/apollo-router/`),组合 5 个 NestJS Federation 2 子图iam/core-edu/content/msg/config。混合部署基础设施 Docker + 应用本地 nest start。
| 场景 | 技术/规则 |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `headers.all.request[].add` oneOf 验证失败 | v1.45.0 schema 移除 `add` 指令,改用 `insert: { name, value }`;用 `/dist/router config schema` 导出 JSON schema 查 oneOf 定义 |
| router 二进制路径 | 镜像 `ghcr.io/apollographql/router:v1.45.0` 的二进制位于 `/dist/router`,不是 `/router`entrypoint.sh 必须 `exec /dist/router --config /dist/configuration.yaml --supergraph /tmp/supergraph.graphql` |
| `cors.credentials`/`cors.headers` 不被允许 | v1.45.0 改名:`cors.credentials` → `cors.allow_credentials``cors.headers` → `cors.allow_headers` |
| `homepage.path` 不被允许 | v1.45.0 中 sandbox 已合并到 homepage移除 `path: /`,仅保留 `homepage: { enabled: true }` |
| `telemetry.logging` 不被允许 | v1.45.0 改为 `telemetry.exporters.logging.stdout.format: json`(值为字符串,非 `{json: true}` |
| `traffic_shaping` schema 变更 | v1.45.0 中 traffic_shaping 子字段 schema 改动较大,开发态可直接移除整个 traffic_shaping 段 |
| rover compose 下载 supergraph 插件失败 | 容器内访问 GitHub 受限docker run 时需传 `HTTP_PROXY=http://host.docker.internal:7897` + `HTTPS_PROXY`;或在 Dockerfile 预下载 `supergraph-v2.9.0` 插件到 `/root/.rover/bin/` |
| NestJS 子图缺少 Federation 2 标记 | GraphQLModule 必须用 `ApolloFederationDriver``@nestjs/apollo`),非 `ApolloDriver`;用 `rover subgraph introspect http://localhost:PORT/graphql` 验证 `_service { sdl }` 返回含 `@key`/`@link` |
| AuthMiddleware 阻止 Federation 内省 | 全局 `forRoutes("*")` 的 AuthMiddleware 会拦截 `/graphql`,导致 rover 无法内省DEV_MODE=true 时在 middleware 开头旁路(设置 dev-user-001 并 next() |
| 全局 PORT 环境变量覆盖所有服务 | `.env` 中通用 `PORT=3011` 会被所有服务继承导致 EADDRINUSE每服务 PORT 在自身 `config/env.ts` 定义默认值,`.env` 只放具名变量(如 `CONFIG_SERVICE_PORT` |
| 混合部署 supergraph routing_url | 应用服务在 host 上运行apollo-router 在容器中,`dev-supergraph.yaml` 的 routing_url 必须用 `http://host.docker.internal:PORT/graphql`docker run 加 `--add-host=host.docker.internal:host-gateway` |
| 无 Federation 子图的服务不可加入 supergraph | classes纯 REST+gRPC、ai/data-anaPython无 Federation不能加入 `dev-supergraph.yaml`rover introspect 报 `Cannot POST /graphql` 或空响应时先确认子图类型 |
| supergraph 不暴露 `_service` 字段 | `_service { sdl }` 仅在子图可用supergraphrouter不暴露验证 supergraph 用 `__schema { queryType { fields { name } } }` 查看聚合字段 |
| PQ Manifest + require_manifest | `router.yaml` 配置 `persisted_queries.enabled: true` + `manifest_path: /etc/apollo-router/pq-manifest.json` + `require_manifest: ${env.APOLLO_REQUIRE_PQ_MANIFEST::false}`;生产 true 时未知 hash 直接拒绝 |
| limits 深度/成本/批量限制 | `router.yaml` `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5`;超限返回 `QUERY_DEPTH_EXCEEDED` / `QUERY_COMPLEXITY_EXCEEDED` |
| introspection 环境变量控制 | `supergraph.introspection: ${env.APOLLO_ROUTER_INTROSPECTION::true}`;生产 false 关闭 schema 内省防止泄露 |
| entrypoint.sh manifest 校验 | 启动 router 前 `if [ "${APOLLO_REQUIRE_PQ_MANIFEST}" = "true" ] && [ ! -f "${MANIFEST_PATH}" ]; then exit 1; fi`;缺失即 fail-fast 避免运行时才报错 |
---
## 二、模块经验
@@ -688,3 +722,38 @@
| P6 学校组织管理子路由 | `app/admin/school/layout.tsx` 子导航 tab + `usePathname` 活动态判定;`page.tsx` 用 `next/navigation` 的 `redirect()` 跳转 `/schools`5 子页复用 `useSchools`/`useGrades`/`useGradeInsights`/`useDepartments`/`useAcademicYears` hooks |
| P6 `\|\|` 与 `??` 混用 | TS5076`a \|\| b ?? c` 非法,必须加括号 `a \|\| (b ?? c)``noUncheckedIndexedAccess` 下 `arr[0]?.id ?? null` 常见) |
| P6 mock 字面量类型拓宽 | `mocks/handlers.ts` 中对象字面量含 `any` 兄弟属性时,`status: "draft"` 被推断为 `string``unshift` 到强类型数组报错;用 `as const` 断言字面量 |
### 2.17 portal-shell插件化仪表盘 Modular Monolithv2.1
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
| 场景 | 技术/规则 |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`shared-ts 不依赖 React前端包引用时断言为 `React.ComponentType<PluginProps>` |
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下widget 文件用 `interface XxxVars` 会报 TS2344改用 `type XxxVars = {...}` 类型别名type alias 满足 index signatureinterface 不满足) |
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`hooks 包通过 `@edu/shared-ts/contracts` 引用typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
| Vitest 解析 workspace 子路径导出失败 | vitest.config.ts resolve.alias 需显式添加 `"@edu/shared-ts/contracts"` → 源码路径Vite 不自动解析 package.json exports 字段 |
| portal-shell tsconfig paths 补全 | tsconfig.json paths 需添加 `"@edu/shared-ts/contracts": ["../../packages/shared-ts/src/contracts/index.ts"]`,否则 tsc 报 TS2307 |
| 插件 dynamic import 注册 | `Registry.tsx` 用 `next/dynamic`ssr:false懒加载 31 个内置插件loading 展示 `PluginSkeleton` 变体card/list/table/stats/chart |
| URL Search Params + Zustand 状态分层 | URL 驱动可分享上下文classId/childId/termId/view/examId/subjectIdZustand 管理纯 UI 状态theme/locale/sidebarCollapsed插件间禁止直接 import |
| useWidgetMutation API 签名 | 返回 `{ run, ...result }``run` 是 `async (variables: TVars): Promise<TData \| undefined>`;消费者用 `const { run } = useWidgetMutation(...)` 解构 |
| usePluginConfig 不直接调 API | fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 原则支持轮询刷新refreshInterval 默认 5 分钟)+ 网络恢复刷新 + visibilitychange 刷新 |
| usePluginStore 注入模式 | `useSyncExternalStore` + `injectPluginStore()` 让 hooks 包不直接依赖特定 store 实例;未注入 store 时返回 DEFAULT_STATE 空操作 |
| 28 个内置插件分类 | universal(7) + sidebar(4) + topbar(4) + teacher(4) + student(4) + parent(2) + admin(3);每个插件含 `index.tsx` + `plugin.manifest.ts`manifest 导出 `manifestMeta: Omit<PluginManifest, "Component">` |
| PluginLifecycle 版本兼容 | MVP 只校验 major 版本(`checkVersionCompatibility` 解析 `^`/`~`/`>=` 前缀 + major 数字);完整 semver range 校验待引入 semver 库 |
| pnpm install --no-frozen-lockfile | parent-portal package.json 新增 `@opentelemetry/instrumentation-document-load` 后 lockfile 过期;开发环境用 `--no-frozen-lockfile` 安装CI 用 frozen |
| Widget 内联 gql 字面量废弃 | 禁止 `const Q = gql\`...\``直接写在 widget统一抽取到`lib/api/operations/*.graphql.ts`widget 只 import `lib/api/<domain>.ts` 的语义化函数ADR-042 |
| 4 层数据访问分层 | WidgetUI→ API语义化函数→ Operationsgql DocumentNode 集中)→ HookuseWidgetQuery/useWidgetMutation 封装 Apollocodegen 从 7 子图 schema 生成类型 |
| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败schema 归一化脚本移除 federation 指令(@key/@requires/@extends防止误解析 |
| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突TS2308topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) |
| APQAutomatic Persisted Queries | `apollo-client.ts` 用 `createPersistedQueryLink({ sha256 })` + `crypto-hash`env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭Link 链顺序authLink → pqLink → httpLink |
| PQ Manifest 生成 | `scripts/generate-pq-manifest.ts` 遍历 `operations/index.ts` 中 DocumentNode`print(doc)` + `sha256(query)` 写入 `public/pq-manifest.json``prebuild` 钩子串联 codegen + generate |
| Windows ESM 动态 import 路径 | Node ESM 动态 `import()` 不支持 Windows 盘符路径(`e:\...`),必须 `url.pathToFileURL(path).href` 转 `file://` URL 再 import |
| apollo-router PQ manifest 挂载 | docker-compose 把 `apps/portal-shell/public/pq-manifest.json` 挂载到 router `/etc/apollo-router/pq-manifest.json:ro`entrypoint.sh 启动前校验 `APOLLO_REQUIRE_PQ_MANIFEST=true` 时文件存在性 |
| apollo-router 安全限制 | `router.yaml` 配置 `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5``supergraph.introspection` 由 env `APOLLO_ROUTER_INTROSPECTION` 控制(生产 false |
| Resolver @RequirePermission 字段级守卫 | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点50 resolver 审计后补齐 19 个 TS resolverPython 子图待补 Strawberry/Ariadne 中间件 |
| TS interface 不满足 Record 约束 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下 widget 用 `interface XxxVars` 报 TS2344改 `type XxxVars = {...}` 别名(满足 index signature |
| parent.test.tsx 可选链 | `data?.[0].name` 报 TS2532`data?.[0]` 可能为 undefined改 `data?.[0]?.name` 双重可选链 |
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
| commitlint body-max-line-length | commit body 每行 ≤100 字符Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |