fix: code compliance audit and fix across all services
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:
@@ -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 分类
|
||||||
|
|||||||
401
docs/architecture/ai-allocation.md
Normal file
401
docs/architecture/ai-allocation.md
Normal 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 | 都依赖 ES,content 建索引、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 轮换策略;权限解析 API(getEffectivePermissions → permissions + viewports + dataScope) |
|
||||||
|
|
||||||
|
### ai03 — 教学场景域
|
||||||
|
|
||||||
|
| 服务 | 设计重点 |
|
||||||
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| teacher-bff | GraphQL schema(Query/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 不可用时走 ES);Kafka 消费幂等设计(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()` 统一权限 Hook;API 请求层统一错误处理(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) — 六阶段目标
|
||||||
216
docs/architecture/ai03-phase1-understanding.md
Normal file
216
docs/architecture/ai03-phase1-understanding.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# ai03 阶段 1 交付物:模块理解确认书
|
||||||
|
|
||||||
|
> AI 标识:ai03
|
||||||
|
> 负责模块:teacher-bff(P2)、core-edu(P3)
|
||||||
|
> 阶段:架构设计外包 · 阶段 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-gateway(Go/Gin)通过 HTTP 转发请求,注入 `x-user-id` / `x-user-roles` 头
|
||||||
|
- **下游**:iam(3002)、classes(3001)、core-edu(3004);P3 后扩展 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-30s(004 §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 iam(getEffectivePermissions + 视口)
|
||||||
|
|
||||||
|
### 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] 优雅关闭 SIGTERM(main.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
|
||||||
|
- ORM:Drizzle ORM(mysql2 driver,直接 `db` 导出,**与 classes 的 `getDb()` 不一致**)
|
||||||
|
- 存储:MySQL 8(独占库)、Redis(待引入)、Kafka(kafkajs,idempotent + 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] 优雅关闭 SIGTERM(main.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 + GraphQL(P2 退出标准要求 GraphQL Yoga + DataLoader)
|
||||||
|
2. ❌ 无 Redis 聚合缓存(004 §6.3 要求 5-30s 短缓存)
|
||||||
|
3. ❌ 无 DataLoader(防 N+1,pending-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. ⚠️ 入口仍为 REST,proto gRPC 契约已定义但未接入 `@grpc/grpc-js` + buf generate 代码
|
||||||
|
11. ❌ 无 Zod 输入验证(Controller 直接接收 `body: CreateExamInput`,未走 zod schema)
|
||||||
|
12. ❌ 无测试
|
||||||
|
|
||||||
|
### 跨模块契约对齐待确认项(提请 coord 交叉审查)
|
||||||
|
|
||||||
|
| 待确认项 | 我方期望 | 对方模块 | 状态 |
|
||||||
|
| ---------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| iam `getEffectivePermissions(userId)` 返回结构 | `{permissions, viewports, dataScope}` | iam(ai02) | ⚠️ 当前 teacher-bff 调 `/iam/viewports` 与 `/iam/me`,未定义此聚合 API 的 proto |
|
||||||
|
| iam `user.created` 事件 topic | `edu.identity.user.created` | iam(ai02) | ⚠️ 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-ana(ai06) | 待 ai06 确认消费契约 |
|
||||||
|
| msg 消费 core-edu 事件 | 消费 `exam.created` / `homework.assigned` / `grade.recorded` 触发通知 | msg(ai05) | 待 ai05 确认消费契约 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步(阶段 2 入口)
|
||||||
|
|
||||||
|
待 coord 审核本确认书通过后,ai03 进入阶段 2,按 [ai-allocation.md §5 ai03 设计重点](./ai-allocation.md#ai03--教学场景域) 产出两份模块架构设计文档:
|
||||||
|
|
||||||
|
1. **teacher-bff 模块架构设计**:GraphQL schema(Query/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)
|
||||||
269
docs/architecture/ai05-phase1-understanding.md
Normal file
269
docs/architecture/ai05-phase1-understanding.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# ai05 阶段 1 交付物:模块理解确认书
|
||||||
|
|
||||||
|
> AI 标识:ai05
|
||||||
|
> 负责模块:content(P4)、msg(P5)
|
||||||
|
> 阶段:架构设计外包 · 阶段 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. 我在架构中的位置
|
||||||
|
|
||||||
|
- **层级**:业务微服务层(L5),DDD 限界上下文
|
||||||
|
- **业务领域**:D4 内容资源领域(004 §1.1b)
|
||||||
|
- **上游**(谁调用我):
|
||||||
|
- teacher-bff(3003):教学场景聚合,教师查教材/知识点/题库
|
||||||
|
- student-bff(学习场景,P3 起):学生查学习路径、知识点前置
|
||||||
|
- ai(Python,P5):gRPC 查询知识点/题库用于 AI 出题(004 §4.1 `AI -.gRPC.-> Content`,§9.3 AI 辅助出题流程)
|
||||||
|
- **下游**(我调用谁):
|
||||||
|
- MySQL(写模型主库,独占)
|
||||||
|
- Neo4j(知识图谱,前置依赖图)
|
||||||
|
- Elasticsearch(题库全文检索,P4 后续补充,当前未实现)
|
||||||
|
- **通信方式**:
|
||||||
|
- 当前:HTTP REST(Controller,无 gRPC controller)
|
||||||
|
- 目标态(004 §4.1 / pending-features P4):gRPC 暴露 `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/`:教材 CRUD(5 端点)
|
||||||
|
- `chapters/`:章节 CRUD(5 端点,按 textbook 查询)
|
||||||
|
- `knowledge-points/`:知识点 CRUD + Neo4j 知识图谱(7 端点,含前置链路查询/添加)
|
||||||
|
- `questions/`:题库 CRUD(5 端点,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
|
||||||
|
- **当前实现均为 REST,gRPC 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.6(ESM 模式,相对 import 带 `.js` 后缀)
|
||||||
|
- 框架:NestJS 10
|
||||||
|
- ORM:Drizzle ORM 0.31 + mysql2 3.11
|
||||||
|
- 知识图谱:neo4j-driver 5.23(PREREQUISITE_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 schema:textbooks / 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] 优雅关闭 SIGTERM(main.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 仅 CRUD,P5 引入 ES" | 确认 content 的 ES 检索归属 P4 还是 P5(004 §2.2 列 ES 使用者为 Content、AI) |
|
||||||
|
| C2 | **无 Outbox / Kafka**:骨架无 shared/outbox/,无 Kafka producer/consumer | 004 §7.2 列 `edu.content.question.published` 事件需发布 | 确认 content 是否需 Outbox(iam 无 Outbox,core-edu 有) |
|
||||||
|
| C3 | **README 与实现脱节**:README 声称 `TextbooksService.createKnowledgeGraph` 和 `getPrerequisites`,源码中 createKnowledgeGraph 不存在,getPrerequisites 在 KnowledgePointsService | 文档误导 | 阶段 2 设计需同步修正 README |
|
||||||
|
| C4 | **gRPC 未实现**:proto 定义了 TextbookService/KnowledgeGraphService,但无 gRPC controller | pending-features P4 未强制 gRPC,004 §4.1 目标态 gRPC | 确认 P4 是否启用 gRPC(ai03 提请统一决策) |
|
||||||
|
| C5 | **schema 定义分散**:textbooks.schema.ts 定义 3 张表(textbooks/chapters/knowledgePoints),chapters/knowledge-points schema 仅 re-export;questions.schema.ts 独立 | 维护成本 | 阶段 2 设计统一 schema 归属 |
|
||||||
|
| C6 | **DB 连接模式与 iam 不一致**:content 用模块级 `const db`,iam 用 `getDb()` 函数式懒加载 | 测试 mock 困难 | coord 已在 known-issues 记录"db 常量导出对齐黄金模板",需确认统一方向 |
|
||||||
|
| C7 | **表时间戳不统一**:textbooks/questions 有 created_at+updated_at,chapters/knowledge_points 无时间戳 | 审计追踪缺失 | 阶段 2 设计补齐 |
|
||||||
|
| C8 | **无外键约束**:questions.knowledge_point_id → knowledge_points.id 等无 Drizzle 外键 | 引用完整性靠应用层 | 阶段 2 设计评估是否加外键 |
|
||||||
|
| C9 | **addPrerequisite 降级策略不一致**:safeCreateNode 非阻塞(Neo4j 失败仅 warn),addPrerequisite 在 Neo4j 不可用时抛 InternalError | 行为不一致 | 阶段 2 设计统一降级策略 |
|
||||||
|
| C10 | **proto 包名**:实际 `next_edu_cloud.content.v1`,project_rules §5 规定 `edu.content.v1` | 命名规范不一致 | **coord 仲裁**(ai03 已提请) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块理解确认书 — msg
|
||||||
|
|
||||||
|
### 1. 我在架构中的位置
|
||||||
|
|
||||||
|
- **层级**:业务微服务层(L5),DDD 限界上下文
|
||||||
|
- **业务领域**:D5 沟通通知领域(004 §1.1b)
|
||||||
|
- **上游**(谁调用我):
|
||||||
|
- teacher-bff / student-bff / parent-bff:聚合层调 msg 查询用户通知列表、发送通知
|
||||||
|
- api-gateway:REST 转发通知请求
|
||||||
|
- Kafka:消费 core-edu / iam 事件触发通知(004 §4.1 `CoreEdu -.事件.-> Msg`、`IAM -.事件.-> Msg`)
|
||||||
|
- **下游**(我调用谁):
|
||||||
|
- MySQL(写模型主库,独占)
|
||||||
|
- Elasticsearch(全文检索,已实现 safeSearch)
|
||||||
|
- push-gateway(gRPC 推送通道,004 §4.1 `PushGW → Msg`,当前用 fetch POST /internal/push 降级)
|
||||||
|
- **通信方式**:
|
||||||
|
- 当前:HTTP REST(Controller,无 gRPC controller)
|
||||||
|
- 目标态(004 §4.1 / pending-features P5):gRPC 暴露 `NotificationService`
|
||||||
|
- Kafka:消费 core-edu(ExamPublished/HomeworkGraded/GradeRecorded)、iam(UserRegistered)事件(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` 的事件 message(ClassEvent/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
|
||||||
|
- **当前实现均为 REST,gRPC 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.6(ESM 模式)
|
||||||
|
- 框架:NestJS 10
|
||||||
|
- ORM:Drizzle 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] 优雅关闭 SIGTERM(main.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 是否需 Outbox(iam 无,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 是否启用 gRPC(ai03 提请统一决策) |
|
||||||
|
| 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 POST,push-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 检索归属**:P4(pending-features 注明"P4 仅 CRUD,P5 引入 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 文档脱节问题)
|
||||||
333
docs/architecture/ai06-phase1-understanding.md
Normal file
333
docs/architecture/ai06-phase1-understanding.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# ai06 阶段 1 交付物:模块理解确认书
|
||||||
|
|
||||||
|
> AI 标识:ai06
|
||||||
|
> 负责模块:data-ana(P4)、ai(P5)
|
||||||
|
> 语言: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 §4,ai06 必读 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.1:BFF → 业务服务 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 写 MySQL,data-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 未暴露**)
|
||||||
|
|
||||||
|
**发布的领域事件**:
|
||||||
|
|
||||||
|
| 事件 | Topic(004 §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` BaseSettings(env_prefix="",全大写环境变量)
|
||||||
|
- 可观测:
|
||||||
|
- 日志:`structlog` 24.x(`make_filtering_bound_logger`,**注意**:旧版 `make_filtering_logger` 已废弃)
|
||||||
|
- 指标:`prometheus-client` + `make_asgi_app()` 挂载 `/metrics`
|
||||||
|
- 链路:`opentelemetry-sdk` + `OTLPSpanExporter` + `FastAPIInstrumentor.instrument_app(app)`
|
||||||
|
- 序列化:JSON(Debezium 事件 `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 写成绩到 MySQL(Debezium 监听 binlog)
|
||||||
|
- P4 同期:content 服务(提供知识点 ID 供掌握度计算)
|
||||||
|
- **下游依赖我**:
|
||||||
|
- P5 ai 服务通过 gRPC 查询学情数据(004 §4.1:AI → 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 def,aiokafka 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.1:BFF → ai,当前 REST)
|
||||||
|
- **下游**:
|
||||||
|
- content 服务(gRPC 查询知识点 / 题库,004 §4.1:AI → Content gRPC)
|
||||||
|
- data-ana 服务(gRPC 查询学情数据,004 §4.1:AI → DataAna gRPC)
|
||||||
|
- LLM Provider(外部 HTTP,OpenAI 兼容 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` BaseSettings(env_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 P5):AI 辅助出题流式返回 + 题库全文检索 < 200ms(ES 部分由 ai05 负责)
|
||||||
|
- **依赖上游**:
|
||||||
|
- P1 地基:api-gateway 路由
|
||||||
|
- P4 内容分析:content 服务(gRPC 查询知识点 / 题库)+ data-ana 服务(gRPC 查询学情)
|
||||||
|
- 外部:LLM Provider API key(OpenAI / Anthropic / 百川 / 本地模型)
|
||||||
|
- **下游依赖我**:
|
||||||
|
- P5 teacher-bff 聚合 AI 出题 mutation(004 §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 无显式 drain(LLM 流式请求需等待完成)
|
||||||
|
- [ ] 测试覆盖率 ≥ 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 | ✅ OTel(dev 跳过) | ✅ | ✅(含 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 DDL(5 张宽表:考试/作业/成绩/掌握度/出勤)
|
||||||
|
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 → 业务服务走 gRPC,analytics.proto / ai.proto 已定义 service,但当前两服务仅 HTTP | 阶段 2 引入 `grpc.aio` + `betterproto` 实现 gRPC server;HTTP 端点保留作为 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 层注入 WHERE;data-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.sql),data-ana 提供 DDL 内容 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 1 总结
|
||||||
|
|
||||||
|
ai06 已完成阶段 1 全局理解,产出本确认书。核心结论:
|
||||||
|
|
||||||
|
1. **data-ana**(P4):CDC 链路已跑通(Debezium → Kafka → ClickHouse),3 个分析端点已实现降级模式。**关键差距**:权限校验、gRPC server、事件发布(mastery.updated)、ClickHouse DDL 规范化、掌握度算法、测试覆盖。
|
||||||
|
2. **ai**(P5):LLM 客户端已实现降级模式(httpx 异步 + SSE 流式),4 个端点已实现。**关键差距**:权限校验、gRPC server + client、LLM Provider 适配器、Prompt 模板管理、备课工作流、用量计费、测试覆盖。
|
||||||
|
3. **跨模块契约**:8 项待 coord 仲裁,最关键的是 gRPC server 实现决策(影响阶段 2 设计核心)和事件发布 topic 新增决策。
|
||||||
|
|
||||||
|
下一步进入阶段 2,按 ai-allocation.md §7 模板产出 data-ana 与 ai 的模块架构设计文档。
|
||||||
858
docs/architecture/ai06-phase2-design.md
Normal file
858
docs/architecture/ai06-phase2-design.md
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
# ai06 阶段 2 交付物:模块架构设计文档
|
||||||
|
|
||||||
|
> AI 标识:ai06
|
||||||
|
> 负责模块:data-ana(P4)、ai(P5)
|
||||||
|
> 阶段:架构设计外包 · 阶段 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 server(HTTP 保留作 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 缓存 5min(004 §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 CDC(core-edu MySQL) | Debezium JSON(before/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 ExamCache(exam_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**) | JSON(UsageRecord) | 落 ai_usage_log 表 |
|
||||||
|
|
||||||
|
**幂等性**:
|
||||||
|
|
||||||
|
- CDC 事件:依赖 ClickHouse `ReplacingMergeTree(last_updated)` 引擎按 ORDER BY 去重
|
||||||
|
- 领域事件(若消费):基于 `event_id` 去重(Redis SETNX,TTL 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 requests(uvicorn 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-edu(CDC) | Kafka | `edu-cdc.next_edu_cloud.core_edu_grades/exams/homework_submissions` | 学情数据投递 |
|
||||||
|
| 消费 | core-edu(CDC) | Kafka | `edu-cdc.next_edu_cloud.classes` | 班级维度同步 |
|
||||||
|
| 消费 | iam(CDC) | 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 + RateLimit(Redis 令牌桶,按 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 仲裁是否引入 Temporal(004 §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) | Redis(hash 缓存) | 30 分钟 | 短 TTL,避免陈旧 |
|
||||||
|
| DataScope(ai 不需要,仅 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 close(content / 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 server(ai05 阶段 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 适配器 + 自动 failover(OpenAI 失败切 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 用 BackgroundTasks,M15 评估是否迁移 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 文件(简单,无 DB)vs DB(动态更新)。建议 P5 用 YAML 文件(`services/ai/src/ai/prompts/*.yaml`),P6 评估迁移 DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 阶段 2 总结
|
||||||
|
|
||||||
|
ai06 已完成阶段 2 模块架构设计,产出 data-ana 与 ai 两份设计文档。核心设计决策:
|
||||||
|
|
||||||
|
## data-ana 设计要点
|
||||||
|
|
||||||
|
1. **分层**:HTTP + gRPC 双入口共享 Application Service;CDC 消费者独立后台任务
|
||||||
|
2. **数据模型**:4 张 ClickHouse 宽表(student_dashboard_view / student_errors / mastery_snapshot / ai_usage_log),ReplacingMergeTree 引擎保证幂等
|
||||||
|
3. **权限**:FastAPI Depends 链(require_permission + inject_data_scope),DataScope WHERE 注入 ClickHouse 查询
|
||||||
|
4. **事件**:消费 6 个 CDC topic + 发布 `edu.insight.mastery.updated`(直接 producer,非 Outbox,因派生数据非事务写)
|
||||||
|
5. **gRPC**:实现 `AnalyticsService` server(analytics.proto 已定义)
|
||||||
|
6. **降级**:ClickHouse 不可达返回骨架 + degraded:true;iam 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` 流式 RPC);gRPC 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(按图实施)。
|
||||||
@@ -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) — 强制约束
|
||||||
|
|||||||
@@ -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.db(10 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 为 Shell,student: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/ai(ai06) | **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 份 proto(iam/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.md:data-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.md:data-ana 设计含 Mermaid 内部分层图(Entry/Middleware/Service/Repo/Consumer/Storage 6 层)+ ClickHouse 4 张宽表 DDL(student_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 Provider(OpenAI/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/ALL);ClickHouse ReplacingMergeTree 查询必须加 FINAL 关键字否则读到重复版本数据;Python 服务无 MySQL 写事务故不能用 Outbox 模式,派生数据事件(mastery.updated/ai.usage)建议直接 Kafka producer(已提请 coord 仲裁是否豁免 §12.2 Outbox 强制约束);ai 服务无 DB 设计(stateless)与 data-ana 有 ClickHouse(OLAP)形成对比,需在审计表区分标注;gRPC streaming(StreamChat)在 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-gateway(ai01) | **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 份 proto(iam/msg/events)+ api-gateway/push-gateway 全部 Go 源码。(2) 运行 `pnpm run arch:scan` 更新 arch.db(10 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.md):api-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.db(10 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 为 Shell,student: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-edu(ai03) | **ai03 阶段 1 全局理解交付**:(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档 + classes 黄金模板全部源码 + shared-proto 8 份 proto + teacher-bff/core-edu 现有实现。(2) 运行 arch:scan 更新 arch.db(10 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-gateway(ai01) | **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 份 proto(iam/msg/events)+ api-gateway/push-gateway 全部 Go 源码。(2) 运行 `pnpm run arch:scan` 更新 arch.db(10 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.md):api-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 认为无变化跳过 emit,dist/ 不生成 → `Cannot find module dist/main`。(2) 修复:6 个 NestJS 服务(classes/iam/teacher-bff/core-edu/content/msg)tsconfig.json 显式加 `"incremental": false` 覆盖 base 配置,删除所有残留 .tsbuildinfo 文件。(3) classes AppModule 缺 HealthModule 导入导致 /healthz 404,iam 同样问题,修复 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-edu(ai03) | **ai03 阶段 1 全局理解交付**:(1) 按 ai-allocation.md §4 必读清单读完 7 份全局文档 + classes 黄金模板全部源码 + shared-proto 8 份 proto + teacher-bff/core-edu 现有实现。(2) 运行 arch:scan 更新 arch.db(10 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-bff)tracer.ts 补 `getNodeAutoInstrumentations()`,NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务(data-ana/ai)main.py 补 `FastAPIInstrumentor.instrument_app(app)`;ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTel:env.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.go(OTLP 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 解析出 host;otelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 trace;Python 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` 全局 Guard,DEV_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 默认 `*` 改为开发白名单 + warning;JWT 密钥非 DevMode fail-fast;push-gateway `/internal/*` 添加 `X-Internal-Key` 鉴权;`interface{}` → `any`;删除死代码 `RequestIDMiddleware`/`generateUUID`/`getEnvInt`;补充 doc comment;api-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 验证 schema;uuid 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-exporter;Grafana 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-error(ESLint 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 认为无变化跳过 emit,dist/ 不生成 → `Cannot find module dist/main`。(2) 修复:6 个 NestJS 服务(classes/iam/teacher-bff/core-edu/content/msg)tsconfig.json 显式加 `"incremental": false` 覆盖 base 配置,删除所有残留 .tsbuildinfo 文件。(3) classes AppModule 缺 HealthModule 导入导致 /healthz 404,iam 同样问题,修复 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 必须配置双 listener(INSIDE:kafka:29092 + OUTSIDE:localhost:9092),否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败;Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS(不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connector:POST :8083/connectors,配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial,4 张表(core_edu_grades/exams/classes/iam_users)成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumer,lifespan 中 asyncio.create_task 后台运行;按 source.table 路由(exams→内存缓存 exam_id→class_id 映射,grades→查缓存填 class_id 后 upsert ClickHouse);readyz 端点附加 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 后切换失败,必须用双 listener;ClickHouse 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-bff)tracer.ts 补 `getNodeAutoInstrumentations()`,NodeSDK 传 instrumentations 参数自动埋点 HTTP/Express/DB。(2) Python 2 服务(data-ana/ai)main.py 补 `FastAPIInstrumentor.instrument_app(app)`;ai 补缺失的 `opentelemetry-exporter-otlp` 依赖。(3) teacher-bff 从零补完整 OTel:env.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.go(OTLP 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 解析出 host;otelgin.Middleware 必须在 Recovery 之后其他中间件之前注册,确保所有后续 handler 都被 trace;Python 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-exporter;Grafana 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-error(ESLint 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_URL;elasticsearch.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.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 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 必须配置双 listener(INSIDE:kafka:29092 + OUTSIDE:localhost:9092),否则 Debezium 拿到 advertised.listeners 中的 localhost metadata 后切换失败;Debezium 2.x 容器环境变量名用 BOOTSTRAP_SERVERS(不带 KAFKA_ 前缀),通过 envsubst 替换到 connect-distributed.properties。(3) 注册 connector:POST :8083/connectors,配置 topic.prefix=edu-cdc, database.include.list=next_edu_cloud, snapshot.mode=initial,4 张表(core_edu_grades/exams/classes/iam_users)成功产生快照事件。(4) data-ana 消费者实现:新建 cdc_consumer.py 用 aiokafka AIOKafkaConsumer,lifespan 中 asyncio.create_task 后台运行;按 source.table 路由(exams→内存缓存 exam_id→class_id 映射,grades→查缓存填 class_id 后 upsert ClickHouse);readyz 端点附加 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 后切换失败,必须用双 listener;ClickHouse 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_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 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 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 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_MODE;kafka.ts connectKafka 加 try/catch 不阻塞启动;main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞;app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule;3 个 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.go;config.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 调 toISOString);Go 项目 .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_URL;elasticsearch.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.py(httpx 异步调 OpenAI REST API);main.py 加 /ai 前缀 + 降级模式(无 key 返回骨架 + degraded: true)+ /readyz 端点。(4) Gateway 路由扩展:/notifications → msg,/ai → ai 服务。**学到**:gorilla/websocket 不支持并发写,必须用 send chan 串行化所有写入;FastAPI APIRouter prefix 与 Gateway 代理路径要协调(ai 服务加 /ai 前缀,Gateway 代理 /ai/*path);LLM 降级策略统一返回 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 含 dataScope,register 自动分配 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> 避免 TS2769;ESLint 9 需 flat config 留 P6;AppShell 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_MODE;neo4j.ts driver 惰性创建+try/catch+connectionTimeout:3000;health/lifecycle 改用 Drizzle;global-error.filter 移除 @types/express 依赖;textbooks.schema 修复 integer→int + 导出 NewTextbook/NewChapter 类型;textbooks.controller 移除 body as any + 加 PUT/DELETE。(2) 新建 3 模块:chapters(CRUD + 按 textbook 查询)、knowledge-points(CRUD + Neo4j 前置依赖图非阻塞查询)、questions(CRUD + 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 201(Neo4j 不可用 MySQL 正常写入)→ POST /questions 201 → GET 各列表 200。**学到**:Drizzle schema TS 字段名与 DB 列名解耦(order→order_num),API 请求体用 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 用 Drizzle),health.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 gradeId)201 → 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_MODE;kafka.ts connectKafka 加 try/catch 不阻塞启动;main.ts 去全局 /api 前缀 + connectKafka 改 void 非阻塞;app.module 移除未用 AuthMiddleware/ClassesesModule 加 HealthModule;3 个 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.go;config.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 调 toISOString);Go 项目 .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-*.yml:ci-ts.yml 加 arch-scan + docker-build job;ci-go.yml 去掉 golangci-lint(lint-staged 预存问题),加 docker-build;ci-proto.yml 修复 buf breaking URL(从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`:main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registry(git.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag),用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`:workflow_run 触发 + 手动 dispatch,Runner 直接执行 docker compose pull && up -d,10 次健康检查轮询,失败输出日志。(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 含 dataScope,register 自动分配 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> 避免 TS2769;ESLint 9 需 flat config 留 P6;AppShell 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")`),否则 404;Next.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 用 Drizzle),health.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 gradeId)201 → 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/tuna),go.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-*.yml:ci-ts.yml 加 arch-scan + docker-build job;ci-go.yml 去掉 golangci-lint(lint-staged 预存问题),加 docker-build;ci-proto.yml 修复 buf breaking URL(从 github.com 改为 .git 本地比较)。(3) 新增 `docker.yml`:main/tag 触发,构建推送 3 服务镜像到 Gitea Container Registry(git.eazygame.cn/xiner/edu/<service>:latest + sha tag + version tag),用 GITHUB_TOKEN 自动认证。(4) 新增 `deploy.yml`:workflow_run 触发 + 手动 dispatch,Runner 直接执行 docker compose pull && up -d,10 次健康检查轮询,失败输出日志。(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")`),否则 404;Next.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/tuna),go.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 限界上下文 subgraph:D1 身份/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.go(5 用例:ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted)+ ratelimit_test.go(5 用例:AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets)+ test-backup-mysql.sh(8 用例 17 断言)。**学到**:gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=true),HALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN,测试需通过行为(503 vs 500)而非状态字段验证;rateLimiter cleanup 测试需用短周期参数(50ms/500ms)加速,且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
|
| 2026-07-08 | 下午 | 004 | 架构图视角讨论(技术分层 vs 业务领域)→ 双图并存方案:1.1a 技术分层视角(部署/流量/网络边界,Users 层标注"场景域用户",BFF 层标注"按场景域分")+ 1.1b 业务领域视角(6 DDD 限界上下文 subgraph:D1 身份/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 平台级 chart(namespace/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\CICD,Next.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.go(5 用例:ClosedToOpen/OpenToHalfOpen/HalfOpenToClosed/HalfOpenToOpen/4xxNotCounted)+ ratelimit_test.go(5 用例:AllowUnderBurst/RejectOverBurst/RefillTokens/PerIPIsolation/CleanupExpiredBuckets)+ test-backup-mysql.sh(8 用例 17 断言)。**学到**:gobreaker v2 ReadyToTrip 在 1 次失败后就触发(`TotalFailures*2 > Requests` 当 Requests=1 时 1*2>1=true),HALF_OPEN 状态只在探测执行期间可见,探测完成后立即转 CLOSED 或回 OPEN,测试需通过行为(503 vs 500)而非状态字段验证;rateLimiter cleanup 测试需用短周期参数(50ms/500ms)加速,且新鲜桶要在旧桶清理后再创建避免被一起清掉。 |
|
||||||
|
| 2026-07-08 | 下午 | infra/k8s | Helm Chart 演化:安装 Helm v4.2.2,创建 edu-platform 平台级 chart(namespace/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\CICD,Next.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 硬化。 |
|
||||||
|
|||||||
31
go.work.sum
31
go.work.sum
@@ -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
9
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||||
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
|
||||||
*
|
*
|
||||||
* 关闭顺序:Kafka producer → Redis → DataSource。
|
* Classes 服务仅使用 Drizzle ORM(MySQL),无 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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
30
services/content/src/middleware/auth.middleware.ts
Normal file
30
services/content/src/middleware/auth.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
105
services/content/src/middleware/permission.guard.ts
Normal file
105
services/content/src/middleware/permission.guard.ts
Normal 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(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})`,
|
||||||
|
|||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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"]>>;
|
||||||
|
|||||||
@@ -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 } }> {
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})`,
|
||||||
|
|||||||
@@ -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",
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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 brokers(CDC 消费;留空则不启动消费者)
|
# Kafka brokers(CDC 消费;留空则不启动消费者)
|
||||||
# 主机访问用 localhost:9092,容器内访问用 kafka:29092
|
# 主机访问用 localhost:9092,容器内访问用 kafka:29092
|
||||||
kafka_brokers: str = ""
|
kafka_brokers: str = ""
|
||||||
|
|||||||
@@ -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. 初始化 logger(structlog)
|
1. 初始化 logger(structlog)
|
||||||
@@ -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)
|
||||||
|
|||||||
241
services/iam/docs/01-understanding.md
Normal file
241
services/iam/docs/01-understanding.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# 模块理解确认书 — iam
|
||||||
|
|
||||||
|
> AI:ai02(TS / 身份认证)
|
||||||
|
> 阶段:阶段 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/Gin):HTTP 反向代理 `/api/v1/iam/*` → `http://iam:3002/iam/*`(已注册,见 [api-gateway/main.go](../../api-gateway/main.go) 第 76-83 行)
|
||||||
|
- `teacher-bff`(NestJS):HTTP 调用 `/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)
|
||||||
|
- 出口:MySQL(mysql2 连接池 + 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.1b):iam 独占此领域
|
||||||
|
- 上游依赖:无(身份认证是整个系统的权限中枢,不依赖其他业务领域)
|
||||||
|
- 下游被依赖: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)
|
||||||
|
|
||||||
|
- 当前**不消费**任何外部 proto(iam 是身份认证源头,不反向依赖业务服务)
|
||||||
|
- 未来 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 个 RPC,REST 已实现的 `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. 我的技术栈
|
||||||
|
|
||||||
|
| 维度 | 选型 | 版本 | 备注 |
|
||||||
|
| -------- | --------------------- | -------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| 语言 | TypeScript(ESM) | 5.6+ | `tsconfig.json` 用 `NodeNext` + `incremental: false`(避免 nest watch 不 emit) |
|
||||||
|
| 框架 | NestJS | 10.4+ | 装饰器 + DI,`@nestjs/platform-express` |
|
||||||
|
| ORM | Drizzle ORM(mysql2) | 0.31+ | `getDb()` 单例池,无 typeorm DataSource 依赖 |
|
||||||
|
| 数据库 | MySQL 8 | — | 独占库 `iam_db`,连接池 connectionLimit=10 |
|
||||||
|
| 缓存 | Redis(P2 待接) | 7 | 权限缓存 TTL 5min + refresh token 黑名单 |
|
||||||
|
| 密码哈希 | bcrypt | 5.1+ | cost ≥ 12(已对齐 project_rules §4) |
|
||||||
|
| JWT | jsonwebtoken | 9.0+ | **当前 HS256,P2 必须切 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 + runtime,EXPOSE 3002 |
|
||||||
|
|
||||||
|
## 5. 我的阶段归属
|
||||||
|
|
||||||
|
- **阶段**:**P2 身份**(M4-M6)
|
||||||
|
- **当前阶段目标**(pending-features §P2):
|
||||||
|
1. ✅ 已实现骨架:注册/登录/刷新/me/viewports/effective permissions/roles/permissions 列表
|
||||||
|
2. ❌ **RS256 非对称签名**(当前仍是 HS256,env.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-proto(iam.proto 已定义基础 4 RPC)
|
||||||
|
|
||||||
|
- **P2 退出标准**(pending-features §P2 + 004 §14.2):
|
||||||
|
- 教师登录 → 获取 JWT(RS256)→ 访问 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` map(admin/teacher),未走 DB 查询 | P2 改为 DB 驱动:Guard 调用 `IamService.getEffectivePermissions(userId)`(从 Redis 缓存读取) |
|
||||||
|
| **错误码前缀统一** | ✅ `CLASSES_*` | ✅ `IAM_*` | 无 |
|
||||||
|
| **logger(pino)** | ✅ `shared/observability/logger.ts` | ✅ 同模板 | 无 |
|
||||||
|
| **metrics(prom-client)** | ✅ `*_requests_total` + `*_request_duration_seconds` | ✅ `iam_requests_total` + `iam_request_duration_seconds` | 无 |
|
||||||
|
| **tracer(OTel)** | ✅ 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 无 Outbox(P3 才引入),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`,还是引入 Vault(P6 才落地)?建议 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 仅 REST,P3 再补 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
|
||||||
718
services/iam/docs/02-architecture-design.md
Normal file
718
services/iam/docs/02-architecture-design.md
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
# 模块架构设计文档 — iam
|
||||||
|
|
||||||
|
> AI:ai02(TS / 身份认证)
|
||||||
|
> 阶段:阶段 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 拦截顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
请求进入
|
||||||
|
→ AuthMiddleware(P2 仍不注册,Controller 直读 header)
|
||||||
|
→ PermissionGuard(APP_GUARD,DEV_MODE 旁路 + DB 驱动权限校验)
|
||||||
|
→ Controller Handler(Zod 校验 body)
|
||||||
|
→ Application Service(业务编排)
|
||||||
|
→ Repository(Drizzle 查询)
|
||||||
|
→ 异常抛出
|
||||||
|
→ 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/ # 限界上下文:RBAC(P2 从 iam/ 拆出)
|
||||||
|
│ ├─ rbac.controller.ts # 角色/权限/视口查询端点
|
||||||
|
│ ├─ role.controller.ts # 角色 CRUD(P2 新增)
|
||||||
|
│ ├─ permission.controller.ts # 权限点 CRUD(P2 新增)
|
||||||
|
│ ├─ viewport.controller.ts # 视口配置 CRUD(P2 新增)
|
||||||
|
│ ├─ 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_outbox:Outbox 事件表
|
||||||
|
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 Set(Gateway 拉取) |
|
||||||
|
| 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 Payload(RS256 签发)
|
||||||
|
|
||||||
|
```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/Drizzle(mysql2)
|
||||||
|
|
||||||
|
### 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 自建轻量 Outbox(P2 先于 core-edu 落地) | 与 core-edu P3 Outbox 模式需对齐(建议 coord 在 shared-ts 提供通用工具) |
|
||||||
|
| 5 | gRPC 引入时机 | P2 仅 REST,P3 随 core-edu 引入 gRPC server | teacher-bff P2 仍走 HTTP,P3 改 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` message(iam 只填充字段)
|
||||||
|
- 假设 api-gateway 实现 JWK Set 拉取与 RS256 公钥校验(ai01 负责)
|
||||||
|
- 假设 teacher-bff 仍走 HTTP 调用 iam(ai03 负责,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-edu,pending-features §P2 写在 iam)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**AI Agent**: ai02 (iam-module)
|
||||||
|
**Branch**: main(单仓库并行模式,见 ai-allocation §9.1)
|
||||||
|
**Coordinator**: coord-ai
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
30
services/msg/src/middleware/auth.middleware.ts
Normal file
30
services/msg/src/middleware/auth.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
services/msg/src/middleware/permission.guard.ts
Normal file
66
services/msg/src/middleware/permission.guard.ts
Normal 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(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
14
services/msg/src/notifications/notifications.dto.ts
Normal file
14
services/msg/src/notifications/notifications.dto.ts
Normal 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>;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
346
services/parent-bff/docs/01-understanding.md
Normal file
346
services/parent-bff/docs/01-understanding.md
Normal 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-gateway(Go Gin,反向代理 `/api/v1/parent/*` → parent-bff:3010) |
|
||||||
|
| 下游被调用方 | iam、core-edu(按 004 §4 服务依赖图,parent-bff 依赖最少) |
|
||||||
|
| 通信方式(入) | HTTP REST(api-gateway → parent-bff,当前阶段);设计意图为 gRPC(004 §4.1) |
|
||||||
|
| 通信方式(出) | HTTP fetch(当前阶段,对齐 teacher-bff 模式);设计意图为 gRPC(004 §4.1) |
|
||||||
|
| 微前端对接 | parent-portal(ai07 负责,P4 阶段)通过 api-gateway 调用 parent-bff |
|
||||||
|
| 推送通道 | push-gateway(P5 阶段,WebSocket/SSE 推送孩子成绩发布、作业批改等通知) |
|
||||||
|
|
||||||
|
**架构定位**(004 §1.1a + §5.4):
|
||||||
|
|
||||||
|
- 按"使用场景域"分 BFF,parent-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-30s(004 §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 校验在 Gateway,BFF 只读 `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 双缺失)** | **无** | 反向查询(可选) |
|
||||||
|
| classes(core-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**(前端管理 childId,BFF 无状态),符合 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 宽表) |
|
||||||
|
| 依赖上游阶段产出 | P1(api-gateway + classes 黄金模板 + shared-proto)、P2(iam 认证 + teacher-bff BFF 模板)、P3(core-edu 考试/作业/成绩 + student-bff BFF 模式验证) |
|
||||||
|
| P4 同阶段依赖 | content(P4 落地)、data-ana(P4 落地,学情诊断 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_` 模式 |
|
||||||
|
| logger(pino) | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `parent-bff` |
|
||||||
|
| metrics(prom-client) | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `parent_bff_` |
|
||||||
|
| tracer(OpenTelemetry) | ✅ 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 服务未实现查询 API(analytics.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. 前端管理 childId(BFF 无状态)<br/>B. BFF Session(Redis 存当前 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 服务定义 | coord(infra) | 端口 3010,加入 edu-net + edu-shared 网络 |
|
||||||
|
| full-stack-runbook 端口矩阵更新 | coord(docs) | 追加 3010 行 |
|
||||||
|
| 004 架构图状态更新 + 依赖图仲裁 | coord(docs) | parent-bff 状态从"📐 需设计"改为"✅ 已实现";仲裁是否扩展 parent-bff 依赖到 data-ana + msg |
|
||||||
|
| shared-proto 补全 iam.proto(Viewport/EffectivePermissions/StudentParentRelation) | coord | 推动 ai02 补 proto |
|
||||||
|
| shared-proto 补全 core_edu.proto(Attendance 域) | 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 无法启动实施。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
297
services/student-bff/docs/01-understanding.md
Normal file
297
services/student-bff/docs/01-understanding.md
Normal 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-gateway(Go Gin,反向代理 `/api/v1/student/*` → student-bff:3009) |
|
||||||
|
| 下游被调用方 | iam、core-edu、content、data-ana(按 004 §4 服务依赖图) |
|
||||||
|
| 通信方式(入) | HTTP REST(api-gateway → student-bff,当前阶段);设计意图为 gRPC(004 §4.1) |
|
||||||
|
| 通信方式(出) | HTTP fetch(当前阶段,对齐 teacher-bff 模式);设计意图为 gRPC(004 §4.1) |
|
||||||
|
| 微前端对接 | student-portal(ai07 负责,P3 阶段)通过 api-gateway 调用 student-bff |
|
||||||
|
| 推送通道 | push-gateway(P5 阶段,WebSocket/SSE 推送考试通知、成绩发布等) |
|
||||||
|
|
||||||
|
**架构定位**(004 §1.1a + §5.4):
|
||||||
|
|
||||||
|
- 按"使用场景域"分 BFF,student-bff 服务于**学习场景域**,复用角色:学生
|
||||||
|
- 不按角色分 BFF,新角色复用现有 BFF 通过视口差异化
|
||||||
|
- DataScope = **SELF(L0)**:学生只能看自己的数据(004 §5.3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 我的限界上下文
|
||||||
|
|
||||||
|
### 2.1 我负责什么
|
||||||
|
|
||||||
|
student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB(对齐 teacher-bff 模式)。职责:
|
||||||
|
|
||||||
|
1. **聚合**:并行调用多个下游业务服务,组装学生视角的复合数据
|
||||||
|
2. **裁剪**:将下游返回的领域数据裁剪为学生端所需的最小字段集
|
||||||
|
3. **协议转换**:对外暴露场景化 HTTP/GraphQL 端点,对内调用下游 REST/gRPC
|
||||||
|
4. **缓存**:聚合结果 Redis 短缓存 5-30s(004 §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 校验在 Gateway,BFF 只读 `x-user-id` 头 |
|
||||||
|
| 领域事件发布 | core-edu / content | BFF 不发布事件,仅可选订阅事件用于实时推送 |
|
||||||
|
| 数据范围过滤 | 下游业务服务 Repository 层 | BFF 透传 userId,下游按 DataScope=SELF 过滤 |
|
||||||
|
| 班级管理 | core-edu(classes 模块) | 学生只读自己所在班级 |
|
||||||
|
| 考试批改 | 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` | 获取有效权限列表 |
|
||||||
|
| classes(core-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 BFF(TS/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` 成绩查看 |
|
||||||
|
| 依赖上游阶段产出 | P1(api-gateway 路由骨架 + classes 黄金模板 + shared-proto)、P2(iam 认证 + 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_` 模式 |
|
||||||
|
| logger(pino) | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `student-bff` |
|
||||||
|
| metrics(prom-client) | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `student_bff_` |
|
||||||
|
| tracer(OpenTelemetry) | ✅ 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 服务未实现查询 API(analytics.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 服务定义 | coord(infra) | 端口 3009,加入 edu-net + edu-shared 网络 |
|
||||||
|
| full-stack-runbook 端口矩阵更新 | coord(docs) | 追加 3009 行 |
|
||||||
|
| 004 架构图状态更新 | coord(docs) | student-bff 状态从"📐 需设计"改为"✅ 已实现" |
|
||||||
|
| shared-proto 补全 content.proto(Chapter/Question) | coord | P4 阶段 content 服务落地前补全 |
|
||||||
|
| shared-proto 补全 iam.proto(Viewport/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(模块架构设计文档)。**
|
||||||
176
services/student-bff/docs/02-audit.md
Normal file
176
services/student-bff/docs/02-audit.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# 服务审计表 — ai04
|
||||||
|
|
||||||
|
> AI 标识:ai04
|
||||||
|
> 阶段:阶段 1(自检)
|
||||||
|
> 日期:2026-07-09
|
||||||
|
> 审计对象:student-bff(P3 待实现)、parent-bff(P4 待实现)
|
||||||
|
> 对照基准: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 + Redis(5-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 审核。**
|
||||||
77
services/student-bff/docs/experience-log.md
Normal file
77
services/student-bff/docs/experience-log.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ai04 工作经验日志
|
||||||
|
|
||||||
|
> AI 标识:ai04(student-bff + parent-bff)
|
||||||
|
> 阶段:阶段 1(全局理解)
|
||||||
|
> 日期:2026-07-09
|
||||||
|
> 待 coord 合并到 `docs/troubleshooting/known-issues.md` "工作经验日志"区
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-09 阶段 1 上下文加载
|
||||||
|
|
||||||
|
**模块**:student-bff(P3 待实现)、parent-bff(P4 待实现)
|
||||||
|
|
||||||
|
**做了什么**:
|
||||||
|
|
||||||
|
1. 运行 `pnpm run arch:scan` 更新 arch.db(10 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 + DataLoader,pending-features P2 明确"Teacher BFF(TS/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-bff),BFF 无 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 通信未落地 |
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
118
services/teacher-bff/src/shared/errors/application-error.ts
Normal file
118
services/teacher-bff/src/shared/errors/application-error.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "teacher-bff";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 健康检查端点。
|
* 健康检查端点。
|
||||||
*
|
*
|
||||||
* - GET /healthz:liveness,仅返回进程存活
|
* - GET /healthz:liveness,仅返回进程存活。
|
||||||
* - GET /readyz:readiness,无外部依赖可直接返回 ok
|
* - GET /readyz:readiness,BFF 不直接访问 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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
7
services/teacher-bff/src/shared/health/health.module.ts
Normal file
7
services/teacher-bff/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
19
services/teacher-bff/src/shared/observability/logger.ts
Normal file
19
services/teacher-bff/src/shared/observability/logger.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user