liu
2026-10-28 cc5438a25896d19f02b652865781dc443b274354
feat(hrm): 员工档案与系统账号双向同步

- 删除员工联动删除系统账号(admin/多档案账号做保护)
- 删除系统账号联动删除员工档案及子表(无档案账号自动跳过)
- 修改员工/账号时同步姓名、手机号、部门、性别、邮箱
- system -> hrm 通过 Spring 事件解耦,hrm -> system 使用静默 API 避免事件回环
- 补充 Mockito 单元测试(12 个用例)

Co-Authored-By: Claude <noreply@anthropic.com>
已添加6个文件
已修改5个文件
723 ■■■■■ 文件已修改
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/listener/HrmEmployeeUserSyncListener.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImplSyncTest.java 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/employee/listener/HrmEmployeeUserSyncListenerTest.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserSyncReqDTO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/event/AdminUserDeletedEvent.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/event/AdminUserUpdatedEvent.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -37,7 +37,9 @@
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -65,6 +67,7 @@
 */
@Service
@Validated
@Slf4j
public class HrmEmployeeServiceImpl implements HrmEmployeeService {
    @Resource
@@ -201,12 +204,39 @@
            adminUserApi.updateUserPassword(employee.getUserId(), updateReqVO.getPassword());
        }
        // åŒæ­¥ç³»ç»Ÿè´¦å·å¯å¯¹åº”字段(姓名 / æ‰‹æœºå· / éƒ¨é—¨ / æ€§åˆ« / é‚®ç®±ï¼‰
        if (employee.getUserId() != null) {
            syncSystemUserForEmployee(employee, updateReqVO);
        }
        // åŒæ­¥æ•™è‚²ç»åކ
        syncEducationList(employeeId, updateReqVO.getEducations());
        // åŒæ­¥å·¥ä½œç»åކ
        syncWorkHistoryList(employeeId, updateReqVO.getWorkHistories());
        // åŒæ­¥ç´§æ€¥è”系人
        syncEmergencyContactList(employeeId, updateReqVO.getEmergencyContacts());
    }
    /**
     * å‘˜å·¥ä¿®æ”¹åŽï¼ŒæŠŠå¯ä¸€ä¸€å¯¹åº”的字段同步到系统账号。
     *
     * åªåŒæ­¥ï¼šå§“名↔昵称、手机号↔mobile、部门、性别、邮箱。
     * ç™»å½•名 username、岗位、角色、状态不同步。
     * åŒæ­¥å¤±è´¥åªè®°å½•日志,不阻断员工修改主流程。
     */
    private void syncSystemUserForEmployee(HrmEmployeeDO employee, HrmEmployeeSaveReqVO reqVO) {
        try {
            AdminUserSyncReqDTO dto = new AdminUserSyncReqDTO();
            dto.setUserId(employee.getUserId());
            dto.setNickname(reqVO.getName() != null ? reqVO.getName() : employee.getName());
            dto.setMobile(reqVO.getPhone() != null ? reqVO.getPhone() : employee.getPhone());
            dto.setDeptId(reqVO.getDeptId() != null ? reqVO.getDeptId() : employee.getDeptId());
            dto.setSex(reqVO.getGender() != null ? reqVO.getGender() : employee.getGender());
            dto.setEmail(reqVO.getEmail() != null ? reqVO.getEmail() : employee.getEmail());
            adminUserApi.updateUserForEmployee(dto);
        } catch (Exception ex) {
            log.warn("[updateEmployee][员工({}) åŒæ­¥è´¦å·({}) å¤±è´¥ï¼Œå·²å¿½ç•¥]", employee.getId(), employee.getUserId(), ex);
        }
    }
    private void syncEducationList(Long employeeId, List<HrmEmployeeEducationSaveReqVO> newList) {
@@ -249,15 +279,42 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteEmployee(Long id) {
        // æ ¡éªŒå­˜åœ¨
        validateEmployeeExists(id);
        // çº§è”删除子表
        HrmEmployeeDO employee = validateEmployeeExists(id);
        // 1. è”动删除系统账号(不发布事件,避免回环)
        deleteLinkedSystemUser(employee);
        // 2. çº§è”删除子表
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // åˆ é™¤å‘˜å·¥
        // 3. åˆ é™¤å‘˜å·¥
        employeeMapper.deleteById(id);
    }
    /**
     * åˆ é™¤å‘˜å·¥æ—¶è”动删除其系统账号。
     *
     * å®ˆå«ï¼š
     * 1. æœªç»‘定账号 -> è·³è¿‡
     * 2. å†…ç½® admin(id=1) -> è·³è¿‡
     * 3. åŒä¸€è´¦å·è¢«å¤šæ¡å‘˜å·¥æ¡£æ¡ˆå¼•用 -> è·³è¿‡ï¼ˆä¸€å‘˜å·¥ä¸€è´¦å·çº¦å®šä¸‹ä¸åº”出现)
     */
    private void deleteLinkedSystemUser(HrmEmployeeDO employee) {
        Long userId = employee.getUserId();
        if (userId == null) {
            return;
        }
        if (Long.valueOf(1L).equals(userId)) {
            log.warn("[deleteEmployee][员工({}) ç»‘定的是内置管理员账号({}),不自动删除账号]", employee.getId(), userId);
            return;
        }
        Long count = employeeMapper.selectCount(HrmEmployeeDO::getUserId, userId);
        if (count != null && count > 1) {
            log.warn("[deleteEmployee][账号({}) å…³è”了 {} æ¡å‘˜å·¥æ¡£æ¡ˆï¼Œä¸è‡ªåŠ¨åˆ é™¤è´¦å·]", userId, count);
            return;
        }
        adminUserApi.deleteUserSilently(userId);
    }
    private HrmEmployeeDO validateEmployeeExists(Long id) {
        HrmEmployeeDO employee = employeeMapper.selectById(id);
        if (employee == null) {
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/listener/HrmEmployeeUserSyncListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.hrm.service.employee.listener;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeWorkHistoryMapper;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserDeletedEvent;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserUpdatedEvent;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
/**
 * ç³»ç»Ÿè´¦å·å˜æ›´ -> å‘˜å·¥æ¡£æ¡ˆ åŒæ­¥ç›‘听器
 *
 * è®¾è®¡è¯´æ˜Žï¼š
 * 1. åªå¤„理「已存在员工档案」的账号;没有员工档案的账号(admin、test、用户管理直接建的账号)直接跳过,不报错。
 * 2. ä½¿ç”¨ {@link EventListener} åŒæ­¥æ‰§è¡Œï¼Œä¸Žå‘布方处于同一事务,保证一致性。
 * 3. ç›‘听器内只使用 Mapper,不调用会再次发布事件的 Service,避免死循环。
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class HrmEmployeeUserSyncListener {
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmEmployeeEducationMapper educationMapper;
    @Resource
    private HrmEmployeeWorkHistoryMapper workHistoryMapper;
    @Resource
    private HrmEmployeeEmergencyContactMapper emergencyContactMapper;
    /**
     * åˆ é™¤è´¦å· -> è”动删除员工档案及子表
     */
    @EventListener
    public void onUserDeleted(AdminUserDeletedEvent event) {
        Long userId = event.getUserId();
        if (userId == null) {
            return;
        }
        List<HrmEmployeeDO> employees = employeeMapper.selectList(HrmEmployeeDO::getUserId, userId);
        if (employees.isEmpty()) {
            // admin/test/未建员工档案的账号,直接跳过
            return;
        }
        if (employees.size() > 1) {
            // ä¸€å‘˜å·¥ä¸€è´¦å·çº¦å®šä¸‹ä¸åº”出现;为安全起见不自动删除
            log.error("[onUserDeleted][用户({}) å…³è”了 {} æ¡å‘˜å·¥æ¡£æ¡ˆï¼Œé¢„期为 1 æ¡ï¼Œè·³è¿‡è‡ªåŠ¨åˆ é™¤]", userId, employees.size());
            return;
        }
        Long employeeId = employees.get(0).getId();
        deleteEmployeeCascade(employeeId);
        log.info("[onUserDeleted][用户({}) åˆ é™¤ï¼Œå·²è”动删除员工档案({})]", userId, employeeId);
    }
    /**
     * ä¿®æ”¹è´¦å· -> å›žå†™å‘˜å·¥æ¡£æ¡ˆå¯å¯¹åº”字段
     */
    @EventListener
    public void onUserUpdated(AdminUserUpdatedEvent event) {
        Long userId = event.getUserId();
        if (userId == null) {
            return;
        }
        List<HrmEmployeeDO> employees = employeeMapper.selectList(HrmEmployeeDO::getUserId, userId);
        if (employees.isEmpty()) {
            // æ²¡æœ‰å‘˜å·¥æ¡£æ¡ˆçš„账号,直接跳过
            return;
        }
        if (employees.size() > 1) {
            log.error("[onUserUpdated][用户({}) å…³è”了 {} æ¡å‘˜å·¥æ¡£æ¡ˆï¼Œé¢„期为 1 æ¡ï¼Œè·³è¿‡åŒæ­¥]", userId, employees.size());
            return;
        }
        try {
            HrmEmployeeDO updateObj = new HrmEmployeeDO();
            updateObj.setId(employees.get(0).getId());
            if (StrUtil.isNotBlank(event.getNickname())) {
                updateObj.setName(event.getNickname());
            }
            if (StrUtil.isNotBlank(event.getMobile())) {
                updateObj.setPhone(event.getMobile());
            }
            if (event.getDeptId() != null) {
                updateObj.setDeptId(event.getDeptId());
            }
            if (event.getSex() != null && event.getSex() != 0) {
                updateObj.setGender(event.getSex());
            }
            if (StrUtil.isNotBlank(event.getEmail())) {
                updateObj.setEmail(event.getEmail());
            }
            employeeMapper.updateById(updateObj);
            log.info("[onUserUpdated][用户({}) ä¿®æ”¹ï¼Œå·²åŒæ­¥å‘˜å·¥æ¡£æ¡ˆ({})]", userId, updateObj.getId());
        } catch (Exception ex) {
            // ä¿®æ”¹åŒæ­¥å¤±è´¥ä¸å½±å“è´¦å·ä¿®æ”¹ä¸»æµç¨‹ï¼Œä»…记录日志
            log.error("[onUserUpdated][用户({}) åŒæ­¥å‘˜å·¥æ¡£æ¡ˆå¤±è´¥]", userId, ex);
        }
    }
    private void deleteEmployeeCascade(Long employeeId) {
        educationMapper.deleteByEmployeeId(employeeId);
        workHistoryMapper.deleteByEmployeeId(employeeId);
        emergencyContactMapper.deleteByEmployeeId(employeeId);
        employeeMapper.deleteById(employeeId);
    }
}
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImplSyncTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,179 @@
package cn.iocoder.yudao.module.hrm.service.employee;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeWorkHistoryMapper;
import cn.iocoder.yudao.module.hrm.dal.redis.HrmNoRedisDAO;
import cn.iocoder.yudao.module.infra.api.config.ConfigApi;
import cn.iocoder.yudao.module.mes.api.cal.MesCalTeamApi;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.PostApi;
import cn.iocoder.yudao.module.system.api.permission.RoleApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserSyncReqDTO;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
 * å‘˜å·¥åˆ é™¤/修改 -> ç³»ç»Ÿè´¦å· åŒæ­¥å•元测试
 */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class HrmEmployeeServiceImplSyncTest {
    private static final Long EMPLOYEE_ID = 1L;
    private static final Long USER_ID = 200L;
    @Mock
    private HrmEmployeeMapper employeeMapper;
    @Mock
    private HrmEmployeeEducationMapper educationMapper;
    @Mock
    private HrmEmployeeWorkHistoryMapper workHistoryMapper;
    @Mock
    private HrmEmployeeEmergencyContactMapper emergencyContactMapper;
    @Mock
    private HrmNoRedisDAO noRedisDAO;
    @Mock
    private DeptApi deptApi;
    @Mock
    private PostApi postApi;
    @Mock
    private AdminUserApi adminUserApi;
    @Mock
    private RoleApi roleApi;
    @Mock
    private MesCalTeamApi mesCalTeamApi;
    @Mock
    private ConfigApi configApi;
    @Mock
    private HrmEmployeeService self;
    @InjectMocks
    private HrmEmployeeServiceImpl service;
    private HrmEmployeeDO employee(Long id, Long userId) {
        return HrmEmployeeDO.builder().id(id).userId(userId).build();
    }
    // ==================== åˆ é™¤å‘˜å·¥ ====================
    @Test
    @DisplayName("删除员工:已绑定账号时,联动删除系统账号")
    void deleteEmployee_withUser_deleteAccount() {
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(employee(EMPLOYEE_ID, USER_ID));
        when(employeeMapper.selectCount(any(SFunction.class), eq(USER_ID))).thenReturn(1L);
        service.deleteEmployee(EMPLOYEE_ID);
        verify(adminUserApi).deleteUserSilently(USER_ID);
        verify(educationMapper).deleteByEmployeeId(EMPLOYEE_ID);
        verify(workHistoryMapper).deleteByEmployeeId(EMPLOYEE_ID);
        verify(emergencyContactMapper).deleteByEmployeeId(EMPLOYEE_ID);
        verify(employeeMapper).deleteById(EMPLOYEE_ID);
    }
    @Test
    @DisplayName("删除员工:未绑定账号时,不调用账号删除")
    void deleteEmployee_noUser_skip() {
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(employee(EMPLOYEE_ID, null));
        service.deleteEmployee(EMPLOYEE_ID);
        verify(adminUserApi, never()).deleteUserSilently(anyLong());
        verify(employeeMapper).deleteById(EMPLOYEE_ID);
    }
    @Test
    @DisplayName("删除员工:账号是内置 admin(id=1) æ—¶ï¼Œä¸åˆ é™¤è´¦å·")
    void deleteEmployee_adminUser_skip() {
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(employee(EMPLOYEE_ID, 1L));
        service.deleteEmployee(EMPLOYEE_ID);
        verify(adminUserApi, never()).deleteUserSilently(anyLong());
        verify(employeeMapper).deleteById(EMPLOYEE_ID);
    }
    @Test
    @DisplayName("删除员工:账号被多条员工档案引用时,不删除账号")
    void deleteEmployee_multiEmployees_skip() {
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(employee(EMPLOYEE_ID, USER_ID));
        when(employeeMapper.selectCount(any(SFunction.class), eq(USER_ID))).thenReturn(2L);
        service.deleteEmployee(EMPLOYEE_ID);
        verify(adminUserApi, never()).deleteUserSilently(anyLong());
        verify(employeeMapper).deleteById(EMPLOYEE_ID);
    }
    // ==================== ä¿®æ”¹å‘˜å·¥ ====================
    @Test
    @DisplayName("修改员工:把可对应字段同步到账号")
    void updateEmployee_syncToAccount() {
        HrmEmployeeDO old = HrmEmployeeDO.builder()
                .id(EMPLOYEE_ID).userId(USER_ID).name("旧名").phone("13800000000")
                .deptId(10L).gender(1).email("old@x.com").build();
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(old);
        when(employeeMapper.selectByPhone("13900000000")).thenReturn(null);
        HrmEmployeeSaveReqVO reqVO = new HrmEmployeeSaveReqVO();
        reqVO.setId(EMPLOYEE_ID);
        reqVO.setName("新名");
        reqVO.setPhone("13900000000");
        reqVO.setDeptId(11L);
        reqVO.setPostId(2L);
        reqVO.setGender(2);
        reqVO.setEmail("new@x.com");
        service.updateEmployee(reqVO);
        ArgumentCaptor<AdminUserSyncReqDTO> captor = ArgumentCaptor.forClass(AdminUserSyncReqDTO.class);
        verify(adminUserApi).updateUserForEmployee(captor.capture());
        AdminUserSyncReqDTO dto = captor.getValue();
        assertEquals(USER_ID, dto.getUserId());
        assertEquals("新名", dto.getNickname());
        assertEquals("13900000000", dto.getMobile());
        assertEquals(11L, dto.getDeptId());
        assertEquals(2, dto.getSex());
        assertEquals("new@x.com", dto.getEmail());
    }
    @Test
    @DisplayName("修改员工:未绑定账号时,不同步账号")
    void updateEmployee_noUser_skip() {
        HrmEmployeeDO old = HrmEmployeeDO.builder()
                .id(EMPLOYEE_ID).userId(null).name("旧名").phone("13800000000")
                .deptId(10L).gender(1).build();
        when(employeeMapper.selectById(EMPLOYEE_ID)).thenReturn(old);
        HrmEmployeeSaveReqVO reqVO = new HrmEmployeeSaveReqVO();
        reqVO.setId(EMPLOYEE_ID);
        reqVO.setName("新名");
        reqVO.setPhone("13800000000");
        reqVO.setDeptId(10L);
        reqVO.setPostId(2L);
        reqVO.setGender(1);
        service.updateEmployee(reqVO);
        verify(adminUserApi, never()).updateUserForEmployee(any());
    }
}
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/employee/listener/HrmEmployeeUserSyncListenerTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,132 @@
package cn.iocoder.yudao.module.hrm.service.employee.listener;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeWorkHistoryMapper;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserDeletedEvent;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserUpdatedEvent;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
 * è´¦å· -> å‘˜å·¥æ¡£æ¡ˆ åŒæ­¥ç›‘听器单元测试
 */
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class HrmEmployeeUserSyncListenerTest {
    private static final Long USER_ID = 200L;
    @Mock
    private HrmEmployeeMapper employeeMapper;
    @Mock
    private HrmEmployeeEducationMapper educationMapper;
    @Mock
    private HrmEmployeeWorkHistoryMapper workHistoryMapper;
    @Mock
    private HrmEmployeeEmergencyContactMapper emergencyContactMapper;
    @InjectMocks
    private HrmEmployeeUserSyncListener listener;
    private void mockEmployees(List<HrmEmployeeDO> employees) {
        when(employeeMapper.selectList(any(SFunction.class), eq(USER_ID))).thenReturn(employees);
    }
    @Test
    @DisplayName("删除账号:没有员工档案时直接跳过,不报错")
    void onUserDeleted_noEmployee_skip() {
        mockEmployees(Collections.emptyList());
        listener.onUserDeleted(new AdminUserDeletedEvent(USER_ID));
        verify(employeeMapper, never()).deleteById(anyLong());
        verifyNoInteractions(educationMapper, workHistoryMapper, emergencyContactMapper);
    }
    @Test
    @DisplayName("删除账号:存在 1 æ¡å‘˜å·¥æ¡£æ¡ˆæ—¶ï¼Œè”动删除员工及子表")
    void onUserDeleted_oneEmployee_deleteCascade() {
        mockEmployees(Collections.singletonList(HrmEmployeeDO.builder().id(10L).userId(USER_ID).build()));
        listener.onUserDeleted(new AdminUserDeletedEvent(USER_ID));
        verify(educationMapper).deleteByEmployeeId(10L);
        verify(workHistoryMapper).deleteByEmployeeId(10L);
        verify(emergencyContactMapper).deleteByEmployeeId(10L);
        verify(employeeMapper).deleteById(10L);
    }
    @Test
    @DisplayName("删除账号:存在多条员工档案时,不自动删除并跳过")
    void onUserDeleted_multiEmployees_skip() {
        mockEmployees(List.of(
                HrmEmployeeDO.builder().id(10L).userId(USER_ID).build(),
                HrmEmployeeDO.builder().id(11L).userId(USER_ID).build()));
        listener.onUserDeleted(new AdminUserDeletedEvent(USER_ID));
        verify(employeeMapper, never()).deleteById(anyLong());
        verifyNoInteractions(educationMapper, workHistoryMapper, emergencyContactMapper);
    }
    @Test
    @DisplayName("修改账号:没有员工档案时直接跳过")
    void onUserUpdated_noEmployee_skip() {
        mockEmployees(Collections.emptyList());
        listener.onUserUpdated(new AdminUserUpdatedEvent(USER_ID, "新名字", "13900000000", 11L, 2, "new@x.com"));
        verify(employeeMapper, never()).updateById(any(HrmEmployeeDO.class));
    }
    @Test
    @DisplayName("修改账号:回写可对应字段")
    void onUserUpdated_syncFields() {
        mockEmployees(Collections.singletonList(HrmEmployeeDO.builder().id(10L).userId(USER_ID).build()));
        listener.onUserUpdated(new AdminUserUpdatedEvent(USER_ID, "新名字", "13900000000", 11L, 2, "new@x.com"));
        ArgumentCaptor<HrmEmployeeDO> captor = ArgumentCaptor.forClass(HrmEmployeeDO.class);
        verify(employeeMapper).updateById(captor.capture());
        HrmEmployeeDO updateObj = captor.getValue();
        assertEquals(10L, updateObj.getId());
        assertEquals("新名字", updateObj.getName());
        assertEquals("13900000000", updateObj.getPhone());
        assertEquals(11L, updateObj.getDeptId());
        assertEquals(2, updateObj.getGender());
        assertEquals("new@x.com", updateObj.getEmail());
    }
    @Test
    @DisplayName("修改账号:性别未知(0)时不覆盖员工性别")
    void onUserUpdated_sexUnknown_notOverride() {
        mockEmployees(Collections.singletonList(HrmEmployeeDO.builder().id(10L).userId(USER_ID).build()));
        listener.onUserUpdated(new AdminUserUpdatedEvent(USER_ID, null, null, null, 0, null));
        ArgumentCaptor<HrmEmployeeDO> captor = ArgumentCaptor.forClass(HrmEmployeeDO.class);
        verify(employeeMapper).updateById(captor.capture());
        assertNull(captor.getValue().getGender());
        assertNull(captor.getValue().getName());
        assertNull(captor.getValue().getPhone());
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserSyncReqDTO;
import java.util.Collection;
import java.util.Collections;
@@ -33,6 +34,25 @@
    void updateUserPassword(Long id, String password);
    /**
     * åˆ é™¤ç”¨æˆ·ï¼Œä½†**不发布** {@code AdminUserDeletedEvent}
     *
     * ä¾› hrm æ¨¡å—删除员工时联动删除账号,避免事件回环(员工删 â†’ è´¦å·åˆ  â†’ å‘˜å·¥åˆ ï¼‰ã€‚
     *
     * @param id ç”¨æˆ·ç¼–号
     */
    void deleteUserSilently(Long id);
    /**
     * å°†å‘˜å·¥æ¡£æ¡ˆå­—段同步到系统账号,但**不发布** {@code AdminUserUpdatedEvent}
     *
     * ä¾› hrm æ¨¡å—修改员工时反向同步账号,避免事件回环。
     * åªæ›´æ–° DTO ä¸­éžç©ºçš„字段。
     *
     * @param reqDTO åŒæ­¥ä¿¡æ¯
     */
    void updateUserForEmployee(AdminUserSyncReqDTO reqDTO);
    /**
     * é€šè¿‡ç”¨æˆ· ID æŸ¥è¯¢ç”¨æˆ·
     *
     * @param id ç”¨æˆ·ID
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserSyncReqDTO;
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.user.AdminUserDO;
@@ -41,6 +42,16 @@
    }
    @Override
    public void deleteUserSilently(Long id) {
        userService.deleteUserSilently(id);
    }
    @Override
    public void updateUserForEmployee(AdminUserSyncReqDTO reqDTO) {
        userService.updateUserForEmployee(reqDTO);
    }
    @Override
    public Long createUser(AdminUserCreateReqDTO createReqDTO) {
        UserSaveReqVO createReqVO = new UserSaveReqVO();
        createReqVO.setUsername(createReqDTO.getUsername());
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/dto/AdminUserSyncReqDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.system.api.user.dto;
import lombok.Data;
/**
 * å‘˜å·¥æ¡£æ¡ˆåŒæ­¥ç³»ç»Ÿè´¦å· Request DTO
 *
 * ç”± hrm æ¨¡å—调用,只同步能一一对应的字段;为空的字段不更新。
 *
 * @author è¶…级管理员
 */
@Data
public class AdminUserSyncReqDTO {
    /**
     * ç”¨æˆ·ç¼–号(必填)
     */
    private Long userId;
    /**
     * æ˜µç§°ï¼ˆå¯¹åº”员工姓名)
     */
    private String nickname;
    /**
     * æ‰‹æœºå·
     */
    private String mobile;
    /**
     * éƒ¨é—¨ç¼–号
     */
    private Long deptId;
    /**
     * æ€§åˆ«ï¼š1-男,2-女
     */
    private Integer sex;
    /**
     * é‚®ç®±
     */
    private String email;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/event/AdminUserDeletedEvent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.system.api.user.event;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ç³»ç»Ÿç”¨æˆ·åˆ é™¤äº‹ä»¶
 *
 * ç”¨äºŽ system æ¨¡å—删除账号后,通知 hrm æ¨¡å—联动删除员工档案。
 * æ³¨æ„ï¼šsystem ä¸èƒ½åå‘依赖 hrm,因此通过 Spring äº‹ä»¶è§£è€¦ã€‚
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public class AdminUserDeletedEvent {
    /**
     * è¢«åˆ é™¤çš„用户编号
     */
    private final Long userId;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/event/AdminUserUpdatedEvent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.system.api.user.event;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ç³»ç»Ÿç”¨æˆ·ä¿®æ”¹äº‹ä»¶
 *
 * ç”¨äºŽ system æ¨¡å—修改账号后,通知 hrm æ¨¡å—把可对应字段回写到员工档案。
 * è¯´æ˜Žï¼šäº‹ä»¶åªæºå¸¦å¯åŒæ­¥å­—段,未修改/不支持的字段为 null,监听方按非空字段更新。
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public class AdminUserUpdatedEvent {
    /**
     * ç”¨æˆ·ç¼–号
     */
    private final Long userId;
    /**
     * æ˜µç§°ï¼ˆå¯¹åº”员工姓名)
     */
    private final String nickname;
    /**
     * æ‰‹æœºå·ï¼ˆå¯¹åº”员工手机号)
     */
    private final String mobile;
    /**
     * éƒ¨é—¨ç¼–号
     */
    private final Long deptId;
    /**
     * æ€§åˆ«ï¼š1-男,2-女,0-未知
     */
    private final Integer sex;
    /**
     * é‚®ç®±
     */
    private final String email;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java
@@ -10,6 +10,7 @@
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserImportRespVO;
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.api.user.dto.AdminUserSyncReqDTO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import jakarta.validation.Valid;
@@ -96,6 +97,15 @@
    void deleteUser(Long id);
    /**
     * åˆ é™¤ç”¨æˆ·ï¼ˆä¸å‘布 AdminUserDeletedEvent)
     *
     * ä¾› hrm æ¨¡å—删除员工时联动删除账号,避免事件回环。
     *
     * @param id ç”¨æˆ·ç¼–号
     */
    void deleteUserSilently(Long id);
    /**
     * æ‰¹é‡åˆ é™¤ç”¨æˆ·
     *
     * @param ids ç”¨æˆ·ç¼–号数组
@@ -103,6 +113,15 @@
    void deleteUserList(List<Long> ids);
    /**
     * å°†å‘˜å·¥æ¡£æ¡ˆå­—段同步到系统账号(不发布 AdminUserUpdatedEvent)
     *
     * åªæ›´æ–° DTO ä¸­éžç©ºçš„字段。
     *
     * @param reqDTO åŒæ­¥ä¿¡æ¯
     */
    void updateUserForEmployee(AdminUserSyncReqDTO reqDTO);
    /**
     * é€šè¿‡ç”¨æˆ·åæŸ¥è¯¢ç”¨æˆ·
     *
     * @param username ç”¨æˆ·å
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java
@@ -20,6 +20,9 @@
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserImportRespVO;
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.api.user.dto.AdminUserSyncReqDTO;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserDeletedEvent;
import cn.iocoder.yudao.module.system.api.user.event.AdminUserUpdatedEvent;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
import cn.iocoder.yudao.module.system.dal.dataobject.dept.UserPostDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
@@ -38,6 +41,7 @@
import jakarta.annotation.Resource;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -91,6 +95,9 @@
    @Resource
    private AdminUserProducer adminUserProducer;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -172,6 +179,11 @@
        // 3. è®°å½•操作日志上下文
        LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldUser, UserSaveReqVO.class));
        LogRecordContext.putVariable("user", oldUser);
        // 4. é€šçŸ¥ hrm æ¨¡å—同步员工档案(没有员工档案的账号会被监听器直接跳过)
        eventPublisher.publishEvent(new AdminUserUpdatedEvent(updateReqVO.getId(),
                updateReqVO.getNickname(), updateReqVO.getMobile(), updateReqVO.getDeptId(),
                updateReqVO.getSex(), updateReqVO.getEmail()));
    }
    private void updateUserPost(UserSaveReqVO reqVO, AdminUserDO updateObj) {
@@ -197,6 +209,7 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateUserProfile(Long id, UserProfileUpdateReqVO reqVO) {
        // 1. æ ¡éªŒæ­£ç¡®æ€§
        AdminUserDO oldUser = validateUserExists(id);
@@ -208,6 +221,10 @@
        // 3. æ˜µç§° / å¤´åƒå˜åŒ–时,发送消息供下游订阅(如 IM æ¨¡å—推 FRIEND_INFO_UPDATED)
        publishUserProfileUpdatedIfChanged(oldUser, reqVO.getNickname(), reqVO.getAvatar());
        // 4. é€šçŸ¥ hrm æ¨¡å—同步员工档案(没有员工档案的账号会被监听器直接跳过)
        eventPublisher.publishEvent(new AdminUserUpdatedEvent(id,
                reqVO.getNickname(), reqVO.getMobile(), null, reqVO.getSex(), reqVO.getEmail()));
    }
    /**
@@ -273,6 +290,18 @@
    @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_DELETE_SUB_TYPE, bizNo = "{{#id}}",
            success = SYSTEM_USER_DELETE_SUCCESS)
    public void deleteUser(Long id) {
        doDeleteUser(id);
        // é€šçŸ¥ hrm æ¨¡å—联动删除员工档案(没有员工档案的账号会被监听器直接跳过)
        eventPublisher.publishEvent(new AdminUserDeletedEvent(id));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteUserSilently(Long id) {
        doDeleteUser(id);
    }
    private void doDeleteUser(Long id) {
        // 1. æ ¡éªŒç”¨æˆ·å­˜åœ¨
        AdminUserDO user = validateUserExists(id);
@@ -298,6 +327,44 @@
            permissionService.processUserDeleted(id);
            userPostMapper.deleteByUserId(id);
        });
        // 3. é€šçŸ¥ hrm æ¨¡å—联动删除员工档案
        ids.forEach(id -> eventPublisher.publishEvent(new AdminUserDeletedEvent(id)));
    }
    @Override
    public void updateUserForEmployee(AdminUserSyncReqDTO reqDTO) {
        if (reqDTO == null || reqDTO.getUserId() == null) {
            return;
        }
        // 1. æ ¡éªŒç”¨æˆ·å­˜åœ¨
        AdminUserDO user = validateUserExists(reqDTO.getUserId());
        // 2. æ ¡éªŒæ‰‹æœºå· / é‚®ç®±å”¯ä¸€ï¼ˆä¸Žæ—§å€¼ä¸åŒæ—¶æ‰æ ¡éªŒï¼‰
        if (StrUtil.isNotBlank(reqDTO.getMobile()) && !ObjUtil.equal(user.getMobile(), reqDTO.getMobile())) {
            validateMobileUnique(reqDTO.getUserId(), reqDTO.getMobile());
        }
        if (StrUtil.isNotBlank(reqDTO.getEmail()) && !ObjUtil.equal(user.getEmail(), reqDTO.getEmail())) {
            validateEmailUnique(reqDTO.getUserId(), reqDTO.getEmail());
        }
        // 3. åªæ›´æ–°éžç©ºå­—段
        AdminUserDO updateObj = new AdminUserDO();
        updateObj.setId(reqDTO.getUserId());
        if (StrUtil.isNotBlank(reqDTO.getNickname())) {
            updateObj.setNickname(reqDTO.getNickname());
        }
        if (StrUtil.isNotBlank(reqDTO.getMobile())) {
            updateObj.setMobile(reqDTO.getMobile());
        }
        if (reqDTO.getDeptId() != null) {
            updateObj.setDeptId(reqDTO.getDeptId());
        }
        if (reqDTO.getSex() != null) {
            updateObj.setSex(reqDTO.getSex());
        }
        if (StrUtil.isNotBlank(reqDTO.getEmail())) {
            updateObj.setEmail(reqDTO.getEmail());
        }
        userMapper.updateById(updateObj);
    }
    @Override