This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
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;
|
||||
@@ -16,6 +18,11 @@ 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 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;
|
||||
@@ -26,16 +33,28 @@ 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.Join;
|
||||
import javax.persistence.criteria.JoinType;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.persistence.criteria.Subquery;
|
||||
import javax.transaction.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
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
|
||||
@@ -73,6 +92,18 @@ public class SysUserService implements ISysUserService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(SysUserRequest request) {
|
||||
// 单条新增走完整校验(含密码强度)
|
||||
createUser(request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户并写入组关联。
|
||||
*
|
||||
* @param enforcePasswordStrength 是否强制密码强度校验:
|
||||
* - 单条新增 = true(前端已交互式校验);
|
||||
* - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。
|
||||
*/
|
||||
private void createUser(SysUserRequest request, boolean enforcePasswordStrength) {
|
||||
if (sysUserRepository.countByAccount(request.getAccount()) > 0) {
|
||||
throw new DException("账号已存在");
|
||||
}
|
||||
@@ -80,7 +111,9 @@ public class SysUserService implements ISysUserService {
|
||||
throw new DException("所属分组不存在");
|
||||
}
|
||||
validateProfile(request);
|
||||
String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword());
|
||||
String encodedPassword = enforcePasswordStrength
|
||||
? validateAndEncodePassword(request.getPassword(), request.getConfirmPassword())
|
||||
: passwordEncoder.encode(request.getPassword());
|
||||
|
||||
SysUser entity = new SysUser();
|
||||
entity.setAccount(request.getAccount());
|
||||
@@ -89,6 +122,8 @@ public class SysUserService implements ISysUserService {
|
||||
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 普通用户
|
||||
@@ -126,6 +161,8 @@ public class SysUserService implements ISysUserService {
|
||||
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
|
||||
@@ -154,6 +191,38 @@ public class SysUserService implements ISysUserService {
|
||||
sysUserRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeStatus(Integer id, boolean disabled) {
|
||||
@@ -164,29 +233,396 @@ public class SysUserService implements ISysUserService {
|
||||
sysUserRepository.save(entity);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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) {
|
||||
Specification<SysUser> spec = buildSpecification(query);
|
||||
return sysUserRepository.findAll(spec, pageable).map(this::convertToVo);
|
||||
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
|
||||
// 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null)
|
||||
if (query.getGroupId() != null) {
|
||||
Map<Integer, Integer> sortNoMap = resolveUserSortNoInGroup(
|
||||
page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()),
|
||||
query.getGroupId());
|
||||
return page.map(u -> convertToVo(u, sortNoMap.get(u.getId())));
|
||||
}
|
||||
return page.map(u -> convertToVo(u, null));
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@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 数组第一个元素(系统角色枚举 key:1管理员 2老师 3校领导 4局领导 5学生 6家长 7工作号)。
|
||||
*/
|
||||
@Override
|
||||
public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException {
|
||||
// 1. 解析组范围(含子孙)并查询范围内全部用户(不分页)
|
||||
SysUserQuery query = new SysUserQuery();
|
||||
query.setGroupId(groupId);
|
||||
query.setWithSub(withSub);
|
||||
query.setName(name);
|
||||
List<SysUser> users = sysUserRepository.findAll(buildSpecification(query));
|
||||
|
||||
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
|
||||
List<Integer> userIds = users.stream().map(SysUser::getId).collect(Collectors.toList());
|
||||
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;
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/**
|
||||
* 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。<br>
|
||||
* 排序按「是否选中具体组」分治:<br>
|
||||
* - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序;
|
||||
* 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。<br>
|
||||
* - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort),
|
||||
* 平台总用户量不影响查询性能。
|
||||
*/
|
||||
private Specification<SysUser> buildSpecification(SysUserQuery query) {
|
||||
return (root, cbQuery, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// 组范围过滤:用户ID 属于目标组集合下的关联记录
|
||||
if (query.getGroupId() != null) {
|
||||
// 具体组上下文:JOIN 关联表以按组内 sort_no 排序
|
||||
List<Integer> groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub());
|
||||
Subquery<Integer> sub = cbQuery.subquery(Integer.class);
|
||||
Root<SysUserGroupRel> relRoot = sub.from(SysUserGroupRel.class);
|
||||
sub.select(relRoot.get("userId"));
|
||||
sub.where(relRoot.get("groupId").in(groupIds));
|
||||
predicates.add(root.get("id").in(sub));
|
||||
Join<SysUser, SysUserGroupRel> rel = root.join("userGroupRelList", JoinType.INNER);
|
||||
predicates.add(rel.get("groupId").in(groupIds));
|
||||
cbQuery.distinct(true);
|
||||
// 排序规则:已设排序号者排最前(NULLS LAST),其次 sort_no 升序,最后 user_id 升序兜底
|
||||
// 注:Hibernate 5.6 的 AST 解析器不支持把 IS NULL 表达式直接放进 ORDER BY,
|
||||
// 改用 CASE WHEN sort_no IS NULL THEN 1 ELSE 0 END(产出普通整数,语义等价 NULLS LAST)。
|
||||
// rel.get("sortNo") 返回 Path<Object>,直接作为 Expression<?> 传入 orderBy 即可。
|
||||
cbQuery.orderBy(
|
||||
cb.asc(cb.selectCase().when(cb.isNull(rel.get("sortNo")), 1).otherwise(0)),
|
||||
cb.asc(rel.get("sortNo")),
|
||||
cb.asc(root.get("id"))
|
||||
);
|
||||
} else {
|
||||
// 根节点 / 未选组:无组上下文,按主键 id 升序(聚簇索引顺序,不产生 filesort)
|
||||
cbQuery.orderBy(cb.asc(root.get("id")));
|
||||
}
|
||||
|
||||
// 关键字:账号/姓名/身份证模糊,手机号精确
|
||||
@@ -208,6 +644,21 @@ public class SysUserService implements ISysUserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取「用户在某组下的排序号」,用于列表 Vo 携带当前组维度 sortNo(仅具体组上下文调用)。
|
||||
*/
|
||||
private Map<Integer, Integer> resolveUserSortNoInGroup(List<Integer> userIds, Integer groupId) {
|
||||
Map<Integer, Integer> sortNoMap = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return sortNoMap;
|
||||
}
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId);
|
||||
for (SysUserGroupRel rel : rels) {
|
||||
sortNoMap.put(rel.getUserId(), rel.getSortNo());
|
||||
}
|
||||
return sortNoMap;
|
||||
}
|
||||
|
||||
/** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */
|
||||
private List<Integer> resolveGroupIds(Integer groupId, Integer withSub) {
|
||||
if (withSub != null && withSub == 1) {
|
||||
@@ -243,12 +694,18 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -276,4 +733,191 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user