gaoluyang
8 小时以前 46d1b5c5ce9020a1022c3df492685e0d261408df
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java
@@ -14,6 +14,7 @@
import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils;
import cn.iocoder.yudao.module.infra.api.config.ConfigApi;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthRegisterReqVO;
import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO;
import cn.iocoder.yudao.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO;
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserImportExcelVO;
@@ -21,15 +22,18 @@
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserPageReqVO;
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.PostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.dept.UserPostMapper;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.mq.producer.user.AdminUserProducer;
import cn.iocoder.yudao.module.system.service.dept.DeptService;
import cn.iocoder.yudao.module.system.service.dept.PostService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import com.google.common.annotations.VisibleForTesting;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
@@ -54,7 +58,7 @@
/**
 * 后台用户 Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service("adminUserService")
@Slf4j
@@ -88,6 +92,9 @@
    @Resource
    private ConfigApi configApi;
    @Resource
    private AdminUserProducer adminUserProducer;
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}",
@@ -112,6 +119,10 @@
        if (CollectionUtil.isNotEmpty(user.getPostIds())) {
            userPostMapper.insertBatch(convertList(user.getPostIds(),
                    postId -> new UserPostDO().setUserId(user.getId()).setPostId(postId)));
        }
        // 2.3 分配用户角色
        if (CollUtil.isNotEmpty(createReqVO.getRoleIds())) {
            permissionService.assignUserRole(user.getId(), createReqVO.getRoleIds());
        }
        // 3. 记录操作日志上下文
@@ -158,6 +169,8 @@
        userMapper.updateById(updateObj);
        // 2.2 更新岗位
        updateUserPost(updateReqVO, updateObj);
        // 2.3 昵称 / 头像变化时,发送消息供下游订阅(如 IM 模块推 FRIEND_INFO_UPDATED)
        publishUserProfileUpdatedIfChanged(oldUser, updateReqVO.getNickname(), updateReqVO.getAvatar());
        // 3. 记录操作日志上下文
        LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldUser, UserSaveReqVO.class));
@@ -188,12 +201,30 @@
    @Override
    public void updateUserProfile(Long id, UserProfileUpdateReqVO reqVO) {
        // 校验正确性
        validateUserExists(id);
        // 1. 校验正确性
        AdminUserDO oldUser = validateUserExists(id);
        validateEmailUnique(id, reqVO.getEmail());
        validateMobileUnique(id, reqVO.getMobile());
        // 执行更新
        // 2. 执行更新
        userMapper.updateById(BeanUtils.toBean(reqVO, AdminUserDO.class).setId(id));
        // 3. 昵称 / 头像变化时,发送消息供下游订阅(如 IM 模块推 FRIEND_INFO_UPDATED)
        publishUserProfileUpdatedIfChanged(oldUser, reqVO.getNickname(), reqVO.getAvatar());
    }
    /**
     * 仅当 nickname 或 avatar 跟旧值不一致时,发送 AdminUserProfileUpdateMessage
     */
    private void publishUserProfileUpdatedIfChanged(AdminUserDO oldUser, String newNickname, String newAvatar) {
        boolean nicknameChanged = newNickname != null && !ObjUtil.equal(oldUser.getNickname(), newNickname);
        boolean avatarChanged = newAvatar != null && !ObjUtil.equal(oldUser.getAvatar(), newAvatar);
        if (!nicknameChanged && !avatarChanged) {
            return;
        }
        adminUserProducer.sendUserProfileUpdateMessage(oldUser.getId(),
                nicknameChanged ? newNickname : null,
                avatarChanged ? newAvatar : null);
    }
    @Override
