package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.userbase.entity.SysAppRole; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.enums.StatusEnum; import com.yunhai.userbase.enums.SysRoleEnum; import com.yunhai.userbase.enums.UserGroupRelStatusEnum; import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserAppRoleRel; import com.yunhai.userbase.entity.SysUserCurrentRole; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserRoleRel; import com.yunhai.userbase.model.sysuser.BatchImportResult; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; 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.model.sysuser.UserIdentityVo; import com.yunhai.userbase.repository.SysAppRoleRepository; import com.yunhai.userbase.repository.SysGroupRepository; import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.repository.SysUserRoleRelRepository; import com.yunhai.userbase.service.ISysGroupService; import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.IdentityService; import com.yunhai.userbase.service.resolve.MainGroupResolver; import com.yunhai.userbase.event.DataSyncEvent; import com.yunhai.userbase.common.context.CallerAppContext; 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 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 SysAppRoleRepository sysAppRoleRepository; @Autowired private SysUserRoleRelRepository sysUserRoleRelRepository; @Autowired private MainGroupResolver mainGroupResolver; @Autowired private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; @Autowired private IdentityService identityService; @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()); // roles 列停写(见《用户身份-学校维度落地方案》§8):身份改由 sys_user_role_rel 承载, // 新增用户身份仍必填(≥1),经下方 saveSchoolIdentity 落库 // 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); // 身份写入:schoolId 未传时按创建目标组解析(前端不感知解析逻辑) Integer schoolId = request.getSchoolId() != null ? validateIdentitySchoolId(request.getSchoolId()) : sysGroupService.resolveSchoolId(request.getGroupId()); saveSchoolIdentity(saved.getId(), schoolId, request.getRoleIds()); // type 重算时序:先写身份再推导(数据源 = role_rel 正常态并集,超管账号特判保留) saved.setType(resolveUserType(saved.getAccount(), saved.getId())); sysUserRepository.save(saved); 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()); // roles 列停写(见 §8):身份改由 role_rel 承载,type 一律在身份落库后重算(不再有 roles 守卫) 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); // 身份按 schoolId 全量覆盖保存(编辑请求总是携带 {schoolId, roleIds},未修改也提交原值); // 未携带 schoolId 的调用(如旧版 oapi edit)跳过身份写库,保持原身份不变 if (request.getSchoolId() != null) { saveSchoolIdentity(id, validateIdentitySchoolId(request.getSchoolId()), request.getRoleIds()); } // type 重算时序:身份落库之后按 role_rel 正常态并集推导(超管账号特判保留) entity.setType(resolveUserType(entity.getAccount(), id)); sysUserRepository.save(entity); } @Override @Transactional public List batchAdd(List requests) { if (requests == null || requests.isEmpty()) { throw new DException("批量新增用户列表不能为空"); } // 批量新增与单条一致:强制密码强度校验(每条需携带 password + confirmPassword) List 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 userIds, Integer groupId) { if (userIds == null || userIds.isEmpty()) { throw new DException("待删除用户不能为空"); } if (groupId == null) { throw new DException("组ID不能为空"); } List 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); sysUserRoleRelRepository.deleteByUserIdBulk(userId); sysUserCurrentRoleRepository.deleteByUserIdBulk(userId); sysUserRepository.deleteById(userId); } } // 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER // 因是「解除组关系」,需携带 (userId, groupId) 供子应用精确处理 publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId); } @Override @Transactional public void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId) { // 开放接口边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/整校移除) assertGroupWithinSchool(groupId, schoolId); batchDeleteFromGroup(Collections.singletonList(userId), groupId); } @Override @Transactional public void deleteUsers(List items) { if (items == null || items.isEmpty()) { throw new DException("批量删除用户-组关系列表不能为空"); } for (SysUserGroupDeleteItem item : items) { deleteFromGroup(item.getUserId(), item.getGroupId(), item.getSchoolId()); } } /** * 学校边界校验:确保待操作的组属于指定学校范围,且不是学校根组本身。
* 与 {@link #moveUserWithinSchool} 的「学校子树范围」判定一致,复用 {@code listSelfAndDescendantIds}。 * * @param groupId 待操作组ID(解绑/删除目标) * @param schoolId 学校组ID(限定范围) */ private void assertGroupWithinSchool(Integer groupId, Integer schoolId) { // 公共锚点校验:schoolId 非空、存在、必须为学校组(复用 SysGroupService.assertSchoolId) sysGroupService.assertSchoolId(schoolId); if (groupId == null) { throw new DException("组ID不能为空"); } // 拿学校根组当 groupId = 把用户从整所学校移除,禁止 if (groupId.equals(schoolId)) { throw new DException("禁止对学校根组执行该操作,请指定具体子组"); } // groupId 必须落在 schoolId 子树(含子孙,不含学校根组自身——上面已拦截) List schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId); if (!schoolScope.contains(groupId)) { throw new DException("组不属于该学校范围,禁止跨校操作"); } } @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 userIds, boolean disabled) { if (userIds == null || userIds.isEmpty()) { throw new DException("待设置用户不能为空"); } List 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 @Transactional public void changeUserGroupState(Integer userId, Integer groupId, Integer schoolId, Integer state) { if (state == null || (state != 0 && state != 1)) { throw new DException("state 仅支持 0=恢复正常、1=转出"); } // 开放接口边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/学校根组) assertGroupWithinSchool(groupId, schoolId); if (!sysUserRepository.existsById(userId)) { throw new DException("系统用户不存在"); } // 状态按组维度存储,必须落到具体的 (userId, groupId) 关联行;同值重复调用幂等 SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) .orElseThrow(() -> new DException("该用户不在此分组中")); rel.setStatus(state == 1 ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL); sysUserGroupRelRepository.save(rel); } @Override public Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId) { assertGroupWithinSchool(groupId, schoolId); SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) .orElseThrow(() -> new DException("该用户不在此分组中")); // 存量数据 status 为 NULL,按正常返回 return rel.getStatus() == null ? UserGroupRelStatusEnum.NORMAL.getKey() : rel.getStatus().getKey(); } @Override @Transactional public void transferUserToSchool(Integer userId, Integer fromSchoolId, Integer toSchoolId, Integer groupId) { if (userId == null) { throw new DException("用户ID不能为空"); } if (fromSchoolId == null) { throw new DException("原学校ID不能为空"); } if (toSchoolId == null) { throw new DException("目标学校ID不能为空"); } if (groupId == null) { throw new DException("目标组ID不能为空"); } // 源学校必须真实存在且为学校组(目标学校在 assertGroupWithinSchool 内一并校验) sysGroupService.assertSchoolId(fromSchoolId); if (!sysUserRepository.existsById(userId)) { throw new DException("系统用户不存在"); } // 目标组必须属于目标学校子树且不等于学校根组(复用移动/删除的边界校验) assertGroupWithinSchool(groupId, toSchoolId); boolean sameSchool = fromSchoolId.equals(toSchoolId); List fromScope = sysGroupService.listSelfAndDescendantIds(fromSchoolId); List toScope = sysGroupService.listSelfAndDescendantIds(toSchoolId); List rels = sysUserGroupRelRepository.findByUserId(userId); // ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系(原组由转出标记隐含携带) List transferOutRels = rels.stream() .filter(r -> r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId())) .collect(Collectors.toList()); if (transferOutRels.isEmpty()) { throw new DException("该用户在原学校不存在转出状态的组关系,禁止转入"); } List toDelete; int newRelIsMain; if (sameSchool) { // 同校转组:仅当原组(源校转出组)与目标组一致才拒绝;否则删除原组关系、新增目标组关系(同校其他组与他校关系保留) boolean sameGroup = transferOutRels.stream().anyMatch(r -> r.getGroupId().equals(groupId)); if (sameGroup) { throw new DException("原学校与目标学校相同且目标组与原组相同,请指定其他目标组"); } // 目标组不得已有任何关系(任意状态),避免重复/合并歧义 boolean existsInTargetGroup = rels.stream().anyMatch(r -> r.getGroupId().equals(groupId)); if (existsInTargetGroup) { throw new DException("用户已在该组存在组关系,禁止重复转入"); } toDelete = transferOutRels; // 主归属继承原组:原组是主组则目标组为主组,否则维持非主,避免同校场景制造双主组 newRelIsMain = transferOutRels.stream().anyMatch(r -> Objects.equals(r.getIsMain(), IS_MAIN)) ? IS_MAIN : 0; } else { // 跨校转入(原逻辑不变):目标学校范围不得已有组关系,删除源校范围全部关系,目标组设为主归属 boolean existsInTarget = rels.stream().anyMatch(r -> toScope.contains(r.getGroupId())); if (existsInTarget) { throw new DException("用户已在该学校存在组关系,禁止重复转入"); } toDelete = rels.stream() .filter(r -> fromScope.contains(r.getGroupId())) .collect(Collectors.toList()); newRelIsMain = IS_MAIN; } if (!toDelete.isEmpty()) { sysUserGroupRelRepository.deleteAll(toDelete); } // 建立目标组关系:状态恢复正常(转出流程结束) SysUserGroupRel rel = new SysUserGroupRel(); rel.setUserId(userId); rel.setGroupId(groupId); rel.setIsMain(newRelIsMain); rel.setStatus(UserGroupRelStatusEnum.NORMAL); rel.setCreateTime(LocalDateTime.now()); sysUserGroupRelRepository.save(rel); // 转入/转组后身份即时对齐:源校组关系已物理删除,旧校身份行清出集合(主身份为读时计算,无物化列需重指向) alignIdentity(userId); // 推送编辑用户订阅 publishUserEvent(PushInterfaceType.UPDATE_USER, userId); } @Override public Page list(SysUserQuery query, Pageable pageable) { // 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页, // 规避 MySQL 的 DISTINCT+ORDER BY 限制以及 Hibernate 不支持 ORDER BY 标量子查询的限制。 if (query.getGroupId() != null) { return attachMainIdentityNames(listByGroup(query, pageable)); } // 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。 Specification spec = buildSpecification(query); Page page = sysUserRepository.findAll(spec, pageable); return attachMainIdentityNames(page.map(u -> convertToVo(u, null, null))); } /** * 按页批量带出各用户主校身份名(动态解析主身份,无 N+1)。 * roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。 */ private Page attachMainIdentityNames(Page page) { List content = page.getContent(); if (content.isEmpty()) { return page; } List userIds = content.stream() .map(SysUserVo::getId) .collect(Collectors.toList()); Map mainRelsByUser = resolveMainRoleRelsByUser(userIds); for (SysUserVo vo : content) { SysUserRoleRel mainRel = mainRelsByUser.get(vo.getId()); if (mainRel == null) { continue; } SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, mainRel.getRoleId()); String name = e != null ? e.getValue() : null; if (name != null) { vo.setIdentityNames(name); } } return page; } /** * 批量解析 用户 -> 主身份行(动态解析,每用户至多 1 条)映射。 * 供列表页主校身份名({@link #attachMainIdentityNames})与导出「用户身份」列共用。 */ private Map resolveMainRoleRelsByUser(List userIds) { return identityService.resolveMainIdentityByUser(userIds); } /** * 组上下文列表:原生 SQL 分页。
* 思路: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 listByGroup(SysUserQuery query, Pageable pageable) { List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); String name = StringUtils.isNotBlank(query.getName()) ? query.getName().trim() : null; Integer disabledKey = query.getDisabled(); Page idPage = sysUserRepository.findGroupUserPage( groupIds, name, disabledKey, PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); List rows = idPage.getContent(); if (rows.isEmpty()) { return new PageImpl<>(Collections.emptyList(), pageable, 0); } // 保持 DB 返回的排序顺序,并记录每个用户的「组范围内最小 sort_no」与「组内状态(任一转出即转出)」 List orderedIds = new ArrayList<>(rows.size()); Map sortNoMap = new LinkedHashMap<>(); Map groupStatusMap = 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(); Object statusObj = row[2]; // MAX(COALESCE(status,0)):组范围内任一条转出即视为转出 Integer groupStatus = statusObj == null ? null : ((Number) statusObj).intValue(); orderedIds.add(userId); sortNoMap.put(userId, sortNo); groupStatusMap.put(userId, groupStatus); } // 按有序 ID 取实体(findAllById 返回顺序不可靠,需按 orderedIds 重排) List users = sysUserRepository.findAllById(orderedIds); Map userMap = users.stream() .collect(Collectors.toMap(SysUser::getId, u -> u, (a, b) -> a)); List voList = new ArrayList<>(orderedIds.size()); for (Integer id : orderedIds) { SysUser u = userMap.get(id); if (u != null) { voList.add(convertToVo(u, sortNoMap.get(id), groupStatusMap.get(id))); } } return new PageImpl<>(voList, pageable, idPage.getTotalElements()); } @Override public List findGroupIdsByUserId(Integer userId) { return sysUserGroupRelRepository.findByUserId(userId).stream() .map(SysUserGroupRel::getGroupId) .collect(Collectors.toList()); } // ===================== 用户身份(SysRoleEnum × 学校) ===================== @Override public UserIdentityVo getUserIdentity(Integer userId, Integer groupId) { if (userId == null) { throw new DException("用户ID不能为空"); } if (groupId == null) { throw new DException("组ID不能为空"); } // 后端按 §3 规则解析 schoolId:同一学校多组返回同一 schoolId 与角色并集 Integer schoolId = sysGroupService.resolveSchoolId(groupId); List roleIds = sysUserRoleRelRepository .findByUserIdAndSchoolIdAndStatus(userId, schoolId, StatusEnum.NORMAL) .stream() .map(SysUserRoleRel::getRoleId) .distinct() .collect(Collectors.toList()); UserIdentityVo vo = new UserIdentityVo(); vo.setSchoolId(schoolId); vo.setRoleIds(roleIds); return vo; } /** * 校验保存身份的 schoolId:非空且必须是存在的组 id。 */ private Integer validateIdentitySchoolId(Integer schoolId) { if (schoolId == null) { throw new DException("学校ID不能为空"); } if (!sysGroupRepository.existsById(schoolId)) { throw new DException("学校ID必须为合法组ID"); } return schoolId; } /** 校验身份角色在 SysRoleEnum 内;非法角色抛异常。 */ private void validateRoleIds(List roleIds) { if (roleIds == null) { return; } for (Integer roleId : roleIds) { if (roleId == null || IEnum.getByKey(SysRoleEnum.class, roleId) == null) { throw new DException("用户身份不存在:" + roleId); } } } /** * 按 schoolId 全量覆盖保存用户身份:删除该 (userId, schoolId) 全部行 + 按 roleIds 重建 * (uk(user_id, role_id, school_id) 防重)。空数组/空列表语义 = 清空该校身份(幂等)。
* 保存完成后统一对齐(清理学校集合外身份行 + 激活身份滞留检查)。主身份已去字段化,不再写 is_main。 */ private void saveSchoolIdentity(Integer userId, Integer schoolId, List roleIds) { validateRoleIds(roleIds); sysUserRoleRelRepository.deleteByUserIdAndSchoolId(userId, schoolId); if (roleIds != null && !roleIds.isEmpty()) { LocalDateTime now = LocalDateTime.now(); List rows = new ArrayList<>(roleIds.size()); // roleIds 去重,避免 uk 冲突 for (Integer roleId : roleIds.stream().filter(Objects::nonNull).distinct() .collect(Collectors.toList())) { SysUserRoleRel rel = new SysUserRoleRel(); rel.setUserId(userId); rel.setRoleId(roleId); rel.setSchoolId(schoolId); rel.setSortNo(0); rel.setStatus(StatusEnum.NORMAL); rel.setCreateTime(now); rel.setUpdateTime(now); rows.add(rel); } sysUserRoleRelRepository.saveAll(rows); } alignIdentity(userId); } /** * 身份统一对齐(保存后与改组三入口 add/edit/changeGroups/transferToSchool 共用):
* ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;
* ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);
* ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录, * 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。
* 主身份已去字段化(读时计算),此处不再维护 is_main 物化列。 */ private void alignIdentity(Integer userId) { Set schoolSet = deriveSchoolSet(userId); List rels = sysUserRoleRelRepository.findByUserId(userId); List remaining = new ArrayList<>(rels.size()); for (SysUserRoleRel rel : rels) { if (schoolSet.contains(rel.getSchoolId())) { remaining.add(rel); } else { sysUserRoleRelRepository.delete(rel); } } if (!remaining.isEmpty()) { sysUserRoleRelRepository.saveAll(remaining); } // 激活身份滞留检查:切换记录指向的学校已不在集合 → 删除(schoolId 为 null 的旧约定行不在此列) sysUserCurrentRoleRepository.findByUserId(userId).ifPresent(current -> { if (current.getSchoolId() != null && !schoolSet.contains(current.getSchoolId())) { sysUserCurrentRoleRepository.delete(current); } }); } /** * 学校集合推导(身份对齐/孤儿清理的数据基准):取该用户全部组关系逐条按 §3 解析 schoolId 去重。
* 不过滤 {@code sys_user_group_rel.status}:「转出」仅是流程标记,转入完成(transferToSchool * 物理删除源校关系)后才视为真正离开;标记期内保留原校身份与业务现状一致。 */ private Set deriveSchoolSet(Integer userId) { Set set = new HashSet<>(); for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) { set.add(sysGroupService.resolveSchoolId(rel.getGroupId())); } return set; } @Override @Transactional public void changeGroups(List userIds, List groupIds, Integer mainGroupId) { if (userIds == null || userIds.isEmpty()) { throw new DException("待调整用户不能为空"); } if (groupIds == null || groupIds.isEmpty()) { throw new DException("请至少保留一个组"); } // 显式主组校验:必须落在目标组列表中(否则 400) if (mainGroupId != null && !groupIds.contains(mainGroupId)) { throw new DException("主归属组必须在目标组列表中"); } List users = sysUserRepository.findAllById(userIds); if (users.size() != userIds.size()) { throw new DException("存在无效用户"); } List groups = sysGroupRepository.findAllById(groupIds); if (groups.size() != groupIds.size()) { throw new DException("存在无效分组"); } LocalDateTime now = LocalDateTime.now(); for (Integer userId : userIds) { // 覆盖式重建前先读旧关系,用于继承组内状态(转出标记)与旧主组解析: // 行级继承——同组照抄旧状态(管理端重设组不误改);用户级继承——新增组在用户处于转出流程中(存在任一转出行)时继承「转出」 List oldRels = sysUserGroupRelRepository.findByUserId(userId); Map oldStatusByGroup = new HashMap<>(); boolean inTransferFlow = false; for (SysUserGroupRel oldRel : oldRels) { oldStatusByGroup.put(oldRel.getGroupId(), oldRel.getStatus()); if (oldRel.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT) { inTransferFlow = true; } } // 主归属组三级降级:① 显式 mainGroupId → ② 旧主组 ∈ 新集合沿用 → ③ groupIds 首项 Integer effectiveMainGroupId = decideEffectiveMainGroupId(mainGroupId, groupIds, mainGroupResolver.resolve(oldRels)); sysUserGroupRelRepository.deleteByUserId(userId); List rels = new ArrayList<>(groupIds.size()); for (Integer groupId : groupIds) { SysUserGroupRel rel = new SysUserGroupRel(); rel.setUserId(userId); rel.setGroupId(groupId); rel.setIsMain(Objects.equals(groupId, effectiveMainGroupId) ? IS_MAIN : 0); rel.setCreateTime(now); rel.setStatus(oldStatusByGroup.containsKey(groupId) ? oldStatusByGroup.get(groupId) : (inTransferFlow ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL)); rels.add(rel); } sysUserGroupRelRepository.saveAll(rels); // 改组后身份即时对齐:学校集合变化可能清掉旧校身份行,主校变更后动态主身份随之切换, // 消除「组已变更而激活身份仍指旧校」窗口期(跨校转学场景依赖此兜底) alignIdentity(userId); } publishUserEvent(PushInterfaceType.UPDATE_USER, userIds); } /** * 主归属组三级降级决策(纯函数,便于单测):
* ① explicitMainGroupId 非空 → 直接返回(调用方已校验 ∈ groupIds);
* ② oldMainRel 非空且其组 ∈ groupIds → 沿用旧主组(只要用户没把主组勾掉,主组不动);
* ③ 否则 → groupIds 首项(现状行为兜底)。
* groupIds 非空由调用方保证。 */ static Integer decideEffectiveMainGroupId(Integer explicitMainGroupId, List groupIds, SysUserGroupRel oldMainRel) { if (explicitMainGroupId != null) { return explicitMainGroupId; } if (oldMainRel != null && groupIds.contains(oldMainRel.getGroupId())) { return oldMainRel.getGroupId(); } return groupIds.get(0); } @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不能为空"); } // 公共锚点校验:schoolId 非空、存在、必须为学校组(复用 SysGroupService.assertSchoolId) sysGroupService.assertSchoolId(schoolId); if (!sysUserRepository.existsById(userId)) { throw new DException("系统用户不存在"); } if (!sysGroupRepository.existsById(groupId)) { throw new DException("目标分组不存在"); } // 学校子树范围(含学校自身):用户与目标组都必须落在此范围内,否则视为跨校移动 List schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId); // ① 用户当前必须已在该学校内(至少一个组归属落在 schoolScope) List 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), 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 列顺序)。
* 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。 */ private static final List 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」两列,其余列为空供用户填写。
* 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。 */ @Override public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException { List 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」列。
* - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);
* - 用户身份 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 groupIds = resolveGroupIds(groupId, withSub); String nameKey = StringUtils.isNotBlank(name) ? name.trim() : null; List userIds = sysUserRepository.findGroupUserIds(groupIds, nameKey); List users = userIds.isEmpty() ? Collections.emptyList() : sysUserRepository.findAllById(userIds).stream() .sorted(Comparator.comparing(SysUser::getId)) .collect(Collectors.toList()); // 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列 Map userMainGroup = resolveUserMainGroup(userIds); // 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源) Map mainRelsByUser = resolveMainRoleRelsByUser(userIds); // 3. 批量取组名 Map groupNameMap = sysGroupRepository.findAllById(userMainGroup.values()) .stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName)); // 4. 组装导出行 List 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()); SysUserRoleRel mainRel = mainRelsByUser.get(u.getId()); row.setIdentity(mainRel == null ? null : mainRel.getRoleId()); 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 取 id 最小,无主兜底 id 最小(统一走 MainGroupResolver)。 */ private Map resolveUserMainGroup(List userIds) { Map mainGroup = new HashMap<>(); if (userIds.isEmpty()) { return mainGroup; } mainGroupResolver.resolveByUserIds(userIds) .forEach((userId, rel) -> mainGroup.put(userId, rel.getGroupId())); return mainGroup; } /** * 收集导入模板所需的「组路径 + 组id」列表:以指定组为根做先序 DFS, * 路径用「/」拼接(如 上海鸿育初级中学/教师组/校长室)。 */ private List buildImportGroupPaths(Integer groupId) { List all = sysGroupRepository.findAll(); Map byId = new HashMap<>(); Map> 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 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 siblings : childrenMap.values()) { siblings.sort(order); } List result = new ArrayList<>(); List 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> childrenMap, List 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 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)。
* 注意:选中具体组的上下文不走 Specification,改由 {@link #listByGroup} 用原生 SQL 分页, * 以规避 MySQL 的 DISTINCT+ORDER BY 限制与 Hibernate 不支持 ORDER BY 标量子查询的限制。 */ private Specification buildSpecification(SysUserQuery query) { return (root, cbQuery, cb) -> { List 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 resolveGroupIds(Integer groupId, Integer withSub) { if (withSub != null && withSub == 1) { return sysGroupService.listSelfAndDescendantIds(groupId); } List single = new ArrayList<>(); single.add(groupId); return single; } @Override public List findUserRoles(Integer userId) { if (userId == null) { throw new DException("用户ID不能为空"); } // 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id // (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug) List rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId); List sorted = rels.stream() .sorted(Comparator.comparingInt((SysUserRoleRel r) -> mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1) .thenComparingInt(SysUserRoleRel::getSortNo) .thenComparingInt(SysUserRoleRel::getId)) .collect(Collectors.toList()); List result = new ArrayList<>(sorted.size()); for (SysUserRoleRel rel : sorted) { BoaRoleInfo info = new BoaRoleInfo(); info.setRoleId(rel.getRoleId()); info.setRoleName(roleName(rel.getRoleId())); info.setSchId(rel.getSchoolId()); info.setSchName(groupName(rel.getSchoolId())); // childId/childName 家长-孩子维度本期不做,恒空 result.add(info); } return result; } /** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */ private String roleName(Integer roleId) { if (roleId == null) { return null; } SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, roleId); return e != null ? e.getValue() : null; } /** 由组 id 反查组名;未知/非正 id 返回 null。 */ private String groupName(Integer groupId) { if (groupId == null || groupId <= 0) { return null; } SysGroup g = sysGroupRepository.findById(groupId).orElse(null); return g != null ? g.getName() : null; } @Override @Transactional public void changePassword(Integer userId, String oldPassword, String newPassword) { SysUser entity = sysUserRepository.findById(userId) .orElseThrow(() -> new DException("系统用户不存在")); if (StringUtils.isBlank(oldPassword) || StringUtils.isBlank(newPassword)) { throw new DException("新旧密码不能为空"); } if (!passwordEncoder.matches(oldPassword, entity.getPassword())) { throw new DException("原密码不正确"); } String weakReason = ValidatorUtil.checkPasswordStrength(newPassword); if (weakReason != null) { throw new DException(weakReason); } entity.setPassword(passwordEncoder.encode(newPassword)); entity.setUpdateUser(SYSTEM_USER_ID); sysUserRepository.save(entity); } /** 校验手机号与身份证(有值才校验)。 */ 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 密文。
*/ 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, null); } private SysUserVo convertToVo(SysUser entity, Integer sortNo, Integer groupStatus) { 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); vo.setGroupStatus(groupStatus); return vo; } /** * 推导用户类型(见 {@link UserTypeEnum}): * - 内置超级管理员账号 -> 1 超级管理员; * - 其余账号 sys_user_role_rel 正常态 roleId 并集含 ADMIN(7) -> 2 学校管理员; * - 其他 -> 0 普通用户。 * 数据源为 role_rel(sys_user.roles 列已停写),须在身份落库之后调用。 */ private UserTypeEnum resolveUserType(String account, Integer userId) { if (account != null && SUPER_ADMIN_ACCOUNTS.contains(account)) { return UserTypeEnum.SUPER_ADMIN; } List roleIds = sysUserRoleRelRepository .findDistinctRoleIdsByUserIdAndStatus(userId, StatusEnum.NORMAL); if (roleIds != null && roleIds.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)); // 身份列改走身份写入:createUser 按导入目标组解析 schoolId 落 sys_user_role_rel(roles 列停写) request.setRoleIds(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 buildImportRoles(Integer nature, Integer identity) { Set 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)); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } private void publishUserEvent(PushInterfaceType type, List userIds) { if (userIds == null || userIds.isEmpty()) { return; } DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setUserIds(userIds); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } /** 组维度的用户事件(如 DELETE_USER=解除组关系),额外携带组 id */ private void publishUserEvent(PushInterfaceType type, List userIds, Integer groupId) { if (userIds == null || userIds.isEmpty()) { return; } DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setUserIds(userIds); e.setGroupId(groupId); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); } }