feat:优化一下用户查询

This commit is contained in:
dzc committed 2026-08-24 10:29:04 +08:00
1 parent dbf48af330
commit 8d31f20cec
9 files changed
+366 -43

No files matched your search

@@ -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;
@@ -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<SysPost> {
*/
List<SysPost> listPostsByUserId(Long userId);
/**
* 批量查询当前分页用户的岗位,返回用户 ID 与岗位资料的扁平行,避免逐用户嵌套查询。
*
* @param userIds 当前页用户主键列表;为空时不应调用
* @return 按用户 ID、岗位排序值和岗位 ID 升序返回岗位资料,未分配岗位的用户不产生记录
*/
List<UserPostPageRow> listPostsByUserIds(@Param("userIds") List<Long> userIds);
}
@@ -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<SysRole> {
*/
List<SysRole> listRolesByUserId(Long userId);
/**
* 批量查询当前分页用户的角色,返回用户 ID 与角色资料的扁平行,避免逐用户嵌套查询。
*
* @param userIds 当前页用户主键列表;为空时不应调用
* @return 按用户 ID、角色 ID 升序返回角色资料,未分配角色的用户不产生记录
*/
List<UserRolePageRow> listRolesByUserIds(@Param("userIds") List<Long> userIds);
}
@@ -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;
/**
* 用户分页岗位批量查询的扁平结果行。
*
* <p>该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。</p>
*/
@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;
}
@@ -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;
/**
* 用户分页角色批量查询的扁平结果行。
*
* <p>该类型仅服务于 Mapper 到 Service 的批量回填,不作为对外接口模型,避免为分页结果增加嵌套查询。</p>
*/
@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;
}
@@ -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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
List<Long> 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<SysUserFaceVo> faceList = sysUserFaceMapper.groupConcatFaces(faceDto);
DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId));
}
// 审批流程
if (userDTO.getIsFindCheckUser()) {
List<String> processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(processInstanceIds)) {
List<TaskDto> taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds);
if (ObjectUtil.isNotEmpty(taskList)) {
Map<String, TaskDto> 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<SysUserMapper, SysUser> impl
}
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
List<Long> 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<SysUserMapper, SysUser> impl
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, null);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
List<Long> 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<SysUserFaceVo> faceList = sysUserFaceMapper.groupConcatFaces(faceDto);
DataUtil.convertFacesList(list.getRecords(), CollectionUtils.convertMap(faceList, SysUserFaceVo::getUserId));
}
// 审批流程
if (userDTO.getIsFindCheckUser()) {
List<String> processInstanceIds = list.getRecords().stream().map(UserVO::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(processInstanceIds)) {
List<TaskDto> taskList = flowUtil.queryProcessInstanceList(SecurityUtils.getUser().getId().toString(), processInstanceIds);
if (ObjectUtil.isNotEmpty(taskList)) {
Map<String, TaskDto> 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<UserVO> userVos, List<Long> userIds) {
if (CollUtil.isEmpty(userVos) || CollUtil.isEmpty(userIds)) {
return;
}
Map<Long, List<SysRole>> 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<Long, List<SysPost>> 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())));
}
}
/**
* 查询当前操作人可处理的审核中用户流程,并回填列表的可审批标记。
*
* <p>用户业务状态不是“审核中”时不可能存在待处理任务,因此不把已通过、已拒绝或已取消流程发送到远程流程服务。</p>
*
* @param userVos 当前页用户展示对象;无认证操作人或无审核中流程时不调用远程服务
*/
private void fillPendingApprovalFlags(List<UserVO> userVos) {
PjcloudUser operator = SecurityUtils.getUser();
if (operator == null) {
return;
}
List<String> 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<TaskDto> taskList = flowUtil.queryProcessInstanceList(operator.getId().toString(), processInstanceIds);
if (CollUtil.isEmpty(taskList)) {
return;
}
Map<String, TaskDto> taskMap = CollectionUtils.convertMap(taskList, TaskDto::getProcessInstanceId);
userVos.forEach(userVo -> userVo.setIsCheckUser(taskMap.containsKey(userVo.getProcessId())));
}
/**
* 通过ID查询用户信息
*
@@ -50,4 +50,25 @@
and up.user_id = #{userId}
</select>
<!-- 按当前页用户一次性读取岗位,避免用户分页结果触发 N+1 岗位查询。 -->
<select id="listPostsByUserIds" resultType="com.bsd.pjcloud.admin.mapper.vo.UserPostPageRow">
SELECT up.user_id,
p.post_id,
p.post_code,
p.post_name,
p.post_sort,
p.del_flag,
p.create_time,
p.update_time,
p.remark
FROM sys_user_post up
INNER JOIN sys_post p ON p.post_id = up.post_id
WHERE p.del_flag = '0'
AND up.user_id IN
<foreach collection="userIds" item="userId" open="(" close=")" separator=",">
#{userId}
</foreach>
ORDER BY up.user_id, p.post_sort, p.post_id
</select>
</mapper>
@@ -50,4 +50,26 @@
AND sys_role.del_flag = '0'
and sys_user_role.user_id = #{userId}
</select>
<!-- 按当前页用户一次性读取角色,避免用户分页结果触发 N+1 角色查询。 -->
<select id="listRolesByUserIds" resultType="com.bsd.pjcloud.admin.mapper.vo.UserRolePageRow">
SELECT ur.user_id,
r.role_id,
r.role_name,
r.role_code,
r.role_desc,
r.ds_type,
r.ds_scope,
r.create_time,
r.update_time,
r.del_flag
FROM sys_user_role ur
INNER JOIN sys_role r ON r.role_id = ur.role_id
WHERE r.del_flag = '0'
AND ur.user_id IN
<foreach collection="userIds" item="userId" open="(" close=")" separator=",">
#{userId}
</foreach>
ORDER BY ur.user_id, r.role_id
</select>
</mapper>
@@ -208,7 +208,11 @@
u.user_id = #{id} and u.del_flag = '0'
</select>
<select id="getUserVosPage" resultMap="baseResultMap">
<!--
分页主查询只映射用户主资料;角色和岗位由 Service 按当前页用户 ID 批量回填。
这里不能使用带嵌套 select 的 baseResultMap,否则每条用户记录都会额外查询一次角色和岗位。
-->
<select id="getUserVosPage" resultType="com.bsd.pjcloud.admin.api.vo.UserVO">
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
<where>
u.del_flag = '0'
<if test="query.userId != null">
@@ -293,10 +296,15 @@
</choose>
</if>
<if test="query.role != null and query.role.size() != 0">
and urole.role_id IN
<foreach collection="query.role" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
AND EXISTS (
SELECT 1
FROM sys_user_role user_role
WHERE user_role.user_id = u.user_id
AND user_role.role_id IN
<foreach collection="query.role" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
)
</if>
<if test="query.deptIds != null and query.deptIds.size() != 0">
<choose>
@@ -329,7 +337,6 @@
</foreach>
</if>
</where>
group by u.user_id
<if test="_databaseId != 'mssql'">
ORDER BY u.create_time DESC, u.user_id DESC
</if>