diff --git a/db/20260824_optimize_sys_user_page_indexes.sql b/db/20260824_optimize_sys_user_page_indexes.sql new file mode 100644 index 00000000..9718c431 --- /dev/null +++ b/db/20260824_optimize_sys_user_page_indexes.sql @@ -0,0 +1,111 @@ +-- 系统用户分页查询索引优化。 +-- +-- 适用接口:POST /admin/user/page、POST /admin/user/select。 +-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间会消耗 IO,建议在业务低峰期执行。 +-- 执行前建议:先备份数据库,并使用文末查询核对生产库已有索引及 EXPLAIN 计划。 + +-- 用户分页的默认排序索引:覆盖租户、逻辑删除和 create_time + user_id 稳定倒序分页。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_created' + ), + 'SELECT ''idx_sys_user_tenant_del_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_created` (`tenant_id`, `del_flag`, `create_time`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 部门数据权限和用户状态分页索引:先缩小部门及审核状态范围,再读取稳定排序列。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'idx_sys_user_tenant_del_dept_status_created' + ), + 'SELECT ''idx_sys_user_tenant_del_dept_status_created already exists'' AS message', + 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_dept_status_created` (`tenant_id`, `del_flag`, `dept_id`, `process_status`, `create_time`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 角色反向筛选索引:现有主键通常为 user_id + role_id,本索引用于从角色快速定位用户。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_role' + AND index_name = 'idx_sys_user_role_role_user' + ), + 'SELECT ''idx_sys_user_role_role_user already exists'' AS message', + 'ALTER TABLE `sys_user_role` ADD INDEX `idx_sys_user_role_role_user` (`role_id`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 当前页人脸聚合及有无人脸 EXISTS 筛选索引,避免按用户查询时回表扫描租户全部人脸。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_face' + AND index_name = 'idx_sys_user_face_tenant_user_del' + ), + 'SELECT ''idx_sys_user_face_tenant_user_del already exists'' AS message', + 'ALTER TABLE `sys_user_face` ADD INDEX `idx_sys_user_face_tenant_user_del` (`tenant_id`, `user_id`, `del_flag`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 身份类型反向筛选索引:支持 identity_type 条件先定位用户,用户 ID 同时用于半连接。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_identity' + AND index_name = 'idx_sys_user_identity_type_user' + ), + 'SELECT ''idx_sys_user_identity_type_user already exists'' AS message', + 'ALTER TABLE `sys_user_identity` ADD INDEX `idx_sys_user_identity_type_user` (`identity_type`, `user_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 数据权限计算会读取当前租户有效部门并在内存构建子树,本索引降低部门全集读取成本。 +SET @index_ddl = IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_dept' + AND index_name = 'idx_sys_dept_tenant_del_parent' + ), + 'SELECT ''idx_sys_dept_tenant_del_parent already exists'' AS message', + 'ALTER TABLE `sys_dept` ADD INDEX `idx_sys_dept_tenant_del_parent` (`tenant_id`, `del_flag`, `parent_id`, `dept_id`)' +); +PREPARE index_stmt FROM @index_ddl; +EXECUTE index_stmt; +DEALLOCATE PREPARE index_stmt; + +-- 执行后核对新增索引;确认无重复或同前缀冗余索引后,再按生产变更流程决定是否清理旧索引。 +SELECT table_name, index_name, seq_in_index, column_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name IN ('sys_user', 'sys_user_role', 'sys_user_face', 'sys_user_identity', 'sys_dept') +ORDER BY table_name, index_name, seq_in_index; + +-- 建议在执行索引后更新统计信息,并用真实租户、部门和状态参数执行 EXPLAIN ANALYZE。 +-- ANALYZE TABLE sys_user, sys_user_role, sys_user_face, sys_user_identity, sys_dept; diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java index f875cb1c..c020ea5d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysPostMapper.java @@ -18,8 +18,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -39,4 +41,12 @@ public interface SysPostMapper extends PjcloudBaseMapper { */ List listPostsByUserId(Long userId); + /** + * 批量查询当前分页用户的岗位,返回用户 ID 与岗位资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、岗位排序值和岗位 ID 升序返回岗位资料,未分配岗位的用户不产生记录 + */ + List listPostsByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java index fd852b90..7632019e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysRoleMapper.java @@ -20,8 +20,10 @@ package com.bsd.pjcloud.admin.mapper; import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -43,4 +45,12 @@ public interface SysRoleMapper extends PjcloudBaseMapper { */ List listRolesByUserId(Long userId); + /** + * 批量查询当前分页用户的角色,返回用户 ID 与角色资料的扁平行,避免逐用户嵌套查询。 + * + * @param userIds 当前页用户主键列表;为空时不应调用 + * @return 按用户 ID、角色 ID 升序返回角色资料,未分配角色的用户不产生记录 + */ + List listRolesByUserIds(@Param("userIds") List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java new file mode 100644 index 00000000..2bf0a0e1 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserPostPageRow.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页岗位批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserPostPageRow { + + /** 当前用户主键,来源于 sys_user_post.user_id。 */ + private Long userId; + + /** 岗位主键,来源于 sys_post.post_id。 */ + private Long postId; + + /** 岗位编码,来源于 sys_post.post_code。 */ + private String postCode; + + /** 岗位名称,来源于 sys_post.post_name。 */ + private String postName; + + /** 岗位排序值,来源于 sys_post.post_sort。 */ + private Integer postSort; + + /** 岗位删除标记,来源于 sys_post.del_flag。 */ + private String delFlag; + + /** 岗位创建时间,来源于 sys_post.create_time。 */ + private LocalDateTime createTime; + + /** 岗位更新时间,来源于 sys_post.update_time。 */ + private LocalDateTime updateTime; + + /** 岗位备注,来源于 sys_post.remark。 */ + private String remark; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java new file mode 100644 index 00000000..299d9367 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/vo/UserRolePageRow.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2025, pjcloud All rights reserved. + */ + +package com.bsd.pjcloud.admin.mapper.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户分页角色批量查询的扁平结果行。 + * + *

该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。

+ */ +@Data +public class UserRolePageRow { + + /** 当前用户主键,来源于 sys_user_role.user_id。 */ + private Long userId; + + /** 角色主键,来源于 sys_role.role_id。 */ + private Long roleId; + + /** 角色名称,来源于 sys_role.role_name。 */ + private String roleName; + + /** 角色编码,来源于 sys_role.role_code。 */ + private String roleCode; + + /** 角色描述,来源于 sys_role.role_desc。 */ + private String roleDesc; + + /** 数据权限类型,来源于 sys_role.ds_type。 */ + private Integer dsType; + + /** 自定义数据权限范围,来源于 sys_role.ds_scope。 */ + private String dsScope; + + /** 角色创建时间,来源于 sys_role.create_time。 */ + private LocalDateTime createTime; + + /** 角色更新时间,来源于 sys_role.update_time。 */ + private LocalDateTime updateTime; + + /** 角色删除标记,来源于 sys_role.del_flag。 */ + private String delFlag; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 3bebaef5..7996b942 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -51,8 +51,12 @@ import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent; import com.bsd.pjcloud.admin.handler.FaceChangeEvent; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.mapper.SysPostMapper; +import com.bsd.pjcloud.admin.mapper.SysRoleMapper; import com.bsd.pjcloud.admin.mapper.SysUserPostMapper; import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper; +import com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow; +import com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow; import com.bsd.pjcloud.admin.service.*; import com.bsd.pjcloud.admin.utils.flow.FlowUtil; import com.bsd.pjcloud.admin.utils.flow.dto.UserApplyFlowDto; @@ -141,6 +145,12 @@ public class SysUserServiceImpl extends ServiceImpl impl private final SysUserPostMapper sysUserPostMapper; + /** 角色批量查询 Mapper,用于按当前分页用户一次性回填角色。 */ + private final SysRoleMapper sysRoleMapper; + + /** 岗位批量查询 Mapper,用于按当前分页用户一次性回填岗位。 */ + private final SysPostMapper sysPostMapper; + private final CacheManager cacheManager; private final StringRedisTemplate redisTemplate; @@ -338,29 +348,18 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); // 分页结果统一补齐身份及扩展资料,保证管理端列表与详情接口使用同一套身份语义。 sysUserIdentityService.fillUserIdentities(list.getRecords()); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + // 人脸聚合必须限定为当前页,禁止每次分页扫描并传输当前租户的全部人脸。 + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; @@ -408,6 +407,8 @@ public class SysUserServiceImpl extends ServiceImpl impl } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { + List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); sysUserIdentityService.fillUserIdentities(list.getRecords()); } return list; @@ -418,32 +419,80 @@ public class SysUserServiceImpl extends ServiceImpl impl IPage list = baseMapper.getUserVosPage(page, userDTO, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { List userIds = list.getRecords().stream().map(UserVO::getUserId).distinct().toList(); + fillUserRolesAndPosts(list.getRecords(), userIds); if (ObjectUtil.isNotEmpty(userIds)) { SysUserFaceDto faceDto = new SysUserFaceDto(); + faceDto.setUserIds(userIds); List faceList = sysUserFaceMapper.groupConcatFaces(faceDto); DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId)); } - // 审批流程 - if (userDTO.getIsFindCheckUser()) { - List processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(processInstanceIds)) { - List taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds); - if (ObjectUtil.isNotEmpty(taskList)) { - Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); - CollectionUtils.convertList(list.getRecords(), v -> { - TaskDto task = taskMap.get(v.getProcessId()); - if (ObjectUtil.isNotEmpty(task)) { - v.setIsCheckUser(true); - } - return v; - }); - } - } + if (Boolean.TRUE.equals(userDTO.getIsFindCheckUser())) { + fillPendingApprovalFlags(list.getRecords()); } } return list; } + /** + * 按当前页用户批量回填角色和岗位,将原先随分页条数线性增长的嵌套查询固定为两次查询。 + * + * @param userVos 当前页用户展示对象,方法会原位设置角色和岗位集合 + * @param userIds 当前页去重后的用户主键;为空时不访问数据库 + */ + private void fillUserRolesAndPosts(List userVos, List userIds) { + if (CollUtil.isEmpty(userVos) || CollUtil.isEmpty(userIds)) { + return; + } + + Map> rolesByUserId = new HashMap<>(); + for (UserRolePageRow row : sysRoleMapper.listRolesByUserIds(userIds)) { + SysRole role = new SysRole(); + BeanUtils.copyProperties(row, role); + rolesByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(role); + } + + Map> postsByUserId = new HashMap<>(); + for (UserPostPageRow row : sysPostMapper.listPostsByUserIds(userIds)) { + SysPost post = new SysPost(); + BeanUtils.copyProperties(row, post); + postsByUserId.computeIfAbsent(row.getUserId(), ignored -> new ArrayList<>()).add(post); + } + + for (UserVO userVo : userVos) { + userVo.setRoleList(new ArrayList<>(rolesByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + userVo.setPostList(new ArrayList<>(postsByUserId.getOrDefault(userVo.getUserId(), Collections.emptyList()))); + } + } + + /** + * 查询当前操作人可处理的审核中用户流程,并回填列表的可审批标记。 + * + *

用户业务状态不是“审核中”时不可能存在待处理任务,因此不把已通过、已拒绝或已取消流程发送到远程流程服务。

+ * + * @param userVos 当前页用户展示对象;无认证操作人或无审核中流程时不调用远程服务 + */ + private void fillPendingApprovalFlags(List userVos) { + PjcloudUser operator = SecurityUtils.getUser(); + if (operator == null) { + return; + } + List processInstanceIds = userVos.stream() + .filter(userVo -> ObjectUtil.equals(userVo.getProcessStatus(), TaskCheckStatusEnum.PADDING.getCode())) + .map(UserVO::getProcessId) + .filter(ObjectUtil::isNotEmpty) + .distinct() + .toList(); + if (CollUtil.isEmpty(processInstanceIds)) { + return; + } + List taskList = flowUtil.queryProcessInstanceList(operator.getId().toString(), processInstanceIds); + if (CollUtil.isEmpty(taskList)) { + return; + } + Map taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId); + userVos.forEach(userVo -> userVo.setIsCheckUser(taskMap.containsKey(userVo.getProcessId()))); + } + /** * 通过ID查询用户信息 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml index 4dc219f5..b66b155c 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysPostMapper.xml @@ -50,4 +50,25 @@ and up.user_id = #{userId} + + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml index ca8c1a82..3e37c1e7 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysRoleMapper.xml @@ -50,4 +50,26 @@ AND sys_role.del_flag = '0' and sys_user_role.user_id = #{userId} + + + diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml index bc0634f3..edd7d48b 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml @@ -208,7 +208,11 @@ u.user_id = #{id} and u.del_flag = '0' - SELECT u.user_id, u.process_id, @@ -224,14 +228,14 @@ u.wx_pc_openid, u.apple_openid, u.qq_openid, - u.gitee_login, - u.osc_id, + u.gitee_login gitee_open_id, + u.osc_id osc_open_id, u.wx_cp_userid, u.wx_ding_userid, u.dept_id, u.create_by, - u.create_time ucreate_time, - u.update_time uupdate_time, + u.create_time create_time, + u.update_time update_time, u.del_flag, u.lock_flag, u.password_expire_flag, @@ -248,7 +252,6 @@ FROM sys_user u LEFT JOIN sys_dept d ON d.dept_id = u.dept_id - LEFT JOIN sys_user_role urole ON urole.user_id = u.user_id u.del_flag = '0' @@ -293,10 +296,15 @@ - and urole.role_id IN - - #{v} - + AND EXISTS ( + SELECT 1 + FROM sys_user_role user_role + WHERE user_role.user_id = u.user_id + AND user_role.role_id IN + + #{v} + + ) @@ -329,7 +337,6 @@ - group by u.user_id ORDER BY u.create_time DESC, u.user_id DESC