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:
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
|
||||
Reference in New Issue
Block a user