9.5 KiB
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(extendsBaseParam):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 JOINsys_user_roleIS 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(列展示"-",搜索按空匹配)。 - 无新外部依赖。