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.
242 lines
23 KiB
Markdown
242 lines
23 KiB
Markdown
# 模块理解确认书 — 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
|