diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index 14ec0a3..5bc9804 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -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+Redis;P3 加 Kafka+Zookeeper;P4 加 Debezium+CH+Neo4j;P5 加 ES;P6 加 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+Redis;P3 加 Kafka+Zookeeper;P4 加 Debezium+CH+Neo4j;P5 加 ES;P6 加 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 ...` 显式指定服务名,避免触发需构建镜像的 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,,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/owned),repository 下推 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,,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/owned),repository 下推 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-ana(Python,无 Federation)不能加入 `dev-supergraph.yaml`;rover introspect 报 `Cannot POST /graphql` 或空响应时先确认子图类型 | +| supergraph 不暴露 `_service` 字段 | `_service { sdl }` 仅在子图可用,supergraph(router)不暴露;验证 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 Monolith,v2.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` | +| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery>` 约束下,widget 文件用 `interface XxxVars` 会报 TS2344;改用 `type XxxVars = {...}` 类型别名(type alias 满足 index signature,interface 不满足) | +| 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/subjectId);Zustand 管理纯 UI 状态(theme/locale/sidebarCollapsed);插件间禁止直接 import | +| useWidgetMutation API 签名 | 返回 `{ run, ...result }`,`run` 是 `async (variables: TVars): Promise`;消费者用 `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` | +| 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/.ts` 的语义化函数(ADR-042) | +| 4 层数据访问分层 | Widget(UI)→ API(语义化函数)→ Operations(gql DocumentNode 集中)→ Hook(useWidgetQuery/useWidgetMutation 封装 Apollo);codegen 从 7 子图 schema 生成类型 | +| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败;schema 归一化脚本移除 federation 指令(@key/@requires/@extends)防止误解析 | +| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突(TS2308);topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) | +| APQ(Automatic 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 resolver,Python 子图待补 Strawberry/Ariadne 中间件 | +| TS interface 不满足 Record 约束 | `useWidgetQuery>` 约束下 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 |