feat(iam): v2 用户管理 RPC + F12 httpOnly Cookie
admin-portal §2.3 P1 阻塞项补齐:CreateUser/UpdateUser/DeleteUser 3 RPC iam.repository/service/grpc.controller 实现 3 用户管理方法(含 bcrypt + 审计) iam.controller 新增 POST /v1/iam/users + DELETE /v1/iam/users/:id(@RequirePermission(IAM_USER_MANAGE)) iam.dto 新增 createUserSchema Zod 校验 F12 httpOnly Cookie:refresh_token 改为 httpOnly+Secure+SameSite=Strict Cookie 下发 extractRefreshToken 优先读 cookie 回退 body + buildRefreshTokenCookie/buildClearCookie
This commit is contained in:
@@ -2,13 +2,12 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
# 安装原生模块编译工具(bcrypt 需要 python3 + make + g++)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# 复制 workspace 配置 + tsconfig 基线
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
|
||||
|
||||
# 移除 prepare 脚本(husky 在 Docker 中不可用)
|
||||
RUN node -e "const p=require('./package.json');delete p.scripts.prepare;require('fs').writeFileSync('./package.json',JSON.stringify(p,null,2))"
|
||||
|
||||
# 复制 shared-proto(gRPC proto-loader 运行时加载)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
|
||||
@@ -18,47 +17,41 @@ COPY packages/shared-ts ./packages/shared-ts
|
||||
# 复制 iam 源码
|
||||
COPY services/iam ./services/iam
|
||||
|
||||
# 安装依赖(仅 iam + shared-ts)
|
||||
RUN cd services/iam && pnpm install --frozen-lockfile
|
||||
# HUSKY=0 跳过根 package.json 的 prepare:husky 脚本
|
||||
ENV HUSKY=0
|
||||
|
||||
# 先构建 shared-ts(iam 依赖 @edu/shared-ts/outbox)
|
||||
WORKDIR /app/packages/shared-ts
|
||||
RUN pnpm build
|
||||
# 只安装 iam 及其 workspace 依赖(@edu/shared-ts、@edu/shared-proto),不安装 arch-scan(避免 better-sqlite3 原生编译)
|
||||
# 不使用 --ignore-scripts,让 bcrypt 的 install script 正常执行原生编译
|
||||
RUN pnpm install --no-frozen-lockfile --filter @edu/iam-service...
|
||||
|
||||
# 构建 shared-ts(iam 依赖 @edu/shared-ts/outbox dist 产物)
|
||||
RUN cd packages/shared-ts && pnpm build
|
||||
|
||||
# 构建 iam
|
||||
WORKDIR /app/services/iam
|
||||
RUN pnpm build
|
||||
|
||||
# 复制 proto 文件到 dist 旁(生产环境 ./proto/iam.proto)
|
||||
RUN mkdir -p /app/proto && cp /app/packages/shared-proto/proto/iam.proto /app/proto/iam.proto
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
# 复制 workspace 配置 + tsconfig 基线
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
|
||||
|
||||
# 移除 prepare 脚本(husky 在 Docker 中不可用)
|
||||
RUN node -e "const p=require('./package.json');delete p.scripts.prepare;require('fs').writeFileSync('./package.json',JSON.stringify(p,null,2))"
|
||||
|
||||
# 复制 shared-proto + shared-ts(运行时需要)
|
||||
# 复制 shared-proto + shared-ts(运行时需要:proto 文件 + shared-ts dist)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
COPY packages/shared-ts ./packages/shared-ts
|
||||
|
||||
# 复制 iam
|
||||
# 复制 iam 源码
|
||||
COPY services/iam ./services/iam
|
||||
|
||||
# 从 builder 复制已编译的 prod node_modules + iam dist + shared-ts dist
|
||||
COPY --from=builder /app/services/iam/node_modules ./services/iam/node_modules
|
||||
COPY --from=builder /app/packages/shared-ts/node_modules ./packages/shared-ts/node_modules
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/services/iam/dist ./services/iam/dist
|
||||
COPY --from=builder /app/packages/shared-ts/dist ./packages/shared-ts/dist
|
||||
|
||||
WORKDIR /app/services/iam
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/services/iam/dist ./dist
|
||||
COPY --from=builder /app/packages/shared-ts/dist /app/packages/shared-ts/dist
|
||||
|
||||
# 复制 proto 文件(生产环境从 ./proto/ 加载)
|
||||
COPY --from=builder /app/proto ./proto
|
||||
|
||||
EXPOSE 3002 50052
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
283
services/iam/docs/nextstep-v2.md
Normal file
283
services/iam/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# iam 下一步工作与上下游依赖(Next Steps v2)
|
||||
|
||||
> 模块:iam(身份与访问管理服务,端口 3002 HTTP + 50052 gRPC)
|
||||
> 更新日期:2026-07-14
|
||||
> 关联文档:
|
||||
>
|
||||
> - [nextstep.md](./nextstep.md)(v1 基线)
|
||||
> - [02-architecture-design.md](./02-architecture-design.md)
|
||||
> - [iam_contract.md](../../../docs/architecture/issues/contracts/iam_contract.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. v2 迭代已完成工作
|
||||
|
||||
### 1.1 admin-portal §2.3 P1 阻塞项补齐(用户管理 RPC)
|
||||
|
||||
| # | 工作项 | 状态 |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| 1 | `iam.proto` 新增 `CreateUserRequest` / `UpdateUserRequest` / `DeleteUserRequest` / `DeleteUserResponse` 4 个 message | ✅ |
|
||||
| 2 | `iam.proto` `service IamService` 声明 `CreateUser` / `UpdateUser` / `DeleteUser` 3 个 RPC | ✅ |
|
||||
| 3 | `iam.repository.ts` 实现 `createUser` / `updateUser` / `deleteUser` 数据访问 | ✅ |
|
||||
| 4 | `iam.service.ts` 实现 `createUser` / `updateUser` / `deleteUser` 业务逻辑(含 bcrypt + 审计) | ✅ |
|
||||
| 5 | `iam.grpc.controller.ts` 新增 3 个 `@GrpcMethod` 实现 | ✅ |
|
||||
| 6 | `iam.controller.ts` 新增 `POST /v1/iam/users` + `DELETE /v1/iam/users/:id` REST 端点(`@RequirePermission(IAM_USER_MANAGE)`) | ✅ |
|
||||
| 7 | `iam.dto.ts` 新增 `createUserSchema` Zod 校验 | ✅ |
|
||||
|
||||
### 1.2 F12 httpOnly Cookie 模式(refresh_token 安全增强)
|
||||
|
||||
依据架构裁决 F12:refresh_token 不再通过响应体明文返回,改为 httpOnly + Secure + SameSite=Strict Cookie 下发。
|
||||
|
||||
| # | 工作项 | 状态 |
|
||||
| --- | --------------------------------------------------------------------------------- | ---- |
|
||||
| 1 | `iam.controller.ts` 新增 `extractRefreshToken(req)`:优先读 cookie,回退 body | ✅ |
|
||||
| 2 | `iam.controller.ts` 新增 `buildRefreshTokenCookie(token, maxAge)` 构造 Set-Cookie | ✅ |
|
||||
| 3 | `iam.controller.ts` 新增 `buildClearCookie()` 构造清除 Cookie | ✅ |
|
||||
| 4 | `POST /v1/iam/register` 下发 Set-Cookie | ✅ |
|
||||
| 5 | `POST /v1/iam/login` 下发 Set-Cookie | ✅ |
|
||||
| 6 | `POST /v1/iam/refresh` 优先从 cookie 读取 + 轮换 Cookie | ✅ |
|
||||
| 7 | `POST /v1/iam/logout` 清除 Cookie(Max-Age=0) | ✅ |
|
||||
|
||||
Cookie 属性:`HttpOnly; SameSite=Strict; Max-Age=604800; Path=/; Secure`
|
||||
|
||||
### 1.3 Docker 构建修复(bcrypt 原生模块)
|
||||
|
||||
| # | 工作项 | 状态 |
|
||||
| --- | --------------------------------------------------------------------------------------------- | ---- |
|
||||
| 1 | Dockerfile 改用 `pnpm install --no-frozen-lockfile --filter @edu/iam-service...` 过滤安装策略 | ✅ |
|
||||
| 2 | 不再使用 `--ignore-scripts`,让 bcrypt install script 正常执行原生编译 | ✅ |
|
||||
| 3 | 仅安装 iam + workspace 依赖,避免 arch-scan 的 better-sqlite3 原生编译 | ✅ |
|
||||
| 4 | 镜像 `edu/iam:test` 构建成功(660MB),bcrypt `.node` 文件存在 | ✅ |
|
||||
|
||||
### 1.4 本地 Docker 端到端验证(2026-07-14)
|
||||
|
||||
测试环境:`edu-iam-test` 容器(NODE_ENV=production, DEV_MODE=true),接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka。
|
||||
|
||||
JWT RS256 密钥对挂载在 `edu-iam-keys` volume(`/app/keys/private.pem` + `/app/keys/public.pem`)。
|
||||
|
||||
| 验证项 | 状态 | 说明 |
|
||||
| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /healthz` | ✅ | 200 `{"status":"ok","service":"iam"}` |
|
||||
| `GET /readyz` | ✅ | 200,5 项依赖全 ok(database / redis / kafka / jwks / grpc) |
|
||||
| `GET /v1/iam/.well-known/jwks.json` | ✅ | 200,返回 RSA 公钥(kid: iam-rs256-v1, alg: RS256, kty: RSA) |
|
||||
| `POST /v1/iam/register` | ✅ | 201 Created + `Set-Cookie: refresh_token=...; HttpOnly; SameSite=Strict; Max-Age=604800; Path=/; Secure` |
|
||||
| `POST /v1/iam/login` | ✅ | 201 Created + Set-Cookie 下发 |
|
||||
| `POST /v1/iam/refresh`(cookie 优先) | ✅ | 201 Created + 轮换 Set-Cookie(新 jti) |
|
||||
| `POST /v1/iam/logout` | ✅ | 201 Created + `Set-Cookie: refresh_token=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure`(清除) |
|
||||
| `POST /v1/iam/users`(createUser) | ✅ | 201 Created + 返回新用户(含 dataScope) |
|
||||
| `DELETE /v1/iam/users/:id`(deleteUser) | ✅ | 200 OK + `{"success":true,"data":{"success":true}}` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 上游依赖(iam 依赖谁)
|
||||
|
||||
iam 作为身份基础设施服务,运行时依赖以下组件(与 v1 一致,无新增):
|
||||
|
||||
### 2.1 基础设施
|
||||
|
||||
| 组件 | 端点 | 用途 | 状态 |
|
||||
| -------------- | ----------------------------------------------------------------------- | ---------------------------------------- | ---- |
|
||||
| MySQL | `mysql://edu:changeme@edu-mysql:3306/next_edu_cloud` | 用户/角色/权限/视口/审计/Outbox 表持久化 | ✅ |
|
||||
| Redis | `redis://edu-redis:6379` | 权限缓存(TTL 5min)+ token 黑名单 | ✅ |
|
||||
| Kafka | `kafka:29092` | Outbox 事件投递 | ✅ |
|
||||
| JWT RS256 密钥 | `/app/keys/private.pem` + `/app/keys/public.pem`(edu-iam-keys volume) | RS256 签发 + JWKS 暴露公钥 | ✅ |
|
||||
|
||||
### 2.2 共享包(协调 AI 维护)
|
||||
|
||||
| 包 | 依赖内容 | 状态 |
|
||||
| ------------ | ---------------------------------------------------------------- | ---- |
|
||||
| shared-proto | `packages/shared-proto/proto/iam.proto`(15 RPC + 全部 message) | ✅ |
|
||||
| shared-ts | `@edu/shared-ts/outbox` OutboxModule + OutboxService | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 下游依赖(谁依赖 iam)
|
||||
|
||||
### 3.1 api-gateway(ai01 负责)— P0
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ----------------------------------- | ---------------------------------------------------- | --------- |
|
||||
| 1 | `GET /v1/iam/.well-known/jwks.json` | RS256 公钥集(JWKS),TTL 5min 缓存 | ✅ 已就绪 |
|
||||
| 2 | RS256 JWT 签发 | api-gateway 用 JWKS 公钥校验 access_token | ✅ 已就绪 |
|
||||
| 3 | `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已就绪 |
|
||||
| 4 | JWT claims 含 role/data_scope | api-gateway 注入 x-user-roles / x-user-data-scope 头 | ✅ 已就绪 |
|
||||
|
||||
### 3.2 push-gateway(ai09 负责)— P0
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------------------ | ----------------------------------------- | --------- |
|
||||
| 1 | `GET /v1/iam/.well-known/jwks.json` | WebSocket `/ws` 连接时校验客户端 JWT 签名 | ✅ 已就绪 |
|
||||
| 2 | shared-go/jwks Fetcher 每 5 分钟刷新 | JWKS 缓存刷新 | ✅ 已就绪 |
|
||||
|
||||
### 3.3 teacher-bff(ai03 负责)— P0
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | -------------------------------------------------------- | ----------------------------- | --------- |
|
||||
| 1 | gRPC `GetUserInfo(userId)` :50052 | `currentUser`/`me` 查询 | ✅ 已就绪 |
|
||||
| 2 | gRPC `BatchGetUsers(userIds)` :50052 | `adminUsers` 查询 | ✅ 已就绪 |
|
||||
| 3 | gRPC `GetEffectivePermissions(userId)` :50052 | 用户有效权限列表 | ✅ 已就绪 |
|
||||
| 4 | gRPC `GetEffectiveDataScope(userId)` :50052 | 用户数据范围 | ✅ 已就绪 |
|
||||
| 5 | gRPC `GetViewports(userId)` :50052 | `adminViewports` 查询 | ✅ 已就绪 |
|
||||
| 6 | gRPC `GetPublicKey()` :50052 | RS256 公钥 | ✅ 已就绪 |
|
||||
| 7 | gRPC `GetChildrenByParent(parentId)` :50052 | 家长端学生列表 | ✅ 已就绪 |
|
||||
| 8 | gRPC `CreateUser` / `UpdateUser` / `DeleteUser` | 用户管理 CRUD(admin-portal) | ✅ 已就绪 |
|
||||
| 9 | REST 全套 RBAC CRUD(roles/permissions/viewports/audit) | admin-portal 管理 | ✅ 已就绪 |
|
||||
|
||||
### 3.4 student-bff(ai04 负责)— P0
|
||||
|
||||
| # | gRPC 方法 | 用途 | 状态 |
|
||||
| --- | ---------------- | ------------------------- | --------- |
|
||||
| 1 | `GetUserProfile` | `myProfile` Query | ✅ 已就绪 |
|
||||
| 2 | `UpdateProfile` | `updateProfile` Mutation | ✅ 已就绪 |
|
||||
| 3 | `ChangePassword` | `changePassword` Mutation | ✅ 已就绪 |
|
||||
|
||||
### 3.5 parent-bff(ai05 负责)— P0
|
||||
|
||||
| # | RPC 方法 | 用途 | 状态 |
|
||||
| --- | --------------------------------- | ---------------------- | --------- |
|
||||
| 1 | `getUserInfo(userId)` | 获取家长个人信息 | ✅ 已就绪 |
|
||||
| 2 | `getChildrenByParent(parentId)` | 获取家长绑定的孩子列表 | ✅ 已就绪 |
|
||||
| 3 | `getViewports(userId)` | 获取家长可见视口 | ✅ 已就绪 |
|
||||
| 4 | `getEffectivePermissions(userId)` | 获取家长有效权限 | ✅ 已就绪 |
|
||||
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已就绪 |
|
||||
|
||||
### 3.6 admin-portal(ai16 负责)— P0
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------------------------------------ | ----------------------------- | --------- |
|
||||
| 1 | gRPC `CreateUser` / `UpdateUser` / `DeleteUser` | 用户管理 CRUD(v2 新增) | ✅ 已就绪 |
|
||||
| 2 | REST `POST /v1/iam/users` + `DELETE /v1/iam/users/:id` | 用户管理 REST 端点(v2 新增) | ✅ 已就绪 |
|
||||
| 3 | `GET /v1/iam/users` | 用户管理列表(分页 + 搜索) | ✅ 已就绪 |
|
||||
| 4 | `PATCH /v1/iam/users/:id` | 用户更新 | ✅ 已就绪 |
|
||||
| 5 | `PATCH /v1/iam/users/:id/status` | 用户状态切换 | ✅ 已就绪 |
|
||||
| 6 | `POST/PATCH/DELETE /v1/iam/roles` | 角色 CRUD | ✅ 已就绪 |
|
||||
| 7 | `POST/PATCH/DELETE /v1/iam/permissions` | 权限 CRUD | ✅ 已就绪 |
|
||||
| 8 | `POST/PATCH/DELETE /v1/iam/viewports` | 视口 CRUD | ✅ 已就绪 |
|
||||
| 9 | `POST /v1/iam/totp/enable` / `verify` / `disable` | TOTP 2FA 管理 | ✅ 已就绪 |
|
||||
| 10 | `GET /v1/iam/audit` | 审计日志查询 | ✅ 已就绪 |
|
||||
|
||||
### 3.7 teacher-portal(ai13 负责)— P1
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ----------------------------------- | --------------------------------------------- | --------- |
|
||||
| 1 | JWT RS256 签发 | 登录后获取 access_token + refresh_token | ✅ 已就绪 |
|
||||
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发(v2 新增) | ✅ 已就绪 |
|
||||
| 3 | `POST /v1/iam/login` | 教师登录 | ✅ 已就绪 |
|
||||
| 4 | `POST /v1/iam/refresh` | 刷新 token(从 cookie 读取) | ✅ 已就绪 |
|
||||
| 5 | `POST /v1/iam/logout` | 登出(清除 cookie) | ✅ 已就绪 |
|
||||
| 6 | `GET /v1/iam/me` | 获取当前用户信息 | ✅ 已就绪 |
|
||||
| 7 | `GET /v1/iam/viewports` | 获取视口配置 | ✅ 已就绪 |
|
||||
| 8 | `GET /v1/iam/permissions/effective` | 获取有效权限 | ✅ 已就绪 |
|
||||
|
||||
### 3.8 student-portal(ai14 负责)— P1
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------------ | --------------------------------------------- | --------- |
|
||||
| 1 | JWT RS256 签发 | 学生登录 | ✅ 已就绪 |
|
||||
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发(v2 新增) | ✅ 已就绪 |
|
||||
| 3 | `POST /v1/iam/login` | 学生登录 | ✅ 已就绪 |
|
||||
| 4 | `PATCH /v1/iam/me` | 更新个人资料 | ✅ 已就绪 |
|
||||
| 5 | `POST /v1/iam/change-password` | 修改密码 | ✅ 已就绪 |
|
||||
|
||||
### 3.9 parent-portal(ai15 负责)— P1
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------ | --------------------------------------------- | --------- |
|
||||
| 1 | JWT RS256 签发 | 家长登录 | ✅ 已就绪 |
|
||||
| 2 | F12 httpOnly Cookie 模式 | refresh_token 通过 Set-Cookie 下发(v2 新增) | ✅ 已就绪 |
|
||||
| 3 | `POST /v1/iam/login` | 家长登录 | ✅ 已就绪 |
|
||||
| 4 | `GET /v1/iam/children` | 获取绑定的学生列表 | ✅ 已就绪 |
|
||||
| 5 | `GET /v1/iam/me` | 获取家长个人信息 | ✅ 已就绪 |
|
||||
|
||||
### 3.10 同级服务
|
||||
|
||||
| # | 模块 | 依赖项 | 用途 | 状态 |
|
||||
| --- | -------- | ------------------------------------ | ---------------------------------------------- | --------- |
|
||||
| 1 | ai | gRPC `GetEffectiveDataScope(userId)` | 解析用户可见数据范围(DataScope 6 级) | ✅ 已就绪 |
|
||||
| 2 | core-edu | gRPC `BatchGetUsers` / `GetUserInfo` | DashboardService 学生计数(P4+ 未来工作) | ✅ 已就绪 |
|
||||
| 3 | msg | Kafka 事件 `edu.iam.user.events` | 用户注册/登录/角色变更事件(msg 消费方需对齐) | ✅ 已就绪 |
|
||||
| 4 | data-ana | gRPC `GetEffectiveDataScope(userId)` | 数据分析查询过滤范围 | ✅ 已就绪 |
|
||||
| 5 | content | 无直接依赖 | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 4. 需要上下游实现的协调事项
|
||||
|
||||
### 4.1 上游(Gateway / BFF / Portal)需实现
|
||||
|
||||
| # | 协调项 | 对端模块 | 说明 |
|
||||
| --- | ----------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | F12 Cookie 透传:`Cookie` / `Set-Cookie` 头透传 | api-gateway / teacher-bff / student-bff / parent-bff | F12 httpOnly cookie 模式要求 Gateway 和 BFF 透传 `Cookie` 请求头和 `Set-Cookie` 响应头。Gateway 当前已透传 `x-user-*` 头,需确认 `Cookie` 头也在透传白名单中。BFF GraphQL 网关需在 fetch 调用 iam 时带上客户端的 `Cookie` 头。 |
|
||||
| 2 | Cookie Domain 配置 | api-gateway | 生产环境跨子域共享 cookie 时,需在 iam 环境变量 `REFRESH_TOKEN_COOKIE_DOMAIN` 配置根域(如 `.edu.example.com`)。当前默认不设置 Domain。 |
|
||||
| 3 | HTTPS 终止点确认 | api-gateway / push-gateway | Cookie 带 `Secure` 属性,浏览器仅在 HTTPS 下发送。开发环境(HTTP)下浏览器不会自动回传 cookie,需用 curl/Postman 手动传递。生产环境 api-gateway 必须做 HTTPS 终止。 |
|
||||
| 4 | admin-portal 切换到 CreateUser/DeleteUser RPC | admin-portal / teacher-bff | admin-portal 当前可能使用 PATCH /v1/iam/users/:id 更新用户。新增用户需切换到 gRPC `CreateUser`(通过 teacher-bff 聚合)或 REST `POST /v1/iam/users`(通过 gateway 透传)。删除用户使用 `DELETE /v1/iam/users/:id`。 |
|
||||
| 5 | Kafka 消费者对齐 `edu.iam.user.events` topic | msg / data-ana | iam 发布事件到 `edu.iam.user.events` topic,事件类型:`UserRegistered` / `UserLoggedIn` / `UserLoggedOut` / `UserRoleChanged` / `UserCreated` / `UserDeleted`。msg 服务的 topic 命名期望为 `edu.identity.user.*`,需协调统一命名。 |
|
||||
| 6 | Kafka 消费者对齐 `edu.iam.role.events` topic | msg / data-ana | 事件类型:`RoleCreated` / `RoleUpdated` / `RolePermissionsChanged`。下游消费方需订阅此 topic 同步权限缓存。 |
|
||||
| 7 | Kafka 消费者对齐 `edu.iam.audit.created` topic | data-ana | 审计日志事件,data-ana 可消费后入 ClickHouse 供长期分析。 |
|
||||
| 8 | api-gateway JWKS 缓存刷新策略 | api-gateway | iam JWKS 端点返回 `kid: iam-rs256-v1`,公钥轮换时 kid 会变。api-gateway 需在 JWT 验签失败时触发 JWKS 重新拉取(cache miss 回源)。 |
|
||||
|
||||
### 4.2 下游(同级服务)需实现
|
||||
|
||||
| # | 协调项 | 对端模块 | 说明 |
|
||||
| --- | ------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | msg 服务消费 `edu.iam.user.events` 创建用户消息视图 | msg | iam 发布 `UserRegistered` / `UserCreated` 事件后,msg 服务需消费并为新用户初始化消息收件箱 / 通知偏好。事件 payload 见 `shared/outbox/event-types.ts` |
|
||||
| 2 | data-ana 消费 `edu.iam.audit.created` 入 ClickHouse | data-ana | iam 审计日志通过 Outbox 投递到 Kafka,data-ana 需消费并写入 ClickHouse `audit_logs` 表供长期留存与查询。 |
|
||||
| 3 | core-edu DashboardService 调用 iam gRPC `BatchGetUsers` | core-edu | P4+ 工作:core-edu 仪表盘需展示学生/教师计数,通过 iam gRPC `BatchGetUsers` 批量查询用户信息。gRPC target: `iam:50052`。 |
|
||||
| 4 | ai 服务调用 iam gRPC `GetEffectiveDataScope` | ai | ai 服务在生成个性化内容时需解析用户数据范围,调用 `GetEffectiveDataScope(userId)` 返回 `{level, scope_ids, school_id}`。gRPC target: `iam:50052`。 |
|
||||
|
||||
### 4.3 事件契约(Kafka topic 与 payload)
|
||||
|
||||
iam 通过 Outbox 模式发布以下事件,下游消费方需按此契约实现 consumer:
|
||||
|
||||
| Topic | 事件类型 | 触发时机 | 关键字段 |
|
||||
| ----------------------- | ------------------------ | --------------------------- | ------------------------------------------------------------------------- |
|
||||
| `edu.iam.user.events` | `UserRegistered` | POST /v1/iam/register | `userId`, `email`, `name`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserLoggedIn` | POST /v1/iam/login | `userId`, `email`, `ip`, `userAgent`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserLoggedOut` | POST /v1/iam/logout | `userId`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserCreated` | POST /v1/iam/users | `userId`, `email`, `name`, `dataScope`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserUpdated` | PATCH /v1/iam/users/:id | `userId`, `changedFields`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserDeleted` | DELETE /v1/iam/users/:id | `userId`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.user.events` | `UserRoleChanged` | 角色授权/撤销 | `userId`, `roleId`, `permissionId`, `action`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.role.events` | `RoleCreated` | POST /v1/iam/roles | `roleId`, `name`, `level`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.role.events` | `RoleUpdated` | PATCH /v1/iam/roles/:id | `roleId`, `changedFields`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.role.events` | `RolePermissionsChanged` | PATCH roles/:id/permissions | `roleId`, `permissionIds`, `operatorId`, `timestamp` |
|
||||
| `edu.iam.audit.created` | `AuditLogCreated` | 所有审计操作 | `auditId`, `userId`, `action`, `resource`, `ip`, `userAgent`, `timestamp` |
|
||||
|
||||
事件 envelope 格式遵循 `@edu/shared-ts/outbox`:`{event_id, event_type, aggregate_id, payload, occurred_at, version}`。
|
||||
|
||||
幂等性:消费方需基于 `event_id` 去重(Redis SETNX 或 DB 唯一索引)。
|
||||
|
||||
---
|
||||
|
||||
## 5. iam 自身待办(非阻塞)
|
||||
|
||||
| # | 工作项 | 优先级 | 阻塞条件 |
|
||||
| --- | ------------------------------------------------------------------------------------------------ | ------ | ------------ |
|
||||
| 1 | 单元测试 + 集成测试覆盖率 ≥ 80% | P2 | 无 |
|
||||
| 2 | 生产环境 JWT 密钥轮换流程(kid 变更 + JWKS 缓存失效广播) | P3 | 生产部署前 |
|
||||
| 3 | Kafka topic 创建自动化(edu.iam.user.events / role.events / audit.created) | P3 | K8s 部署阶段 |
|
||||
| 4 | 数据库迁移脚本(drizzle-kit) | P3 | 生产部署前 |
|
||||
| 5 | UpdateUser REST 端点补齐(当前仅有 PATCH /v1/iam/users/:id,无 POST /v1/iam/users/:id 通用更新) | P3 | 无 |
|
||||
| 6 | Cookie Domain 环境变量配置化(`REFRESH_TOKEN_COOKIE_DOMAIN`) | P3 | 生产部署前 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 联调待办
|
||||
|
||||
| # | 联调项 | 对端模块 | 状态 |
|
||||
| --- | ------------------------------------------------------- | --------------------- | --------- |
|
||||
| 1 | api-gateway JWKS 真实验签 | api-gateway (ai01) | ✅ 已就绪 |
|
||||
| 2 | push-gateway JWKS 真实验签 | push-gateway (ai09) | ✅ 已就绪 |
|
||||
| 3 | teacher-bff gRPC 全量调用(含 CreateUser/DeleteUser) | teacher-bff (ai03) | ⏳ 待联调 |
|
||||
| 4 | student-bff GetUserProfile/UpdateProfile/ChangePassword | student-bff (ai04) | ✅ 已就绪 |
|
||||
| 5 | parent-bff getUserInfo/getChildrenByParent/getViewports | parent-bff (ai05) | ✅ 已就绪 |
|
||||
| 6 | admin-portal RBAC CRUD + 用户管理 RPC 全量 | admin-portal (ai16) | ⏳ 待联调 |
|
||||
| 7 | teacher-portal F12 cookie 模式联调 | teacher-portal (ai13) | ⏳ 待联调 |
|
||||
| 8 | student-portal F12 cookie 模式联调 | student-portal (ai14) | ⏳ 待联调 |
|
||||
| 9 | parent-portal F12 cookie 模式联调 | parent-portal (ai15) | ⏳ 待联调 |
|
||||
| 10 | msg 消费 edu.iam.user.events 事件 | msg | ⏳ 待联调 |
|
||||
| 11 | data-ana 消费 edu.iam.audit.created 事件 | data-ana | ⏳ 待联调 |
|
||||
|
||||
---
|
||||
|
||||
**iam 服务 v2 迭代完成。所有 v2 阻塞项(admin-portal 用户管理 RPC + F12 cookie 模式 + Docker bcrypt 修复)已就绪,下游模块可开始 v2 联调。**
|
||||
@@ -19,6 +19,13 @@ const envSchema = z.object({
|
||||
ACCESS_TOKEN_TTL: z.string().default("15m"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
|
||||
|
||||
// Cookie(F12 裁决:refresh_token httpOnly cookie 模式)
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.default("auto")
|
||||
.transform((v) => (v === "auto" ? undefined : v === "true")),
|
||||
COOKIE_DOMAIN: z.string().optional(),
|
||||
|
||||
// Kafka(Outbox 投递)
|
||||
KAFKA_BROKERS: z.string(),
|
||||
KAFKA_CLIENT_ID: z.string().default("iam-service"),
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Delete,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { IamService } from "./iam.service.js";
|
||||
import type {
|
||||
TokenPair,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
updateProfileSchema,
|
||||
updateUserSchema,
|
||||
updateUserStatusSchema,
|
||||
createUserSchema,
|
||||
listUsersQuerySchema,
|
||||
} from "./iam.dto.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
@@ -35,6 +39,62 @@ import {
|
||||
type AuthenticatedRequest,
|
||||
extractAuditContext,
|
||||
} from "../middleware/auth.middleware.js";
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
/**
|
||||
* 解析请求中的 refresh_token:优先从 httpOnly cookie 读取,回退到 body(F12 裁决).
|
||||
*/
|
||||
function extractRefreshToken(req: AuthenticatedRequest): string | undefined {
|
||||
// 1. 优先从 httpOnly cookie 读取
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith("refresh_token="));
|
||||
if (match) {
|
||||
return decodeURIComponent(match.split("=")[1] ?? "");
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Set-Cookie 值(F12 裁决:HttpOnly + Secure + SameSite=Strict).
|
||||
*/
|
||||
function buildRefreshTokenCookie(
|
||||
refreshToken: string,
|
||||
maxAgeSeconds: number,
|
||||
): string {
|
||||
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
|
||||
const parts = [
|
||||
`refresh_token=${encodeURIComponent(refreshToken)}`,
|
||||
"HttpOnly",
|
||||
`SameSite=Strict`,
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
`Path=/`,
|
||||
];
|
||||
if (secure) parts.push("Secure");
|
||||
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建清除 cookie 的 Set-Cookie 值.
|
||||
*/
|
||||
function buildClearCookie(): string {
|
||||
const secure = env.COOKIE_SECURE ?? env.NODE_ENV === "production";
|
||||
const parts = [
|
||||
"refresh_token=",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
"Max-Age=0",
|
||||
"Path=/",
|
||||
];
|
||||
if (secure) parts.push("Secure");
|
||||
if (env.COOKIE_DOMAIN) parts.push(`Domain=${env.COOKIE_DOMAIN}`);
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM REST Controller(双入口之 REST 侧)。
|
||||
@@ -47,9 +107,16 @@ export class IamController {
|
||||
async register(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
|
||||
const dto = registerSchema.parse(body);
|
||||
const result = await this.service.register(dto, extractAuditContext(req));
|
||||
// F12 裁决:注册即登录,同样设置 httpOnly cookie
|
||||
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
|
||||
);
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
@@ -57,18 +124,40 @@ export class IamController {
|
||||
async login(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true; data: { user: UserInfo; tokens: TokenPair } }> {
|
||||
const dto = loginSchema.parse(body);
|
||||
const result = await this.service.login(dto, extractAuditContext(req));
|
||||
// F12 裁决:设置 httpOnly cookie 携带 refresh_token
|
||||
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
buildRefreshTokenCookie(result.tokens.refreshToken, maxAgeSeconds),
|
||||
);
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
async refresh(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true; data: TokenPair }> {
|
||||
const dto = refreshTokenSchema.parse(body);
|
||||
const tokens = await this.service.refresh(dto.refreshToken);
|
||||
// F12 裁决:优先从 httpOnly cookie 读取 refresh_token,回退到 body
|
||||
const cookieToken = extractRefreshToken(req);
|
||||
const body_ = refreshTokenSchema.safeParse(body);
|
||||
const refreshToken =
|
||||
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedError("Missing refresh token");
|
||||
}
|
||||
const tokens = await this.service.refresh(refreshToken);
|
||||
// 轮换 cookie 中的 refresh_token
|
||||
const maxAgeSeconds = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10) * 86400;
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
buildRefreshTokenCookie(tokens.refreshToken, maxAgeSeconds),
|
||||
);
|
||||
return { success: true as const, data: tokens };
|
||||
}
|
||||
|
||||
@@ -77,17 +166,22 @@ export class IamController {
|
||||
async logout(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
const dto = logoutSchema.parse(body);
|
||||
// F12 裁决:优先从 cookie 读取 refresh_token,回退到 body
|
||||
const cookieToken = extractRefreshToken(req);
|
||||
const body_ = logoutSchema.safeParse(body);
|
||||
const refreshToken =
|
||||
cookieToken ?? (body_.success ? body_.data.refreshToken : undefined);
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing user identity");
|
||||
}
|
||||
await this.service.logout(
|
||||
dto.refreshToken,
|
||||
userId,
|
||||
extractAuditContext(req),
|
||||
);
|
||||
if (refreshToken) {
|
||||
await this.service.logout(refreshToken, userId, extractAuditContext(req));
|
||||
}
|
||||
// 清除 httpOnly cookie
|
||||
res.setHeader("Set-Cookie", buildClearCookie());
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
|
||||
@@ -223,4 +317,25 @@ export class IamController {
|
||||
);
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
|
||||
@Post("users")
|
||||
@RequirePermission(Permissions.IAM_USER_MANAGE)
|
||||
async createUser(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: UserInfo }> {
|
||||
const dto = createUserSchema.parse(body);
|
||||
const user = await this.service.createUser(dto, extractAuditContext(req));
|
||||
return { success: true as const, data: user };
|
||||
}
|
||||
|
||||
@Delete("users/:id")
|
||||
@RequirePermission(Permissions.IAM_USER_MANAGE)
|
||||
async deleteUser(
|
||||
@Param("id") id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { success: boolean } }> {
|
||||
await this.service.deleteUser(id, extractAuditContext(req));
|
||||
return { success: true as const, data: { success: true } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ export const updateUserStatusSchema = z.object({
|
||||
status: z.enum(["active", "inactive", "locked"]),
|
||||
});
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(72),
|
||||
name: z.string().min(1).max(100),
|
||||
roleId: z.string().uuid().optional(),
|
||||
dataScope: z
|
||||
.enum(["self", "subject", "class", "grade", "school", "all"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const listUsersQuerySchema = z.object({
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
@@ -109,6 +119,7 @@ export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
|
||||
export type UpdateProfileDto = z.infer<typeof updateProfileSchema>;
|
||||
export type UpdateUserDto = z.infer<typeof updateUserSchema>;
|
||||
export type UpdateUserStatusDto = z.infer<typeof updateUserStatusSchema>;
|
||||
export type CreateUserDto = z.infer<typeof createUserSchema>;
|
||||
export type ListUsersQueryDto = z.infer<typeof listUsersQuerySchema>;
|
||||
export type CreateRoleDto = z.infer<typeof createRoleSchema>;
|
||||
export type UpdateRoleDto = z.infer<typeof updateRoleSchema>;
|
||||
|
||||
@@ -125,6 +125,65 @@ export class IamGrpcController {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 管理员用户管理类 ============
|
||||
|
||||
@GrpcMethod("IamService", "CreateUser")
|
||||
async createUser(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
roleId?: string;
|
||||
dataScope?: string;
|
||||
}): Promise<unknown> {
|
||||
const user = await this.service.createUser({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
roleId: data.roleId || undefined,
|
||||
dataScope:
|
||||
(data.dataScope as
|
||||
| "self"
|
||||
| "subject"
|
||||
| "class"
|
||||
| "grade"
|
||||
| "school"
|
||||
| "all"
|
||||
| undefined) ?? undefined,
|
||||
});
|
||||
return this.toUserInfoProto(user);
|
||||
}
|
||||
|
||||
@GrpcMethod("IamService", "UpdateUser")
|
||||
async updateUser(data: {
|
||||
userId: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
dataScope?: string;
|
||||
}): Promise<unknown> {
|
||||
const user = await this.service.updateUser(data.userId, {
|
||||
name: data.name || undefined,
|
||||
email: data.email || undefined,
|
||||
status: data.status || undefined,
|
||||
dataScope:
|
||||
(data.dataScope as
|
||||
| "self"
|
||||
| "subject"
|
||||
| "class"
|
||||
| "grade"
|
||||
| "school"
|
||||
| "all"
|
||||
| undefined) ?? undefined,
|
||||
});
|
||||
return this.toUserInfoProto(user);
|
||||
}
|
||||
|
||||
@GrpcMethod("IamService", "DeleteUser")
|
||||
async deleteUser(data: { userId: string }): Promise<{ success: boolean }> {
|
||||
await this.service.deleteUser(data.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private toUserInfoProto(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
@@ -391,6 +391,29 @@ export class IamRepository {
|
||||
return this.findUserById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬删除用户:清理所有关联数据(角色/密码历史/刷新令牌/TOTP/备份码).
|
||||
* 审计日志保留以满足合规要求。
|
||||
*/
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
// 清理关联数据
|
||||
await db.delete(userRoles).where(eq(userRoles.userId, userId));
|
||||
await db.delete(passwordHistory).where(eq(passwordHistory.userId, userId));
|
||||
await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId));
|
||||
await db.delete(totpBackupCodes).where(eq(totpBackupCodes.userId, userId));
|
||||
await db.delete(userTotp).where(eq(userTotp.userId, userId));
|
||||
// 删除学生-家长关系(作为 guardian 或 student)
|
||||
await db
|
||||
.delete(studentGuardians)
|
||||
.where(eq(studentGuardians.guardianId, userId));
|
||||
await db
|
||||
.delete(studentGuardians)
|
||||
.where(eq(studentGuardians.studentId, userId));
|
||||
// 最后删除用户
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
// ============ 角色 CRUD ============
|
||||
|
||||
async findRoleById(id: string): Promise<Role | undefined> {
|
||||
|
||||
@@ -497,6 +497,125 @@ export class IamService {
|
||||
return this.buildUserInfo(updated!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建用户:由 admin 直接创建账户(非自助注册),可指定角色与数据范围.
|
||||
* 区别于 register():不发 token、不登录,仅创建用户并分配角色.
|
||||
*/
|
||||
async createUser(
|
||||
data: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
roleId?: string;
|
||||
dataScope?: DataScope;
|
||||
},
|
||||
context?: AuditContext,
|
||||
): Promise<UserInfo> {
|
||||
validatePasswordStrength(data.password);
|
||||
|
||||
const existing = await this.repository.findUserByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new ConflictError("Email already registered");
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
const user = await this.repository.createUser({
|
||||
id: userId,
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
name: data.name,
|
||||
dataScope: data.dataScope,
|
||||
});
|
||||
|
||||
const roleId = data.roleId ?? DEFAULT_ROLE_ID;
|
||||
await this.repository.assignRole(userId, roleId);
|
||||
await this.repository.addPasswordHistory(userId, passwordHash);
|
||||
|
||||
// Outbox: UserCreated event
|
||||
await this.outbox.publish(
|
||||
"UserCreated",
|
||||
{
|
||||
event_id: crypto.randomUUID(),
|
||||
aggregate_id: userId,
|
||||
event_type: "UserCreated",
|
||||
occurred_at: Date.now(),
|
||||
user_id: userId,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
roles: [roleId],
|
||||
data_scope: data.dataScope ?? "self",
|
||||
action: "created",
|
||||
metadata: { source: "admin_create" },
|
||||
},
|
||||
{ aggregateId: userId },
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"create",
|
||||
"user",
|
||||
userId,
|
||||
null,
|
||||
{ id: userId, email: data.email, name: data.name, roleId },
|
||||
context,
|
||||
);
|
||||
|
||||
return this.buildUserInfo(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员删除用户:硬删除 + 清理关联数据 + 撤销 token + 发布 UserDeleted 事件.
|
||||
* 审计日志保留以满足合规要求.
|
||||
*/
|
||||
async deleteUser(userId: string, context?: AuditContext): Promise<void> {
|
||||
const user = await this.repository.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundError("User", userId);
|
||||
}
|
||||
|
||||
const beforeState = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
};
|
||||
|
||||
// 撤销所有 token + 清理权限缓存
|
||||
await this.repository.revokeAllUserTokens(userId);
|
||||
await this.permissionCache.invalidate(userId);
|
||||
|
||||
// 硬删除用户及关联数据
|
||||
await this.repository.deleteUser(userId);
|
||||
|
||||
// Outbox: UserDeleted event
|
||||
await this.outbox.publish(
|
||||
"UserDeleted",
|
||||
{
|
||||
event_id: crypto.randomUUID(),
|
||||
aggregate_id: userId,
|
||||
event_type: "UserDeleted",
|
||||
occurred_at: Date.now(),
|
||||
user_id: userId,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
action: "deleted",
|
||||
metadata: { source: "admin_delete" },
|
||||
},
|
||||
{ aggregateId: userId },
|
||||
);
|
||||
|
||||
await this.writeAuditLog(
|
||||
"system",
|
||||
"delete",
|
||||
"user",
|
||||
userId,
|
||||
beforeState,
|
||||
null,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 权限与视口类 ============
|
||||
|
||||
async getEffectivePermissions(userId: string): Promise<string[]> {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../../config/env.js';
|
||||
import { pino } from "pino";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
base: {
|
||||
service: 'iam',
|
||||
version: '0.1.0',
|
||||
service: "iam",
|
||||
version: "0.1.0",
|
||||
},
|
||||
transport:
|
||||
env.NODE_ENV === 'development'
|
||||
env.NODE_ENV === "development"
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true },
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user