@@ -329,6 +360,11 @@
        }
        return userMapper.selectByIds(ids);
    }
    public List<AdminUserDO> getUserListAll() {
        return userMapper.selectList();
    }
    @Override
    public void validateUserList(Collection<Long> ids) {
@@ -473,17 +509,19 @@
    }
    @Override
    @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_IMPORT_SUB_TYPE, bizNo = "0",
            success = SYSTEM_USER_IMPORT_SUCCESS)
    @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入
    public UserImportRespVO importUserList(List<UserImportExcelVO> importUsers, boolean isUpdateSupport) {
        // 1.1 参数校验
        if (CollUtil.isEmpty(importUsers)) {
            throw exception(USER_IMPORT_LIST_IS_EMPTY);
        }
        // 1.2 初始化密码不能为空
        // 1.2 初始化密码:Excel 的「初始密码」列留空时,使用系统配置的初始密码
        String initPassword = configApi.getConfigValueByKey(USER_INIT_PASSWORD_KEY);
        if (StrUtil.isEmpty(initPassword)) {
            throw exception(USER_IMPORT_INIT_PASSWORD);
        }
        // 1.3 预加载部门、岗位,用于把 Excel 里填写的「名称」翻译成编号
        List<DeptDO> deptList = DataPermissionUtils.executeIgnore(() -> deptService.getDeptList(new DeptListReqVO()));
        List<PostDO> postList = postService.getPostList(null, null);
        // 2. 遍历,逐个创建 or 更新
        UserImportRespVO respVO = UserImportRespVO.builder().createUsernames(new ArrayList<>())
@@ -491,42 +529,203 @@
        AtomicInteger index = new AtomicInteger(1);
        importUsers.forEach(importUser -> {
            int currentIndex = index.getAndIncrement();
            // 2.1.1 校验字段是否符合要求
            String username = StrUtil.trim(importUser.getUsername());
            // 失败明细的 key 定位到具体的行(并带上用户账号),方便用户在 Excel 中找到这一行
            String rowKey = StrUtil.format("第 {} 行{}", currentIndex,
                    StrUtil.isEmpty(username) ? "" : StrUtil.format("「{}」", username));
            try {
                ValidationUtils.validate(BeanUtils.toBean(importUser, UserSaveReqVO.class).setPassword(initPassword));
            } catch (ConstraintViolationException ex) {
                String key = StrUtil.blankToDefault(importUser.getUsername(), "第 " + currentIndex + " 行");
                respVO.getFailureUsernames().put(key, ex.getMessage());
                return;
            }
            // 2.1.2 校验,判断是否有不符合的原因
            try {
                validateUserForCreateOrUpdate(null, null, importUser.getMobile(), importUser.getEmail(),
                        importUser.getDeptId(), null);
            } catch (ServiceException ex) {
                respVO.getFailureUsernames().put(importUser.getUsername(), ex.getMessage());
                return;
            }
                // 2.1.1 解析部门、岗位(支持填名称,部门兼容填编号)
                Long deptId = parseDeptId(importUser.getDept(), deptList);
                Set<Long> postIds = parsePostIds(importUser.getPost(), postList);
                // 2.1.2 校验字段是否符合要求。密码优先取 Excel 的「初始密码」,为空时取系统配置
                String password = StrUtil.blankToDefault(StrUtil.trim(importUser.getPassword()), initPassword);
                UserSaveReqVO saveReqVO = BeanUtils.toBean(importUser, UserSaveReqVO.class)
                        .setUsername(username).setDeptId(deptId).setPostIds(postIds).setPassword(password);
                validateUserImportRow(saveReqVO, password);
                // 2.1.3 校验用户账号、手机号、邮箱唯一,以及部门、岗位可用
                validateUserForCreateOrUpdate(null, username, saveReqVO.getMobile(), saveReqVO.getEmail(),
                        deptId, postIds);
            // 2.2.1 判断如果不存在,在进行插入
            AdminUserDO existUser = userMapper.selectByUsername(importUser.getUsername());
            if (existUser == null) {
                userMapper.insert(BeanUtils.toBean(importUser, AdminUserDO.class)
                        .setPassword(encodePassword(initPassword)).setPostIds(new HashSet<>())); // 设置默认密码及空岗位编号数组
                respVO.getCreateUsernames().add(importUser.getUsername());
                return;
                // 2.2.1 判断如果不存在,在进行插入
                AdminUserDO existUser = userMapper.selectByUsername(username);
                if (existUser == null) {
                    AdminUserDO user = BeanUtils.toBean(saveReqVO, AdminUserDO.class)
                            .setPassword(encodePassword(password))
                            .setStatus(ObjUtil.defaultIfNull(importUser.getStatus(),
                                    CommonStatusEnum.ENABLE.getStatus())); //「账号状态」留空时默认开启
                    userMapper.insert(user);
                    insertUserPost(user.getId(), postIds); // 同步岗位关联,避免导入出来的用户没有岗位
                    respVO.getCreateUsernames().add(username);
                    return;
                }
                // 2.2.2 如果存在,判断是否允许更新
                if (!isUpdateSupport) {
                    throw new ServiceException(USER_USERNAME_EXISTS.getCode(),
                            "用户账号已存在。如需覆盖这个用户,请勾选「是否覆盖」后重新导入;否则请修改 Excel 中的用户账号");
                }
                AdminUserDO updateUser = BeanUtils.toBean(saveReqVO, AdminUserDO.class)
                        .setId(existUser.getId()).setPassword(null) // 覆盖时不改密码,避免把在用账号的密码改掉
                        .setStatus(importUser.getStatus()); //「账号状态」留空时保持原状态
                userMapper.updateById(updateUser);
                userPostMapper.deleteByUserId(existUser.getId());
                insertUserPost(existUser.getId(), postIds);
                respVO.getUpdateUsernames().add(username);
            } catch (ConstraintViolationException ex) {
                respVO.getFailureUsernames().put(rowKey, buildConstraintFailureMessage(ex));
            } catch (ServiceException ex) {
                respVO.getFailureUsernames().put(rowKey, buildImportFailureMessage(ex, importUser));
            }
            // 2.2.2 如果存在,判断是否允许更新
            if (!isUpdateSupport) {
                respVO.getFailureUsernames().put(importUser.getUsername(), USER_USERNAME_EXISTS.getMsg());
                return;
            }
            AdminUserDO updateUser = BeanUtils.toBean(importUser, AdminUserDO.class);
            updateUser.setId(existUser.getId());
            userMapper.updateById(updateUser);
            respVO.getUpdateUsernames().add(importUser.getUsername());
        });
        // 记录导入结果汇总,供 @LogRecord 的 success 文案引用
        OperateLogUtils.putImportSummary(respVO.getCreateUsernames(), respVO.getUpdateUsernames(),
                respVO.getFailureUsernames());
        return respVO;
    }
    /** Bean 校验失败时的字段名 -> 给用户的修改建议 */
    private static final Map<String, String> USER_IMPORT_FIELD_HINTS = Map.of(
            "username", "「用户账号」必须填写,且只能由 4-30 位数字、字母组成(例如 zhangsan)",
            "nickname", "「用户昵称」最多 30 个字符",
            "password", "「初始密码」长度为 4-16 位",
            "email", "「用户邮箱」格式不正确,请填写形如 zhangsan@example.com 的邮箱,或留空该列",
            "mobile", "「手机号码」格式不正确,请填写 11 位手机号(例如 13800000000),或留空该列");
    /**
     * 校验单行的账号、昵称、密码等字段是否符合要求
     *
     * @param saveReqVO Excel 行转换出的用户信息
     * @param password  该行最终使用的密码
     */
    private void validateUserImportRow(UserSaveReqVO saveReqVO, String password) {
        if (StrUtil.isEmpty(password)) {
            throw new ServiceException(USER_IMPORT_INIT_PASSWORD.getCode(),
                    "未填写「初始密码」,且系统没有配置默认的初始密码。请在 Excel 的「初始密码」列填写 4-16 位密码,"
                            + "或让管理员在「基础设施 → 配置管理」中配置 system.user.init-password");
        }
        ValidationUtils.validate(saveReqVO);
    }
    /** 把 Bean 校验结果整理成「哪个字段、怎么改」 */
    private static String buildConstraintFailureMessage(ConstraintViolationException ex) {
        List<String> messages = new ArrayList<>();
        ex.getConstraintViolations().forEach(violation -> {
            // 校验的是 Bean 本身,属性路径就是字段名(如 username),兼容带前缀的写法
            String path = violation.getPropertyPath().toString();
            String field = StrUtil.contains(path, '.') ? StrUtil.subAfter(path, '.', true) : path;
            messages.add(StrUtil.blankToDefault(USER_IMPORT_FIELD_HINTS.get(field), violation.getMessage()));
        });
        return String.join(";", messages);
    }
    /** 把系统内置的校验异常,补充成用户看得懂、知道怎么改的提示 */
    private static String buildImportFailureMessage(ServiceException ex, UserImportExcelVO importUser) {
        if (ObjUtil.equal(ex.getCode(), USER_USERNAME_EXISTS.getCode())) {
            return "用户账号已存在,如需覆盖请勾选「是否覆盖」后重新导入,否则请修改 Excel 中的用户账号";
        }
        if (ObjUtil.equal(ex.getCode(), USER_MOBILE_EXISTS.getCode())) {
            return StrUtil.format("手机号码「{}」已被其他用户使用,请更换手机号,或留空「手机号码」列", importUser.getMobile());
        }
        if (ObjUtil.equal(ex.getCode(), USER_EMAIL_EXISTS.getCode())) {
            return StrUtil.format("邮箱「{}」已被其他用户使用,请更换邮箱,或留空「用户邮箱」列", importUser.getEmail());
        }
        return ex.getMessage();
    }
    /**
     * 解析 Excel「部门」列:支持填写部门名称,也兼容填写部门编号
     *
     * @param deptText Excel 中填写的内容,允许为空(表示暂不归属部门)
     * @return 部门编号
     */
    private Long parseDeptId(String deptText, List<DeptDO> deptList) {
        if (StrUtil.isEmpty(deptText)) {
            return null;
        }
        String text = StrUtil.trim(deptText);
        // 情况一:填写的是部门编号
        if (StrUtil.isNumeric(text) && text.length() <= 18) {
            long deptId = Long.parseLong(text);
            DeptDO dept = deptList.stream().filter(item -> ObjUtil.equal(item.getId(), deptId))
                    .findFirst().orElse(null);
            if (dept == null) {
                throw new ServiceException(USER_IMPORT_DEPT_NOT_EXISTS.getCode(), StrUtil.format(
                        "「部门」列填写的部门编号「{}」不存在,请在「系统管理 → 部门」中查看正确的部门编号,或直接填写部门名称", text));
            }
            validateMasterDataEnabled(dept.getStatus(), StrUtil.format("部门「{}」已停用", dept.getName()),
                    "请先在「系统管理 → 部门」中启用该部门");
            return deptId;
        }
        // 情况二:填写的是部门名称
        List<DeptDO> matched = deptList.stream()
                .filter(item -> StrUtil.equals(text, StrUtil.trim(item.getName()))).toList();
        if (matched.isEmpty()) {
            throw new ServiceException(USER_IMPORT_DEPT_NOT_EXISTS.getCode(), StrUtil.format(
                    "「部门」列填写的部门「{}」不存在,请先在「系统管理 → 部门」中创建,或改成已存在的部门名称/编号", text));
        }
        if (matched.size() > 1) {
            throw new ServiceException(USER_IMPORT_DEPT_NAME_DUPLICATE.getCode(), StrUtil.format(
                    "部门名称「{}」在系统里有 {} 个同名部门,请改填「部门编号」(可在「系统管理 → 部门」中查看)", text, matched.size()));
        }
        DeptDO dept = matched.get(0);
        validateMasterDataEnabled(dept.getStatus(), StrUtil.format("部门「{}」已停用", dept.getName()),
                "请先在「系统管理 → 部门」中启用该部门");
        return dept.getId();
    }
    /**
     * 解析 Excel「岗位」列:填写岗位名称,多个岗位用「、」「,」「;」分隔
     *
     * @return 岗位编号集合,至少一个
     */
    private Set<Long> parsePostIds(String postText, List<PostDO> postList) {
        if (StrUtil.isEmpty(postText)) {
            throw new ServiceException(USER_IMPORT_POST_EMPTY.getCode(),
                    "未填写「岗位」,请填写岗位名称;多个岗位用「、」分隔,例如:普工、班组长");
        }
        Set<Long> postIds = new HashSet<>();
        List<String> notExistsNames = new ArrayList<>();
        for (String part : postText.split("[、,,;;/\\s]+")) {
            String name = StrUtil.trim(part);
            if (StrUtil.isEmpty(name)) {
                continue;
            }
            PostDO post = postList.stream().filter(item -> StrUtil.equals(name, StrUtil.trim(item.getName())))
                    .findFirst().orElse(null);
            if (post == null) {
                notExistsNames.add(name);
                continue;
            }
            validateMasterDataEnabled(post.getStatus(), StrUtil.format("岗位「{}」已停用", post.getName()),
                    "请先在「系统管理 → 岗位」中启用该岗位");
            postIds.add(post.getId());
        }
        if (!notExistsNames.isEmpty()) {
            throw new ServiceException(USER_IMPORT_POST_NOT_EXISTS.getCode(), StrUtil.format(
                    "「岗位」列填写的岗位「{}」不存在,请先在「系统管理 → 岗位」中维护,或改成已存在的岗位名称",
                    String.join("、", notExistsNames)));
        }
        if (postIds.isEmpty()) { // 例如只填了分隔符
            throw new ServiceException(USER_IMPORT_POST_EMPTY.getCode(),
                    "未填写「岗位」,请填写岗位名称;多个岗位用「、」分隔,例如:普工、班组长");
        }
        return postIds;
    }
    /** 校验部门、岗位是否处于开启状态 */
    private static void validateMasterDataEnabled(Integer status, String disabledTip, String actionTip) {
        if (ObjUtil.equal(status, CommonStatusEnum.DISABLE.getStatus())) {
            throw new ServiceException(USER_IMPORT_MASTER_DATA_DISABLED.getCode(),
                    StrUtil.format("{},{}", disabledTip, actionTip));
        }
    }
    /** 写入用户与岗位的关联,导入出来的用户同样要有岗位(否则在页面上编辑保存不了) */
    private void insertUserPost(Long userId, Set<Long> postIds) {
        if (CollUtil.isEmpty(postIds)) {
            return;
        }
        userPostMapper.insertBatch(convertList(postIds,
                postId -> new UserPostDO().setUserId(userId).setPostId(postId)));
    }
    @Override
@@ -535,6 +734,11 @@
    }
    @Override
    public List<AdminUserDO> getDeptUsers(Collection<Long> deptIds) {
        return userMapper.selectListByDeptIds(deptIds);
    }
    @Override
    public boolean isPasswordMatch(String rawPassword, String encodedPassword) {
        return passwordEncoder.matches(rawPassword, encodedPassword);
    }