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