# 模块架构设计文档 — 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 请求
带 x-user-id / x-user-roles 头] end subgraph NestJS["iam 服务(NestJS)"] direction TB MW[AuthMiddleware
❌ 当前未注册,P2 仍走 header 直读] Guard[PermissionGuard
APP_GUARD 全局守卫] Filter[GlobalErrorFilter
全局异常过滤器] subgraph Controllers["Controller 层"] IamCtl[IamController
/iam/register, login, refresh, me] RbacCtl[RbacController
/iam/viewports, permissions, roles] UserCtl[UserController
P2 新增: 用户 CRUD] RoleCtl[RoleController
P2 新增: 角色 CRUD] ViewportCtl[ViewportController
P2 新增: 视口 CRUD] JwksCtl[JwksController
P2 新增: RS256 公钥暴露] end subgraph Services["Application Service 层"] IamSvc[IamService
认证编排] RbacSvc[RbacService
RBAC 编排] UserSvc[UserService
用户领域编排] CacheSvc[PermissionCacheService
Redis 权限缓存] end subgraph Domain["Domain 层(P2 轻量)"] UserEntity[UserEntity
聚合根] RoleEntity[RoleEntity
聚合根] end subgraph Repo["Repository 层"] IamRepo[IamRepository
Drizzle 查询] RbacRepo[RbacRepository
Drizzle 查询] end subgraph Outbox["Outbox 模块"] OutboxTbl[(iam_outbox 表)] Relay[OutboxRelayWorker
后台轮询 + Kafka 投递] end subgraph Infra["基础设施"] Db[(MySQL
iam_db)] Redis[(Redis
权限缓存 + token 黑名单)] Kafka[(Kafka
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 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 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