Files
UserBaseService/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
T
chenxing a91dacd442
ci-userbase-service / build-and-push (push) Successful in 2m9s
推送订阅更改
2026-08-19 17:35:55 +08:00

1036 lines
44 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.model.push.PushInterfaceType;
import org.springframework.context.ApplicationEventPublisher;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import com.yunhai.userbase.model.sysuser.ExcelSysUser;
import com.yunhai.userbase.utils.EasyPoiUtils;
import com.yunhai.userbase.utils.ValidatorUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.*;
import org.springframework.web.multipart.MultipartFile;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletResponse;
/**
* @author chenx
* @date 2026/7/20 16:14
* @description 系统用户服务实现
*/
@Service
public class SysUserService implements ISysUserService {
/** 主归属标识:1=本职主学校/部门 */
private static final int IS_MAIN = 1;
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
private static final int SYSTEM_USER_ID = 0;
/** 系统内置超级管理员账号(与 sys_user_init.sql 种子账号保持一致) */
private static final Set<String> SUPER_ADMIN_ACCOUNTS =
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
@Transactional
public Integer add(SysUserRequest request) {
// 单条新增走完整校验(含密码强度)
return createUser(request, true);
}
/**
* 创建用户并写入组关联。
*
* @param enforcePasswordStrength 是否强制密码强度校验:
* - 单条新增 = true(前端已交互式校验);
* - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。
*/
private Integer createUser(SysUserRequest request, boolean enforcePasswordStrength) {
if (sysUserRepository.countByAccount(request.getAccount()) > 0) {
throw new DException("账号已存在");
}
if (!sysGroupRepository.existsById(request.getGroupId())) {
throw new DException("所属分组不存在");
}
validateProfile(request);
String encodedPassword = enforcePasswordStrength
? validateAndEncodePassword(request.getPassword(), request.getConfirmPassword())
: passwordEncoder.encode(request.getPassword());
SysUser entity = new SysUser();
entity.setAccount(request.getAccount());
entity.setPassword(encodedPassword);
entity.setRealName(request.getRealName());
entity.setPhone(request.getPhone());
entity.setBirthday(request.getBirthday());
entity.setIdcard(request.getIdcard());
entity.setEmail(request.getEmail());
entity.setGender(request.getGender());
entity.setProfilePicture(request.getProfilePicture());
entity.setRoles(request.getRoles());
// 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
// disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用)
entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL);
entity.setExpired(request.getExpired() != null ? request.getExpired() : false);
// oapi 写操作无登录态时,用系统哨兵值兜底 create_user/update_user(NOT NULL 约束)
entity.setCreateUser(SYSTEM_USER_ID);
entity.setUpdateUser(SYSTEM_USER_ID);
SysUser saved = sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.ADD_USER, saved.getId());
// 写入组用户关联
SysUserGroupRel rel = new SysUserGroupRel();
rel.setUserId(entity.getId());
rel.setGroupId(request.getGroupId());
rel.setIsMain(IS_MAIN);
rel.setCreateTime(LocalDateTime.now());
sysUserGroupRelRepository.save(rel);
return saved.getId();
}
@Override
@Transactional
public void edit(Integer id, SysUserRequest request) {
if (sysUserRepository.countByAccountAndIdNot(request.getAccount(), id) > 0) {
throw new DException("账号已被其他用户使用");
}
SysUser entity = sysUserRepository.findById(id)
.orElseThrow(() -> new DException("系统用户不存在"));
validateProfile(request);
entity.setAccount(request.getAccount());
// 密码非空才修改:需两次一致 + 弱密码校验 + BCrypt 加密
if (StringUtils.isNotBlank(request.getPassword())) {
entity.setPassword(validateAndEncodePassword(request.getPassword(), request.getConfirmPassword()));
}
entity.setRealName(request.getRealName());
entity.setPhone(request.getPhone());
entity.setBirthday(request.getBirthday());
entity.setIdcard(request.getIdcard());
entity.setEmail(request.getEmail());
entity.setGender(request.getGender());
entity.setProfilePicture(request.getProfilePicture());
entity.setRoles(request.getRoles());
// 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0
if (request.getRoles() != null) {
entity.setType(resolveUserType(entity.getAccount(), entity.getRoles()));
}
if (request.getDisabled() != null) {
entity.setDisabled(resolveStatus(request.getDisabled()));
}
if (request.getExpired() != null) {
entity.setExpired(request.getExpired());
}
entity.setUpdateUser(SYSTEM_USER_ID);
sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.UPDATE_USER, id);
}
@Override
@Transactional
public List<Integer> batchAdd(List<SysUserRequest> requests) {
if (requests == null || requests.isEmpty()) {
throw new DException("批量新增用户列表不能为空");
}
// 批量新增与单条一致:强制密码强度校验(每条需携带 password + confirmPassword)
List<Integer> ids = new ArrayList<>();
for (SysUserRequest request : requests) {
ids.add(createUser(request, true));
}
return ids;
}
@Override
@Transactional
public void deleteFromGroup(Integer userId, Integer groupId) {
batchDeleteFromGroup(Collections.singletonList(userId), groupId);
}
@Override
@Transactional
public void batchDeleteFromGroup(List<Integer> userIds, Integer groupId) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待删除用户不能为空");
}
if (groupId == null) {
throw new DException("组ID不能为空");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
for (Integer userId : userIds) {
// 删除当前组关联
sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId);
// 若用户已无其它组关联,则级联删除用户本身及其角色绑定
if (sysUserGroupRelRepository.countByUserId(userId) == 0) {
sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
sysUserRepository.deleteById(userId);
}
}
// 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER
// 因是「解除组关系」,需携带 (userId, groupId) 供子应用精确处理
publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId);
}
@Override
@Transactional
public void changeStatus(Integer id, boolean disabled) {
SysUser entity = sysUserRepository.findById(id)
.orElseThrow(() -> new DException("系统用户不存在"));
// 实体 disabled 字段为 StatusEnum(0=正常/未禁用,1=禁用):boolean 直接映射枚举
entity.setDisabled(disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL);
sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.UPDATE_USER, id);
}
@Override
@Transactional
public void batchChangeStatus(List<Integer> userIds, boolean disabled) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待设置用户不能为空");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
StatusEnum targetStatus = disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL;
for (SysUser user : users) {
user.setDisabled(targetStatus);
}
sysUserRepository.saveAll(users);
publishUserEvent(PushInterfaceType.UPDATE_USER,
users.stream().map(SysUser::getId).collect(Collectors.toList()));
}
@Override
@Transactional
public void changeSortNo(Integer userId, Integer groupId, Integer sortNo) {
if (!sysUserRepository.existsById(userId)) {
throw new DException("系统用户不存在");
}
// 排序号按组维度存储,必须落到具体的 (userId, groupId) 关联行
SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId)
.orElseThrow(() -> new DException("用户不在该组,无法设置排序号"));
rel.setSortNo(sortNo);
sysUserGroupRelRepository.save(rel);
}
@Override
public Page<SysUserVo> list(SysUserQuery query, Pageable pageable) {
// 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页,
// 规避 MySQL 的 DISTINCT+ORDER BY 限制以及 Hibernate 不支持 ORDER BY 标量子查询的限制。
if (query.getGroupId() != null) {
return listByGroup(query, pageable);
}
// 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。
Specification<SysUser> spec = buildSpecification(query);
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
return page.map(u -> convertToVo(u, null));
}
/**
* 组上下文列表:原生 SQL 分页。<br>
* 思路:INNER JOIN 关联表后按 user_id 聚合(GROUP BY u.id),用 MIN(sort_no) 作为排序键
* (NULLS LAST + sort_no 升序 + user_id 兜底升序)。GROUP BY 天然去重,既无 DISTINCT+ORDER BY 冲突,
* 也无 ORDER BY 标量子查询(Hibernate 5.6 不支持),分页/排序语义与「组内自定义排序」完全一致。
* 同页的 (userId, minSortNo) 一并查出,省去二次查询。
*/
private Page<SysUserVo> listByGroup(SysUserQuery query, Pageable pageable) {
List<Integer> groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub());
String name = StringUtils.isNotBlank(query.getName()) ? query.getName().trim() : null;
Integer disabledKey = query.getDisabled();
Page<Object[]> idPage = sysUserRepository.findGroupUserPage(
groupIds, name, disabledKey,
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()));
List<Object[]> rows = idPage.getContent();
if (rows.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
// 保持 DB 返回的排序顺序,并记录每个用户的「组范围内最小 sort_no」
List<Integer> orderedIds = new ArrayList<>(rows.size());
Map<Integer, Integer> sortNoMap = new LinkedHashMap<>();
for (Object[] row : rows) {
Integer userId = ((Number) row[0]).intValue();
Object minObj = row[1]; // MIN(sort_no) 可能为空(该用户在组范围内均未设置排序号)
Integer sortNo = minObj == null ? null : ((Number) minObj).intValue();
orderedIds.add(userId);
sortNoMap.put(userId, sortNo);
}
// 按有序 ID 取实体(findAllById 返回顺序不可靠,需按 orderedIds 重排)
List<SysUser> users = sysUserRepository.findAllById(orderedIds);
Map<Integer, SysUser> userMap = users.stream()
.collect(Collectors.toMap(SysUser::getId, u -> u, (a, b) -> a));
List<SysUserVo> voList = new ArrayList<>(orderedIds.size());
for (Integer id : orderedIds) {
SysUser u = userMap.get(id);
if (u != null) {
voList.add(convertToVo(u, sortNoMap.get(id)));
}
}
return new PageImpl<>(voList, pageable, idPage.getTotalElements());
}
@Override
public List<Integer> findGroupIdsByUserId(Integer userId) {
return sysUserGroupRelRepository.findByUserId(userId).stream()
.map(SysUserGroupRel::getGroupId)
.collect(Collectors.toList());
}
@Override
@Transactional
public void changeGroups(List<Integer> userIds, List<Integer> groupIds) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待调整用户不能为空");
}
if (groupIds == null || groupIds.isEmpty()) {
throw new DException("请至少保留一个组");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
List<SysGroup> groups = sysGroupRepository.findAllById(groupIds);
if (groups.size() != groupIds.size()) {
throw new DException("存在无效分组");
}
LocalDateTime now = LocalDateTime.now();
for (Integer userId : userIds) {
sysUserGroupRelRepository.deleteByUserId(userId);
List<SysUserGroupRel> rels = new ArrayList<>(groupIds.size());
for (int i = 0; i < groupIds.size(); i++) {
SysUserGroupRel rel = new SysUserGroupRel();
rel.setUserId(userId);
rel.setGroupId(groupIds.get(i));
rel.setIsMain(i == 0 ? IS_MAIN : 0);
rel.setCreateTime(now);
rels.add(rel);
}
sysUserGroupRelRepository.saveAll(rels);
}
publishUserEvent(PushInterfaceType.UPDATE_USER, userIds);
}
@Override
@Transactional
public void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId) {
if (userId == null) {
throw new DException("用户ID不能为空");
}
if (groupId == null) {
throw new DException("目标组ID不能为空");
}
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
if (!sysUserRepository.existsById(userId)) {
throw new DException("系统用户不存在");
}
if (!sysGroupRepository.existsById(groupId)) {
throw new DException("目标分组不存在");
}
if (!sysGroupRepository.existsById(schoolId)) {
throw new DException("学校分组不存在");
}
// 学校子树范围(含学校自身):用户与目标组都必须落在此范围内,否则视为跨校移动
List<Integer> schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId);
// ① 用户当前必须已在该学校内(至少一个组归属落在 schoolScope)
List<Integer> userGroupIds = findGroupIdsByUserId(userId);
boolean userInSchool = userGroupIds.stream().anyMatch(schoolScope::contains);
if (!userInSchool) {
throw new DException("用户不在该学校范围内,禁止跨校移动");
}
// ② 目标组必须也在该学校内
if (!schoolScope.contains(groupId)) {
throw new DException("目标组不属于该学校范围,禁止跨校移动");
}
// 复用覆盖式改组逻辑(目标设为主组)
changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId));
}
@Override
@Transactional(rollbackFor = Exception.class)
public BatchImportResult batchImport(MultipartFile file) {
BatchImportResult result = new BatchImportResult();
if (file == null || file.isEmpty()) {
throw new DException("上传文件不能为空");
}
Workbook workbook = null;
try {
workbook = WorkbookFactory.create(file.getInputStream());
Sheet sheet = workbook.getSheetAt(0);
int lastRowNum = sheet.getLastRowNum();
for (int i = 1; i <= lastRowNum; i++) {
Row row = sheet.getRow(i);
if (row == null || isBlankRow(row)) {
continue;
}
try {
SysUserRequest request = parseImportRow(row);
createUser(request, false);
result.addSuccess();
} catch (DException e) {
result.addFail(i + 1, e.getMessage());
} catch (Exception e) {
result.addFail(i + 1, "系统异常:" + e.getMessage());
}
}
// 仅统计实际参与解析的数据行(跳过空行),使 总数 = 成功 + 失败
result.setTotal(result.getSuccess() + result.getFail());
} catch (Exception e) {
throw new DException("文件读取失败:" + e.getMessage());
} finally {
if (workbook != null) {
try {
workbook.close();
} catch (IOException ignored) {
// 关闭资源失败不阻断主流程
}
}
}
return result;
}
// ===================== 导入模板下载 =====================
/**
* 导入/导出共用的列定义(顺序即 Excel 列顺序)。<br>
* 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。
*/
private static final List<ImportColumnDef> IMPORT_COLUMN_DEFS = Arrays.asList(
new ImportColumnDef("groupName", "组名\n(不必填,供与id对应参考用)", 36, false, null),
new ImportColumnDef("groupId", "组id(必须)", 14, true, null),
new ImportColumnDef("account", "用户名(必须)", 20, true, null),
new ImportColumnDef("password", "密码\n(可选,\n默认Vzuk4Dp3)", 18, false, ""),
new ImportColumnDef("realName", "姓名(必须)", 16, true, null),
new ImportColumnDef("idcard", "身份证号(可选)", 22, false, null),
new ImportColumnDef("phone", "手机号(可选)", 16, false, null),
new ImportColumnDef("email", "email(可选)", 24, false, null),
new ImportColumnDef("nature", "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", 24, false, "0"),
new ImportColumnDef("identity", "用户身份\n(1教师 2校领导 3学生 4家长)", 24, false, null),
new ImportColumnDef("gender", "性别(可选)", 12, false, null),
new ImportColumnDef("birthday", "生日(yyyy-MM-dd)(可选)", 22, false, null)
);
/** 组织树虚拟根 parentId(与 SysGroupService 约定一致)。 */
private static final int ROOT_PARENT_ID = 0;
/**
* 下载用户导入模板:以指定组(含)为根,将其自身及所有子孙组平铺为数据行,
* 仅填充「组名(路径)」与「组id」两列,其余列为空供用户填写。<br>
* 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。
*/
@Override
public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException {
List<GroupPath> groups = buildImportGroupPaths(groupId);
XSSFWorkbook workbook = new XSSFWorkbook();
try {
XSSFSheet sheet = workbook.createSheet("用户导入模板");
// 表头样式:加粗、居中、自动换行、细边框
XSSFCellStyle headerStyle = workbook.createCellStyle();
XSSFFont headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
headerStyle.setWrapText(true);
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
headerStyle.setAlignment(HorizontalAlignment.CENTER);
headerStyle.setBorderTop(BorderStyle.THIN);
headerStyle.setBorderBottom(BorderStyle.THIN);
headerStyle.setBorderLeft(BorderStyle.THIN);
headerStyle.setBorderRight(BorderStyle.THIN);
XSSFRow headerRow = sheet.createRow(0);
for (int i = 0; i < IMPORT_COLUMN_DEFS.size(); i++) {
ImportColumnDef col = IMPORT_COLUMN_DEFS.get(i);
XSSFCell cell = headerRow.createCell(i);
cell.setCellValue(col.getTitle());
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, col.getWidth() * 256);
}
headerRow.setHeightInPoints(64);
// 数据行:每个组一行,仅填 组名(路径) + 组id
int r = 1;
for (GroupPath g : groups) {
XSSFRow row = sheet.createRow(r++);
row.createCell(IDX_GROUP_NAME).setCellValue(g.getPath());
row.createCell(IDX_GROUP_ID).setCellValue(String.valueOf(g.getId()));
}
String filename = URLEncoder.encode("用户导入模板.xlsx", "UTF-8");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + filename);
try (OutputStream out = response.getOutputStream()) {
workbook.write(out);
}
} finally {
workbook.close();
}
}
// ===================== 导出 =====================
/**
* 导出组下所有用户(含子组)为 Excel:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构,末尾追加「用户id」列。<br>
* - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;<br>
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);<br>
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);<br>
* - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 SysRoleEnum key:1老师 2校领导 3学生 4家长 5局领导 6工作号 7管理员)。
*/
@Override
public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException {
// 1. 解析组范围(含子孙),复用与列表一致的 INNER JOIN 原生 SQL 取范围内用户(含超管豁免),不分页。
// 无组归属的用户被 INNER JOIN 天然排除;type=1 超管在 SQL 中显式豁免,永不导出。
List<Integer> groupIds = resolveGroupIds(groupId, withSub);
String nameKey = StringUtils.isNotBlank(name) ? name.trim() : null;
List<Integer> userIds = sysUserRepository.findGroupUserIds(groupIds, nameKey);
List<SysUser> users = userIds.isEmpty()
? Collections.emptyList()
: sysUserRepository.findAllById(userIds).stream()
.sorted(Comparator.comparing(SysUser::getId))
.collect(Collectors.toList());
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
Map<Integer, Integer> userMainGroup = resolveUserMainGroup(userIds);
// 3. 批量取组名
Map<Integer, String> groupNameMap = sysGroupRepository.findAllById(userMainGroup.values())
.stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName));
// 4. 组装导出行
List<ExcelSysUser> rows = new ArrayList<>(users.size());
for (SysUser u : users) {
ExcelSysUser row = new ExcelSysUser();
Integer gid = userMainGroup.get(u.getId());
row.setGroupId(gid == null ? "" : String.valueOf(gid));
row.setGroupName(gid != null ? groupNameMap.get(gid) : null);
row.setAccount(u.getAccount());
row.setPassword(""); // 密文不可逆,导出留空
row.setRealName(u.getRealName());
row.setIdcard(u.getIdcard());
row.setPhone(u.getPhone());
row.setEmail(u.getEmail());
row.setNature(u.getType() == null ? null : u.getType().getKey());
row.setIdentity(u.getRoles() == null || u.getRoles().isEmpty() ? null : u.getRoles().get(0));
row.setGender(u.getGender());
row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString());
row.setUserId(u.getId());
rows.add(row);
}
// 5. 写出(HSSF .xls,与 EasyPoiUtils.downLoadExcel 的 .xls 兜底一致)
ExportParams exportParams = new ExportParams("组用户导出", "组用户");
exportParams.setType(ExcelType.HSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, ExcelSysUser.class, rows);
EasyPoiUtils.downLoadExcel("组用户导出", response, workbook);
}
/**
* 批量解析 用户 -> 主组(groupId) 映射:优先取 isMain=1 的组,否则取第一个关联组。
*/
private Map<Integer, Integer> resolveUserMainGroup(List<Integer> userIds) {
Map<Integer, Integer> mainGroup = new HashMap<>();
if (userIds.isEmpty()) {
return mainGroup;
}
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdIn(userIds);
Map<Integer, List<SysUserGroupRel>> byUser = rels.stream()
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
for (Map.Entry<Integer, List<SysUserGroupRel>> e : byUser.entrySet()) {
Integer gid = e.getValue().stream()
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
.map(SysUserGroupRel::getGroupId)
.findFirst()
.orElse(e.getValue().get(0).getGroupId());
mainGroup.put(e.getKey(), gid);
}
return mainGroup;
}
/**
* 收集导入模板所需的「组路径 + 组id」列表:以指定组为根做先序 DFS,
* 路径用「/」拼接(如 上海鸿育初级中学/教师组/校长室)。
*/
private List<GroupPath> buildImportGroupPaths(Integer groupId) {
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, SysGroup> byId = new HashMap<>();
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup g : all) {
byId.put(g.getId(), g);
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
}
// 同层级按 sort 升序、再按 id 升序,保证模板顺序稳定且与树展示一致
Comparator<SysGroup> order = (a, b) -> {
int sa = a.getSort() == null ? 999 : a.getSort();
int sb = b.getSort() == null ? 999 : b.getSort();
if (sa != sb) {
return Integer.compare(sa, sb);
}
return Integer.compare(a.getId(), b.getId());
};
for (List<SysGroup> siblings : childrenMap.values()) {
siblings.sort(order);
}
List<GroupPath> result = new ArrayList<>();
List<SysGroup> roots;
if (groupId != null && byId.containsKey(groupId)) {
roots = Collections.singletonList(byId.get(groupId));
} else {
roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
}
for (SysGroup root : roots) {
dfsGroup(root, "", childrenMap, result);
}
return result;
}
private void dfsGroup(SysGroup node, String parentPath,
Map<Integer, List<SysGroup>> childrenMap, List<GroupPath> out) {
String path = parentPath.isEmpty() ? node.getName() : parentPath + "/" + node.getName();
GroupPath gp = new GroupPath();
gp.setId(node.getId());
gp.setPath(path);
out.add(gp);
List<SysGroup> children = childrenMap.get(node.getId());
if (children != null) {
for (SysGroup child : children) {
dfsGroup(child, path, childrenMap, out);
}
}
}
/** 导入/导出列定义(轻量 DTO)。 */
@lombok.Data
private static class ImportColumnDef {
private final String key;
private final String title;
private final int width;
private final boolean required;
private final String sample;
}
/** 模板所需的组路径节点。 */
@lombok.Data
private static class GroupPath {
private Integer id;
private String path;
}
// ===================== 私有工具 =====================
/**
* 组装「根节点 / 未选组」上下文的用户列表查询条件:关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态,
* 按主键 id 升序(聚簇索引顺序,无 filesort)。<br>
* 注意:选中具体组的上下文不走 Specification,改由 {@link #listByGroup} 用原生 SQL 分页,
* 以规避 MySQL 的 DISTINCT+ORDER BY 限制与 Hibernate 不支持 ORDER BY 标量子查询的限制。
*/
private Specification<SysUser> buildSpecification(SysUserQuery query) {
return (root, cbQuery, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 根节点 / 未选组:无组上下文,按主键 id 升序
cbQuery.orderBy(cb.asc(root.get("id")));
// 关键字:账号/姓名/身份证模糊,手机号精确
if (StringUtils.isNotBlank(query.getName())) {
String keyword = query.getName().trim();
String like = "%" + keyword + "%";
Predicate byAccount = cb.like(root.get("account"), like);
Predicate byRealName = cb.like(root.get("realName"), like);
Predicate byIdcard = cb.like(root.get("idcard"), like);
Predicate byPhone = cb.equal(root.get("phone"), keyword);
predicates.add(cb.or(byAccount, byRealName, byIdcard, byPhone));
}
if (query.getDisabled() != null) {
// 实体 disabled 为 StatusEnum,查询条件需用枚举值比较(按 key 解析)
predicates.add(cb.equal(root.get("disabled"), IEnum.getByKey(StatusEnum.class, query.getDisabled())));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
/**
* 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。
*/
private List<Integer> resolveGroupIds(Integer groupId, Integer withSub) {
if (withSub != null && withSub == 1) {
return sysGroupService.listSelfAndDescendantIds(groupId);
}
List<Integer> single = new ArrayList<>();
single.add(groupId);
return single;
}
/** 校验手机号与身份证(有值才校验)。 */
private void validateProfile(SysUserRequest request) {
if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) {
throw new DException("手机号格式不正确");
}
if (StringUtils.isNotBlank(request.getIdcard()) && !ValidatorUtil.isIdCard(request.getIdcard())) {
throw new DException("身份证号不合法");
}
}
/**
* 校验两次密码一致性与弱密码规则,通过后返回 BCrypt 密文。<br>
*/
private String validateAndEncodePassword(String password, String confirmPassword) {
if (!StringUtils.equals(password, confirmPassword)) {
throw new DException("两次输入的密码不一致");
}
String weakReason = ValidatorUtil.checkPasswordStrength(password);
if (weakReason != null) {
throw new DException(weakReason);
}
return passwordEncoder.encode(password);
}
private SysUserVo convertToVo(SysUser entity) {
return convertToVo(entity, null);
}
private SysUserVo convertToVo(SysUser entity, Integer sortNo) {
SysUserVo vo = new SysUserVo();
BeanUtils.copyProperties(entity, vo);
// disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致)
vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey());
// type 为枚举,同样映射为 key
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
// 当前组维度的排序号;根节点 / 未选组时为 null
vo.setSortNo(sortNo);
return vo;
}
/**
* 推导用户类型(见 {@link UserTypeEnum}):
* - 内置超级管理员账号 -> 1 超级管理员;
* - 其余账号若拥有 ADMIN(7) 角色 -> 2 学校管理员;
* - 其他 -> 0 普通用户。
*/
private UserTypeEnum resolveUserType(String account, List<Integer> roles) {
if (account != null && SUPER_ADMIN_ACCOUNTS.contains(account)) {
return UserTypeEnum.SUPER_ADMIN;
}
if (roles != null && roles.contains(SysRoleEnum.ADMIN.getKey())) {
return UserTypeEnum.SCHOOL_ADMIN;
}
return UserTypeEnum.NORMAL;
}
/** 校验并返回用户状态枚举;非法状态抛异常。 */
private StatusEnum resolveStatus(Integer status) {
StatusEnum statusEnum = IEnum.getByKey(StatusEnum.class, status);
if (statusEnum == null) {
throw new DException("用户状态不存在");
}
return statusEnum;
}
// ===================== 批量导入私有工具 =====================
private static final int IDX_GROUP_NAME = 0;
private static final int IDX_GROUP_ID = 1;
private static final int IDX_ACCOUNT = 2;
private static final int IDX_PASSWORD = 3;
private static final int IDX_REAL_NAME = 4;
private static final int IDX_IDCARD = 5;
private static final int IDX_PHONE = 6;
private static final int IDX_EMAIL = 7;
private static final int IDX_NATURE = 8;
private static final int IDX_IDENTITY = 9;
private static final int IDX_GENDER = 10;
private static final int IDX_BIRTHDAY = 11;
/**
* 解析 Excel 一行为 SysUserRequest。
* 列顺序与前端导入模板严格一致:
* 0组名 1组id 2用户名 3密码 4姓名 5身份证 6手机 7email 8用户性质 9用户身份 10性别 11生日。
*/
private SysUserRequest parseImportRow(Row row) {
SysUserRequest request = new SysUserRequest();
Integer groupId = parseInteger(getCellString(row, IDX_GROUP_ID));
if (groupId == null) {
throw new DException("组id不能为空");
}
if (!sysGroupRepository.existsById(groupId)) {
throw new DException("组id【" + groupId + "】不存在");
}
request.setGroupId(groupId);
String account = getCellString(row, IDX_ACCOUNT);
if (StringUtils.isBlank(account)) {
throw new DException("用户名不能为空");
}
request.setAccount(account.trim());
String password = getCellString(row, IDX_PASSWORD);
if (StringUtils.isBlank(password)) {
password = "Vzuk4Dp3";
}
request.setPassword(password);
request.setConfirmPassword(password);
String realName = getCellString(row, IDX_REAL_NAME);
if (StringUtils.isBlank(realName)) {
throw new DException("姓名不能为空");
}
request.setRealName(realName.trim());
request.setIdcard(getCellString(row, IDX_IDCARD));
request.setPhone(getCellString(row, IDX_PHONE));
request.setEmail(getCellString(row, IDX_EMAIL));
Integer nature = parseInteger(getCellString(row, IDX_NATURE));
Integer identity = parseInteger(getCellString(row, IDX_IDENTITY));
request.setRoles(buildImportRoles(nature, identity));
request.setGender(parseGender(getCellString(row, IDX_GENDER)));
request.setBirthday(parseBirthday(row.getCell(IDX_BIRTHDAY)));
validateProfile(request);
return request;
}
/**
* 根据模板中的用户性质与用户身份构建 roles 列表。
* - 用户性质:0普通用户(不加管理员),2组管理员(加 ADMIN=7);其他值视为非法。
* - 用户身份:1教师->TEACHER(1),2校领导->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。
*/
private List<Integer> buildImportRoles(Integer nature, Integer identity) {
Set<Integer> roleSet = new HashSet<>();
if (nature != null) {
if (nature == 2) {
roleSet.add(SysRoleEnum.ADMIN.getKey());
} else if (nature != 0) {
throw new DException("用户性质只能为 0 或 2");
}
}
if (identity != null) {
switch (identity) {
case 1:
roleSet.add(SysRoleEnum.TEACHER.getKey());
break;
case 2:
roleSet.add(SysRoleEnum.SCHOOL_LEADER.getKey());
break;
case 3:
roleSet.add(SysRoleEnum.STUDENT.getKey());
break;
case 4:
roleSet.add(SysRoleEnum.PARENT.getKey());
break;
default:
throw new DException("用户身份只能为 1/2/3/4");
}
}
return new ArrayList<>(roleSet);
}
private Integer parseGender(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
String v = value.trim();
if ("1".equals(v) || "男".equals(v)) {
return 1;
}
if ("0".equals(v) || "女".equals(v)) {
return 0;
}
throw new DException("性别只能为 0/女 或 1/男");
}
private LocalDate parseBirthday(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
String value = getCellString(cell);
if (StringUtils.isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim());
} catch (Exception e) {
throw new DException("生日格式错误,请使用 yyyy-MM-dd");
}
}
private boolean isBlankRow(Row row) {
for (int i = 0; i <= IDX_BIRTHDAY; i++) {
if (StringUtils.isNotBlank(getCellString(row, i))) {
return false;
}
}
return true;
}
private String getCellString(Row row, int cellIndex) {
Cell cell = row.getCell(cellIndex);
if (cell == null) {
return "";
}
return getCellString(cell);
}
private String getCellString(Cell cell) {
switch (cell.getCellType()) {
case STRING:
return StringUtils.trim(cell.getStringCellValue());
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString();
}
double numeric = cell.getNumericCellValue();
if (numeric == Math.floor(numeric)) {
return String.valueOf((long) numeric);
}
return String.valueOf(numeric);
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
try {
return String.valueOf(cell.getNumericCellValue());
} catch (Exception e) {
return cell.getStringCellValue();
}
default:
return "";
}
}
private Integer parseInteger(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
// ===================== 推送事件 =====================
private void publishUserEvent(PushInterfaceType type, Integer userId) {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setUserIds(Collections.singletonList(userId));
eventPublisher.publishEvent(e);
}
private void publishUserEvent(PushInterfaceType type, List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setUserIds(userIds);
eventPublisher.publishEvent(e);
}
/** 组维度的用户事件(如 DELETE_USER=解除组关系),额外携带组 id */
private void publishUserEvent(PushInterfaceType type, List<Integer> userIds, Integer groupId) {
if (userIds == null || userIds.isEmpty()) {
return;
}
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setUserIds(userIds);
e.setGroupId(groupId);
eventPublisher.publishEvent(e);
}
}