# 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>` ### 5.2 指标卡统计 `GET /api/system/users/stats?deptId=…` - 权限:`@PreAuthorize` 管理员门禁(意图权限码 `system:user:list`) - 入参:`deptId`(可选,null=全公司) - 返回 `Result`: - `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(列展示"-",搜索按空匹配)。 - 无新外部依赖。