# 模块架构设计文档 — iam
> 模块:IAM(身份认证与权限)
> 版本:v1.0(最终态,对齐 coord-final-decisions I1-I8 + president §2.15/§2.16/§3.2/§5.5)
> 日期:2026-07-10
> 关联:
>
> - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)
> - [仲裁决策](../../../docs/architecture/coord-final-decisions.md)
> - [总裁裁决](../../../docs/architecture/president-final-rulings.md)
> - [iam 对接契约](../../../docs/architecture/issues/contracts/iam_contract.md)
---
## 1. 模块内部分层图
### 1.1 调用链总览
```mermaid
flowchart TB
subgraph Client["客户端 / Gateway / BFF"]
REST[HTTP 请求
带 x-user-id / x-user-roles / x-user-data-scope 头]
GRPC[gRPC 调用
next_edu_cloud.iam.v1]
end
subgraph NestJS["iam 服务(NestJS Hybrid App)"]
direction TB
MW[AuthMiddleware
已注册:me/logout/viewports/
permissions/roles/audit/children]
Guard[PermissionGuard
APP_GUARD 全局守卫
DB 驱动 + Redis 缓存]
Filter[GlobalErrorFilter
全局异常过滤器]
subgraph Controllers["Controller 层(双入口)"]
IamCtl[IamController
REST /v1/iam/*]
RbacCtl[RbacController
REST /v1/iam/roles, /permissions]
AuditCtl[AuditController
REST /v1/iam/audit]
JwksCtl[JwksController
GET /v1/iam/.well-known/jwks.json]
GrpcCtl[IamGrpcController
12 RPC @ GrpcMethod]
end
subgraph Services["Application Service 层"]
IamSvc[IamService
认证 + RBAC + 审计编排
REST 与 gRPC 共用]
CacheSvc[PermissionCacheService
Redis 权限缓存 TTL 5min]
BlacklistSvc[TokenBlacklistService
Redis jti 黑名单]
JwksSvc[JwksService
PEM→JWK 转换]
end
subgraph Repo["Repository 层"]
IamRepo[IamRepository
Drizzle 查询 10 表]
end
subgraph Outbox["Outbox 模块(shared-ts)"]
OutboxPub[OutboxPublisher
事务内写入]
OutboxRelay[OutboxRelayWorker
后台轮询 + Kafka 投递]
end
subgraph Infra["基础设施"]
Db[(MySQL
iam_db)]
Redis[(Redis
权限缓存 + token 黑名单)]
Kafka[(Kafka
edu.iam.* topics)]
end
end
REST --> MW
MW --> Guard
GRPC --> Guard
Guard --> Controllers
Controllers --> IamSvc
IamSvc --> IamRepo
IamSvc --> CacheSvc
IamSvc --> BlacklistSvc
IamSvc --> OutboxPub
IamRepo --> Db
CacheSvc --> Redis
BlacklistSvc --> Redis
OutboxPub --> OutboxRelay
OutboxRelay --> Kafka
Controllers -.异常.-> Filter
```
### 1.2 中间件 / Guard / Filter 拦截顺序
```
请求进入
→ AuthMiddleware(注册于 me/logout/viewports/permissions/roles/audit/children)
→ PermissionGuard(APP_GUARD,data_scope=all 直放行 + DB 驱动 + Redis 缓存)
→ Controller Handler(Zod 校验 body / @GrpcMethod)
→ Application Service(业务编排:Repository + Cache + Outbox + 审计)
→ Repository(Drizzle 查询)
→ 异常抛出
→ GlobalErrorFilter(统一兜底,注入 traceId)
→ 响应返回
```
### 1.3 目录结构(最终态)
```
services/iam/src/
├─ iam/ # 限界上下文:认证 + RBAC + 审计
│ ├─ iam.controller.ts # REST /v1/iam 入口
│ ├─ iam.grpc.controller.ts # gRPC 12 RPC 入口
│ ├─ rbac.controller.ts # REST /v1/iam/roles, /permissions
│ ├─ audit.controller.ts # REST /v1/iam/audit
│ ├─ jwks.controller.ts # REST /v1/iam/.well-known/jwks.json
│ ├─ jwks.service.ts # PEM→JWK 转换
│ ├─ iam.service.ts # Application Service(12 RPC + Outbox + 审计)
│ ├─ iam.repository.ts # Drizzle 查询(10 表)
│ ├─ iam.schema.ts # 10 张表 schema + DataScope/RoleType 枚举
│ └─ iam.dto.ts # Zod schema
├─ config/
│ ├─ database.ts # Drizzle 池 + getDbInstance + closeDb
│ ├─ redis.ts # Redis 单例
│ ├─ jwt.ts # RS256 密钥加载 + ttlToSeconds
│ ├─ kafka.ts # Kafka producer 单例 + IAM_KAFKA_TOPICS
│ └─ env.ts # Zod 校验环境变量
├─ middleware/
│ ├─ auth.middleware.ts # 解析 x-user-* 头
│ └─ permission.guard.ts # DB 驱动 + Redis 缓存权限校验
├─ shared/
│ ├─ cache/
│ │ ├─ permission-cache.service.ts # Redis 权限缓存
│ │ └─ token-blacklist.service.ts # Redis jti 黑名单
│ ├─ errors/ # ApplicationError 层次 + GlobalErrorFilter
│ ├─ health/ # /healthz + /readyz(5 依赖检查)
│ ├─ lifecycle/ # 优雅停机(Kafka→Redis→DB)
│ └─ observability/ # logger / metrics / tracer
├─ app.module.ts # 根模块(IamModule + HealthModule + OutboxModule + APP_GUARD)
└─ main.ts # Hybrid 启动(gRPC + HTTP)
```
## 2. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class UserEntity {
-id: string
-email: string
-passwordHash: string
-name: string
-status: UserStatus
-dataScope: DataScope
-passwordChangedAt: Date
+verifyPassword(plain) bool
}
class RoleEntity {
-id: string
-name: string
-description: string?
-roleType: RoleType
-level: int
}
class Permission {
+id: string
+name: string
+resource: string
+action: string
}
class RoleViewport {
+id: string
+roleId: string
+viewportKey: string
+label: string
+route: string
+level: ViewportLevel
+sortOrder: int
+requiredPermission: string?
}
class RefreshToken {
+id: string
+userId: string
+tokenHash: string
+jti: string
+expiresAt: Date
+revokedAt: Date?
}
class StudentGuardian {
+id: string
+studentId: string
+guardianId: string
+relation: string
}
class AuditLog {
+id: string
+actorUserId: string?
+action: string
+resourceType: string
+resourceId: string
+beforeState: string?
+afterState: string?
+ipAddress: string?
+userAgent: string?
}
UserEntity "1" --> "many" RefreshToken : 拥有
UserEntity "1" --> "many" StudentGuardian : 作为 guardian
RoleEntity "1" --> "many" Permission : 通过 role_permissions
RoleEntity "1" --> "many" RoleViewport : 配置
UserEntity "1" --> "many" AuditLog : 作为 actor
```
### 2.2 值对象(枚举)
```typescript
enum UserStatus {
ACTIVE = "active",
DISABLED = "disabled",
}
// DataScope 6 级(I8 裁决:SUBJECT 替代 DISTRICT)
enum DataScope {
SELF = "self", // L0:仅自己
SUBJECT = "subject", // L1:科目(教师所教科目+班级)
CLASS = "class", // L2:班级(班主任所带班级)
GRADE = "grade", // L3:年级
SCHOOL = "school", // L4:全校
ALL = "all", // L5:跨校(系统管理员)
}
// 三层角色模型(§2.15)
enum RoleType {
SYSTEM = "system", // 系统预设(admin/teacher/student/parent),level=0
ORGANIZATION = "organization", // 组织分配(年级组长/班主任/学科组长),level=1
TEMPORARY = "temporary", // 临时授权(代课教师),level=2
}
enum ViewportLevel {
ADMIN = "admin",
TEACHER = "teacher",
STUDENT = "student",
PARENT = "parent",
}
```
### 2.3 聚合间通信
- **同服务内**:`IamService` 直接调用 `PermissionCacheService`、`TokenBlacklistService`、`JwksService`、`IamRepository`(NestJS DI)
- **跨服务**:通过 Kafka 事件(Outbox 发布),不直接访问其他服务 DB
## 3. 数据模型
### 3.1 表清单(10 张表)
| 表名 | 用途 | 主键 | 唯一索引 |
| ----------------------- | ---------------------------- | ---- | ------------------------- |
| `iam_users` | 用户主表 | `id` | `email` |
| `iam_roles` | 角色表(role_type + level) | `id` | `name` |
| `iam_user_roles` | 用户-角色绑定 | `id` | `(userId, roleId)` |
| `iam_permissions` | 权限点表 | `id` | `name` |
| `iam_role_permissions` | 角色-权限映射 | `id` | `(roleId, permissionId)` |
| `iam_refresh_tokens` | refresh token(含 jti) | `id` | `jti` |
| `iam_role_viewports` | 角色-视口配置(含 level) | `id` | `(roleId, viewportKey)` |
| `iam_student_guardians` | 学生-家长关系(I6 裁决表名) | `id` | `(studentId, guardianId)` |
| `iam_user_audit_log` | 审计日志(§5.5) | `id` | — |
| `iam_password_history` | 密码重用限制 | `id` | — |
### 3.2 索引策略
| 表 | 索引 | 用途 |
| ----------------------- | ---------------------------------------------------------------------------- | -------------------------------- |
| `iam_users` | PK(`id`)、UNIQUE(`email`) | 主键查询、登录 |
| `iam_user_roles` | UNIQUE(`userId`,`roleId`)、INDEX(`roleId`) | 防重、按角色查用户 |
| `iam_role_permissions` | UNIQUE(`roleId`,`permissionId`)、INDEX(`permissionId`) | 防重、按权限查角色 |
| `iam_refresh_tokens` | UNIQUE(`jti`)、INDEX(`userId`)、INDEX(`tokenHash`) | jti 黑名单、按用户、按 hash 校验 |
| `iam_role_viewports` | UNIQUE(`roleId`,`viewportKey`) | 防重 |
| `iam_student_guardians` | UNIQUE(`studentId`,`guardianId`)、INDEX(`guardianId`) | 防重、按家长查学生 |
| `iam_user_audit_log` | INDEX(`actorUserId`)、INDEX(`resourceType`,`resourceId`)、INDEX(`createdAt`) | 按操作者、按资源、按时间查审计 |
| `iam_password_history` | INDEX(`userId`,`createdAt`) | 按用户查密码历史 |
### 3.3 读写分离策略
- **写路径**:所有 Command 走 MySQL 主库(iam 独占库)
- **读路径**:MySQL 直接读(iam 读多写少但数据量小,无需 ClickHouse 读模型)
- **缓存层**:
- `getEffectivePermissions` 结果走 Redis 缓存(TTL 5min),Key: `iam:perm:{userId}`
- refresh token jti 黑名单走 Redis,Key: `iam:bl:{jti}`,TTL 与 refresh_token 剩余有效期对齐
## 4. API 设计
### 4.1 REST API 清单
| Method | Path | 权限 | 说明 |
| ------ | ------------------------------- | ----------------- | -------------------------------------- |
| POST | `/v1/iam/register` | 公开 | 注册 |
| POST | `/v1/iam/login` | 公开 | 登录 |
| POST | `/v1/iam/refresh` | 公开 | 刷新令牌(轮换 + 旧 jti 加黑名单) |
| POST | `/v1/iam/logout` | `IAM_USER_READ` | 登出(refresh token 加黑名单) |
| GET | `/v1/iam/me` | `IAM_USER_READ` | 当前用户信息 |
| GET | `/v1/iam/viewports` | `IAM_USER_READ` | 当前用户视口 |
| GET | `/v1/iam/permissions/effective` | `IAM_USER_READ` | 当前用户有效权限 |
| GET | `/v1/iam/children` | `IAM_USER_READ` | 家长的孩子列表 |
| GET | `/v1/iam/roles` | `IAM_ROLE_MANAGE` | 角色列表 |
| GET | `/v1/iam/permissions` | `IAM_ROLE_MANAGE` | 权限点列表 |
| GET | `/v1/iam/audit` | `IAM_AUDIT_READ` | 审计日志(支持 actor/resource 查询) |
| GET | `/v1/iam/.well-known/jwks.json` | 公开 | RS256 公钥 JWK Set(Gateway 拉取验签) |
### 4.2 gRPC API 清单(12 RPC,package `next_edu_cloud.iam.v1`)
| RPC | 请求 | 响应 | 说明 |
| ------------------------- | ------------------------- | ---------------------- | -------------------------- |
| `Register` | `{email, password, name}` | `{user, tokens}` | 注册 |
| `Login` | `{email, password}` | `{user, tokens}` | 登录 |
| `RefreshToken` | `{refreshToken}` | `{tokens}` | 刷新令牌 |
| `Logout` | `{refreshToken, userId}` | `{success}` | 登出 |
| `GetUserInfo` | `{userId}` | `UserInfo` | 查询用户信息 |
| `BatchGetUsers` | `{userIds[]}` | `{users[]}` | 批量查询用户(BFF 聚合用) |
| `GetEffectivePermissions` | `{userId}` | `{permissions[]}` | 用户有效权限 |
| `GetEffectiveAccess` | `{userId, permission}` | `{allowed, dataScope}` | 用户对某权限的有效访问判定 |
| `GetEffectiveDataScope` | `{userId}` | `{dataScope}` | 用户数据范围 |
| `GetViewports` | `{userId}` | `{viewports[]}` | 用户视口 |
| `GetPublicKey` | — | `{kid, alg, pem}` | 公钥拉取(备用 JWKS) |
| `GetChildrenByParent` | `{parentId}` | `{children[]}` | 家长的孩子列表 |
### 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(仅 refresh token,用于黑名单)
kid: string; // 密钥 ID(header,支持 JWKS 轮换)
}
```
- **access_token**:TTL 15min(`ACCESS_TOKEN_TTL`),RS256 签名
- **refresh_token**:TTL 7day(`REFRESH_TOKEN_TTL_DAYS`),RS256 签名,含 jti
- **密钥**:本地文件(`IAM_PRIVATE_KEY_PATH` / `IAM_PUBLIC_KEY_PATH`),启动时一次性加载
## 5. 事件设计
### 5.1 我发布的领域事件
| 事件 | 触发时机 | Topic | 消费者 |
| -------------- | ------------------------------------ | ----------------------- | -------------- |
| `UserCreated` | 注册成功 | `edu.iam.user.events` | core-edu / msg |
| `AuditCreated` | 关键操作(登录/角色变更/密码修改等) | `edu.iam.audit.created` | data-ana |
| `RoleChanged` | 角色绑定变更 | `edu.iam.role.events` | msg / 缓存失效 |
### 5.2 事件 Schema(events.proto)
```protobuf
message UserEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string user_id = 5;
string email = 6;
string name = 7;
repeated string roles = 8;
string data_scope = 9;
string action = 10;
map metadata = 11;
}
message RoleEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string role_id = 5;
string role_name = 6;
string action = 7;
map metadata = 8;
}
message AuditEvent {
string event_id = 1;
string actor_user_id = 2;
string action = 3;
string resource_type = 4;
string resource_id = 5;
int64 occurred_at = 6;
string ip_address = 7;
map metadata = 8;
}
```
### 5.3 我消费的事件
无。iam 是权限中枢,单向发布,不消费业务事件。
### 5.4 Outbox 实现策略
- **shared-ts OutboxModule**(I4 裁决):`OutboxModule.forRoot({ config, db, kafkaProducer })`
- **表名**:`iam_outbox`
- **topic**:`edu.iam.user.events`(默认)/ `edu.iam.role.events` / `edu.iam.audit.created`
- **轮询间隔**:1000ms,批量 20 条,最大重试 5 次,退避 1000ms
- **幂等性**:producer `idempotent=true` + `transactionalId: "iam-tx"`,consumer 基于 `event_id` 去重
```mermaid
sequenceDiagram
participant Ctl as Controller
participant Svc as IamService
participant DB as MySQL
participant Pub as OutboxPublisher
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->>DB: INSERT iam_user_audit_log
Svc->>Pub: publish(UserCreated + AuditCreated)
Pub->>DB: INSERT iam_outbox
Svc->>DB: COMMIT TX
Svc-->>Ctl: {user, tokens}
loop 每 1000ms 轮询
Relay->>DB: SELECT pending LIMIT 20
Relay->>Kafka: produce(topic, payload)
Kafka-->>Relay: ack
Relay->>DB: UPDATE status='published'
end
```
## 6. 横切关注点对齐清单
### 6.1 权限装饰器
每个 Controller 方法用 `@RequirePermission()` 装饰器声明权限点。权限点常量:
```typescript
export const Permissions = {
IAM_USER_READ: "iam:user:read",
IAM_USER_MANAGE: "iam:user:manage",
IAM_ROLE_MANAGE: "iam:role:manage",
IAM_AUDIT_READ: "iam:audit:read",
IAM_VIEWPORT_READ: "iam:viewport:read",
} 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_DATABASE_ERROR` | 500 | Drizzle 操作失败 |
| `IAM_INTERNAL_ERROR` | 500 | 未预期异常 |
| `IAM_OUTBOX_ERROR` | 500 | Outbox 写入或投递失败 |
### 6.3 可观测性
- **日志**:pino,`base: { service: 'iam', version: '0.2.0' }`,`level: env.LOG_LEVEL`,注入 traceId
- **指标**:prom-client,`/metrics` 端点
- `iam_login_attempts_total`、`iam_login_duration_seconds`
- `iam_jwt_issued_total`(type=access/refresh)
- `iam_permission_cache_hits_total` / `iam_permission_cache_misses_total`
- `iam_outbox_pending`、`iam_outbox_publish_duration_seconds`
- **链路**:OpenTelemetry SDK + OTLP exporter,serviceName: `iam`
### 6.4 健康检查
- **`/healthz`**:liveness,仅返回进程存活
- 响应:`{ status: 'ok', service: 'iam', timestamp: ISO }`
- **`/readyz`**:readiness,5 依赖检查
- DB:`SELECT 1`
- Redis:`ping`
- Kafka:producer 实例存在且已连接
- JWKS:密钥文件可读
- gRPC:进程内 microservice 存在
- 失败:HTTP 503,响应体含失败项详情
### 6.5 优雅关闭顺序
```typescript
async onApplicationShutdown(signal?: string) {
// 1. 停止接收新请求(NestJS 自动)
// 2. 停止 OutboxRelayWorker(shared-ts 自动)
// 3. 关闭 Kafka producer
await closeKafkaProducer();
// 4. 关闭 Redis 连接
await closeRedis();
// 5. 关闭 MySQL 连接池
await closeDb();
}
```
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ----------- | ----- | ------------------------------------------------- | ------------------------------- |
| 被调用 | api-gateway | HTTP | `POST /v1/iam/register` 等 | Gateway 反向代理 `/iam/v1/*` |
| 被调用 | api-gateway | HTTP | `GET /v1/iam/.well-known/jwks.json` | Gateway 拉取 RS256 公钥校验 JWT |
| 被调用 | teacher-bff | gRPC | `GetUserInfo`、`BatchGetUsers`、`GetViewports` 等 | BFF 聚合用户身份与视口 |
| 被调用 | student-bff | gRPC | 同上 | 同上 |
| 被调用 | parent-bff | gRPC | `GetChildrenByParent` | 家长视角聚合孩子信息 |
| 发布 | — | Kafka | `edu.iam.user.events` | core-edu / msg 消费 |
| 发布 | — | Kafka | `edu.iam.role.events` | msg / 缓存失效 |
| 发布 | — | Kafka | `edu.iam.audit.created` | data-ana 消费 |
### 7.1 端口分配
| 服务 | 端口 | 备注 |
| -------- | ----- | -------------------- |
| iam HTTP | 3002 | REST 入口 |
| iam gRPC | 50052 | gRPC 入口(I1 裁决) |
### 7.2 Topic 命名
- `edu.iam.user.events`
- `edu.iam.role.events`
- `edu.iam.audit.created`
## 8. 仲裁裁决对齐
### 8.1 I1-I8 裁决
| 裁决 | 内容 | 实现位置 |
| ---- | ------------------------------------ | ------------------------------------------------- |
| I1 | 双入口(REST + gRPC),gRPC 50052 | main.ts Hybrid app + iam.grpc.controller.ts |
| I2 | JWT RS256 本地文件密钥 | config/jwt.ts + env IAM_PRIVATE/PUBLIC_KEY_PATH |
| I3 | DB 驱动 + Redis 缓存 PermissionGuard | permission.guard.ts + permission-cache.service.ts |
| I4 | shared-ts OutboxModule 接入 | app.module.ts OutboxModule.forRoot |
| I5 | /iam/v1/* 前缀 | iam.controller.ts @Controller("v1/iam") |
| I6 | iam_student_guardians 表名 | iam.schema.ts + iam-init.sql |
| I7 | jti 黑名单 | token-blacklist.service.ts |
| I8 | DataScope SUBJECT 替代 DISTRICT | iam.schema.ts DataScope enum |
### 8.2 总裁裁决
| 裁决 | 内容 | 实现位置 |
| ----- | --------------------------------------------- | -------------------------------------------- |
| §2.15 | 三层角色模型(system/organization/temporary) | iam.schema.ts RoleType + iam_roles.level |
| §2.16 | 双入口策略(同一 IamService) | iam.service.ts REST 与 gRPC 共用 |
| §3.2 | AuthMiddleware 注册 | app.module.ts configure() |
| §5.5 | 审计日志(iam_user_audit_log + AuditEvent) | iam.schema.ts + iam.service.ts writeAuditLog |
## 9. 技术风险
| 风险 | 影响 | 缓解措施 |
| -------------------- | ---------------------------------- | -------------------------------------------------------- |
| Redis 不可用 | 权限校验回退到 DB 查询,性能下降 | `getEffectivePermissions` catch 后走 DB,不抛错 |
| Kafka 不可用 | Outbox 事件积压,下游服务感知延迟 | Relay Worker 重试 + `status='failed'` 记录,不阻塞主流程 |
| RS256 密钥泄露 | 任何人可伪造 JWT | 密钥文件权限 600 + 容器 Secret 挂载 |
| 权限缓存与 DB 不一致 | 用户角色变更后 5min 内旧权限仍生效 | 角色变更主动 DEL + 短 TTL |
| Outbox 表膨胀 | 磁盘占用增长 | 已发布事件定期清理(保留 7 天) |
---
**模块**: iam-service(@edu/iam-service v0.2.0)
**分支**: feat-implement-iam-module
**入口**: HTTP 3002 + gRPC 50052