# 已知问题速查 > 索引式速查手册:场景 → 技术/规则映射。不写代码示例,不写流水日志,不标注 AI 身份。 > 架构规则见 [../architecture/004_architecture_impact_map.md](../architecture/004_architecture_impact_map.md) 与 [../../.trae/rules/project_rules.md](../../.trae/rules/project_rules.md) > **AI 查阅时只读自己负责模块的分区(§二 对应小节),禁止读其他 AI 模块分区**,避免被历史记录或他人方案误导。 > **经验沉淀不标注 AI 身份**:所有记录只写"场景→技术/规则"映射,不得出现 AI 标识(如 ai01/ai02/coord 等),避免新同名 AI 将前任记录误认为当前事实。模块内经验沉淀在各自服务 README。 --- ## 一、全局经验 ### 1.1 多语言 monorepo 配置 | 场景 | 技术/规则 | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | 多语言 workspace | pnpm workspace(TS)+ go.work(Go)+ pyproject.toml/uv workspace(Python)三套并存 | | 根 package.json scripts | 封装多语言命令入口:`pnpm dev` / `pnpm lint` / `pnpm test` / `pnpm build` | | pnpm-workspace.yaml | 仅声明 TS 包路径(packages/_、services/classes、bff/_、apps/_、scripts/_),Go/Python 不入 | | go.work | 列出所有 Go 服务模块(services/api-gateway、services/push-gateway) | | pyproject.toml | uv workspace members 列 Python 服务(services/data-ana、services/ai-gateway) | | 跨语言共享类型 | protobuf 生成三端代码(TS/Go/Python),单一契约源 | | tsx 执行 TS 脚本 | arch-scan 等工具脚本用 `tsx` 直接运行,无需编译 | | husky + commitlint | pre-commit 跑 eslint+prettier,commit-msg 校验 Conventional Commits | | .editorconfig 多语言缩进 | Go 用 tab,Python 用 4 空格,TS/默认用 2 空格 | | ESLint 9 flat config | P6 硬化:创建 `eslint.config.js`(flat config),lint 脚本去掉 `--ext .ts`,lint-staged 恢复 `eslint --fix` | | next lint 交互式初始化 | teacher-portal 无 `.eslintrc.json` 时 `next lint` 触发 Strict/Base 选择提示,CI 中需预置配置或改 `eslint src` | | uv.lock 解析失败 | `uv.lock` 中 `grpcio-health-checking` 缺 `source` 字段且匹配多包,导致 `uv run pytest` 全 workspace Python 服务不可用,需重建 lock(`uv lock`) | ### 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` | ### 1.3 protobuf + buf 契约 | 场景 | 技术/规则 | | -------------------- | --------------------------------------------------------------------------- | | 契约唯一源 | `packages/shared-proto/proto/*.proto`,禁止 REST/gRPC 混用 | | breaking change 检测 | `buf breaking --against '.git#branch=main'` CI 强制,必须升版本号 | | proto 版本化 | 包名带版本 `xxx.v1`、`xxx.v2`,新旧版本共存 | | buf lint | `buf lint` 使用 STANDARD 规则集,可按需 except(如 PACKAGE_VERSION_SUFFIX) | | 三端代码生成 | `buf.gen.yaml` 配置 TS/Go/Python 插件,`pnpm proto:gen` 统一生成 | | 契约先行硬约束 | 任何服务间通信必须先改 proto,CI 检测 breaking change 阻止违规合并 | | 事件 schema 版本化 | Kafka 事件带 `schema_version` 字段,消费端按版本处理 | | 错误码定义在 proto | 错误码三端共享,格式 `{服务前缀}_{错误类型}_{资源}`(如 `IAM_PERM_USER`) | ### 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 | ### 1.5 Go Gateway 开发 | 场景 | 技术/规则 | | ----------------- | --------------------------------------------------------------------------------------- | | 框架 | Gin + httputil.ReverseProxy 路由转发 | | P1 鉴权 | Gateway 内置 HS256 JWT 校验(测试密钥),P2 改 RS256(IAM 签发,公钥校验) | | JWT claims | `{ user_id, roles[], registered_claims }`,校验后注入 `x-user-id`/`x-user-roles` 头转发 | | 请求 ID 注入 | Gateway 生成或透传 `X-Request-ID`,全链路传递 | | 健康检查 | `GET /health` 无需鉴权,返回 `{ status, timestamp }` | | 包结构 | `internal/{config,middleware,proxy}`,P6 扩展 handler/service/repository | | error 处理 | 显式处理,禁止 `_` 忽略;`gin.AbortWithStatusJSON` 统一错误响应 | | 配置加载 | 环境变量 + 默认值(`getEnv(key, fallback)`),P6 引入 viper | | Dockerfile 多阶段 | golang:1.22-alpine 构建 → alpine:3.20 运行,CGO_ENABLED=0 | ### 1.6 可观测性(OTel + Prometheus + Loki) | 场景 | 技术/规则 | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | P1 最小可观测集 | 每服务结构化日志 + `/metrics` + OTel SDK 初始化(不引入完整后端) | | 三支柱 | Logs(pino/winston/zap)+ Metrics(prom-client)+ Traces(OTel SDK) | | traceId 注入 | Gateway 注入 → 服务读取 header → 日志/响应携带 | | P6 完整后端 | Loki(日志)+ Grafana(仪表盘)+ Jaeger(trace)+ Prometheus(metrics) | | Prometheus 指标 | `http_request_duration_seconds`(Histogram)+ `http_requests_total`(Counter) | | 采样策略 | P1 全量 trace,P6 引入采样率降低开销 | | 日志参数顺序 | `log.error({ err: error, userId, traceId }, "操作描述")`,错误对象字段名用 `err` | | P6 /metrics 端点 | NestJS 用 `app.getHttpAdapter().get('/metrics', handler)`,不能用 `app.get()`(会被解析为 DI 容器 get) | | P6 prometheus.yml | 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身;rule_files 引用 rules.yml;alerting 关联 alertmanager | | P6 Grafana 数据源 | provisioning/datasources 同时声明 Prometheus(默认)和 Loki(uid=loki) | | P6 Promtail 采集 | docker_sd_configs + relabel_configs 仅采集 `edu-*` 前缀容器日志,避免无关日志 | | P6 service label | static_configs.labels.service 给所有抓取目标打服务标签,告警规则按 service 聚合 | | P6 collectDefaultMetrics | prom-client 的 `collectDefaultMetrics({ register })` 自动收集进程级指标(CPU/内存/事件循环/GC),无需业务埋点即可让 /metrics 有数据 | | P6 Counter/Histogram 埋点缺失 | metrics.ts 定义了 Counter/Histogram 但 service/controller 未调用 `.inc()`/`.observe()`,需后续补 HTTP 中间件自动埋点或业务埋点 | | P6 OTel instrumentations 缺失 | tracer.ts 只配置 traceExporter 未注册 auto-instrumentations(HttpInstrumentation/ExpressInstrumentation),导致 Jaeger 收不到业务 trace,需后续补 `@opentelemetry/auto-instrumentations`。**已补全**:NestJS 6 服务用 `getNodeAutoInstrumentations()`;Python 2 服务用 `FastAPIInstrumentor.instrument_app(app)`;Go 2 服务用 `otelgin.Middleware()` | | P6 镜像源配置 | 国内 docker.io 被墙,compose image 必须加 `docker.m.daocloud.io/` 前缀;Elastic 官方镜像在 docker.elastic.co 不被墙 | | P6 Grafana 端口冲突 | Grafana 默认 3000 与 teacher-portal Next.js dev 冲突,改映射为 3030:3000 | | P6 compose --no-deps | mysql/redis 已在另一 compose 项目运行时,启动新服务用 `--no-deps` + 显式指定服务名,避免重建依赖容器 | | ### 1.7 微前端 Module Federation | 场景 | 技术/规则 | | ---------------------- | ---------------------------------------------------------------------------- | | 4 个稳定 portal | teacher-portal / student-portal / parent-app / admin-console,按场景域划分 | | P1 测试页 | teacher-portal 仅一个测试页验证 classes CRUD 链路,P2 起配 Module Federation | | 视口驱动渲染 | 侧边栏由 `viewports.L1` 驱动渲染,路由由 `viewports.L2` 控制 | | 共享翻译资源包 | next-intl 微前端共享,BFF/服务返回 i18n key 不返回翻译文本 | | Module Federation 配置 | P2 起在 next.config.js 配置,P1 用单一 Next.js 应用 | ### 1.8 从旧项目迁移的通用经验 | 场景 | 技术/规则 | | --------------------- | ------------------------------------------------------------------------------------ | | React 19 乐观更新 | `useOptimistic` 替代手动 isPending,配合 `useTransition` 自动管理回滚 | | Zustand 细粒度选择器 | 单字段 selector 优于 `useShallow` 多字段包装 | | Tiptap SSR | 必须 `immediatelyRender: false` 避免 hydration mismatch | | 请求级去重 | React `cache()` 包装优于跨请求缓存(权限数据易变,不用 `unstable_cache`) | | 批量 SQL | INSERT batch `db.insert().values([...])` + UPDATE `CASE WHEN` 单次执行,禁止循环单条 | | 动态导入重 UI 库 | `xxx-inner.tsx` + `xxx.tsx` lazy wrapper(tiptap / @xyflow/react / AI SDK) | | ReactFlowProvider | 留在外层同步渲染,保证 lazy 子组件 hook 可用 | | 递归删除 | 批量收集后代 ID + `inArray` 单次删除,禁止递归逐个 | | 统计走 SQL 聚合 | `COUNT(*)`/`SUM(CASE WHEN ...)` 替代拉全表内存循环 | | 高频子组件 React.memo | `AssignmentCard` / `StatusBadge` / `EmptyState` 等 | | 虚拟化大表 | `@tanstack/react-virtual` | | 破坏性操作独立权限 | `AUDIT_LOG_PURGE` ≠ `AUDIT_LOG_READ`,沿用旧项目规则 | | i18n key 命名 | 禁止包含 `.`,动态 key 必须包含完整嵌套路径(`t(\`type.${type}\`)`) | | i18n 翻译文件对称性 | zh-CN 与 en 必须同步新增/删除 key | | 类型守卫优先 | JSON.parse 结果经 `unknown` 中转 + 守卫;禁止 `as` 断言(除非从 `unknown` 转换) | | 函数返回值显式标注 | 特别是 `Promise` | | 仅类型导入 | `import type` | ### 1.9 架构工具与验证命令 | 场景 | 命令/规则 | | ------------------- | ----------------------------------------------------------- | | arch.db 扫描 | `pnpm run arch:scan`(多语言:TS+Go+Python+Proto) | | arch.db 查询 | `pnpm run arch:query -- ` | | 查服务依赖 | `pnpm run arch:query -- deps ` | | 查 proto 契约 | `pnpm run arch:query -- sql "SELECT * FROM contracts"` | | 查 Kafka 事件 | `pnpm run arch:query -- sql "SELECT * FROM events"` | | 查架构违规 | `pnpm run arch:query -- violations`(骨架,P1 后期补全) | | proto lint | `cd packages/shared-proto && pnpm exec buf lint` | | proto breaking 检测 | `pnpm exec buf breaking --against '.git#branch=main'` | | proto 代码生成 | `pnpm proto:gen` | | Go 编译验证 | `cd services/api-gateway && go build ./... && go vet ./...` | | TS 类型检查 | `pnpm -r exec tsc --noEmit` | | 全量 lint | `pnpm lint` | | 全量测试 | `pnpm test` | | 启动最小基础设施 | `docker compose -f infra/docker-compose.minimal.yml up -d` | ### 1.10 coord 交叉审查仲裁审查(跨模块) > AI 在执行 coord 仲裁整改清单前,必须以 ai-allocation.md §3.2 为唯一源交叉验证责任方,并检查裁决内部自洽性。 | 场景 | 技术/规则 | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 仲裁责任方错配(content→ai05) | content 模块由 ai09 负责(ai-allocation §3.2 L80),coord 整改清单若标 ai05 则为错配;AI 必须以 ai-allocation §3.2 为准,提请 coord 修正而非自行执行 | | 仲裁责任方错配(msg→ai05) | msg 模块由 ai10 负责(ai-allocation §3.2 L81),coord 整改清单若标 ai05 则为错配;同上 | | 仲裁责任方错配(iam→ai02) | iam 模块由 ai06 负责(ai-allocation §3.2 L77),ai02=push-gateway;coord 整改清单若标 ai02 则为错配;同上 | | 仲裁责任方错配(push-gateway→ai01) | push-gateway 由 ai02 负责(ai-allocation §3.2 L73),ai01=api-gateway;coord §4.2/§5.6/整改第 21 项若标 ai01 则为错配;同上 | | 仲裁责任方错配(teacher-portal→ai07) | teacher-portal 由 ai13 负责(ai-allocation §3.2 L84),ai07=classes(黄金模板);coord §2.4/§4.1/§5.5/整改第 2 项若标 ai07 修正 teacher-portal 则为错配;同上 | | 仲裁责任方错配(parent-portal→ai07) | parent-portal 由 ai15 负责(ai-allocation §3.2 L86),ai07=classes;coord §2.4 第 4 项若标 ai07 修正 parent-portal 端点则为错配;同上 | | 仲裁责任方错配(core-edu→ai03) | core-edu 由 ai08 负责(ai-allocation §3.2 L79),ai03=teacher-bff;coord §4.2/整改第 14 项/整改第 23 项若标 ai03 则为错配;同上 | | 仲裁责任方错配(parent-bff→ai07) | parent-bff 由 ai05 负责(ai-allocation §3.2 L76),ai07=classes;coord §5.2 "ai03/ai04/ai07" 若指 BFF 前缀迁移则 ai07 应为 ai05;同上 | | 仲裁责任方错配系统性(5 组认知错位) | coord 存在 5 组系统性 AI 编号映射错位(ai07→teacher-portal/ai05→content-msg/ai02→iam/ai01→push-gateway/ai03→core-edu),疑似沿用之前 session 错误认知;整改前必须以 ai-allocation §3.2 全量交叉验证 | | 仲裁裁决内部冲突(topic 保留 vs 废弃) | coord-cross-review.md §3.5 保留 `edu.notification.events`,但 parent-bff 02 §0.1 C5 废弃改为 `edu.notification.sent/read/recalled/failed`;coord 自身前后不一致时,以正式审查报告(coord-cross-review.md)为准或提请 coord 统一 | | 仲裁裁决内部冲突(GraphQL 引入时机) | coord-cross-review.md §2.5 裁决"P2-P3 用 REST",但 parent-bff 02 §0.1 U3 用户仲裁"GraphQL P2 立即引入";coord §2.5 与用户 U3 仲裁直接冲突,用户仲裁优先级高,应以 U3 为准,提请 coord 修正 §2.5 | | 仲裁现状误判(project_rules §5) | coord §2.1 裁决"修订 project_rules §5 包名规范为 next_edu_cloud.*",但 project_rules §5 L214 已是目标态;整改前应先验证现状,避免多余整改 | | 仲裁审查三维度 | ① 责任方是否匹配 ai-allocation §3.2 ② 裁决是否自洽(无内部冲突)③ 现状是否误判;三维度全过方可执行 | | 仲裁问题处理流程 | 发现问题记录到 known-issues 工作日志 + 提请 coord 修正,禁止自行修改仲裁文档或按错误责任分配执行 | | coord 代笔文档仲裁 vs 正式审查报告 | coord 代笔文档(如 parent-bff 02)中的仲裁决策(如 C5)可能与 coord 正式审查报告(coord-cross-review.md)不一致;以正式审查报告为准,代笔文档中的仲裁需 coord 在正式报告中确认 | | 仲裁审查覆盖面要求 | 首次审查覆盖面不足(仅查 3 组错配遗漏 4 组),二次审查应严格逐条核对全部 24 项整改项,禁止抽样核对;系统性错配需按 ai-allocation §3.2 全量交叉验证 | | 用户仲裁 vs coord 仲裁优先级 | 用户仲裁(U1-U4)优先级高于 coord 仲裁;coord 裁决与用户仲裁冲突时(如 §2.5 GraphQL vs U3),以用户仲裁为准,提请 coord 修正其裁决以保持一致 | | 整改清单整体可执行性判断 | 若整改清单责任方错配占比超阈值(本次 71%,17/24 项),则整改清单整体不可直接执行,必须等 coord 重新发布修正版整改清单后方可推进 | --- ## 二、模块经验 ### 2.1 api-gateway(Go) | 场景 | 技术/规则 | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | P2 鉴权(RS256) | `shared-go/jwks.Fetcher` 拉 IAM `/.well-known/jwks.json` 公钥,TTL 5min 缓存,`jwt.Parse` 用公钥验签 | | 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` | | 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务(方案 B:Gateway 不改路径,各服务 Controller 内加 `/v1`) | | 用户上下文注入 | `c.Request.Header.Set("x-user-id/x-user-roles/x-data-scope", ...)` 传递给下游 | | 请求 ID | Gateway 生成或透传 `X-Request-Id`(`req-` 格式),`c.Set("request_id", ...)` + `c.Header(...)` | | 限流 | per-IP 令牌桶(`sync.Map`),超限 429 `GW_RATE_LIMITED`;P6 迁 Redis 支持多副本 | | 熔断 | `gobreaker v2` 共享 downstream 实例,5xx > 50% 触发 OPEN,返回 503 `GW_CIRCUIT_OPEN` + Retry-After | | 错误码 | 统一 `GW_` 前缀(W1),响应体 ActionState 信封 `{success,error:{code,message}}`(W2) | | 结构化日志 | 标准库 `log/slog`(W3),`slog.Info`/`slog.Error` 带 key-value;未接入 shared-go/logger(zap,待评估) | | 健康检查 | `GET /healthz`(liveness,无鉴权)+ `GET /readyz`(readiness,并行 ping 9 下游 /healthz,软失败规则,W4) | | Metrics | 7 个业务指标(promauto),`GET /metrics` 暴露(W5):requests/duration/circuit/ratelimit/proxy/jwks/authfail | | Tracer 资源属性 | `resource.New` 补 service.name/version/deployment.environment/host.name(W6,4 项完整) | | DevMode 生产防护 | 启动时 `DevMode=true && ENV=production` panic 拒绝启动(W7,config.go Load 校验) | | 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` | | 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` | | 路由注册位置 | 真实路由在 `main.go` 的 `api.Group` 内注册,`internal/routing/routing.go` 若未被 main 引用即为死代码 | | 多服务路由扩展 | 新增服务代理时用 `registerProxy(api, prefix, targetURL)` helper 注册两组路由,统一管理 | | DEV_MODE 环境变量 | Go 不自动加载 .env,`DEV_MODE` 必须在启动前 export 或写入系统环境变量,否则 DevMode=false 导致 dev-token 被拒 | | go.work workspace 解析 | `go.work` 列 `use ./packages/shared-go`,本地解析 shared-go 依赖,无需 `go mod tidy` 拉远程 | | go.work 版本冲突 | go.work 的 `go` 版本须 ≥ workspace 内所有模块的最高版本(push-gateway 要求 1.25.0,故 go.work 用 1.25.0) | | CORS 配置 | `CORS(cfg *config.Config)` 从 Config 读白名单,未配置时用开发默认白名单(localhost:3000,3001) | | 请求体限制 | `RequestBodyLimit` 先检 Content-Length 再用 `http.MaxBytesReader`,超限 413 `GW_REQUEST_TOO_LARGE` 信封 | ### 2.2 classes(TS/NestJS,P1 黄金模板) | 场景 | 技术/规则 | | ---------------- | -------------------------------------------------------------------------------------------------- | | 黄金模板定位 | P1 完整实现所有横切关注点,后续 8 个服务复制此模板 | | 黄金模板复制流程 | `cp -r services/classes services/xxx` → 改错误码前缀 → 改 proto → 改业务逻辑 → 改 README → 改 CI | | 横切关注点清单 | 错误处理 / 可观测 / 安全 / 契约 / 测试 / 文档 / 配置 / i18n / CI / Dockerfile | | 错误处理 | `ApplicationError` 基类 + 子类,错误码 `CLASSES_*` 前缀 | | 可观测 | pino logger + prom-client metrics + OTel tracer | | 安全 | auth.middleware(信任 Gateway 头)+ permission.guard + data-scope.interceptor | | 配置 | 三层配置 + Zod 校验 env | | i18n | `ERROR_CODES` 映射表(错误码 → i18n key) | | 测试四类 | 单元(vitest)+ 集成(Testcontainers)+ 契约(Pact)+ E2E(Playwright) | | 覆盖率门槛 | 领域逻辑 ≥ 80%,Handler ≥ 60%,整体 ≥ 60% | | Drizzle schema | `mysqlTable` + `varchar`/`timestamp` + `index` | | ID 生成 | `@paralleldrive/cuid2` 的 `createId()` | | 响应转换 | repository 返回 Date,service 转换为 `createdAt: number`(时间戳) | | 阶段特有模式回写 | Outbox(P3)/ CDC(P4)/ 长连接(P5)实现后回写黄金模板 README | | 健康检查依赖 | `readyz` 用 Drizzle `getDb().execute(sql\`SELECT 1\`)` 校验,不要依赖 typeorm DataSource DI | | AppModule 注册 | HealthModule 必须在 `app.module.ts` imports 数组显式声明,否则 NestFactory 不扫描 HealthController | | 增量编译陷阱 | `tsconfig.json` 显式 `"incremental": false` 覆盖 base,避免 .tsbuildinfo 导致 nest watch 不 emit | ### 2.3 iam(TS/NestJS) | 场景 | 技术/规则 | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 双入口 | REST `/v1/iam/*` + gRPC 50052(package `next_edu_cloud.iam.v1`),同一 IamService 实例共用(Hybrid app `connectMicroservice`) | | 认证 | RS256 非对称签名,私钥本地文件(`IAM_PRIVATE_KEY_PATH`),公钥通过 JWKS 端点暴露给 Gateway | | JWT payload | `{ sub, email, roles, dataScope, type, iss, aud, jti(仅 refresh), kid(header) }`,register 自动分配 teacher 角色 | | Token TTL | access 15min / refresh 7day,refresh 含 jti,轮换时旧 jti 加入 Redis 黑名单 | | RBAC | 角色/权限/角色-权限查询 + `getEffectivePermissions(userId)` API,三层角色模型(system/organization/temporary,level 0/1/2) | | 视口配置 | 4 层模型(导航/路由/组件/数据),`iam_role_viewports` 表含 `level` ENUM(admin/teacher/student/parent) | | DataScope 解析 | 6 级:self/subject/class/grade/school/all(SUBJECT 替代 DISTRICT),注入 JWT payload | | 权限校验 | PermissionGuard(APP_GUARD)DB 驱动 + Redis 缓存 TTL 5min,`data_scope=all` 直放行,Key `iam:perm:{userId}` | | Token 黑名单 | Redis Key `iam:bl:{jti}` TTL 与 refresh_token 剩余有效期对齐,logout/refresh 时写入 | | 审计日志 | `iam_user_audit_log` 表 + `AuditCreated` Kafka 事件,登录/角色变更/密码修改等关键操作记录 | | 家长-学生关系 | `iam_student_guardians` 表(unique(studentId, guardianId)),`GetChildrenByParent` RPC + `GET /v1/iam/children` REST | | schema 表 | iam_users / iam_roles / iam_user_roles / iam_permissions / iam_role_permissions / iam_refresh_tokens / iam_role_viewports / iam_student_guardians / iam_user_audit_log / iam_password_history | | Outbox | shared-ts `OutboxModule.forRoot({ config, db, kafkaProducer })`,表名 `iam_outbox`,topic `edu.iam.user.events`/`edu.iam.role.events`/`edu.iam.audit.created` | | AuthMiddleware 注册 | `app.module.ts` `configure()` 注册于 me/logout/viewports/permissions/roles/audit/children 路由,解析 `x-user-*` 头 | | 公开路径 | register/login/refresh/jwks.json 无 `@RequirePermission()`,PermissionGuard 旁路 | | ESM 模式 DI | `providers: [IamService, IamRepository]` + 构造器 `@Inject(IamRepository)` 显式注入,避免 `undefined` 运行时错误 | | APP_GUARD DI 导出 | PermissionGuard 注册为 APP_GUARD(AppModule 级),其依赖 PermissionCacheService/IamRepository 须在 IamModule `exports` 中导出,否则 AppModule 上下文无法注入 | | Proto 路径解析 | `resolveProtoPath()` 兼顾 cwd=monorepo 根(CI)与 cwd=services/iam(`pnpm --filter` dev),先 `packages/shared-proto/proto/`,回退 `../../packages/shared-proto/proto/` 与本地 `proto/`(Docker) | | Drizzle ORM API | `inArray(col, vals)` 替代不存在的 `.in()`;`delete().where()` 不支持链式多次 where,用 `and(eq(...), eq(...))` 组合 | | Drizzle DataScope 类型 | `mysqlEnum` 推断类型为字面量联合,Repository 方法参数须用 `DataScope` 类型而非 `string`,否则 TS 报错 | | ioredis 类型 | `import { Redis } from "ioredis"`(named import),`type RedisClient = InstanceType`,默认导入在 TS 中不可构造 | | jwt.sign expiresIn 类型 | `@types/jsonwebtoken` v9 `expiresIn` 类型为 `number \| StringValue`,普通 string 不兼容,用 `ttlToSeconds(ttl)` 返回 number | | 健康检查依赖 | `/readyz` 5 依赖:DB(SELECT 1) / Redis(ping) / Kafka(producer 实例) / JWKS(密钥文件可读) / gRPC(进程内) | | 优雅停机 | 顺序:Kafka producer disconnect → Redis quit → DB pool end(LifecycleService `onApplicationShutdown`) | ### 2.4 core-edu(TS/NestJS,P3) | 场景 | 技术/规则 | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 | | Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka | | Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 | | Kafka topic | `edu.teaching.exam.created` / `edu.teaching.homework.graded` / `edu.teaching.grade.recorded`(ISSUE-002/004 仲裁统一前缀) | | 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 | | 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) | | Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) | | schema 表 | exams / exam_questions / exam_submissions / homework / homework_submissions / homework_answers / grades / grade_formulas / attendance / courses / lessons / schedules / classes / teacher_associations / outbox | | datetime 列 | Drizzle `datetime` 列需 Date 对象,HTTP 请求体里是 ISO 字符串,service 层必须 `new Date(input)` 转换否则报 `toISOString is not a function` | | Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 | | 相对 import | `src//` 下文件回溯一级用 `../`,不要用 `../../`(NestJS ESM 模块) | | 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy,不依赖未注册的 AuthMiddleware | | 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource | | Outbox 验证 | 业务事务同写 `core_edu_outbox` 表,Kafka 未启动时事件 status=failed,启动后会重试 | | Drizzle 索引语法 | drizzle-orm 0.31+ 的 `mysqlTable` 第三参数回调必须返回对象(`{ idx: index(...).on(...) }`),不能返回数组 | | Drizzle datetime defaultNow | `datetime` 列类型不支持 `defaultNow()`,需改用 `timestamp` 列类型或在 service 层显式传入 `new Date()` | | KafkaJS 连接状态 | KafkaJS Producer 类型无 `isConnected()` 方法,需监听 `producer.connect`/`producer.disconnect` 事件自行维护布尔标志 | | 状态机类型转换 | DB 查询返回的 `status` 字段为 `string`,传入状态机函数前需 `as ExamStatus` 转换(drizzle 不支持 enum 字面量类型推导) | | Controller createdBy | Controller 构造 `CreateExamInput`/`AssignHomeworkInput` 时需显式从 `req.userId` 赋值 `createdBy` 字段,Zod schema 不含此字段 | | Redis 分布式锁 | 作业提交幂等用 Redis `SET NX EX`,Redis 不可用时降级到 DB 唯一索引兜底 | | 课表冲突检测 | `findOverlappingSchedules` 查 teacher + class 两个维度的时间重叠,ISSUE-006 #2 裁决 room_id 仅 P3 预留不校验冲突 | | 成绩公式优先级 | scope 优先级 class(3) > subject(2) > school(1),ISSUE-006 #3 裁决;`gradeFormulas` 表按 effectiveFrom/effectiveTo 时间窗口匹配 | | 作业宽限期 | 默认 grace_period=300 秒(ISSUE-006 #4 裁决),提交时 `dueDate + gracePeriod` 判断是否迟到 | | 考试归档 | ISSUE-006 #7 裁决 archived 仅软删除(设 status + archivedAt),不物理删除 | | 事件 payload | `buildEvent` 统一封装 event_id/occurred_at/metadata.schema_version,ISSUE-006 #6 裁决 exam.submitted 事件只带 submission_id+studentId | | IAM 事件消费 | `iam-consumer` 订阅 `edu.iam.teacher.assigned`/`edu.iam.class.created`,幂等 upsert 到 `teacher_associations`/`classes` 表 | ### 2.5 content(TS/NestJS,P4) | 场景 | 技术/规则 | | -------------- | ------------------------------------------------------------------------------- | | 知识图谱 | Neo4j 查询前置依赖图(秒级返回) | | 题库 CRUD | P4 仅 CRUD,P5 引入 ES 实现检索,避免 MySQL FULLTEXT → ES 迁移成本 | | 双写避免 | Neo4j/ES 不直接双写,由消费 Kafka 事件同步,天然最终一致 | | Neo4j 写入 | Content 服务写 MySQL 同时发事件,独立 worker 消费事件同步 Neo4j | | API 字段名 | 请求体用 TS schema 字段名(如 `order`),非 DB 列名(如 `order_num`) | | Neo4j 不可用 | 未设置 NEO4J_URL 时 driver=null,getNeo4jSession 返回 null,业务正常落库 MySQL | | Neo4j 连接超时 | driver 配置 connectionTimeout:3000,避免 Neo4j 不可用时拖慢 HTTP 响应 | | Drizzle int | drizzle-orm/mysql-core 导出 `int()` 不是 `integer()` | | db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数,与 core-edu/classes 黄金模板对齐 | ### 2.6 data-ana(Python/FastAPI,P4) | 场景 | 技术/规则 | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | 学情诊断 | ClickHouse 宽表查询,5s 内返回 | | CDC 链路 | Debezium 监听 MySQL binlog → Kafka(`edu-cdc.next_edu_cloud.`)→ DataAna 消费写 ClickHouse | | CDC 延迟监控 | Debezium 暴露 lag metrics,超阈值告警 | | 双轨读策略 | 实时查 MySQL 主库(刚提交的成绩),聚合查 CH 宽表(延迟 1-5s 可接受) | | 幂等消费 | 所有事件消费者必须幂等(基于 event_id 去重) | | CDC 消费者实现 | `cdc_consumer.py` 用 aiokafka AIOKafkaConsumer,lifespan 启动 asyncio.create_task 后台运行 | | ClickHouse 写入 | `clickhouse_client.upsert_student_dashboard()` 用 client.insert() 写宽表,client 为 None 时降级返回 False | | Debezium 事件解析 | `before/after/source/op/ts_ms` 五字段,op=r(快照)/c(新增)/u(更新)/d(删除) | | 多表关联缓存 | 内存 ExamCache 缓存 exam_id→class_id 映射(来自 core_edu_exams CDC 事件),grades 事件触发时查缓存填充宽表 class_id | | Consumer offset 重置 | `kafka-consumer-groups --reset-offsets --to-earliest --execute` 需先停消费者让 group 处于 Empty 状态 | | 5 张宽表对齐 | student_dashboard_view / student_errors / mastery_snapshot / attendance_logs / ai_usage_log(ai-allocation §5 强制) | | ActionState 信封对齐 | Python 服务 `{success, data, degraded}` 必须改 `{success, data, error:{code, message, details?, traceId?}}`,degraded 作 details 子字段(004 §11.5 P0) | | gRPC server 启用 | P4 启用 data-ana gRPC server 端口 50055(004 §4.2 矩阵),HTTP 3006 保留作 Gateway 直连降级 | | DataScope 6 级过滤 | Repository 层注入 WHERE(SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALL),iam `GetEffectiveDataScope` gRPC P4 补全(004 §15.3 #5) | | Outbox 豁免 | 派生数据事件(MasteryUpdated / WarningTriggered / AIUsageRecorded)豁免 Outbox,允许直接 Kafka producer(004 §12.2 + §15.3 #6) | | 掌握度算法 | WEIGHTED_MOVING_AVG(w_i=0.6^i)+ FORGETTING_CURVE(半衰期 30 天)取 max,近 5 次不足降级 SIMPLE_AVG | | 预警阈值设计 | LOW_MASTERY(<0.4) / CRITICAL_LOW(<0.2) / SCORE_DROP(下降≥20%) / ABSENT_FREQUENT(≥3次/周) / TREND_DECLINE(连续3次降) | | 预警去重 | 同一 (target_id, warning_type) 24h 内只触发一次,Redis 位图 `data_ana:warning:{target_id}:{type}:{date}` TTL 25h | | CDC 水平扩展 | P4 单实例 + 内存 ExamCache LRU → P6 多实例 + Redis ExamCache + partition 扩容 + HPA | | CDC 手动 commit | v2 改进:`enable_auto_commit=False` 手动 commit(at-least-once),commit 前确保 ClickHouse 写入成功 | | ClickHouse 容量规划 | 5 宽表年增量 ~2.3GB(5000 学生×20 次/月),单节点可承载;P6 按 class_id hash 分片 + ReplicatedMergeTree | | ClickHouse FINAL | ReplacingMergeTree 查询必须加 `FINAL` 或用 `argMax` 聚合,否则读到重复版本(clickhouse_repository.py 已实现 P0 整改) | | 缓存键命名 | `data_ana:datascope:{user_id}` 5min / `data_ana:exam:{exam_id}` 30day / `data_ana:dedup:{event_id}` 7day | | 4 端 Dashboard | teacher/student/parent/admin 各自 Dashboard API + 权限点 ANALYTICS_*_DASHBOARD(v2 新增 4 端权限) | | SubscribeMasteryUpdate | gRPC server-streaming RPC 为 P5+ AI 个性化推荐预留实时推送通道(analytics.proto v2 提案) | | GDPR 数据删除 | ClickHouse 无 UPDATE/DELETE,用 `ALTER TABLE ... DELETE WHERE student_id=?` 异步 mutation(P6 实现) | | 字段脱敏 | student_id 在日志中 hash 化(structlog processor),PII 不入日志 | | 事件版本化 | 事件 payload 加 `schema_version: "v1"` 字段,破坏性变更发 v2,consumer 双消费 6 个月过渡 | | API 版本化 | HTTP `/analytics/v1/*` + gRPC `next_edu_cloud.analytics.v1`,破坏性变更新增 v2,旧版保留 6 个月 | | Projection 演进 | P4 CDC 直连 → P4 后期双消费(CDC + 领域事件)→ P6 ClickHouse MaterializedView 自动聚合 | | 测试策略 | pytest + Testcontainers(ClickHouse/Kafka 容器化)+ 单元 ≥80% + 集成 ≥60% + 契约 pact-python + E2E | | Dockerfile 多阶段 | builder(uv sync --frozen)+ runtime(python:3.12-slim < 200MB)+ HEALTHCHECK 集成 | | 配置项完整清单 | pydantic-settings 30+ 配置项,env_prefix="DATA_ANA_",含 ClickHouse/Kafka/iam gRPC/Redis/OTel/算法参数/预警阈值 | | 降级模式 4 场景 | ClickHouse/Kafka/Redis/iam gRPC 不可用时返回骨架 + `error.details.degraded=true`,4 错误码 DATA_ANA_*_UNAVAILABLE | | Python gRPC 实现 | grpc.aio + betterproto + ServerInterceptor 透传 W3C trace context,与 HTTP 共享同一 Application Service | | structlog API | 24.x 用 `make_filtering_bound_logger(level)`,旧版 `make_filtering_logger` 已废弃 | | ruff per-file-ignores | gRPC servicer 方法必须 PascalCase(N802 忽略)/ FastAPI Depends 在参数默认值(B008 忽略),pyproject.toml 配 per-file-ignores | | Mock 数据脚本 | `scripts/data-ana-mock-data.sql` 提供 5 宽表演示数据(20 学生×2 考试×3 知识点),用 `now()-INTERVAL` 避免硬编码日期 | | Python 3.12+ 现代化 | ruff UP042 用 StrEnum 替代 `str, Enum` / UP046 用 PEP 695 类型参数 `class Foo[T]` 替代 `Generic[T]` | ### 2.7 messaging(TS/NestJS,P5) | 场景 | 技术/规则 | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 消息 CRUD | 会话/消息 + 调 Push Gateway 推送 + 通知偏好 | | 通知批量化 | `createBatch(items)` 单次 INSERT,沿用旧项目 dispatcher 模式 | | 多渠道策略 | ChannelStrategy 接口 + InApp/Email/Sms/Push 实现,新增渠道不改核心(ai10 设计) | | fan-out 分页 | `listByUserWithPagination(userId, page, pageSize)` 分页查询 | | ES 降级(单向) | ES_URL 未设置时 esClient=null,safeIndex/safeSearch 跳过返回空结果 | | DB→ES 降级读 | DB 故障时 fallback 到 ES 读模型,响应头标 `X-Read-Model: es-degraded`(ai10 设计) | | ES→DB 降级检索 | ES 故障时 search() fallback 到 DB LIKE,降级计数打 metrics(ai10 设计) | | Push 推送降级 | PUSH_GATEWAY_URL 未设置或连接失败时 try/catch 跳过,不影响 DB 写入;PushChannel 失败降级 in_app | | Kafka 消费幂等 | event_id → Redis SETNX `msg:idem:{event_id}` TTL 7d;Redis 不可用降级 msg_idempotency 表唯一键(ai10 设计) | | 三层幂等防线 | L1 Redis SETNX / L2 msg_idempotency 表 / L3 notifications.source_event_id 唯一索引(ai10 设计) | | 已读/未读位图 | Redis Bitmap `msg:unread:{userId}`,BITCOUNT 计数;Redis 不可用降级 DB COUNT(ai10 设计) | | Outbox 强制(待 ai10 确认) | 004 §7.2 列 msg 生产 `edu.notification.events`,按 004 §12.2 强制 Outbox,P5 必须实现 msg_outbox_events 表 + Publisher worker | | 12 类事件消费清单(待 ai10 确认) | 消费 iam(user.created/updated/deleted/role_changed + role.created/updated)+ core-edu(exam.published/assignment.submitted/assignment.graded/grade.recorded/attendance.recorded)+ data-ana(mastery.updated)共 12 类事件触发通知 | | 4 类发布事件(待 ai10 确认) | 发布 NotificationSent / NotificationRead / NotificationRecalled / NotificationFailed 4 类事件,消费者 push-gateway + data-ana | | 通知状态机(待 ai10 确认) | pending → sent → delivered → read / failed / recalled,含 6 状态转换 + 撤回场景预留 | | NotificationTemplate 模板渲染(待 ai10 确认) | titleTemplate/contentTemplate 含 `{{variable}}` 占位符,RenderTemplate RPC 根据 variables 替换渲染,支持 locale 多语言 | | NotificationPreference 用户偏好(待 ai10 确认) | 渠道开关 channels[] + 频率限制 frequency_limit + 静默时段 quiet_hours,5 维度配置矩阵 | | Push Gateway 调用方向澄清(待 ai10 确认) | 004 §4.1 表述"PushGW→Msg 推送通道建立"歧义,实际方向是"msg → push-gateway (gRPC PushService.Push)",建议 coord 修正 004 §4.1 表述 | | 频率限流(待 ai10 确认) | Redis 计数 `msg:ratelimit:{userId}:{type}:{yyyyMMddHH}` TTL 1h,按 NotificationPreference.frequency_limit 限制;Redis 不可用降级跳过限流 | | 优雅关闭顺序(待 ai10 确认) | HTTP server → Kafka consumer → Outbox publisher → ChannelDispatcher → Redis → ES → DB pool → Tracer,8 步序列保证在途消息处理完成 | | events.proto message 缺失阻塞(待 ai10 确认) | events.proto 缺 UserEvent/RoleEvent/NotificationEvent/MasteryEvent 4 类 message,msg 消费 iam/data-ana 事件阻塞,提请 coord 补齐 proto | | 死信队列 | 消费失败超 3 次投递 `edu.notification.dlq`,告警人工介入(ai10 设计) | | Outbox 事件发布 | 事务内写 msg_outbox + 业务表,独立 publisher 投递 `edu.notification.events`(参照 core-edu) | | 模板渲染 | TemplateService + {{var}} 替换 + i18n locale;渲染失败降级默认文案记 MSG_TEMPLATE_RENDER_ERROR | | 静默时段 | QuietHours 过滤非紧急通知,URGENT 优先级绕过静默(ai10 设计) | | 短信限流配额 | Redis 滑动窗口限流 + 配额计数,超限返 MSG_RATE_LIMITED/MSG_QUOTA_EXCEEDED(ai10 设计) | | 优雅关闭 | 统一到 LifecycleService,顺序 HTTP→Kafka→Outbox→ES→Redis→DB→Tracer(ai10 修订) | | db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 | | db 常量导出 | database.ts 导出 `db` 常量替代 `getDb()` 函数 | ### 2.8 push-gateway(Go,P5) | 场景 | 技术/规则 | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | WebSocket 长连接 | 单实例支撑 10w+ 连接(ISSUE-003),业务服务调 /internal/push 或消费 Kafka | | 心跳协议 | RFC 6455 控制帧(Ping/Pong),30s 间隔,SetReadDeadline(60s) 超时断开;禁止文本消息 ping/pong | | 单用户连接数限制 | 默认 5,超限返 PUSH_TOO_MANY_CONNECTIONS + close frame 1008 | | 跨实例同步 | Redis Pub/Sub 订阅 `edu:push:channel:user:` + `edu:push:channel:broadcast`;在线 SET `edu:push:online:` 60s TTL | | Redis SET 启动重建(ISSUE-058) | 启动时 SREM 旧 instanceID 成员,基于内存连接 ForEachUser 重新 SADD,避免 Pod 重启后残留幽灵在线状态 | | 离线消息 | 仅推在线用户;离线消息落库由 msg 服务负责(ISSUE-005),push-gateway 无 DB | | 并发写修复 | send chan + 单写协程模式,避免 gorilla/websocket 并发写竞争 | | 内部 API 鉴权 | `X-Internal-Token` 头 + `INTERNAL_API_TOKEN` 环境变量(ISSUE-002,原名 X-Internal-Key 已废弃) | | Kafka topic 命名(ISSUE-053) | `edu.notification.requested`(非 edu.notification.events,遵循 G16 `..` 规则) | | Kafka 幂等性 | 基于 event_id Redis SETNX 去重(24h TTL),at-least-once + 消费失败 3 次入 DLQ | | /readyz 软失败(ISSUE-058/006) | Redis/Kafka 不可达返 200 + `degraded: true`,仅 Hub.CloseAll 触发 closing 时返 503;避免 K8s 在依赖抖动时驱逐 Pod | | 无 gRPC(ISSUE-007) | 仅 HTTP + WebSocket,不暴露 gRPC 服务 | | shared-go 复用(ARB-015 §17.5) | tracer/logger/jwks/env 走 shared-go 包,不重复实现 | | 错误码前缀 | `PUSH_*`(PUSH_UNAUTHORIZED/PUSH_INVALID_REQUEST/PUSH_TOO_MANY_CONNECTIONS 等) | | 多阶段 Dockerfile | golang:1.25-alpine 构建 → alpine:3.20 运行,CGO_ENABLED=0 + ldflags -s -w + non-root user + wget healthcheck | | slog 结构化日志 | log/slog(JSON prod / text dev),替代标准 log 包;字段含 request_id/trace_id/user_id/conn_id/event | | DEV_MODE 鉴权 | DEV_MODE=true 时接受 dev-token(HS256 fallback),生产环境必须 JWT RS256(JWKS 5min 缓存) | | 广播端点 | POST /internal/broadcast,body {event, data},调用 hub.Broadcast | | gorilla/websocket API 陷阱 | SetReadLimit 返回 void(不可 `_ =` 赋值);SetPongHandler 签名为 `func(appData string) error`(非 `func(string, error) error`) | | sync.RWMutex 重入死锁 | 持写锁(Lock)的 goroutine 不可再调用同结构体的 RLock 方法(Go RWMutex 非重入);CloseAll/Broadcast 内部计数改为内联遍历 | | httptest.Server.Close() 死锁 | 测试 handler 阻塞 ReadMessage 时 Close() 会死锁;用 done channel 模式:cleanup 先 close(done) → cli.Close() → srv.Close() | ### 2.9 ai(Python/FastAPI,P5) | 场景 | 技术/规则 | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | | LLM Provider 适配 | 4 适配器(OpenAI/Anthropic/Baichuan/Ollama)+ ProviderFailoverChain + CircuitBreaker(3 次失败 60s 熔断) | | 降级模式方案 B | success=true + error=null + data 内 degraded=true + degraded_reason(总裁 §2.6,非顶层 degraded 字段) | | ActionState 信封 | 所有 HTTP 响应统一 `{success, data, error:{code,message,details?,traceId?}}`(004 §11.5 强制) | | 路由前缀 | /v1/ai 前缀(APIRouter prefix="/v1/ai"),Gateway 代理 /api/v1/ai/* → /v1/ai/* | | gRPC server | grpc.aio 端口 50058,8 RPC + 3 interceptor(Logging/Auth/Error) | | 评估三道防线 | RuleValidator(JSON/字段/难度校验)→ LLMJudge(语义评分)→ QualityGate(综合决策门控) | | 备课工作流 | P5 用 asyncio.create_task + Redis 状态存储(24h TTL),P6+ 评估 Temporal | | 工作流状态机 | pending→analyzing→generating→pending_review→persisted/failed,生成失败重试最多 3 次 | | 限流三维度 | user 10/min + IP 30/min + school 100/min,Redis Lua 原子令牌桶,Redis 不可用降级放行 | | 用量记录 | Redis INCRBY 按 user/school/month 维度,35 天过期;Kafka 发布 AIUsageEvent 到 edu.ai.usage topic | | Kafka 派生数据豁免 Outbox | AIUsageEvent 为派生数据,004 §12.2 豁免 Outbox,直接 aiokafka producer(事务性 + 幂等) | | 安全层全本地 | PIIRedactor(5 类 PII 正则脱敏)+ InputSanitizer(prompt injection 检测)+ OutputModerator(5 类敏感内容审核) | | 六边形架构端口模式 | ContentClient/DataAnaClient/IamClient 均为抽象接口 + Mock + gRPC 实现,全并行模式用 Mock | | 权限校验 | PermissionGuard 5 权限点(ai:chat / ai:question:generate / ai:expression:optimize / ai:lesson:generate / confirm) | | dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 + 跳过权限校验 | | proto 包名 | 保持 `next_edu_cloud..v1`(coord G17 裁决覆盖 project_rules §5) | | proto_gen 排除 ruff | pyproject.toml `[tool.ruff] exclude = ["src/ai/proto_gen"]`,自动生成文件不参与 lint | | ruff UP017 | `datetime.now(timezone.utc)` 改用 `datetime.now(datetime.UTC)` 别名(Python 3.12+) | | ruff SIM103/SIM110 | `if x: return False; return True` 改 `return not x`;for 循环改 `all()` 表达式 | | PowerShell 不支持 && | RunCommand 用 cwd 参数指定工作目录,不用 `cd ... && uv run` | ### 2.10 shared-proto(契约包) | 场景 | 技术/规则 | | ------------ | ------------------------------------------------------------------------------- | | 目录结构 | `proto/*.proto` + `buf.yaml` + `buf.gen.yaml` | | P1 契约 | 仅 `classes.proto`,`iam.proto`/`core_edu.proto` 占位 | | 代码生成输出 | TS → `shared-ts/generated`,Go → `shared-go/`,Python → `services/*/generated/` | ### 2.11 arch-scan(多语言扫描器) | 场景 | 技术/规则 | | -------------- | ----------------------------------------------------------------------------------------------- | | TS 扫描 | regex 提取(function/class/interface/UPPER_CASE const),避免 ts-morph 对未安装依赖文件解析失败 | | Go 扫描 | 正则提取(行首锚定 `^func`/`^type`),P2 起替换为 tree-sitter-go AST | | Python 扫描 | 正则提取(行首锚定 `^def`/`^class`),P4 起替换为 tree-sitter-python AST | | arch.db schema | modules/symbols/dependencies/contracts/events/violations 六表 | | 全量扫描 | 先清空旧数据再扫描,避免残留 | | 并行扫描风险 | 并行子代理执行 arch:scan 可能因竞争报 FOREIGN KEY 错误,必须串行执行 | | 扫描后验证 | `npm run arch:query -- violations` 输出 0 为合格 | | Windows 路径 | 自定义 ESLint 规则加载用 `path.join`,不用 `path.posix.join` | ### 2.12 teacher-portal(微前端宿主,P1 测试页) | 场景 | 技术/规则 | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | P1 测试页 | 单一 Next.js 应用,验证 classes CRUD 端到端链路 | | API 调用 | `fetch(${API_BASE}/api/v1/classes)` + `Authorization: Bearer ${TEST_JWT}` | | P1 测试 JWT | 开发工具生成 HS256 token,P2 起由 IAM 签发 RS256 | | P2 Module Federation | next.config.js 配置,按场景域分 4 个稳定 portal | | P2 路由组 + AppShell | `app/(app)/layout.tsx` 用 AppShell 包裹受保护页;`/login` 与 `/` 不套壳 | | P2 真实 JWT | 登录后 token 存 localStorage,`authHeaders()` 读 `Bearer ${getToken()}` | | P2 视口驱动侧边栏 | AppShell fetch `/teacher/viewports` 渲染左侧导航,active 路由高亮 | | P2 根路径重定向 | `app/page.tsx` 客户端组件 `router.replace(isAuthenticated() ? '/dashboard' : '/login')` | | fetch headers 类型 | `authHeaders(): Record` 显式标注,避免 `{}` 与 `HeadersInit` 不兼容 | | P2 MF Shell+Remote 架构 | teacher-portal:**4000** 为 Shell,student:**4001**/parent:**4002**/admin:**4003** 为 Remote(004 §1.2 + coord final §F1);MF 暴露 **AppShell 整体**(coord final §F10),各 Remote 自行决定内部布局 | | P2 统一 API 请求层 | ApiClient 封装 401 自动刷新 token 轮转 + ActionState `{success,data,error:{code,message,details?,traceId}}` 解析 + 错误码前缀路由 i18n + trace_id 透传;**P2 起对接 GraphQL**(urql/apollo,coord final §F9/B1) | | P2 统一 API 请求层 | ApiClient 封装 401 自动刷新 token 轮转 + ActionState `{success,data,error}` 解析 + 错误码前缀路由 i18n,替代页面级 `authHeaders()+fetch` 重复 | | P2 设计令牌三层模型 | primitive(原始色板)→ semantic-light/dark(语义)→ tailwind-theme(`@theme inline` 暴露 `bg-*`);ESLint `no-restricted-syntax` 禁 `#hex` + `design-tokens/no-hardcoded-fonts` 禁字面量字体(§3.10) | | P2 4 端错误码前缀对齐 | **BFF_TEACHER\_=teacher-bff / BFF_STUDENT\_=student-bff / BFF_PARENT\_=parent-bff**(coord final §B5,统一 `BFF_XXX_` 风格);admin-bff 待 P6 定;前端按前缀路由 i18n key | | P2 4 端错误码前缀对齐 | TP_=teacher-bff / SP_=student-bff / PP_=parent-bff / AP_=admin-bff,前端按前缀路由 i18n key | | P2 i18n key 命名 | **统一 `error..`**(如 error.iam.invalid_credentials、error.bffTeacher.validation_error,coord final §F4);翻译文件按域分 namespace | | P2 权限点命名风格 | **统一 `_`**(如 DASHBOARD_VIEW、EXAM_READ、GRADE_READ),数据范围用后缀 `_OWN`/`_CHILD`(如 GRADE_READ_CHILD,coord final §F7) | | P2 Token 存储 | **P2 用 localStorage**(accessToken + refreshToken,coord final §F12);httpOnly Cookie + CSRF Token **P6 单独评估**(非分阶段迁移,P2 直接采用最终方案的当前阶段形态) | | P2 packages 归属 | **ai13 维护** packages/ui-tokens、ui-components、hooks(coord final §F8);coord 仅维护 shared-ts、contracts;ai13 需建立这 3 个共享包 | | P2 GraphQL 客户端 | **P2 起前端用 urql 或 apollo**(coord final §F9),对接 teacher-bff GraphQL Yoga + DataLoader;废弃 REST fetch 模式;ApiClient 适配 GraphQL 操作类型(query/mutation/subscription) | | P2 视口端点对齐 | **GET /iam/permissions/effective**(非 /iam/effective-permissions,coord final §F6/I8)+ GET /iam/viewports + GET /iam/roles;parent 子女切换 POST /parent/children/:childId/select | | P2 MSW mock 层 | `src/mocks/` 含 handlers.ts(拦截 /api/auth/login + /api/v1/teacher/graphql)+ fixtures/*.ts + browser.ts(setupWorker)+ server.ts(setupServer)+ index.ts(initMocks 按 NEXT_PUBLIC_API_MOCKING=enabled 启用) | | P2 MSW worker 脚本生成 | `npx msw init public/` 生成 mockServiceWorker.js 静态文件;package.json 自动添加 `msw.workerDirectory` 配置;必须在 teacher-portal 目录下执行(msw 在 devDependencies) | | P2 MSW providers 集成 | GraphQLProvider 内 `initMocks()` ready 前返回 null 避免 GraphQL 请求未拦截;server.ts `onUnhandledRequest: "bypass"` 放行非 mock 请求 | | P2 urql useQuery 类型 | urql useQuery 返回 data 类型推断不足,map 回调参数隐式 any;需 `import type { XxxItem } from "@/lib/graphql"` + `(data.xxx as XxxItem[]).map(...)` 显式断言 | | P2 Suspense + useSearchParams | Next.js App Router 中 useSearchParams 必须包裹 ``,否则构建报错;students/exams/homework/grades 页面均用 `Suspense fallback={}` 包裹 StudentsContent | | P2 DataScope 类型对齐 | graphql.ts 的 DataScope 必须与 hooks/types.ts 的 DataScope 完全一致(`"SELF" \| "CLASS" \| "GRADE" \| "SCHOOL" \| "DISTRICT" \| "ALL"`,ARB-001 §1.2 schema),否则 buildPermissionContext 返回类型不匹配 | | P2 TS4114 override | tsconfig.base.json `noImplicitOverride: true` 下,React.Component 子类的 state/componentDidCatch/render 必须加 `override` 修饰符 | | P2 cn.ts null vs undefined | `match ? match[1] : null` 在 noUncheckedIndexedAccess 下返回 `string \| undefined`,与 `string \| null` 不兼容;改用 `match?.[1] ?? null` | | P2 AppShell GraphQL 化 | AppShell 从 REST `/teacher/viewports` 改为 GraphQL `ViewportsQuery` + `MeQuery`;权限上下文从 localStorage 读取 + usePermission 注入;视口按 requiredPermission 过滤 | | P2 健康检查路由 | `app/api/health/route.ts`(liveness)+ `app/api/ready/route.ts`(readiness)均 `export const dynamic = "force-dynamic"`,返回 NextResponse.json;Dockerfile HEALTHCHECK 指向 :4000/api/health | | P2 Dockerfile 端口对齐 | teacher-portal 端口 3000→4000(004 §1.2);Dockerfile `ENV PORT=4000` + `EXPOSE 4000` + `CMD next start -p 4000` + HEALTHCHECK `http://localhost:4000/api/health` | | P2 MSW GraphQL 路由 | handlers.ts `http.post("*/api/v1/teacher/graphql")` 按 operationName switch 路由;未传 operationName 时从 query 文本正则提取 `/(?:query\|mutation)\s+(\w+)/` 兜底 | ### 2.13 parent-portal(家长端微前端 Remote,P4) > parent-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 parent-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。 | 场景 | 技术/规则 | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | P4 端口分配 | parent-portal = **4002**(004 §1.2 强制 4 端 4000-4003;ai07 初稿误用 3002 与 iam 冲突,ai15 修订) | | P4 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf` 暴露 `./ChildSwitcher` 等家长端特有组件,复用 Shell 提供的 `AppShell`/`usePermission`/`ApiClient`/`RequirePermission` | | P4 DataScope CHILDREN | 家长仅能查看自己绑定子女的数据(004 §5.4 L0 SELF 变体),前端通过 ApiClient 透传 userId + childId,下游服务按 DataScope 过滤 | | P4 多子女切换 ChildSwitcher | Zustand slice(`selectedChildId`)+ 切换后 `queryClient.invalidateQueries({ queryKey: ['child', prevId] })`;多子女 Tab(≤3)或下拉(≥4);空态/单子女/多子女三态分支 | | P4 多子女数量边界 | 0 子女(EmptyChildState 引导绑定)→ 1 子女(隐藏切换器)→ 2-3(Tab Bar)→ 4-10(下拉)→ >10(搜索 + 列表);超出 10 触发性能预警 | | P4 通知偏好三维矩阵 | `Record>>`;react-hook-form + Zod 校验;默认值矩阵(成绩发布=邮件+短信、作业批改=邮件、教师沟通=短信、学校通知=邮件);渠道可用性校验 | | P4 跨标签同步 | BroadcastChannel API(`parent-portal-sync`)+ localStorage 持久化 + LWW(Last-Write-Wins)冲突解决;同步 selectedChildId / 通知偏好 / 已读状态 | | P4 隐私合规(COPPA/FERPA/PIPL) | 未成年人数据最小化采集;通知偏好默认 opt-in;数据保留 7 年后自动归档;前端不缓存敏感字段到 localStorage(仅 childId + 已读位图) | | P4 i18n 5 语言含 RTL | next-intl 支持 zh-CN / en-US / zh-TW / ja-JP / ar-SA;ar-SA 用 `dir="rtl"` + Tailwind `rtl:` 变体;翻译文件按域分 namespace(parent/grades/notifications/preferences) | | P4 PWA + Service Worker | manifest.json(name/short_name/icons/theme_color)+ Service Worker 缓存策略(App Shell precache + API stale-while-revalidate + 离线兜底页);P5+ 支持离线查看子女成绩缓存 | | P4 API 契约版本管理 | 主版本 URL `/api/v1/*` → `/api/v2/*` + 子版本响应头 `X-API-Version` + Deprecation/Sunset 头埋点 + 低带宽场景 `X-Fields` 字段裁剪(GraphQL 风格,BFF REST 透传) | | P4 性能预算 | Bundle ≤ 80KB JS / 20KB CSS / 200KB 总;recharts 懒加载;MF shared 单例避免 React 重复打包;dynamic import 非首屏组件;预加载子女数据 | | P4 监控与降级 | 10 项前端指标(MF 加载失败率/ApiClient 错误率/Web Vitals/切换延迟等)+ 9 项降级策略(MF 失败回退独立页/BFF 5xx 降级缓存/WS 重试指数退避等) | | P4 模块演化与解耦 | 拆分触发条件(Bundle > 120KB / 团队 > 3 人 / 独立部署需求);拆分后 MF 配置从 Shell 暴露;状态管理迁移路径(Zustand → Server Component + Server Actions);MF 升级路径(MF 2.0 → 3.0) | | P4 iam 家长-学生关联 P0 阻塞 | iam 缺 `GetChildrenByParent` proto RPC + `GET /iam/children` REST + `iam_student_guardians` 表;ai02 补全前 parent-portal 仅能假设单子女硬编码 childId 开发调试 | | P4 BFF 错误码前缀 | **BFF_PARENT\_=parent-bff**(coord final §B5,统一 `BFF_XXX_` 风格);前端按 `BFF_PARENT_*` 前缀路由 i18n key,与 BFF_TEACHER_/BFF_STUDENT_ 对齐 | | P4 认证 token 存储 | localStorage(F12 裁决,P4 用 localStorage,非 cookie);refreshAccessToken 竞态防护用 inflight Promise 复用,并发请求只触发一次 refresh fetch | | P4 switchChild 双层实现 | ARB-020 §22.5 裁决:切换子女走 GraphQL Mutation(后端审计,Redis `parent:selected:{parentId}` TTL 30s)+ Zustand slice 前端缓存(乐观更新,mutation fire-and-forget,失败不回滚) | | P4 通知偏好 localStorage 降级 | ISSUE-033 裁决:P4 localStorage 为准,更新先写本地再尝试 GraphQL mutation(失败不阻塞);P5+ 切换到后端为主存储 | | P4 上游 mock 策略 | MSW 拦截 parent-bff GraphQL + iam REST;iam GetChildrenByParent P0 阻塞用 mock fixtures(student-001 + student-002 双子女);MSW server 在 providers 启动 | | P4 双 /v1 前缀路由 | ARB-022 §24.4 ISSUE-003 方案 A:所有 API 路径双 /v1(gateway /api/v1 + 服务 /v1);parent-portal GraphQL=`/api/v1/parent/v1/graphql`、iam login=`/api/v1/iam/v1/login`、notifications=`/api/v1/parent/v1/notifications`、web-vitals=`/api/v1/parent/v1/web-vitals` | | P4 测试覆盖率阈值 | vitest.config.ts thresholds: statements/lines/functions ≥85%,branches ≥75%;pages/observability/middleware/ErrorBoundary 排除(由集成/E2E 覆盖);413 测试通过,覆盖率 99%+ | | TS noUncheckedIndexedAccess | Record 索引访问返回 `T \| undefined`;内层 Record 改 `Partial>` 或空对象回退(`?? {}`)避免 undefined 传播 | | urql fetchOptions headers 类型 | 返回类型须用 `Record` 累积再展开,不可用条件对象(`{...(token?{Authorization}:{})}`)否则 TS 推断不兼容 | | Vitest Promise 身份比较 | `expect(p1).toBe(p2)` 对 Promise 不可靠(async 包装产生新 Promise);改为验证行为副作用(如 fetch 调用次数 === 1 证明竞态防护生效) | | jsdom window.location.href | 设置触发 "Not implemented: navigation" stderr 警告(非错误,测试仍通过);mock 用 `Object.defineProperty(window, "location", {...})` 替代 `vi.spyOn` | | MSW 响应过快观察加载态 | MSW 默认同步响应,加载态测试看不到 "加载中";用 `server.use()` + `delay(ms)` 延迟响应再断言按钮文本 | | Testing Library getByRole 误匹配 | `getByRole("button", { name: /未读/ })` 会匹配通知项 aria-label="未读";改用 `getAllByRole("button")` + find 文本内容过滤精确选中筛选按钮 | | ESLint flat config 测试文件 | 放宽规则需含 `*.test.tsx`/`*.spec.tsx`/`__tests__/**` 模式,否则 tsx 测试文件按严格规则报错 | | Dockerfile 多阶段构建 | G1 裁决首次即多阶段:builder(node:20-alpine 跑 build)+ runner(node:20-alpine 跑 server),端口 4002,HEALTHCHECK 指向 `/api/health`,非 root 用户 nextjs | ### 2.14 parent-bff(TS/NestJS,P4) > parent-bff 是 BFF 聚合层,无 DB,无聚合根,无 @RequirePermission(U4 仲裁豁免)。本节记录 parent-bff 特有的"场景→技术"映射。 | 场景 | 技术/规则 | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BFF 聚合层定位 | 只聚合不持状态,无 DB,无聚合根,无 @RequirePermission(U4 仲裁豁免,权限由 Gateway JWT + 下游服务负责) | | GraphQL Yoga 端点 | POST /graphql(GraphQL over HTTP),对前端 GraphQL,对下游 gRPC(U3 仲裁 GraphQL P2 立即引入,parent-bff P4 直接用 GraphQL) | | DataScope=CHILDREN 越权校验 | ChildGuard 在 Resolver 执行前校验 requestedChildId ∈ iam.GetChildrenByParent(parentId),不绑定抛 BFF_PARENT_CHILD_NOT_BOUND(403) | | 多子女切换方案 A | 前端管理 childId(Zustand slice),BFF 无状态,ParentSession 仅请求级值对象不持久化;selectChild mutation 仅记录审计日志 | | 并行 gRPC 编排 | Promise.allSettled + 降级策略(iam 失败返回 user=null,core-edu 失败返回 grades=null),部分失败标记 partial=true | | DataLoader 批量去重 | per-request 实例,按 parentId 批量取孩子列表、按 studentId 批量取成绩/作业/考试,防 N+1 | | Redis 聚合缓存 | 30s TTL(coord 推断 60s,ai05 调整为 30s + 主动失效),key=bff:parent:{userId}:{queryHash},singleflight 防击穿 | | 错误码前缀 | BFF_PARENT__(coord 仲裁 C1,对齐 004 §11.4 BFF 在前风格,不是 PARENT_BFF__) | | 端口分配 | HTTP 3010,不暴露 gRPC(coord 仲裁 C2,BFF 对下游走 gRPC,对上游仅 HTTP/GraphQL) | | 下游依赖 | iam + core-edu + data-ana + msg(coord 仲裁 C6 扩展,与 student-bff 对齐) | | P0 阻塞 | iam 缺 GetChildrenByParent RPC + iam_student_guardians 表,ai02 补全前 parent-bff 无法启动 | | push-gateway 调用 | 走 HTTP /internal/push(U2 仲裁豁免 gRPC,msg 调用方改用 HTTP) | | Kafka topic 命名 | edu.notification.sent/read/recalled/failed(coord 仲裁 C5,废弃 edu.notification.events 抽象名) | | /readyz 下游探针 | P4 添加并行 ping iam + core-edu + data-ana + msg 的 /healthz(coord 推断 BFF 直接 ok,ai05 调整为 P4 加下游探针) | | GraphQL complexity 防御 | depth limit(≤7)+ query cost analysis(≤1000)+ persisted queries(P6 预留) | | 优雅关闭 | HTTP server → Redis → gRPC clients → Tracer(BFF 无 DB/Kafka,关闭顺序简化) | | ParentSession 值对象 | parentId/roles/dataScope:CHILDREN/requestedChildId/traceId,每次请求重建不跨请求(004 §12.1 无状态约束) | | 通知偏好配置 | NotificationPreference(渠道开关 channels[] + 频率限制 frequency_limit + 静默时段 quiet_hours),P4 通过 msg 服务管理 | | P5 msg gRPC 客户端 | MsgClient interface + MockMsgClient + GrpcMsgClient;NotificationPreferences RPC 未定义时回退默认偏好(避免下游未实现阻塞 BFF) | | P5 push-gateway HTTP 客户端 | PushHttpClient interface + MockPushHttpClient + HttpPushHttpClient(fetch + 3s timeout + AbortController) | | P5 通知偏好过滤 | EVENT_TYPE_MAP 映射事件→通知类型,拉取偏好后检查偏好开关 + channels 交集,跳过关闭偏好的家长推送 | | P5 Kafka consumer 多 handler | 同一 topic 可注册多个 handler(push + cache-invalidation),handlers 改为 `Map` 支持多 handler | | P5 Kafka 幂等 + DLQ | event_id Redis SETNX 去重(24h TTL)+ MAX_RETRIES=3 指数退避 + DLQ `edu.parent-bff.dlq` | | P5 缓存失效 handler | edu.teaching.grade.recorded→grades+dashboard 失效;edu.notification.read/recalled→notifications 失效 | | P6 opossum 熔断器 | per-service 单例 + 事件监听(open/close/halfOpen→circuitStateGauge)+ withCircuitBreaker 包装 callWithMetrics,开启抛 503 | | P6 熔断器配置 | timeout 5s / errorThresholdPercentage 50% / resetTimeout 30s / volumeThreshold 10 | | P6 Helm chart 结构 | Chart.yaml + values.yaml + values-{dev,staging,prod}.yaml + templates/(_helpers.tpl + deployment + service + hpa + configmap) | | P6 podAntiAffinity | preferredDuringSchedulingIgnoredDuringExecution + weight 100 + topologyKey kubernetes.io/hostname | | P6 HPA 多环境 | dev: 1 replica HPA disabled;staging: 2 replicas HPA 2-5;prod: 3 replicas HPA 3-10 + CPU 70%/memory 80% | | P6 SLO 告警规则 | parent-bff-slo 组 9 条告警:P95>200ms / 错误率>0.1% / 可用性<99.9% / 熔断器open / 下游错误率>5% / 缓存命中率<50% / Kafka停滞 / push失败率>5% / ChildGuard突增 | | P6 Grafana 面板 | 11 面板:P95延迟/错误率/可用性/熔断器/缓存命中率/ChildGuard/下游QPS/下游P95/Kafka消费/push推送/GraphQL QPS | | ESM 模式 import 后缀 | 相对 import 用 `.js` 后缀(NodeNext 构建产物),test 文件 import 源码也用 `.js` 后缀 | | vitest coverage 配置 | coverage 配置在 `test` 内部(vitest v2 API),不在根层级;exclude 排除基础设施文件(gRPC clients/DataLoader/GraphQL 框架/Redis/Kafka) | | 测试覆盖率排除策略 | 基础设施/框架代码(gRPC client 实现/DataLoader 工厂/GraphQL 框架设置/Redis client/Kafka consumer)需 E2E 测试覆盖,单元/集成测试排除 | | Kafka consumer Buffer 类型 | kafkajs `message.value` 类型为 `Buffer | null`,传参需 `?? undefined`转换为`Buffer | undefined` | | Mock 客户端 markAsRead 类型 | `MOCK_NOTIFICATIONS[parentId]` 可能 undefined,需 `if (!list) continue`;`list[idx]` 可能 undefined,需 `const target = list[idx]; if (target)` 守卫 | | msg.client callWithMetrics | callWithMetrics 签名是 `(client, method, request, serviceName)`,不是 `("msg", "X", () => fn)` | ### 2.14.1 student-bff(TS/NestJS/GraphQL,P3) > student-bff 是学生场景域 BFF 聚合层,GraphQL Yoga + gRPC 下游通信。本节仅记录 student-bff **特有**的"场景→技术"映射,通用 NestJS 映射见 §1.4。 | 场景 | 技术/规则 | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | P3 API 风格 | **GraphQL Yoga**(coord §B1 裁决,P2 起直接 GraphQL,禁止 REST 渐进);schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`(president §2.2.1) | | P3 下游通信 | **gRPC 首次即用**(coord §B2 裁决,@grpc/grpc-js + @grpc/proto-loader,禁止 HTTP fetch);DownstreamClient 抽象复用 shared-ts(coord §B8,3 BFF 统一) | | P3 错误码前缀 | `BFF_STUDENT_`(coord §B5 裁决,BFF 在前,非 `STUDENT_BFF_`);i18n key `error.bffStudent.`(coord §F4) | | P3 越权防御 | AuthorizationGuard 强制 `studentId = userId`(coord §B4);场景 A 抛 `ForbiddenResourceError`,场景 B 抛 `IdentityMismatchError`(president §2.7);DEV_MODE 放行(president §2.9 方案 D) | | P3 降级模式 | 方案 B(president §2.6):`success=true + data 内 degraded=true + degradedFields`,下游部分失败仍返回部分数据 | | P3 Redis 缓存 | 5-30s 短缓存(coord §B6),TTL ±20% 随机抖动防雪崩;CacheService 封装 get/set/invalidate/invalidateByPrefix | | P3 ESM 相对 import 路径深度 | 嵌套目录(如 `src/student/events/`、`src/student/guards/`)引用 shared 层需 `../../shared/` 前缀(非 `../shared/`),NodeNext + .js 后缀 | | P5 GraphQL Subscription SSE | GraphQL Yoga 原生 SSE 传输;AsyncGenerator 桥接 gRPC server-streaming(chunkQueue + resolveWait 机制将 Node ReadableStream 'data'/'end'/'error' 事件转为 async iterable) | | P5 DownstreamClient.callStream | gRPC server-streaming 调用 `callFn.call(client, request, meta, { deadline })`,返回 Node ReadableStream,需手动转换为 AsyncIterable;finally 块调用 `stream.destroy?.()` 清理 | | P5 Push-gateway 模块化 | 从 EventSubscriber 解耦推送逻辑为独立 PushGatewayService(DI 注入),fetch POST + AbortSignal.timeout(3000),失败软处理(仅 warn 日志,不抛异常) | | P5 Kafka 事件订阅 | coord §B7 裁决:P2-P4 不订阅,P5 起订阅 7 个 topic;消费者组 `student-bff-event-subscriber`;Redis SETNX `event_id` 幂等去重 | | P6 熔断器设计 | opossum 库,每下游服务一个独立 CircuitBreaker 实例(Map 缓存);action 函数闭包捕获动态参数;状态变更监听同步 Prometheus 指标(student_bff_circuit_state Gauge) | | P6 熔断器配置 | timeout 5000ms,errorThresholdPercentage 50,resetTimeout 30000,volumeThreshold 10,rollingCountTimeout 60000(coord §G11) | | Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据(config/mock-data.ts),callStream 产出单个 mock chunk 后结束 | | /readyz 探针 | 检查 6 个下游服务可达性(iam/classes/core-edu/content/msg/ai/data-ana),必需失败返回 503,可选软失败返回 200 + degraded=true | | ActionState 信封 | coord §G8 裁决:成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, i18nKey, details?, traceId?}}`;ok()/fail()/degraded() 工具函数 | | GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/logger,vi.stubGlobal 模拟 fetch,mockContext 工厂函数注入 downstream/cache mock 对象;Vitest 覆盖率 ≥ 80% | | prom-client getSingleMetric 类型 | `getSingleMetric()` 返回 `Metric` 联合类型,需 `as Counter`/`Histogram`/`Gauge` 断言后调用 `.inc()`/`.observe()`/`.set()` | | opossum v8 Status 类型 | `CircuitBreaker.Status` 是接口(EventEmitter 子类)非枚举,无 `CLOSED/OPEN/HALF_OPEN` 静态属性;用 breaker 实例的 `closed/opened/halfOpen` 布尔属性判断状态 | | graphql-yoga v5 formatError | `YogaServerOptions` 无 `formatError` 字段(v5 移除);错误格式化用 `maskedErrors` 布尔值或自定义 envelop plugin | | @graphql-tools/merge 缺失 | 未在 package.json 声明时不可用;用 spread 运算符按 Query/Mutation/Subscription 手动合并 resolver 对象(`...(x.Query ?? {})`) | | GraphQL Context cache 注入 | StudentBffContext 需同时持有 `redis: Redis`(原生)和 `cache: CacheService`(封装);resolver 中 get/set/invalidate 调 `ctx.cache`,不直接调 `ctx.redis` | ### 2.15 student-portal(学生端微前端 Remote,P3) > student-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 student-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。 | 场景 | 技术/规则 | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | P3 端口分配 | student-portal = **4001**(004 §1.2 强制 4 端 4000-4003;ai07 初稿误用 3001 与 iam 冲突,ai14 修订) | | P3 MF Remote 角色 | next.config.js 配 `@module-federation/nextjs-mf`(name: `student_app`,port 4001)暴露 `./pages` + `./ExamTaking`,复用 Shell 的 `AppShell`/`usePermission`/`ApiClient` | | P3 DataScope SELF | 学生仅看自己数据(004 §5.4 L0),前端**无班级/年级/学校切换器**(与 teacher-portal L1-L5 核心差异),路由参数不含 classId/gradeId/schoolId | | P3 错误码前缀统一 | core-edu 子域统一 `CORE_EDU_`(移除 `EXAMS_`/`HOMEWORK_`/`GRADES_` 独立前缀);BFF 前缀 `BFF_STUDENT_`(coord §5.2 裁决,从 `STUDENT_BFF_` 迁移) | | P3 考试防作弊 useAntiCheat | `visibilitychange` 切屏检测 + `blur` 失焦 + `copy`/`paste`/`contextmenu` 禁止 + 违规计数(maxViolations=3)+ 超限自动交卷;前端层仅辅助,最终权威由后端考试时长 + 服务端时间校准保证 | | P3 服务器时间同步 | `useServerTimeSync` Hook:RTT 计算 `offset = (t1 + rtt/2) - serverTime` + 5min 重同步周期;`CountdownTimer` 用 `role="timer"` + `aria-live="polite"` 朗读剩余时间 | | P3 考试作答状态机 | NotStarted→InProgress→Paused(切屏警告)→AutoSubmitting(倒计时归零)→Submitting→Submitted;Zustand L3 + localStorage 持久化草稿,自动保存每 30s + blur 时保存 | | P3 考试页面 CSP iframe 防嵌套 | 考试路由 `frame-ancestors 'none'` + `X-Frame-Options: DENY`;其他路由允许 Shell 同源嵌入 | | P3 考试 chunk 性能预算 | 考试作答 chunk < 200KB(动态 import 隔离);总 Bundle < 300KB;LCP < 2.5s / CLS < 0.1 / INP < 200ms | | P3 移动端一等公民 | 学生端高频移动场景:Tailwind 响应式断点 + viewport meta + 触摸优化(按钮 ≥ 44px);考试页面支持横屏锁定 | | P3 A11y 考试专项 | WCAG 2.2 AA:键盘导航作答(Tab/Enter/方向键)+ `aria-live` 倒计时 + 焦点陷阱(考试中不可 Tab 到地址栏)+ 屏幕阅读器兼容 | | P3 考试草稿多设备同步预留 | 当前 localStorage 单设备;P5+ 预留草稿同步(LWW 冲突解决 + WebSocket 推送 + version 字段) | | P3 Feature Flag 阶段化 | P3-P5 用环境变量(`NEXT_PUBLIC_FEATURE_*`);P6+ 预留 Consul 配置中心动态开关 | | P3 PWA 离线壳预留 | P5+ Service Worker 缓存考试壳,断网时仍可作答,恢复后自动提交 | | P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 | | P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 | | P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) | ### 2.16 admin-portal(管理端微前端 Remote,P2-P6) > admin-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / 设计令牌,本节仅记录 admin-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。 | 场景 | 技术/规则 | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | P2 端口分配 | admin-portal = **4003**(004 §1.2 强制 4 端 4000-4003;初稿误用 3003 与 data-ana 冲突,修订对齐仲裁 ARB-003) | | P2 GraphQL admin 命名空间 | 复用 teacher-bff GraphQL endpoint(`POST /api/admin/graphql`),不新建 admin-bff(ARB-001);urql client standalone 自建,MF 模式复用 Shell 单例 | | P2 权限点 ADMIN_ 前缀 | admin 自身资源用 `ADMIN_` 前缀(如 `ADMIN_DASHBOARD_VIEW`/`ADMIN_AUDIT_READ`),跨服务资源保留原前缀(如 `IAM_USER_READ`);路由权限映射在 `lib/permissions.ts` ROUTE_PERMISSIONS | | P2 MSW mock 全量拦截 | `NEXT_PUBLIC_API_MOCKING=enabled` 时 MSW 拦截所有 GraphQL/HTTP;`mocks/handlers.ts` 实现 18 个 GraphQL operation + `/api/auth/login` mock;fixtures 内存可变副本支持增删改 | | P2 WebSocket 实时通知 | 接入 push-gateway `GET /ws`(ARB-006);开发期 `use-websocket.ts` 用定时器模拟推送(30s/条);3 类通知:`audit_alert`/`abnormal_login`/`system_error` | | P2 MF Remote 入口 | `next.config.js` 暴露 `./AdminApp`(exposes: `./src/app/admin-app.tsx`);`NEXT_PUBLIC_MF_ENABLED` 控制启用(standalone 默认 false,Shell 就绪后设 true) | | P3 用户管理 CRUD | `useUsers`/`useUser`/`useCreateUser`/`useUpdateUser`/`useToggleUserStatus` hooks;`UserManagementTable` + `UserFormModal` 组件;mock 登录 admin@edu.test/admin123 | | P3 角色权限矩阵 | `RolePermissionMatrix` 组件按 resource 分组、支持全选/半选/单选;`useUpdateRolePermissions` 提交权限码列表;系统预置角色不可删除 | | P3 视口拖拽排序 | `@dnd-kit/sortable` 实现拖拽排序 + `useSortable` hook;`SortableViewportItem` 每行可编辑 label/permission/visible;保存时批量调用 `updateViewport` | | P4 组织树递归加载 | `OrganizationTree` 递归组件 + `useOrganization(parentId)` 按需加载子节点;展开/折叠状态本地管理;A11y `role="tree"`/`role="treeitem"`/`aria-expanded`/`aria-selected` | | P5 审计日志 CSV 导出 | `exportAuditLogsCsv` 生成 BOM + UTF-8 CSV;`useAuditLogs` 支持 search/action 过滤;审计日志经 teacher-bff GraphQL 消费(ARB-005 不直连 Kafka) | | P5 仪表盘服务健康 | `AdminDashboardViewModel.serviceHealth` 含 serviceName/status/latencyMs;`Badge` 组件映射 healthy/degraded/down 状态色 | | P6 A11y WCAG 2.2 AA | `eslint-plugin-jsx-a11y` flat config(ESLint 9);skip-link + focus-visible + aria-* 完整;`@next/eslint-plugin-next` 14.x 与 ESLint 9 不兼容,暂用 jsx-a11y + tseslint 替代 | | P6 Web Vitals 采集 | `web-vitals` 包(LCP/CLS/FCP/INP/TTFB);`WebVitalsInitializer` 组件仅 production 启用;`navigator.sendBeacon` 上报到 `/api/admin/web-vitals` | | P6 健康检查端点 | `/api/health`(liveness 200)+ `/api/ready`(readiness 检查 gateway 可达性,2s 超时;mock 模式直接 ready) | | P6 Dockerfile no-push | 多阶段构建 `node:22-alpine`;builder 阶段 `pnpm install --frozen-lockfile` + `next build`;runner 阶段 `standalone` 输出 + EXPOSE 4003 | | P6 urql 类型断言 | urql `useQuery`/`useMutation` 泛型与纯字符串 query 兼容性有限;`use-graphql.ts` 用 `as unknown as` 从 unknown 转换(项目规则允许从 unknown 断言) | | 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` 断言字面量 |