# 模块架构设计文档 — 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