fix(hrm): 修复修改员工手机号报 500 - 隔离软删除历史数据占用的唯一索引

现象:员工档案修改手机号,若该手机号被「已软删除」的历史档案占用,接口返回 500「系统异常」,日志报 DuplicateKeyException: Duplicate entry 'xxx' for key 'hrm_employee.uk_phone'

原因:hrm_employee.uk_phone 唯一索引建在 phone 列上(不含 deleted),已软删除的记录仍占用该手机号;而 selectByPhone 会被逻辑删除插件过滤,预校验查不到,写库时被数据库唯一索引拦下

修复(不改库结构):

1. 新增 HrmEmployeeMapper.selectByPhoneIncludeDeleted,可查到软删除记录

2. 新增 validatePhoneUniqueAndReleaseStale:手机号被软删除历史记录占用时自动清空其手机号释放索引;被活跃员工占用时抛业务异常「手机号已存在」

3. createEmployee / updateEmployee / importEmployeeList 三处统一走该校验

4. deleteEmployee 软删除前清空 phone 与 user_id,避免历史数据长期占用 uk_phone / uk_user_id

验证:改手机号同步账号OK;撞活跃员工返回业务错误(1030000002)不再500;撞软删除历史数据自动释放并保存成功
已修改2个文件
75 ■■■■■ 文件已修改
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java
@@ -7,6 +7,8 @@
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeePageReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
@@ -23,6 +25,17 @@
        return selectOne(HrmEmployeeDO::getPhone, phone);
    }
    /**
     * 按手机号查询员工,<b>包含已软删除的记录</b>
     *
     * <p>用于释放「软删除但仍占用唯一索引」的历史手机号:
     * hrm_employee.uk_phone 建在 phone 列上(不含 deleted),
     * 而 {@link #selectByPhone(String)} 会被逻辑删除插件过滤掉 deleted=1 的行,
     * 导致预校验查不到、写库时却撞唯一索引,最终抛 DuplicateKeyException 变成 500。</p>
     */
    @Select("SELECT * FROM hrm_employee WHERE phone = #{phone} LIMIT 1")
    HrmEmployeeDO selectByPhoneIncludeDeleted(@Param("phone") String phone);
    default HrmEmployeeDO selectByName(String name) {
        return selectOne(HrmEmployeeDO::getName, name);
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -38,6 +38,7 @@
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserSyncReqDTO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
@@ -107,10 +108,8 @@
        deptApi.validateDeptList(Collections.singleton(createReqVO.getDeptId()));
        // 2. 校验岗位
        postApi.validPostList(Collections.singleton(createReqVO.getPostId()));
        // 3. 校验手机号是否已存在
        if (employeeMapper.selectByPhone(createReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        }
        // 3. 校验手机号是否已存在(并释放软删除历史记录占用的手机号)
        validatePhoneUniqueAndReleaseStale(null, createReqVO.getPhone());
        // 4. 同步创建系统用户
        AdminUserCreateReqDTO userCreateReqDTO = new AdminUserCreateReqDTO();
        userCreateReqDTO.setUsername(createReqVO.getPhone());
@@ -167,6 +166,42 @@
        return employeeId;
    }
    /**
     * 统一手机号唯一性校验
     *
     * <p>背景:hrm_employee.uk_phone 是建在 phone 列上的唯一索引(不含 deleted),
     * 因此「已软删除但手机号未清空」的历史档案会长期占着这个手机号。
     * 此时 MyBatis-Plus 的 selectByPhone 因逻辑删除过滤查不到它,预校验通过,
     * 但写库时数据库仍会抛 DuplicateKeyException,接口表现为 500「系统异常」。</p>
     *
     * <p>处理策略(不改库结构):
     * 1. 若手机号被「已软删除」的历史记录占用 → 自动清空该历史记录的手机号,释放唯一索引;
     * 2. 若被「未删除」的其他员工占用 → 抛业务异常「手机号已存在」,不再返回 500。</p>
     *
     * @param selfId 当前正在新增/修改的员工编号(新增传 null);命中自身时跳过
     * @param phone  待校验的手机号
     */
    private void validatePhoneUniqueAndReleaseStale(Long selfId, String phone) {
        if (StrUtil.isBlank(phone)) {
            return;
        }
        // 1. 释放被软删除历史记录占用的手机号
        HrmEmployeeDO staleHolder = employeeMapper.selectByPhoneIncludeDeleted(phone);
        if (staleHolder != null && !Objects.equals(staleHolder.getId(), selfId)
                && Boolean.TRUE.equals(staleHolder.getDeleted())) {
            employeeMapper.update(null, new LambdaUpdateWrapper<HrmEmployeeDO>()
                    .eq(HrmEmployeeDO::getId, staleHolder.getId())
                    .set(HrmEmployeeDO::getPhone, null));
            log.info("[validatePhoneUniqueAndReleaseStale][释放历史软删除员工({})占用的手机号]",
                    staleHolder.getId());
        }
        // 2. 校验是否仍被活跃员工占用
        HrmEmployeeDO exist = employeeMapper.selectByPhone(phone);
        if (exist != null && !Objects.equals(exist.getId(), selfId)) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        }
    }
    private String generateUniqueEmployeeNo() {
        String employeeNo = noRedisDAO.generateEmployeeNo();
        if (employeeMapper.selectByEmployeeNo(employeeNo) != null) {
@@ -184,10 +219,9 @@
        deptApi.validateDeptList(Collections.singleton(updateReqVO.getDeptId()));
        // 校验岗位
        postApi.validPostList(Collections.singleton(updateReqVO.getPostId()));
        // 校验手机号是否已存在(如果手机号有变更)
        if (!Objects.equals(employee.getPhone(), updateReqVO.getPhone())
                && employeeMapper.selectByPhone(updateReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        // 校验手机号是否已存在(如果手机号有变更;并释放软删除历史记录占用的手机号)
        if (!Objects.equals(employee.getPhone(), updateReqVO.getPhone())) {
            validatePhoneUniqueAndReleaseStale(updateReqVO.getId(), updateReqVO.getPhone());
        }
        // 更新员工主表
        HrmEmployeeDO updateObj = BeanUtils.toBean(updateReqVO, HrmEmployeeDO.class);
@@ -286,7 +320,15 @@
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // 3. 删除员工
        // 3. 软删除前释放唯一约束字段,避免历史数据长期占用 uk_phone / uk_user_id,
        //    导致后续新档案使用同一手机号或绑定同一账号时撞数据库唯一索引(表现为 500)
        //    注:employee_no 为 NOT NULL 且 uk_employee_no 未含 deleted,这里不置空,
        //    但把 user_id 置空可释放 uk_user_id;手机号置空可释放 uk_phone。
        employeeMapper.update(null, new LambdaUpdateWrapper<HrmEmployeeDO>()
                .eq(HrmEmployeeDO::getId, id)
                .set(HrmEmployeeDO::getPhone, null)
                .set(HrmEmployeeDO::getUserId, null));
        // 4. 删除员工
        employeeMapper.deleteById(id);
    }
@@ -474,6 +516,8 @@
                    }
                }
                HrmEmployeeSaveReqVO save = buildImportSaveReqVO(row, deptMatches.get(0).getId(), postMatches.get(0).getId(), roleIds);
                // 导入前先释放被软删除历史记录占用的手机号,避免整行导入因唯一索引冲突失败
                validatePhoneUniqueAndReleaseStale(null, row.getPhone());
                HrmEmployeeDO existing = employeeMapper.selectByPhone(row.getPhone());
                if (existing == null) {
                    if (row.getGender() == null) throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "性别");