1500 lines
68 KiB
Java
1500 lines
68 KiB
Java
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<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 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<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);
|
||
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<SysUserGroupDeleteItem> items) {
|
||
if (items == null || items.isEmpty()) {
|
||
throw new DException("批量删除用户-组关系列表不能为空");
|
||
}
|
||
for (SysUserGroupDeleteItem item : items) {
|
||
deleteFromGroup(item.getUserId(), item.getGroupId(), item.getSchoolId());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 学校边界校验:确保待操作的组属于指定学校范围,且不是学校根组本身。<br>
|
||
* 与 {@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<Integer> 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<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
|
||
@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<Integer> fromScope = sysGroupService.listSelfAndDescendantIds(fromSchoolId);
|
||
List<Integer> toScope = sysGroupService.listSelfAndDescendantIds(toSchoolId);
|
||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
|
||
|
||
// ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系(原组由转出标记隐含携带)
|
||
List<SysUserGroupRel> transferOutRels = rels.stream()
|
||
.filter(r -> r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId()))
|
||
.collect(Collectors.toList());
|
||
if (transferOutRels.isEmpty()) {
|
||
throw new DException("该用户在原学校不存在转出状态的组关系,禁止转入");
|
||
}
|
||
|
||
List<SysUserGroupRel> 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<SysUserVo> 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<SysUser> spec = buildSpecification(query);
|
||
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
|
||
return attachMainIdentityNames(page.map(u -> convertToVo(u, null, null)));
|
||
}
|
||
|
||
/**
|
||
* 按页批量带出各用户主校身份名(动态解析主身份,无 N+1)。
|
||
* roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。
|
||
*/
|
||
private Page<SysUserVo> attachMainIdentityNames(Page<SysUserVo> page) {
|
||
List<SysUserVo> content = page.getContent();
|
||
if (content.isEmpty()) {
|
||
return page;
|
||
}
|
||
List<Integer> userIds = content.stream()
|
||
.map(SysUserVo::getId)
|
||
.collect(Collectors.toList());
|
||
Map<Integer, SysUserRoleRel> 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<Integer, SysUserRoleRel> resolveMainRoleRelsByUser(List<Integer> userIds) {
|
||
return identityService.resolveMainIdentityByUser(userIds);
|
||
}
|
||
|
||
/**
|
||
* 组上下文列表:原生 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<>();
|
||
Map<Integer, Integer> 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<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), groupStatusMap.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());
|
||
}
|
||
|
||
// ===================== 用户身份(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<Integer> 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<Integer> 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) 防重)。空数组/空列表语义 = 清空该校身份(幂等)。<br>
|
||
* 保存完成后统一对齐(清理学校集合外身份行 + 激活身份滞留检查)。主身份已去字段化,不再写 is_main。
|
||
*/
|
||
private void saveSchoolIdentity(Integer userId, Integer schoolId, List<Integer> roleIds) {
|
||
validateRoleIds(roleIds);
|
||
sysUserRoleRelRepository.deleteByUserIdAndSchoolId(userId, schoolId);
|
||
if (roleIds != null && !roleIds.isEmpty()) {
|
||
LocalDateTime now = LocalDateTime.now();
|
||
List<SysUserRoleRel> 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 共用):<br>
|
||
* ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;<br>
|
||
* ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);<br>
|
||
* ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录,
|
||
* 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。<br>
|
||
* 主身份已去字段化(读时计算),此处不再维护 is_main 物化列。
|
||
*/
|
||
private void alignIdentity(Integer userId) {
|
||
Set<Integer> schoolSet = deriveSchoolSet(userId);
|
||
|
||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserId(userId);
|
||
List<SysUserRoleRel> 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 去重。<br>
|
||
* 不过滤 {@code sys_user_group_rel.status}:「转出」仅是流程标记,转入完成(transferToSchool
|
||
* 物理删除源校关系)后才视为真正离开;标记期内保留原校身份与业务现状一致。
|
||
*/
|
||
private Set<Integer> deriveSchoolSet(Integer userId) {
|
||
Set<Integer> set = new HashSet<>();
|
||
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
|
||
set.add(sysGroupService.resolveSchoolId(rel.getGroupId()));
|
||
}
|
||
return set;
|
||
}
|
||
|
||
@Override
|
||
@Transactional
|
||
public void changeGroups(List<Integer> userIds, List<Integer> 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<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) {
|
||
// 覆盖式重建前先读旧关系,用于继承组内状态(转出标记)与旧主组解析:
|
||
// 行级继承——同组照抄旧状态(管理端重设组不误改);用户级继承——新增组在用户处于转出流程中(存在任一转出行)时继承「转出」
|
||
List<SysUserGroupRel> oldRels = sysUserGroupRelRepository.findByUserId(userId);
|
||
Map<Integer, UserGroupRelStatusEnum> 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<SysUserGroupRel> 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);
|
||
}
|
||
|
||
/**
|
||
* 主归属组三级降级决策(纯函数,便于单测):<br>
|
||
* ① explicitMainGroupId 非空 → 直接返回(调用方已校验 ∈ groupIds);<br>
|
||
* ② oldMainRel 非空且其组 ∈ groupIds → 沿用旧主组(只要用户没把主组勾掉,主组不动);<br>
|
||
* ③ 否则 → groupIds 首项(现状行为兜底)。<br>
|
||
* groupIds 非空由调用方保证。
|
||
*/
|
||
static Integer decideEffectiveMainGroupId(Integer explicitMainGroupId, List<Integer> 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<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), 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);
|
||
|
||
// 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源)
|
||
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(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());
|
||
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<Integer, Integer> resolveUserMainGroup(List<Integer> userIds) {
|
||
Map<Integer, Integer> 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<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;
|
||
}
|
||
|
||
@Override
|
||
public List<BoaRoleInfo> findUserRoles(Integer userId) {
|
||
if (userId == null) {
|
||
throw new DException("用户ID不能为空");
|
||
}
|
||
// 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id
|
||
// (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug)
|
||
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
|
||
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
|
||
List<SysUserRoleRel> 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<BoaRoleInfo> 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 密文。<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, 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<Integer> 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<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));
|
||
e.setSourceAppKey(CallerAppContext.getAppKey());
|
||
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);
|
||
e.setSourceAppKey(CallerAppContext.getAppKey());
|
||
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);
|
||
e.setSourceAppKey(CallerAppContext.getAppKey());
|
||
eventPublisher.publishEvent(e);
|
||
}
|
||
}
|