fix: code compliance audit and fix across all services
Some checks failed
CI / quality-ts (push) Failing after 48s
CI / quality-go (push) Failing after 4s
CI / quality-proto (push) Failing after 2s
CI / deploy (push) Has been skipped

NestJS (6 services): implement @RequirePermission decorator with
SetMetadata+Reflector, register APP_GUARD globally, fix as assertions
to type guards, add explicit return types, fix import type for express,
fix /metrics implicit any, replace native Error with ApplicationError,
remove typeorm remnants, register LifecycleService.

teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward
real userId to downstream, log downstream failures, migrate health
controller to shared/health.

Go (2 services): interface to any, doc comments, CORS dev whitelist,
JWT secret fail-fast, push-gateway internal API auth, metrics and
readyz endpoints, remove dead code.

Python (2 services): lifespan return type, dev_mode to bool, data-ana
APIRouter, ai POST body model, ClickHouse async wrapping.
This commit is contained in:
SpecialX
2026-07-09 17:28:27 +08:00
parent b53a486c6e
commit 0a71b02e04
93 changed files with 5775 additions and 608 deletions

View File

@@ -156,7 +156,7 @@ graph TB
subgraph D4["D4 内容资源领域"] subgraph D4["D4 内容资源领域"]
CONTENT[content 服务] CONTENT[content 服务]
CONTENT_M[textbooks / knowledge-points<br/>questions / grading / search(ES)] CONTENT_M[textbooks / knowledge-points<br/>questions / grading / search]
end end
subgraph D5["D5 沟通通知领域"] subgraph D5["D5 沟通通知领域"]
@@ -167,19 +167,19 @@ graph TB
subgraph D6["D6 智能洞察领域"] subgraph D6["D6 智能洞察领域"]
DATA[data-ana 服务] DATA[data-ana 服务]
AI[ai 服务] AI[ai 服务]
DATA_M[analytics / dashboard / diagnostic(ClickHouse)] DATA_M[analytics / dashboard / diagnostic]
AI_M[ai 备课/出题/分析 / search] AI_M[AI 备课 / 出题 / 分析 / 搜索]
end end
D1 --> D2 IAM --> ORG
D1 --> D3 IAM --> TEACH
D1 --> D4 IAM --> CONTENT
D1 --> D5 IAM --> MSG
D2 --> D3 ORG --> TEACH
D3 --> D4 TEACH --> CONTENT
D3 --> D5 TEACH --> MSG
D4 --> D6 CONTENT --> DATA
D3 --> D6 TEACH --> DATA
``` ```
**双图并存说明** **双图并存说明**
@@ -517,24 +517,24 @@ graph LR
Cmd[Command 命令] --> App[Application Service] Cmd[Command 命令] --> App[Application Service]
App --> Domain[Domain 领域模型] App --> Domain[Domain 领域模型]
Domain --> Repo[Repository 写模型] Domain --> Repo[Repository 写模型]
Repo -->[(MySQL 主库)] Repo --> mysql_w[(MySQL 主库)]
App --> Outbox[(Outbox 表<br/>同事务)] App --> Outbox[(Outbox 表<br/>同事务)]
end end
subgraph Sync["同步链路"] subgraph Sync["同步链路"]
Outbox --> Relay[Relay Worker] Outbox --> Relay[Relay Worker]
Relay --> Kafka[(Kafka)] Relay --> kafka_sync[(Kafka)]
Kafka --> Proj[Projection] kafka_sync --> Proj[Projection]
Proj -->[(ClickHouse 宽表)] Proj --> ch_sync[(ClickHouse 宽表)]
Proj -->[(Redis 缓存)] Proj --> redis_sync[(Redis 缓存)]
Proj -->[(ES 索引)] Proj --> es_sync[(ES 索引)]
end end
subgraph Read["读路径"] subgraph Read["读路径"]
Query[Query 查询] --> ReadModel[Read Model] Query[Query 查询] --> ReadModel[Read Model]
ReadModel -->[(ClickHouse 宽表)] ReadModel --> ch_read[(ClickHouse 宽表)]
ReadModel -->[(Redis 缓存)] ReadModel --> redis_read[(Redis 缓存)]
ReadModel -->[(ES 索引)] ReadModel --> es_read[(ES 索引)]
end end
``` ```
@@ -581,7 +581,7 @@ graph LR
Outbox[(Outbox 表)] Outbox[(Outbox 表)]
end end
subgraph MySQL[("MySQL 主库")] subgraph MySQL["MySQL 主库"]
BizTable[(业务表)] BizTable[(业务表)]
OutboxTable[(outbox 表)] OutboxTable[(outbox 表)]
end end
@@ -593,7 +593,7 @@ graph LR
end end
subgraph Bus["事件总线"] subgraph Bus["事件总线"]
Kafka[(Kafka topic)] kafka_bus[(Kafka topic)]
end end
subgraph Consumers["消费者"] subgraph Consumers["消费者"]
@@ -607,10 +607,10 @@ graph LR
Repo --> OutboxTable Repo --> OutboxTable
OutboxTable --> Poll OutboxTable --> Poll
Poll --> Publish Poll --> Publish
Publish --> Kafka Publish --> kafka_bus
Kafka --> Proj kafka_bus --> Proj
Kafka --> OtherSvc kafka_bus --> OtherSvc
Proj -->[(ClickHouse/Redis/ES)] Proj --> read_stores[(ClickHouse / Redis / ES)]
``` ```
### 7.2 事件 Topic 分类 ### 7.2 事件 Topic 分类

View File

@@ -0,0 +1,401 @@
# AI 分配方案与架构设计外包流程
> 版本1.0
> 日期2026-07-09
> 适用范围Edu 微服务项目模块架构设计外包阶段
> 关联文档:[多 AI 协作指南](../standards/multi-ai-collaboration.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[待开发功能路线图](./roadmap/pending-features.md)
---
## 1. 外包总流程:三阶段
```
阶段 1全局理解 阶段 2模块架构设计 阶段 3按图实施
(每个 AI 独立) (每个 AI 独立) (并行开发)
│ │ │
阅读全局架构文档 产出模块内部架构图 按自己画的图写代码
理解边界与契约 定义内部模块/数据流 coord 定期巡检一致性
理解与其他模块的接口 标注与其他模块的交互点 遇到偏差更新架构图
│ │ │
▼ ▼ ▼
交付:理解确认书 交付:模块架构设计文档 交付:代码 + 更新图
```
**阶段 1 目标**:每个 AI 读懂自己负责的模块在全局架构中的位置、边界、契约。
**阶段 2 目标**:每个 AI 产出自己模块的内部架构设计,经过 coord 交叉审查后放行。
**阶段 3 目标**按设计文档写代码coord 定期巡检一致性。
---
## 2. 完整服务清单
| 类别 | 服务名 | 语言/框架 | 限界上下文 | 阶段 | 状态 |
| ---- | -------------- | ---------------- | ---------------------- | ------ | ----------------- |
| 网关 | api-gateway | Go (Gin) | API 网关 | P1 | ✅ 已实现 |
| 网关 | push-gateway | Go (Gin) | 推送网关 | P5 | 📐 需设计 |
| BFF | teacher-bff | TS (NestJS) | 教学场景域聚合 | P2 | ✅ 已实现 |
| BFF | student-bff | TS (NestJS) | 学习场景域聚合 | P3 | 📐 需设计 |
| BFF | parent-bff | TS (NestJS) | 家长场景域聚合 | P4 | 📐 需设计 |
| 业务 | iam | TS (NestJS) | 身份认证 | P2 | ✅ 已实现 |
| 业务 | core-edu | TS (NestJS) | 教学核心(含 classes | P3 | 📐 待合并 classes |
| 业务 | content | TS (NestJS) | 内容资源 | P4 | 📐 需设计 |
| 业务 | msg | TS (NestJS) | 消息通知 | P5 | 📐 需设计 |
| 业务 | data-ana | Python (FastAPI) | 数据分析 | P4 | 📐 需设计 |
| 业务 | ai | Python (FastAPI) | AI 网关 | P5 | 📐 需设计 |
| 前端 | teacher-portal | TS (Next.js) | 教学场景域前端 | P2 | ✅ 已实现 |
| 前端 | student-portal | TS (Next.js) | 学习场景域前端 | P3 | 📐 需设计 |
| 前端 | parent-portal | TS (Next.js) | 家长场景域前端 | P4 | 📐 需设计 |
| 前端 | admin-portal | TS (Next.js) | 管理场景域前端 | P6 | 📐 需设计 |
| 共享 | shared-proto | protobuf | 契约 | 跨阶段 | ✅ 部分 |
| 共享 | shared-ts | TS | TS 共享工具 | 跨阶段 | — |
| 共享 | shared-go | Go | Go 共享工具 | 跨阶段 | — |
| 共享 | shared-py | Python | Python 共享工具 | 跨阶段 | — |
| 基础 | infra | — | K8s/Grafana/WAF | 跨阶段 | ✅ 部分 |
> 状态标记:✅ 已实现需审计 | 📐 需架构设计(本次外包核心产出)
---
## 3. AI 分配方案7 AI + 1 coord
### 3.1 分配原则
- **同语言内聚**:一个 AI 负责多个同语言服务,学习成本只付一次
- **领域亲缘性**同类业务放一起BFF 归 BFF、Python 归 Python
- **工作负载均衡**Neo4j+ES 的内容服务、ClickHouse 的分析服务复杂度高,不绑太多其他服务
- **前端统一**Module Federation 微前端由一人设计,保证 shell + remote 架构一致
- **黄金模板对齐**:已实现的 services 负责 AI 需审计并对齐 classes 标准
### 3.2 分配矩阵
| AI 标识 | 语言 | 服务 | 数量 | 阶段归属 |
| --------- | ------ | ----------------------------------------------------------- | ---- | -------- |
| **ai01** | Go | api-gateway、push-gateway | 2 | P1 + P5 |
| **ai02** | TS | iam | 1 | P2 |
| **ai03** | TS | teacher-bff、core-edu | 2 | P2 + P3 |
| **ai04** | TS | student-bff、parent-bff | 2 | P3 + P4 |
| **ai05** | TS | content、msg | 2 | P4 + P5 |
| **ai06** | Python | data-ana、ai | 2 | P4 + P5 |
| **ai07** | TS | teacher-portal、student-portal、parent-portal、admin-portal | 4 | P2-P6 |
| **coord** | — | shared-proto、shared-*、infra/、docs/、CI/CD | — | 跨阶段 |
### 3.3 为什么这样拆
| 决策 | 理由 |
| ----------------------------- | ------------------------------------------------------------------------------- |
| ai02 独立负责 iam | RBAC 三层角色 + DataScope 6 级 + 视口 4 层是整个系统的权限中枢,复杂度最高 |
| ai03 teacher-bff + core-edu | 教学域全栈BFF 聚合 + 核心业务。考试/作业/成绩状态机在一个人手里,不跨 AI 协调 |
| ai04 student-bff + parent-bff | 两个 BFF 都是纯聚合层技术同质GraphQL + DataLoader设计模式完全复用 |
| ai05 content + msg | 都依赖 EScontent 建索引、msg 查索引,一人设计避免 ES 索引冲突 |
| ai07 前端 4 端 | Module Federation shell + remote 架构需一人统一设计4 端共享组件库和权限体系 |
| coord 不写业务代码 | 专注契约管理 + 交叉审查,保证 7 份设计文档的接口一致性 |
---
## 4. 各 AI 阶段 1 必读文档清单
以下为每个 AI 在阶段 1 必须按顺序阅读的文档(标注 ★ 为强制必读):
| 顺序 | 文档 | ai01 | ai02 | ai03 | ai04 | ai05 | ai06 | ai07 | coord |
| ---- | ------------------------------------------------------------------- | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :---: |
| 1 | [README.md](../../README.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 2 | [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 3 | [004 架构影响地图](./004_architecture_impact_map.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 4 | [pending-features.md](./roadmap/pending-features.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 5 | [project_rules.md](../../.trae/rules/project_rules.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 6 | [coding-standards.md](../standards/coding-standards.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 7 | [multi-ai-collaboration.md](../standards/multi-ai-collaboration.md) | ★ | ★ | ★ | ★ | ★ | ★ | ★ | ★ |
| 8 | 黄金模板 `services/classes/src/` 全部源码 | ★ | ★ | ★ | ★ | — | — | — | ★ |
| 9 | `packages/shared-proto/proto/` 全部 .proto | ★ | ★ | ★ | ★ | ★ | ★ | — | ★ |
**语言特定补充阅读**
| AI | 语言 | 补充文档 |
| ------- | -------- | --------------------------------------------------------------- |
| ai01 | Go | `services/api-gateway/` 全部源码 |
| ai02-05 | TS | `services/iam/``services/teacher-bff/` 源码(参考已实现模板) |
| ai06 | Python | `services/data-ana/``services/ai/` 骨架源码 |
| ai07 | TS/React | `apps/teacher-portal/` 全部源码、Module Federation 配置 |
---
## 5. 各 AI 阶段 2 设计重点
### ai01 — Go 网关层
| 服务 | 设计重点 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api-gateway | 路由表矩阵(路径 → 下游服务 + 端口,需覆盖全部 6 个业务服务 + 3 个 BFF限流策略表每路由 QPS熔断阈值配置错误率/延迟阈值JWT RS256 公钥校验流程CORS 白名单;请求 ID 注入 |
| push-gateway | WebSocket 连接生命周期(认证 → 心跳 → 断线重连);与 msg 的 gRPC 推送通道协议;用户 session 映射(在线用户 → WebSocket 连接水平扩展方案Redis Pub/Sub 跨实例广播) |
### ai02 — 身份认证
| 服务 | 设计重点 |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iam | RBAC 权限点枚举(全部模块的 CRUD 权限常量);三层角色模型(系统/组织/临时权限合并规则DataScope 6 级 SQL WHERE 注入规则(每级对应的过滤条件);视口 4 层配置表设计(导航/路由/组件/数据JWT RS256 私钥签发 + 公钥暴露端点refresh_token 轮换策略;权限解析 APIgetEffectivePermissions → permissions + viewports + dataScope |
### ai03 — 教学场景域
| 服务 | 设计重点 |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| teacher-bff | GraphQL schemaQuery/Mutation按场景域组织DataLoader 批量去重策略;并行 gRPC 调用编排;聚合结果缓存 TTL 策略5-30s 短缓存);教师角色差异化(教师 vs 教导主任 vs 教研组长 → 视口推导) |
| core-edu | classes 模块黄金模板对齐;考试生命周期状态机(草稿 → 已发布 → 作答中 → 批改中 → 已出分 → 已归档Outbox 事件定义ExamPublished、HomeworkSubmitted、GradeRecorded成绩计算公式与配置化作业提交高并发优化Redis 分布式锁 + 排队);排课/考勤数据模型 |
### ai04 — 学习 + 家长场景域 BFF
| 服务 | 设计重点 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| student-bff | 学生端 GraphQL schema与 teacher-bff 对比差异DataLoader 复用 teacher-bff 模式权限区分学生只能看自己的数据DataScope=SELF考试/作业/成绩的学生视角 API |
| parent-bff | 家长端 GraphQL schema与 iam 的学生-家长关联查询;多子女账户切换设计;家长通知偏好配置 |
### ai05 — 内容 + 通知
| 服务 | 设计重点 |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| content | Neo4j 图模型(知识点 → 知识点前置依赖 → 教材关联ES 索引 mapping 设计(题库全文检索 + 标签过滤);题库 CRUD 完整 API含批量导入与 ai 服务的 gRPC 接口(查询知识点/题库用于 AI 出题);教材/章节结构树 |
| msg | 通知渠道抽象(站内信/邮件/短信策略模式ES 降级查询策略DB 不可用时走 ESKafka 消费幂等设计event_id 去重);与 push-gateway 的推送通道协议;通知模板管理;已读/未读状态管理 |
### ai06 — Python 数据 + AI
| 服务 | 设计重点 |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| data-ana | ClickHouse 宽表设计(考试、作业、成绩、掌握度、出勤 5 张宽表CDC 消费者架构Debezium → Kafka → ClickHouse学情分析 API班级统计/个人趋势/预警阈值掌握度计算算法加权滑动平均Dashboard 数据聚合 |
| ai | LLM Provider 适配器模式OpenAI/百川/本地模型SSE 流式响应(题目逐字生成);出题 Prompt 模板管理;备课工作流(分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库);用量计费/频率限制 |
### ai07 — 前端 4 端
| 服务 | 设计重点 |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| teacher-portal | 现有代码审计对齐黄金标准Module Federation shell 暴露的共享组件 |
| 全部 4 端 | Module Federation shell + remote 架构设计路由骨架4 端路由表对照);共享组件库(错误边界 ErrorBoundary、Loading 骨架屏、Empty 空态、权限控制组件);`usePermission().hasPermission()` 统一权限 HookAPI 请求层统一错误处理toast 提示4 端差异化对比表(导航菜单/路由/组件/数据 4 层差异) |
### coord — 协调 AI
| 职责 | 具体内容 |
| ---------------- | ------------------------------------------------------------------------------------------------------- |
| proto 契约维护 | 统一管理 `packages/shared-proto/`,跨 AI 的 proto 变更唯一入口 |
| 设计文档交叉审查 | 审查 7 份模块架构设计文档的跨模块接口一致性接口签名匹配、topic 不重复、端口不冲突、错误码前缀不重叠) |
| 黄金模板维护 | 维护 classes 黄金模板标准,审查其他服务对齐情况 |
| 架构文档同步 | 各 AI 产出设计文档后,同步更新 `004_architecture_impact_map.md` |
| 共享包管理 | shared-ts / shared-go / shared-py 建立与维护 |
| CI/CD | `.github/workflows/ci.yml` 覆盖全部 15 服务 |
| 基础设施 | `infra/` K8s/Grafana/WAF/灾备(可由 SRE AI 协助) |
---
## 6. 阶段 1 交付物模板
每个 AI 阅读完 §4 的文档清单后,必须产出以下确认书:
```markdown
## 模块理解确认书 — [模块名]
### 1. 我在架构中的位置
- 层级Gateway / BFF / Service / Data / Frontend
- 上游:谁调用我?
- 下游:我调用谁?
- 通信方式HTTP / gRPC / Kafka / WebSocket / 直接 DB
### 2. 我的限界上下文
- 我负责哪些聚合/实体?
- 我的数据属于哪个业务领域D1-D6 中的哪个)?
- 我不负责什么(明确边界外的东西)?
### 3. 我与外部的契约
- 我消费哪些 proto message从 shared-proto
- 我暴露哪些 API 端点或 gRPC 方法或 Kafka 事件?
- 错误码前缀是什么?
### 4. 我的技术栈
- 语言 / 框架 / ORM / 存储
### 5. 我的阶段归属
- 属于 P1-P6 哪个阶段?
- 当前阶段目标是什么?
- 依赖哪些上游阶段的产出?
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [ ] 权限装饰器 @RequirePermission(全部 Controller 方法)
- [ ] 错误码前缀统一
- [ ] logger / metrics / tracer 三支柱
- [ ] /healthz + /readyz 健康检查
- [ ] 优雅关闭SIGTERM
- [ ] 测试覆盖率 ≥ 80%
- [ ] Dockerfile 多阶段构建
- [ ] Zod 输入验证
- [ ] GlobalErrorFilter 统一兜底
```
---
## 7. 阶段 2 交付物模板
每个 AI 在阶段 1 确认书通过 coord 审核后,产出以下架构设计文档:
```markdown
## 模块架构设计文档 — [模块名]
### 1. 模块内部分层图
[画图Controller → Guard → Service → Repository → DB 调用链]
[标注中间件、Guard、Filter 的拦截点]
### 2. 领域模型
- 聚合根:有哪些?
- 实体/值对象:有哪些?
- 聚合间如何通信?(同服务内直接调用 / 跨服务走事件)
### 3. 数据模型
- 有哪些表?(列出 schema标注字段类型、约束
- 每张表的索引策略(主键、唯一索引、查询索引)
- 读写分离策略(哪些走主库、哪些走读模型)
### 4. API 设计
| method | path | 权限 | 请求/响应结构 | 说明 |
| ------ | ---- | ---------- | ------------- | ---- |
| POST | /xxx | XXX_CREATE | { ... } | ... |
### 5. 事件设计(如适用)
- 我发布哪些领域事件?触发时机是什么?
- 我消费哪些外部事件?消费后做什么?
- 事件 Topic 名称(遵循 `edu.<domain>.<aggregate>.<action>` 格式)
### 6. 横切关注点对齐清单
- [ ] 权限装饰器(列出所有端点及对应权限常量)
- [ ] 错误码清单(带前缀,每个错误码 → 触发条件 → HTTP 状态码)
- [ ] Logger 初始化位置与配置pino/zap/structlog
- [ ] Metrics 指标清单(指标名 / 类型 / 标签 / 描述)
- [ ] Tracer 初始化位置OTLP endpoint
- [ ] /healthz 检查逻辑
- [ ] /readyz 检查逻辑DB SELECT 1 / Redis PING / Kafka 连接)
- [ ] 优雅关闭顺序HTTP server → DB → Redis → Kafka
### 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | -------- | ----- | --------- | ---- |
| 调用 | xxx | gRPC | XxxMethod | ... |
| 被调用 | xxx | gRPC | YyyMethod | ... |
| 发布 | — | Kafka | topic 名 | ... |
| 消费 | — | Kafka | topic 名 | ... |
### 8. 风险与假设
- 我假设 [某服务] 提供了 [某接口],如果没提供我的 fallback 是什么?
- 我的模块有哪些技术风险?(性能瓶颈、数据一致性、外部依赖)
- 有哪些未决的设计决策需要协调 AI 仲裁?
```
---
## 8. 交叉审查规则
coord 收到全部 7 份设计文档后,执行以下审查:
### 8.1 接口一致性检查
```markdown
| 服务 A 说 | 服务 B 说 | 是否匹配 |
| --------------------------------------------------------- | ---------------------------------------------- | --------- |
| ai02 iam: 暴露 getUserInfo(userId) | ai03 teacher-bff: 调用 iam.getUserInfo(userId) | ✅ |
| ai03 core-edu: 调用 content.getKnowledgePoints(subjectId) | ai05 content: ??? | ⚠️ 待确认 |
```
### 8.2 全局冲突检查
| 检查项 | 检查方式 |
| -------------------- | ---------------------------------------------------------------------- |
| 端口不冲突 | 对照 [full-stack-runbook](../standards/full-stack-runbook.md) 端口矩阵 |
| Topic 不重复 | 汇总全部 AI 的 §5 事件设计,去重检查 |
| 错误码前缀不重叠 | 汇总全部 AI 的 §6 错误码清单,前缀唯一性检查 |
| Proto message 不遗漏 | 检查全部"跨模块交互点"是否在 proto 中有对应定义 |
### 8.3 黄金模板对齐检查
| 检查项 | 全部 TS NestJS 服务 |
| ----------------------- | ---------------------- |
| @RequirePermission 覆盖 | 每个 Controller 方法 |
| 错误码前缀 | 用服务名大写前缀 |
| /healthz + /readyz | 存在且逻辑正确 |
| Zod 输入验证 | Controller 层解析 body |
| GlobalErrorFilter | 注册到 AppModule |
| Dockerfile 多阶段 | builder + runtime |
---
## 9. 协作规则
### 9.1 单仓库并行开发
当前阶段采用**单仓库直接 push main**模式,不经过 PR
- 每个 AI 只能修改自己负责的目录(见 §3.2
- `packages/shared-proto/` 仅 coord 修改,其他 AI 只读
- `docs/``.trae/``infra/``.github/` 仅 coord 修改
### 9.2 唯一冲突文件处理
`pnpm-lock.yaml` 是唯一可能多 AI 同时修改的文件,冲突时:
```bash
git pull origin main --rebase
# 冲突时:
git checkout --theirs pnpm-lock.yaml # 取远程版本
pnpm install # 重新生成
git add pnpm-lock.yaml
git rebase --continue
```
### 9.3 提交规范
```bash
# 每个 AI 在自己的服务目录内工作
git add services/<service>/...
git commit -m "docs(<service>): 模块架构设计文档"
# 或
git commit -m "docs(<service>): 阶段1理解确认书"
```
### 9.4 proto 变更流程
任何 AI 需要新增/修改 proto
1. 在共享协调渠道声明需求(格式:`# proto-change: <描述>`
2. coord 统一修改 `packages/shared-proto/`
3. coord 通知受影响 AI 更新设计文档
---
## 10. 审计模板(阶段 1 自检用)
每个 AI 审计自己负责的已实现服务时,填写下表:
```markdown
## 服务审计表 — [AI标识]
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---- | ---------- | ---------- | -------- | -------- | -------- | -------- | -------- | -------- | ---------- | ---------- |
| xxx | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | ✅/❌/⚠️ | XX% | ✅/❌/⚠️ |
```
---
## 11. 相关文档
- [多 AI 协作指南](../standards/multi-ai-collaboration.md) — 日常开发协作流程
- [004 架构影响地图](./004_architecture_impact_map.md) — 全局架构与依赖
- [项目规则](../../.trae/rules/project_rules.md) — 强制约束
- [编码规范](../standards/coding-standards.md) — 多语言编码标准
- [待开发功能路线图](./roadmap/pending-features.md) — 六阶段目标

View File

@@ -0,0 +1,216 @@
# ai03 阶段 1 交付物:模块理解确认书
> AI 标识ai03
> 负责模块teacher-bffP2、core-eduP3
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)
---
## 模块理解确认书 — teacher-bff
### 1. 我在架构中的位置
- **层级**BFF 聚合层L4
- **上游**api-gatewayGo/Gin通过 HTTP 转发请求,注入 `x-user-id` / `x-user-roles`
- **下游**iam3002、classes3001、core-edu3004P3 后扩展 content、data-ana、ai
- **通信方式**
- 当前REST `fetch`(同步)
- 目标态004 §4.1 / pending-features P2**gRPC** 调下游业务服务 + **GraphQL Yoga** 对前端暴露 + DataLoader 防 N+1
- **端口**3003见 [teacher-bff env.ts](../../services/teacher-bff/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:教学场景域(教师 / 教导主任 / 教研组长 共用)的数据聚合、裁剪、协议转换
- **业务领域**:跨 D2 教学组织 + D3 教学核心 + D1 身份认证(只读拉取视口/权限)
- **我不负责**
- 不持有业务状态(无 DB 写入,无 Outbox
- 不做权限决策(依赖 Gateway JWT 校验 + 下游服务 `@RequirePermission`
- 不直接访问任何业务服务数据库
- **复用策略**004 §5.4):教导主任 / 教研组长复用 Teacher BFF通过视口差异化L1 导航扩展管理菜单L4 DataScope 扩大到年级)
### 3. 我与外部的契约
- **消费的 proto message**
- `iam.v1.IamService`GetUserInfo / getEffectivePermissions视口 + DataScope
- `classes.v1.ClassService`ListClasses / GetClass
- `core_edu.v1.ExamService / HomeworkService / GradeService`:全部 RPC
- **暴露的 API**(当前 REST目标 GraphQL
- `GET /teacher/dashboard` — 聚合 IAM 用户信息 + classes 列表
- `GET /teacher/viewports` — 拉取 IAM 视口配置L1 导航)
- `GET /teacher/classes/:classId/exams` — 聚合 core-edu 考试列表
- `GET /teacher/classes/:classId/homework` — 聚合 core-edu 作业列表
- `GET /teacher/exams/:examId/grades` — 聚合 core-edu 成绩列表
- **错误码前缀**BFF 自身错误用 `BAD_GATEWAY`(下游不可达);业务错误透传下游 `CORE_EDU_*` / `IAM_*` / `CLASSES_*`
- **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.3,当前未实现)
### 4. 我的技术栈
- 语言TypeScript 5.5+ESM 模式,相对 import 带 `.js` 后缀)
- 框架NestJS 10
- 下游通信:当前 `fetch`REST→ 目标 `@grpc/grpc-js` + `@bufbuild/protobuf`
- 对前端:目标 GraphQL Yoga + DataLoader
- 缓存Redis待引入
- 可观测pino logger + prom-client metrics + OpenTelemetry tracer已具备 [tracer.ts](../../services/teacher-bff/src/shared/observability/tracer.ts)
### 5. 我的阶段归属
- **P2 身份**:教师登录 → 获取 JWT → 访问 teacher-portal → 侧边栏按 viewports.L1 渲染 → 空白 Dashboard
- **P3 扩展**:考试/作业/成绩的 GraphQL 查询与 mutation
- **依赖上游**P1 黄金模板 classes、P2 iamgetEffectivePermissions + 视口)
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [ ] 权限装饰器 `@RequirePermission`**BFF 不做权限决策**当前无目标态BFF 不加 Guard仅校验 `x-user-id` 存在)
- [x] 错误处理:[GlobalErrorFilter](../../services/teacher-bff/src/shared/errors/global-error.filter.ts) + ApplicationError 层次
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [ ] `/readyz`(当前 HealthController 仅 liveness无下游就绪探针
- [x] 优雅关闭 SIGTERMmain.ts 已处理)
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [ ] Dockerfile 多阶段构建(需核对)
- [ ] Zod 输入验证(当前 Controller 直接透传 unknown**未做 Zod 校验**
- [x] GlobalErrorFilter 统一兜底
---
## 模块理解确认书 — core-edu
### 1. 我在架构中的位置
- **层级**业务微服务层L5
- **上游**teacher-bff聚合层、api-gateway直接路由 `/api/v1/exams` 等)
- **下游**MySQL独占库、Kafka事件发布、Redis待引入高并发提交锁
- **通信方式**
- 入口:当前 REST`/exams``/homework``/grades`
- 目标态proto 注释P3 起转 gRPC`core_edu.v1.ExamService/HomeworkService/GradeService` 已定义)
- 出口Kafka 事件Outbox 模式发布)
- **端口**3004见 [core-edu env.ts](../../services/core-edu/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:跨 **D2 教学组织**classes 模块,待合并)+ **D3 教学核心**exams / homework / grades
- **聚合根**Exam、Homework、Grade、Class待合并
- **我不负责**
- 不负责题库内容(→ content 服务)
- 不负责学情分析(→ data-ana 服务,消费 core-edu 事件)
- 不负责通知投递(→ msg 服务,消费 core-edu 事件)
- **数据自治**:独占 `core_edu` 数据库,表前缀 `core_edu_*`
### 3. 我与外部的契约
- **暴露的 gRPC 契约**[core_edu.proto](../../packages/shared-proto/proto/core_edu.proto),已定义待实现):
- `ExamService`CreateExam / GetExam / ListExamsByClass / UpdateExam / DeleteExam
- `HomeworkService`AssignHomework / GetHomework / ListHomeworkByClass / SubmitHomework
- `GradeService`RecordGrade / GetGrade / ListGradesByStudent/Exam/Homework
- **发布的领域事件**[events.proto](../../packages/shared-proto/proto/events.proto) + [outbox.publisher.ts TOPIC_MAP](../../services/core-edu/src/shared/outbox/outbox.publisher.ts)
| 事件 | Topic | 触发时机 | 消费者 |
| ------------------ | ------------------- | --------------------- | ------------- |
| exam.created | edu.exam.events | CreateExam 事务内 | msg、data-ana |
| exam.updated | edu.exam.events | UpdateExam | msg |
| exam.deleted | edu.exam.events | DeleteExam | data-ana |
| homework.assigned | edu.homework.events | AssignHomework | msg、data-ana |
| homework.submitted | edu.homework.events | SubmitHomework | data-ana、msg |
| homework.graded | edu.homework.events | **未实现**P3 待补) | msg、data-ana |
| grade.recorded | edu.grade.events | RecordGrade | data-ana、msg |
| grade.updated | edu.grade.events | **未实现**P3 待补) | data-ana |
| class.transferred | edu.class.events | classes 合并后 | data-ana |
- **消费的事件**`edu.identity.user.created`IAM初始化教师默认班级关联**当前未消费**P3 待补)
- **错误码前缀**`CORE_EDU_*`(见 [application-error.ts CoreEduErrorCode](../../services/core-edu/src/shared/errors/application-error.ts)
### 4. 我的技术栈
- 语言TypeScript 5.5+ESM 模式)
- 框架NestJS 10
- ORMDrizzle ORMmysql2 driver直接 `db` 导出,**与 classes 的 `getDb()` 不一致**
- 存储MySQL 8独占库、Redis待引入、Kafkakafkajsidempotent + transactionalId
- 可观测pino + prom-client + OTel已具备
### 5. 我的阶段归属
- **P3 核心教学**:考试全生命周期 + Outbox + Kafka 事件落地
- **退出标准**:教师创建考试 → 发布 → 学生作答 → 教师批改 → 事件到 Kafka → 成绩统计更新 → 全链路可观测
- **依赖上游**P1 classes 黄金模板、P2 iam用户身份 + 权限)
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`exams.controller 全覆盖;需核对 homework/grades controller
- [x] 错误码前缀 `CORE_EDU_*`(已用 [CoreEduErrorCode 枚举](../../services/core-edu/src/shared/errors/application-error.ts)
- [x] logger / metrics / tracer 三支柱
- [x] `/healthz` 健康检查
- [ ] `/readyz`(需补 DB SELECT 1 / Kafka 连接探针)
- [x] 优雅关闭 SIGTERMmain.ts 已处理 outboxPublisher.stop + disconnectKafka
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [ ] Dockerfile 多阶段构建(需核对)
- [ ] Zod 输入验证(**当前 Controller 直接接收 body未 Zod 校验**classes 用 zod schema
- [x] GlobalErrorFilter 统一兜底
- [x] Outbox 模式(事务内写业务表 + outbox 表,独立 publisher 投递)
---
## §10 服务审计表 — ai03
> 对照 [黄金模板 classes 服务](../../services/classes/src/),审计已实现的两服务。状态:✅ 达标 / ⚠️ 部分 / ❌ 缺失
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ----------- | --------------------------------------- | --------------------------------- | ------- | -------------- | ------- | -------- | ------- | -------- | ---------- | ---------- |
| teacher-bff | ⚠️ 无BFF 不做权限决策,依赖 Gateway | ⚠️ 用 `BAD_GATEWAY`(无自有前缀) | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ❌ | ✅ | 0% ❌ | 待核对 |
| core-edu | ✅ `@RequirePermission(EXAM_*)` 全覆盖 | ✅ `CORE_EDU_*` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ❌ | ✅ | 0% ❌ | 待核对 |
### 审计发现的关键差距P3 阶段 2 设计需解决)
**teacher-bff**
1. ❌ 通信方式:当前 REST `fetch`,目标 gRPC + GraphQLP2 退出标准要求 GraphQL Yoga + DataLoader
2. ❌ 无 Redis 聚合缓存004 §6.3 要求 5-30s 短缓存)
3. ❌ 无 DataLoader防 N+1pending-features P2 明确要求)
4. ❌ 无 `/readyz` 下游就绪探针
5. ⚠️ env 配置用 `IamServiceUrl`/`ClassesServiceUrl`REST URL转 gRPC 后需改为 gRPC target
6. ⚠️ 无 Zod 输入验证
7. ⚠️ 无测试
**core-edu**
1. ❌ 考试生命周期状态机缺失(当前仅 `draft` 初值,无 `published → in_progress → grading → graded → archived` 转换与校验)
2. ❌ 作业状态机不完整(仅 `assigned → submitted`,缺 `graded`pending-features 要求 `HomeworkGraded` 事件)
3. ❌ 成绩录入无业务校验(不校验 exam/homework 是否存在、score 是否在 totalScore 范围内、是否重复录入)
4. ❌ 作业提交高并发优化缺失004 §9.2 要求 Redis 分布式锁 + 排队)
5. ❌ 无 `grade.updated` / `homework.graded` 事件触发点proto 已定义service 未实现)
6. ❌ 未消费 IAM `user.created` 事件(初始化教师默认关联)
7. ⚠️ Drizzle `db` 直接导出 vs classes 的 `getDb()` 函数式 — **不一致**,建议统一为 `getDb()`
8. ⚠️ [kafka.ts](../../services/core-edu/src/config/kafka.ts) 用 `console.log`/`console.warn`,应改用结构化 logger
9. ⚠️ classes 模块在 core-edu 仅有 `classes.module.ts` 占位,**P3 待合并**classes 服务代码迁入 + 删除独立 services/classes
10. ⚠️ 入口仍为 RESTproto gRPC 契约已定义但未接入 `@grpc/grpc-js` + buf generate 代码
11. ❌ 无 Zod 输入验证Controller 直接接收 `body: CreateExamInput`,未走 zod schema
12. ❌ 无测试
### 跨模块契约对齐待确认项(提请 coord 交叉审查)
| 待确认项 | 我方期望 | 对方模块 | 状态 |
| ---------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| iam `getEffectivePermissions(userId)` 返回结构 | `{permissions, viewports, dataScope}` | iamai02 | ⚠️ 当前 teacher-bff 调 `/iam/viewports``/iam/me`,未定义此聚合 API 的 proto |
| iam `user.created` 事件 topic | `edu.identity.user.created` | iamai02 | ⚠️ 004 §7.2 定义,但 core-edu 未消费,需确认 iam 是否发布 |
| core-edu 端口 3004 | 不冲突 | 全局端口矩阵 | 待 coord 核对 |
| Kafka topic 命名 | `edu.exam.events` / `edu.homework.events` / `edu.grade.events` / `edu.class.events` | 004 §7.2 用 `edu.teaching.*` 前缀 | ⚠️ **不一致**004 文档用 `edu.teaching.exam.published`,代码用 `edu.exam.events`,需 coord 仲裁统一 |
| data-ana 消费 core-edu 事件 | 消费 `exam.created` / `homework.submitted` / `grade.recorded` | data-anaai06 | 待 ai06 确认消费契约 |
| msg 消费 core-edu 事件 | 消费 `exam.created` / `homework.assigned` / `grade.recorded` 触发通知 | msgai05 | 待 ai05 确认消费契约 |
---
## 下一步(阶段 2 入口)
待 coord 审核本确认书通过后ai03 进入阶段 2按 [ai-allocation.md §5 ai03 设计重点](./ai-allocation.md#ai03--教学场景域) 产出两份模块架构设计文档:
1. **teacher-bff 模块架构设计**GraphQL schemaQuery/Mutation 按场景域组织、DataLoader 批量去重、并行 gRPC 编排、聚合缓存 TTL、教师角色差异化视口推导
2. **core-edu 模块架构设计**classes 黄金模板对齐、考试生命周期状态机、Outbox 事件定义、成绩计算配置化、作业提交高并发Redis 锁 + 排队)、排课/考勤数据模型
阶段 2 设计需先解决上述 12 项差距与 6 项跨模块契约对齐。
---
**AI Agent**: ai03 (teacher-bff + core-edu)
**Coordinator**: coord-ai
**Branch**: 单仓库并行模式(直接 push main

View File

@@ -0,0 +1,269 @@
# ai05 阶段 1 交付物:模块理解确认书
> AI 标识ai05
> 负责模块contentP4、msgP5
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)、[known-issues.md](../troubleshooting/known-issues.md)
---
## 模块理解确认书 — content
### 1. 我在架构中的位置
- **层级**业务微服务层L5DDD 限界上下文
- **业务领域**D4 内容资源领域004 §1.1b
- **上游**(谁调用我):
- teacher-bff3003教学场景聚合教师查教材/知识点/题库
- student-bff学习场景P3 起):学生查学习路径、知识点前置
- aiPythonP5gRPC 查询知识点/题库用于 AI 出题004 §4.1 `AI -.gRPC.-> Content`§9.3 AI 辅助出题流程)
- **下游**(我调用谁):
- MySQL写模型主库独占
- Neo4j知识图谱前置依赖图
- Elasticsearch题库全文检索P4 后续补充,当前未实现)
- **通信方式**
- 当前HTTP RESTController无 gRPC controller
- 目标态004 §4.1 / pending-features P4gRPC 暴露 `TextbookService` + `KnowledgeGraphService`
- Kafka消费 core-edu 教学内容变更通知004 §4.1 `CoreEdu -.事件.-> Content`);发布 `edu.content.question.published`004 §7.2
- **端口**3005见 [content env.ts](../../services/content/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:管理 Textbook教材、Chapter章节、KnowledgePoint知识点、Question题库四个聚合
- **我的数据属于**D4 内容资源领域
- **我不负责**
- 不负责学情分析D6由 data-ana 承载)
- 不负责考试/作业/成绩D3由 core-edu 承载)
- 不负责通知分发D5由 msg 承载)
- 不直接访问 core-edu / iam / msg 的数据库
- **现有骨架领域模块**4 个,见 [content/src](../../services/content/src)
- `textbooks/`:教材 CRUD5 端点)
- `chapters/`:章节 CRUD5 端点,按 textbook 查询)
- `knowledge-points/`:知识点 CRUD + Neo4j 知识图谱7 端点,含前置链路查询/添加)
- `questions/`:题库 CRUD5 端点4 种题型校验)
### 3. 我与外部的契约
- **消费的 proto message**(从 shared-proto
- 无直接消费其他服务 proto通过 Kafka 事件接收 core-edu 教学内容变更(事件契约见 `events.proto`
- **暴露的契约**(见 [content.proto](../../packages/shared-proto/proto/content.proto),包名 `next_edu_cloud.content.v1`
- `TextbookService`CreateTextbook / GetTextbook / ListTextbooks
- `KnowledgeGraphService`GetPrerequisites / GetLearningPath
- **当前实现均为 RESTgRPC controller 未实现**proto 已定义待迁移)
- **事件契约**
- 发布:`edu.content.question.published`(题库新增/发布,消费者 AI、ES见 004 §7.2
- 消费core-edu 教学内容变更事件004 §4.1,具体 topic 待 core-edu ai03 设计确认)
- **错误码前缀**`CONTENT_*`CONTENT_VALIDATION_ERROR / NOT_FOUND / PERMISSION_DENIED / CONFLICT / BUSINESS_ERROR / DATABASE_ERROR / INTERNAL_ERROR见 [application-error.ts](../../services/content/src/shared/errors/application-error.ts)
- **权限点**16 个,见 [permission.guard.ts](../../services/content/src/middleware/permission.guard.ts)
- `CONTENT_TEXTBOOK_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_CHAPTER_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_QUESTION_{CREATE,READ,UPDATE,DELETE}`
- `CONTENT_KNOWLEDGE_POINT_{CREATE,READ,UPDATE,DELETE}`
### 4. 我的技术栈
- 语言TypeScript 5.6ESM 模式,相对 import 带 `.js` 后缀)
- 框架NestJS 10
- ORMDrizzle ORM 0.31 + mysql2 3.11
- 知识图谱neo4j-driver 5.23PREREQUISITE_OF 关系Cypher 查询深度 1..5
- 全文检索Elasticsearch**待引入**env.ts 预留 ES_URL 但 package.json 未装 @elastic/elasticsearch
- 可观测pino logger + prom-client metrics + OpenTelemetry tracer三支柱已具备
- 消息总线Kafka**待引入**pending-features P4 未明确要求 content 发事件,但 004 §7.2 列了 `edu.content.question.published`
### 5. 我的阶段归属
- **P4 内容分析阶段**pending-features §P4
- 教材/章节/知识点 CRUD仅 CRUD不实现检索+ 知识图谱查询Neo4j+ 题库 CRUD不实现检索
- MySQL schematextbooks / chapters / knowledge_points / questions
- Neo4j 数据:知识点前置依赖图(从 MySQL 同步)
- CDC 链路Debezium 监听 MySQL binlog → Kafka → data-ana 消费写 ClickHouse
- **退出标准**教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s
- **依赖上游**P1 黄金模板 classes横切关注点对齐、P3 core-edu教学内容变更事件待 ai03 设计确认 topic
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`16 个 CONTENT_* 权限点,全部 Controller 方法已覆盖)
- [x] 错误码前缀统一(`CONTENT_*`
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [⚠️] `/readyz`**仅检查 DB `SELECT 1`,未检查 Neo4j 连通性**Neo4j 故障时仍返回 ok
- [x] 优雅关闭 SIGTERMmain.ts 已处理closeNeo4j → closeDb → shutdownTracer
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [x] Dockerfile 多阶段构建(已具备)
- [⚠️] Zod 输入验证questions 用 service 层手动 if 校验抛 ValidationError**非 Controller 层 schema.parse**
- [x] GlobalErrorFilter 统一兜底
### 7. 现有骨架差距与待决策(提请 coord 仲裁)
| # | 差距 | 影响 | 提请决策 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| C1 | **ES 完全未实现**env.ts 预留 ES_URL 但无 @elastic/elasticsearch 依赖、无 config/elasticsearch.ts、无 service 调用 | P4 退出标准要求题库检索pending-features 注明"P4 仅 CRUDP5 引入 ES" | 确认 content 的 ES 检索归属 P4 还是 P5004 §2.2 列 ES 使用者为 Content、AI |
| C2 | **无 Outbox / Kafka**:骨架无 shared/outbox/,无 Kafka producer/consumer | 004 §7.2 列 `edu.content.question.published` 事件需发布 | 确认 content 是否需 Outboxiam 无 Outboxcore-edu 有) |
| C3 | **README 与实现脱节**README 声称 `TextbooksService.createKnowledgeGraph``getPrerequisites`,源码中 createKnowledgeGraph 不存在getPrerequisites 在 KnowledgePointsService | 文档误导 | 阶段 2 设计需同步修正 README |
| C4 | **gRPC 未实现**proto 定义了 TextbookService/KnowledgeGraphService但无 gRPC controller | pending-features P4 未强制 gRPC004 §4.1 目标态 gRPC | 确认 P4 是否启用 gRPCai03 提请统一决策) |
| C5 | **schema 定义分散**textbooks.schema.ts 定义 3 张表textbooks/chapters/knowledgePointschapters/knowledge-points schema 仅 re-exportquestions.schema.ts 独立 | 维护成本 | 阶段 2 设计统一 schema 归属 |
| C6 | **DB 连接模式与 iam 不一致**content 用模块级 `const db`iam 用 `getDb()` 函数式懒加载 | 测试 mock 困难 | coord 已在 known-issues 记录"db 常量导出对齐黄金模板",需确认统一方向 |
| C7 | **表时间戳不统一**textbooks/questions 有 created_at+updated_atchapters/knowledge_points 无时间戳 | 审计追踪缺失 | 阶段 2 设计补齐 |
| C8 | **无外键约束**questions.knowledge_point_id → knowledge_points.id 等无 Drizzle 外键 | 引用完整性靠应用层 | 阶段 2 设计评估是否加外键 |
| C9 | **addPrerequisite 降级策略不一致**safeCreateNode 非阻塞Neo4j 失败仅 warnaddPrerequisite 在 Neo4j 不可用时抛 InternalError | 行为不一致 | 阶段 2 设计统一降级策略 |
| C10 | **proto 包名**:实际 `next_edu_cloud.content.v1`project_rules §5 规定 `edu.content.v1` | 命名规范不一致 | **coord 仲裁**ai03 已提请) |
---
## 模块理解确认书 — msg
### 1. 我在架构中的位置
- **层级**业务微服务层L5DDD 限界上下文
- **业务领域**D5 沟通通知领域004 §1.1b
- **上游**(谁调用我):
- teacher-bff / student-bff / parent-bff聚合层调 msg 查询用户通知列表、发送通知
- api-gatewayREST 转发通知请求
- Kafka消费 core-edu / iam 事件触发通知004 §4.1 `CoreEdu -.事件.-> Msg``IAM -.事件.-> Msg`
- **下游**(我调用谁):
- MySQL写模型主库独占
- Elasticsearch全文检索已实现 safeSearch
- push-gatewaygRPC 推送通道004 §4.1 `PushGW → Msg`,当前用 fetch POST /internal/push 降级)
- **通信方式**
- 当前HTTP RESTController无 gRPC controller
- 目标态004 §4.1 / pending-features P5gRPC 暴露 `NotificationService`
- Kafka消费 core-eduExamPublished/HomeworkGraded/GradeRecorded、iamUserRegistered事件004 §7.3
- **端口**3007见 [msg env.ts](../../services/msg/src/config/env.ts)
### 2. 我的限界上下文
- **聚合职责**:管理 Notification通知聚合 + NotificationPreference用户通知偏好
- **我的数据属于**D5 沟通通知领域
- **我不负责**
- 不负责 WebSocket 长连接管理(由 push-gateway 承载)
- 不负责业务数据变更(仅消费事件触发通知)
- 不直接访问 core-edu / iam 的数据库
- **现有骨架领域模块**1 个,见 [msg/src](../../services/msg/src)
- `notifications/`:通知 CRUD + ES 全文检索 + Push Gateway 推送 + 用户偏好6 端点)
### 3. 我与外部的契约
- **消费的 proto message**(从 shared-proto
- 消费 `events.proto` 的事件 messageClassEvent/ExamEvent/HomeworkEvent/GradeEvent
- **events.proto 当前无 NotificationEvent**,若 msg 发事件需补充
- **暴露的契约**(见 [msg.proto](../../packages/shared-proto/proto/msg.proto),包名 `next_edu_cloud.msg.v1`
- `NotificationService`SendNotification / ListNotifications / MarkAsRead / SearchNotifications
- **当前实现均为 RESTgRPC controller 未实现**
- **事件契约**
- 消费004 §7.2 / §7.3
- `edu.identity.user.created` / `edu.identity.user.updated`IAM 发msg 发欢迎通知)
- `edu.teaching.exam.published`core-edu 发msg 推送考试通知给学生)
- `edu.teaching.assignment.submitted`core-edu 发msg 通知教师)
- `edu.teaching.grade.recorded`core-edu 发msg 通知学生)
- `edu.insight.mastery.updated`data-ana 发msg 触发预警)
- 发布无明确pending-features 未要求 msg 发事件)
- **错误码前缀**`MSG_*`MSG_VALIDATION_ERROR / NOT_FOUND / PERMISSION_DENIED / CONFLICT / BUSINESS_ERROR / DATABASE_ERROR / INTERNAL_ERROR见 [application-error.ts](../../services/msg/src/shared/errors/application-error.ts)
- **权限点**3 个,见 [permission.guard.ts](../../services/msg/src/middleware/permission.guard.ts)
- `MSG_NOTIFICATION_SEND``MSG_NOTIFICATION_READ``MSG_NOTIFICATION_MANAGE`
### 4. 我的技术栈
- 语言TypeScript 5.6ESM 模式)
- 框架NestJS 10
- ORMDrizzle ORM 0.31 + mysql2 3.11
- 全文检索:@elastic/elasticsearch 8.15(已实现 safeIndex/safeSearch**无 mapping 定义**,依赖动态 mapping
- 消息总线kafkajs 2.2**已装依赖但无 consumer/producer 代码**env.ts 有 KAFKA_BROKERS 默认值)
- 推送fetch POST 到 push-gateway `/internal/push`降级模式PUSH_GATEWAY_URL 未配置或失败时跳过)
- 幂等去重Redis**env.ts 预留 REDIS_URL 但无 redis 客户端依赖**pending-features P5 要求 event_id 去重)
- 可观测pino + prom-client + OpenTelemetry三支柱已具备
### 5. 我的阶段归属
- **P5 沟通与 AI 阶段**pending-features §P5
- 会话/消息 CRUD + 调 Push Gateway 推送 + 通知偏好
- 多渠道(站内/SMS/邮件/微信),沿用旧项目 dispatcher 模式
- Elasticsearch 题库全文检索(从 MySQL 同步)
- **退出标准**:教师发广播通知 → 全在线学生实时收到Push Gateway→ AI 辅助出题流式返回 → 题库全文检索 < 200ms
- **依赖上游**P1 黄金模板 classes、P3 core-edu事件来源、P5 push-gateway推送通道ai01 设计、P5 ai无直接依赖
### 6. 我需要对齐的黄金模板项(对照 classes 服务)
- [x] 权限装饰器 `@RequirePermission`3 个 MSG_* 权限点,全部 Controller 方法已覆盖)
- [x] 错误码前缀统一(`MSG_*`
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` 健康检查HealthModule 已注册)
- [⚠️] `/readyz`**仅检查 DB `SELECT 1`,未检查 ES 连通性**ES 故障时仍返回 ok
- [x] 优雅关闭 SIGTERMmain.ts 已处理app.close → closeEs → closeDb → shutdownTracer
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无测试文件)
- [x] Dockerfile 多阶段构建(已具备)
- [⚠️] Zod 输入验证Controller 用 `schema.parse(body)`**但 ZodError 未在 GlobalErrorFilter 特殊处理,走默认 500 而非 400**
- [x] GlobalErrorFilter 统一兜底
### 7. 现有骨架差距与待决策(提请 coord 仲裁)
| # | 差距 | 影响 | 提请决策 |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| M1 | **Kafka 消费未实现**:装了 kafkajs 但无 consumer 代码,无 shared/kafka/ 目录 | 004 §7.3 列 msg 消费 5 类事件ExamPublished/HomeworkGraded/GradeRecorded/UserRegistered/MasteryUpdated | 阶段 2 设计需补 Kafka consumer + 幂等去重 |
| M2 | **无 Outbox**:骨架无 shared/outbox/ | msg 作为通知中心,发送通知后可能需发事件(如 NotificationSent | 确认 msg 是否需 Outboxiam 无core-edu 有) |
| M3 | **ES 无 mapping 定义**:依赖动态 mapping索引名 "notifications" 硬编码在 service | 检索质量不稳定,索引管理缺失 | 阶段 2 设计补 mapping + ensureIndex |
| M4 | **无独立 repository**notifications.service.ts 直接用 db无 repository 抽象 | 与黄金模板分层不一致iam 有 repository | 阶段 2 设计补 repository 层 |
| M5 | **Redis 未引入**env.ts 预留 REDIS_URL 但无 redis 客户端依赖 | pending-features P5 要求 event_id 去重Redis SETNX 或 DB 唯一索引) | 阶段 2 设计决策Redis SETNX vs DB 唯一索引 |
| M6 | **createBatch 无事务/无批量优化**for 循环串行调 send无批量 INSERT | 性能瓶颈(广播场景) | 阶段 2 设计改为批量 INSERT |
| M7 | **NotificationsModule 缺 exports**notifications.module.ts 无 `exports: [NotificationsService]` | 未来 BFF 注入受阻 | 阶段 2 设计补 exports |
| M8 | **ZodError 未特殊处理**GlobalErrorFilter 未识别 ZodError走默认 500 | 输入校验错误返回码错误500 而非 400 | 阶段 2 设计 GlobalErrorFilter 补 ZodError 分支iam 已有可参考) |
| M9 | **gRPC 未实现**proto 定义了 NotificationService但无 gRPC controller | pending-features P5 未强制 gRPC | 确认 P5 是否启用 gRPCai03 提请统一决策) |
| M10 | **README 与实现脱节**README API 表标 `POST /notifications/:id/read`,实际是 PUT漏 batch/user/:userId/user/:userId/page 端点;声称"消费 Kafka 事件"但无代码 | 文档误导 | 阶段 2 设计同步修正 README |
| M11 | **main.ts 与 LifecycleService 重复关闭资源**:两者都调 closeDb/closeEs | 重复关闭可能报错(虽有 try-catch | 阶段 2 设计统一关闭逻辑到 LifecycleService |
| M12 | **proto 包名**:实际 `next_edu_cloud.msg.v1`project_rules §5 规定 `edu.msg.v1` | 命名规范不一致 | **coord 仲裁**ai03 已提请) |
---
## 三、服务审计表 — ai05
> 审计标准对照 [project_rules §3](../../.trae/rules/project_rules.md) 与 [known-issues §2.2 classes 黄金模板](../troubleshooting/known-issues.md)
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ------- | ---------------- | -------------- | ------- | -------------- | ------------------------------- | -------- | ------------------- | --------------------- | ---------- | ---------- |
| content | ✅ 16 端点全覆盖 | ✅ `CONTENT_*` | ✅ pino | ✅ prom-client | ✅ OTel + auto-instrumentations | ✅ | ⚠️ 仅 DB 不查 Neo4j | ✅ closeNeo4j→closeDb | 0% | ✅ |
| msg | ✅ 6 端点全覆盖 | ✅ `MSG_*` | ✅ pino | ✅ prom-client | ✅ OTel + auto-instrumentations | ✅ | ⚠️ 仅 DB 不查 ES | ✅ closeEs→closeDb | 0% | ✅ |
---
## 四、跨模块契约对齐提请coord 交叉审查)
### 4.1 接口一致性检查
| 本服务声明 | 对方服务声明 | 是否匹配 | 备注 |
| --------------------------------------------------------- | ------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
| ai05 content: 暴露 KnowledgeGraphService.GetPrerequisites | ai06 ai: 调 content 查知识点004 §9.3 | ⚠️ 待确认 | ai06 设计文档需确认调用签名 |
| ai05 content: 暴露 TextbookService.ListTextbooks | ai03 teacher-bff: 聚合 content 查教材004 §4.1 | ⚠️ 待确认 | ai03 设计文档需确认调用 |
| ai05 content: 发布 `edu.content.question.published` | ai06 ai: 消费题库事件004 §7.2 消费者 AI | ⚠️ 待确认 | ai06 设计需确认是否消费 |
| ai05 msg: 消费 `edu.teaching.exam.published` | ai03 core-edu: 发布考试事件 | ⚠️ 待确认 | ai03 提请 topic 命名统一004 `edu.teaching.exam.published` vs core-edu 代码 `edu.exam.events` |
| ai05 msg: 消费 `edu.identity.user.created` | ai02 iam: 发布用户创建事件 | ⚠️ 待确认 | ai02 设计需确认 topic |
| ai05 msg: 调 push-gateway `/internal/push` | ai01 push-gateway: 暴露推送端点 | ✅ 已实现 | msg 当前用 fetch POSTpush-gateway 已有 /internal/push 端点 |
### 4.2 全局冲突检查
| 检查项 | 检查结果 | 备注 |
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 端口不冲突 | ✅ content 3005、msg 3007 | 与 iam(3002)/classes(3001)/teacher-bff(3003)/core-edu(3004)/data-ana(3006)/ai(3008) 不冲突 |
| Topic 不重复 | ⚠️ 待汇总 | content 发布 `edu.content.question.published`msg 仅消费不发布 |
| 错误码前缀不重叠 | ✅ `CONTENT_*` / `MSG_*` 唯一 | 与 iam `IAM_*` / core-edu `CORE_EDU_*` 不重叠 |
| Proto message 不遗漏 | ⚠️ 待确认 | events.proto 无 NotificationEvent若 msg 发事件需补充content.proto 缺 Update/Delete/分页(对比 classes.proto 有 page_token |
| proto 包名规范 | ⚠️ 不一致 | 实际 `next_edu_cloud.<domain>.v1`,规则要求 `edu.<domain>.v1`**提请 coord 仲裁**ai03 已提请) |
### 4.3 待 coord 仲裁的决策项
1. **proto 包名统一**`next_edu_cloud.*` vs `edu.*`(影响全部 proto需 coord 决策)
2. **gRPC 启用时机**P4/P5 是否启用 gRPC controller还是继续 REST影响 content/msg/iam/core-edu 全部服务)
3. **content ES 检索归属**P4pending-features 注明"P4 仅 CRUDP5 引入 ES"vs 004 §2.2(列 ES 使用者 Content、AI—— 需明确 content 何时引入 ES
4. **content Outbox**是否需要004 §7.2 列 content 发事件,但 pending-features P4 未要求)
5. **msg Outbox**是否需要msg 仅消费事件触发通知,是否需发 NotificationSent 事件)
6. **msg Redis 引入**env.ts 预留 REDIS_URL 但无依赖,幂等去重用 Redis SETNX 还是 DB 唯一索引
7. **events.proto 补充**:若 msg 发事件需追加 NotificationEvent message
---
## 五、下一步
1. **等待 coord 审核本确认书**§4 跨模块契约对齐 + §4.3 仲裁项)
2. coord 放行后进入**阶段 2模块架构设计文档**,按 ai-allocation.md §7 模板产出:
- content 模块架构设计文档(含 Neo4j 图模型、ES 索引 mapping、题库 CRUD API、与 ai 的 gRPC 接口、教材/章节结构树)
- msg 模块架构设计文档含通知渠道抽象策略模式、ES 降级查询、Kafka 消费幂等、push-gateway 推送协议、通知模板、已读/未读状态管理)
3. 阶段 2 设计完成后同步更新 README修正 §C3/M10 文档脱节问题)

View File

@@ -0,0 +1,333 @@
# ai06 阶段 1 交付物:模块理解确认书
> AI 标识ai06
> 负责模块data-anaP4、aiP5
> 语言Python 3.12+ / FastAPI 0.115+
> 阶段:架构设计外包 · 阶段 1全局理解
> 日期2026-07-09
> 关联文档:[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)、[known-issues.md](../troubleshooting/known-issues.md)
---
## 阶段 1 必读清单完成确认
按 ai-allocation.md §4ai06 必读 7 份全局文档 + 全部 `.proto` + `services/data-ana/``services/ai/` 骨架源码:
| # | 文档 | 状态 |
| --- | ------------------------------------------------------------------- | ------------------------------ |
| 1 | [README.md](../../README.md) | ✅ 已读 |
| 2 | [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | ✅ 已读 |
| 3 | [004 架构影响地图](./004_architecture_impact_map.md) | ✅ 已读 |
| 4 | [pending-features.md](./roadmap/pending-features.md) | ✅ 已读 |
| 5 | [project_rules.md](../../.trae/rules/project_rules.md) | ✅ 已读workspace 规则) |
| 6 | [coding-standards.md](../standards/coding-standards.md) | ✅ 已读(重点 §4 Python 规范) |
| 7 | [multi-ai-collaboration.md](../standards/multi-ai-collaboration.md) | ✅ 已读 |
| 8 | `packages/shared-proto/proto/*.proto`8 份) | ✅ 已读 |
| 9 | `services/data-ana/src/``services/ai/src/` 全部源码 | ✅ 已读 |
> ai06 不需要读 classes 黄金模板ai-allocation.md §4 矩阵 ai06 列对黄金模板行为 "—"),但审计表横切关注点对齐仍参考 classes 标准。
---
## 模块理解确认书 — data-ana
### 1. 我在架构中的位置
- **层级**业务微服务层L5**D6 智能洞察领域**004 §1.1b
- **上游**
- api-gateway 直接 HTTP 代理 `/api/v1/analytics/*` → data-ana:3006见 [api-gateway main.go:142-148](../../services/api-gateway/main.go)
- teacher-bff / student-bff 聚合查询004 §4.1BFF → 业务服务 gRPC当前为 REST
- **下游**
- ClickHouse独占读模型宽表 `student_dashboard_view` / `student_errors`
- Kafka消费 Debezium CDC 事件 + 待发布 `edu.insight.mastery.updated` 领域事件)
- **通信方式**
- 入口:当前 REST`/analytics/class/{id}/performance``/analytics/student/{id}/weakness``/analytics/student/{id}/errorbook`目标态004 §4.1 + analytics.proto**gRPC** 暴露 `AnalyticsService`
- 出口Kafka 消费CDC + 领域事件订阅Kafka 发布(`edu.insight.mastery.updated`**未实现**
- **端口**3006见 [data-ana config.py:7](../../services/data-ana/src/data_ana/config.py) + [api-gateway config.go:55](../../services/api-gateway/internal/config/config.go)
### 2. 我的限界上下文
- **聚合职责**:学情分析读模型构建 + 查询服务,承载 **D6 智能洞察领域**的"分析/诊断"子域
- **聚合根 / 实体**
- `StudentDashboard`学生学情宽表视图ClickHouse 物化)
- `StudentErrorBook`学生错题本ClickHouse 物化)
- `ClassPerformance`班级成绩聚合ClickHouse 即时聚合)
- `MasterySnapshot`(知识点掌握度快照,**待实现**
- **业务领域**D6 智能洞察(与 ai 服务共享领域ai 偏"生成"data-ana 偏"分析"
- **我不负责**
- 不负责写模型(成绩由 core-edu 写 MySQLdata-ana 只消费 CDC
- 不负责题库内容(→ content 服务)
- 不负责通知投递(→ msg 服务消费 data-ana 发布的 `mastery.updated` 事件触发预警)
- 不负责 AI 推理(→ ai 服务ai 通过 gRPC 反向查询 data-ana 学情数据)
- **数据自治**:独占 `edu_analytics` ClickHouse 数据库(不与 MySQL 写模型混用)
### 3. 我与外部的契约
#### 消费的 proto message
| 来源 | message | 用途 |
| --------------- | ------------------------------- | ---------------------------------------------- |
| events.proto | `GradeEvent` | 消费 core-edu 成绩写入事件 → 更新学情宽表 |
| events.proto | `ExamEvent` | 消费考试事件 → 缓存 exam_id→class_id 映射 |
| events.proto | `HomeworkEvent` | 消费作业提交/批改事件 → 更新学情(**待实现** |
| events.proto | `ClassEvent` | 消费班级变更事件 → 同步班级维度(**待实现** |
| analytics.proto | `GetClassPerformanceRequest` 等 | gRPC 暴露契约(**待实现 gRPC server** |
> **CDC 直连 vs 领域事件双通道**:当前实现走 Debezium CDC直接监听 MySQL binlog不依赖 core-edu 的 Outbox。这是 ADR-008 的设计决策CDC 解耦 Outbox Relay减少业务侵入。Outbox 领域事件events.proto作为业务语义更清晰的补充通道待 P4 后期评估是否双消费。
#### 暴露的 API / 事件
**HTTP 端点**(当前实现,见 [main.py](../../services/data-ana/src/data_ana/main.py)
| method | path | 说明 |
| ------ | ------------------------------------------- | -------------------------------------- |
| GET | `/healthz` | liveness |
| GET | `/readyz` | readiness含 ClickHouse + CDC 状态) |
| GET | `/metrics` | Prometheus 指标 |
| GET | `/analytics/class/{class_id}/performance` | 班级成绩分析(平均分/及格率/参考人数) |
| GET | `/analytics/student/{student_id}/weakness` | 学生薄弱知识点mastery < 0.6 |
| GET | `/analytics/student/{student_id}/errorbook` | 学生错题本 |
**gRPC 契约**analytics.proto**待实现**
- `GetClassPerformance(GetClassPerformanceRequest) → ClassPerformance`
- `GetStudentWeakness(GetStudentWeaknessRequest) → StudentWeakness`
- `GetLearningTrend(GetLearningTrendRequest) → LearningTrend`**当前 HTTP 未暴露**
**发布的领域事件**
| 事件 | Topic004 §7.2 | 触发时机 | 消费者004 §7.3 |
| ---------------- | ----------------------------- | -------------- | --------------------------------------- |
| `MasteryUpdated` | `edu.insight.mastery.updated` | 掌握度计算完成 | core-edu推荐个性化练习、msg预警 |
> **当前未实现发布**data-ana 当前只消费不发布。掌握度计算完成后应通过 Kafka 发布 `MasteryUpdated`,下游 core-edu / msg 消费。阶段 2 设计需补全此发布链路Python 无 Outbox 模式,需评估直接 producer 还是引入 Outbox 表)。
- **错误码前缀**`DATA_ANA_*`(待定义清单,见阶段 2 §6
- **缓存**:当前无 Redis 缓存004 §6.3 学情宽表走 ClickHouse 实时CDC 同步延迟 < 5s
### 4. 我的技术栈
- 语言Python 3.12+
- 框架FastAPI 0.115+ / uvicorn
- ORM / 客户端:`clickhouse-connect`HTTP 协议,非原生协议)
- 消息:`aiokafka`CDC 消费者AIOKafkaConsumer
- 配置:`pydantic-settings` BaseSettingsenv_prefix="",全大写环境变量)
- 可观测:
- 日志:`structlog` 24.x`make_filtering_bound_logger`**注意**:旧版 `make_filtering_logger` 已废弃)
- 指标:`prometheus-client` + `make_asgi_app()` 挂载 `/metrics`
- 链路:`opentelemetry-sdk` + `OTLPSpanExporter` + `FastAPIInstrumentor.instrument_app(app)`
- 序列化JSONDebezium 事件 `schemas.enable=false`,直接 `json.loads`
- 测试pytest + pytest-asyncio**当前 0% 覆盖率**
### 5. 我的阶段归属
- **P4 内容分析阶段M11-M13**:建 DataAna 服务 + CDC 链路落地
- **退出标准**pending-features P4学生查看学情诊断 ClickHouse 宽表 5s 内返回 + CDC 链路延迟 < 5s
- **依赖上游**
- P1 地基api-gateway 路由 + arch.db 扫描器 Python 支持
- P3 核心教学core-edu 写成绩到 MySQLDebezium 监听 binlog
- P4 同期content 服务(提供知识点 ID 供掌握度计算)
- **下游依赖我**
- P5 ai 服务通过 gRPC 查询学情数据004 §4.1AI → DataAna gRPC
- P5 msg 服务消费 `mastery.updated` 触发预警
### 6. 我需要对齐的黄金模板项(对照 classes 服务 + Python 规范)
> Python 服务无 NestJS 装饰器体系,权限校验等通过等价方式实现。
- [ ] 权限装饰器等价物:**当前 HTTP 端点全部裸露,无权限校验**。Gateway 层做 JWT 校验,但 data-ana 本身未校验 `x-user-id` / DataScope。**阶段 2 需设计 FastAPI Depends 权限依赖 + DataScope 过滤注入**
- [ ] 错误码前缀统一:**当前无错误码体系**,降级时返回 `degraded: true` 标记但无业务错误码。**阶段 2 需定义 `DATA_ANA_*` 错误码清单**
- [x] logger / metrics / tracer 三支柱(已具备,见 main.py + clickhouse_client.py
- [x] `/healthz` + `/readyz` 健康检查已具备readyz 含 ClickHouse ping + CDC 状态)
- [ ] 优雅关闭 SIGTERM当前 lifespan 仅关闭 CDC task + ClickHouse client**未注册 SIGTERM 信号处理器**显式 drain
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无 tests/ 目录
- [ ] Dockerfile 多阶段构建:**当前单阶段**`FROM python:3.12-slim``uv sync``COPY src`),非多阶段
- [ ] Pydantic 输入验证:**当前端点直接接收 path param无 Pydantic 请求模型校验**(应补 `ClassPerformanceResponse` 等 response_model
- [x] 配置通过 pydantic-settings 管理(已具备)
- [x] 异步优先async defaiokafka async consumer
- [ ] 类型注解强制:**部分函数缺返回值标注**(如 `init_logger` 返回 `BoundLogger``_logger` 全局变量标注 `None`,需统一)
- [ ] ruff check 零错误:需阶段 2 验证
---
## 模块理解确认书 — ai
### 1. 我在架构中的位置
- **层级**业务微服务层L5**D6 智能洞察领域**004 §1.1b,与 data-ana 共享领域)
- **上游**
- api-gateway 直接 HTTP 代理 `/api/v1/ai/*` → ai:3008见 [api-gateway main.go:138-139](../../services/api-gateway/main.go)
- teacher-bff 聚合 AI 出题/优化能力004 §4.1BFF → ai当前 REST
- **下游**
- content 服务gRPC 查询知识点 / 题库004 §4.1AI → Content gRPC
- data-ana 服务gRPC 查询学情数据004 §4.1AI → DataAna gRPC
- LLM Provider外部 HTTPOpenAI 兼容 REST API
- **通信方式**
- 入口:当前 REST`/ai/chat``/ai/chat/stream``/ai/generate/question``/ai/optimize/expression`目标态ai.proto**gRPC** 暴露 `AiService`(含 `StreamChat` 流式 RPC
- 出口gRPC 调 content / data-ana**当前未实现,仅 LLM HTTP 调用**SSE 流式对前端
- **端口**3008见 [ai config.py:8](../../services/ai/src/ai/config.py) + [api-gateway config.go:57](../../services/api-gateway/internal/config/config.go)
### 2. 我的限界上下文
- **聚合职责**LLM 调用网关 + 教学场景 AI 编排(备课 / 出题 / 表达优化),承载 **D6 智能洞察领域**的"生成"子域
- **聚合根 / 实体**
- `ChatConversation`(聊天会话,**待实现**,当前无状态)
- `GeneratedQuestion`(生成的题目,待审核入库)
- `PromptTemplate`Prompt 模板,**待实现**
- `UsageRecord`(用量计费记录,**待实现**
- **业务领域**D6 智能洞察(与 data-ana 共享data-ana 偏"分析"ai 偏"生成"
- **我不负责**
- 不负责题库存储(→ content 服务ai 生成后调 content.CreateQuestions 入库)
- 不负责学情计算(→ data-ana 服务ai 查询学情用于个性化出题)
- 不负责通知投递(→ msg 服务)
- 不持有业务状态(无 DB 写入,无 Outbox用量计费记录可走 Kafka 事件给 data-ana 落 ClickHouse
- **数据自治****无独占数据库**(设计上无状态;用量计费通过 Kafka 事件外发)
### 3. 我与外部的契约
#### 消费的 proto message
| 来源 | message / service | 用途 |
| --------------- | ---------------------------------------- | --------------------------------------- |
| content.proto | `KnowledgeGraphService.GetPrerequisites` | 查询知识点前置依赖用于出题上下文 |
| content.proto | `KnowledgeGraphService.GetLearningPath` | 查询学生学习路径用于个性化出题 |
| analytics.proto | `AnalyticsService.GetStudentWeakness` | 查询学生薄弱知识点用于靶向出题 |
| analytics.proto | `AnalyticsService.GetLearningTrend` | 查询学习趋势用于难度调节 |
| ai.proto | `ChatRequest` 等 | gRPC 暴露契约(**待实现 gRPC server** |
#### 暴露的 API / 事件
**HTTP 端点**(当前实现,见 [main.py](../../services/ai/src/ai/main.py)
| method | path | 说明 |
| ------ | ------------------------- | ---------------------------- |
| GET | `/healthz` | liveness |
| GET | `/readyz` | readiness含 LLM 是否配置) |
| GET | `/metrics` | Prometheus 指标 |
| POST | `/ai/chat` | LLM 聊天(非流式) |
| POST | `/ai/chat/stream` | LLM 流式聊天SSE |
| POST | `/ai/generate/question` | 生成题目 |
| POST | `/ai/optimize/expression` | 优化表达 |
**gRPC 契约**ai.proto**待实现**
- `Chat(ChatRequest) → ChatResponse`
- `StreamChat(ChatRequest) → stream ChatChunk`(流式 RPC
- `GenerateQuestion(GenerateQuestionRequest) → GeneratedQuestion`
- `OptimizeExpression(OptimizeExpressionRequest) → OptimizedExpression`
**发布的领域事件****当前无发布**。设计上可发布 `AIUsageRecorded` 事件(用量计费),由 data-ana 消费落 ClickHouse。004 §7.2 未列出此 topic**阶段 2 需与 coord 确认是否新增 `edu.insight.ai.usage` topic**。
- **错误码前缀**`AI_*`(待定义清单,见阶段 2 §6
- **降级策略**LLM API key 为空或调用失败时返回 `degraded: true` 骨架响应(见 [llm_client.py](../../services/ai/src/ai/llm_client.py)
### 4. 我的技术栈
- 语言Python 3.12+
- 框架FastAPI 0.115+ / uvicorn
- LLM 客户端:`httpx` 异步直接调 OpenAI 兼容 REST API**不依赖 openai SDK**,见 [llm_client.py:1-7](../../services/ai/src/ai/llm_client.py)
- 配置:`pydantic-settings` BaseSettingsenv_prefix=""
- 可观测:
- 日志:`structlog`
- 指标:`prometheus-client` + `make_asgi_app()`
- 链路:`opentelemetry-sdk` + `OTLPSpanExporter` + `FastAPIInstrumentor`dev_mode=true 时跳过 exporter 初始化避免本地无 collector 报错)
- 流式响应FastAPI `StreamingResponse` + `AsyncGenerator`SSE 格式 `data: <chunk>\n\n`
- 测试pytest**当前 0% 覆盖率**
### 5. 我的阶段归属
- **P5 沟通与 AI 阶段M14-M16**:建 AI 网关 + LLM Provider 适配 + 流式 SSE
- **退出标准**pending-features P5AI 辅助出题流式返回 + 题库全文检索 < 200msES 部分由 ai05 负责)
- **依赖上游**
- P1 地基api-gateway 路由
- P4 内容分析content 服务gRPC 查询知识点 / 题库)+ data-ana 服务gRPC 查询学情)
- 外部LLM Provider API keyOpenAI / Anthropic / 百川 / 本地模型)
- **下游依赖我**
- P5 teacher-bff 聚合 AI 出题 mutation004 §9.3:教师用 AI 出题并发布到班级)
- P5 teacher-portal SSE 流式 AI 对话(前端)
### 6. 我需要对齐的黄金模板项(对照 classes 服务 + Python 规范)
- [ ] 权限装饰器等价物:**当前 HTTP 端点全部裸露,无权限校验**。阶段 2 需设计 FastAPI Depends 权限依赖AI 出题需 `AI_QUESTION_GENERATE` 权限,表达优化需 `AI_EXPRESSION_OPTIMIZE`
- [ ] 错误码前缀统一:**当前无错误码体系**,降级时返回 `degraded: true` 但无业务错误码。**阶段 2 需定义 `AI_*` 错误码清单**
- [x] logger / metrics / tracer 三支柱(已具备)
- [x] `/healthz` + `/readyz` 健康检查已具备readyz 含 LLM 配置状态)
- [ ] 优雅关闭 SIGTERM当前 lifespan 无显式 drainLLM 流式请求需等待完成)
- [ ] 测试覆盖率 ≥ 80%**当前 0%**,无 tests/ 目录
- [ ] Dockerfile 多阶段构建:**当前单阶段**`FROM python:3.12-slim`
- [ ] Pydantic 输入验证:**当前仅 `ChatRequest` 是 BaseModel`generate/question``optimize/expression` 直接接收 `prompt: str` / `text: str` query param无请求体模型**。阶段 2 需补完整 Pydantic 请求/响应模型
- [x] 配置通过 pydantic-settings 管理(已具备)
- [x] 异步优先httpx async + AsyncGenerator stream
- [x] 类型注解强制已基本符合main.py 函数返回值已标注)
- [ ] LLM Provider 适配器模式:**当前仅 OpenAI 兼容 REST**,未抽象 Provider 接口。阶段 2 需设计 `LLMProvider` 抽象 + OpenAI/Anthropic/百川/本地 多适配器
- [ ] Prompt 模板管理:**当前 Prompt 硬编码在 main.py**,阶段 2 需设计模板管理DB 或文件)
- [ ] 用量计费 / 频率限制:**当前无用量记录和限流**,阶段 2 需设计Kafka 事件 + Redis 限流)
- [ ] 备课工作流:**当前未实现**pending-features P5 要求"分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库"完整工作流
- [ ] ruff check 零错误:需阶段 2 验证
---
## ai06 服务审计表
> 对照 ai-allocation.md §10 审计模板。data-ana 与 ai 均为 Python/FastAPI黄金模板对齐按 Python 规范coding-standards §4评估。
> 符号说明:✅ 已实现 | ❌ 缺失 | ⚠️ 部分实现
| 服务 | 权限校验 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| -------- | -------- | ---------- | ------------ | ------------- | ------------------- | -------- | -------------------- | ------------------- | ---------- | ---------- |
| data-ana | ❌ | ❌ | ✅ structlog | ✅ prometheus | ✅ OTel | ✅ | ✅(含 CH+CDC 状态) | ⚠️ 仅 lifespan 关闭 | 0% | ❌ 单阶段 |
| ai | ❌ | ❌ | ✅ structlog | ✅ prometheus | ✅ OTeldev 跳过) | ✅ | ✅(含 LLM 状态) | ⚠️ 仅 lifespan 关闭 | 0% | ❌ 单阶段 |
### 审计发现的关键差距清单
#### data-ana 关键差距(按优先级)
1. **P0 权限校验缺失**:所有 `/analytics/*` 端点裸露,无 DataScope 过滤。学生 A 可查询学生 B 的错题本(越权风险)。阶段 2 必须设计 `Depends(require_permission)` + `Depends(inject_data_scope)` 依赖注入
2. **P0 gRPC server 未实现**analytics.proto 定义了 `AnalyticsService` 但 data-ana 当前仅 HTTP。004 §4.1 明确 BFF → 业务服务走 gRPC阶段 2 需引入 `grpc.aio` + `betterproto` 实现 gRPC server
3. **P1 事件发布缺失**`edu.insight.mastery.updated` 事件未发布,下游 core-edu/msg 无法消费。需设计掌握度计算 + Kafka producer 发布链路
4. **P1 ClickHouse schema 不规范**:当前 `student_dashboard_view` 实为 MergeTree 表(注释提到应为 ReplacingMergeTree(last_updated) 实现幂等去重),无 DDL 文件管理。阶段 2 需产出完整 ClickHouse DDL5 张宽表:考试/作业/成绩/掌握度/出勤)
5. **P2 测试覆盖率 0%**:无 tests/ 目录pytest 未配置
6. **P2 Dockerfile 单阶段**未做多阶段构建builder + runtime镜像体积大
7. **P2 掌握度计算算法缺失**:当前 `_handle_grades_event``score / 100.0` 简化,未实现 pending-features 要求的"加权滑动平均"算法
8. **P2 无 Pydantic 响应模型**:端点返回 `dict` 而非 `BaseModel`,无 `response_model` 校验
#### ai 关键差距(按优先级)
1. **P0 权限校验缺失**:所有 `/ai/*` 端点裸露AI 出题等敏感操作无权限校验
2. **P0 gRPC server 未实现**ai.proto 定义了 `AiService`(含 `StreamChat` 流式 RPC但 ai 当前仅 HTTP。阶段 2 需引入 `grpc.aio` 实现 gRPC server + 流式 RPC
3. **P0 gRPC client 未实现**004 §4.1 明确 AI → Content / AI → DataAna 走 gRPC当前未实现。阶段 2 需设计 gRPC client 调用 content / data-ana
4. **P1 LLM Provider 适配器缺失**:当前 `llm_client.py` 仅 OpenAI 兼容 REST未抽象 Provider 接口。pending-features P5 要求"LLM Provider 适配OpenAI/Anthropic/百川/本地模型)"
5. **P1 Prompt 模板管理缺失**system prompt 硬编码在 main.py无模板管理。阶段 2 需设计模板存储(文件 or DB+ 模板渲染
6. **P1 备课工作流缺失**pending-features P5 要求"分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库"完整工作流,当前仅"生成题目"单步
7. **P1 用量计费 / 频率限制缺失**无用量记录token 消耗)、无频率限制(用户可无限调用 LLM。阶段 2 需设计 Redis 限流 + Kafka 事件外发用量
8. **P2 测试覆盖率 0%**:无 tests/ 目录
9. **P2 Dockerfile 单阶段**:未做多阶段构建
10. **P2 Pydantic 输入验证不完整**`generate/question``optimize/expression` 直接接收 query param无请求体模型
---
## 阶段 1 待 coord 交叉审查的跨模块契约对齐项
以下项需 coord 在交叉审查时仲裁(见 ai-allocation.md §8
| # | 议题 | 涉及方 | 当前状态 | ai06 建议 |
| --- | -------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **data-ana 是否发布 `edu.insight.mastery.updated` 事件** | data-ana → core-edu / msg | 004 §7.2 列出此 topic但当前 data-ana 未实现发布 | 阶段 2 设计发布链路Python 无 Outbox建议直接 Kafka producer掌握度计算是派生数据非事务写 |
| 2 | **ai 是否发布 `edu.insight.ai.usage` 用量事件** | ai → data-ana | 004 §7.2 未列出此 topic | 建议 coord 新增此 topic 用于用量计费落 ClickHouse若 coord 不同意则 ai 自建用量记录表(破坏无状态原则) |
| 3 | **data-ana / ai 是否需要实现 gRPC server** | data-ana / ai ← teacher-bff / student-bff / ai | 004 §4.1 明确 BFF → 业务服务走 gRPCanalytics.proto / ai.proto 已定义 service但当前两服务仅 HTTP | 阶段 2 引入 `grpc.aio` + `betterproto` 实现 gRPC serverHTTP 端点保留作为 Gateway 直连降级通道 |
| 4 | **CDC 直连 vs Outbox 领域事件双通道** | data-ana ← core-edu | 当前 data-ana 走 Debezium CDC监听 binlog不消费 core-edu Outbox 事件events.proto | 维持 CDC 为主通道ADR-008 决策events.proto 作为业务语义补充,待 P4 后期评估是否双消费 |
| 5 | **data-ana DataScope 过滤实现位置** | data-ana + iam | 004 §5.3 DataScope 6 级,业务服务在 Repository 层注入 WHEREdata-ana 是 ClickHouse 查询无 Repository 层 | 阶段 2 在 ClickHouse 查询 SQL 拼接时注入 DataScope WHERE学生只能看自己教师看本班校管理员看本校需 iam 提供 `getEffectiveDataScope(userId)` API |
| 6 | **ai 备课工作流是否引入 Temporal** | ai + coord | 004 §2.3 列出 Temporal 用于"工作流编排考试生命周期、AI 编排)"P3 已引入 Temporal 试点 | 阶段 2 评估简单工作流4 步)可用 FastAPI BackgroundTasks复杂工作流含教师审核等待建议 Temporal待 coord 仲裁 |
| 7 | **LLM Provider 切换的配置化** | ai + coord | 当前 ai config.py 仅 OpenAI + Anthropic 字段 | 阶段 2 设计 `LLMProvider` 抽象 + 配置化路由(按 model 名路由到不同 provider本地模型走 Ollama REST API |
| 8 | **data-ana ClickHouse DDL 管理位置** | data-ana + coord | 当前无 DDL 文件,宽表手动创建 | 建议 coord 在 `infra/clickhouse/` 下建立 DDL 目录(类似 MySQL init.sqldata-ana 提供 DDL 内容 |
---
## 阶段 1 总结
ai06 已完成阶段 1 全局理解,产出本确认书。核心结论:
1. **data-ana**P4CDC 链路已跑通Debezium → Kafka → ClickHouse3 个分析端点已实现降级模式。**关键差距**权限校验、gRPC server、事件发布mastery.updated、ClickHouse DDL 规范化、掌握度算法、测试覆盖。
2. **ai**P5LLM 客户端已实现降级模式httpx 异步 + SSE 流式4 个端点已实现。**关键差距**权限校验、gRPC server + client、LLM Provider 适配器、Prompt 模板管理、备课工作流、用量计费、测试覆盖。
3. **跨模块契约**8 项待 coord 仲裁,最关键的是 gRPC server 实现决策(影响阶段 2 设计核心)和事件发布 topic 新增决策。
下一步进入阶段 2按 ai-allocation.md §7 模板产出 data-ana 与 ai 的模块架构设计文档。

View File

@@ -0,0 +1,858 @@
# ai06 阶段 2 交付物:模块架构设计文档
> AI 标识ai06
> 负责模块data-anaP4、aiP5
> 阶段:架构设计外包 · 阶段 2模块架构设计
> 日期2026-07-09
> 关联文档:[ai06 阶段 1 确认书](./ai06-phase1-understanding.md)、[ai-allocation.md](./ai-allocation.md)、[004 架构影响地图](./004_architecture_impact_map.md)、[pending-features.md](./roadmap/pending-features.md)
> 审查请求:本设计文档待 coord 按 ai-allocation.md §8 交叉审查(接口一致性 / 端口冲突 / Topic 重复 / 错误码重叠 / 黄金模板对齐)
---
## 设计原则与全局约束
本设计遵循以下强制约束(来自 project_rules.md + coding-standards.md + 004
1. **契约先行**proto 已定义analytics.proto / ai.proto实现前不修改 proto如需修改走 coord 流程
2. **CQRS 读写分离**data-ana 是纯读模型服务(无 MySQL 写ClickHouse 宽表由 CDC 投影构建
3. **事件驱动**data-ana 消费 CDC + 领域事件ai 不参与事件流(无状态)
4. **gRPC 优先**004 §4.1 明确 BFF → 业务服务走 gRPC两服务需实现 gRPC serverHTTP 保留作 Gateway 直连降级)
5. **DataScope 过滤**004 §5.3 DataScope 6 级在查询层注入 WHERE
6. **三支柱可观测**structlog + prometheus-client + OpenTelemetry已具备需补业务指标
7. **降级模式**外部依赖ClickHouse / LLM / 下游 gRPC不可用时返回骨架数据 + `degraded: true`
8. **Python 规范**pydantic-settings 配置 / Pydantic 模型校验 / async 优先 / 类型注解强制 / ruff 零错误
---
# 模块架构设计文档 — data-ana
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/analytics/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AnalyticsService]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
SCOPE[DataScopeDepends<br/>注入 data_scope 元数据]
TRACE[OTel FastAPIInstrumentor<br/>+ grpc.aio server interceptor]
end
subgraph Service["应用服务层 Application Service"]
S1[AnalyticsService<br/>班级/学生/趋势查询编排]
S2[MasteryService<br/>掌握度计算 + 事件发布]
S3[ErrorBookService<br/>错题本查询]
end
subgraph Repo["数据访问层 Repository"]
R1[ClickHouseRepository<br/>宽表查询 + DataScope WHERE 注入]
R2[KafkaProducer<br/>mastery.updated 事件发布]
R3[IamClient<br/>gRPC 调 iam.getEffectiveDataScope]
end
subgraph Consumer["CDC 消费者(后台任务)"]
C1[CdcConsumer<br/>aiokafka AIOKafkaConsumer]
C2[ExamCache<br/>exam_id→class_id 内存映射]
C3[EventHandler<br/>grades/exams/homework/classes 路由]
end
subgraph Storage["存储 / 总线"]
CH[(ClickHouse<br/>edu_analytics 库)]
KAFKA[(Kafka<br/>edu-cdc.* + edu.insight.mastery.updated)]
IAM[iam:3002 gRPC]
end
HTTP --> AUTH --> SCOPE --> S1
HTTP --> S3
GRPC --> S1
S1 --> R1
S3 --> R1
S2 --> R1
S2 --> R2
SCOPE --> R3
R1 --> CH
R2 --> KAFKA
R3 --> IAM
C1 --> C3
C3 --> C2
C3 --> R1
C1 --> KAFKA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连降级)+ gRPC主入口BFF 调用)。两入口共享同一 Application Service
- **中间件层**FastAPI Depends 链(`AuthDepends``DataScopeDepends`gRPC 用 server interceptor 注入身份元数据
- **应用服务层**:编排查询 / 计算掌握度 / 发布事件,不直接访问存储
- **数据访问层**ClickHouse 查询封装 + Kafka producer + gRPC client调 iam
- **CDC 消费者**独立后台任务lifespan 启动),与 HTTP/gRPC 入口解耦
## 2. 领域模型
data-ana 是**纯读模型服务**,不持有写聚合根。领域模型为**视图聚合**ClickHouse 物化):
### 聚合根(视图型)
| 聚合根 | 含义 | 物化载体 | 不变式 |
| ------------------ | ---------------- | ----------------------------------------- | ------------------------------------------------------------- |
| `StudentDashboard` | 学生学情宽表 | ClickHouse `student_dashboard_view` | 同一 (student_id, exam_id, knowledge_point_id) 仅保留最新版本 |
| `ClassPerformance` | 班级成绩聚合 | ClickHouse 即时聚合(不物化) | 聚合维度为 class_id + 时间窗 |
| `StudentErrorBook` | 学生错题本 | ClickHouse `student_errors` | 同一 (student_id, question_id) 累计 error_count |
| `MasterySnapshot` | 知识点掌握度快照 | ClickHouse `mastery_snapshot`**新增** | 同一 (student_id, knowledge_point_id) 保留历史版本 |
### 值对象
- `WeakPoint`knowledge_point_id + title + mastery_level
- `TrendPoint`date + score
- `DataScope`level (SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALL) + scope_ids具体可见的 class_id / grade_id 列表)
### 聚合间通信
- 同服务内直接函数调用Application Service → Repository
- 跨服务:仅通过 Kafka 事件(发布 `mastery.updated`+ gRPC调 iam 查 DataScope
## 3. 数据模型ClickHouse DDL
> DDL 文件由 coord 统一管理在 `infra/clickhouse/ddl/`(待 coord 建立data-ana 提供内容。
### 3.1 宽表 `student_dashboard_view`
```sql
-- 学生学情宽表:每次成绩写入产生一行,按 ORDER BY 去重保留最新版本
CREATE TABLE IF NOT EXISTS student_dashboard_view
(
student_id String,
class_id String,
exam_id String,
subject_id String,
score Float64,
rank_in_class UInt32,
knowledge_point_id String,
mastery_level Float32, -- 0.0-1.0
error_count UInt32,
last_updated DateTime64(3, 'UTC')
)
ENGINE = ReplacingMergeTree(last_updated)
PARTITION BY toYYYYMM(last_updated)
ORDER BY (student_id, exam_id, knowledge_point_id)
SETTINGS index_granularity = 8192;
```
**索引策略**
- ORDER BY `(student_id, exam_id, knowledge_point_id)`:主键索引,支持按学生查学情、按考试查成绩、按知识点查掌握度
- PARTITION BY `toYYYYMM(last_updated)`:按月分区,支持历史数据归档
- ReplacingMergeTree(last_updated):同 ORDER BY 自动去重,保留 last_updated 最大版本(幂等消费保证)
### 3.2 错题本 `student_errors`
```sql
CREATE TABLE IF NOT EXISTS student_errors
(
student_id String,
question_id String,
knowledge_point_id String,
error_count UInt32,
last_error_time DateTime64(3, 'UTC'),
content String
)
ENGINE = ReplacingMergeTree(last_error_time)
PARTITION BY toYYYYMM(last_error_time)
ORDER BY (student_id, question_id);
```
### 3.3 掌握度快照 `mastery_snapshot`**新增**
```sql
-- 知识点掌握度历史快照:每次掌握度计算产生新版本,支持趋势查询
CREATE TABLE IF NOT EXISTS mastery_snapshot
(
student_id String,
knowledge_point_id String,
subject_id String,
mastery_level Float32,
calculated_at DateTime64(3, 'UTC'),
calculation_method LowCardinality(String) -- 'weighted_moving_avg' / 'simple_avg'
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(calculated_at)
ORDER BY (student_id, knowledge_point_id, calculated_at);
```
### 3.4 用量计费 `ai_usage_log`**新增,供 ai 服务写入**
```sql
-- AI 用量记录ai 服务通过 Kafka 事件投递data-ana 消费落库
CREATE TABLE IF NOT EXISTS ai_usage_log
(
request_id String,
user_id String,
provider LowCardinality(String), -- 'openai' / 'anthropic' / 'baichuan' / 'local'
model LowCardinality(String),
prompt_tokens UInt32,
completion_tokens UInt32,
total_tokens UInt32,
latency_ms UInt32,
success Boolean,
occurred_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(occurred_at)
ORDER BY (user_id, occurred_at);
```
### 3.5 读写分离策略
| 操作 | 路径 | 说明 |
| -------------- | ----------------------------------- | ------------------------------------------ |
| 学情查询 | ClickHouse 宽表 | 实时聚合,亚秒级响应 |
| 错题本查询 | ClickHouse student_errors | 实时查询 |
| 掌握度趋势 | ClickHouse mastery_snapshot | 历史快照 |
| 掌握度计算 | CDC 触发 → 内存计算 → 写 ClickHouse | 派生数据,非事务写 |
| DataScope 解析 | gRPC 调 iam | 实时查询,结果 Redis 缓存 5min004 §6.3 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求 | 响应 | 说明 |
| ------ | ------------------------------------------- | ----------------------------- | ------------------------------------------------ | ----------------------------------------------------- | -------------------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, ready, degraded, clickhouse, cdc_consumer}` | readiness |
| GET | `/metrics` | — | — | Prometheus 格式 | 指标 |
| GET | `/analytics/class/{class_id}/performance` | `ANALYTICS_CLASS_READ` | query: `subject_id?`, `start_date?`, `end_date?` | `ClassPerformanceResponse` | 班级成绩分析 |
| GET | `/analytics/student/{student_id}/weakness` | `ANALYTICS_STUDENT_READ` | query: `subject_id?` | `StudentWeaknessResponse` | 学生薄弱知识点DataScope 过滤) |
| GET | `/analytics/student/{student_id}/errorbook` | `ANALYTICS_STUDENT_READ` | query: `page?`, `size?` | `StudentErrorBookResponse` | 学生错题本DataScope 过滤) |
| GET | `/analytics/student/{student_id}/trend` | `ANALYTICS_STUDENT_READ` | query: `start_date`, `end_date`, `subject_id?` | `LearningTrendResponse` | 学习趋势(**新增** |
| GET | `/analytics/dashboard/teacher/{user_id}` | `ANALYTICS_TEACHER_DASHBOARD` | query: `class_id?` | `TeacherDashboardResponse` | 教师仪表盘聚合(**新增** |
### 4.2 gRPC 契约analytics.proto待实现 server
| RPC | 请求 | 响应 | 权限 |
| --------------------- | ------------------------------------------------------------------------ | ------------------ | ------------------------ |
| `GetClassPerformance` | `GetClassPerformanceRequest{class_id, subject_id, start_date, end_date}` | `ClassPerformance` | `ANALYTICS_CLASS_READ` |
| `GetStudentWeakness` | `GetStudentWeaknessRequest{student_id, subject_id}` | `StudentWeakness` | `ANALYTICS_STUDENT_READ` |
| `GetLearningTrend` | `GetLearningTrendRequest{student_id, start_date, end_date}` | `LearningTrend` | `ANALYTICS_STUDENT_READ` |
**权限校验**gRPC server interceptor 从 metadata 提取 `x-user-id` / `x-user-roles` / `x-data-scope`,调用 `AuthDepends` 等价逻辑。
### 4.3 Pydantic 请求/响应模型
```python
# 示例:班级成绩分析响应
class ClassPerformanceResponse(BaseModel):
success: bool
data: ClassPerformanceData
degraded: bool = False
class ClassPerformanceData(BaseModel):
class_id: str
average_score: float
pass_rate: float
total_students: int
scores: list[StudentScore] = [] # 详细成绩列表(受 DataScope 过滤)
class StudentScore(BaseModel):
student_id: str
score: float
grade: str
```
## 5. 事件设计
### 5.1 消费的事件
| Topic | 来源 | 消息格式 | 消费动作 |
| ------------------------------------------------------ | ------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `edu-cdc.next_edu_cloud.core_edu_grades` | Debezium CDCcore-edu MySQL | Debezium JSONbefore/after/source/op/ts_ms | 解析 → 查 ExamCache 填 class_id → 计算掌握度 → upsert student_dashboard_view |
| `edu-cdc.next_edu_cloud.core_edu_exams` | Debezium CDC | Debezium JSON | upsert ExamCacheexam_id → class_id, subject_id |
| `edu-cdc.next_edu_cloud.core_edu_homework_submissions` | Debezium CDC**新增订阅** | Debezium JSON | 记录作业提交行为 → 更新 student_dashboard_view |
| `edu-cdc.next_edu_cloud.classes` | Debezium CDC | Debezium JSON | 同步班级维度head_teacher_id用于教师 DataScope |
| `edu-cdc.next_edu_cloud.iam_users` | Debezium CDC**新增订阅** | Debezium JSON | 同步用户 dataScope 用于查询过滤(避免每次查 iam |
| `edu.insight.ai.usage` | ai 服务 Kafka producer**待 coord 新增 topic** | JSONUsageRecord | 落 ai_usage_log 表 |
**幂等性**
- CDC 事件:依赖 ClickHouse `ReplacingMergeTree(last_updated)` 引擎按 ORDER BY 去重
- 领域事件(若消费):基于 `event_id` 去重Redis SETNXTTL 7 天)
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ---------------- | ----------------------------- | ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `MasteryUpdated` | `edu.insight.mastery.updated` | 掌握度计算完成CDC grades 事件触发后异步计算) | core-edu推荐个性化练习、msg掌握度预警 | `{event_id, student_id, knowledge_point_id, mastery_level, calculated_at}` |
**发布实现**Python 无 Outbox 模式):
- 掌握度计算是**派生数据**(非业务事务写),不需要 Outbox 保证事务一致
- 直接用 `aiokafka.AIOKafkaProducer` 发布,`idempotent=true` + 事务性 producer
- 失败重试 3 次,仍失败记录日志 + 落 `mastery_publish_failed` 本地表(待 P6 评估是否引入 Outbox
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物FastAPI Depends
```python
# 权限点常量(与 iam 权限点对齐)
class Permissions:
ANALYTICS_CLASS_READ = "analytics:class:read"
ANALYTICS_STUDENT_READ = "analytics:student:read"
ANALYTICS_TEACHER_DASHBOARD = "analytics:teacher:dashboard"
async def require_permission(permission: str) -> UserContext:
"""FastAPI Depends 权限校验.
从 x-user-id / x-user-roles 头提取身份,校验角色是否含 permission。
"""
...
async def inject_data_scope(ctx: UserContext = Depends(require_permission(...)))-> DataScope:
"""注入 DataScope从 iam.getEffectiveDataScope 查询Redis 缓存 5min."""
...
```
### 6.2 错误码清单(前缀 `DATA_ANA_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------------- | --------------------------------------- | ------------------- | ------------------ |
| `DATA_ANA_UNAUTHORIZED` | 缺失 x-user-id 头或 token 无效 | 401 | UNAUTHENTICATED |
| `DATA_ANA_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `DATA_ANA_DATASCOPE_VIOLATION` | 查询目标超出 DataScope 范围 | 403 | PERMISSION_DENIED |
| `DATA_ANA_CLICKHOUSE_UNAVAILABLE` | ClickHouse 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `DATA_ANA_INVALID_DATE_RANGE` | start_date > end_date | 400 | INVALID_ARGUMENT |
| `DATA_ANA_STUDENT_NOT_FOUND` | student_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_CLASS_NOT_FOUND` | class_id 不存在 | 404 | NOT_FOUND |
| `DATA_ANA_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py` `init_logger()`(已具备)
- 配置:`structlog.make_filtering_bound_logger(level)` + `TimeStamper(fmt="iso")` + `ConsoleRenderer`
- **改进**:生产环境改用 `structlog.processors.JSONRenderer()`(当前 ConsoleRenderer 适合开发)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| --------------------------------------------- | --------- | -------------------- | -------------------------- |
| `data_ana_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `data_ana_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `data_ana_clickhouse_query_duration_seconds` | Histogram | query_type | ClickHouse 查询延迟 |
| `data_ana_clickhouse_query_total` | Counter | query_type, status | ClickHouse 查询总数 |
| `data_ana_cdc_events_consumed_total` | Counter | table, op | CDC 事件消费总数 |
| `data_ana_cdc_event_process_duration_seconds` | Histogram | table | CDC 事件处理延迟 |
| `data_ana_cdc_consumer_lag` | Gauge | topic, partition | CDC 消费者 lag |
| `data_ana_mastery_calculated_total` | Counter | — | 掌握度计算次数 |
| `data_ana_mastery_published_total` | Counter | status | mastery.updated 事件发布数 |
| `data_ana_datascope_cache_hits_total` | Counter | — | DataScope 缓存命中 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`(已具备)
- endpoint`settings.otel_endpoint` + `/v1/traces`
- **改进**gRPC server 注册 `grpc.aio.ServerInterceptor` 透传 W3C trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok" if all_ready else "degraded",
"ready": all_ready,
"degraded": not all_ready,
"clickhouse": "ok" | "unreachable" | "not_configured",
"cdc_consumer": "running" | "disabled" | "failed",
"kafka_brokers": settings.kafka_brokers or None,
"iam_grpc": "ok" | "unreachable", # 新增iam gRPC 连通性
"timestamp": datetime.now(UTC).isoformat(),
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requestsuvicorn graceful shutdown
2. gRPC server graceful stop等待在途 RPC 完成30s 超时)
3. CDC consumer stop等待在途消息处理完成commit offset
4. Kafka producer flush + close确保 mastery.updated 事件已投递)
5. ClickHouse client close
6. iam gRPC channel close
**信号处理**:注册 `signal.SIGTERM` handler触发上述顺序。
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------------------- | ----- | ------------------------------------------------------------------- | ----------------------------- |
| 被调用 | api-gateway | HTTP | `/analytics/*` | Gateway 代理 |
| 被调用 | teacher-bff / student-bff | gRPC | `AnalyticsService.*` | BFF 聚合查询 |
| 被调用 | ai | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | AI 个性化出题上下文 |
| 调用 | iam | gRPC | `IamService.GetEffectiveDataScope`**待 proto 新增** | DataScope 解析 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.core_edu_grades/exams/homework_submissions` | 学情数据投递 |
| 消费 | core-eduCDC | Kafka | `edu-cdc.next_edu_cloud.classes` | 班级维度同步 |
| 消费 | iamCDC | Kafka | `edu-cdc.next_edu_cloud.iam_users` | 用户 dataScope 同步 |
| 消费 | ai | Kafka | `edu.insight.ai.usage`**待 coord 新增** | AI 用量落库 |
| 发布 | — | Kafka | `edu.insight.mastery.updated` | 掌握度更新通知 core-edu / msg |
## 8. 风险与假设
### 8.1 假设
- **假设 1**iam 提供 `GetEffectiveDataScope(userId) → DataScope` gRPC API。若 iam 未提供fallback 为:从 `x-user-roles` 头推导admin=ALL, teacher=CLASS_TAUGHT, student=SELF但无法支持细粒度年级/学校范围
- **假设 2**core-edu 的 `core_edu_homework_submissions` 表存在 binlog。若不存在作业相关学情无法通过 CDC 获取,需 core-edu 补表或走 Outbox 事件
- **假设 3**ClickHouse `ReplacingMergeTree` 在查询时需 `FINAL` 关键字确保去重生效。当前查询未加 `FINAL`,可能读到重复版本。**修复**:所有查询加 `FINAL` 或使用 `argMax` 聚合
- **假设 4**coord 同意新增 `edu.insight.ai.usage` topic。若不同意ai 服务的用量计费需自建记录(破坏 ai 无状态原则)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------------------------ | ------------------------------------------------ |
| ClickHouse 查询延迟超 5s | 违反 P4 退出标准 | 宽表索引优化 + 物化视图预聚合 + 查询超时 3s 降级 |
| CDC 消费者 lag 过大 | 学情数据延迟 > 5s | 监控 lag + 告警 + 水平扩展消费者(分区数提升) |
| ExamCache 内存泄漏 | 长期运行 OOM | LRU 淘汰策略max 10000 条)+ 定期清理过期 exam |
| mastery.updated 事件丢失 | 下游 core-edu/msg 收不到通知 | Kafka producer `acks=all` + 本地失败表重试 |
| iam gRPC 不可达 | DataScope 无法解析 → 查询降级为 SELF | Redis 缓存 5min + fallback SELF 范围(最保守) |
| ClickHouse `FINAL` 查询性能 | 查询变慢 | 使用 `argMax` 替代 `FINAL`,或在写入时去重 |
### 8.3 未决设计决策(需 coord 仲裁)
1. **mastery.updated 发布是否需要 Outbox**Python 无 Outbox 模式,建议直接 producer但 004 §12.2 明确"事件发布Outbox 模式 / 禁止直接 Kafka producer"。**冲突**data-ana 是 Python 服务无 MySQL 写事务Outbox 不适用。建议 coord 裁定:派生数据事件(非业务事务)允许直接 producer
2. **iam GetEffectiveDataScope proto 新增**:当前 iam.proto 仅有 `GetUserInfo`,无 DataScope 解析 API。需 coord 在 shared-proto 新增 `GetEffectiveDataScope` RPC
3. **edu.insight.ai.usage topic 新增**004 §7.2 未列出,需 coord 确认是否新增
---
# 模块架构设计文档 — ai
## 1. 模块内部分层图
```mermaid
flowchart TB
subgraph Entry["入口层"]
HTTP[FastAPI HTTP Router<br/>/ai/* + /healthz + /readyz]
GRPC[grpc.aio Server<br/>AiService 含 StreamChat]
end
subgraph Middleware["中间件层"]
AUTH[AuthDepends<br/>校验 x-user-id / x-user-roles]
RATE[RateLimitDepends<br/>Redis 令牌桶限流]
TRACE[OTel + grpc interceptor]
end
subgraph Service["应用服务层"]
S1[ChatService<br/>聊天编排 + Prompt 模板渲染]
S2[QuestionGenerationService<br/>出题工作流编排]
S3[ExpressionOptimizationService<br/>表达优化]
S4[LessonPreparationWorkflow<br/>备课工作流 4 步编排]
end
subgraph Provider["LLM Provider 适配层"]
P0[LLMProvider 抽象接口<br/>chat / stream_chat]
P1[OpenAIProvider<br/>httpx 异步]
P2[AnthropicProvider<br/>httpx 异步]
P3[BaichuanProvider<br/>httpx 异步]
P4[LocalOllamaProvider<br/>httpx 异步]
end
subgraph Template["Prompt 模板管理"]
T1[PromptTemplateRegistry<br/>模板注册 + 渲染]
T2[模板存储<br/>YAML 文件 / DB]
end
subgraph Client["下游 gRPC client"]
C1[ContentClient<br/>查询知识点 / 题库]
C2[DataAnaClient<br/>查询学情 / 薄弱点]
end
subgraph Usage["用量计费"]
U1[UsageRecorder<br/>token 消耗统计]
U2[KafkaProducer<br/>发布 edu.insight.ai.usage]
end
subgraph External["外部 / 存储"]
LLM[LLM Provider API<br/>OpenAI/Anthropic/百川/Ollama]
CONTENT[content:3005 gRPC]
DATAANA[data-ana:3006 gRPC]
KAFKA[(Kafka)]
REDIS[(Redis<br/>限流 + 缓存)]
end
HTTP --> AUTH --> RATE --> S1
HTTP --> S2
HTTP --> S3
GRPC --> S1
GRPC --> S2
S2 --> S4
S4 --> C1
S4 --> C2
S1 --> T1
S2 --> T1
S1 --> P0
S2 --> P0
P0 --> P1
P0 --> P2
P0 --> P3
P0 --> P4
P1 --> LLM
P2 --> LLM
P3 --> LLM
P4 --> LLM
S1 --> U1
S2 --> U1
U1 --> U2
U2 --> KAFKA
RATE --> REDIS
C1 --> CONTENT
C2 --> DATAANA
```
**分层规则**
- **入口层**HTTP保留作 Gateway 直连)+ gRPC主入口`StreamChat` 流式 RPC
- **中间件层**Auth + RateLimitRedis 令牌桶,按 user_id 限流)
- **应用服务层**4 个 Service每个对应一类 AI 能力
- **Provider 适配层**:抽象 `LLMProvider` 接口,多适配器实现(策略模式)
- **Prompt 模板**:模板注册 + 渲染模板存储可配置YAML 文件 or DB
- **下游 client**gRPC 调 content / data-ana
- **用量计费**token 消耗统计 + Kafka 事件外发
## 2. 领域模型
ai 是**无状态服务**,不持有持久化聚合根。领域模型为**请求/响应模型 + 工作流编排**
### 聚合根(请求型,无持久化)
| 聚合根 | 含义 | 生命周期 |
| ---------------------------- | ------------ | ------------------------------------------------------------ |
| `ChatConversation` | 单次聊天请求 | 单次请求 |
| `QuestionGenerationTask` | 出题任务 | 单次请求(备课工作流中多步) |
| `ExpressionOptimizationTask` | 表达优化任务 | 单次请求 |
| `LessonPreparationWorkflow` | 备课工作流 | 跨多步(分析学情 → 推荐知识点 → 生成题目 → 教师审核 → 入库) |
### 值对象
- `ChatMessage`role + content
- `Usage`prompt_tokens + completion_tokens + total_tokens
- `GeneratedQuestion`question + answer + explanation
- `PromptTemplate`name + system_prompt + user_template + variables
### 工作流编排(备课)
```mermaid
sequenceDiagram
participant T as 教师
participant BFF as teacher-bff
participant AI as ai 服务
participant Content as content
participant DA as data-ana
T->>BFF: 请求备课class_id, subject_id
BFF->>AI: gRPC GenerateLessonPlan
AI->>DA: gRPC GetStudentWeakness(class_id)
DA-->>AI: 薄弱知识点列表
AI->>Content: gRPC GetPrerequisites(knowledge_point_id)
Content-->>AI: 前置依赖知识点
AI->>AI: LLM 生成题目(基于学情 + 知识点)
AI-->>BFF: 题目列表 + 推荐理由
BFF-->>T: 题目供审核
T->>BFF: 确认入库
BFF->>Content: gRPC CreateQuestions
Content-->>BFF: 入库成功
```
**工作流实现**
- **简单场景**4 步内FastAPI BackgroundTasks + asyncio.gather 并行查询
- **复杂场景**(含教师审核等待):待 coord 仲裁是否引入 Temporal004 §2.3 列出 Temporal 用于 AI 编排)
## 3. 数据模型
ai 服务**无独占数据库**,无 MySQL schema。所有数据通过 Kafka 事件外发(用量计费)或 gRPC 查询下游。
### 用量计费Kafka 事件 → data-ana 落 ClickHouse
```json
{
"event_id": "uuid",
"user_id": "user-xxx",
"request_id": "req-xxx",
"provider": "openai",
"model": "gpt-4o-mini",
"prompt_tokens": 150,
"completion_tokens": 80,
"total_tokens": 230,
"latency_ms": 1200,
"success": true,
"occurred_at": "2026-07-09T12:00:00Z"
}
```
**Topic**`edu.insight.ai.usage`**待 coord 新增**
### 缓存策略
| 数据 | 存储 | TTL | 失效策略 |
| -------------------------------------- | ----------------------------- | -------- | ---------------------- |
| Prompt 模板 | Redis模板变更事件驱动失效 | 1 小时 | 文件/DB 变更时主动失效 |
| LLM 响应(相同 prompt | Redishash 缓存) | 30 分钟 | 短 TTL避免陈旧 |
| DataScopeai 不需要,仅 data-ana 用) | — | — | — |
| 限流计数 | Redis 令牌桶 | 滑动窗口 | 自动过期 |
## 4. API 设计
### 4.1 HTTP 端点(保留作 Gateway 直连降级)
| method | path | 权限 | 请求体 | 响应 | 说明 |
| ------ | ------------------------- | ------------------------ | --------------------------- | ------------------------------------ | ---------------------- |
| GET | `/healthz` | — | — | `{status, service}` | liveness |
| GET | `/readyz` | — | — | `{status, llm_configured, degraded}` | readiness |
| GET | `/metrics` | — | — | Prometheus | 指标 |
| POST | `/ai/chat` | `AI_CHAT` | `ChatRequest` | `ChatResponse` | LLM 聊天 |
| POST | `/ai/chat/stream` | `AI_CHAT` | `ChatRequest` | SSE stream | 流式聊天 |
| POST | `/ai/generate/question` | `AI_QUESTION_GENERATE` | `GenerateQuestionRequest` | `GeneratedQuestionResponse` | 生成题目 |
| POST | `/ai/optimize/expression` | `AI_EXPRESSION_OPTIMIZE` | `OptimizeExpressionRequest` | `OptimizedExpressionResponse` | 优化表达 |
| POST | `/ai/lesson/preparation` | `AI_LESSON_PREPARE` | `LessonPreparationRequest` | `LessonPreparationResponse` | 备课工作流(**新增** |
### 4.2 gRPC 契约ai.proto待实现 server
| RPC | 请求 | 响应 | 权限 | 说明 |
| -------------------- | ------------------------------------------------------ | ------------------------------------- | ------------------------ | ------------------------- |
| `Chat` | `ChatRequest{messages, model, temperature}` | `ChatResponse{content, model, usage}` | `AI_CHAT` | 非流式聊天 |
| `StreamChat` | `ChatRequest` | `stream ChatChunk` | `AI_CHAT` | 流式聊天SSE over gRPC |
| `GenerateQuestion` | `GenerateQuestionRequest{prompt, subject, difficulty}` | `GeneratedQuestion` | `AI_QUESTION_GENERATE` | 生成题目 |
| `OptimizeExpression` | `OptimizeExpressionRequest{text, context}` | `OptimizedExpression` | `AI_EXPRESSION_OPTIMIZE` | 优化表达 |
### 4.3 Pydantic 请求/响应模型
```python
class ChatRequest(BaseModel):
messages: list[ChatMessage]
model: str = "gpt-4o-mini"
temperature: float = Field(0.7, ge=0.0, le=2.0)
stream: bool = False
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class ChatResponse(BaseModel):
success: bool
data: ChatData
degraded: bool = False
class ChatData(BaseModel):
content: str
model: str
usage: Usage
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class GenerateQuestionRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=2000)
subject: str
difficulty: Literal["easy", "medium", "hard"]
knowledge_point_ids: list[str] = [] # 可选:靶向知识点
class GeneratedQuestionResponse(BaseModel):
success: bool
data: GeneratedQuestionData
degraded: bool = False
```
## 5. 事件设计
### 5.1 消费的事件
ai 服务**不消费任何事件**(无状态,纯请求-响应)。
### 5.2 发布的事件
| 事件 | Topic | 触发时机 | 消费者 | Payload |
| ----------------- | ------------------------------------------- | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `AIUsageRecorded` | `edu.insight.ai.usage`**待 coord 新增** | 每次 LLM 调用完成 | data-ana落 ClickHouse ai_usage_log | `{event_id, user_id, request_id, provider, model, prompt_tokens, completion_tokens, total_tokens, latency_ms, success, occurred_at}` |
**发布实现**
- 每次 LLM 调用后异步发布(不阻塞响应)
- `aiokafka.AIOKafkaProducer` + `acks=all`
- 失败重试 3 次,仍失败记录日志(不影响主流程)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器等价物
```python
class Permissions:
AI_CHAT = "ai:chat"
AI_QUESTION_GENERATE = "ai:question:generate"
AI_EXPRESSION_OPTIMIZE = "ai:expression:optimize"
AI_LESSON_PREPARE = "ai:lesson:prepare"
async def require_permission(permission: str) -> UserContext:
"""从 x-user-id / x-user-roles 校验权限."""
...
```
### 6.2 错误码清单(前缀 `AI_*`
| 错误码 | 触发条件 | HTTP | gRPC status |
| --------------------------- | ----------------------------------------- | ------------------- | ------------------ |
| `AI_UNAUTHORIZED` | 缺失 x-user-id 或 token 无效 | 401 | UNAUTHENTICATED |
| `AI_FORBIDDEN` | 角色无对应权限 | 403 | PERMISSION_DENIED |
| `AI_RATE_LIMITED` | 触发限流 | 429 | RESOURCE_EXHAUSTED |
| `AI_LLM_UNAVAILABLE` | LLM Provider 不可达(降级模式仍返回骨架) | 200 + degraded:true | OK + degraded flag |
| `AI_LLM_TIMEOUT` | LLM 调用超时30s | 504 | DEADLINE_EXCEEDED |
| `AI_INVALID_MODEL` | model 名不支持 | 400 | INVALID_ARGUMENT |
| `AI_INVALID_DIFFICULTY` | difficulty 不在 easy/medium/hard | 400 | INVALID_ARGUMENT |
| `AI_DOWNSTREAM_UNAVAILABLE` | content / data-ana gRPC 不可达 | 502 | UNAVAILABLE |
| `AI_PROMPT_RENDER_FAILED` | Prompt 模板渲染失败(变量缺失) | 500 | INTERNAL |
| `AI_INTERNAL_ERROR` | 未捕获异常 | 500 | INTERNAL |
### 6.3 Logger 初始化
- 位置:`main.py`(已具备)
- **改进**:生产环境改用 `JSONRenderer`
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ---------------------------------- | --------- | ----------------------------------------- | ------------------ |
| `ai_http_requests_total` | Counter | method, path, status | HTTP 请求总数 |
| `ai_http_request_duration_seconds` | Histogram | method, path | HTTP 请求延迟 |
| `ai_llm_calls_total` | Counter | provider, model, status | LLM 调用总数 |
| `ai_llm_call_duration_seconds` | Histogram | provider, model | LLM 调用延迟 |
| `ai_llm_tokens_total` | Counter | provider, model, type (prompt/completion) | token 消耗总数 |
| `ai_llm_stream_chunks_total` | Counter | provider, model | 流式 chunk 总数 |
| `ai_grpc_calls_total` | Counter | downstream, method, status | 下游 gRPC 调用总数 |
| `ai_rate_limit_hits_total` | Counter | user_id | 限流命中次数 |
| `ai_usage_events_published_total` | Counter | status | 用量事件发布数 |
| `ai_prompt_template_renders_total` | Counter | template_name, status | 模板渲染次数 |
### 6.5 Tracer 初始化
- 位置:`main.py` `init_tracer()`已具备dev_mode 跳过)
- **改进**gRPC server interceptor + 下游 gRPC client interceptor 透传 trace context
### 6.6 /healthz 检查逻辑
- liveness仅进程存活已具备
### 6.7 /readyz 检查逻辑
```python
async def readyz() -> dict:
return {
"status": "ok",
"service": "ai",
"llm_configured": settings.llm_available,
"degraded": not settings.llm_available,
"providers": {
"openai": bool(settings.openai_api_key),
"anthropic": bool(settings.anthropic_api_key),
"baichuan": bool(settings.baichuan_api_key),
"local_ollama": bool(settings.ollama_base_url),
},
"downstream_grpc": {
"content": "ok" | "unreachable", # 新增
"data_ana": "ok" | "unreachable", # 新增
},
"redis": "ok" | "unreachable", # 新增(限流依赖)
}
```
### 6.8 优雅关闭顺序
1. HTTP server stop accepting new requests
2. gRPC server graceful stop**关键**:等待在途 `StreamChat` 流式 RPC 完成60s 超时,避免截断用户响应)
3. LLM 流式请求 drain等待 httpx stream 完成)
4. Kafka producer flush + close确保用量事件已投递
5. 下游 gRPC channels closecontent / data-ana
6. Redis connection close
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------ | ----- | ---------------------------------------------------------- | ------------------ |
| 被调用 | api-gateway | HTTP | `/ai/*` | Gateway 代理 |
| 被调用 | teacher-bff | gRPC | `AiService.*` | BFF 聚合 AI 能力 |
| 调用 | content | gRPC | `KnowledgeGraphService.GetPrerequisites / GetLearningPath` | 出题上下文查询 |
| 调用 | content | gRPC | `TextbookService.*`(若需教材上下文) | 出题教材关联 |
| 调用 | data-ana | gRPC | `AnalyticsService.GetStudentWeakness / GetLearningTrend` | 个性化出题学情查询 |
| 调用 | LLM Provider | HTTP | OpenAI 兼容 REST `/chat/completions` | LLM 推理 |
| 发布 | — | Kafka | `edu.insight.ai.usage`**待 coord 新增** | 用量计费外发 |
## 8. 风险与假设
### 8.1 假设
- **假设 1**content 服务实现了 `KnowledgeGraphService` gRPC server。当前 content.proto 已定义但未实现 gRPC serverai05 阶段 2 设计中。ai 调用前需确认 content gRPC 可用
- **假设 2**data-ana 实现了 `AnalyticsService` gRPC server本设计文档已设计。ai 调用前需确认 data-ana gRPC 可用
- **假设 3**coord 同意新增 `edu.insight.ai.usage` topic。若不同意用量计费降级为 ai 本地日志(不落 ClickHouse影响成本分析
- **假设 4**Redis 可用(限流依赖)。若 Redis 不可用,限流降级为"无限制"风险LLM 成本失控),或降级为内存令牌桶(单实例有效,多实例不一致)
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| ---------------------- | ---------------- | --------------------------------------------------------------- |
| LLM 调用延迟高(>30s | 用户体验差 | 超时 30s + 降级骨架响应 + 流式优先(用户感知首字延迟) |
| LLM 成本失控 | 财务风险 | Redis 令牌桶限流(每用户每分钟 10 次)+ 用量计费监控 + 告警阈值 |
| LLM Provider 单点故障 | 服务不可用 | 多 Provider 适配器 + 自动 failoverOpenAI 失败切 Anthropic |
| 流式 RPC 中断 | 用户响应截断 | gRPC server graceful shutdown 60s drain + 客户端重连机制 |
| Prompt 注入攻击 | LLM 输出恶意内容 | 输入 sanitize + system prompt 加安全约束 + 输出过滤 |
| 下游 gRPC 不可达 | 备课工作流失败 | 降级:跳过学情查询,仅基于 prompt 生成题目 + `degraded: true` |
### 8.3 未决设计决策(需 coord 仲裁)
1. **备课工作流是否引入 Temporal**004 §2.3 列出 Temporal 用于 AI 编排,但简单 4 步工作流可用 FastAPI BackgroundTasks。建议M14 用 BackgroundTasksM15 评估是否迁移 Temporal
2. **`edu.insight.ai.usage` topic 新增**:需 coord 在 shared-proto events.proto 新增 `AIUsageEvent` message + 004 §7.2 新增 topic
3. **LLM Provider 配置化路由**:是否在 shared-py 建立通用 `LLMProvider` 抽象(供未来其他 Python 服务复用)。建议 P5 阶段在 ai 服务内部实现P6 评估是否提取到 shared-py
4. **Prompt 模板存储位置**YAML 文件(简单,无 DBvs DB动态更新。建议 P5 用 YAML 文件(`services/ai/src/ai/prompts/*.yaml`P6 评估迁移 DB
---
# 阶段 2 总结
ai06 已完成阶段 2 模块架构设计,产出 data-ana 与 ai 两份设计文档。核心设计决策:
## data-ana 设计要点
1. **分层**HTTP + gRPC 双入口共享 Application ServiceCDC 消费者独立后台任务
2. **数据模型**4 张 ClickHouse 宽表student_dashboard_view / student_errors / mastery_snapshot / ai_usage_logReplacingMergeTree 引擎保证幂等
3. **权限**FastAPI Depends 链require_permission + inject_data_scopeDataScope WHERE 注入 ClickHouse 查询
4. **事件**:消费 6 个 CDC topic + 发布 `edu.insight.mastery.updated`(直接 producer非 Outbox因派生数据非事务写
5. **gRPC**:实现 `AnalyticsService` serveranalytics.proto 已定义)
6. **降级**ClickHouse 不可达返回骨架 + degraded:trueiam gRPC 不可达降级为 SELF DataScope
## ai 设计要点
1. **分层**HTTP + gRPC 双入口LLM Provider 适配层策略模式4 适配器)
2. **无状态**:无 DB用量计费通过 Kafka 事件外发
3. **权限**FastAPI Depends + Redis 令牌桶限流(每用户每分钟 10 次)
4. **工作流**:备课 4 步编排(学情查询 → 知识点推荐 → 题目生成 → 教师审核入库),简单场景用 BackgroundTasks
5. **gRPC**:实现 `AiService` server`StreamChat` 流式 RPCgRPC client 调 content / data-ana
6. **降级**LLM 不可达返回骨架 + degraded:true下游 gRPC 不可达降级跳过
## 待 coord 交叉审查项(汇总)
| # | 议题 | 涉及文档 |
| --- | ------------------------------------------------------------------------------------ | ----------------------- |
| 1 | data-ana 发布 `edu.insight.mastery.updated` 用直接 producer非 Outbox是否合规 | 004 §12.2 |
| 2 | 新增 `edu.insight.ai.usage` topic + `AIUsageEvent` proto message | 004 §7.2 + events.proto |
| 3 | iam 新增 `GetEffectiveDataScope` gRPC RPC | iam.proto |
| 4 | data-ana / ai 实现 gRPC server 决策 | 004 §4.1 |
| 5 | ClickHouse DDL 管理位置(建议 `infra/clickhouse/ddl/` | infra/ |
| 6 | ai 备课工作流是否引入 Temporal | 004 §2.3 |
| 7 | 端口冲突检查data-ana 3006 / ai 3008无冲突 | full-stack-runbook |
| 8 | 错误码前缀检查:`DATA_ANA_*` / `AI_*`(与其他服务不重叠) | — |
| 9 | 黄金模板对齐Python 服务无 NestJS 装饰器,权限校验用 FastAPI Depends 等价物是否认可 | — |
下一步:等待 coord 交叉审查通过后,进入阶段 3按图实施

View File

@@ -37,36 +37,171 @@
--- ---
## 2. 模块分工矩阵 ## 2. 两种协作模式
### 2.1 模块清单与 AI 分配 本项目支持两种 AI 协作模式,根据开发阶段灵活切换:
每个 AI 负责一个"限界上下文",避免跨模块修改导致冲突。 ### 2.0 模式选择
| 模 | scope | 路径 | 负责阶段 | 建议 AI 数 | | 模 | 适用阶段 | 特点 |
| -------------- | ---------------- | ------------------------- | -------- | ------------ | | ------------------ | ---------------------------------- | ----------------------------------------------------- |
| api-gateway | `api-gateway` | `services/api-gateway/` | P1 | 1 | | **单仓库并行模式** | 架构设计外包、各服务独立功能开发 | 无需 PR直接 push main路径级别物理隔离几乎零冲突 |
| classes | `classes` | `services/classes/` | P1 | 1 | | **PR 模式** | 共享文件修改、跨模块变更、代码审核 | 标准 PR 流程coord 审核后 Squash Merge |
| teacher-portal | `teacher-portal` | `apps/teacher-portal/` | P1-P2 | 1 |
| iam | `iam` | `services/iam/` | P2 | 1 |
| teacher-bff | `teacher-bff` | `services/teacher-bff/` | P2 | 1 |
| core-edu | `core-edu` | `services/core-edu/` | P3 | 1 |
| content | `content` | `services/content/` | P4 | 1 |
| data-ana | `data-ana` | `services/data-ana/` | P4 | 1 |
| msg | `msg` | `services/msg/` | P5 | 1 |
| ai | `ai` | `services/ai/` | P5 | 1 |
| push-gateway | `push-gateway` | `services/push-gateway/` | P5 | 1 |
| shared-proto | `shared-proto` | `packages/shared-proto/` | 跨阶段 | 协调 AI 维护 |
| shared-tokens | `shared-tokens` | `packages/shared-tokens/` | 跨阶段 | 协调 AI 维护 |
| infra | `infra` | `infra/` | 跨阶段 | 1 (SRE AI) |
| docs | `docs` | `docs/` | 跨阶段 | 协调 AI 维护 |
### 2.2 分工原则 > 当前架构设计外包阶段使用**单仓库并行模式**,详见 [AI 分配方案](../architecture/ai-allocation.md)。
---
### 2.1 单仓库并行模式
**适用场景**:各 AI 修改的文件路径物理隔离(不同 `services/<name>/` 目录),无需 PR 审核。
**核心规则**
1. 每个 AI 只能修改自己负责的目录
2. `pnpm-lock.yaml` 是唯一可能冲突的共享文件,冲突时取远程版本后 `pnpm install` 重新生成
3. proto 变更由 coord 统一管理(其他 AI 只读引用 `packages/shared-proto/`
**提交流程**
```bash
# 每天开始
git pull origin main --rebase
# 在自己目录内工作后提交
git add services/<my-service>/...
git commit -m "feat(<scope>): <描述>"
# 直接 push
git pull origin main --rebase # 先拉最新
git push
```
**pnpm-lock.yaml 冲突处理**
```bash
git pull origin main --rebase
# 若 pnpm-lock.yaml 冲突:
git checkout --theirs pnpm-lock.yaml
pnpm install
git add pnpm-lock.yaml
git rebase --continue
```
---
### 2.2 PR 模式(标准流程)
PR 模式见 §3-§6分支策略、推送流程、PR 流程、审核合并)。
---
### 2.3 模块完整清单
| 类别 | 服务名 | scope | 路径 | 阶段 | 语言 |
| ---- | -------------- | ---------------- | ------------------------ | ------ | -------- |
| 网关 | api-gateway | `api-gateway` | `services/api-gateway/` | P1 | Go |
| 网关 | push-gateway | `push-gateway` | `services/push-gateway/` | P5 | Go |
| BFF | teacher-bff | `teacher-bff` | `services/teacher-bff/` | P2 | TS |
| BFF | student-bff | `student-bff` | `services/student-bff/` | P3 | TS |
| BFF | parent-bff | `parent-bff` | `services/parent-bff/` | P4 | TS |
| 业务 | iam | `iam` | `services/iam/` | P2 | TS |
| 业务 | core-edu | `core-edu` | `services/core-edu/` | P3 | TS |
| 业务 | content | `content` | `services/content/` | P4 | TS |
| 业务 | msg | `msg` | `services/msg/` | P5 | TS |
| 业务 | data-ana | `data-ana` | `services/data-ana/` | P4 | Python |
| 业务 | ai | `ai` | `services/ai/` | P5 | Python |
| 前端 | teacher-portal | `teacher-portal` | `apps/teacher-portal/` | P2 | TS |
| 前端 | student-portal | `student-portal` | `apps/student-portal/` | P3 | TS |
| 前端 | parent-portal | `parent-portal` | `apps/parent-portal/` | P4 | TS |
| 前端 | admin-portal | `admin-portal` | `apps/admin-portal/` | P6 | TS |
| 共享 | shared-proto | `shared-proto` | `packages/shared-proto/` | 跨阶段 | protobuf |
| 共享 | shared-ts | `shared-ts` | `packages/shared-ts/` | 跨阶段 | TS |
| 共享 | shared-go | `shared-go` | `packages/shared-go/` | 跨阶段 | Go |
| 共享 | shared-py | `shared-py` | `packages/shared-py/` | 跨阶段 | Python |
| 基础 | infra | `infra` | `infra/` | 跨阶段 | — |
### 2.4 当前阶段 AI 分配7 AI + 1 coord
详见 [AI 分配方案](../architecture/ai-allocation.md#3-ai-分配方案7-ai--1-coord)。摘要如下:
| AI | 服务 | 语言 |
| ----- | ----------------------------------------------------------- | ------ |
| ai01 | api-gateway、push-gateway | Go |
| ai02 | iam | TS |
| ai03 | teacher-bff、core-edu | TS |
| ai04 | student-bff、parent-bff | TS |
| ai05 | content、msg | TS |
| ai06 | data-ana、ai | Python |
| ai07 | teacher-portal、student-portal、parent-portal、admin-portal | TS |
| coord | shared-proto、shared-*、infra/、docs/、CI/CD | — |
### 2.5 分工原则
1. **单一负责制**:每个模块只有一个 AI 负责,避免并行修改同一文件 1. **单一负责制**:每个模块只有一个 AI 负责,避免并行修改同一文件
2. **契约集中管理**`shared-proto` 由协调 AI 维护,开发 AI 只读引用 2. **同语言内聚**:一个 AI 负责多个同语言服务,降低学习成本
3. **跨模块变更拆分**:需要修改多个模块时,拆成多个 PR按依赖顺序合并 3. **契约集中管理**`shared-proto` 由 coord 维护,开发 AI 只读引用
4. **基础设施独立**`infra/` 由 SRE AI 专门负责,业务 AI 不直接修改 4. **跨模块变更拆分**需要修改多个模块时按依赖顺序proto → service → gateway → BFF → frontend
5. **基础设施独立**`infra/` 由 coord或 SRE AI专门负责
6. **物理路径隔离**:目录级别隔离,单仓库并行几乎零文件冲突
### 2.6 架构设计外包三阶段
当前项目处于**架构设计外包阶段**,所有 AI 需先完成设计再动手写代码。完整文档见 [AI 分配方案](../architecture/ai-allocation.md)。简述如下:
```
阶段 1全局理解 → 交付"理解确认书"
阶段 2模块架构设计 → 交付"模块架构设计文档"coord 交叉审查
阶段 3按图实施 → 按设计文档写代码coord 定期巡检一致性
```
**阶段 1 交付物模板**
```markdown
## 模块理解确认书 — [模块名]
### 1. 我在架构中的位置
- 层级 / 上下游 / 通信方式
### 2. 我的限界上下文
- 聚合/实体 / 业务领域D1-D6/ 边界外
### 3. 我与外部的契约
- proto message / API/事件 / 错误码前缀
### 4. 我的技术栈
### 5. 我的阶段归属P1-P6
### 6. 黄金模板对齐清单(对照 classes 服务)
```
**阶段 2 交付物模板**
```markdown
## 模块架构设计文档 — [模块名]
### 1. 模块内部分层图
### 2. 领域模型(聚合根/实体/值对象)
### 3. 数据模型(表/schema/索引/读写分离)
### 4. API 设计(端点/权限/请求响应)
### 5. 事件设计(发布/消费/Topic
### 6. 横切关注点(权限/错误/可观测/健康/优雅关闭)
### 7. 与其他模块的交互点(契约清单)
### 8. 风险与假设
```
**coord 交叉审查**:收到全部 7 份设计文档后检查接口一致性、端口冲突、Topic 重复、错误码重叠、黄金模板对齐。
--- ---
@@ -726,6 +861,7 @@ git tag -a v<version> -m "..." && git push origin v<version>
## 15. 相关文档 ## 15. 相关文档
- [AI 分配方案](../architecture/ai-allocation.md) — 架构设计外包 AI 分配与三阶段流程
- [Git 工作流](./git-workflow.md) — 提交规范、分支策略、CODEOWNERS - [Git 工作流](./git-workflow.md) — 提交规范、分支策略、CODEOWNERS
- [本地启动手册](./local-dev-runbook.md) — 手动启动服务 - [本地启动手册](./local-dev-runbook.md) — 手动启动服务
- [项目规则](../../.trae/rules/project_rules.md) — 强制约束 - [项目规则](../../.trae/rules/project_rules.md) — 强制约束

View File

@@ -358,30 +358,32 @@
> 按时间倒序50 条上限。AI 发现更好方案时可更新本节。 > 按时间倒序50 条上限。AI 发现更好方案时可更新本节。
| 日期 | 时间 | 模块 | 做了什么 + 学到什么 | | 日期 | 时间 | 模块 | 做了什么 + 学到什么 |
| ---------- | ---- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------- | ---- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-09 | 夜间 | teacher-portal/前端4端ai07 | **ai07 阶段 1+2 交付(前端 4 端**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板源码controller/service/repository/schema/dto+ shared-proto 8 份 proto + ai-allocation.md §5 ai07 分工。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 4 端模块理解确认书(架构定位/限界上下文/契约依赖/技术栈/阶段归属/黄金模板对齐审计),合并到 apps/teacher-portal/README.md。(4) teacher-portal 现状审计 19 维度,发现 7 项高优违规AppShell.tsx L143 硬编码 `user.roles.join(",")`(违反 §3.1 前端禁 role 硬编码)/ globals.css+tailwind.config.js 硬编码 `hsl()` 字面量与 `#hex`(违反 §3.10 设计令牌)/ layout.tsx 直接 import `'Inter'/'Fraunces'/'JetBrains Mono'` 字面量(违反 §3.10 禁硬编码字体)/ 缺统一 ApiClient 层4 页面重复 local authHeaders()+fetch/ 缺 usePermission Hook前端无 @RequirePermission 等价物)/ 缺 ErrorBoundary白屏风险/ 缺 5 层状态管理nuqs/TanStack Query/Zustand/Zustand-UI/react-hook-form 全缺)/ next.config.js 无 Module Federation 配置4 端无法 Shell+Remote 组合)。(5) 阶段 2 产出模块架构设计文档MF 2.0 Shell+Remote 架构teacher-portal:3000 为 Shellstudent:3001/parent:3002/admin:3003 为 Remote含 mermaid 架构图 + MF config 代码示例 + 领域模型Session/Viewport/Permission TS 接口)+ 数据模型缓存策略表5 层 DataScope × 5 类缓存键)+ API 请求层设计ApiClient 401 自动刷新 + ActionState 解析 + 错误码前缀路由 i18n+ WebSocket/SSE 事件设计 + 横切关注点对齐清单(权限表 4 端前缀/错误码前缀 TP_/SP_/PP_/AP_/logger/metrics/tracer/health。(6) 4 端差异化对照表 5 张(总体/L1 导航/L2 路由/L3 组件/L4 数据)。(7) 交互点契约清单 12 项 + 风险 7 项 + 假设 5 项 + 4 项待 coord 仲裁packages 归属/GraphQL vs REST/i18n key 命名/MF 暴露粒度)。(8) coord 交叉审查信息:端口矩阵 3000-3003、5 个 shared 包待建shared-ts/shared-tokens/shared-ui/shared-mf/shared-perm、11 个后端契约依赖、错误码前缀对齐、无 Kafka 事件消费。**学到**:前端权限校验等价物是 `usePermission().hasPermission()` Hook + `<RequirePermission>` 组件(镜像后端 `@RequirePermission()` 装饰器MF Shell+Remote 架构选择优于 4 独立 Shell共享登录态/布局/组件库/权限体系)和单 Next.js 应用4 端独立部署/独立 CI/独立回滚teacher-portal 现状有 7 项高优违规需 P2 闭环前修复4 端 API 错误码前缀需与后端服务对齐TP_=teacher-bff、SP_=student-bff、PP_=parent-bff、AP_=admin-bff设计令牌三层模型primitive/semantic-light+dark/tailwind-theme必须 ESLint 强制约束no-restricted-syntax 禁 #hex + design-tokens/no-hardcoded-fonts 禁字面量字体)。 | | 2026-07-09 | 夜间 | data-ana/aiai06 | **ai06 阶段 1+2 交付(Python 双服务**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板源码controller/service/repository/schema/dto+ shared-proto 8 份 protoiam/classes/core_edu/events/analytics/ai/msg/push+ ai-allocation.md §5 ai06 分工data-ana P4 + ai P5+ data-ana/ai 现有全部源码main.py/clickhouse_client.py/cdc_consumer.py/llm_client.py/config.py)。(2) 阶段 1 产出 docs/architecture/ai06-phase1-understanding.mddata-ana 模块理解确认书(架构定位 L5 业务服务/限界上下文 D6 智能洞察/契约 AnalyticsService 3RPC + 消费 6 CDC topic/技术栈 Python3.12+FastAPI+ClickHouse+aiokafka/P4 阶段归属)+ ai 模块理解确认书(架构定位 L5/限界上下文 D6/契约 AiService 4RPC 含 StreamChat/技术栈 Python+FastAPI+LLM REST/P5 阶段归属)+ §10 服务审计表(两服务均缺 gRPC server 实现/缺权限校验 Depends/data-ana 缺 mastery.updated 事件发布/ai 缺 Redis 限流)+ 8 项跨模块契约待 coord 仲裁。(3) 阶段 2 产出 docs/architecture/ai06-phase2-design.mddata-ana 设计含 Mermaid 内部分层图Entry/Middleware/Service/Repo/Consumer/Storage 6 层)+ ClickHouse 4 张宽表 DDLstudent_dashboard_view ReplacingMergeTree(last_updated)/student_errors/mastery_snapshot/ai_usage_log+ HTTP+gRPC 双入口 API 设计 + 6 个 CDC topic 消费路由 + 发布 edu.insight.mastery.updated 事件 + DATA_ANA_* 错误码清单 + 10 项 metrics + 优雅关闭序列ai 设计含 Mermaid 分层图LLM Provider 适配器模式)+ 无 DB 状态less + 4 LLM ProviderOpenAI/Anthropic/Baichuan/Ollama+ Prompt 模板注册表 + 出题 4 步编排 + AI_* 错误码 + Redis 令牌桶限流 + edu.insight.ai.usage 事件发布 + 9 项待 coord 仲裁决策。(4) **学到**Python 服务权限校验等价 NestJS @RequirePermission 的是 FastAPI Depends 依赖注入 + DataScope 6 级过滤SELF/CLASS/GRADE/SCHOOL/DISTRICT/ALLClickHouse ReplacingMergeTree 查询必须加 FINAL 关键字否则读到重复版本数据Python 服务无 MySQL 写事务故不能用 Outbox 模式派生数据事件mastery.updated/ai.usage建议直接 Kafka producer已提请 coord 仲裁是否豁免 §12.2 Outbox 强制约束ai 服务无 DB 设计stateless与 data-ana 有 ClickHouseOLAP形成对比需在审计表区分标注gRPC streamingStreamChat在 Python 用 grpc.aio + AsyncGenerator与 TS @grpc/grpc-js 双向流实现差异需在阶段 3 实施时对照ai-allocation.md §9.3 提交规范为 `docs(<service>): 模块架构设计文档`,三阶段强制经验沉淀到 known-issues.md 工作经验日志。 |
| 2026-07-09 | 夜间 | api-gateway/push-gatewayai01 | **ai01 阶段 1+2 交付(Go 网关层**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板全部源码 + shared-proto 8 份 protoiam/msg/events+ api-gateway/push-gateway 全部 Go 源码。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 2 份模块理解确认书(services/{api-gateway,push-gateway}/docs/01-understanding.md+ 审计表api-gateway 审计 13 项差距3 高优先:缺 /metrics 端点、/readyz 是 stub 返回 ok 不检查依赖、auth.go L124-139 死代码 RequestIDMiddleware+generateUUID 与 requestid.go 重复4 中logger 用 fmt 非 log/slog、go.mod go 1.25.0 与 Dockerfile golang:1.22-alpine 版本不匹配、HS256 待 P2 升 RS256、DevMode 生产环境风险6 低push-gateway 审计出 16 项差距6 高:无 Redis Pub/Sub 横向扩展、CheckOrigin 直接 return true 安全风险、用文本 "ping"/"pong" 心跳非 RFC 6455 控制帧、无单用户连接数上限、/internal/* 无鉴权、Dockerfile 单阶段且 root 用户无 healthcheck)。(4) 阶段 2 产出 2 份模块架构设计文档02-architecture-design.mdapi-gateway 覆盖 9 节(内部分层图、路由表矩阵 9 下游含端口+鉴权规则、限流策略表按路由差异化 RPS/burst、熔断阈值表按服务、JWT RS256 流程含 JWKS 缓存、CORS 白名单、请求 ID 注入、metrics 7 项指标清单、P0-P3 实施优先级push-gateway 覆盖 13 节内部分层图、Connection/Hub 领域模型、Redis 4 个 key pattern、WebSocket 端点+子协议 JSON 格式、内部推送 API+X-Internal-Token 鉴权、双通道协议 HTTP 同步+Kafka 异步、WebSocket 生命周期状态图、心跳协议 RFC 6455 控制帧 30s 间隔 60s 超时、单用户最大 5 连接、重连协议 P6 预留、多实例架构图、Redis Pub/Sub 跨实例流程、容量目标 10w+ 连接 <50ms 本地推送 <200ms 跨实例)。**学到**gobreaker v2 ReadyToTrip 在 Requests=1 时 1*2>1=true 即 1 次失败就触发 OPEN与 P6 集成测试观察一致gorilla/websocket 不支持并发写同一连接Hub.Send 必须用 send chan + 单写协程串行化(已在 P5 修复但设计文档需明确标注此约束push-gateway 骨架用文本 "ping"/"pong" 违反 RFC 6455应用 SetPongHandler 处理控制帧004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀但代码 TOPIC_MAP 用 `edu.exam.events`ai03 已提请 coord 仲裁,本 AI 在 push-gateway 设计中消费 `edu.notification.events` 待 coord 统一命名后同步api-gateway 与 push-gateway 重复 tracer.go/logger.go/jwks.go/env.go建议提取到 `packages/shared-go/`(需 coord 创建包后多 AI 协同迁移Go 服务 .env 不会自动加载DevMode 必须在启动前 `export DEV_MODE=true` 或集成 godotenv。 | | 2026-07-09 | 夜间 | teacher-portal/前端4端ai07 | **ai07 阶段 1+2 交付(前端 4 端**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板源码controller/service/repository/schema/dto+ shared-proto 8 份 proto + ai-allocation.md §5 ai07 分工。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 4 端模块理解确认书(架构定位/限界上下文/契约依赖/技术栈/阶段归属/黄金模板对齐审计),合并到 apps/teacher-portal/README.md。(4) teacher-portal 现状审计 19 维度,发现 7 项高优违规AppShell.tsx L143 硬编码 `user.roles.join(",")`(违反 §3.1 前端禁 role 硬编码)/ globals.css+tailwind.config.js 硬编码 `hsl()` 字面量与 `#hex`(违反 §3.10 设计令牌)/ layout.tsx 直接 import `'Inter'/'Fraunces'/'JetBrains Mono'` 字面量(违反 §3.10 禁硬编码字体)/ 缺统一 ApiClient 层4 页面重复 local authHeaders()+fetch/ 缺 usePermission Hook前端@RequirePermission 等价物)/ 缺 ErrorBoundary白屏风险/ 缺 5 层状态管理nuqs/TanStack Query/Zustand/Zustand-UI/react-hook-form 全缺)/ next.config.js 无 Module Federation 配置4 端无法 Shell+Remote 组合)。(5) 阶段 2 产出模块架构设计文档MF 2.0 Shell+Remote 架构teacher-portal:3000 为 Shellstudent:3001/parent:3002/admin:3003 为 Remote含 mermaid 架构图 + MF config 代码示例 + 领域模型Session/Viewport/Permission TS 接口)+ 数据模型缓存策略表5 层 DataScope × 5 类缓存键)+ API 请求层设计ApiClient 401 自动刷新 + ActionState 解析 + 错误码前缀路由 i18n+ WebSocket/SSE 事件设计 + 横切关注点对齐清单(权限表 4 端前缀/错误码前缀 TP_/SP_/PP_/AP_/logger/metrics/tracer/health。(6) 4 端差异化对照表 5 张(总体/L1 导航/L2 路由/L3 组件/L4 数据)。(7) 交互点契约清单 12 项 + 风险 7 项 + 假设 5 项 + 4 项待 coord 仲裁packages 归属/GraphQL vs REST/i18n key 命名/MF 暴露粒度)。(8) coord 交叉审查信息:端口矩阵 3000-3003、5 个 shared 包待建shared-ts/shared-tokens/shared-ui/shared-mf/shared-perm、11 个后端契约依赖、错误码前缀对齐、无 Kafka 事件消费。**学到**:前端权限校验等价物是 `usePermission().hasPermission()` Hook + `<RequirePermission>` 组件(镜像后端 `@RequirePermission()` 装饰器MF Shell+Remote 架构选择优于 4 独立 Shell共享登录态/布局/组件库/权限体系)和单 Next.js 应用4 端独立部署/独立 CI/独立回滚teacher-portal 现状有 7 项高优违规需 P2 闭环前修复4 端 API 错误码前缀需与后端服务对齐TP_=teacher-bff、SP_=student-bff、PP_=parent-bff、AP_=admin-bff设计令牌三层模型primitive/semantic-light+dark/tailwind-theme必须 ESLint 强制约束no-restricted-syntax 禁 #hex + design-tokens/no-hardcoded-fonts 禁字面量字体)。 |
| 2026-07-09 | 夜间 | teacher-bff/core-eduai03 | **ai03 阶段 1 全局理解交付**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档 + classes 黄金模板全部源码 + shared-proto 8 份 proto + teacher-bff/core-edu 现有实现。(2) 运行 arch:scan 更新 arch.db10 TS/264、2 Go/33、2 Py/45、138 proto 契约)arch:query deps/stats 发现 arch.db 仅记录模块/符号统计不记录跨模块调用边。(3) 按 §6 模板产出两份模块理解确认书 + §10 审计表,交付 docs/architecture/ai03-phase1-understanding.md。(4) 审计发现 teacher-bff 7 项差距REST 非 gRPC/无 GraphQL/无 DataLoader/无 Redis 缓存/无 readyz/无 Zod/无测试、core-edu 12 项差距(考试状态机缺失/作业状态机不完整/成绩无校验/无并发锁/homework.graded 与 grade.updated 事件未触发/未消费 IAM user.created/Drizzle db 导出 vs classes getDb() 不一致/kafka.ts 用 console 非 logger/classes 模块仅占位待合并/REST 未转 gRPC/无 Zod/无测试)。(5) 提请 coord 交叉审查 6 项跨模块契约对齐iam getEffectivePermissions 聚合 API proto / iam user.created topic / 端口 3004 / Kafka topic 命名 004 文档与代码不一致 / data-ana 消费契约 / msg 消费契约)。**学到**004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀,但 core-edu outbox.publisher.ts TOPIC_MAP 用 `edu.exam.events`,文档与代码不一致需 coord 仲裁统一teacher-bff 当前用 REST fetch 但 P2 退出标准要求 GraphQL Yoga + DataLoader阶段 2 设计需补通信方式迁移core-edu 与 classes 黄金模板的 Drizzle 访问方式不一致core-edu 直接 `export const db`classes 用 `getDb()` 函数),建议统一为 `getDb()` 函数式以匹配 HealthController 已有约定。 | | 2026-07-09 | 夜间 | api-gateway/push-gatewayai01 | **ai01 阶段 1+2 交付Go 网关层)**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档README/MIGRATION_GUIDE/004/pending-features/project_rules/coding-standards/multi-ai-collaboration+ classes 黄金模板全部源码 + shared-proto 8 份 protoiam/msg/events+ api-gateway/push-gateway 全部 Go 源码。(2) 运行 `pnpm run arch:scan` 更新 arch.db10 TS/264 符号、2 Go/33 符号、2 Py/45 符号、138 proto 契约)。(3) 阶段 1 产出 2 份模块理解确认书services/{api-gateway,push-gateway}/docs/01-understanding.md+ 审计表api-gateway 审计出 13 项差距3 高优先:缺 /metrics 端点、/readyz 是 stub 返回 ok 不检查依赖、auth.go L124-139 死代码 RequestIDMiddleware+generateUUID 与 requestid.go 重复4 中logger 用 fmt 非 log/slog、go.mod go 1.25.0 与 Dockerfile golang:1.22-alpine 版本不匹配、HS256 待 P2 升 RS256、DevMode 生产环境风险6 低push-gateway 审计出 16 项差距6 高:无 Redis Pub/Sub 横向扩展、CheckOrigin 直接 return true 安全风险、用文本 "ping"/"pong" 心跳非 RFC 6455 控制帧、无单用户连接数上限、/internal/* 无鉴权、Dockerfile 单阶段且 root 用户无 healthcheck)。(4) 阶段 2 产出 2 份模块架构设计文档02-architecture-design.mdapi-gateway 覆盖 9 节(内部分层图、路由表矩阵 9 下游含端口+鉴权规则、限流策略表按路由差异化 RPS/burst、熔断阈值表按服务、JWT RS256 流程含 JWKS 缓存、CORS 白名单、请求 ID 注入、metrics 7 项指标清单、P0-P3 实施优先级push-gateway 覆盖 13 节内部分层图、Connection/Hub 领域模型、Redis 4 个 key pattern、WebSocket 端点+子协议 JSON 格式、内部推送 API+X-Internal-Token 鉴权、双通道协议 HTTP 同步+Kafka 异步、WebSocket 生命周期状态图、心跳协议 RFC 6455 控制帧 30s 间隔 60s 超时、单用户最大 5 连接、重连协议 P6 预留、多实例架构图、Redis Pub/Sub 跨实例流程、容量目标 10w+ 连接 <50ms 本地推送 <200ms 跨实例)。**学到**gobreaker v2 ReadyToTrip 在 Requests=1 时 1*2>1=true 即 1 次失败就触发 OPEN与 P6 集成测试观察一致gorilla/websocket 不支持并发写同一连接Hub.Send 必须用 send chan + 单写协程串行化(已在 P5 修复但设计文档需明确标注此约束push-gateway 骨架用文本 "ping"/"pong" 违反 RFC 6455应用 SetPongHandler 处理控制帧;004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀但代码 TOPIC_MAP 用 `edu.exam.events`ai03 已提请 coord 仲裁,本 AI 在 push-gateway 设计中消费 `edu.notification.events` 待 coord 统一命名后同步api-gateway 与 push-gateway 重复 tracer.go/logger.go/jwks.go/env.go建议提取到 `packages/shared-go/`(需 coord 创建包后多 AI 协同迁移Go 服务 .env 不会自动加载DevMode 必须在启动前 `export DEV_MODE=true` 或集成 godotenv。 |
| 2026-07-09 | 下午 | classes/全局 | **一键启动脚本 NestJS dist/ 不生成根因定位 + classes 健康检查修复**(1) 根因定位:`tsconfig.base.json` 的 `incremental: true` + `nest-cli.json` 的 `deleteOutDir: true` 冲突。`nest start --watch` 启动时先删除 dist/tsc 读残留 .tsbuildinfo 认为无变化跳过 emitdist/ 不生成 → `Cannot find module dist/main`。(2) 修复6 个 NestJS 服务classes/iam/teacher-bff/core-edu/content/msgtsconfig.json 显式加 `"incremental": false` 覆盖 base 配置,删除所有残留 .tsbuildinfo 文件。(3) classes AppModule 缺 HealthModule 导入导致 /healthz 404iam 同样问题,修复 app.module.ts 加 `imports: [..., HealthModule]`。(4) classes HealthController 误用 TypeORM `DataSource` DI与 iam 不一致),运行时报 `Nest can't resolve dependencies of the HealthController (DataSource)`。修复:改为 Drizzle `getDb()` 函数式调用,与 iam 一致。(5) 一键启动验证11/11 应用 + 11/11 基础设施 + 5/5 可观测性端点全绿。**学到**NestJS + TypeScript incremental 编译是陷阱组合——nest-cli deleteOutDir 删 dist 但 tsc 读 tsbuildinfo 认为无变化,必须在服务级 tsconfig 显式 `incremental: false`HealthModule 必须在 AppModule imports 中显式声明才能被 NestFactory 扫描到5 个 NestJS 服务的 HealthController 应统一用 Drizzle `getDb()` 函数式调用而非 TypeORM DataSource DI项目已弃 TypeORM 改 Drizzle | | 2026-07-09 | 夜间 | teacher-bff/core-eduai03 | **ai03 阶段 1 全局理解交付**(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档 + classes 黄金模板全部源码 + shared-proto 8 份 proto + teacher-bff/core-edu 现有实现。(2) 运行 arch:scan 更新 arch.db10 TS/264、2 Go/33、2 Py/45、138 proto 契约arch:query deps/stats 发现 arch.db 仅记录模块/符号统计不记录跨模块调用边。(3) 按 §6 模板产出两份模块理解确认书 + §10 审计表,交付 docs/architecture/ai03-phase1-understanding.md。(4) 审计发现 teacher-bff 7 项差距REST 非 gRPC/无 GraphQL/无 DataLoader/无 Redis 缓存/无 readyz/无 Zod/无测试、core-edu 12 项差距(考试状态机缺失/作业状态机不完整/成绩无校验/无并发锁/homework.graded 与 grade.updated 事件未触发/未消费 IAM user.created/Drizzle db 导出 vs classes getDb() 不一致/kafka.ts 用 console 非 logger/classes 模块仅占位待合并/REST 未转 gRPC/无 Zod/无测试)。(5) 提请 coord 交叉审查 6 项跨模块契约对齐iam getEffectivePermissions 聚合 API proto / iam user.created topic / 端口 3004 / Kafka topic 命名 004 文档与代码不一致 / data-ana 消费契约 / msg 消费契约)。**学到**004 §7.2 事件 topic 用 `edu.teaching.exam.published` 前缀,但 core-edu outbox.publisher.ts TOPIC_MAP 用 `edu.exam.events`,文档与代码不一致需 coord 仲裁统一teacher-bff 当前用 REST fetch 但 P2 退出标准要求 GraphQL Yoga + DataLoader阶段 2 设计需补通信方式迁移core-edu 与 classes 黄金模板的 Drizzle 访问方式不一致core-edu 直接 `export const db`classes 用 `getDb()` 函数),建议统一为 `getDb()` 函数式以匹配 HealthController 已有约定。 |
| 2026-07-09 | 下午 | 全局 | **OTel auto-instrumentations 全服务补全**(1) NestJS 6 服务iam/classes/core-edu/content/msg/teacher-bfftracer.ts 补 `getNodeAutoInstrumentations()`NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务data-ana/aimain.py 补 `FastAPIInstrumentor.instrument_app(app)`ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTelenv.ts 加 OTEL_EXPORTER_OTLP_ENDPOINT 字段 + 新建 shared/observability/tracer.ts + main.ts 调用 initTracer/shutdownTracer + package.json 加 sdk-node/exporter/auto-instrumentations 依赖。(4) Go 2 服务api-gateway/push-gateway)新建 internal/observability/tracer.goOTLP HTTP exporter + resource + TracerProvider + W3C propagator+ main.go 调用 InitTracer + otelgin.Middleware 注册 Gin 中间件push-gateway config.go 补 OTLPEndpoint 字段。(5) 质量校验全通过TS typecheck 9 服务 + ESLint 6 服务 + ruff 2 服务 + go vet/build 2 服务零错误。**学到**`getNodeAutoInstrumentations()` 一次注册所有 Node.js 自动埋点http/express/dns/fs/net/grpc 等),比手动逐个注册 HttpInstrumentation 更简洁Go OTel 用 `otlptracehttp.WithEndpoint(host)` + `WithInsecure()` 需从 "http://host:port" URL 解析出 hostotelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 tracePython FastAPIInstrumentor.instrument_app(app) 在 app 创建后立即调用lifespan 不受影响。 | | 2026-07-09 | 傍晚 | 全局 | **全服务代码合规性审查 + 批量修复**:对 10 个服务6 NestJS + 2 Go + 2 Python执行严格代码审查发现 7 critical + 42 major + 35 minor 问题,批量修复如下。(1) **@RequirePermission() 装饰器实现**6 个 NestJS 服务全部实现 `SetMetadata` + `Reflector` 标准模式PermissionGuard 改用 `getAllAndOverride` 读取元数据,注册为 `APP_GUARD` 全局 GuardDEV_MODE 旁路。content/msg 新建 permission.guard.ts + auth.middleware.ts。(2) **as 断言消除**:所有 `req.headers['x-user-id'] as string` 改为 `typeof` 类型守卫,涉及 auth.middleware.ts/global-error.filter.ts/controller.ts。(3) **返回类型补充**`getDb()` 标注 `MySql2Database<typeof schema>`Controller 方法补充 `Promise<{ success: true; data: ... }>`。(4) **import type 修复**express 的 `Request`/`Response`/`NextFunction` 改为 `import type`。(5) **main.ts /metrics 隐式 any 修复**:参数标注 `Request`/`Response` 类型。(6) **原生 Error → ApplicationError**repository.ts 的 `throw new Error` 改为 `DatabaseError`。(7) **typeorm 残留清理**classes lifecycle.service.ts 移除 typeorm DataSource 依赖改用 Drizzle `closeDb()`。(8) **LifecycleService 注册**5 个 NestJS 服务的 AppModule 注册 LifecycleService。(9) **teacher-bff 补全**:新建 logger.ts + ApplicationError 体系 + GlobalErrorFilter下游调用转发真实 userId替换硬编码 "bff");失败时 `logger.warn` + `BadGatewayError`不再静默吞错health.controller 迁移到 shared/health/ 标准结构。(10) **Go 安全修复**CORS 默认 `*` 改为开发白名单 + warningJWT 密钥非 DevMode fail-fastpush-gateway `/internal/*` 添加 `X-Internal-Key` 鉴权;`interface{}` → `any`;删除死代码 `RequestIDMiddleware`/`generateUUID`/`getEnvInt`;补充 doc commentapi-gateway + push-gateway 添加 `/metrics` 端点push-gateway 添加 `/readyz`。(11) **Python 修复**lifespan 返回类型标注 `AsyncGenerator[None, None]``dev_mode` 从 `str` 改为 `bool`data-ana 业务路由改用 `APIRouter`ai POST 端点从 query 参数改为 Pydantic 请求体模型data-ana ClickHouse 同步调用包装在 `asyncio.to_thread()` 中。(12) **core-edu GlobalErrorFilter 统一**:响应契约改为 `{ success: false, error: { code, message, details, traceId } }` 与 content/msg 一致。(13) **msg 修复**notifications.dto.ts 新建 Zod 验证 schemauuid v4 替换为 `node:crypto.randomUUID`ES 模块 console.error 改为结构化 logger。**学到**NestJS `SetMetadata` `@nestjs/common` 导入(非 `@nestjs/core``APP_GUARD` 注册全局 Guard 是标准模式;`Reflector.getAllAndOverride` 支持 handler+class 两级元数据查找PermissionGuard 必须在 DEV_MODE 下旁路否则开发环境无法测试Go 的 `interface{}` 在 1.18+ 应统一用 `any`Python `asyncio.to_thread()` 是包装同步 IO 为异步的标准方式FastAPI `lifespan` 返回类型是 `AsyncGenerator[None, None]`。 |
| 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**(1) 可观测性栈完善5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporterGrafana datasource 新增 Loki新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-errorESLint 9 flat config 已配置完成test 保留 continue-on-error部分服务无 test 脚本)。**学到**NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 | | 2026-07-09 | 下午 | classes/全局 | **一键启动脚本 NestJS dist/ 不生成根因定位 + classes 健康检查修复**(1) 根因定位:`tsconfig.base.json` 的 `incremental: true` + `nest-cli.json` 的 `deleteOutDir: true` 冲突。`nest start --watch` 启动时先删除 dist/tsc 读残留 .tsbuildinfo 认为无变化跳过 emitdist/ 不生成 → `Cannot find module dist/main`。(2) 修复6 个 NestJS 服务classes/iam/teacher-bff/core-edu/content/msgtsconfig.json 显式加 `"incremental": false` 覆盖 base 配置,删除所有残留 .tsbuildinfo 文件。(3) classes AppModule 缺 HealthModule 导入导致 /healthz 404iam 同样问题,修复 app.module.ts 加 `imports: [..., HealthModule]`。(4) classes HealthController 误用 TypeORM `DataSource` DI与 iam 不一致),运行时报 `Nest can't resolve dependencies of the HealthController (DataSource)`。修复:改为 Drizzle `getDb()` 函数式调用,与 iam 一致。(5) 一键启动验证11/11 应用 + 11/11 基础设施 + 5/5 可观测性端点全绿。**学到**NestJS + TypeScript incremental 编译是陷阱组合——nest-cli deleteOutDir 删 dist 但 tsc 读 tsbuildinfo 认为无变化,必须在服务级 tsconfig 显式 `incremental: false`HealthModule 必须在 AppModule imports 中显式声明才能被 NestFactory 扫描到5 个 NestJS 服务的 HealthController 应统一用 Drizzle `getDb()` 函数式调用而非 TypeORM DataSource DI项目已弃 TypeORM 改 Drizzle |
| 2026-07-09 | 下午 | data-ana/infra | **CDC 完整链路实现**MySQL binlog → Debezium Connect → Kafka → data-ana 消费者 → ClickHouse 宽表。(1) MySQL binlog 配置log_bin=ON, binlog_format=ROW, binlog_row_image=FULL, server_id=1用 root 创建 `debezium` 用户授予 REPLICATION SLAVE + REPLICATION CLIENT。(2) Debezium Connect 容器daocloud 禁用 debezium 镜像改用 `quay.io/debezium/connect:2.7`MySQL 容器在 edu-minimal_default 网络,需 `docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达Kafka 必须配置双 listenerINSIDE:kafka:29092 + OUTSIDE:localhost:9092否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connectorPOST :8083/connectors配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial4 张表core_edu_grades/exams/classes/iam_users成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumerlifespan 中 asyncio.create_task 后台运行;按 source.table 路由exams→内存缓存 exam_id→class_id 映射grades→查缓存填 class_id 后 upsert ClickHousereadyz 端点附加 cdc_consumer 状态。(5) ClickHouse 远程访问:默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP。(6) structlog 24.x API`make_filtering_bound_logger(level)` 替代废弃的 `make_filtering_logger`。(7) E2E 验证MySQL INSERT 成绩 → Debezium op=c 事件 → Kafka → 消费者写 ClickHouse 宽表class_id 通过 exam 缓存正确填充)→ /readyz cdc_consumer=running → /analytics/student/student-002/weakness 返回实时 92 分数据。**学到**Debezium 2.x 容器 bootstrap.servers 默认值是 0.0.0.0:9092 必须显式覆盖Kafka 单 listener 配置 localhost 会让容器间通信的客户端拿到 metadata 后切换失败,必须用双 listenerClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 | | 2026-07-09 | 下午 | 全局 | **OTel auto-instrumentations 全服务补全**(1) NestJS 6 服务iam/classes/core-edu/content/msg/teacher-bfftracer.ts 补 `getNodeAutoInstrumentations()`NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务data-ana/aimain.py 补 `FastAPIInstrumentor.instrument_app(app)`ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTelenv.ts 加 OTEL_EXPORTER_OTLP_ENDPOINT 字段 + 新建 shared/observability/tracer.ts + main.ts 调用 initTracer/shutdownTracer + package.json 加 sdk-node/exporter/auto-instrumentations 依赖。(4) Go 2 服务api-gateway/push-gateway新建 internal/observability/tracer.goOTLP HTTP exporter + resource + TracerProvider + W3C propagator+ main.go 调用 InitTracer + otelgin.Middleware 注册 Gin 中间件push-gateway config.go 补 OTLPEndpoint 字段。(5) 质量校验全通过TS typecheck 9 服务 + ESLint 6 服务 + ruff 2 服务 + go vet/build 2 服务零错误。**学到**`getNodeAutoInstrumentations()` 一次注册所有 Node.js 自动埋点http/express/dns/fs/net/grpc 等),比手动逐个注册 HttpInstrumentation 更简洁Go OTel 用 `otlptracehttp.WithEndpoint(host)` + `WithInsecure()` 需从 "http://host:port" URL 解析出 hostotelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 tracePython FastAPIInstrumentor.instrument_app(app) 在 app 创建后立即调用lifespan 不受影响。 |
| 2026-07-09 | 下午 | 全局 | **P6 硬化:ESLint 9 flat config 配置**(1) 根目录创建 `eslint.config.js`ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`flat config 不需要 --ext。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 | | 2026-07-09 | 下午 | 全局 | **P6 硬化:可观测性 + 部署 + CI 硬化**(1) 可观测性栈完善5 个 NestJS 服务 main.ts 添加 `/metrics` Prometheus 端点(用 `app.getHttpAdapter().get('/metrics', ...)` 绕过 DI 容器 get 方法prometheus.yml 从 2 个目标扩展到 8 个应用服务 + MySQL/Redis + node-exporter + prometheus 自身 + rule_files + alertmanager 关联monitoring compose 用 Loki + Promtail 替换未配置的 blackbox-exporterGrafana datasource 新增 Loki新建 promtail/config.yml 用 docker_sd_configs 仅采集 `edu-*` 容器日志。(2) 部署 compose 扩展docker-compose.deploy.yml 从 3 服务扩展到 11 服务(+ iam/teacher-bff/core-edu/content/msg/ai/data-ana/push-gateway每个服务带 healthcheck + depends_on 条件 + edu-net/edu-shared 双网络deploy.env.example 补全 Neo4j/ES/ClickHouse/LLM/Kafka 可选依赖配置。(3) teacher-bff 补 health.controller.ts原缺失 /healthz 导致 deploy depends_on service_healthy 失败)。(4) CI 硬化:移除 lint 步骤的 continue-on-errorESLint 9 flat config 已配置完成test 保留 continue-on-error部分服务无 test 脚本)。**学到**NestJS `app.get('/metrics')` 会被解析为 DI 容器 `get(typeOrToken)`,必须用 `app.getHttpAdapter().get()` 才能注册 Express 路由Promtail docker_sd_configs 通过 relabel_configs 的 `regex: '/(edu-.*).*'` 过滤容器名前缀docker-compose.depends_on.condition: service_healthy 要求被依赖服务必须有 healthcheck 配置,否则启动失败。 |
| 2026-07-09 | 午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调ai 服务加 /ai 前缀Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 | | 2026-07-09 | 午 | data-ana/infra | **CDC 完整链路实现**MySQL binlog → Debezium Connect → Kafka → data-ana 消费者 → ClickHouse 宽表。(1) MySQL binlog 配置log_bin=ON, binlog_format=ROW, binlog_row_image=FULL, server_id=1用 root 创建 `debezium` 用户授予 REPLICATION SLAVE + REPLICATION CLIENT。(2) Debezium Connect 容器daocloud 禁用 debezium 镜像改用 `quay.io/debezium/connect:2.7`MySQL 容器在 edu-minimal_default 网络,需 `docker network connect edu-full_default edu-mysql` 让 Debezium 同时可达Kafka 必须配置双 listenerINSIDE:kafka:29092 + OUTSIDE:localhost:9092否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connectorPOST :8083/connectors配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial4 张表core_edu_grades/exams/classes/iam_users成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumerlifespan 中 asyncio.create_task 后台运行;按 source.table 路由exams→内存缓存 exam_id→class_id 映射grades→查缓存填 class_id 后 upsert ClickHousereadyz 端点附加 cdc_consumer 状态。(5) ClickHouse 远程访问:默认 default-user.xml 限制 127.0.0.1/::1 无密码,挂载 `clickhouse/users.d/custom-users.xml` 覆盖密码+任意 IP。(6) structlog 24.x API`make_filtering_bound_logger(level)` 替代废弃的 `make_filtering_logger`。(7) E2E 验证MySQL INSERT 成绩 → Debezium op=c 事件 → Kafka → 消费者写 ClickHouse 宽表class_id 通过 exam 缓存正确填充)→ /readyz cdc_consumer=running → /analytics/student/student-002/weakness 返回实时 92 分数据。**学到**Debezium 2.x 容器 bootstrap.servers 默认值是 0.0.0.0:9092 必须显式覆盖Kafka 单 listener 配置 localhost 会让容器间通信的客户端拿到 metadata 后切换失败,必须用双 listenerClickHouse users_xml 存储是 readonly 不能用 ALTER USER 修改密码,必须挂载 users.d 配置文件覆盖;消费者 offset 重置必须先停消费者让 group 处于 Empty 状态才能执行 --reset-offsets。 |
| 2026-07-09 | 午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 | | 2026-07-09 | 午 | 全局 | **P6 硬化ESLint 9 flat config 配置**(1) 根目录创建 `eslint.config.js`ESLint 9 flat config 格式):用 `typescript-eslint` recommended 规则集 + `@eslint/js` recommended + `eslint-config-prettier` 禁用冲突规则;自定义规则:`no-explicit-any` warn + `no-unused-vars` 允许下划线前缀 + 测试文件放宽。(2) 6 个 TS 服务 package.json lint 脚本从 `eslint src --ext .ts` 改为 `eslint src`flat config 不需要 --ext。(3) `lint-staged.config.js` 恢复 `eslint --fix`。(4) 验证classes/content/msg/core-edu 四服务 lint 全部零错误零警告通过。**学到**ESLint 9 flat config 用 `tseslint.config()` 工厂函数组装配置数组;`--ext` 参数在 flat config 模式下被移除ESLint 自动根据 `eslint.config.js` 中的 `files` 匹配;`@typescript-eslint/consistent-type-assertions` 规则选项格式在 v8 中变化(`objectLiteralType` → `objectLiteralTypeAssertions`),配置时需查最新文档。 |
| 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 | | 2026-07-09 | 中午 | msg/push-gateway/ai/api-gateway | **P5 沟通与 AI 阶段三服务完善**(1) msg 服务修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL 改 optional 加 DEV_MODE/PUSH_GATEWAY_URLelasticsearch.ts ES 降级esClient=null 时 safeIndex/safeSearch 跳过notifications.service.ts 加 createBatch + listByUserWithPagination + Push Gateway 推送调用try/catch 降级);新建 msg-init.sql 2 张表。(2) push-gateway 完善hub.go 重写用 send chan + 单写协程模式修复 gorilla/websocket 并发写竞争handler.go 加 DEV_MODE dev-token 支持 + broadcast 端点config.go 加 DevMode/RedisURL。(3) ai 服务完善config.py 加 openai_api_key/base_url/dev_mode新建 llm_client.pyhttpx 异步调 OpenAI REST APImain.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg/ai → ai 服务。**学到**gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*pathLLM 降级策略统一返回 degraded 标记,调用方据此判断是否路由流量。 |
| 2026-07-09 | 上午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 | | 2026-07-09 | 上午 | content/api-gateway | **P4 内容分析服务端到端打通**(1) content 服务系统性修复database.ts 导出 db 常量env.ts JWT_SECRET/ES_URL/NEO4J_URL/NEO4J_PASSWORD 改 optional 加 DEV_MODEneo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000health/lifecycle 改用 Drizzleglobal-error.filter 移除 @types/express 依赖textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块chaptersCRUD + 按 textbook 查询、knowledge-pointsCRUD + Neo4j 前置依赖图非阻塞查询、questionsCRUD + 4 种题型校验)。(3) Gateway 路由扩展textbooks/chapters/knowledge-points/questions 四组路由。(4) 数据库content-init.sql 4 张表。(5) E2E 验证POST /textbooks 201 → POST /chapters 201字段用 order 非 orderNum→ POST /knowledge-points 201Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**Drizzle schema TS 字段名与 DB 列名解耦order→order_numAPI 请求体用 TS 字段名Neo4j 不可用时必须 driver=null不设 NEO4J_URL否则每次请求尝试连接拖慢响应neo4j-driver safeCreateNode 用 try/catch 非阻塞MySQL 数据始终先落库。 |
| 2026-07-08 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证:iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 | | 2026-07-09 | 凌晨 | core-edu/api-gateway | **P3 核心教学服务端到端打通**(1) core-edu 服务系统性修复 13 项database.ts 导出 db 常量替代 getDb()env.ts JWT_SECRET 改 optional 加 DEV_MODEkafka.ts connectKafka 加 try/catch 不阻塞启动main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule3 个 controller 路由去前缀去 UseGuards 从 x-user-id 读身份exams/homework service datetime 列 ISO 字符串转 Date 修复 drizzle toISOString 错误;修正 10 处相对 import 路径health/lifecycle 改用 Drizzle 原生查询;新增 core-edu-init.sql 4 张表。(2) Gateway 路由扩展:发现 internal/routing/routing.go 是死代码(未被 main 引用),真正路由在 main.go在 main.go 添加 exams/homework/grades 三组路由(无尾斜杠+通配符);删除 routing.goconfig.go 加 CoreEduServiceURL。(3) DEV_MODE 环境变量问题Go 不自动加载 .env必须在启动前 export DEV_MODE=true 否则 dev-token 被拒 401。(4) E2E 验证:POST /exams 201 → GET /exams/:id 200 → GET /exams/class/:id 200 → POST /homework 201 → POST /grades 201 → Outbox 3 条事件正确写入exam.failed 因 Kafka 未启动homework/grade pending。**学到**drizzle datetime 列需 Date 对象不是 ISO 字符串mapToDriverValue 调 toISOStringGo 项目 .env 不会自动加载需显式 export 或 godotenv 库NestJS controller 路由前缀与 Gateway 代理路径要协调Gateway 去掉 /api/v1 后转发controller 用裸路径如 'exams'Outbox 模式业务事务同写验证通过Kafka 未启动时事件 status=failed 但业务数据已落库。 |
| 2026-07-08 | 午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 | | 2026-07-09 | 午 | iam/teacher-bff/teacher-portal | **P2 身份阶段完整实现**(1) Gateway 公开路径白名单register/login/refresh解决无 token 死锁。(2) IAM schema 扩展users 加 dataScope新增 role_viewports 表。(3) RBAC 端点 4 个 GET。(4) 视口按 requiredPermission 过滤 + sortOrder 排序getEffectivePermissions 用 Set 去重。(5) JWT payload 含 dataScoperegister 自动分配 teacher 角色。(6) 种子数据 7 权限+12 映射+7 视口。(7) Teacher BFF 视口聚合。(8) 前端lib/auth.ts + login + AppShell + (app) 路由组 + dashboard + classes真实 JWT+ 根重定向。(9) E2E 全链路通过。**学到**Next.js 路由组 (app) 不影响 URL/login 与 /dashboard 共存只后者套壳fetch headers 函数返回 Record<string,string> 避免 TS2769ESLint 9 需 flat config 留 P6AppShell aside 用 flex flex-col + mt-auto 比 absolute 稳健。 |
| 2026-07-08 | 下午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 | | 2026-07-08 | 晚上 | iam/classes/api-gateway | **P1 端到端链路验证 + IAM 服务修复**:验证 register → JWT → Gateway /iam/me → Gateway /classes CRUD → teacher-portal 前端渲染全链路打通。(1) IAM 服务 14 个 TS 编译错误修复:移除 typeorm/ioredis/kafkajs 依赖IAM 用 Drizzlehealth.controller.ts 改用 `db.execute(sql\`SELECT 1\`)`lifecycle.service.ts 简化为只关闭 Drizzle 连接池Drizzle API 修正(`r.roles`→`r.iam_roles``.in()`→`inArray()`)。(2) NestJS ESM DI 修复iam.module.ts 简化 providers 为 `[IamService, IamRepository]`iam.service.ts 构造器加 `@Inject(IamRepository)`(参考 classes 黄金模板),修复运行时 `Cannot read properties of undefined (reading 'findUserByEmail')`。(3) Gateway /iam/me 404 修复iam.controller.ts 直接读 `req.headers['x-user-id']`替代未注册的`AuthenticatedRequest`。(4) 创建 `scripts/iam-init.sql`建 6 张 IAM 表 + 种子数据。(5) E2E 验证iam:3002 注册/登录 → Gateway /iam/me 200 → Gateway GET /classes 200 → Gateway POST /classes合法 UUID gradeId201 → teacher-portal:3000 首页渲染 200 + 含"班级管理" → Next.js rewrites 透传 dev-token 到 Gateway 全链路通。**学到**NestJS ESM 模式下 DI 无法通过类型推断解析 token必须显式`@Inject(Token)`Drizzle select 返回字段名按 schema 定义而非表名classes.dto.ts 的 gradeId 要求 UUID 格式,测试数据不能用 "grade-12" 这类字符串PowerShell 控制台中文显示为 `?`是编码问题数据库实际存储正确DEV_MODE 下前端用`Bearer dev-token` 即可走通链路,无需真实 JWT。 |
| 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过)。**学到**:多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 | | 2026-07-08 | 下午 | 全局 | **CI/CD 完整配置 + 多AI协作规范入规则**(1) project_rules.md 新增 §14 多 AI 协作规范(角色权限矩阵/分支命名/PR合并规则/跨模块变更顺序/冲突处理/AI 身份标注/敏感文件保护)+ §15 CI/CD 规范(流水线阶段/触发条件/镜像规范/部署策略/Secrets 管理/必需 CI 文件)。(2) 优化现有 4 个 ci-*.ymlci-ts.yml 加 arch-scan + docker-build jobci-go.yml 去掉 golangci-lintlint-staged 预存问题),加 docker-buildci-proto.yml 修复 buf breaking URL从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registrygit.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`workflow_run 触发 + 手动 dispatchRunner 直接执行 docker compose pull && up -d10 次健康检查轮询,失败输出日志。(5) 新增 `infra/docker-compose.deploy.yml`(部署用,镜像来自 Gitea registry连接服务器已有 MySQL/Redis 通过 edu-shared 外部网络)+ `infra/deploy.env.example`(部署环境变量模板)。(6) 编写 `docs/standards/cicd-runbook.md`CI/CD 使用手册,含架构总览/一次性配置/日常使用/镜像管理/部署验证/回滚/常见问题/排查命令/安全注意事项)。**学到**Docker Compose 不支持 `restart_policy`(是 swarm 字段),用 `restart: unless-stopped` 替代Gitea Actions 兼容 GitHub Actions 语法但 `workflow_run` 触发可能不完整,备选手动 dispatch应用容器访问宿主机已有 MySQL/Redis 需通过共享外部网络(`docker network create edu-shared` + `docker network connect`)而非 `host.docker.internal`。 |
| 2026-07-08 | 午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 | | 2026-07-08 | 午 | api-gateway | **重定向循环修复 + 生产模式部署准备 + 多AI协作文档**(1) 修复 `ERR_TOO_MANY_REDIRECTS`Gin 默认 `RedirectTrailingSlash=true` 导致 `/api/v1/classes` → 301 → `/classes/`Next.js rewrites 代理时形成循环。**修复**`r.RedirectTrailingSlash=false` + 同时注册无尾斜杠路由(`/classes`)与通配符路由(`/classes/*path`)。(2) 新增 DEV_MODE 旁路:`config.go` 加 `DevMode` 字段,`auth.go` 在 `DEV_MODE=true` 时接受 `dev-token` 注入固定身份(生产必须 false。(3) 生产 Docker 化:新建 `apps/teacher-portal/Dockerfile`(多阶段 Next.js build+ `services/api-gateway/Dockerfile`(多阶段 Go 静态编译)+ `infra/docker-compose.prod.yml`(三服务编排,强制 DEV_MODE=false。(4) 编写 `docs/standards/local-dev-runbook.md`(本地启动手册,含端口表/开发模式/生产模式/常见问题)+ `docs/standards/multi-ai-collaboration.md`多AI协作文档含模块分工矩阵/分支命名/PR流程/合并策略/冲突处理/权限矩阵)。**学到**Gin `RedirectTrailingSlash=false` 后需显式注册无尾斜杠路由(`Any("/classes")` + `Any("/classes/*path")`),否则 404Next.js rewrites 代理会透传 301 给浏览器形成循环,开发模式旁路应通过环境变量控制而非硬编码。 |
| 2026-07-08 | 上午 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 | | 2026-07-08 | 全天 | 全局 | **P6 后续工作手册执行**:完整执行 post-p6-followup.md 12 节任务。环境准备pnpm 925 包 + go mod tidy 双服务 + uv sync 双服务 + buf 安装)→ 代码质量校验Go vet/build 0 错误Python ruff 8 错误自动修复)→ arch.db 同步(实现 4 个扫描器骨架,输出 12 模块/233 符号/138 契约)→ project_rules.md P0 修复(迁移到 .trae/rules/17881 字节)→ 004 架构图修复1.1a/1.1b 双图 + 1.2 业务领域列 + 5.4 视口四层)→ P6 集成测试10 Go + 17 bash = 27 用例全通过)→ Helm Chart 演化8 chart lint 通过)。**学到**:多语言 monorepo 工具链配置需统一镜像源npmmirror/goproxy.cn/tunago.work BOM 字符会导致 `unexpected input character` 错误必须重写文件。 |
| 2026-07-08 | 上午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**:重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 | | 2026-07-08 | 上午 | 全局 | pnpm install 网络失败ECONNRESET→ 配置 `npm config set registry https://registry.npmmirror.com` + `pnpm config set registry https://registry.npmmirror.com` 重试成功。**学到**Windows 下 pnpm 还需配置 `PNPM_HOME` 和 `TMP` 环境变量避免 `_tmp_` 文件 ENOENT 错误。 |
| 2026-07-08 | 上午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 | | 2026-07-08 | 上午 | 全局 | project_rules.md 损坏72 字节乱码,从 P1 提交 2ba4250 就损坏git 历史无完整版本)→ 从 CICD 项目完整版迁移到 `e:\Desktop\Edu\.trae\rules\project_rules.md`(按用户要求放 .trae/rules/),按 MIGRATION_GUIDE 4.1 策略矩阵调整为微服务版13 章 17881 字节),删除根目录损坏文件,更新 7 处引用README/MIGRATION_GUIDE/004/known-issues/git-workflow/coding-standards。**学到**:迁移文件后必须 `Get-Item | Select Length` 验证完整性 + 全文搜索引用更新git commit 前运行 cat 检查内容。 |
| 2026-07-08 | 午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3 | | 2026-07-08 | 午 | api-gateway | go.work BOM 字符 + 版本不匹配:`unexpected input character '\ufeff'` 和 `module requires go >= 1.22.0, but go.work lists go 1.22`。**修复**:重写 go.work 去除 BOM版本改为 `go 1.26.0`,移除不存在的 `./packages/shared-go`。**学到**PowerShell `Out-File` 默认加 BOM写 go.work 这类敏感文件应用 `Write` 工具或 `[System.IO.File]::WriteAllText` 指定 UTF8 无 BOM。 |
| 2026-07-08 | 午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 | | 2026-07-08 | 午 | arch-scan | arch:scan 返回 0 模块 0 符号 → 4 个扫描器ts/go/py/proto都是骨架实现。**修复**:完整实现 4 个扫描器TS 用 regex 提取(避免 ts-morph 对未安装依赖文件解析失败Go/Python 用行首锚定正则Proto 扫描 service/message/rpc。结果12 模块≥10 ✓、233 符号≥100 ✓、138 契约。**学到**ts-morph Project 对未 `pnpm install` 的 workspace 文件会报模块解析失败,改用 regex 更鲁棒scanner.ts main() 开头需 `DELETE FROM` 清空旧数据避免重跑重复。 |
| 2026-07-08 | 下午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 | | 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案1.1a 技术分层视角(部署/流量/网络边界Users 层标注"场景域用户"BFF 层标注"按场景域分"+ 1.1b 业务领域视角6 DDD 限界上下文 subgraphD1 身份/D2 教学组织/D3 教学核心/D4 内容/D5 沟通/D6 智能洞察。1.2 服务清单新增"业务领域"列。**学到**双图互补1.1a 服务运维/SRE 视角1.1b 服务产品/架构视角同一服务可横跨多领域core-edu 同时承载 D2+D3 |
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 | | 2026-07-08 | 下午 | 004 | 视口四层模型补充5.4 章节L1 导航navigation_config 表)/ L2 路由route_permission + Gateway 校验)/ L3 组件usePermission().hasPermission/ L4 数据DataScope 枚举)。场景域 BFF 复用策略:按使用场景域分 BFF 而非按角色分,教导主任复用 Teacher BFF + 额外管理视口。iam 服务职责:认证 + RBAC + 视口配置 + DataScope + 权限解析 API。**学到**视口既可独立配置RoleViewport 表)也可由权限推导,新角色只需配权限集,视口自动推导。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 | | 2026-07-08 | 下午 | api-gateway | P6 集成测试补充circuit-breaker_test.go5 用例ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted+ ratelimit_test.go5 用例AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets+ test-backup-mysql.sh8 用例 17 断言)。**学到**gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=trueHALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN测试需通过行为503 vs 500而非状态字段验证rateLimiter cleanup 测试需用短周期参数50ms/500ms加速且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chartnamespace/configmap/secret/ingress/hpa + 4 环境 values 文件)+ api-gateway 服务级 chart完整迁移自原 deployment.yaml参数化所有字段+ 6 业务服务 chart 桩iam/core-edu/content/msg/data-ana/ai。删除原 api-gateway-deployment.yaml保留 namespace.yaml。**学到**Helm `{{- with ... -}}` 双向修剪会导致标签连在一行(`managed-by: Helmpart-of: edu-platform`),应改为 `{{- with ... }}` 只修剪左侧;`helm lint` 全部通过但 `helm template` 才能发现 YAML 渲染错误,验证时两个都要跑。 |
| 2026-07-07 | 全天 | 全局 | 文档体系初始化从旧项目e:\Desktop\CICDNext.js 单体)迁移 spec + plan + known-issues 模板到新仓库e:\Desktop\Edu微服务架构。known-issues 重组为微服务分区:多语言 monorepo / Docker Compose / protobuf+buf / NestJS / Go Gateway / 可观测性 / 微前端。从旧项目提炼可迁移经验React 19 useOptimistic / Zustand 细粒度选择器 / Tiptap SSR / 请求级去重 / 批量 SQL / 动态导入模式 / arch:scan 串行执行。新增微服务特有经验:契约先行 / Outbox / CDC / 双轨读 / DataScope / 黄金模板复制流程。路线图按 6 阶段组织P1 地基 → P2 身份 → P3 核心教学 → P4 内容分析 → P5 沟通AI → P6 硬化。 |

View File

@@ -1,4 +1,35 @@
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

9
pnpm-lock.yaml generated
View File

@@ -251,6 +251,9 @@ importers:
'@nestjs/cli': '@nestjs/cli':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.9 version: 10.4.9
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.20.0 version: 22.20.0
@@ -479,6 +482,9 @@ importers:
'@nestjs/cli': '@nestjs/cli':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.9 version: 10.4.9
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.20.0 version: 22.20.0
@@ -543,6 +549,9 @@ importers:
eslint: eslint:
specifier: ^9.10.0 specifier: ^9.10.0
version: 9.39.4(jiti@2.6.1) version: 9.39.4(jiti@2.6.1)
pino-pretty:
specifier: ^11.2.0
version: 11.3.0
typescript: typescript:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 version: 5.9.3

View File

@@ -12,7 +12,7 @@ class Settings(BaseSettings):
openai_base_url: str = "https://api.openai.com/v1" openai_base_url: str = "https://api.openai.com/v1"
anthropic_api_key: str = "" anthropic_api_key: str = ""
# 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错 # 开发模式true 时跳过 OTel exporter 初始化,避免本地无 collector 时报错
dev_mode: str = "false" dev_mode: bool = False
# 可观测性 # 可观测性
otel_endpoint: str = "http://localhost:4318" otel_endpoint: str = "http://localhost:4318"
log_level: str = "info" log_level: str = "info"
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
@property @property
def is_dev(self) -> bool: def is_dev(self) -> bool:
"""是否处于开发模式.""" """是否处于开发模式."""
return self.dev_mode.lower() == "true" return self.dev_mode
@property @property
def llm_available(self) -> bool: def llm_available(self) -> bool:

View File

@@ -41,7 +41,7 @@ def init_tracer() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期.""" """应用生命周期."""
init_tracer() init_tracer()
logger.info( logger.info(
@@ -89,6 +89,12 @@ class ChatResponse(BaseModel):
degraded: bool = False degraded: bool = False
class QuestionRequest(BaseModel):
"""题目生成请求."""
prompt: str
def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]: def _extract_content(result: dict[str, Any] | None) -> tuple[str, str, dict[str, Any]]:
"""从 OpenAI 响应中抽取 (content, model, usage)。""" """从 OpenAI 响应中抽取 (content, model, usage)。"""
if result is None: if result is None:
@@ -171,7 +177,7 @@ async def chat_stream(req: ChatRequest) -> StreamingResponse:
@router.post("/generate/question") @router.post("/generate/question")
async def generate_question(prompt: str) -> dict[str, Any]: async def generate_question(req: QuestionRequest) -> dict[str, Any]:
"""生成题目(无 API key 时降级返回骨架).""" """生成题目(无 API key 时降级返回骨架)."""
with tracer.start_as_current_span("generate_question"): with tracer.start_as_current_span("generate_question"):
messages = [ messages = [
@@ -180,7 +186,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
"content": "You are an educational question generator. " "content": "You are an educational question generator. "
"Generate a clear, concise question based on the user's prompt.", "Generate a clear, concise question based on the user's prompt.",
}, },
{"role": "user", "content": prompt}, {"role": "user", "content": req.prompt},
] ]
result = await chat_completion( result = await chat_completion(
messages=messages, messages=messages,
@@ -190,7 +196,7 @@ async def generate_question(prompt: str) -> dict[str, Any]:
base_url=settings.openai_base_url, base_url=settings.openai_base_url,
) )
if result is None: if result is None:
logger.warning("generate_question_degraded", prompt=prompt[:100]) logger.warning("generate_question_degraded", prompt=req.prompt[:100])
return { return {
"success": True, "success": True,
"data": {"question": "[degraded] question generation skeleton"}, "data": {"question": "[degraded] question generation skeleton"},

View File

@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/sony/gobreaker/v2 v2.1.0 github.com/sony/gobreaker/v2 v2.1.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel v1.44.0
@@ -14,6 +15,7 @@ require (
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -40,7 +42,11 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.59.1 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect github.com/redis/go-redis/v9 v9.7.0 // indirect
@@ -52,6 +58,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.27.0 // indirect golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect

View File

@@ -2,6 +2,8 @@ github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZp
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -74,8 +76,16 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -85,10 +95,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
@@ -97,6 +117,8 @@ github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo=
github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM= github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM=
github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg= github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -148,6 +170,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
@@ -171,6 +195,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,10 +1,12 @@
package config package config
import ( import (
"log"
"os" "os"
"strconv" "strconv"
) )
// Config 持有 api-gateway 运行时配置
type Config struct { type Config struct {
Port string Port string
JWTSecret string JWTSecret string
@@ -23,10 +25,26 @@ type Config struct {
DevMode bool DevMode bool
} }
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET
const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。
func Load() *Config { func Load() *Config {
devMode := getEnvBool("DEV_MODE", false)
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
if devMode {
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
}
return &Config{ return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"), Port: getEnv("API_GATEWAY_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"), JWTSecret: jwtSecret,
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"), JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"), JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
@@ -39,10 +57,11 @@ func Load() *Config {
AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"), AiServiceURL: getEnv("AI_SERVICE_URL", "http://localhost:3008"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false), DevMode: devMode,
} }
} }
// getEnv 读取环境变量,缺失时返回 fallback
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
@@ -50,15 +69,7 @@ func getEnv(key, fallback string) string {
return fallback return fallback
} }
func getEnvInt(key string, fallback int) int { // getEnvBool 读取环境变量并解析为 bool缺失或解析失败时返回 fallback
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
func getEnvBool(key string, fallback bool) bool { func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil { if b, err := strconv.ParseBool(v); err == nil {

View File

@@ -7,7 +7,6 @@ import (
"github.com/edu-cloud/api-gateway/internal/config" "github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径) // publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -73,7 +72,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return return
} }
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid return nil, jwt.ErrSignatureInvalid
} }
@@ -107,7 +106,7 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
if sub, ok := claims["sub"].(string); ok { if sub, ok := claims["sub"].(string); ok {
c.Request.Header.Set("x-user-id", sub) c.Request.Header.Set("x-user-id", sub)
} }
if roles, ok := claims["roles"].([]interface{}); ok { if roles, ok := claims["roles"].([]any); ok {
roleStrs := make([]string, 0, len(roles)) roleStrs := make([]string, 0, len(roles))
for _, r := range roles { for _, r := range roles {
if s, ok := r.(string); ok { if s, ok := r.(string); ok {
@@ -120,22 +119,3 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// RequestIDMiddleware 注入请求 ID 用于全链路追踪
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateUUID()
}
c.Set("request_id", requestID)
c.Writer.Header().Set("X-Request-ID", requestID)
c.Next()
}
}
// generateUUID 生成带 req- 前缀的唯一请求 ID
// 使用 uuid.New() 基于 RFC 4122 v4 随机 UUID避免 time.Now() 产生的冲突与可预测性
func generateUUID() string {
return "req-" + uuid.New().String()
}

View File

@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@@ -12,22 +13,26 @@ import (
// corsMaxAge 预检缓存时长12 小时 // corsMaxAge 预检缓存时长12 小时
const corsMaxAge = 12 * 60 * 60 const corsMaxAge = 12 * 60 * 60
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
// CORS 返回跨域资源共享中间件。 // CORS 返回跨域资源共享中间件。
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔,默认 *)。 // 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔
// 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。
// 允许方法GET POST PUT DELETE OPTIONS PATCH // 允许方法GET POST PUT DELETE OPTIONS PATCH
// 允许头Authorization Content-Type X-Request-Id X-Trace-Id // 允许头Authorization Content-Type X-Request-Id X-Trace-Id
// 暴露头X-Request-Id X-Trace-Id // 暴露头X-Request-Id X-Trace-Id
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS")) allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS"))
if len(allowed) == 0 {
log.Println("warning: CORS_ORIGINS not set, using dev default whitelist")
allowed = parseCORSOrigins(devCORSOrigins)
}
return func(c *gin.Context) { return func(c *gin.Context) {
origin := c.GetHeader("Origin") origin := c.GetHeader("Origin")
allowOrigin := "" allowOrigin := ""
if allowed[origin] {
if len(allowed) == 0 {
// 未配置则默认允许所有来源
allowOrigin = "*"
} else if allowed[origin] {
allowOrigin = origin allowOrigin = origin
} }
@@ -39,9 +44,7 @@ func CORS() gin.HandlerFunc {
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id") h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge)) h.Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge))
// 非通配来源需标注 Vary便于缓存正确区分 // 非通配来源需标注 Vary便于缓存正确区分
if allowOrigin != "*" { h.Add("Vary", "Origin")
h.Add("Vary", "Origin")
}
} }
// 预检请求直接返回 204 // 预检请求直接返回 204
@@ -53,7 +56,7 @@ func CORS() gin.HandlerFunc {
} }
} }
// parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map(表示通配 * // parseCORSOrigins 解析逗号分隔的来源列表为集合,空字符串返回空 map
func parseCORSOrigins(raw string) map[string]bool { func parseCORSOrigins(raw string) map[string]bool {
allowed := map[string]bool{} allowed := map[string]bool{}
if raw == "" { if raw == "" {

View File

@@ -15,6 +15,7 @@ import (
"github.com/edu-cloud/api-gateway/internal/observability" "github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy" "github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
) )
@@ -51,6 +52,8 @@ func main() {
// 健康检查路由(无需鉴权,在 Auth 之前) // 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz) r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz) r.GET("/readyz", health.Readyz)
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 反向代理 // API v1 组:熔断 + 鉴权 + 反向代理
api := r.Group("/api/v1") api := r.Group("/api/v1")

View File

@@ -1,8 +1,15 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ClassesModule } from "./classes/classes.module.js"; import { ClassesModule } from "./classes/classes.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [ClassesModule, HealthModule], imports: [ClassesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -7,11 +7,15 @@ import {
Post, Post,
Put, Put,
Req, Req,
} from '@nestjs/common'; } from "@nestjs/common";
import { ClassesService } from './classes.service.js'; import { ClassesService } from "./classes.service.js";
import { createClassSchema, updateClassSchema } from './classes.dto.js'; import { createClassSchema, updateClassSchema } from "./classes.dto.js";
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js'; import {
import type { Class } from './classes.schema.js'; Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import type { Class } from "./classes.schema.js";
interface ClassResponse { interface ClassResponse {
id: string; id: string;
@@ -28,11 +32,12 @@ interface SuccessResponse<T> {
data: T; data: T;
} }
@Controller('classes') @Controller("classes")
export class ClassesController { export class ClassesController {
constructor(private readonly service: ClassesService) {} constructor(private readonly service: ClassesService) {}
@Post() @Post()
@RequirePermission(Permissions.CLASSES_CREATE)
async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> { async create(@Body() body: unknown): Promise<SuccessResponse<ClassResponse>> {
const dto = createClassSchema.parse(body); const dto = createClassSchema.parse(body);
const result = await this.service.create(dto); const result = await this.service.create(dto);
@@ -40,21 +45,32 @@ export class ClassesController {
} }
@Get() @Get()
async list(@Req() req: AuthenticatedRequest): Promise<SuccessResponse<ClassResponse[]>> { @RequirePermission(Permissions.CLASSES_READ)
const gradeId = req.query.gradeId as string | undefined; async list(
@Req() req: AuthenticatedRequest,
): Promise<SuccessResponse<ClassResponse[]>> {
const gradeIdRaw = req.query.gradeId;
const gradeId = typeof gradeIdRaw === "string" ? gradeIdRaw : undefined;
const result = await this.service.list(gradeId); const result = await this.service.list(gradeId);
return { success: true as const, data: result.map((c) => this.toResponse(c)) }; return {
success: true as const,
data: result.map((c) => this.toResponse(c)),
};
} }
@Get(':id') @Get(":id")
async getById(@Param('id') id: string): Promise<SuccessResponse<ClassResponse>> { @RequirePermission(Permissions.CLASSES_READ)
async getById(
@Param("id") id: string,
): Promise<SuccessResponse<ClassResponse>> {
const result = await this.service.getById(id); const result = await this.service.getById(id);
return { success: true as const, data: this.toResponse(result) }; return { success: true as const, data: this.toResponse(result) };
} }
@Put(':id') @Put(":id")
@RequirePermission(Permissions.CLASSES_UPDATE)
async update( async update(
@Param('id') id: string, @Param("id") id: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<SuccessResponse<ClassResponse>> { ): Promise<SuccessResponse<ClassResponse>> {
const dto = updateClassSchema.parse(body); const dto = updateClassSchema.parse(body);
@@ -62,8 +78,9 @@ export class ClassesController {
return { success: true as const, data: this.toResponse(result) }; return { success: true as const, data: this.toResponse(result) };
} }
@Delete(':id') @Delete(":id")
async delete(@Param('id') id: string): Promise<{ success: true }> { @RequirePermission(Permissions.CLASSES_DELETE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id); await this.service.delete(id);
return { success: true as const }; return { success: true as const };
} }

View File

@@ -1,14 +1,18 @@
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { getDb } from '../config/database.js'; import { getDb } from "../config/database.js";
import { classes, type Class, type NewClass } from './classes.schema.js'; import { classes, type Class, type NewClass } from "./classes.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class ClassesRepository { export class ClassesRepository {
async create(data: NewClass): Promise<Class> { async create(data: NewClass): Promise<Class> {
const db = getDb(); const db = getDb();
await db.insert(classes).values(data); await db.insert(classes).values(data);
const [result] = await db.select().from(classes).where(eq(classes.id, data.id)); const [result] = await db
.select()
.from(classes)
.where(eq(classes.id, data.id));
if (!result) { if (!result) {
throw new Error('Failed to read created class'); throw new DatabaseError("Failed to read created class");
} }
return result; return result;
} }
@@ -27,7 +31,10 @@ export class ClassesRepository {
return db.select().from(classes); return db.select().from(classes);
} }
async update(id: string, data: Partial<NewClass>): Promise<Class | undefined> { async update(
id: string,
data: Partial<NewClass>,
): Promise<Class | undefined> {
const db = getDb(); const db = getDb();
await db.update(classes).set(data).where(eq(classes.id, id)); await db.update(classes).set(data).where(eq(classes.id, id));
const [result] = await db.select().from(classes).where(eq(classes.id, id)); const [result] = await db.select().from(classes).where(eq(classes.id, id));

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from "drizzle-orm/mysql2";
import mysql from 'mysql2/promise'; import type { MySql2Database } from "drizzle-orm/mysql2";
import { env } from './env.js'; import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
export function getDb() { export function getDb(): MySql2Database {
if (!pool) { if (!pool) {
pool = mysql.createPool({ pool = mysql.createPool({
uri: env.DATABASE_URL, uri: env.DATABASE_URL,

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 // 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => { app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; import {
import { Request, Response, NextFunction } from 'express'; Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息 // 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const rolesHeader = req.headers['x-user-roles'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedException('Missing x-user-id header'); throw new UnauthorizedException("Missing x-user-id header");
} }
req.userId = userId; req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : []; req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next(); next();
} }
} }

View File

@@ -1,50 +1,71 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import {
import type { Reflector } from '@nestjs/core'; Injectable,
import { PermissionDeniedError } from '../shared/errors/application-error.js'; CanActivate,
import type { AuthenticatedRequest } from './auth.middleware.js'; ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission = export type Permission =
| 'CLASS_CREATE' "CLASSES_CREATE" | "CLASSES_READ" | "CLASSES_UPDATE" | "CLASSES_DELETE";
| 'CLASS_READ'
| 'CLASS_UPDATE'
| 'CLASS_DELETE';
export const Permissions = { export const Permissions = {
CLASS_CREATE: 'CLASS_CREATE' as const, CLASSES_CREATE: "CLASSES_CREATE" as const,
CLASS_READ: 'CLASS_READ' as const, CLASSES_READ: "CLASSES_READ" as const,
CLASS_UPDATE: 'CLASS_UPDATE' as const, CLASSES_UPDATE: "CLASSES_UPDATE" as const,
CLASS_DELETE: 'CLASS_DELETE' as const, CLASSES_DELETE: "CLASSES_DELETE" as const,
}; };
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = { const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE], admin: [
teacher: [Permissions.CLASS_CREATE, Permissions.CLASS_READ, Permissions.CLASS_UPDATE], Permissions.CLASSES_CREATE,
student: [Permissions.CLASS_READ], Permissions.CLASSES_READ,
parent: [Permissions.CLASS_READ], Permissions.CLASSES_UPDATE,
Permissions.CLASSES_DELETE,
],
teacher: [
Permissions.CLASSES_CREATE,
Permissions.CLASSES_READ,
Permissions.CLASSES_UPDATE,
],
student: [Permissions.CLASSES_READ],
parent: [Permissions.CLASSES_READ],
}; };
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? []; const roles = request.userRoles ?? [];
for (const role of roles) { for (const role of roles) {
const perms = ROLE_PERMISSIONS[role]; const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) { if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true; return true;
} }
} }
throw new PermissionDeniedError(this.requiredPermission); throw new PermissionDeniedError(requiredPermissions.join(", "));
} }
} }
// 工厂函数用于装饰器修复import Reflector 已移至文件顶部)
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; import {
import { Request, Response } from 'express'; Catch,
import { ZodError } from 'zod'; ExceptionFilter,
import { ApplicationError } from './application-error.js'; ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'CLASSES_VALIDATION_ERROR', code: "CLASSES_VALIDATION_ERROR",
message: 'Validation failed', message: "Validation failed",
details: exception.flatten(), details: exception.flatten(),
traceId, traceId,
}, },
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'HTTP_ERROR', code: "HTTP_ERROR",
message, message,
traceId, traceId,
}, },
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'An unexpected error occurred', message: "An unexpected error occurred",
traceId, traceId,
}, },
}; };
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body); response.status(statusCode).json(body);
} }
private extractHttpMessage(res: string | object, exception: HttpException): string { private extractHttpMessage(
if (typeof res === 'string') { res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res; return res;
} }
if (res && typeof res === 'object' && 'message' in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段) // 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }
return exception.message; return exception.message;
} }

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import {
import { DataSource } from 'typeorm'; Injectable,
import type { Redis } from 'ioredis'; Logger,
import type { Producer } from 'kafkajs'; OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'classes'; const SERVICE_NAME = "classes";
/** /**
* 优雅停机服务。 * 优雅停机服务。
@@ -12,52 +15,31 @@ const SERVICE_NAME = 'classes';
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。 * 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。 * K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
* *
* 关闭顺序Kafka producer → Redis → DataSource * Classes 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 先停外部消息生产避免新事件,再关缓存,最后关 DB * 关闭时仅需关闭数据库连接池
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
*/ */
@Injectable() @Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void { onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`); this.logger.log(`service ${SERVICE_NAME} module initialized`);
} }
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
); );
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect()); try {
await this.safeDisconnect('redis', () => this.redis.quit()); await closeDb();
await this.safeDisconnect('datasource', () => this.dataSource.destroy()); this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`); this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
} }
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} }

View File

@@ -31,6 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"

View File

@@ -1,9 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { TextbooksModule } from "./textbooks/textbooks.module.js"; import { TextbooksModule } from "./textbooks/textbooks.module.js";
import { ChaptersModule } from "./chapters/chapters.module.js"; import { ChaptersModule } from "./chapters/chapters.module.js";
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js"; import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "./questions/questions.module.js"; import { QuestionsModule } from "./questions/questions.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [ imports: [
@@ -13,5 +16,9 @@ import { HealthModule } from "./shared/health/health.module.js";
QuestionsModule, QuestionsModule,
HealthModule, HealthModule,
], ],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -13,12 +13,17 @@ import {
type UpdateChapterInput, type UpdateChapterInput,
} from "./chapters.service.js"; } from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js"; import type { Chapter } from "./chapters.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("chapters") @Controller("chapters")
export class ChaptersController { export class ChaptersController {
constructor(private readonly service: ChaptersService) {} constructor(private readonly service: ChaptersService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
async create( async create(
@Body() body: CreateChapterInput, @Body() body: CreateChapterInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class ChaptersController {
} }
@Get("textbook/:textbookId") @Get("textbook/:textbookId")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async listByTextbook( async listByTextbook(
@Param("textbookId") textbookId: string, @Param("textbookId") textbookId: string,
): Promise<{ success: true; data: Chapter[] }> { ): Promise<{ success: true; data: Chapter[] }> {
@@ -35,6 +41,7 @@ export class ChaptersController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Chapter }> { ): Promise<{ success: true; data: Chapter }> {
@@ -43,6 +50,7 @@ export class ChaptersController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateChapterInput, @Body() body: UpdateChapterInput,
@@ -52,6 +60,7 @@ export class ChaptersController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_CHAPTER_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -14,12 +14,17 @@ import {
type PrerequisiteNode, type PrerequisiteNode,
} from "./knowledge-points.service.js"; } from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js"; import type { KnowledgePoint } from "./knowledge-points.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("knowledge-points") @Controller("knowledge-points")
export class KnowledgePointsController { export class KnowledgePointsController {
constructor(private readonly service: KnowledgePointsService) {} constructor(private readonly service: KnowledgePointsService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create( async create(
@Body() body: CreateKnowledgePointInput, @Body() body: CreateKnowledgePointInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -28,6 +33,7 @@ export class KnowledgePointsController {
} }
@Get("chapter/:chapterId") @Get("chapter/:chapterId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async listByChapter( async listByChapter(
@Param("chapterId") chapterId: string, @Param("chapterId") chapterId: string,
): Promise<{ success: true; data: KnowledgePoint[] }> { ): Promise<{ success: true; data: KnowledgePoint[] }> {
@@ -36,6 +42,7 @@ export class KnowledgePointsController {
} }
@Get(":id/prerequisites") @Get(":id/prerequisites")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getPrerequisites( async getPrerequisites(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: PrerequisiteNode[] }> { ): Promise<{ success: true; data: PrerequisiteNode[] }> {
@@ -44,6 +51,7 @@ export class KnowledgePointsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: KnowledgePoint }> { ): Promise<{ success: true; data: KnowledgePoint }> {
@@ -52,6 +60,7 @@ export class KnowledgePointsController {
} }
@Post(":id/prerequisites/:prerequisiteId") @Post(":id/prerequisites/:prerequisiteId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async addPrerequisite( async addPrerequisite(
@Param("id") id: string, @Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string, @Param("prerequisiteId") prerequisiteId: string,
@@ -61,6 +70,7 @@ export class KnowledgePointsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateKnowledgePointInput, @Body() body: UpdateKnowledgePointInput,
@@ -70,6 +80,7 @@ export class KnowledgePointsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -8,6 +8,7 @@ import { closeDb } from "./config/database.js";
import { closeNeo4j } from "./config/neo4j.js"; import { closeNeo4j } from "./config/neo4j.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -20,8 +21,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,105 @@
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
CONTENT_TEXTBOOK_CREATE: "CONTENT_TEXTBOOK_CREATE" as const,
CONTENT_TEXTBOOK_READ: "CONTENT_TEXTBOOK_READ" as const,
CONTENT_TEXTBOOK_UPDATE: "CONTENT_TEXTBOOK_UPDATE" as const,
CONTENT_TEXTBOOK_DELETE: "CONTENT_TEXTBOOK_DELETE" as const,
CONTENT_CHAPTER_CREATE: "CONTENT_CHAPTER_CREATE" as const,
CONTENT_CHAPTER_READ: "CONTENT_CHAPTER_READ" as const,
CONTENT_CHAPTER_UPDATE: "CONTENT_CHAPTER_UPDATE" as const,
CONTENT_CHAPTER_DELETE: "CONTENT_CHAPTER_DELETE" as const,
CONTENT_QUESTION_CREATE: "CONTENT_QUESTION_CREATE" as const,
CONTENT_QUESTION_READ: "CONTENT_QUESTION_READ" as const,
CONTENT_QUESTION_UPDATE: "CONTENT_QUESTION_UPDATE" as const,
CONTENT_QUESTION_DELETE: "CONTENT_QUESTION_DELETE" as const,
CONTENT_KNOWLEDGE_POINT_CREATE: "CONTENT_KNOWLEDGE_POINT_CREATE" as const,
CONTENT_KNOWLEDGE_POINT_READ: "CONTENT_KNOWLEDGE_POINT_READ" as const,
CONTENT_KNOWLEDGE_POINT_UPDATE: "CONTENT_KNOWLEDGE_POINT_UPDATE" as const,
CONTENT_KNOWLEDGE_POINT_DELETE: "CONTENT_KNOWLEDGE_POINT_DELETE" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.CONTENT_TEXTBOOK_CREATE,
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_TEXTBOOK_UPDATE,
Permissions.CONTENT_TEXTBOOK_DELETE,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_CHAPTER_DELETE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_QUESTION_DELETE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_DELETE,
],
teacher: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_CREATE,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_CHAPTER_UPDATE,
Permissions.CONTENT_QUESTION_CREATE,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_QUESTION_UPDATE,
Permissions.CONTENT_KNOWLEDGE_POINT_CREATE,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE,
],
student: [
Permissions.CONTENT_TEXTBOOK_READ,
Permissions.CONTENT_CHAPTER_READ,
Permissions.CONTENT_QUESTION_READ,
Permissions.CONTENT_KNOWLEDGE_POINT_READ,
],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -13,12 +13,17 @@ import {
type UpdateQuestionInput, type UpdateQuestionInput,
} from "./questions.service.js"; } from "./questions.service.js";
import type { Question } from "./questions.schema.js"; import type { Question } from "./questions.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("questions") @Controller("questions")
export class QuestionsController { export class QuestionsController {
constructor(private readonly service: QuestionsService) {} constructor(private readonly service: QuestionsService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create( async create(
@Body() body: CreateQuestionInput, @Body() body: CreateQuestionInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,6 +32,7 @@ export class QuestionsController {
} }
@Get("knowledge-point/:knowledgePointId") @Get("knowledge-point/:knowledgePointId")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async listByKnowledgePoint( async listByKnowledgePoint(
@Param("knowledgePointId") knowledgePointId: string, @Param("knowledgePointId") knowledgePointId: string,
): Promise<{ success: true; data: Question[] }> { ): Promise<{ success: true; data: Question[] }> {
@@ -35,6 +41,7 @@ export class QuestionsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Question }> { ): Promise<{ success: true; data: Question }> {
@@ -43,6 +50,7 @@ export class QuestionsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateQuestionInput, @Body() body: UpdateQuestionInput,
@@ -52,6 +60,7 @@ export class QuestionsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_QUESTION_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -5,6 +5,7 @@ import {
HttpException, HttpException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js"; import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例, const response = ctx.getResponse<Response>();
// 但 content 服务未引入 @types/express此处按 core-edu 模式不显式标注类型。 const request = ctx.getRequest<Request>();
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceIdHeader = request.headers["x-request-id"];
const traceId = const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown"; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode; statusCode = exception.statusCode;
body = exception.toJSON(); body = exception.toJSON();
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400; statusCode = 400;
body = { body = {
success: false, success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res; return res;
} }
if (res && typeof res === "object" && "message" in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }

View File

@@ -1,13 +1,22 @@
import { Injectable, Logger } from "@nestjs/common"; import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js"; import { closeDb } from "../../config/database.js";
import { closeNeo4j } from "../../config/neo4j.js"; import { closeNeo4j } from "../../config/neo4j.js";
const SERVICE_NAME = "content"; const SERVICE_NAME = "content";
@Injectable() @Injectable()
export class LifecycleService { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -13,12 +13,17 @@ import {
type UpdateTextbookInput, type UpdateTextbookInput,
} from "./textbooks.service.js"; } from "./textbooks.service.js";
import type { Textbook } from "./textbooks.schema.js"; import type { Textbook } from "./textbooks.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("textbooks") @Controller("textbooks")
export class TextbooksController { export class TextbooksController {
constructor(private readonly service: TextbooksService) {} constructor(private readonly service: TextbooksService) {}
@Post() @Post()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
async create( async create(
@Body() body: CreateTextbookInput, @Body() body: CreateTextbookInput,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
@@ -27,12 +32,14 @@ export class TextbooksController {
} }
@Get() @Get()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async list(): Promise<{ success: true; data: Textbook[] }> { async list(): Promise<{ success: true; data: Textbook[] }> {
const data = await this.service.list(); const data = await this.service.list();
return { success: true, data }; return { success: true, data };
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async getById( async getById(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: Textbook }> { ): Promise<{ success: true; data: Textbook }> {
@@ -41,6 +48,7 @@ export class TextbooksController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateTextbookInput, @Body() body: UpdateTextbookInput,
@@ -50,6 +58,7 @@ export class TextbooksController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.CONTENT_TEXTBOOK_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,10 +1,17 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ExamsModule } from "./exams/exams.module.js"; import { ExamsModule } from "./exams/exams.module.js";
import { HomeworkModule } from "./homework/homework.module.js"; import { HomeworkModule } from "./homework/homework.module.js";
import { GradesModule } from "./grades/grades.module.js"; import { GradesModule } from "./grades/grades.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule], imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -8,23 +8,32 @@ import {
Put, Put,
Req, Req,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request } from "express";
import { import {
ExamsService, ExamsService,
type CreateExamInput, type CreateExamInput,
type UpdateExamInput, type UpdateExamInput,
} from "./exams.service.js"; } from "./exams.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("exams") @Controller("exams")
export class ExamsController { export class ExamsController {
constructor(private readonly examsService: ExamsService) {} constructor(private readonly examsService: ExamsService) {}
@Post() @Post()
@RequirePermission(Permissions.EXAM_CREATE)
async create( async create(
@Body() body: CreateExamInput, @Body() body: CreateExamInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.examsService.createExam({ const result = await this.examsService.createExam({
...body, ...body,
createdBy: userId, createdBy: userId,
@@ -33,6 +42,7 @@ export class ExamsController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.EXAM_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<ExamsService["getExam"]>>; data: Awaited<ReturnType<ExamsService["getExam"]>>;
@@ -42,6 +52,7 @@ export class ExamsController {
} }
@Get("class/:classId") @Get("class/:classId")
@RequirePermission(Permissions.EXAM_READ)
async listByClass(@Param("classId") classId: string): Promise<{ async listByClass(@Param("classId") classId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>; data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
@@ -51,6 +62,7 @@ export class ExamsController {
} }
@Put(":id") @Put(":id")
@RequirePermission(Permissions.EXAM_UPDATE)
async update( async update(
@Param("id") id: string, @Param("id") id: string,
@Body() body: UpdateExamInput, @Body() body: UpdateExamInput,
@@ -60,6 +72,7 @@ export class ExamsController {
} }
@Delete(":id") @Delete(":id")
@RequirePermission(Permissions.EXAM_DELETE)
async remove( async remove(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -1,17 +1,26 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { GradesService, type RecordGradeInput } from "./grades.service.js"; import { GradesService, type RecordGradeInput } from "./grades.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("grades") @Controller("grades")
export class GradesController { export class GradesController {
constructor(private readonly gradesService: GradesService) {} constructor(private readonly gradesService: GradesService) {}
@Post() @Post()
@RequirePermission(Permissions.GRADE_CREATE)
async record( async record(
@Body() body: RecordGradeInput, @Body() body: RecordGradeInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.gradesService.recordGrade({ const result = await this.gradesService.recordGrade({
...body, ...body,
gradedBy: userId, gradedBy: userId,
@@ -20,6 +29,7 @@ export class GradesController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.GRADE_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["getGrade"]>>; data: Awaited<ReturnType<GradesService["getGrade"]>>;
@@ -29,6 +39,7 @@ export class GradesController {
} }
@Get("student/:studentId") @Get("student/:studentId")
@RequirePermission(Permissions.GRADE_READ)
async listByStudent(@Param("studentId") studentId: string): Promise<{ async listByStudent(@Param("studentId") studentId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByStudent"]>>; data: Awaited<ReturnType<GradesService["listByStudent"]>>;
@@ -38,6 +49,7 @@ export class GradesController {
} }
@Get("exam/:examId") @Get("exam/:examId")
@RequirePermission(Permissions.GRADE_READ)
async listByExam(@Param("examId") examId: string): Promise<{ async listByExam(@Param("examId") examId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByExam"]>>; data: Awaited<ReturnType<GradesService["listByExam"]>>;
@@ -47,6 +59,7 @@ export class GradesController {
} }
@Get("homework/:homeworkId") @Get("homework/:homeworkId")
@RequirePermission(Permissions.GRADE_READ)
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{ async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<GradesService["listByHomework"]>>; data: Awaited<ReturnType<GradesService["listByHomework"]>>;

View File

@@ -1,20 +1,29 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { import {
HomeworkService, HomeworkService,
type AssignHomeworkInput, type AssignHomeworkInput,
} from "./homework.service.js"; } from "./homework.service.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller("homework") @Controller("homework")
export class HomeworkController { export class HomeworkController {
constructor(private readonly homeworkService: HomeworkService) {} constructor(private readonly homeworkService: HomeworkService) {}
@Post() @Post()
@RequirePermission(Permissions.HOMEWORK_CREATE)
async assign( async assign(
@Body() body: AssignHomeworkInput, @Body() body: AssignHomeworkInput,
@Req() req: Request, @Req() req: AuthenticatedRequest,
): Promise<{ success: true; data: { id: string } }> { ): Promise<{ success: true; data: { id: string } }> {
const userId = req.headers["x-user-id"] as string; const userId = req.userId;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const result = await this.homeworkService.assignHomework({ const result = await this.homeworkService.assignHomework({
...body, ...body,
createdBy: userId, createdBy: userId,
@@ -23,6 +32,7 @@ export class HomeworkController {
} }
@Get(":id") @Get(":id")
@RequirePermission(Permissions.HOMEWORK_READ)
async findOne(@Param("id") id: string): Promise<{ async findOne(@Param("id") id: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<HomeworkService["getHomework"]>>; data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
@@ -32,6 +42,7 @@ export class HomeworkController {
} }
@Get("class/:classId") @Get("class/:classId")
@RequirePermission(Permissions.HOMEWORK_READ)
async listByClass(@Param("classId") classId: string): Promise<{ async listByClass(@Param("classId") classId: string): Promise<{
success: true; success: true;
data: Awaited<ReturnType<HomeworkService["listByClass"]>>; data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
@@ -41,6 +52,7 @@ export class HomeworkController {
} }
@Post(":id/submit") @Post(":id/submit")
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
async submit( async submit(
@Param("id") id: string, @Param("id") id: string,
): Promise<{ success: true; data: { success: true } }> { ): Promise<{ success: true; data: { success: true } }> {

View File

@@ -7,6 +7,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { registry } from "./shared/observability/metrics.js"; import { registry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -16,8 +17,7 @@ async function bootstrap(): Promise<void> {
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", registry.contentType); res.set("Content-Type", registry.contentType);
res.end(await registry.metrics()); res.end(await registry.metrics());
}); });

View File

@@ -2,39 +2,29 @@ import {
Injectable, Injectable,
NestMiddleware, NestMiddleware,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from "@nestjs/common";
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedUser {
id: string;
role: string;
permissions: string[];
}
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; userId?: string;
userRoles?: string[];
} }
@Injectable() @Injectable()
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const role = req.headers['x-user-role'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const permissionsHeader = req.headers['x-user-permissions'] as const rolesHeaderRaw = req.headers["x-user-roles"];
| string const rolesHeader =
| undefined; typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId || !role) { if (!userId) {
throw new UnauthorizedException( throw new UnauthorizedException("Missing x-user-id header");
'Missing authentication headers (x-user-id, x-user-role)',
);
} }
req.user = { req.userId = userId;
id: userId, req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
role,
permissions: permissionsHeader ? permissionsHeader.split(',') : [],
};
next(); next();
} }
} }

View File

@@ -1,48 +1,92 @@
import { import {
Injectable,
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Injectable, SetMetadata,
ForbiddenException, } from "@nestjs/common";
} from '@nestjs/common'; import { Reflector } from "@nestjs/core";
import type { AuthenticatedRequest } from './auth.middleware.js'; import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = { export const Permissions = {
EXAM_CREATE: 'exam:create', EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
EXAM_READ: 'exam:read', EXAM_READ: "CORE_EDU_EXAM_READ" as const,
EXAM_UPDATE: 'exam:update', EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
EXAM_DELETE: 'exam:delete', EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
HOMEWORK_CREATE: 'homework:create', HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
HOMEWORK_READ: 'homework:read', HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
HOMEWORK_UPDATE: 'homework:update', HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
HOMEWORK_DELETE: 'homework:delete', HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
HOMEWORK_GRADE: 'homework:grade', GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
HOMEWORK_SUBMIT: 'homework:submit', GRADE_READ: "CORE_EDU_GRADE_READ" as const,
GRADE_CREATE: 'grade:create',
GRADE_READ: 'grade:read',
GRADE_UPDATE: 'grade:update',
GRADE_DELETE: 'grade:delete',
CLASS_MANAGE: 'class:manage',
CLASS_READ: 'class:read',
CLASS_TRANSFER: 'class:transfer',
} as const; } as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions]; export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_UPDATE,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_CREATE,
Permissions.GRADE_READ,
],
student: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.GRADE_READ,
],
};
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user; const roles = request.userRoles ?? [];
if (!user) {
throw new ForbiddenException('User not authenticated'); for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
} }
if (!user.permissions.includes(this.requiredPermission)) {
throw new ForbiddenException( throw new PermissionDeniedError(requiredPermissions.join(", "));
`Missing permission: ${this.requiredPermission}`,
);
}
return true;
} }
} }

View File

@@ -1,25 +1,45 @@
export enum CoreEduErrorCode { export enum CoreEduErrorCode {
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR', VALIDATION_ERROR = "CORE_EDU_VALIDATION_ERROR",
NOT_FOUND = 'CORE_EDU_NOT_FOUND', NOT_FOUND = "CORE_EDU_NOT_FOUND",
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED', UNAUTHORIZED = "CORE_EDU_UNAUTHORIZED",
FORBIDDEN = 'CORE_EDU_FORBIDDEN', FORBIDDEN = "CORE_EDU_FORBIDDEN",
CONFLICT = 'CORE_EDU_CONFLICT', CONFLICT = "CORE_EDU_CONFLICT",
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR', INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND', EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND', HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND', GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED', OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
} }
export class ApplicationError extends Error { export class ApplicationError extends Error {
readonly code: CoreEduErrorCode;
readonly statusCode: number;
readonly details?: unknown;
traceId?: string;
constructor( constructor(
public readonly code: CoreEduErrorCode, code: CoreEduErrorCode,
message: string, message: string,
public readonly statusCode: number = 500, statusCode: number = 500,
public readonly details?: unknown, details?: unknown,
) { ) {
super(message); super(message);
this.name = 'ApplicationError'; this.name = this.constructor.name;
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
} }
} }
@@ -36,17 +56,25 @@ export class NotFoundError extends ApplicationError {
} }
export class UnauthorizedError extends ApplicationError { export class UnauthorizedError extends ApplicationError {
constructor(message: string = 'Unauthorized') { constructor(message: string = "Unauthorized") {
super(CoreEduErrorCode.UNAUTHORIZED, message, 401); super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
} }
} }
export class ForbiddenError extends ApplicationError { export class ForbiddenError extends ApplicationError {
constructor(message: string = 'Forbidden') { constructor(message: string = "Forbidden") {
super(CoreEduErrorCode.FORBIDDEN, message, 403); super(CoreEduErrorCode.FORBIDDEN, message, 403);
} }
} }
export class PermissionDeniedError extends ApplicationError {
constructor(permission: string) {
super(CoreEduErrorCode.FORBIDDEN, `Permission denied: ${permission}`, 403, {
permission,
});
}
}
export class ConflictError extends ApplicationError { export class ConflictError extends ApplicationError {
constructor(message: string, details?: unknown) { constructor(message: string, details?: unknown) {
super(CoreEduErrorCode.CONFLICT, message, 409, details); super(CoreEduErrorCode.CONFLICT, message, 409, details);
@@ -54,7 +82,7 @@ export class ConflictError extends ApplicationError {
} }
export class InternalError extends ApplicationError { export class InternalError extends ApplicationError {
constructor(message: string = 'Internal server error', details?: unknown) { constructor(message: string = "Internal server error", details?: unknown) {
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details); super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
} }
} }

View File

@@ -1,63 +1,95 @@
import { import {
ExceptionFilter,
Catch, Catch,
ExceptionFilter,
ArgumentsHost, ArgumentsHost,
HttpException, HttpException,
HttpStatus, Logger,
} from '@nestjs/common'; } from "@nestjs/common";
import { ZodError } from 'zod'; import type { Request, Response } from "express";
import { ApplicationError, CoreEduErrorCode } from './application-error.js'; import { ZodError } from "zod";
import { logger } from '../observability/logger.js'; import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; const traceIdHeader = request.headers["x-request-id"];
let code = CoreEduErrorCode.INTERNAL_ERROR; const traceId =
let message = 'Internal server error'; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let details: unknown;
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) { if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode; statusCode = exception.statusCode;
code = exception.code; body = exception.toJSON();
message = exception.message;
details = exception.details;
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
statusCode = HttpStatus.BAD_REQUEST; statusCode = 400;
code = CoreEduErrorCode.VALIDATION_ERROR; body = {
message = 'Validation failed'; success: false,
details = exception.flatten().fieldErrors; error: {
code: "CORE_EDU_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) { } else if (exception instanceof HttpException) {
statusCode = exception.getStatus(); statusCode = exception.getStatus();
const resp = exception.getResponse(); const res = exception.getResponse();
message = const message = this.extractHttpMessage(res, exception);
typeof resp === 'string' body = {
? resp success: false,
: (resp as { message?: string }).message ?? exception.message; error: {
} else if (exception instanceof Error) { code: "HTTP_ERROR",
message = exception.message; message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
} }
logger.error( this.logger.error(
{ {
err: exception, err: exception,
path: request.url, path: request.url,
method: request.method, method: request.method,
code,
}, },
`Request failed: ${message}`, `Request failed: ${request.method} ${request.url}`,
); );
response.status(statusCode).json({ response.status(statusCode).json(body);
code, }
message,
details, private extractHttpMessage(
timestamp: new Date().toISOString(), res: string | object,
path: request.url, exception: HttpException,
}); ): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
} }
} }

View File

@@ -1,12 +1,21 @@
import { Injectable, Logger } from "@nestjs/common"; import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js"; import { closeDb } from "../../config/database.js";
const SERVICE_NAME = "core-edu"; const SERVICE_NAME = "core-edu";
@Injectable() @Injectable()
export class LifecycleService { export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name); private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> { async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log( this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,

View File

@@ -5,6 +5,7 @@
保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。 保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。
""" """
import asyncio
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -68,7 +69,7 @@ async def close_client() -> None:
global _client, _client_initialized global _client, _client_initialized
if _client is not None: if _client is not None:
try: try:
_client.close() await asyncio.to_thread(_client.close)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_client_close_failed", error=str(exc)) logger.warning("clickhouse_client_close_failed", error=str(exc))
finally: finally:
@@ -86,7 +87,8 @@ async def query_dashboard(student_id: str) -> dict | None:
return None return None
try: try:
rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT student_id, class_id, exam_id, subject_id, score, " "SELECT student_id, class_id, exam_id, subject_id, score, "
"rank_in_class, knowledge_point_id, mastery_level, error_count, " "rank_in_class, knowledge_point_id, mastery_level, error_count, "
"last_updated " "last_updated "
@@ -95,7 +97,8 @@ async def query_dashboard(student_id: str) -> dict | None:
"ORDER BY last_updated DESC " "ORDER BY last_updated DESC "
"LIMIT 50", "LIMIT 50",
parameters={"sid": student_id}, parameters={"sid": student_id},
).result_rows )
rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id) logger.warning("query_dashboard_failed_degraded", error=str(exc), student_id=student_id)
return None return None
@@ -131,7 +134,8 @@ async def query_class_performance(class_id: str) -> dict | None:
try: try:
# 平均分、参考人数、及格率(>=60 # 平均分、参考人数、及格率(>=60
agg_rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT " "SELECT "
" count() AS total_students, " " count() AS total_students, "
" avg(score) AS average_score, " " avg(score) AS average_score, "
@@ -139,7 +143,8 @@ async def query_class_performance(class_id: str) -> dict | None:
"FROM student_dashboard_view " "FROM student_dashboard_view "
"WHERE class_id = {cid:String}", "WHERE class_id = {cid:String}",
parameters={"cid": class_id}, parameters={"cid": class_id},
).result_rows )
agg_rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"query_class_performance_failed_degraded", "query_class_performance_failed_degraded",
@@ -175,7 +180,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
return None return None
try: try:
rows = client.query( result = await asyncio.to_thread(
client.query,
"SELECT student_id, question_id, knowledge_point_id, error_count, " "SELECT student_id, question_id, knowledge_point_id, error_count, "
"last_error_time, content " "last_error_time, content "
"FROM student_errors " "FROM student_errors "
@@ -183,7 +189,8 @@ async def query_student_errors(student_id: str) -> list[dict] | None:
"ORDER BY last_error_time DESC " "ORDER BY last_error_time DESC "
"LIMIT 100", "LIMIT 100",
parameters={"sid": student_id}, parameters={"sid": student_id},
).result_rows )
rows = result.result_rows
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"query_student_errors_failed_degraded", "query_student_errors_failed_degraded",
@@ -212,7 +219,7 @@ async def ping() -> bool:
if client is None: if client is None:
return False return False
try: try:
client.query("SELECT 1") await asyncio.to_thread(client.query, "SELECT 1")
return True return True
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("clickhouse_ping_failed", error=str(exc)) logger.warning("clickhouse_ping_failed", error=str(exc))
@@ -241,7 +248,8 @@ async def upsert_student_dashboard(
return False return False
try: try:
client.insert( await asyncio.to_thread(
client.insert,
"student_dashboard_view", "student_dashboard_view",
[ [
[ [
@@ -305,7 +313,8 @@ async def upsert_student_error(
return False return False
try: try:
client.insert( await asyncio.to_thread(
client.insert,
"student_errors", "student_errors",
[ [
[ [

View File

@@ -23,8 +23,8 @@ class Settings(BaseSettings):
# 可观测性 # 可观测性
otel_endpoint: str = "http://localhost:4318" otel_endpoint: str = "http://localhost:4318"
log_level: str = "info" log_level: str = "info"
# 开发模式开关"true"/"false" # 开发模式开关
dev_mode: str = "false" dev_mode: bool = False
# Kafka brokersCDC 消费;留空则不启动消费者) # Kafka brokersCDC 消费;留空则不启动消费者)
# 主机访问用 localhost:9092容器内访问用 kafka:29092 # 主机访问用 localhost:9092容器内访问用 kafka:29092
kafka_brokers: str = "" kafka_brokers: str = ""

View File

@@ -9,11 +9,12 @@
import asyncio import asyncio
import contextlib import contextlib
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
import structlog import structlog
from fastapi import FastAPI from fastapi import APIRouter, FastAPI
from opentelemetry import trace from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
@@ -89,7 +90,7 @@ def init_tracer() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期. """应用生命周期.
1. 初始化 loggerstructlog 1. 初始化 loggerstructlog
@@ -133,6 +134,9 @@ FastAPIInstrumentor.instrument_app(app)
# Prometheus 指标 # Prometheus 指标
app.mount("/metrics", make_asgi_app()) app.mount("/metrics", make_asgi_app())
# 业务路由
router = APIRouter()
@app.get("/healthz") @app.get("/healthz")
async def healthz() -> dict: async def healthz() -> dict:
@@ -189,7 +193,7 @@ async def readyz() -> dict:
} }
@app.get("/analytics/class/{class_id}/performance") @router.get("/analytics/class/{class_id}/performance")
async def class_performance(class_id: str) -> dict: async def class_performance(class_id: str) -> dict:
"""班级成绩分析. """班级成绩分析.
@@ -215,7 +219,7 @@ async def class_performance(class_id: str) -> dict:
return {"success": True, "data": {**result, "degraded": False}} return {"success": True, "data": {**result, "degraded": False}}
@app.get("/analytics/student/{student_id}/weakness") @router.get("/analytics/student/{student_id}/weakness")
async def student_weakness(student_id: str) -> dict: async def student_weakness(student_id: str) -> dict:
"""学生薄弱知识点分析. """学生薄弱知识点分析.
@@ -259,7 +263,7 @@ async def student_weakness(student_id: str) -> dict:
} }
@app.get("/analytics/student/{student_id}/errorbook") @router.get("/analytics/student/{student_id}/errorbook")
async def student_errorbook(student_id: str) -> dict: async def student_errorbook(student_id: str) -> dict:
"""学生错题本. """学生错题本.
@@ -290,3 +294,6 @@ async def student_errorbook(student_id: str) -> dict:
"degraded": False, "degraded": False,
}, },
} }
app.include_router(router)

View File

@@ -0,0 +1,241 @@
# 模块理解确认书 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 1 交付物
> 日期2026-07-09
> 关联:[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[AI 分配方案](../../../docs/architecture/ai-allocation.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
- **层级**L5 业务微服务层004 §3.1 六层架构)
- **业务领域****D1 身份认证领域**004 §1.1b),独立限界上下文,不归属其他领域
- **上游(谁调用我)**
- `api-gateway`Go/GinHTTP 反向代理 `/api/v1/iam/*``http://iam:3002/iam/*`(已注册,见 [api-gateway/main.go](../../api-gateway/main.go) 第 76-83 行)
- `teacher-bff`NestJSHTTP 调用 `/iam/me``/iam/viewports` 聚合教师身份与视口(见 [teacher-bff/teacher.service.ts](../../teacher-bff/src/teacher/teacher.service.ts) 第 30/78 行)
- 未来:`student-bff``parent-bff`(同样走 HTTP 聚合)
- **下游(我调用谁)**
- `MySQL`(独占库 `iam_db`,连接串 `DATABASE_URL`
- `Redis`P2 待启用:权限缓存 + refresh token 黑名单env 已留 `REDIS_URL` 占位)
- `Kafka`P2 待启用:发布 `edu.identity.user.*` 事件,由 core-edu/msg 消费)
- **通信方式**
- 入口:**HTTP/REST**(当前实现,端口 3002
- 出口MySQLmysql2 连接池 + Drizzle ORM、Redis待接、Kafka待接
- 演进P3 起对外暴露 gRPC`iam.proto` 已定义 `IamService`REST 与 gRPC 并存过渡期)
- **不持有跨服务状态**:会话/token 黑名单放 Redis不落本地内存
## 2. 我的限界上下文
### 2.1 我负责的聚合 / 实体
| 聚合根 | 实体 / 值对象 | 当前表 | 职责 |
| ----------------------------------- | --------------------- | ---------------------- | ------------------------------------ |
| **User**(用户) | UserStatus、DataScope | `iam_users` | 注册、登录、密码校验、用户信息查询 |
| **Role**(角色) | — | `iam_roles` | 三层角色模型(系统/组织/临时)的载体 |
| **Permission**(权限点) | resource + action | `iam_permissions` | 权限点常量集中表 |
| **RolePermission**(角色-权限映射) | — | `iam_role_permissions` | 多对多,权限并集来源 |
| **UserRole**(用户-角色绑定) | — | `iam_user_roles` | 用户拥有的多层角色 |
| **RoleViewport**(角色-视口配置) | componentConfig(JSON) | `iam_role_viewports` | 4 层视口模型的 L1 导航 + L3 组件配置 |
| **RefreshToken**(刷新令牌) | tokenHash + revokedAt | `iam_refresh_tokens` | refresh token 持久化 + 轮换/撤销 |
### 2.2 业务领域归属
- **D1 身份认证领域**004 §1.1biam 独占此领域
- 上游依赖:无(身份认证是整个系统的权限中枢,不依赖其他业务领域)
- 下游被依赖core-edu消费 `UserRegistered` 初始化默认班级关联、msg消费 `UserRegistered` 发欢迎通知)
### 2.3 我不负责什么(边界外)
- ❌ 班级/学科/年级数据 → `core-edu`D2 教学组织)
- ❌ 考试/作业/成绩 → `core-edu`D3 教学核心)
- ❌ 教材/知识点/题库 → `content`D4 内容资源)
- ❌ 站内信/通知投递 → `msg`D5 沟通通知)
- ❌ 学情分析/掌握度计算 → `data-ana`D6 智能洞察)
- ❌ WebSocket 长连接管理 → `push-gateway`
- ❌ JWT 公钥校验 → `api-gateway`iam 只签发,不校验)
- ❌ 前端权限 Hook → `packages/ui-components/hooks/use-permission.ts`(前端通过 BFF 拉取,不直连 iam
## 3. 我与外部的契约
### 3.1 我消费的 proto message从 shared-proto
- 当前**不消费**任何外部 protoiam 是身份认证源头,不反向依赖业务服务)
- 未来 P3 转 gRPC 时,消费 `google/protobuf/timestamp.proto`(时间字段标准化)
### 3.2 我暴露的契约
#### 当前 REST API已实现见 [iam.controller.ts](../src/iam/iam.controller.ts)、[rbac.controller.ts](../src/iam/rbac.controller.ts)
| Method | Path | 权限 | 说明 |
| ------ | ---------------------------- | ----------------- | ------------------------------------- |
| POST | `/iam/register` | 公开 | 注册(自动分配 teacher 角色) |
| POST | `/iam/login` | 公开 | 登录,返回 accessToken + refreshToken |
| POST | `/iam/refresh` | 公开 | 刷新令牌 |
| GET | `/iam/me` | `IAM_USER_READ` | 当前用户信息(读 `x-user-id` 头) |
| GET | `/iam/viewports` | `IAM_USER_READ` | 当前用户视口L1 导航,按权限过滤) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限(多角色去重) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | 所有角色列表(管理端) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | 所有权限点列表(管理端) |
#### 健康检查([health.controller.ts](../src/shared/health/health.controller.ts)
| Method | Path | 鉴权 | 用途 |
| ------ | ---------- | ---- | ---------------------------------------------------------------- |
| GET | `/healthz` | 无 | liveness进程存活 |
| GET | `/readyz` | 无 | readiness校验 DB `SELECT 1`,失败 503 |
| GET | `/metrics` | 无 | Prometheus 指标抓取([main.ts](../src/main.ts) 第 23-26 行注册) |
#### Proto 契约([iam.proto](../../../packages/shared-proto/proto/iam.proto)
```protobuf
package next_edu_cloud.iam.v1;
service IamService {
rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (AuthResponse);
rpc RefreshToken(RefreshTokenRequest) returns (TokenPair);
rpc GetUserInfo(GetUserInfoRequest) returns (UserInfo);
}
```
> **缺口**proto 仅定义 4 个 RPCREST 已实现的 `viewports`、`permissions/effective`、`roles`、`permissions` 未在 proto 中体现。P2 阶段 2 设计文档需补齐 `GetViewports`、`GetEffectivePermissions`、`ListRoles`、`ListPermissions`、`Logout`、`GetPublicKey`RS256 公钥暴露端点)等 RPC。
#### 我发布的领域事件P2 待实现 Outbox
| 事件 | 触发时机 | Topic遵循 004 §7.2 | 消费者 |
| ------------------------------ | ------------- | -------------------------------- | ------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu初始化默认班级关联、msg发欢迎通知 |
| `UserUpdated` | 用户信息变更 | `edu.identity.user.updated` | core-edu、msg |
| `UserDeleted` / `UserDisabled` | 用户注销/禁用 | `edu.identity.user.deleted` | core-edu、msg清理关联 |
| `UserRoleChanged` | 角色绑定变更 | `edu.identity.user.role_changed` | 自身缓存失效、msg审计 |
> **Topic 命名遵循 004 §7.2**`edu.identity.user.created` / `edu.identity.user.updated`。
#### 我消费的事件
- 当前:**无**
- 未来不主动消费业务事件iam 是权限中枢,不订阅其他领域事件)
### 3.3 错误码前缀
- **前缀**`IAM_`(见 [application-error.ts](../src/shared/errors/application-error.ts)
- 已定义错误码:
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 / 字段非法 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复 |
| `IAM_BUSINESS_ERROR` | 422 | 业务规则违反(如账号禁用) |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
> **全局错误格式**`{ success: false, error: { code, message, details?, traceId } }`,由 [GlobalErrorFilter](../src/shared/errors/global-error.filter.ts) 统一兜底traceId 从 `x-request-id` 头注入。
## 4. 我的技术栈
| 维度 | 选型 | 版本 | 备注 |
| -------- | --------------------- | -------------- | ------------------------------------------------------------------------------- |
| 语言 | TypeScriptESM | 5.6+ | `tsconfig.json``NodeNext` + `incremental: false`(避免 nest watch 不 emit |
| 框架 | NestJS | 10.4+ | 装饰器 + DI`@nestjs/platform-express` |
| ORM | Drizzle ORMmysql2 | 0.31+ | `getDb()` 单例池,无 typeorm DataSource 依赖 |
| 数据库 | MySQL 8 | — | 独占库 `iam_db`,连接池 connectionLimit=10 |
| 缓存 | RedisP2 待接) | 7 | 权限缓存 TTL 5min + refresh token 黑名单 |
| 密码哈希 | bcrypt | 5.1+ | cost ≥ 12已对齐 project_rules §4 |
| JWT | jsonwebtoken | 9.0+ | **当前 HS256P2 必须切 RS256** |
| 日志 | pino | 9.4+ | 结构化 JSON`service: iam` base 字段 |
| 指标 | prom-client | 15.1+ | `iam_requests_total` + `iam_request_duration_seconds` |
| 链路 | OpenTelemetry SDK | 0.53+ | auto-instrumentations + OTLP HTTP exporter |
| 输入校验 | Zod | 3.23+ | Controller 层 `schema.parse(body)` |
| 测试 | Vitest | 2.1+ | **当前缺失P2 必须补齐** |
| 容器 | Dockerfile 多阶段 | node:20-alpine | builder + runtimeEXPOSE 3002 |
## 5. 我的阶段归属
- **阶段****P2 身份**M4-M6
- **当前阶段目标**pending-features §P2
1. ✅ 已实现骨架:注册/登录/刷新/me/viewports/effective permissions/roles/permissions 列表
2.**RS256 非对称签名**(当前仍是 HS256env.JWT_SECRET 单密钥)
3.**refresh_token 轮换 + Redis 黑名单失效**(当前只存 hash未实现撤销检查
4.**权限缓存**`getEffectivePermissions` 结果 Redis 缓存 TTL 5min角色变更主动失效
5.**Outbox 事件发布**`UserRegistered` / `UserUpdated` / `UserRoleChanged`
6.**2FA**pending-features §P2 提及但优先级低)
7.**补全数据表**`parent_student_relations``class_subject_teachers`pending-features §P2 schema 清单)
8.**gRPC 化**iam.proto 已定义 4 RPC但服务尚未实现 gRPC server
9.**测试覆盖**classes 有 `test/unit/classes.service.test.ts`iam 无任何测试)
10.**RBAC CRUD 完整化**(当前只有读,缺角色/权限/视口的增删改)
- **依赖的上游阶段产出**
- P1 ✅ api-gateway已注册 `/iam/*` 路由,已透传 `x-user-id`/`x-user-roles` 头)
- P1 ✅ classes 黄金模板(横切关注点模板:错误处理/可观测/健康检查/优雅关闭)
- P1 ✅ shared-protoiam.proto 已定义基础 4 RPC
- **P2 退出标准**pending-features §P2 + 004 §14.2
- 教师登录 → 获取 JWTRS256→ 访问 teacher-portal → 侧边栏按 `viewports.L1` 渲染 → 看到空白 Dashboard
- 打 tag `v0.2.0-p2`
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 参照 [classes 黄金模板](../../classes/src/) 全部源码与 [known-issues §2.2 classes](../../../docs/troubleshooting/known-issues.md) 经验。
| 对齐项 | classes 状态 | iam 当前状态 | iam 待补齐 |
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **权限装饰器 `@RequirePermission`** | ✅ 每个 Handler 都有 | ⚠️ Handler 有装饰器,但 `PermissionGuard` 用本地硬编码 `ROLE_PERMISSIONS` mapadmin/teacher未走 DB 查询 | P2 改为 DB 驱动Guard 调用 `IamService.getEffectivePermissions(userId)`(从 Redis 缓存读取) |
| **错误码前缀统一** | ✅ `CLASSES_*` | ✅ `IAM_*` | 无 |
| **loggerpino** | ✅ `shared/observability/logger.ts` | ✅ 同模板 | 无 |
| **metricsprom-client** | ✅ `*_requests_total` + `*_request_duration_seconds` | ✅ `iam_requests_total` + `iam_request_duration_seconds` | 无 |
| **tracerOTel** | ✅ auto-instrumentations + OTLP | ✅ 同模板 | 无 |
| **`/healthz` + `/readyz`** | ✅ Drizzle `SELECT 1` | ✅ 同模板 | 无 |
| **优雅关闭SIGTERM** | ✅ `LifecycleService` 关闭 DB 池 | ✅ 同模板 | P2 接入 Redis/Kafka 后需补关闭顺序HTTP → Kafka → Redis → DB |
| **测试覆盖率 ≥ 80%** | ✅ `test/unit/classes.service.test.ts` | ❌ **完全缺失** | P2 必须补齐:`test/unit/iam.service.test.ts``test/unit/iam.repository.test.ts``test/unit/permission.guard.test.ts` |
| **Dockerfile 多阶段构建** | ✅ builder + runtime | ✅ 同模板 | 无 |
| **Zod 输入验证** | ✅ Controller 层 `schema.parse(body)` | ✅ 同模板 | 无 |
| **GlobalErrorFilter** | ✅ 注册到 `main.ts` | ✅ 同模板 | 无 |
| **ESM `.js` 后缀** | ✅ 相对 import 带 `.js` | ✅ 同模板 | 无 |
| **`tsconfig.json` incremental: false** | ✅ | ✅ | 无 |
| **AppModule 显式 imports HealthModule** | ✅ | ✅ | 无 |
| **Drizzle `getDb()` 单例** | ✅ | ✅ | 无 |
| **Controller 读 `x-user-id` 头** | ✅ | ✅ | 无 |
### 6.1 当前 iam 与 classes 黄金模板的差异点(需在阶段 2 设计文档中明确处理)
1. **PermissionGuard 数据源**classes 用本地 `ROLE_PERMISSIONS` map够用因为 classes 权限点固定iam **必须改为 DB 驱动**——因为 iam 自身就是权限中枢,权限点/RBAC 是动态的,硬编码会导致角色变更不生效。
2. **AuthMiddleware 未注册**[app.module.ts](../src/app.module.ts) 只注册了 `PermissionGuard` 作为 `APP_GUARD``AuthMiddleware` 未在 `AppModule.configure()` 中消费。Controller 直接从 `req.headers['x-user-id']` 读取,这是 known-issues §2.3 记录的决策。P2 设计文档需明确:要么注册 AuthMiddleware要么继续走 header 直读(当前选择后者,与 Gateway 透传策略一致)。
3. **JWT 签名算法**classes 不签发 JWT只校验iam 是**唯一签发方**,必须切 RS256 并暴露公钥端点 `/iam/.well-known/jwks.json``/iam/public-key`
4. **Outbox 模式**classes 无 OutboxP3 才引入iam P2 需要引入 Outbox 发布用户事件——需要 coord 在 shared-ts 中提供 Outbox 工具或 iam 自建(参照 core-edu P3 设计)。
## 7. 服务审计表ai02 自检)
> 依照 ai-allocation §10 模板,对当前 iam 已实现代码进行审计。
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---- | ---------- | ---------- | ------ | ------- | ------ | -------- | ------- | -------- | ---------- | ---------- |
| iam | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 0% ❌ | ✅ |
**说明**
- 权限装饰器 ⚠️:装饰器已挂载到每个 Handler但 Guard 用本地硬编码 `ROLE_PERMISSIONS`,未走 DB与 iam 作为权限中枢的职责矛盾)
- 测试覆盖率 0%[services/iam/](../) 下无 `test/` 目录Vitest 配置缺失classes 有 `vitest.config.ts` + `test/unit/`
---
## 8. 待 coord 交叉审查的关键决策点
以下决策需在阶段 2 设计文档中明确,并在 coord 交叉审查时确认:
1. **RS256 密钥管理**P2 用本地文件 `/opt/edu/keys/iam-private.pem` + `/opt/edu/keys/iam-public.pem`,还是引入 VaultP6 才落地)?建议 P2 用本地文件 + 环境变量 `IAM_PRIVATE_KEY_PATH`P6 迁 Vault。
2. **公钥暴露端点**:用 `GET /iam/.well-known/jwks.json`JWK Set 标准,支持密钥轮换)还是 `GET /iam/public-key`(简单 PEM建议前者为未来密钥轮换留余地。
3. **权限缓存失效策略**:角色变更时主动 `DEL iam:perms:{userId}`,还是发 `UserRoleChanged` 事件让消费者自行失效?建议两者都做:本地服务内主动 DEL同步事件供其他服务感知。
4. **Outbox 实现位置**iam 自建 `iam_outbox` 表 + relay worker还是复用 shared-ts 提供的通用 Outbox 工具?**需 coord 确认 shared-ts 是否在 P2 提供 Outbox 工具**。若未提供iam 自建轻量 Outbox参照 core-edu P3 设计,但要先于 core-edu 落地)。
5. **gRPC 与 REST 并存策略**P2 是否同步实现 gRPC server还是 P2 仅 RESTP3 再补 gRPC建议 P2 仅 REST保证退出标准达成gRPC server 在 P3 随 core-edu 一起引入(此时 shared-ts 也有 gRPC 工具沉淀)。
6. **DataScope 枚举对齐**:当前 schema 用 `self/class/grade/school/district/all`004 §5.3 用 `L0-SELF` ~ `L5-ALL`。建议保持 schema 字符串枚举DB 友好),在 API/proto 层用 `L0`~`L5` 数值枚举映射,业务代码用语义常量。
7. **parent_student_relations 表归属**pending-features §P2 schema 清单把此表放在 iam但 004 §5.4 提到家长场景域由 parent-bff 聚合。需确认:此表是 iam 管理(家长-学生关系是身份关系),还是 core-edu 管理(教学组织关系)?**建议归 iam**(身份关系优先于教学组织)。
8. **`class_subject_teachers` 表归属**:同上,此表横跨 iam教师身份与 core-edu班级/学科。pending-features §P2 放 iam但 core-edu 的 classes 模块也需要。**建议归 core-edu**(班级-学科-教师是教学组织数据iam 只存 `userId` 与角色,不存具体任教关系。
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -0,0 +1,718 @@
# 模块架构设计文档 — iam
> AIai02TS / 身份认证)
> 阶段:阶段 2 交付物
> 日期2026-07-09
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features P2](../../../docs/architecture/roadmap/pending-features.md)
> 状态:待 coord 交叉审查
---
## 1. 模块内部分层图
### 1.1 调用链总览
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
Req[HTTP 请求<br/>带 x-user-id / x-user-roles 头]
end
subgraph NestJS["iam 服务NestJS"]
direction TB
MW[AuthMiddleware<br/>❌ 当前未注册P2 仍走 header 直读]
Guard[PermissionGuard<br/>APP_GUARD 全局守卫]
Filter[GlobalErrorFilter<br/>全局异常过滤器]
subgraph Controllers["Controller 层"]
IamCtl[IamController<br/>/iam/register, login, refresh, me]
RbacCtl[RbacController<br/>/iam/viewports, permissions, roles]
UserCtl[UserController<br/>P2 新增: 用户 CRUD]
RoleCtl[RoleController<br/>P2 新增: 角色 CRUD]
ViewportCtl[ViewportController<br/>P2 新增: 视口 CRUD]
JwksCtl[JwksController<br/>P2 新增: RS256 公钥暴露]
end
subgraph Services["Application Service 层"]
IamSvc[IamService<br/>认证编排]
RbacSvc[RbacService<br/>RBAC 编排]
UserSvc[UserService<br/>用户领域编排]
CacheSvc[PermissionCacheService<br/>Redis 权限缓存]
end
subgraph Domain["Domain 层P2 轻量)"]
UserEntity[UserEntity<br/>聚合根]
RoleEntity[RoleEntity<br/>聚合根]
end
subgraph Repo["Repository 层"]
IamRepo[IamRepository<br/>Drizzle 查询]
RbacRepo[RbacRepository<br/>Drizzle 查询]
end
subgraph Outbox["Outbox 模块"]
OutboxTbl[(iam_outbox 表)]
Relay[OutboxRelayWorker<br/>后台轮询 + Kafka 投递]
end
subgraph Infra["基础设施"]
Db[(MySQL<br/>iam_db)]
Redis[(Redis<br/>权限缓存 + token 黑名单)]
Kafka[(Kafka<br/>edu.identity.user.* topic)]
end
end
Req --> MW
MW --> Guard
Guard --> Controllers
Controllers --> Services
Services --> Domain
Services --> Repo
Services --> CacheSvc
Repo --> Db
CacheSvc --> Redis
Services --> OutboxTbl
OutboxTbl --> Relay
Relay --> Kafka
Controllers -.异常.-> Filter
```
### 1.2 中间件 / Guard / Filter 拦截顺序
```
请求进入
→ AuthMiddlewareP2 仍不注册Controller 直读 header
→ PermissionGuardAPP_GUARDDEV_MODE 旁路 + DB 驱动权限校验)
→ Controller HandlerZod 校验 body
→ Application Service业务编排
→ RepositoryDrizzle 查询)
→ 异常抛出
→ GlobalErrorFilter统一兜底注入 traceId
→ 响应返回
```
### 1.3 目录结构P2 目标态)
```
services/iam/src/
├─ iam/ # 限界上下文:认证
│ ├─ iam.controller.ts # 认证端点register/login/refresh/me/logout
│ ├─ iam.service.ts # 认证编排
│ ├─ iam.repository.ts # 用户/refresh_token 查询
│ ├─ iam.schema.ts # users / refresh_tokens 表
│ ├─ iam.dto.ts # Zod schema
│ └─ domain/
│ └─ user.entity.ts # UserEntity 聚合根P2 新增)
├─ rbac/ # 限界上下文RBACP2 从 iam/ 拆出)
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
│ ├─ role.controller.ts # 角色 CRUDP2 新增)
│ ├─ permission.controller.ts # 权限点 CRUDP2 新增)
│ ├─ viewport.controller.ts # 视口配置 CRUDP2 新增)
│ ├─ rbac.service.ts # RBAC 编排
│ ├─ rbac.repository.ts # 角色/权限/视口查询
│ ├─ rbac.schema.ts # roles / permissions / role_permissions / role_viewports 表
│ └─ domain/
│ └─ role.entity.ts # RoleEntity 聚合根P2 新增)
├─ jwks/ # 限界上下文JWT 公钥暴露P2 新增)
│ ├─ jwks.controller.ts # GET /iam/.well-known/jwks.json
│ ├─ jwks.service.ts # 密钥加载 + JWK Set 生成
│ └─ jwks.repository.ts # 密钥元数据持久化(可选)
├─ cache/ # 限界上下文Redis 缓存P2 新增)
│ ├─ permission-cache.service.ts # getEffectivePermissions 缓存
│ └─ token-blacklist.service.ts # refresh token 黑名单
├─ outbox/ # Outbox 模式P2 新增)
│ ├─ outbox.schema.ts # iam_outbox 表
│ ├─ outbox.publisher.ts # 写入 outbox事务内
│ └─ outbox.relay-worker.ts # 后台轮询 + Kafka 投递
├─ config/
│ ├─ database.ts # Drizzle 池(已有)
│ ├─ redis.ts # Redis 客户端P2 新增)
│ ├─ jwt.ts # RS256 密钥加载P2 新增)
│ └─ env.ts # 环境变量P2 扩展)
├─ middleware/
│ ├─ auth.middleware.ts # 保留P2 仍不注册)
│ └─ permission.guard.ts # 改造DB 驱动 + 缓存
├─ shared/
│ ├─ errors/ # 已有
│ ├─ health/ # 已有
│ ├─ lifecycle/ # 改造:关闭顺序 HTTP→Kafka→Redis→DB
│ └─ observability/ # 已有
├─ app.module.ts # 改造imports 新增 OutboxModule、CacheModule、JwksModule
└─ main.ts # 改造:启动 OutboxRelayWorker
```
## 2. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class UserEntity {
-id: string
-email: string
-passwordHash: string
-name: string
-status: UserStatus
-dataScope: DataScope
-createdAt: Date
-updatedAt: Date
+create(props) UserEntity$
+rename(name) UserRenamedEvent
+disable() UserDisabledEvent
+changeDataScope(scope) UserDataScopeChangedEvent
+verifyPassword(plain) bool
}
class RoleEntity {
-id: string
-name: string
-description: string?
-roleType: RoleType
+create(props) RoleEntity$
+rename(name) RoleRenamedEvent
}
class Permission {
+id: string
+name: string
+resource: string
+action: string
}
class RoleViewport {
+id: string
+roleId: string
+viewportKey: string
+label: string
+route: string
+sortOrder: string
+requiredPermission: string?
+componentConfig: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+expiresAt: Date
+revokedAt: Date?
+isRevoked() bool
+isExpired() bool
}
UserEntity "1" --> "many" RefreshToken : 拥有
RoleEntity "1" --> "many" Permission : 通过 role_permissions
RoleEntity "1" --> "many" RoleViewport : 配置
```
### 2.2 值对象(枚举)
```typescript
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING = "pending", // P2 新增:注册后待激活
}
enum DataScope {
SELF = "self", // L0
CLASS = "class", // L1
GRADE = "grade", // L2
SCHOOL = "school", // L3
DISTRICT = "district", // L4
ALL = "all", // L5
}
enum RoleType {
// P2 新增:三层角色模型
SYSTEM = "system", // 系统预设admin/teacher/student/parent
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长)
TEMPORARY = "temporary", // 临时授权(代课教师)
}
```
### 2.3 聚合间通信
- **同服务内**`IamService` 直接调用 `RbacService``PermissionCacheService`NestJS DI
- **跨服务**:通过 Kafka 事件Outbox 发布),不直接调用其他服务
## 3. 数据模型
### 3.1 表清单P2 目标态)
#### 3.1.1 已有表(保留)
| 表名 | 用途 | 主键 | 唯一索引 |
| ---------------------- | -------------------- | ----------------------------- | ----------------------- |
| `iam_users` | 用户主表 | `id` (char36) | `email` |
| `iam_roles` | 角色表 | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `(userId, roleId)` 复合 | — |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `(roleId, permissionId)` 复合 | — |
| `iam_refresh_tokens` | refresh token 持久化 | `id` | — |
| `iam_role_viewports` | 角色-视口配置 | `id` | `(roleId, viewportKey)` |
#### 3.1.2 P2 新增表
| 表名 | 用途 | 主键 | 唯一索引 |
| ------------------------------ | ------------------------------- | ---- | ----------------------- |
| `iam_outbox` | Outbox 事件表(事务内写入) | `id` | — |
| `iam_parent_student_relations` | 家长-学生关系表 | `id` | `(parentId, studentId)` |
| `iam_user_sessions` | 用户会话记录(审计 + 强制下线) | `id` | `userId + deviceHash` |
> **注**`class_subject_teachers` 表归属 core-edu见 §8.1 决策点 8不在 iam。
#### 3.1.3 P2 表结构定义
```typescript
// iam_outboxOutbox 事件表
export const iamOutbox = mysqlTable(
"iam_outbox",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), // 'User' | 'Role'
eventType: varchar("event_type", { length: 100 }).notNull(), // 'UserRegistered' | ...
payload: text("payload").notNull(), // JSON 序列化
topic: varchar("topic", { length: 100 }).notNull(), // 'edu.identity.user.created'
status: mysqlEnum("status", ["pending", "published", "failed"])
.notNull()
.default("pending"),
retryCount: int("retry_count").notNull().default(0),
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
statusIdx: index("idx_outbox_status").on(table.status), // relay 轮询用
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateId),
}),
);
// iam_parent_student_relations家长-学生关系
export const parentStudentRelations = mysqlTable(
"iam_parent_student_relations",
{
id: char("id", { length: 36 }).notNull().primaryKey(),
parentId: char("parent_id", { length: 36 }).notNull(),
studentId: char("student_id", { length: 36 }).notNull(),
relation: varchar("relation", { length: 20 }).notNull(), // 'father' | 'mother' | 'guardian'
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(table) => ({
parentStudentUniq: uniqueIndex("uniq_parent_student").on(
table.parentId,
table.studentId,
),
studentIdx: index("idx_student").on(table.studentId),
}),
);
// iam_roles 表扩展:新增 role_type 字段
// 在现有 iam_roles 表 ALTER ADD:
// role_type ENUM('system','organization','temporary') NOT NULL DEFAULT 'system'
// level INT NOT NULL DEFAULT 0 -- 三层优先级system=0(最高) / organization=1 / temporary=2
```
### 3.2 索引策略
| 表 | 索引 | 用途 |
| ------------------------------ | -------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录查询 |
| `iam_user_roles` | INDEX(`userId`)、INDEX(`roleId`) | 按用户查角色、按角色查用户 |
| `iam_role_permissions` | INDEX(`roleId`)、INDEX(`permissionId`) | 按角色查权限 |
| `iam_refresh_tokens` | INDEX(`userId`)、INDEX(`tokenHash`) | 按用户查 token、按 hash 校验 |
| `iam_role_viewports` | INDEX(`roleId`) | 按角色查视口 |
| `iam_outbox` | INDEX(`status`)、INDEX(`aggregateId`) | relay 轮询 pending、按聚合查事件 |
| `iam_parent_student_relations` | UNIQUE(`parentId`,`studentId`)、INDEX(`studentId`) | 防重、按学生查家长 |
### 3.3 读写分离策略
- **写路径**:所有 Command 走 MySQL 主库iam 独占库,无读写分离)
- **读路径**P2 暂不引入 ClickHouse 读模型iam 读多写少但数据量小MySQL 足够)
- **缓存层**`getEffectivePermissions` / `getUserViewports` 结果走 Redis 缓存TTL 5min
## 4. API 设计
### 4.1 REST API 完整清单P2 目标态)
| Method | Path | 权限 | 请求体 / 参数 | 响应 | 说明 |
| ------ | ------------------------------------------ | ----------------- | ---------------------------------- | ----------------- | -------------------------------------- |
| POST | `/iam/register` | 公开 | `{email, password, name}` | `{user, tokens}` | 注册 + 自动分配 teacher 角色 |
| POST | `/iam/login` | 公开 | `{email, password}` | `{user, tokens}` | 登录 |
| POST | `/iam/refresh` | 公开 | `{refreshToken}` | `{tokens}` | 刷新令牌(轮换 + 旧 token 加入黑名单) |
| POST | `/iam/logout` | `IAM_USER_READ` | `{refreshToken}` | `{success}` | 登出refresh token 加黑名单) |
| GET | `/iam/me` | `IAM_USER_READ` | — | `{user}` | 当前用户信息 |
| GET | `/iam/viewports` | `IAM_USER_READ` | — | `{viewports[]}` | 当前用户视口L1 导航) |
| GET | `/iam/permissions/effective` | `IAM_USER_READ` | — | `{permissions[]}` | 当前用户有效权限 |
| GET | `/iam/.well-known/jwks.json` | 公开 | — | `{keys[]}` | RS256 公钥 JWK SetGateway 拉取) |
| GET | `/iam/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 角色列表 |
| POST | `/iam/roles` | `IAM_ROLE_MANAGE` | `{name, description, roleType}` | `{role}` | 创建角色 |
| PUT | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | `{name?, description?}` | `{role}` | 更新角色 |
| DELETE | `/iam/roles/:id` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除角色(系统角色禁止删) |
| GET | `/iam/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 权限点列表 |
| GET | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | — | `{roles[]}` | 用户角色列表 |
| POST | `/iam/users/:id/roles` | `IAM_ROLE_MANAGE` | `{roleId}` | `{success}` | 给用户分配角色 |
| DELETE | `/iam/users/:id/roles/:roleId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除用户角色(触发缓存失效) |
| GET | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | — | `{permissions[]}` | 角色权限列表 |
| POST | `/iam/roles/:id/permissions` | `IAM_ROLE_MANAGE` | `{permissionId}` | `{success}` | 给角色授予权限 |
| DELETE | `/iam/roles/:id/permissions/:permissionId` | `IAM_ROLE_MANAGE` | — | `{success}` | 移除角色权限(触发缓存失效) |
| GET | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | — | `{viewports[]}` | 角色视口列表 |
| POST | `/iam/roles/:id/viewports` | `IAM_ROLE_MANAGE` | `{viewportKey, label, route, ...}` | `{viewport}` | 创建视口配置 |
| PUT | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | `{label?, route?, ...}` | `{viewport}` | 更新视口配置 |
| DELETE | `/iam/roles/:id/viewports/:viewportId` | `IAM_ROLE_MANAGE` | — | `{success}` | 删除视口配置 |
| GET | `/iam/users/:id/parents` | `IAM_USER_READ` | — | `{parents[]}` | 学生家长列表(家长-学生关系) |
| POST | `/iam/users/:studentId/parents` | `IAM_ROLE_MANAGE` | `{parentId, relation}` | `{success}` | 绑定家长-学生关系 |
### 4.2 请求/响应结构示例
```typescript
// 注册响应
interface RegisterResponse {
success: true;
data: {
user: {
id: string;
email: string;
name: string;
roles: string[]; // ['teacher']
permissions: string[]; // ['IAM_USER_READ', 'CLASSES_READ', ...]
dataScope: "self" | "class" | "grade" | "school" | "district" | "all";
};
tokens: {
accessToken: string; // RS256 签名15min
refreshToken: string; // RS256 签名7day
expiresIn: 900; // 秒
};
};
}
// JWK Set 响应RS256 公钥暴露)
interface JwkSet {
keys: Array<{
kty: "RSA";
use: "sig";
alg: "RS256";
kid: string; // 密钥 ID支持轮换
n: string; // modulus base64url
e: string; // exponent base64url
}>;
}
```
### 4.3 JWT PayloadRS256 签发)
```typescript
interface JwtPayload {
sub: string; // userId
email: string;
roles: string[]; // ['teacher', 'grade_leader']
dataScope: DataScope; // 'class' | 'grade' | ...
type: "access" | "refresh";
iat: number; // 签发时间
exp: number; // 过期时间
iss: "next-edu-cloud"; // 签发者
aud: "next-edu-cloud"; // 受众
jti: string; // JWT ID用于黑名单
}
```
## 5. 事件设计
### 5.1 我发布的领域事件
| 事件 | 触发时机 | Topic | 消费者动作 |
| ----------------- | ------------------------------ | -------------------------------- | -------------------------------------------------- |
| `UserRegistered` | 注册成功 | `edu.identity.user.created` | core-edu 初始化默认班级关联msg 发欢迎通知 |
| `UserUpdated` | 用户信息变更name/dataScope | `edu.identity.user.updated` | core-edu 同步用户快照msg 通知 |
| `UserDisabled` | 用户禁用/注销 | `edu.identity.user.deleted` | core-edu 解除关联msg 通知push-gateway 强制下线 |
| `UserRoleChanged` | 用户角色绑定变更 | `edu.identity.user.role_changed` | 自身 Redis 缓存失效msg 审计日志 |
| `RoleCreated` | 角色创建 | `edu.identity.role.created` | msg 审计(仅管理端关注) |
| `RoleUpdated` | 角色权限变更 | `edu.identity.role.updated` | 所有该角色用户的缓存失效msg 审计 |
### 5.2 事件 Schema建议 coord 在 shared-proto 中统一定义)
```protobuf
// 建议在 packages/shared-proto/proto/events.proto 新增:
message UserEvent {
string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // userId
string event_type = 3; // 'UserRegistered' | 'UserUpdated' | ...
int64 occurred_at = 4; // 发生时间戳ms
string user_id = 5;
string email = 6;
string name = 7;
repeated string roles = 8;
string data_scope = 9;
string action = 10; // 'created' | 'updated' | 'disabled' | 'role_changed'
map<string, string> metadata = 11; // trace_id 等
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2; // roleId
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
string action = 7; // 'created' | 'updated' | 'deleted'
map<string, string> metadata = 8;
}
```
> **需 coord 在 shared-proto/events.proto 中统一定义**iam 只负责填充字段并写入 outbox。
### 5.3 我消费的事件
- **当前**:无
- **未来**不主动消费业务事件iam 是权限中枢,单向发布)
### 5.4 Outbox 实现策略
```mermaid
sequenceDiagram
participant Ctl as Controller
participant Svc as IamService
participant DB as MySQL
participant Outbox as iam_outbox 表
participant Relay as OutboxRelayWorker
participant Kafka as Kafka
Ctl->>Svc: register(dto)
Svc->>DB: BEGIN TX
Svc->>DB: INSERT iam_users
Svc->>DB: INSERT iam_user_roles
Svc->>Outbox: INSERT event (status=pending)
Svc->>DB: COMMIT TX
Svc-->>Ctl: {user, tokens}
loop 每 100ms 轮询
Relay->>Outbox: SELECT * WHERE status='pending' LIMIT 100
Outbox-->>Relay: events[]
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>Outbox: UPDATE status='published', published_at=NOW()
end
```
**Relay Worker 实现**
- 独立 `@Injectable()` 服务,`OnModuleInit` 启动轮询
- 每 100ms 查询 `status='pending'` 的事件,批量投递 Kafka
- 投递失败重试 3 次,超过后标记 `status='failed'`,记录日志
- Kafka 未启动时不阻塞主服务try/catch + 日志警告)
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(所有端点及对应权限常量)
| 端点 | 权限常量 |
| ----------------------------------------------- | ----------------- |
| POST /iam/register | 公开(无装饰器) |
| POST /iam/login | 公开 |
| POST /iam/refresh | 公开 |
| GET /iam/.well-known/jwks.json | 公开 |
| POST /iam/logout | `IAM_USER_READ` |
| GET /iam/me | `IAM_USER_READ` |
| GET /iam/viewports | `IAM_USER_READ` |
| GET /iam/permissions/effective | `IAM_USER_READ` |
| GET /iam/users/:id/parents | `IAM_USER_READ` |
| GET /iam/roles | `IAM_ROLE_MANAGE` |
| POST /iam/roles | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id | `IAM_ROLE_MANAGE` |
| GET /iam/permissions | `IAM_ROLE_MANAGE` |
| GET /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| POST /iam/users/:id/roles | `IAM_ROLE_MANAGE` |
| DELETE /iam/users/:id/roles/:roleId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/permissions | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/permissions/:permissionId | `IAM_ROLE_MANAGE` |
| GET /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| POST /iam/roles/:id/viewports | `IAM_ROLE_MANAGE` |
| PUT /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| DELETE /iam/roles/:id/viewports/:viewportId | `IAM_ROLE_MANAGE` |
| POST /iam/users/:studentId/parents | `IAM_ROLE_MANAGE` |
**权限常量清单**P2 完整化):
```typescript
export const Permissions = {
// 用户管理
IAM_USER_CREATE: "IAM_USER_CREATE",
IAM_USER_READ: "IAM_USER_READ",
IAM_USER_UPDATE: "IAM_USER_UPDATE",
IAM_USER_DELETE: "IAM_USER_DELETE",
// 角色管理
IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE",
// 视口管理
IAM_VIEWPORT_MANAGE: "IAM_VIEWPORT_MANAGE",
// 家长-学生关系管理
IAM_RELATION_MANAGE: "IAM_RELATION_MANAGE",
} as const;
```
### 6.2 错误码清单(带前缀)
| 错误码 | HTTP | 触发条件 |
| ----------------------- | ---- | ---------------------------------------------------- |
| `IAM_VALIDATION_ERROR` | 400 | Zod 校验失败 |
| `IAM_UNAUTHORIZED` | 401 | 未登录、密码错误、token 失效、refresh token 在黑名单 |
| `IAM_PERMISSION_DENIED` | 403 | 缺少所需权限点 |
| `IAM_NOT_FOUND` | 404 | 用户/角色/权限/视口不存在 |
| `IAM_CONFLICT` | 409 | 邮箱已注册、角色名重复、家长-学生关系已存在 |
| `IAM_BUSINESS_ERROR` | 422 | 账号禁用、系统角色禁止删除、refresh token 已撤销 |
| `IAM_RATE_LIMITED` | 429 | 登录失败次数过多P2 可选,限流在 Gateway |
| `IAM_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 Logger 初始化位置与配置
- **位置**`src/shared/observability/logger.ts`(已有)
- **配置**pino`base: { service: 'iam', version: '0.1.0' }``level: env.LOG_LEVEL`
- **P2 新增**:日志中注入 `traceId`(从 `x-request-id` 头读取OTel auto-instrumentation 已覆盖)
### 6.4 Metrics 指标清单
| 指标名 | 类型 | 标签 | 描述 |
| ------------------------------------- | --------- | ------------------------ | ------------------------------ |
| `iam_requests_total` | Counter | method, endpoint, status | 请求总数(已有) |
| `iam_request_duration_seconds` | Histogram | method, endpoint | 请求延迟(已有) |
| `iam_login_attempts_total` | Counter | result(success/failure) | 登录尝试次数P2 新增) |
| `iam_login_duration_seconds` | Histogram | — | 登录耗时P2 新增) |
| `iam_jwt_issued_total` | Counter | type(access/refresh) | JWT 签发次数P2 新增) |
| `iam_permission_cache_hits_total` | Counter | — | 权限缓存命中P2 新增) |
| `iam_permission_cache_misses_total` | Counter | — | 权限缓存未命中P2 新增) |
| `iam_outbox_pending` | Gauge | — | Outbox 待投递事件数P2 新增) |
| `iam_outbox_publish_duration_seconds` | Histogram | — | Outbox 投递耗时P2 新增) |
### 6.5 Tracer 初始化位置
- **位置**`src/shared/observability/tracer.ts`(已有)
- **配置**NodeSDK + OTLP HTTP exporter`serviceName: 'iam'`
- **P2 保持**auto-instrumentations 覆盖 HTTP/Express/Drizzlemysql2
### 6.6 /healthz 检查逻辑
- **端点**`GET /healthz`
- **逻辑**:仅返回进程存活,不检查依赖
- **响应**`{ status: 'ok', service: 'iam', timestamp: ISO }`
### 6.7 /readyz 检查逻辑P2 改造)
- **端点**`GET /readyz`
- **逻辑**P2 新增 Redis + Kafka 检查):
```typescript
async readiness() {
const checks = await Promise.allSettled([
this.checkDb(), // db.execute(sql`SELECT 1`)
this.checkRedis(), // redis.ping()
this.checkKafka(), // kafka.admin().listTopics()(轻量探活)
]);
const allOk = checks.every(r => r.status === 'fulfilled');
if (!allOk) throw 503;
return { status: 'ok', service: 'iam', timestamp, checks };
}
```
- **失败**HTTP 503响应体含失败项详情
### 6.8 优雅关闭顺序P2 改造)
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求NestJS 自动)
// 2. 停止 OutboxRelayWorker停止轮询
await this.relayWorker.stop();
// 3. 关闭 Kafka producer
await this.kafkaProducer.disconnect();
// 4. 关闭 Redis 连接
await this.redisClient.quit();
// 5. 关闭 MySQL 连接池
await closeDb();
// 6. 关闭 Tracer
await shutdownTracer();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------- | -------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /iam/register` 等 | Gateway 反向代理 `/api/v1/iam/*` |
| 被调用 | teacher-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | HTTP | `GET /iam/me`、`GET /iam/viewports` | 同上P3 |
| 被调用 | parent-bff | HTTP | `GET /iam/me`、`GET /iam/users/:id/parents` | 同上 + 家长-学生关系P4 |
| 被调用 | api-gateway | HTTP | `GET /iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 发布 | — | Kafka | `edu.identity.user.created` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.updated` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.deleted` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.identity.user.role_changed` | 自身缓存失效 / msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.created` | msg 审计 |
| 发布 | — | Kafka | `edu.identity.role.updated` | 自身缓存失效 / msg 审计 |
| 消费 | — | — | — | iam 不消费外部事件 |
### 7.1 端口分配
| 服务 | 端口 | 备注 |
| -------- | ----- | ------------------------------------------------------------ |
| iam HTTP | 3002 | 已有REST 入口 |
| iam gRPC | 50052 | P3 引入(与 core-edu 50053 等区分,需 coord 全局端口表确认) |
### 7.2 Topic 命名(遵循 004 §7.2
- `edu.identity.user.created`
- `edu.identity.user.updated`
- `edu.identity.user.deleted`
- `edu.identity.user.role_changed`
- `edu.identity.role.created`
- `edu.identity.role.updated`
> **需 coord 在全局 Topic 表中登记**,避免与 core-edu 的 `edu.identity.user.created` 冲突004 §7.2 已记录 iam 为生产者)。
## 8. 风险与假设
### 8.1 架构决策点(需 coord 仲裁)
| # | 决策点 | 我的建议 | 风险 |
| --- | ------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 1 | RS256 密钥管理 | P2 本地文件 `IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`P6 迁 Vault | 密钥文件权限管理;容器挂载 |
| 2 | 公钥暴露端点 | `GET /iam/.well-known/jwks.json`JWK Set 标准) | Gateway 需实现 JWK 解析 |
| 3 | 权限缓存失效 | 角色变更时本地主动 `DEL iam:perms:{userId}` + 发事件 | 缓存与 DB 短暂不一致(< 5min TTL |
| 4 | Outbox 实现 | iam 自建轻量 OutboxP2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
| 5 | gRPC 引入时机 | P2 仅 RESTP3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTPP3 改 gRPC 需协调 ai03 |
| 6 | DataScope 枚举 | schema 用字符串枚举proto 用数值枚举 L0-L5 映射 | 跨层映射需文档化 |
| 7 | `parent_student_relations` 归属 | 归 iam身份关系优先 | core-edu 查询家长需走 iam API |
| 8 | `class_subject_teachers` 归属 | 归 core-edu教学组织数据 | iam 只存 userId + 角色,不存任教关系 |
| 9 | PermissionGuard 改造 | DB 驱动 + Redis 缓存,废弃本地 ROLE_PERMISSIONS map | 性能:每次请求多一次缓存查询 |
| 10 | AuthMiddleware 注册 | P2 仍不注册Controller 直读 header与 Gateway 透传策略一致) | 多 Controller 重复读 header 代码 |
### 8.2 技术风险
| 风险 | 影响 | 缓解措施 |
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB不抛错 |
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 + 定期轮换P6 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 事件驱动失效 + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天)或归档到 ClickHouse |
### 8.3 假设
- 假设 coord 在 shared-proto/events.proto 中统一定义 `UserEvent` / `RoleEvent` messageiam 只填充字段)
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验ai01 负责)
- 假设 teacher-bff 仍走 HTTP 调用 iamai03 负责P3 改 gRPC 时协调)
- 假设 shared-ts 在 P2 不提供通用 Outbox 工具iam 自建P3 core-edu 落地后回写到 shared-ts
### 8.4 未决问题(需 coord 回复)
1. shared-ts 是否在 P2 提供通用 Outbox 工具?还是 iam 自建?
2. events.proto 中 `UserEvent` / `RoleEvent` 由 coord 统一定义,还是 iam 提交 PR
3. iam gRPC 端口 50052 是否与全局端口表冲突?
4. `class_subject_teachers` 表归属确认(我建议归 core-edupending-features §P2 写在 iam
---
**AI Agent**: ai02 (iam-module)
**Branch**: main单仓库并行模式见 ai-allocation §9.1
**Coordinator**: coord-ai

View File

@@ -1,8 +1,15 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { IamModule } from "./iam/iam.module.js"; import { IamModule } from "./iam/iam.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [IamModule, HealthModule], imports: [IamModule, HealthModule],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,10 +1,11 @@
import { drizzle } from 'drizzle-orm/mysql2'; import { drizzle } from "drizzle-orm/mysql2";
import mysql from 'mysql2/promise'; import type { MySql2Database } from "drizzle-orm/mysql2";
import { env } from './env.js'; import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
export function getDb() { export function getDb(): MySql2Database {
if (!pool) { if (!pool) {
pool = mysql.createPool({ pool = mysql.createPool({
uri: env.DATABASE_URL, uri: env.DATABASE_URL,

View File

@@ -1,37 +1,53 @@
import { Body, Controller, Get, Post, Req } from "@nestjs/common"; import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { TokenPair, UserInfo } from "./iam.service.js";
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js"; import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js"; import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
@Controller("iam") @Controller("iam")
export class IamController { export class IamController {
constructor(private readonly service: IamService) {} constructor(private readonly service: IamService) {}
// 公开端点:注册,不设权限校验
@Post("register") @Post("register")
async register(@Body() body: unknown) { async register(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = registerSchema.parse(body); const dto = registerSchema.parse(body);
const result = await this.service.register(dto); const result = await this.service.register(dto);
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:登录,不设权限校验
@Post("login") @Post("login")
async login(@Body() body: unknown) { async login(
@Body() body: unknown,
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
const dto = loginSchema.parse(body); const dto = loginSchema.parse(body);
const result = await this.service.login(dto); const result = await this.service.login(dto);
return { success: true as const, data: result }; return { success: true as const, data: result };
} }
// 公开端点:刷新令牌,不设权限校验
@Post("refresh") @Post("refresh")
async refresh(@Body() body: unknown) { async refresh(
@Body() body: unknown,
): Promise<{ success: true; data: TokenPair }> {
const dto = refreshTokenSchema.parse(body); const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken); const tokens = await this.service.refresh(dto.refreshToken);
return { success: true as const, data: tokens }; return { success: true as const, data: tokens };
} }
@Get("me") @Get("me")
async me(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async me(@Req() req: Request): Promise<{ success: true; data: UserInfo }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }

View File

@@ -10,6 +10,7 @@ import {
roleViewports, roleViewports,
} from "./iam.schema.js"; } from "./iam.schema.js";
import type { User, Role, Permission, RoleViewport } from "./iam.schema.js"; import type { User, Role, Permission, RoleViewport } from "./iam.schema.js";
import { DatabaseError } from "../shared/errors/application-error.js";
export class IamRepository { export class IamRepository {
async createUser(data: { async createUser(data: {
@@ -22,7 +23,7 @@ export class IamRepository {
await db.insert(users).values(data); await db.insert(users).values(data);
const [result] = await db.select().from(users).where(eq(users.id, data.id)); const [result] = await db.select().from(users).where(eq(users.id, data.id));
if (!result) { if (!result) {
throw new Error("Failed to create user"); throw new DatabaseError("Failed to create user");
} }
return result; return result;
} }

View File

@@ -92,7 +92,11 @@ export class IamService {
async refresh(refreshToken: string): Promise<TokenPair> { async refresh(refreshToken: string): Promise<TokenPair> {
let payload: jwt.JwtPayload; let payload: jwt.JwtPayload;
try { try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload; const decoded = jwt.verify(refreshToken, env.JWT_SECRET);
if (typeof decoded === "string") {
throw new UnauthorizedError("Invalid refresh token");
}
payload = decoded;
} catch { } catch {
throw new UnauthorizedError("Invalid refresh token"); throw new UnauthorizedError("Invalid refresh token");
} }
@@ -101,9 +105,14 @@ export class IamService {
throw new UnauthorizedError("Invalid token type"); throw new UnauthorizedError("Invalid token type");
} }
const user = await this.repository.findUserById(payload.sub as string); const sub = typeof payload.sub === "string" ? payload.sub : undefined;
if (!sub) {
throw new UnauthorizedError("Invalid token subject");
}
const user = await this.repository.findUserById(sub);
if (!user) { if (!user) {
throw new NotFoundError("User", payload.sub as string); throw new NotFoundError("User", sub);
} }
return this.issueTokens(user).then((r) => r.tokens); return this.issueTokens(user).then((r) => r.tokens);

View File

@@ -1,7 +1,13 @@
import { Controller, Get, Req } from "@nestjs/common"; import { Controller, Get, Req } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { IamService } from "./iam.service.js"; import { IamService } from "./iam.service.js";
import type { ViewportItem } from "./iam.service.js";
import type { Role, Permission } from "./iam.schema.js";
import { UnauthorizedError } from "../shared/errors/application-error.js"; import { UnauthorizedError } from "../shared/errors/application-error.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
// RBAC 管理端点:角色/权限/视口查询 // RBAC 管理端点:角色/权限/视口查询
@Controller("iam") @Controller("iam")
@@ -10,8 +16,12 @@ export class RbacController {
// 获取当前用户的视口配置L1 导航) // 获取当前用户的视口配置L1 导航)
@Get("viewports") @Get("viewports")
async viewports(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async viewports(
@Req() req: Request,
): Promise<{ success: true; data: ViewportItem[] }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }
@@ -21,8 +31,12 @@ export class RbacController {
// 获取当前用户的有效权限 // 获取当前用户的有效权限
@Get("permissions/effective") @Get("permissions/effective")
async effectivePermissions(@Req() req: Request) { @RequirePermission(Permissions.IAM_USER_READ)
const userId = req.headers["x-user-id"] as string; async effectivePermissions(
@Req() req: Request,
): Promise<{ success: true; data: { permissions: string[] } }> {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedError("Missing x-user-id header"); throw new UnauthorizedError("Missing x-user-id header");
} }
@@ -32,14 +46,16 @@ export class RbacController {
// 列出所有角色(管理端用) // 列出所有角色(管理端用)
@Get("roles") @Get("roles")
async roles() { @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async roles(): Promise<{ success: true; data: Role[] }> {
const data = await this.service.getAllRoles(); const data = await this.service.getAllRoles();
return { success: true as const, data }; return { success: true as const, data };
} }
// 列出所有权限点(管理端用) // 列出所有权限点(管理端用)
@Get("permissions") @Get("permissions")
async permissions() { @RequirePermission(Permissions.IAM_ROLE_MANAGE)
async permissions(): Promise<{ success: true; data: Permission[] }> {
const data = await this.service.getAllPermissions(); const data = await this.service.getAllPermissions();
return { success: true as const, data }; return { success: true as const, data };
} }

View File

@@ -6,6 +6,7 @@ import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { logger } from "./shared/observability/logger.js"; import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,7 +20,7 @@ async function bootstrap(): Promise<void> {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 // 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8
app.getHttpAdapter().get("/metrics", async (req, res) => { app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -1,5 +1,9 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; import {
import { Request, Response, NextFunction } from 'express'; Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
@@ -10,15 +14,18 @@ export interface AuthenticatedRequest extends Request {
export class AuthMiddleware implements NestMiddleware { export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, res: Response, next: NextFunction): void { use(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
// 从 Gateway 注入的头部读取用户信息 // 从 Gateway 注入的头部读取用户信息
const userId = req.headers['x-user-id'] as string | undefined; const userIdHeader = req.headers["x-user-id"];
const rolesHeader = req.headers['x-user-roles'] as string | undefined; const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) { if (!userId) {
throw new UnauthorizedException('Missing x-user-id header'); throw new UnauthorizedException("Missing x-user-id header");
} }
req.userId = userId; req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(',') : []; req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next(); next();
} }
} }

View File

@@ -1,23 +1,32 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import {
import type { Reflector } from '@nestjs/core'; Injectable,
import { PermissionDeniedError } from '../shared/errors/application-error.js'; CanActivate,
import type { AuthenticatedRequest } from './auth.middleware.js'; ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export type Permission = export type Permission =
| 'IAM_USER_CREATE' | "IAM_USER_CREATE"
| 'IAM_USER_READ' | "IAM_USER_READ"
| 'IAM_USER_UPDATE' | "IAM_USER_UPDATE"
| 'IAM_USER_DELETE' | "IAM_USER_DELETE"
| 'IAM_ROLE_MANAGE'; | "IAM_ROLE_MANAGE";
export const Permissions = { export const Permissions = {
IAM_USER_CREATE: 'IAM_USER_CREATE' as const, IAM_USER_CREATE: "IAM_USER_CREATE" as const,
IAM_USER_READ: 'IAM_USER_READ' as const, IAM_USER_READ: "IAM_USER_READ" as const,
IAM_USER_UPDATE: 'IAM_USER_UPDATE' as const, IAM_USER_UPDATE: "IAM_USER_UPDATE" as const,
IAM_USER_DELETE: 'IAM_USER_DELETE' as const, IAM_USER_DELETE: "IAM_USER_DELETE" as const,
IAM_ROLE_MANAGE: 'IAM_ROLE_MANAGE' as const, IAM_ROLE_MANAGE: "IAM_ROLE_MANAGE" as const,
}; };
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = { const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [ admin: [
Permissions.IAM_USER_CREATE, Permissions.IAM_USER_CREATE,
@@ -31,26 +40,32 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
constructor(private readonly requiredPermission: Permission) {} constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? []; const roles = request.userRoles ?? [];
for (const role of roles) { for (const role of roles) {
const perms = ROLE_PERMISSIONS[role]; const perms = ROLE_PERMISSIONS[role];
if (perms && perms.includes(this.requiredPermission)) { if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true; return true;
} }
} }
throw new PermissionDeniedError(this.requiredPermission); throw new PermissionDeniedError(requiredPermissions.join(", "));
} }
} }
// 工厂函数,用于装饰器
export function createPermissionGuardFactory(_reflector: Reflector) {
return {
create: (permission: Permission) => new PermissionGuard(permission),
};
}

View File

@@ -1,7 +1,13 @@
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; import {
import { Request, Response } from 'express'; Catch,
import { ZodError } from 'zod'; ExceptionFilter,
import { ApplicationError } from './application-error.js'; ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
@@ -12,7 +18,9 @@ export class GlobalErrorFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const traceId = (request.headers['x-request-id'] as string | undefined) ?? 'unknown'; const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -27,8 +35,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'IAM_VALIDATION_ERROR', code: "IAM_VALIDATION_ERROR",
message: 'Validation failed', message: "Validation failed",
details: exception.flatten(), details: exception.flatten(),
traceId, traceId,
}, },
@@ -40,7 +48,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'HTTP_ERROR', code: "HTTP_ERROR",
message, message,
traceId, traceId,
}, },
@@ -53,8 +61,8 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: 'INTERNAL_ERROR', code: "INTERNAL_ERROR",
message: 'An unexpected error occurred', message: "An unexpected error occurred",
traceId, traceId,
}, },
}; };
@@ -63,14 +71,17 @@ export class GlobalErrorFilter implements ExceptionFilter {
response.status(statusCode).json(body); response.status(statusCode).json(body);
} }
private extractHttpMessage(res: string | object, exception: HttpException): string { private extractHttpMessage(
if (typeof res === 'string') { res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res; return res;
} }
if (res && typeof res === 'object' && 'message' in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段) // 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === 'string' ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }
return exception.message; return exception.message;
} }

View File

@@ -32,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",

View File

@@ -1,10 +1,15 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { NotificationsModule } from "./notifications/notifications.module.js"; import { NotificationsModule } from "./notifications/notifications.module.js";
import { HealthModule } from "./shared/health/health.module.js"; import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@Module({ @Module({
imports: [NotificationsModule, HealthModule], imports: [NotificationsModule, HealthModule],
providers: [LifecycleService], providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },
LifecycleService,
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,35 +1,24 @@
import { Client } from "@elastic/elasticsearch"; import { Client } from "@elastic/elasticsearch";
import { env } from "./env.js"; import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
/**
* Elasticsearch 客户端。
*
* 降级模式:当 ES_URL 未设置或连接失败时esClient 为 null
* 所有 ES 操作通过 safeIndex / safeSearch 自动跳过,服务仍可启动。
*/
export const esClient: Client | null = env.ES_URL export const esClient: Client | null = env.ES_URL
? new Client({ node: env.ES_URL }) ? new Client({ node: env.ES_URL })
: null; : null;
/**
* 探活 ES 连接。失败仅记录日志,不抛错(降级模式)。
*/
export async function checkEsConnection(): Promise<void> { export async function checkEsConnection(): Promise<void> {
if (!esClient) { if (!esClient) {
console.log("Elasticsearch disabled (ES_URL not set)"); logger.info("Elasticsearch disabled (ES_URL not set)");
return; return;
} }
try { try {
await esClient.ping(); await esClient.ping();
console.log("Elasticsearch connected"); logger.info("Elasticsearch connected");
} catch (err) { } catch (err) {
console.error("Elasticsearch connection failed:", err); logger.error({ err }, "Elasticsearch connection failed");
} }
} }
/**
* 关闭 ES 客户端连接。
*/
export async function closeEs(): Promise<void> { export async function closeEs(): Promise<void> {
if (esClient) { if (esClient) {
await esClient.close(); await esClient.close();
@@ -51,23 +40,20 @@ export interface SearchResult {
hits: SearchHit[]; hits: SearchHit[];
} }
/**
* 安全索引文档。client 为 null 或失败时跳过(降级模式),返回是否成功。
*/
export async function safeIndex(params: IndexParams): Promise<boolean> { export async function safeIndex(params: IndexParams): Promise<boolean> {
if (!esClient) return false; if (!esClient) return false;
try { try {
await esClient.index(params); await esClient.index(params);
return true; return true;
} catch (err) { } catch (err) {
console.error("Elasticsearch index failed:", err); logger.error(
{ err, index: params.index, id: params.id },
"Elasticsearch index failed",
);
return false; return false;
} }
} }
/**
* 安全搜索。client 为 null 或失败时返回空数组(降级模式)。
*/
export async function safeSearch( export async function safeSearch(
index: string, index: string,
query: Record<string, unknown>, query: Record<string, unknown>,
@@ -75,13 +61,25 @@ export async function safeSearch(
if (!esClient) return { hits: [] }; if (!esClient) return { hits: [] };
try { try {
const result = await esClient.search({ index, query }); const result = await esClient.search({ index, query });
const hits = (result.hits.hits as unknown[]).map((raw) => { const hits: SearchHit[] = [];
const hit = raw as { _id: string; _source: Record<string, unknown> }; for (const raw of result.hits.hits) {
return { _id: hit._id, _source: hit._source }; const source = raw._source;
}); if (
raw._id &&
source &&
typeof source === "object" &&
!Array.isArray(source)
) {
// source 已收窄为 object但 object 无索引签名,需断言为 Record<string, unknown>
hits.push({
_id: raw._id,
_source: source as Record<string, unknown>,
});
}
}
return { hits }; return { hits };
} catch (err) { } catch (err) {
console.error("Elasticsearch search failed:", err); logger.error({ err, index }, "Elasticsearch search failed");
return { hits: [] }; return { hits: [] };
} }
} }

View File

@@ -8,6 +8,7 @@ import { logger } from "./shared/observability/logger.js";
import { checkEsConnection, closeEs } from "./config/elasticsearch.js"; import { checkEsConnection, closeEs } from "./config/elasticsearch.js";
import { closeDb } from "./config/database.js"; import { closeDb } from "./config/database.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
@@ -19,13 +20,11 @@ async function bootstrap(): Promise<void> {
logger: ["log", "error", "warn"], logger: ["log", "error", "warn"],
}); });
// 无全局路由前缀Gateway 会去掉 /api/v1再转发至本服务。
app.useGlobalFilters(new GlobalErrorFilter()); app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
// 返回 register.metrics()Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8 app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
app.getHttpAdapter().get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });

View File

@@ -0,0 +1,30 @@
import {
Injectable,
NestMiddleware,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
export interface AuthenticatedRequest extends Request {
userId?: string;
userRoles?: string[];
}
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];
const rolesHeader =
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
req.userId = userId;
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
next();
}
}

View File

@@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
SetMetadata,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
export const Permissions = {
MSG_NOTIFICATION_SEND: "MSG_NOTIFICATION_SEND" as const,
MSG_NOTIFICATION_READ: "MSG_NOTIFICATION_READ" as const,
MSG_NOTIFICATION_MANAGE: "MSG_NOTIFICATION_MANAGE" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
export const PERMISSIONS_KEY = "permissions";
export const RequirePermission = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_NOTIFICATION_MANAGE,
],
teacher: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
],
student: [Permissions.MSG_NOTIFICATION_READ],
};
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (process.env.DEV_MODE === "true") {
return true;
}
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const roles = request.userRoles ?? [];
for (const role of roles) {
const perms = ROLE_PERMISSIONS[role];
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
return true;
}
}
throw new PermissionDeniedError(requiredPermissions.join(", "));
}
}

View File

@@ -2,8 +2,6 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
HttpException,
HttpStatus,
Param, Param,
Post, Post,
Put, Put,
@@ -11,42 +9,42 @@ import {
Req, Req,
} from "@nestjs/common"; } from "@nestjs/common";
import { NotificationsService } from "./notifications.service.js"; import { NotificationsService } from "./notifications.service.js";
import type { SendNotificationDto } from "./notifications.service.js"; import {
sendNotificationSchema,
interface AuthedRequest { sendNotificationBatchSchema,
headers: Record<string, string | string[] | undefined>; } from "./notifications.dto.js";
} import type { SendNotificationDto } from "./notifications.dto.js";
import {
/** Permissions,
* 从请求头读取用户身份。 RequirePermission,
* Gateway 在通过鉴权后会注入 x-user-id如未注入开发模式或匿名请求返回 null。 } from "../middleware/permission.guard.js";
*/ import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
function getUserIdFromRequest(req: AuthedRequest): string | null { import { PermissionDeniedError } from "../shared/errors/application-error.js";
const raw = req.headers["x-user-id"];
if (typeof raw === "string" && raw.length > 0) return raw;
return null;
}
@Controller("notifications") @Controller("notifications")
export class NotificationsController { export class NotificationsController {
constructor(private readonly service: NotificationsService) {} constructor(private readonly service: NotificationsService) {}
@Post() @Post()
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> { async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
const result = await this.service.send(body as SendNotificationDto); const dto: SendNotificationDto = sendNotificationSchema.parse(body);
const result = await this.service.send(dto);
return { success: true, data: result }; return { success: true, data: result };
} }
@Post("batch") @Post("batch")
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
async createBatch( async createBatch(
@Body() body: unknown, @Body() body: unknown,
): Promise<{ success: true; data: unknown }> { ): Promise<{ success: true; data: unknown }> {
const dtos = (body as SendNotificationDto[]) ?? []; const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
const result = await this.service.createBatch(dtos); const result = await this.service.createBatch(dtos);
return { success: true, data: result }; return { success: true, data: result };
} }
@Get("user/:userId") @Get("user/:userId")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUser( async listByUser(
@Param("userId") userId: string, @Param("userId") userId: string,
@Query("unread") unread: string, @Query("unread") unread: string,
@@ -56,6 +54,7 @@ export class NotificationsController {
} }
@Get("user/:userId/page") @Get("user/:userId/page")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async listByUserPaginated( async listByUserPaginated(
@Param("userId") userId: string, @Param("userId") userId: string,
@Query("page") page: string, @Query("page") page: string,
@@ -72,29 +71,22 @@ export class NotificationsController {
} }
@Put(":id/read") @Put(":id/read")
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
async markAsRead(@Param("id") id: string): Promise<{ success: true }> { async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
await this.service.markAsRead(id); await this.service.markAsRead(id);
return { success: true }; return { success: true };
} }
@Get("search") @Get("search")
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
async search( async search(
@Req() req: AuthedRequest, @Req() req: AuthenticatedRequest,
@Query("q") q: string, @Query("q") q: string,
@Query("userId") userIdParam: string, @Query("userId") userIdParam: string,
): Promise<{ success: true; data: unknown }> { ): Promise<{ success: true; data: unknown }> {
const userId = getUserIdFromRequest(req) ?? userIdParam; const userId = req.userId ?? userIdParam;
if (!userId) { if (!userId) {
throw new HttpException( throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
{
success: false,
error: {
code: "MSG_PERMISSION_DENIED",
message: "Missing user identity",
},
},
HttpStatus.FORBIDDEN,
);
} }
const result = await this.service.search(userId, q); const result = await this.service.search(userId, q);
return { success: true, data: result }; return { success: true, data: result };

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const sendNotificationSchema = z.object({
userId: z.string().min(1),
type: z.string().min(1).max(50),
title: z.string().min(1).max(200),
content: z.string().min(1),
channel: z.string().max(20).optional(),
metadata: z.record(z.unknown()).optional(),
});
export const sendNotificationBatchSchema = z.array(sendNotificationSchema);
export type SendNotificationDto = z.infer<typeof sendNotificationSchema>;

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { eq, and, desc, sql } from "drizzle-orm"; import { eq, and, desc, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { import {
notifications, notifications,
@@ -10,19 +10,11 @@ import type {
Notification, Notification,
NotificationPreference, NotificationPreference,
} from "./notifications.schema.js"; } from "./notifications.schema.js";
import type { SendNotificationDto } from "./notifications.dto.js";
import { safeIndex, safeSearch } from "../config/elasticsearch.js"; import { safeIndex, safeSearch } from "../config/elasticsearch.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js"; import { logger } from "../shared/observability/logger.js";
export interface SendNotificationDto {
userId: string;
type: string;
title: string;
content: string;
channel?: string;
metadata?: Record<string, unknown>;
}
export interface SendResult { export interface SendResult {
id: string; id: string;
skipped: boolean; skipped: boolean;
@@ -39,7 +31,7 @@ export interface PaginatedResult {
@Injectable() @Injectable()
export class NotificationsService { export class NotificationsService {
async send(dto: SendNotificationDto): Promise<SendResult> { async send(dto: SendNotificationDto): Promise<SendResult> {
const id = uuidv4(); const id = randomUUID();
const channel = dto.channel ?? "in_app"; const channel = dto.channel ?? "in_app";
const [pref] = await db const [pref] = await db

View File

@@ -5,6 +5,7 @@ import {
HttpException, HttpException,
Logger, Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js"; import { ApplicationError } from "./application-error.js";
@@ -14,13 +15,12 @@ export class GlobalErrorFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
// NestJS HttpArgumentsHost 的 getResponse/getRequest 返回 express 实例, const response = ctx.getResponse<Response>();
// 但 msg 服务未引入 @types/express此处按 core-edu / content 模式不显式标注类型。 const request = ctx.getRequest<Request>();
const response = ctx.getResponse();
const request = ctx.getRequest();
const traceIdHeader = request.headers["x-request-id"];
const traceId = const traceId =
(request.headers["x-request-id"] as string | undefined) ?? "unknown"; typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500; let statusCode = 500;
let body: Record<string, unknown>; let body: Record<string, unknown>;
@@ -30,7 +30,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
statusCode = exception.statusCode; statusCode = exception.statusCode;
body = exception.toJSON(); body = exception.toJSON();
} else if (exception instanceof ZodError) { } else if (exception instanceof ZodError) {
// FIX #3: 捕获 Zod 解析错误,转换为结构化 ValidationError 响应
statusCode = 400; statusCode = 400;
body = { body = {
success: false, success: false,
@@ -79,7 +78,6 @@ export class GlobalErrorFilter implements ExceptionFilter {
return res; return res;
} }
if (res && typeof res === "object" && "message" in res) { if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message; const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message; return typeof msg === "string" ? msg : exception.message;
} }

View File

@@ -0,0 +1,346 @@
# 模块理解确认书 — parent-bff
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P4](../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
| 维度 | 内容 |
| -------------- | ---------------------------------------------------------------------------- |
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/parent/*` → parent-bff:3010 |
| 下游被调用方 | iam、core-edu按 004 §4 服务依赖图parent-bff 依赖最少) |
| 通信方式(入) | HTTP RESTapi-gateway → parent-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 微前端对接 | parent-portalai07 负责P4 阶段)通过 api-gateway 调用 parent-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送孩子成绩发布、作业批改等通知) |
**架构定位**004 §1.1a + §5.4
- 按"使用场景域"分 BFFparent-bff 服务于**家长场景域**,复用角色:家长
- 不按角色分 BFF新角色复用现有 BFF 通过视口差异化
- DataScope = **CHILDREN自定义级**家长只能看自己绑定孩子的数据004 §5.3 的 L0 SELF 变体dataScope 枚举值含 `children`,见 004 §5.4 iam 服务职责)
**004 §4 服务依赖图明确**
```
PBFF --> IAM
PBFF --> CoreEdu
```
parent-bff 仅依赖 iam + core-edu**不直接依赖 content / data-ana / msg / ai**。这是设计意图:家长场景的核心是"查看孩子的教学数据",教学数据由 core-edu 提供。但 ai-allocation.md §5 ai04 设计重点提到"家长通知偏好配置",意味着 parent-bff 可能需要调用 msg 服务。**此差异需 coord 仲裁**(见 §7.2)。
---
## 2. 我的限界上下文
### 2.1 我负责什么
parent-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB对齐 teacher-bff 模式)。职责:
1. **多子女账户切换**家长账号可绑定多个孩子parent-bff 维护"当前选中孩子"上下文
2. **聚合**:并行调用 iam查家长信息 + 孩子列表)+ core-edu查孩子的教学数据
3. **裁剪**:将下游返回的领域数据裁剪为家长端所需的最小字段集
4. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
5. **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.2 BFF 混合读策略)
6. **通知偏好**:家长可配置通知偏好(哪些事件推送、哪些不推送),存于 msg 服务或 iam 服务
### 2.2 我的聚合场景(家长视角)
| 场景 | 聚合的下游服务 | 用途 |
| ------------------ | --------------------------------------------------------------------------------- | ---------------------------------- |
| 家长首页 Dashboard | iam `/iam/me` + iam `/iam/children`(缺失) + core-edu `/grades/student/:childId` | 个人信息 + 孩子列表 + 孩子近期成绩 |
| 切换当前孩子 | iam `/iam/children`(缺失) + core-edu `/homework/class/:classId` | 切换上下文后加载孩子数据 |
| 孩子的考试成绩 | core-edu `/exams/class/:classId` + `/grades/student/:childId` | 孩子所在班级考试 + 孩子成绩 |
| 孩子的作业情况 | core-edu `/homework/class/:classId` | 孩子作业列表 + 提交状态 |
| 孩子的成绩趋势 | data-ana `/analytics/student/:childId/trend`004 未列入依赖,待仲裁) | 历史成绩曲线 |
| 孩子的学情诊断 | data-ana `/analytics/student/:childId/weakness`(待仲裁) | 薄弱知识点 |
| 班级学情对比 | data-ana `/analytics/class/:classId/performance`(待仲裁) | 孩子相对班级的位置 |
| 消息中心 | msg `/notifications`004 未列入依赖,待仲裁) | 家长通知列表 |
| 通知偏好配置 | msg 或 iam缺失 | 配置哪些事件推送 |
| 孩子的出勤 | core-edu `/attendance/student/:childId`(缺失) | 出勤记录 |
### 2.3 我不负责什么(明确边界外)
| 不负责项 | 归属服务 | 说明 |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| 业务数据持久化 | core-edu / iam | BFF 不写 DB |
| 权限校验 | 下游业务服务 + iam | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 让下游校验 |
| 用户认证 | iam + api-gateway | JWT 校验在 GatewayBFF 只读 `x-user-id` 头 |
| 学生-家长关系维护 | iam 或 core-edu缺失 | parent-bff 只读取关系,不维护 |
| 领域事件发布 | core-edu | BFF 不发布事件,仅可选订阅事件用于实时推送 |
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId + childId下游按 DataScope=CHILDREN 过滤 |
| 考试批改 | core-edu | 家长不能批改,只能查看孩子成绩 |
| 孩子的作业提交 | core-edu学生端职责 | 家长不能代孩子提交作业 |
---
## 3. 我与外部的契约
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件。
> ⚠️ **核心依赖缺失**iam 没有"家长-学生关联查询"接口parent-bff 无法实现核心场景。
| 下游服务 | proto service设计意图 | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------- |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取家长个人信息 |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取家长端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| iam | **`GetChildrenByParent`proto + REST 双缺失)** | **无** | 查询家长绑定的孩子列表(核心缺失) |
| iam | **`GetParentsByStudent`proto + REST 双缺失)** | **无** | 反向查询(可选) |
| classescore-edu | `ClassService.GetClass` | `GET /classes/:id` | 查孩子所在班级信息 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查孩子班级考试 |
| core-edu | `HomeworkService.ListHomeworkByClass` | `GET /homework/class/:classId` | 查孩子作业 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` / `ListGradesByExam` | `GET /grades/student/:childId` / `GET /grades/exam/:examId` | 查孩子成绩 |
| core-edu | **`AttendanceService`proto + REST 双缺失)** | **无** | 查孩子出勤(全局缺失) |
| data-ana | `AnalyticsService.GetClassPerformance` / `GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析004 未列入依赖,待仲裁) |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` | `GET /notifications`msg 服务 P5 才落地) | 家长通知004 未列入依赖,待仲裁) |
| msg 或 iam | **通知偏好配置proto + REST 双缺失)** | **无** | 配置推送偏好 |
### 3.2 我暴露的 API 端点parent-bff 对外)
> 路由前缀:`/parent`(对齐 teacher-bff 用 `/teacher` 的命名规律)
> 网关路径:`/api/v1/parent/*` → api-gateway 剥离 `/api/v1` 后代理到 parent-bff:3010
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | ---------------------------------------------- | ------------------ | ------------------------ | --------------------------------------- |
| GET | `/parent/dashboard` | iam + core-edu | PARENT_DASHBOARD_READ | 家长首页(含孩子列表 + 近期成绩) |
| GET | `/parent/children` | iam | PARENT_CHILDREN_READ | 我的孩子列表 |
| POST | `/parent/children/:childId/select` | BFF 内部状态) | PARENT_CHILDREN_READ | 切换当前选中孩子sessionId 或 cookie |
| GET | `/parent/children/:childId/exams` | core-edu | PARENT_EXAM_READ | 孩子的考试列表 |
| GET | `/parent/children/:childId/homework` | core-edu | PARENT_HOMEWORK_READ | 孩子的作业列表 |
| GET | `/parent/children/:childId/grades` | core-edu | PARENT_GRADE_READ | 孩子的成绩列表 |
| GET | `/parent/children/:childId/analytics/trend` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子成绩趋势 |
| GET | `/parent/children/:childId/analytics/weakness` | data-ana待仲裁 | PARENT_ANALYTICS_READ | 孩子薄弱知识点 |
| GET | `/parent/notifications` | msg待仲裁 | PARENT_NOTIFICATION_READ | 家长通知列表 |
| POST | `/parent/notifications/:id/read` | msg待仲裁 | PARENT_NOTIFICATION_READ | 标记已读 |
| GET | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_READ | 通知偏好配置 |
| PUT | `/parent/notification-preferences` | msg 或 iam缺失 | PARENT_PREFERENCE_UPDATE | 更新通知偏好 |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| ------------- | ---------------------- | --------------------------------------------------------------------------------- |
| `PARENT_BFF_` | parent-bff 自身错误 | `PARENT_BFF_UNAUTHORIZED``PARENT_BFF_BAD_GATEWAY``PARENT_BFF_CHILD_NOT_BOUND` |
| 下游错误透传 | 下游服务错误码原样返回 | `IAM_USER_NOT_FOUND``CORE_EDU_GRADE_NOT_FOUND` |
错误类清单(对齐 teacher-bff application-error.ts + 新增家长特有错误):
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `BusinessError(422)` — 业务校验失败(如家长查询的 childId 不在自己绑定列表内)
- `NotFoundError(404)` — 资源不存在
- `InternalError(500)` — 未捕获异常
**家长特有错误**
- `PARENT_BFF_CHILD_NOT_BOUND(403)` — 家长查询的 childId 未在自己绑定的孩子列表内DataScope=CHILDREN 越权)
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| Topic | 事件 | 消费动作 |
| --------------------- | ----------------- | ------------------------------ |
| `edu.grade.events` | `grade.recorded` | 推送给家长(孩子成绩发布) |
| `edu.homework.events` | `homework.graded` | 推送给家长(孩子作业批改完成) |
| `edu.exam.events` | `exam.published` | 推送给家长(考试提醒) |
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P4 阶段 parent-bff 可不消费事件,仅做同步聚合。
> ⚠️ 家长通知偏好配置生效后BFF 在订阅事件时应过滤:若家长关闭了"成绩推送"偏好,则不推送该类事件。
---
## 4. 我的技术栈
| 维度 | 选型 | 依据 |
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------- |
| 语言 | TypeScript 5.5+ | 004 §2.1 |
| 框架 | NestJS 10 | 004 §2.1,对齐 teacher-bff 模板 |
| ORM | **无**BFF 不访问 DB | 对齐 teacher-bff无 repository/schema/dto |
| 缓存 | Redis 7短缓存 5-30s | 004 §6.2 BFF 混合读策略 |
| 会话状态 | Redis当前选中孩子 childId | 多子女账户切换,不持久化在 BFF 内存 |
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状;与 student-bff 一致,需 coord 仲裁是否统一升级 GraphQL |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
### 4.1 关于 GraphQL 的设计决策(待 coord 仲裁)
与 student-bff §4.1 相同的矛盾ai04 建议 P4 阶段 parent-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,与 student-bff 保持一致。此决策需 coord 仲裁。
### 4.2 关于多子女账户切换的设计
**问题**:家长账号绑定多个孩子,前端需要知道"当前选中的孩子"。
**方案对比**
| 方案 | 实现 | 优点 | 缺点 |
| -------------- | --------------------------------------------------------- | ---------------- | --------------------------------------------------- |
| A. 前端管理 | 前端在 URL query 或 localStorage 存 childId每次请求带上 | BFF 无状态,简单 | 切换后页面状态丢失风险 |
| B. BFF Session | Redis 存 `parent:userId:currentChildId`BFF 读取 | 切换全局生效 | BFF 引入会话状态,违反"无状态服务"约束004 §12.1 |
| C. JWT Claims | iam 在 JWT 中注入 `currentChildId` claim | 与认证一体化 | 切换孩子需重签 JWT成本高 |
**ai04 建议****方案 A**(前端管理 childIdBFF 无状态),符合 004 §12.1"无状态服务"约束。`POST /parent/children/:childId/select` 端点可选,仅用于记录切换日志(审计),不持久化会话。
---
## 5. 我的阶段归属
| 维度 | 内容 |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 阶段 | **P4 内容分析阶段**M11-M13 |
| 退出标准pending-features P4 | 教师查看知识图谱前置依赖Neo4j 秒级返回)→ 学生查看学情诊断ClickHouse 宽表 5s 内返回)→ CDC 链路延迟 < 5s |
| parent-bff 在 P4 的最小交付 | 家长查看孩子成绩 + 学情诊断(双轨读:实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表) |
| 依赖上游阶段产出 | P1api-gateway + classes 黄金模板 + shared-proto、P2iam 认证 + teacher-bff BFF 模板、P3core-edu 考试/作业/成绩 + student-bff BFF 模式验证) |
| P4 同阶段依赖 | contentP4 落地、data-anaP4 落地,学情诊断 API |
| P5 阶段扩展 | msg 通知 + push-gateway 推送 + 通知偏好配置 |
| 前置阻塞 | iam 必须先补"家长-学生关联查询"接口(见 §7.1 |
### 5.1 P4 阶段最小可行集合MVP
parent-bff 在 P4 阶段优先级:
| 优先级 | 端点 | P4 必需 | 说明 |
| ------ | -------------------------------------------------- | ------- | ------------------------------------------- |
| P0 | `/parent/children` GET | ✅ | 家长核心场景:查孩子列表(依赖 iam 补接口) |
| P0 | `/parent/children/:childId/grades` GET | ✅ | 查孩子成绩 |
| P0 | `/parent/dashboard` GET | ✅ | 家长首页 |
| P1 | `/parent/children/:childId/exams` GET | ✅ | 孩子考试 |
| P1 | `/parent/children/:childId/homework` GET | ✅ | 孩子作业 |
| P1 | `/parent/children/:childId/analytics/trend` GET | ✅ | 学情趋势(依赖 data-ana P4 落地) |
| P1 | `/parent/children/:childId/analytics/weakness` GET | ✅ | 薄弱知识点 |
| P2 | `/parent/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/parent/notification-preferences` GET/PUT | ⚠️ 可选 | P5 落地 |
### 5.2 与 student-bff 的协同
parent-bff 与 student-bff 同属 ai04 负责,技术栈完全一致(同语言、同框架、同 BFF 模式)。两者共享:
- **teacher-bff 克隆模板**shared/ 目录结构、main.ts、env.ts、health.controller.ts 等基础代码
- **聚合模式**Promise.allSettled 并行调用 + BadGatewayError 错误处理
- **可观测性**pino + prom-client + OTel 三支柱
- **错误码前缀**`PARENT_BFF_` / `STUDENT_BFF_` 仅前缀不同
**差异点**
- parent-bff 多了"多子女账户切换"上下文
- parent-bff 多了"DataScope=CHILDREN 越权校验"(家长查询的 childId 必须在自己绑定列表内)
- parent-bff 不直接依赖 content / msg / ai按 004 §4但实际场景可能需要待 coord 仲裁)
---
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 对照 ai-allocation.md §6 模板第 6 节 + §10 审计模板
| 对齐项 | classes 黄金模板 | parent-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `PARENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `parent-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `parent_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `parent-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接 |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 + DataScope 越权校验 |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3010 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 通知偏好更新 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
| ESM `.js` 后缀 import | ✅ tsconfig NodeNext | ✅ 复制 teacher-bff tsconfig | 所有相对 import 带 `.js` |
| `import type` 纯类型导入 | ✅ | ✅ | 对齐 classes 规范 |
| 环境变量 Zod 校验 | ✅ `config/env.ts` | ✅ 复制 teacher-bff env.ts | 下游 URL 配置项扩展 |
### 6.1 与 teacher-bff 模板的差异点(克隆时必须改)
| 文件 | teacher-bff 现值 | parent-bff 应改为 |
| ------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| `package.json` name | `@edu/teacher-bff` | `@edu/parent-bff` |
| `src/config/env.ts` `PORT` default | `"3003"` | `"3010"` |
| `src/config/env.ts` 下游 URL | IamServiceUrl / ClassesServiceUrl / CoreEduServiceUrl | + DataAnaServiceUrl / MsgServiceUrl按仲裁结果 |
| `src/teacher/` 目录名 | `teacher/` | `parent/` |
| `@Controller("teacher")` | `"teacher"` | `"parent"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"parent-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `PARENT_BFF_` |
| `application-error.ts` 新增错误类 | — | `ChildNotBoundError(403)`DataScope=CHILDREN 越权) |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `parent_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"parent-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"parent-bff"` |
| `main.ts` 启动日志 | `"Teacher BFF started"` | `"Parent BFF started"` |
| `Dockerfile` `EXPOSE` | `3003` | `3010` |
---
## 7. 风险与依赖(待 coord 仲裁)
### 7.1 上游依赖缺口(核心阻塞)
| 风险 | 影响 | 缓解措施 |
| ------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **iam 缺失"家长-学生关联查询"接口**proto + REST + schema 三缺失) | **P0 阻塞**parent-bff 核心场景无法实现 | 推动 coord 协调 ai02 在 iam 补1) `iam_student_guardians`2) Repository 查询方法3) `GET /iam/children` REST 端点4) proto `GetChildrenByParent` RPC |
| pending-features P2 提到 `parent_student_relations` 表,但 iam.schema.ts 未实现 | 表缺失 | 同上,推动 ai02 补表 |
| data-ana 服务未实现查询 APIanalytics.proto 3 个 method 无 REST 端点) | P4 学情诊断端点无法实现 | 推动 ai06 在 P4 实现 data-ana 查询 API |
| content.proto 缺 Chapter/Question 域 | parent-bff 不直接依赖 content影响较小 | 推动 coord 在 shared-proto 补全P4 content 服务落地前) |
| iam.proto 缺 Viewport/EffectivePermissions | 家长端导航视口 proto 契约不全 | 当前走 REST `/iam/viewports`proto 补全后切换 |
| 出勤attendance全局缺失 | 家长无法查孩子出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P4 或后续) |
| msg 通知偏好配置接口缺失 | 家长通知偏好无法配置 | 推动 ai05 在 msg 服务补接口P5 阶段) |
### 7.2 设计决策待仲裁
| 决策点 | 选项 | ai04 建议 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图) | **A**(与 student-bff 保持一致) |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| **DataScope=CHILDREN 越权校验位置** | A. BFF 校验(家长查询的 childId 必须在绑定列表内)<br/>B. 下游服务校验core-edu 接收 childId 时校验) | **A**BFF 层校验更早失败,减少下游调用;但需 iam 提供"查询家长绑定孩子列表"接口) |
| 多子女账户切换方案 | A. 前端管理 childIdBFF 无状态)<br/>B. BFF SessionRedis 存当前 childId<br/>C. JWT Claims | **A**(符合 004 §12.1 无状态约束) |
| **004 §4 依赖图与实际场景的偏差** | 004 列 parent-bff 仅依赖 iam + core-edu但家长通知、学情诊断需要 msg + data-ana | **建议扩展依赖**parent-bff → iam + core-edu + data-ana + msg与 student-bff 对齐),需 coord 仲裁并更新 004 §4 |
| Kafka 事件订阅 | A. P4 不订阅(仅同步聚合)<br/>B. P4 订阅事件推送 | **A**push-gateway P5 才落地) |
| 通知偏好存储位置 | A. msg 服务(通知域内)<br/>B. iam 服务(用户偏好域内) | **A**(通知偏好与通知发送强相关,归 msg 服务) |
| 端口分配 | 3010 | 对齐 full-stack-runbook 端口矩阵3001-3009 已用/将用) |
### 7.3 跨模块协作需求(需提交 coord 协调)
| 需求 | 涉及 AI | 协调内容 |
| ---------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| **iam 新增家长-学生关联接口**P0 阻塞) | ai02 | 补 `iam_student_guardians` 表 + Repository + `GET /iam/children` 端点 + proto `GetChildrenByParent` RPC |
| api-gateway 新增 `/parent` 路由 | ai01 | 在 main.go + config.go 新增 `ParentBffURL` 字段 + 路由块 |
| docker-compose.deploy.yml 新增 parent-bff 服务定义 | coordinfra | 端口 3010加入 edu-net + edu-shared 网络 |
| full-stack-runbook 端口矩阵更新 | coorddocs | 追加 3010 行 |
| 004 架构图状态更新 + 依赖图仲裁 | coorddocs | parent-bff 状态从"📐 需设计"改为"✅ 已实现";仲裁是否扩展 parent-bff 依赖到 data-ana + msg |
| shared-proto 补全 iam.protoViewport/EffectivePermissions/StudentParentRelation | coord | 推动 ai02 补 proto |
| shared-proto 补全 core_edu.protoAttendance 域) | coord | 推动 ai03 补 proto |
| data-ana 实现 analytics.proto 查询 API | ai06 | P4 阶段落地 |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P4 升级到 gRPC 通信 |
---
## 8. 阶段 1 自检结论
| 检查项 | 状态 |
| ------------------------------------ | ------------------------------- |
| 已读必读文档清单ai-allocation §4 | ✅ |
| 已运行 arch:scan 更新 arch.db | ✅ |
| 已查 arch:query modules / stats | ✅ |
| 已读 classes 黄金模板源码 | ✅ |
| 已读 teacher-bff BFF 模板源码 | ✅ |
| 已读 iam 认证服务源码 | ✅ |
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3010 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
| 已识别 P0 阻塞项 | ✅iam 家长-学生关联接口缺失) |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**
**特别提示 coord**parent-bff 存在 P0 阻塞项——iam 缺失"家长-学生关联查询"接口。建议 coord 优先协调 ai02 在 P3 阶段parent-bff P4 落地前)补全此接口,否则 P4 阶段 parent-bff 无法启动实施。

View File

@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/prometheus/client_golang v1.23.2
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
@@ -13,6 +14,7 @@ require (
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -36,7 +38,11 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.59.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -47,6 +53,7 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.27.0 // indirect golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect

View File

@@ -1,3 +1,5 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
@@ -51,8 +53,16 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF2
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -62,14 +72,26 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -115,6 +137,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
@@ -136,6 +160,8 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,24 +1,44 @@
package config package config
import "os" import (
"log"
"os"
)
// Config 持有 push-gateway 运行时配置 // Config 持有 push-gateway 运行时配置
type Config struct { type Config struct {
Port string Port string
JWTSecret string JWTSecret string
DevMode bool DevMode bool
RedisURL string RedisURL string
OTLPEndpoint string OTLPEndpoint string
InternalAPIKey string
} }
// Load 从环境变量加载配置并提供默认值 // devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET
const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。
func Load() *Config { func Load() *Config {
devMode := getEnv("DEV_MODE", "false") == "true"
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
if devMode {
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
}
return &Config{ return &Config{
Port: getEnv("PUSH_GATEWAY_PORT", "8081"), Port: getEnv("PUSH_GATEWAY_PORT", "8081"),
JWTSecret: getEnv("JWT_SECRET", "p1-dev-secret-change-in-production"), JWTSecret: jwtSecret,
DevMode: getEnv("DEV_MODE", "false") == "true", DevMode: devMode,
RedisURL: getEnv("REDIS_URL", ""), RedisURL: getEnv("REDIS_URL", ""),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
InternalAPIKey: getEnv("INTERNAL_API_KEY", ""),
} }
} }

View File

@@ -40,6 +40,17 @@ func NewHub() *Hub {
} }
} }
// ClientCount 返回当前在线连接总数(供 /readyz 探针使用)
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for _, conns := range h.clients {
count += len(conns)
}
return count
}
// Register 注册一个用户连接,返回 Connection 供调用方持有 // Register 注册一个用户连接,返回 Connection 供调用方持有
func (h *Hub) Register(userID string, conn *websocket.Conn) *Connection { func (h *Hub) Register(userID string, conn *websocket.Conn) *Connection {
c := &Connection{ c := &Connection{

View File

@@ -3,6 +3,7 @@ package ws
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -25,16 +26,20 @@ var upgrader = websocket.Upgrader{
}, },
} }
// internalAPIKeyHeader 是内部 API 鉴权请求头名称
const internalAPIKeyHeader = "X-Internal-Key"
// Handler 处理 WebSocket 升级与内部推送 API // Handler 处理 WebSocket 升级与内部推送 API
type Handler struct { type Handler struct {
hub *hub.Hub hub *hub.Hub
jwtSecret string jwtSecret string
devMode bool devMode bool
internalAPIKey string
} }
// NewHandler 创建 Handler 实例 // NewHandler 创建 Handler 实例
func NewHandler(h *hub.Hub, jwtSecret string, devMode bool) *Handler { func NewHandler(h *hub.Hub, jwtSecret string, devMode bool, internalAPIKey string) *Handler {
return &Handler{hub: h, jwtSecret: jwtSecret, devMode: devMode} return &Handler{hub: h, jwtSecret: jwtSecret, devMode: devMode, internalAPIKey: internalAPIKey}
} }
// HandleWebSocket 升级 HTTP 为 WebSocket保持长连接并处理心跳 // HandleWebSocket 升级 HTTP 为 WebSocket保持长连接并处理心跳
@@ -47,6 +52,7 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
log.Printf("[ws] upgrade failed, user=%s: %v", userID, err)
return return
} }
defer conn.Close() defer conn.Close()
@@ -58,6 +64,7 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
go func() { go func() {
for msg := range connPtr.Outgoing() { for msg := range connPtr.Outgoing() {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("[ws] write failed, user=%s: %v", userID, err)
return return
} }
} }
@@ -67,6 +74,9 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
for { for {
_, msg, err := conn.ReadMessage() _, msg, err := conn.ReadMessage()
if err != nil { if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("[ws] read failed, user=%s: %v", userID, err)
}
break break
} }
if strings.ToLower(string(msg)) == "ping" { if strings.ToLower(string(msg)) == "ping" {
@@ -77,6 +87,14 @@ func (h *Handler) HandleWebSocket(c *gin.Context) {
// PushHandler 接收来自 Msg 服务的定向推送请求 // PushHandler 接收来自 Msg 服务的定向推送请求
func (h *Handler) PushHandler(c *gin.Context) { func (h *Handler) PushHandler(c *gin.Context) {
if !h.checkInternalAPIKey(c) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
})
return
}
var req struct { var req struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Event string `json:"event"` Event string `json:"event"`
@@ -103,6 +121,14 @@ func (h *Handler) PushHandler(c *gin.Context) {
// BroadcastHandler 接收来自 Msg 服务的广播请求 // BroadcastHandler 接收来自 Msg 服务的广播请求
func (h *Handler) BroadcastHandler(c *gin.Context) { func (h *Handler) BroadcastHandler(c *gin.Context) {
if !h.checkInternalAPIKey(c) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{"code": "UNAUTHORIZED", "message": "invalid or missing internal api key"},
})
return
}
var req struct { var req struct {
Event string `json:"event"` Event string `json:"event"`
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
@@ -122,6 +148,19 @@ func (h *Handler) BroadcastHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// checkInternalAPIKey 校验内部 API 请求头 X-Internal-Key。
// DevMode 下跳过校验;非 DevMode 下要求 INTERNAL_API_KEY 已配置且与请求头匹配。
func (h *Handler) checkInternalAPIKey(c *gin.Context) bool {
if h.devMode {
return true
}
if h.internalAPIKey == "" {
log.Println("[ws] internal api key not configured, rejecting request")
return false
}
return c.GetHeader(internalAPIKeyHeader) == h.internalAPIKey
}
// authenticate 校验 WebSocket 连接的 JWT 或 dev token // authenticate 校验 WebSocket 连接的 JWT 或 dev token
func (h *Handler) authenticate(c *gin.Context) (string, error) { func (h *Handler) authenticate(c *gin.Context) (string, error) {
tokenStr := c.Query("token") tokenStr := c.Query("token")

View File

@@ -14,6 +14,7 @@ import (
"github.com/edu-cloud/push-gateway/internal/observability" "github.com/edu-cloud/push-gateway/internal/observability"
"github.com/edu-cloud/push-gateway/internal/ws" "github.com/edu-cloud/push-gateway/internal/ws"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
) )
@@ -26,7 +27,7 @@ func main() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
h := hub.NewHub() h := hub.NewHub()
wsHandler := ws.NewHandler(h, cfg.JWTSecret, cfg.DevMode) wsHandler := ws.NewHandler(h, cfg.JWTSecret, cfg.DevMode, cfg.InternalAPIKey)
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
@@ -37,6 +38,18 @@ func main() {
c.JSON(200, gin.H{"status": "ok", "service": "push-gateway"}) c.JSON(200, gin.H{"status": "ok", "service": "push-gateway"})
}) })
// 就绪探针:检查 WebSocket hub 状态
r.GET("/readyz", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "push-gateway",
"connections": h.ClientCount(),
})
})
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// WebSocket 升级端点 // WebSocket 升级端点
r.GET("/ws", wsHandler.HandleWebSocket) r.GET("/ws", wsHandler.HandleWebSocket)

View File

@@ -0,0 +1,297 @@
# 模块理解确认书 — student-bff
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P3](../../docs/architecture/roadmap/pending-features.md)
---
## 1. 我在架构中的位置
| 维度 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/student/*` → student-bff:3009 |
| 下游被调用方 | iam、core-edu、content、data-ana按 004 §4 服务依赖图) |
| 通信方式(入) | HTTP RESTapi-gateway → student-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 微前端对接 | student-portalai07 负责P3 阶段)通过 api-gateway 调用 student-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送考试通知、成绩发布等) |
**架构定位**004 §1.1a + §5.4
- 按"使用场景域"分 BFFstudent-bff 服务于**学习场景域**,复用角色:学生
- 不按角色分 BFF新角色复用现有 BFF 通过视口差异化
- DataScope = **SELFL0**学生只能看自己的数据004 §5.3
---
## 2. 我的限界上下文
### 2.1 我负责什么
student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB对齐 teacher-bff 模式)。职责:
1. **聚合**:并行调用多个下游业务服务,组装学生视角的复合数据
2. **裁剪**:将下游返回的领域数据裁剪为学生端所需的最小字段集
3. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
4. **缓存**:聚合结果 Redis 短缓存 5-30s004 §6.2 BFF 混合读策略)
### 2.2 我的聚合场景(学生视角)
| 场景 | 聚合的下游服务 | 用途 |
| ------------------ | --------------------------------------------------------------------------- | ------------------------------ |
| 学生首页 Dashboard | iam `/iam/me` + core-edu `/homework/class/:classId` + msg `/notifications` | 个人信息 + 待办作业 + 未读消息 |
| 即将到来的考试 | core-edu `/exams/class/:classId` | 考试日程提醒 |
| 我的作业列表 | core-edu `/homework/class/:classId` | 查看待完成作业 |
| 提交作业 | core-edu `/homework/:id/submit` | 学生提交作业答案 |
| 我的成绩 | core-edu `/grades/student/:studentId` | 查询历史成绩 |
| 消息中心 | msg `/notifications` + `/notifications/:id/read` | 通知列表 + 已读 |
| 教材浏览 | content `/textbooks` + `/chapters` | 按章节学习 |
| 题库练习 | content `/questions` | 按知识点刷题 |
| 学情诊断 | data-ana `/analytics/student/:id/weakness` + `/analytics/student/:id/trend` | 自我掌握度分析 |
| AI 答疑 | ai `/ai/chat` + `/ai/stream-chat`SSE 流式) | 智能答疑辅助 |
| 个性化学习路径 | content `/knowledge-points/:id/learning-path` | 基于学情推荐学习路径 |
### 2.3 我不负责什么(明确边界外)
| 不负责项 | 归属服务 | 说明 |
| -------------- | ------------------------------ | ----------------------------------------------------------------- |
| 业务数据持久化 | core-edu / content / msg / iam | BFF 不写 DB |
| 权限校验 | 下游业务服务 + iam | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 让下游校验 |
| 用户认证 | iam + api-gateway | JWT 校验在 GatewayBFF 只读 `x-user-id` 头 |
| 领域事件发布 | core-edu / content | BFF 不发布事件,仅可选订阅事件用于实时推送 |
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId下游按 DataScope=SELF 过滤 |
| 班级管理 | core-educlasses 模块) | 学生只读自己所在班级 |
| 考试批改 | core-edu | 学生不能批改,只能查看成绩 |
---
## 3. 我与外部的契约
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件。
| 下游服务 | proto service设计意图 | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------ |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取学生个人信息 + roles + dataScope |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取学生端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| classescore-edu | `ClassService.GetClass` / `ListClasses` | `GET /classes` / `GET /classes/:id` | 查自己所在班级 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查班级考试 |
| core-edu | `HomeworkService.GetHomework` / `ListHomeworkByClass` / `SubmitHomework` | `GET /homework/class/:classId` / `POST /homework/:id/submit` | 查作业 + 提交 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` | `GET /grades/student/:studentId` | 查自己成绩 |
| content | `TextbookService.GetTextbook` / `ListTextbooks` | `GET /textbooks` | 查教材 |
| content | `ChapterService`proto 缺失) | `GET /chapters` / `GET /chapters/:id` | 查章节 |
| content | `QuestionService`proto 缺失) | `GET /questions` | 查题库 |
| content | `KnowledgeGraphService.GetLearningPath` | `GET /knowledge-points/:id/learning-path` | 学习路径 |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` / `SearchNotifications` | `GET /notifications` / `POST /notifications/:id/read` | 消息中心 |
| data-ana | `AnalyticsService.GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析 |
| ai | `AiService.Chat` / `StreamChat` / `GenerateQuestion` | **REST 未实现** | AI 答疑 |
### 3.2 我暴露的 API 端点student-bff 对外)
> 路由前缀:`/student`(对齐 teacher-bff 用 `/teacher` 的命名规律BFF 用角色单数无 `-bff` 后缀)
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | --------------------------------- | -------------------- | ------------------------- | -------------------- |
| GET | `/student/dashboard` | iam + core-edu + msg | STUDENT_DASHBOARD_READ | 学生首页聚合 |
| GET | `/student/exams` | core-edu | STUDENT_EXAM_READ | 即将到来的考试 |
| GET | `/student/homework` | core-edu | STUDENT_HOMEWORK_READ | 我的作业列表 |
| POST | `/student/homework/:id/submit` | core-edu | STUDENT_HOMEWORK_SUBMIT | 提交作业 |
| GET | `/student/grades` | core-edu | STUDENT_GRADE_READ | 我的成绩 |
| GET | `/student/notifications` | msg | STUDENT_NOTIFICATION_READ | 消息列表 |
| POST | `/student/notifications/:id/read` | msg | STUDENT_NOTIFICATION_READ | 标记已读 |
| GET | `/student/textbooks` | content | STUDENT_CONTENT_READ | 教材列表 |
| GET | `/student/chapters/:textbookId` | content | STUDENT_CONTENT_READ | 章节树 |
| GET | `/student/questions` | content | STUDENT_CONTENT_READ | 题库(按知识点过滤) |
| GET | `/student/analytics/weakness` | data-ana | STUDENT_ANALYTICS_READ | 学情诊断 |
| GET | `/student/analytics/trend` | data-ana | STUDENT_ANALYTICS_READ | 学习趋势 |
| POST | `/student/ai/chat` | ai | STUDENT_AI_CHAT | AI 答疑(同步) |
| POST | `/student/ai/stream-chat` | ai | STUDENT_AI_CHAT | AI 答疑SSE 流式) |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| -------------- | ---------------------- | ----------------------------------------------------- |
| `STUDENT_BFF_` | student-bff 自身错误 | `STUDENT_BFF_UNAUTHORIZED``STUDENT_BFF_BAD_GATEWAY` |
| 下游错误透传 | 下游服务错误码原样返回 | `CLASSES_NOT_FOUND``IAM_USER_NOT_FOUND` |
错误类清单(对齐 teacher-bff application-error.ts
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `InternalError(500)` — 未捕获异常
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| Topic | 事件 | 消费动作 |
| --------------------- | --------------------------------------- | -------------------------- |
| `edu.homework.events` | `homework.assigned` / `homework.graded` | 推送给学生push-gateway |
| `edu.exam.events` | `exam.published` / `exam.updated` | 考试提醒推送 |
| `edu.grade.events` | `grade.recorded` | 成绩发布推送 |
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P3 阶段 student-bff 可不消费事件,仅做同步聚合。
---
## 4. 我的技术栈
| 维度 | 选型 | 依据 |
| ------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| 语言 | TypeScript 5.5+ | 004 §2.1 |
| 框架 | NestJS 10 | 004 §2.1,对齐 teacher-bff 模板 |
| ORM | **无**BFF 不访问 DB | 对齐 teacher-bff无 repository/schema/dto |
| 缓存 | Redis 7短缓存 5-30s | 004 §6.2 BFF 混合读策略 |
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状pending-features P2 设计意图为 GraphQL但 teacher-bff 实际未落地 GraphQL需 coord 仲裁是否在 student-bff 引入 |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
### 4.1 关于 GraphQL 的设计决策(待 coord 仲裁)
**现状矛盾**
- 004 §11.3 BFF 聚合模式图示为 GraphQL Resolver + DataLoader + Redis 缓存
- pending-features P2 明确"Teacher BFFTS/GraphQL"用 GraphQL Yoga + DataLoader
- **实际**teacher-bff 当前是纯 REST + fetch无 GraphQL、无 DataLoader
- ai-allocation.md §5 ai04 设计重点提到"DataLoader 复用 teacher-bff 模式"
**ai04 倾向方案**P3 阶段 student-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,避免技术栈分裂;若 coord 决策统一升级到 GraphQL则在 P3 后期或 P4 阶段同步升级 teacher-bff + student-bff + parent-bff 三端。此决策需 coord 仲裁。
---
## 5. 我的阶段归属
| 维度 | 内容 |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 阶段 | **P3 核心教学阶段**M7-M10 |
| 退出标准pending-features P3 | 教师创建考试 → 发布 → 学生作答提交 → 教师批改 → 事件发到 Kafka → 成绩统计更新 → 全链路可观测 |
| student-bff 在 P3 的最小交付 | 学生作答作业页面所需 API`/student/homework` 列表 + `/student/homework/:id/submit` 提交 + `/student/grades` 成绩查看 |
| 依赖上游阶段产出 | P1api-gateway 路由骨架 + classes 黄金模板 + shared-proto、P2iam 认证 + teacher-bff BFF 模板 + teacher-portal 微前端骨架) |
| P3 同阶段依赖 | core-edu考试/作业/成绩域 CRUD + Outbox 事件) |
| P4 阶段扩展 | 学情诊断查询(双轨读:实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表) |
| P5 阶段扩展 | AI 答疑流式响应 + Kafka 事件订阅推送 |
### 5.1 P3 阶段最小可行集合MVP
student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 优先级 | 端点 | P3 必需 | 说明 |
| ------ | ----------------------------------------------------------------- | ------- | ------------------------- |
| P0 | `/student/homework` GET | ✅ | 学生作答作业页面核心 |
| P0 | `/student/homework/:id/submit` POST | ✅ | 学生作答提交 |
| P0 | `/student/grades` GET | ✅ | 成绩查看 |
| P0 | `/student/dashboard` GET | ✅ | 学生首页 |
| P1 | `/student/exams` GET | ✅ | 考试日程 |
| P1 | `/student/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/student/textbooks` / `/student/chapters` / `/student/questions` | ❌ P4 | content 服务 P4 才落地 |
| P2 | `/student/analytics/*` | ❌ P4 | data-ana 学情诊断 P4 |
| P2 | `/student/ai/*` | ❌ P5 | ai 服务 P5 |
---
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
> 对照 ai-allocation.md §6 模板第 6 节 + §10 审计模板
| 对齐项 | classes 黄金模板 | student-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `STUDENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `student-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `student_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `student-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok可选检查下游服务可达性 |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接,仅需关闭 HTTP server + tracer |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 mock 下游 fetch |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3009 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 提交作业 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
| ESM `.js` 后缀 import | ✅ tsconfig NodeNext | ✅ 复制 teacher-bff tsconfig | 所有相对 import 带 `.js` |
| `import type` 纯类型导入 | ✅ | ✅ | 对齐 classes 规范 |
| 环境变量 Zod 校验 | ✅ `config/env.ts` | ✅ 复制 teacher-bff env.ts | 下游 URL 配置项扩展 |
### 6.1 与 teacher-bff 模板的差异点(克隆时必须改)
| 文件 | teacher-bff 现值 | student-bff 应改为 |
| ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `package.json` name | `@edu/teacher-bff` | `@edu/student-bff` |
| `src/config/env.ts` `PORT` default | `"3003"` | `"3009"` |
| `src/config/env.ts` 下游 URL | IamServiceUrl / ClassesServiceUrl / CoreEduServiceUrl | + ContentServiceUrl / DataAnaServiceUrl / MsgServiceUrl / AiServiceUrl按聚合需求 |
| `src/teacher/` 目录名 | `teacher/` | `student/` |
| `@Controller("teacher")` | `"teacher"` | `"student"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"student-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `STUDENT_BFF_` |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `student_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"student-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"student-bff"` |
| `main.ts` 启动日志 | `"Teacher BFF started"` | `"Student BFF started"` |
| `Dockerfile` `EXPOSE` | `3003` | `3009` |
---
## 7. 风险与依赖(待 coord 仲裁)
### 7.1 上游依赖缺口
| 风险 | 影响 | 缓解措施 |
| -------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
| data-ana 服务未实现查询 APIanalytics.proto 3 个 method 无 REST 端点) | P4 学情诊断端点无法实现 | P3 阶段先不实现 `/student/analytics/*`,等 ai06 在 P4 实现 data-ana 查询 API 后再补 |
| ai 服务未实现 REST/gRPC 端点 | P5 AI 答疑端点无法实现 | P3/P4 阶段先不实现 `/student/ai/*`,等 ai06 在 P5 实现 ai 服务后再补 |
| content.proto 缺 Chapter/Question 域 | P4 教材/题库端点 proto 契约不全 | 推动 coord 在 shared-proto 补全 content.proto |
| iam.proto 缺 Viewport/EffectivePermissions | 学生端导航视口 proto 契约不全 | 当前走 REST `/iam/viewports`proto 补全后切换 |
| 出勤attendance全局缺失 | 学生端无法查出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P3 后期或 P4 |
| 学生-家长关联表缺失pending-features P2 提到 `parent_student_relations` | 影响 parent-bff不影响 student-bff | 报告给 coord由 ai02 在 iam 或 ai03 在 core-edu 补表 |
### 7.2 设计决策待仲裁
| 决策点 | 选项 | ai04 建议 |
| ------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图 + pending-features P2 | **A**P3 阶段先 REST避免技术栈分裂后续统一升级 |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| `/readyz` 检查逻辑 | A. 直接返回 ok对齐 teacher-bff<br/>B. 检查下游服务可达性 | **A**P3 阶段,下游可达性由 Prometheus 监控) |
| Kafka 事件订阅 | A. P3 不订阅(仅同步聚合)<br/>B. P3 订阅事件推送 | **A**push-gateway P5 才落地P3 无推送通道) |
| 端口分配 | 3009 | 对齐 full-stack-runbook 端口矩阵3001-3008 已用) |
### 7.3 跨模块协作需求(需提交 coord 协调)
| 需求 | 涉及 AI | 协调内容 |
| ------------------------------------------------------------ | -------------- | --------------------------------------------------------- |
| api-gateway 新增 `/student` 路由 | ai01 | 在 main.go + config.go 新增 `StudentBffURL` 字段 + 路由块 |
| docker-compose.deploy.yml 新增 student-bff 服务定义 | coordinfra | 端口 3009加入 edu-net + edu-shared 网络 |
| full-stack-runbook 端口矩阵更新 | coorddocs | 追加 3009 行 |
| 004 架构图状态更新 | coorddocs | student-bff 状态从"📐 需设计"改为"✅ 已实现" |
| shared-proto 补全 content.protoChapter/Question | coord | P4 阶段 content 服务落地前补全 |
| shared-proto 补全 iam.protoViewport/EffectivePermissions | coord | 推动 ai02 补 proto |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P3 升级到 gRPC 通信 |
---
## 8. 阶段 1 自检结论
| 检查项 | 状态 |
| ------------------------------------ | --------------------------- |
| 已读必读文档清单ai-allocation §4 | ✅ |
| 已运行 arch:scan 更新 arch.db | ✅ |
| 已查 arch:query modules / stats | ✅ |
| 已读 classes 黄金模板源码 | ✅ |
| 已读 teacher-bff BFF 模板源码 | ✅ |
| 已读 iam 认证服务源码 | ✅ |
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3009 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**

View File

@@ -0,0 +1,176 @@
# 服务审计表 — ai04
> AI 标识ai04
> 阶段:阶段 1自检
> 日期2026-07-09
> 审计对象student-bffP3 待实现、parent-bffP4 待实现)
> 对照基准classes 黄金模板(已实现)+ teacher-bff BFF 模板(已实现)
> 模板来源:[ai-allocation.md §10](../../docs/architecture/ai-allocation.md)
---
## 1. 审计状态说明
| 标记 | 含义 |
| ---- | --------------------------------------------------- |
| ✅ | 已对齐黄金模板(实施时复制 + 改名即可) |
| ⚠️ | 有差异但已识别缓解方案(需 coord 仲裁或实施时调整) |
| ❌ | 未对齐,需补齐 |
| 🆕 | 全新服务,尚未实现,审计结果为"设计目标" |
由于 student-bff 和 parent-bff 均为**全新服务(未实现)**,本审计表为**设计目标审计**,列出实施时需对齐的各项。
---
## 2. ai04 服务审计表
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ---------------------------- | ---------- | ----------------- | ------- | -------------- | ------- | -------- | ------- | ---------- | ------------ | ---------- |
| **student-bff**P3 待实现) | ⚠️ 不对齐 | ✅ `STUDENT_BFF_` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ✅ | ✅ SIGTERM | 🆕 目标 ≥80% | ✅ 多阶段 |
| **parent-bff**P4 待实现) | ⚠️ 不对齐 | ✅ `PARENT_BFF_` | ✅ pino | ✅ prom-client | ✅ OTel | ✅ | ✅ | ✅ SIGTERM | 🆕 目标 ≥80% | ✅ 多阶段 |
---
## 3. 审计项详解
### 3.1 权限装饰器 `@RequirePermission` — ⚠️ 不对齐
**classes 黄金模板**:全部 Controller 方法用 `@RequirePermission(Permissions.XXX)` 装饰,全局 `PermissionGuard` 校验。
**ai04 服务决策****不对齐**。理由:
- BFF 是纯聚合层,权限由下游业务服务校验(对齐 teacher-bff 现状)
- BFF 透传 `x-user-id` 头给下游,下游 Repository 按 DataScope 过滤
- parent-bff 在 BFF 层做"DataScope=CHILDREN 越权校验"(家长查询的 childId 必须在绑定列表内),但用普通 Service 层校验,不用装饰器
**风险**:若 coord 要求 BFF 也加权限装饰器(双重校验),需在 student-bff/parent-bff 引入 `middleware/permission.guard.ts`(复制 classes 实现)。
### 3.2 错误码前缀 — ✅ 对齐
| 服务 | 前缀 | 示例 |
| ----------- | -------------- | --------------------------------------------------------------------------------- |
| student-bff | `STUDENT_BFF_` | `STUDENT_BFF_UNAUTHORIZED``STUDENT_BFF_BAD_GATEWAY` |
| parent-bff | `PARENT_BFF_` | `PARENT_BFF_UNAUTHORIZED``PARENT_BFF_BAD_GATEWAY``PARENT_BFF_CHILD_NOT_BOUND` |
对齐 teacher-bff 的 `TEACHER_BFF_` 模式。
### 3.3 logger — ✅ 对齐
复制 teacher-bff `shared/observability/logger.ts`,仅改 `service` 字段:
- student-bff: `service: 'student-bff'`
- parent-bff: `service: 'parent-bff'`
### 3.4 metrics — ✅ 对齐
复制 teacher-bff `shared/observability/metrics.ts` + `main.ts``/metrics` 端点注册,仅改指标名前缀:
- student-bff: `student_bff_requests_total``student_bff_request_duration_seconds`
- parent-bff: `parent_bff_requests_total``parent_bff_request_duration_seconds`
### 3.5 tracer — ✅ 对齐
复制 teacher-bff `shared/observability/tracer.ts`,仅改 `serviceName`
- student-bff: `serviceName: 'student-bff'`
- parent-bff: `serviceName: 'parent-bff'`
### 3.6 /healthz — ✅ 对齐
复制 teacher-bff `shared/health/health.controller.ts`,仅改 `SERVICE_NAME`
- student-bff: `service: 'student-bff'`
- parent-bff: `service: 'parent-bff'`
### 3.7 /readyz — ✅ 对齐
BFF 不查 DB`/readyz` 直接返回 ok对齐 teacher-bff
**可选增强**P3 后期检查下游服务可达性HEAD 请求 iam/core-edu 的 `/healthz`),但 P3 阶段建议先简单返回 ok下游可达性由 Prometheus 监控。
### 3.8 优雅关闭 — ✅ 对齐
复制 teacher-bff `main.ts` 中 SIGTERM 处理:
```ts
process.on("SIGTERM", async () => {
await app.close();
await shutdownTracer();
});
```
BFF 无 DB 连接需关闭(无 LifecycleService仅需关闭 HTTP server + tracer。
### 3.9 测试覆盖率 — 🆕 目标 ≥ 80%
**测试重点**
- Service 层聚合逻辑mock 下游 fetch验证 Promise.allSettled 容错)
- Controller 层入参校验Zod schema
- 错误处理BadGatewayError、UnauthorizedError、ValidationError
- parent-bff 特有DataScope=CHILDREN 越权校验
**测试框架**Jest对齐 classes待确认 classes 是否已配置 Jest
### 3.10 Dockerfile — ✅ 对齐
复制 teacher-bff `Dockerfile`多阶段构建builder + runtime仅改 `EXPOSE`
- student-bff: `EXPOSE 3009`
- parent-bff: `EXPOSE 3010`
---
## 4. 与 teacher-bff 模板对比(克隆基线)
teacher-bff 是已实现的最小 BFF 模板student-bff/parent-bff 应 1:1 克隆后改造。
### 4.1 完全复制的部分(无需改动)
| 文件/目录 | 说明 |
| -------------------------------------- | ----------------------------------- |
| `shared/errors/global-error.filter.ts` | 全局错误过滤器(仅错误码前缀不同) |
| `shared/health/health.module.ts` | 健康检查模块 |
| `shared/observability/logger.ts` | pino logger仅 service 名不同) |
| `shared/observability/metrics.ts` | prom-client仅指标名前缀不同 |
| `shared/observability/tracer.ts` | OTel tracer仅 serviceName 不同) |
| `app.module.ts` | 根模块(仅 imports 的业务模块不同) |
| `main.ts` | 启动流程(仅端口和日志不同) |
| `tsconfig.json` | ESM 配置 |
| `nest-cli.json` | NestJS CLI 配置 |
### 4.2 需要改造的部分
| 文件 | 改造点 |
| --------------------------------------- | ---------------------------------------------------------- |
| `package.json` | name 字段、dependencies按下游服务需求扩展 |
| `src/config/env.ts` | PORT default、下游服务 URL 配置项 |
| `src/<context>/` 目录名 | teacher → student / parent |
| `src/<context>/<context>.controller.ts` | @Controller 装饰器前缀、端点路由 |
| `src/<context>/<context>.service.ts` | 聚合逻辑(按场景域不同) |
| `src/<context>/<context>.module.ts` | 模块注册 |
| `shared/errors/application-error.ts` | 错误码前缀、新增错误类parent-bff 加 ChildNotBoundError |
| `shared/health/health.controller.ts` | SERVICE_NAME |
| `Dockerfile` | EXPOSE 端口 |
### 4.3 teacher-bff 模板的可改进点ai04 实施时可优化)
| 改进点 | teacher-bff 现状 | ai04 建议改进 |
| ------------ | ----------------------------- | ------------------------------------------------------------ |
| 聚合响应类型 | 大量用 `unknown` | 用 Zod schema 推导强类型响应 |
| 下游调用封装 | 原生 `fetch()` 散落在 Service | 抽取 `DownstreamClient` 工具类带超时、重试、traceId 透传) |
| 错误信封 | 下游错误直接吞掉返回 null | 下游错误结构化记录service/endpoint/status透传 traceId |
| 缓存 | 无缓存 | 引入 NestJS CacheInterceptor + Redis5-30s 短缓存) |
> ⚠️ 这些改进点需 coord 仲裁是否回写到 teacher-bff避免技术栈分裂还是仅用于 student-bff/parent-bff。
---
## 5. 审计结论
| 服务 | 总体对齐度 | 阻塞项 | 备注 |
| ----------- | ------------ | -------------------------------------- | ---------------------------------------------------------------- |
| student-bff | 🟢 高90% | 无 P0 阻塞 | P3 阶段可直接克隆 teacher-bff 实施data-ana/ai 端点延后到 P4/P5 |
| parent-bff | 🟡 中70% | **P0 阻塞iam 家长-学生关联接口缺失** | 需 coord 协调 ai02 在 P3 阶段补全 iam 接口,否则 P4 无法启动实施 |
**ai04 阶段 1 审计完成。两份理解确认书 + 本审计表已交付,请 coord 审核。**

View File

@@ -0,0 +1,77 @@
# ai04 工作经验日志
> AI 标识ai04student-bff + parent-bff
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 待 coord 合并到 `docs/troubleshooting/known-issues.md` "工作经验日志"区
---
## 2026-07-09 阶段 1 上下文加载
**模块**student-bffP3 待实现、parent-bffP4 待实现)
**做了什么**
1. 运行 `pnpm run arch:scan` 更新 arch.db10 TS / 2 Go / 2 Python / 138 proto 契约12 模块 342 符号)
2. 通过 3 个 search subagent 并行探索classes/teacher-bff/iam 三个 TS NestJS 模板服务结构、shared-proto 全部 8 个 .proto 契约、student-bff/parent-bff 现状与端口/路由预留
3. 阅读 README、MIGRATION_GUIDE、004 架构影响地图、pending-features 路线图
4. 产出两份模块理解确认书 + 一份服务审计表
**学到什么**
1. **teacher-bff 实际实现与设计意图存在差距**
- 004 §11.3 设计意图为 GraphQL + DataLoaderpending-features P2 明确"Teacher BFFTS/GraphQL"
- 实际 teacher-bff 是纯 REST + 原生 fetch + Promise.allSettled无 GraphQL、无 DataLoader
- ai04 建议 student-bff/parent-bff 先对齐 teacher-bff 现状REST避免技术栈分裂待 coord 仲裁是否统一升级
2. **shared-proto 契约与实际 REST 实现存在显著缺口**
- proto 包名用 `next_edu_cloud.*` 而非项目规则要求的 `edu.*`8 个文件全不合规)
- buf.gen.yaml 只配置了 protocolbuffers 序列化插件,**无 gRPC 插件**proto 目前仅作"契约文档"
- iam REST 已暴露 viewports/permissions/effective/roles 端点,但 proto 完全缺失
- content REST 已实现 chapters/questions CRUD但 proto 完全缺失
- data-ana/ai 服务的 proto 已定义但 REST 端点未实现
3. **parent-bff 存在 P0 阻塞项**
- iam 缺失"家长-学生关联查询"接口proto + REST + schema 三缺失)
- pending-features P2 提到 `parent_student_relations` 表,但 iam.schema.ts 未实现
- 需 coord 协调 ai02 在 P3 阶段补全,否则 P4 parent-bff 无法启动
4. **004 §4 服务依赖图与实际场景存在偏差**
- 004 列 parent-bff 仅依赖 iam + core-edu
- 但家长通知、学情诊断场景需要 msg + data-ana
- 需 coord 仲裁是否扩展 parent-bff 依赖
5. **端口/路由预留情况**
- student-bff=3009、parent-bff=3010 可用3001-3008 已用)
- api-gateway 未预留 `/student``/parent` 路由(需 ai01 协调)
- pnpm-workspace.yaml glob `services/*` 自动覆盖新目录
- commitlint scope-enum + CODEOWNERS 已预留 student-bff/parent-bff
6. **BFF 克隆模板策略**
- teacher-bff 是最小 BFF 模板shared/ 目录结构与 classes 完全一致
- BFF 特征:无 DB、无 repository/schema/dto、无 PermissionGuard、无 LifecycleService
- student-bff/parent-bff 可 1:1 克隆 teacher-bff 后改造11 处需改名)
**下次注意**
1. 阶段 2 设计文档需等 coord 审核阶段 1 后再产出
2. 实施前需推动 coord 协调 ai02 补全 iam 家长-学生关联接口P0 阻塞)
3. 实施前需推动 coord 协调 ai01 在 api-gateway 预留 `/student``/parent` 路由
4. proto 包名合规性(`next_edu_cloud.*``edu.*`)需 coord 仲裁是否迁移
5. buf.gen.yaml 是否补 gRPC 插件需 coord 决策
---
## 待 coord 合并到 known-issues.md "场景→技术"映射
| 场景 | 技术/规则 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| BFF 服务克隆 teacher-bff 模板 | 复制 shared/ + 改 11 处命名package.json/env.ts/Controller/SERVICE_NAME/错误码前缀/指标名前缀/tracer serviceName/logger service/Dockerfile EXPOSE |
| BFF 不做权限校验 | 透传 `x-user-id` 给下游服务校验(对齐 teacher-bffBFF 无 PermissionGuard |
| BFF 聚合用 Promise.allSettled | Dashboard 类聚合容错(部分失败返回 null单依赖查询用 throw BadGatewayError 快速失败 |
| BFF 无 DB 依赖 | /readyz 直接返回 ok无 LifecycleService无 closeDb() |
| 新服务端口分配 | NestJS 服务按 3001-3008 顺序分配student-bff=3009、parent-bff=3010 |
| api-gateway 路由前缀命名 | BFF 用角色单数无 `-bff` 后缀teacher-bff → `/teacher`student-bff → `/student` |
| proto 包名不合规 | 当前用 `next_edu_cloud.*`,规则要求 `edu.*`8 个文件全不合规,待 coord 仲裁迁移 |
| buf.gen.yaml 缺 gRPC 插件 | 当前只配置 protocolbuffers 序列化proto 仅作"契约文档"gRPC 通信未落地 |

View File

@@ -30,6 +30,7 @@
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"eslint": "^9.10.0", "eslint": "^9.10.0",
"pino-pretty": "^11.2.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
} }

View File

@@ -1,9 +1,8 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { TeacherModule } from "./teacher/teacher.module.js"; import { TeacherModule } from "./teacher/teacher.module.js";
import { HealthController } from "./health.controller.js"; import { HealthModule } from "./shared/health/health.module.js";
@Module({ @Module({
imports: [TeacherModule], imports: [TeacherModule, HealthModule],
controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,26 +1,31 @@
import "reflect-metadata"; import "reflect-metadata";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js"; import { AppModule } from "./app.module.js";
import { env } from "./config/env.js"; import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { env } from "./config/env.js";
import { metricsRegistry } from "./shared/observability/metrics.js"; import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
initTracer(); initTracer();
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: ["log", "error", "warn"], logger: ["log", "error", "warn"],
}); });
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。 // Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
app.getHttpAdapter().get("/metrics", async (_req, res) => { app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType); res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics()); res.end(await metricsRegistry.metrics());
}); });
await app.listen(env.PORT); await app.listen(env.PORT);
console.log(`Teacher BFF started on port ${env.PORT}`); logger.info({ port: env.PORT }, "Teacher BFF started");
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
await app.close(); await app.close();
@@ -29,6 +34,6 @@ async function bootstrap(): Promise<void> {
} }
bootstrap().catch((err: unknown) => { bootstrap().catch((err: unknown) => {
console.error("Failed to start Teacher BFF", err); logger.error({ err }, "Failed to start Teacher BFF");
process.exit(1); process.exit(1);
}); });

View File

@@ -0,0 +1,118 @@
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "conflict"
| "business"
| "database"
| "bad_gateway"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_VALIDATION_ERROR", details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "TEACHER_BFF_NOT_FOUND", {
resource,
id,
});
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, "TEACHER_BFF_PERMISSION_DENIED", {
permission,
});
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_UNAUTHORIZED", details);
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_DATABASE_ERROR", details);
}
}
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BAD_GATEWAY", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,87 @@
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "TEACHER_BFF_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
// 从 HttpException 响应体收窄类型NestJS 约定包含 message 字段)
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -1,10 +1,12 @@
import { Controller, Get } from "@nestjs/common"; import { Controller, Get } from "@nestjs/common";
const SERVICE_NAME = "teacher-bff";
/** /**
* *
* *
* - GET /healthzliveness * - GET /healthzliveness
* - GET /readyzreadiness ok * - GET /readyzreadinessBFF 访 DB ok
* *
* *
*/ */
@@ -14,7 +16,7 @@ export class HealthController {
liveness(): { status: string; service: string; timestamp: string } { liveness(): { status: string; service: string; timestamp: string } {
return { return {
status: "ok", status: "ok",
service: "teacher-bff", service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }
@@ -23,7 +25,7 @@ export class HealthController {
readiness(): { status: string; service: string; timestamp: string } { readiness(): { status: string; service: string; timestamp: string } {
return { return {
status: "ok", status: "ok",
service: "teacher-bff", service: SERVICE_NAME,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,19 @@
import pino from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: "teacher-bff",
version: "0.1.0",
},
transport:
env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: { colorize: true },
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -1,55 +1,70 @@
import { import { Controller, Get, Param, Req } from "@nestjs/common";
Controller,
Get,
Param,
Req,
UnauthorizedException,
} from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { TeacherService } from "./teacher.service.js"; import { TeacherService } from "./teacher.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
interface SuccessResponse<T> {
success: true;
data: T;
}
@Controller("teacher") @Controller("teacher")
export class TeacherController { export class TeacherController {
constructor(private readonly service: TeacherService) {} constructor(private readonly service: TeacherService) {}
@Get("dashboard") @Get("dashboard")
async dashboard(@Req() req: Request) { async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = req.headers["x-user-id"] as string; const userId = this.extractUserId(req);
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
const data = await this.service.getDashboard(userId); const data = await this.service.getDashboard(userId);
return { success: true as const, data }; return { success: true as const, data };
} }
@Get("viewports") @Get("viewports")
async viewports(@Req() req: Request) { async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = req.headers["x-user-id"] as string; const userId = this.extractUserId(req);
if (!userId) {
throw new UnauthorizedException("Missing x-user-id header");
}
const data = await this.service.getViewports(userId); const data = await this.service.getViewports(userId);
return { success: true as const, data }; return { success: true as const, data };
} }
// 聚合班级下的考试列表core-edu // 聚合班级下的考试列表core-edu
@Get("classes/:classId/exams") @Get("classes/:classId/exams")
async listExamsByClass(@Param("classId") classId: string) { async listExamsByClass(
const data = await this.service.listExamsByClass(classId); @Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listExamsByClass(userId, classId);
return { success: true as const, data }; return { success: true as const, data };
} }
// 聚合班级下的作业列表core-edu // 聚合班级下的作业列表core-edu
@Get("classes/:classId/homework") @Get("classes/:classId/homework")
async listHomeworkByClass(@Param("classId") classId: string) { async listHomeworkByClass(
const data = await this.service.listHomeworkByClass(classId); @Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listHomeworkByClass(userId, classId);
return { success: true as const, data }; return { success: true as const, data };
} }
// 聚合考试下的成绩列表core-edu // 聚合考试下的成绩列表core-edu
@Get("exams/:examId/grades") @Get("exams/:examId/grades")
async listGradesByExam(@Param("examId") examId: string) { async listGradesByExam(
const data = await this.service.listGradesByExam(examId); @Req() req: Request,
@Param("examId") examId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listGradesByExam(userId, examId);
return { success: true as const, data }; return { success: true as const, data };
} }
private extractUserId(req: Request): string {
const header = req.headers["x-user-id"];
const userId = typeof header === "string" ? header : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
return userId;
}
} }

View File

@@ -1,5 +1,7 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import { BadGatewayError } from "../shared/errors/application-error.js";
export interface ViewportItem { export interface ViewportItem {
key: string; key: string;
@@ -10,13 +12,20 @@ export interface ViewportItem {
requiredPermission: string | null; requiredPermission: string | null;
} }
interface DownstreamEnvelope<T> {
success: boolean;
data?: T;
}
interface DashboardData {
user: unknown;
classes: unknown;
}
@Injectable() @Injectable()
export class TeacherService { export class TeacherService {
// 聚合 IAM + classes 服务的数据 // 聚合 IAM + classes 服务的数据
async getDashboard(userId: string): Promise<{ async getDashboard(userId: string): Promise<DashboardData> {
user: unknown;
classes: unknown;
}> {
const [iamRes, classesRes] = await Promise.allSettled([ const [iamRes, classesRes] = await Promise.allSettled([
fetch(`${env.IamServiceUrl}/iam/me`, { fetch(`${env.IamServiceUrl}/iam/me`, {
headers: { "x-user-id": userId }, headers: { "x-user-id": userId },
@@ -26,13 +35,42 @@ export class TeacherService {
}), }),
]); ]);
return { let user: unknown = null;
user: iamRes.status === "fulfilled" ? await iamRes.value.json() : null, let classes: unknown = null;
classes:
classesRes.status === "fulfilled" if (iamRes.status === "fulfilled") {
? await classesRes.value.json() if (iamRes.value.ok) {
: null, user = await iamRes.value.json();
}; } else {
logger.warn(
{ status: iamRes.value.status, url: iamRes.value.url },
"Downstream IAM service call failed",
);
}
} else {
logger.warn(
{ err: iamRes.reason, service: "iam" },
"Downstream IAM service call rejected",
);
}
if (classesRes.status === "fulfilled") {
if (classesRes.value.ok) {
classes = await classesRes.value.json();
} else {
logger.warn(
{ status: classesRes.value.status, url: classesRes.value.url },
"Downstream classes service call failed",
);
}
} else {
logger.warn(
{ err: classesRes.reason, service: "classes" },
"Downstream classes service call rejected",
);
}
return { user, classes };
} }
// 聚合 IAM 视口配置L1 导航) // 聚合 IAM 视口配置L1 导航)
@@ -41,51 +79,80 @@ export class TeacherService {
headers: { "x-user-id": userId }, headers: { "x-user-id": userId },
}); });
if (!res.ok) { if (!res.ok) {
return []; logger.warn(
{ status: res.status, url: res.url },
"Downstream IAM service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "iam",
endpoint: "viewports",
status: res.status,
});
} }
const json = (await res.json()) as { const json = (await res.json()) as DownstreamEnvelope<ViewportItem[]>;
success: boolean;
data?: ViewportItem[];
};
return json.data ?? []; return json.data ?? [];
} }
// 聚合班级下的考试列表core-edu // 聚合班级下的考试列表core-edu
async listExamsByClass(classId: string): Promise<unknown> { async listExamsByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch( const res = await fetch(
`${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`, `${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": "bff" } }, { headers: { "x-user-id": userId } },
); );
if (!res.ok) { if (!res.ok) {
return []; logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "exams-by-class",
status: res.status,
});
} }
const json = (await res.json()) as { success: boolean; data?: unknown }; const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? []; return json.data ?? [];
} }
// 聚合班级下的作业列表core-edu // 聚合班级下的作业列表core-edu
async listHomeworkByClass(classId: string): Promise<unknown> { async listHomeworkByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch( const res = await fetch(
`${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`, `${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": "bff" } }, { headers: { "x-user-id": userId } },
); );
if (!res.ok) { if (!res.ok) {
return []; logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "homework-by-class",
status: res.status,
});
} }
const json = (await res.json()) as { success: boolean; data?: unknown }; const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? []; return json.data ?? [];
} }
// 聚合考试下的成绩列表core-edu // 聚合考试下的成绩列表core-edu
async listGradesByExam(examId: string): Promise<unknown> { async listGradesByExam(userId: string, examId: string): Promise<unknown> {
const res = await fetch( const res = await fetch(
`${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`, `${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`,
{ headers: { "x-user-id": "bff" } }, { headers: { "x-user-id": userId } },
); );
if (!res.ok) { if (!res.ok) {
return []; logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "grades-by-exam",
status: res.status,
});
} }
const json = (await res.json()) as { success: boolean; data?: unknown }; const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? []; return json.data ?? [];
} }
} }