You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

145 lines
9.5 KiB

1 month ago
# PRD — 用户管理:用户列表模块
**Status:** proposed
**Source prototype:** `D:\code\prototype`(React+Antd,dev server http://localhost:5174),页面 `src/pages/system/UserManagePage.tsx`
**Backend module:** `crm-auth`,接入点 `SystemController`(`/api/system/**`)
**Grilling 决策记录:** 2026-08-01,9 题逐项共识(见下"决策摘要")
## 1. 目标
照原型"用户管理"页,在后端补齐**只读用户列表 + 指标卡统计 + 分配角色**能力,使前端用户管理页可对接真实后端。基本信息(姓名/部门/职务/手机号/在职状态)遵从 ADR-0002(OA 唯一来源,CRM 只读),CRM 侧唯一写操作=分配角色。
## 2. 决策摘要(9 题共识)
| # | 决策 | 选项 |
|---|---|---|
| 1 | 模块范围 | 列表+分配+指标卡;OA 入职/离职/复职按钮、移交数据**留后续** |
| 2 | 在职状态数据模型 | `AuthUser` 新增独立字段 `employment_status`(active/resigned),与 `enabled` 并存 |
| 3 | 部门语义 | 对齐后端 主部门+兼职部门+部门集合;部门列显主部门(兼职小标签);子树筛选按"部门集合 ∩ 子树" |
| 4 | OA 只读字段 | `title`(职务)、`account`(账号)都加,nullable,OA 同步接缝 |
| 5 | 列表接口形状 | `POST /api/system/users/page` + `@RequestBody UserPageParam`,后端按 deptId 展开子树 |
| 6 | 指标卡接口 | 独立 `GET /api/system/users/stats?deptId=…` |
| 7 | 访问控制 | 管理员专用,**不在 AuthUser 加 `@DataScope`**,列表按用户选的 deptId 子树返回 |
| 8 | DTO 反范式 | 全反范式:`primaryDeptName` + `partTimeDepts[{id,name}]` + `roles[{id,name}]` |
| 9 | 门禁机制 | 轻量 `@PreAuthorize` 角色门禁;权限码体系延后 |
## 3. 范围(In / Out)
**In:**
- 用户分页查询(部门子树 × 在职状态 × 关键词 × 最后登录时间)
- 四指标卡统计(部门总数 / 在职 / 离职 / 待分配),跟随选中部门子树
- 复用已有 `users/assign-roles`(补门禁)
- `AuthUser` 新增 `employment_status` / `title` / `account` 三只读字段
**Out(显式 no):**
- **OA 入职/离职/复职按钮**——后端无 OA 集成;`employment_status` 字段即未来 OA 同步落地点,按钮替换为同步任务。
- **移交数据**——依赖未建的线索/商机/客户/项目四个业务模块,无法落地。
- **完整权限码体系**(SysMenu 加 `perms`/`apiUrl` + 接口级鉴权 + 权限点管理页)——独立立项(原型 issue 04),本次只到角色门禁。
- **用户 CRUD**(新增/编辑/删除)——ADR-0002:OA 是用户主数据唯一来源,CRM 不造不删用户;离职用户保留(名下可能挂业务数据)。
## 4. 数据模型变更(`AuthUser` / `crm_auth_user`)
新增列(均 nullable,OA 来源只读,CRM 不写):
| 列 | 类型 | 说明 |
|---|---|---|
| `employment_status` | varchar(16) not null default 'active' | 在职(active)/离职(resigned);现有存量数据默认 active |
| `title` | varchar(50) null | 职务(OA) |
| `account` | varchar(50) null | 登录账号(OA),参与列表模糊搜索 |
保留:`enabled`(账号启用,CRM 可禁用,**不等于**在职状态)、`mobile`、`username`、`last_login_time`、`dept_id`(主部门)。兼职部门走既有 `sys_user_dept`
**离职即回收角色**:语义保留(employment_status→resigned 时清空 `sys_user_role`),但**触发方是未来 OA 同步任务,不在本模块实现**;本模块只保证字段与查询就绪。
## 5. API 设计
### 5.1 用户列表
`POST /api/system/users/page`
- 权限:`@PreAuthorize` 管理员角色门禁(意图权限码 `system:user:list`)
- 入参 `UserPageParam`(extends `BaseParam`):
- `current` / `size`(分页)
- `deptId`(Long,可选)——选中部门节点;null=全公司
- `employmentStatus`(String,可选)——`active`/`resigned`;`unassigned` 由前端"待分配"卡走特殊路径(见 5.3)
- `keyword`(String,可选)——模糊匹配 `username` / `account` / `mobile`(忽略大小写首尾空白)
- `loginFrom` / `loginTo`(`LocalDate`,可选)——`last_login_time` 落在 [from 0:00, to 23:59:59];**设定区间时从未登录的用户一律排除**
- 后端过滤逻辑:
- 部门:若 `deptId` 非空,展开为子树(含自身及全部子孙,沿用 `sys_dept.ancestors` 祖级链),取用户**部门集合**(`dept_id` ∪ `sys_user_dept.dept_id`)与子树交集非空者。null 则不限部门。
- 状态/关键词/登录时间:上述规则。
- 返回 `Result<PageResult<UserListDTO>>`
### 5.2 指标卡统计
`GET /api/system/users/stats?deptId=…`
- 权限:`@PreAuthorize` 管理员门禁(意图权限码 `system:user:list`)
- 入参:`deptId`(可选,null=全公司)
- 返回 `Result<UserStatsVO>`:
- `deptCount`——选中节点及其子孙部门数(null 时为全部部门数)
- `activeCount`——子树内在职用户数
- `resignedCount`——子树内离职用户数
- `unassignedCount`——子树内**在职且无角色**用户数(LEFT JOIN `sys_user_role` IS NULL)
- 口径:**只随部门子树变,不受 keyword/登录时间影响**(与列表过滤解耦)
### 5.3 待分配卡片
前端点"待分配"卡 → 调 `users/page`,后端需支持 `employmentStatus=unassigned` 等价于"在职且无角色"的过滤(实现可在 Service 层把 `unassigned` 翻成 `employment_status=active AND NOT EXISTS(sys_user_role)`)。`stats` 的 `unassignedCount` 与此同口径。
### 5.4 复用既有接口
- `POST /api/system/users/assign-roles`(已存在,补 `@PreAuthorize` 门禁;意图权限码 `system:user:assign-roles`)
- `GET /api/system/depts/tree`(左下部门树,已存在)
- `POST /api/system/roles/page`(分配角色弹窗的角色选项,已存在)
## 6. `UserListDTO`(全反范式)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | Long | 用户 id |
| `username` | String | 姓名 |
| `account` | String | 账号(可空) |
| `title` | String | 职务(可空) |
| `mobile` | String | 手机号 |
| `primaryDeptId` | Long | 主部门 id |
| `primaryDeptName` | String | 主部门名 |
| `partTimeDepts` | `List<{id,name}>` | 兼职部门(部门列小标签) |
| `employmentStatus` | String | active/resigned |
| `enabled` | Boolean | 账号是否启用 |
| `roles` | `List<{id,name}>` | 所持角色(空则前端显"无角色") |
| `lastLoginTime` | LocalDateTime | 最后登录时间(null=从未登录) |
后端批量查 dept/role 组装(收集 primaryDeptId + partTimeDeptIds → 批量取 SysDept;收集 roleIds → 批量取 SysRole),避免 N+1。
## 7. 访问控制
- `@EnableMethodSecurity` 已开;给 `users/page`、`users/stats`、`users/assign-roles` 加 `@PreAuthorize`,基于 admin 角色/authority(DataInitializer 已初始化管理员角色)。
- 开发期需核实角色→authority 映射是否就绪;若 JWT 未把角色映射为 authority,则用 Service 层 `SecurityUtils` + `sys_user_role` 判定 admin(实现细节,不改变"管理员专用"决策)。
- **不在 `AuthUser` 上加 `@DataScope`**:用户管理是管理职能,管理员要看全公司;`deptId` 过滤是用户显式选择,非按当前用户数据范围自动收窄。
- 意图权限码(待权限码体系落地后切换):`system:user:list`(page+stats)、`system:user:assign-roles`。
## 8. 行为规格(对齐原型)
- 部门树:根节点平铺,无虚拟"全公司"节点;进入默认选首根节点。选中节点含全部子孙。
- 指标卡"在职/离职/待分配"可点筛选(互斥,再点取消),与搜索区"账号状态"下拉共用同一状态;清空筛选复位。
- 列:姓名(+当前身份/待分配 tag)/部门(主+兼职小标签)/职务/手机号/状态(在职·离职 tag)/所属角色/最后登录时间/操作。
- 行内操作(在职):**分配角色**(弹窗多选,复用 assign-roles);**模拟离职/复职/移交数据均为 Out**,前端如保留按钮则只作占位,后端不提供。
## 9. 错误码
复用 `crm-base``crm-auth` 既有区间(见根 README §7)。本模块不新增业务错误码;参数校验失败走 `@Valid`+全局异常处理(40001);非管理员访问走 403(由 `SecurityExceptionHandlers` 返回)。
## 10. ADR 候选(建议记录,待你确认)
后端 `docs/adr/` 尚不存在;README/AuthUserServiceImpl 引用了 ADR-0003/0004/0006(文件未落地),故新 ADR **从 0007 起编号**以避让。
- **ADR-0007:用户在职状态独立于账号启用,来源于 OA 同步**——`employment_status` 与 `enabled` 分离,理由:OA 管雇佣状态、CRM 管账号启停,二者不可混(离职应禁登录,禁用未必离职)。难拆、非显而易见、有真实取舍 → 满足三条。
- **ADR-0008:用户列表访问控制暂用角色门禁,权限码体系延后**——`@PreAuthorize` 角色门禁先于完整权限码体系(接口级鉴权未落地)。约束未在代码显式、有真实取舍、切换有成本 → 满足三条。
## 11. 测试计划
- `users/page`:① 单部门子树命中主部门用户;② 兼职用户在兼职部门子树命中(部门集合∩子树);③ 状态/关键词/登录时间四道过滤叠加;④ 分页 size 边界;⑤ `employmentStatus=unassigned` 等价在职且无角色;⑥ loginFrom/to 设定时从未登录者排除。
- `users/stats`:四个口径随子树正确;`unassignedCount` 与"在职且无角色"一致;不随 keyword 变。
- `assign-roles`:既有逻辑不变,补门禁后非管理员 → 403。
- `@PreAuthorize`:非管理员调三个端点均 403。
## 12. 依赖与假设
- 依赖 `crm-auth` 既有用户/部门/角色模型;`DataInitializer` 管理员角色;`crm-base` 的 `BaseParam`/`PageResult`/`Result`/`SecurityUtils`/`AssertUtils`。
- 假设:存量 `crm_auth_user` 行迁移时 `employment_status` 默认 active、`title`/`account` 为 null(列展示"-",搜索按空匹配)。
- 无新外部依赖。