模块架构设计文档 — iam
AI:ai02(TS / 身份认证)
阶段:阶段 2 交付物
日期:2026-07-09
关联:阶段 1 理解确认书、004 架构影响地图、pending-features P2
状态:待 coord 交叉审查
1. 模块内部分层图
1.1 调用链总览
1.2 中间件 / Guard / Filter 拦截顺序
1.3 目录结构(P2 目标态)
2. 领域模型
2.1 聚合根与实体
2.2 值对象(枚举)
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 表结构定义
// 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 请求/响应结构示例
4.3 JWT Payload(RS256 签发)
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 中统一定义)
需 coord 在 shared-proto/events.proto 中统一定义,iam 只负责填充字段并写入 outbox。
5.3 我消费的事件
- 当前:无
- 未来:不主动消费业务事件(iam 是权限中枢,单向发布)
5.4 Outbox 实现策略
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 完整化):
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 检查):
- 失败:HTTP 503,响应体含失败项详情
6.8 优雅关闭顺序(P2 改造)
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 回复)
- shared-ts 是否在 P2 提供通用 Outbox 工具?还是 iam 自建?
- events.proto 中
UserEvent / RoleEvent 由 coord 统一定义,还是 iam 提交 PR?
- iam gRPC 端口 50052 是否与全局端口表冲突?
class_subject_teachers 表归属确认(我建议归 core-edu,pending-features §P2 写在 iam)
AI Agent: ai02 (iam-module)
Branch: main(单仓库并行模式,见 ai-allocation §9.1)
Coordinator: coord-ai