diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java b/backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java new file mode 100644 index 0000000..faa6175 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java @@ -0,0 +1,15 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.module.usr.dto.UpdateUserReq; +import com.xly.erp.module.usr.vo.UserDetailVo; + +public interface UserUpdateService { + /** + * REQ-USR-003 PUT /api/v1/users/{userId}:部分字段更新 + 权限分类增量差集。 + * + * @throws com.xly.erp.common.exception.BizException + * 40004 employee/permissionCategory 不存在 / 40302 自我停用 / 40401 用户不存在 / 40902 用户号冲突 + */ + UserDetailVo update(Integer userId, UpdateUserReq req, + Integer operatorUserId, String operatorUsername); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java new file mode 100644 index 0000000..88420c7 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java @@ -0,0 +1,135 @@ +package com.xly.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.UpdateUserReq; +import com.xly.erp.module.usr.entity.SysEmployee; +import com.xly.erp.module.usr.entity.SysUser; +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; +import com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; +import com.xly.erp.module.usr.service.UserDetailService; +import com.xly.erp.module.usr.service.UserUpdateService; +import com.xly.erp.module.usr.vo.UserDetailVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserUpdateServiceImpl implements UserUpdateService { + + private final SysUserMapper userMapper; + private final SysEmployeeMapper employeeMapper; + private final SysPermissionCategoryMapper permissionCategoryMapper; + private final SysUserPermissionCategoryMapper upcMapper; + private final UserDetailService userDetailService; + + @Override + @Transactional + public UserDetailVo update(Integer userId, UpdateUserReq req, + Integer operatorUserId, String operatorUsername) { + // 1. 存在性 + SysUser existing = userMapper.selectById(userId); + if (existing == null) { + throw new BizException(ErrorCode.USER_NOT_FOUND, "用户不存在"); + } + + // 2. 自我停用守卫 + if (Boolean.TRUE.equals(req.getIsDeleted()) && userId.equals(operatorUserId)) { + throw new BizException(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE, + "不允许停用当前登录用户自己"); + } + + // 3. userCode 唯一(排除自身) + if (req.getUserCode() != null + && !req.getUserCode().equals(existing.getSUserCode()) + && userMapper.existsByUserCodeExcludingId(req.getUserCode(), userId)) { + throw new BizException(ErrorCode.CONFLICT_USERCODE, "用户号已被占用"); + } + + // 4. employeeId 外键(仅正整数才查) + if (req.getEmployeeId() != null && req.getEmployeeId() > 0) { + SysEmployee emp = employeeMapper.selectById(req.getEmployeeId()); + if (emp == null || Integer.valueOf(1).equals(emp.getIIsDeleted())) { + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "指定的员工不存在或已删除"); + } + } + + // 5. permissionCategoryIds 外键(dedup 后校验) + Set targetPcSet = null; + if (req.getPermissionCategoryIds() != null) { + targetPcSet = new LinkedHashSet<>(req.getPermissionCategoryIds()); + if (!targetPcSet.isEmpty()) { + int active = permissionCategoryMapper.countActiveByIds( + new java.util.ArrayList<>(targetPcSet)); + if (active != targetPcSet.size()) { + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, + "指定的权限分类含不存在或已删除项"); + } + } + } + + // 6. 写 sys_user 字段 + UpdateWrapper uw = new UpdateWrapper<>(); + uw.eq("iIncrement", userId); + + if (req.getUserCode() != null) uw.set("sUserCode", req.getUserCode()); + if (req.getUserType() != null) uw.set("sUserType", req.getUserType()); + if (req.getLanguage() != null) uw.set("sLanguage", req.getLanguage()); + if (req.getCanEditDocument() != null) + uw.set("iCanEditDocument", req.getCanEditDocument() ? 1 : 0); + if (req.getIsDeleted() != null) + uw.set("iIsDeleted", req.getIsDeleted() ? 1 : 0); + if (req.getEmployeeId() != null) { + if (req.getEmployeeId() == 0) { + uw.set("iEmployeeId", null); // 解除关联 + } else { + uw.set("iEmployeeId", req.getEmployeeId()); + } + } + uw.set("sUpdatedBy", operatorUsername); + uw.set("tUpdatedDate", LocalDateTime.now()); + + userMapper.update(null, uw); + + // 7. 权限分类增量差集 + if (targetPcSet != null) { + List currentList = upcMapper.selectPermissionCategoryIdsByUserId(userId); + Set currentSet = new HashSet<>(currentList); + + Set toRemove = new HashSet<>(currentSet); + toRemove.removeAll(targetPcSet); + + Set toAdd = new LinkedHashSet<>(targetPcSet); + toAdd.removeAll(currentSet); + + if (!toRemove.isEmpty()) { + upcMapper.deleteByUserAndCategoryIds(userId, new java.util.ArrayList<>(toRemove)); + } + for (Integer pcId : toAdd) { + SysUserPermissionCategory link = new SysUserPermissionCategory(); + link.setIUserId(userId); + link.setIPermissionCategoryId(pcId); + link.setSGrantedBy(operatorUsername); + upcMapper.insert(link); + } + log.info("[user-update] userId={} pc.toRemove={} pc.toAdd={}", + userId, toRemove.size(), toAdd.size()); + } + + log.info("[user-update] userId={} byOperator={} 完成", userId, operatorUsername); + return userDetailService.getById(userId); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java new file mode 100644 index 0000000..2c0ad5b --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java @@ -0,0 +1,296 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.UpdateUserReq; +import com.xly.erp.module.usr.entity.SysUser; +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; +import com.xly.erp.module.usr.support.LoginTestSeeder; +import com.xly.erp.module.usr.vo.UserDetailVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class UserUpdateServiceImplTest { + + @Autowired private UserUpdateService service; + @Autowired private SysUserMapper userMapper; + @Autowired private SysUserPermissionCategoryMapper upcMapper; + @Autowired private LoginTestSeeder seeder; + + private LoginTestSeeder.Fixture fx; + + @BeforeEach + void setUp() { + fx = seeder.reset(); + } + + private UpdateUserReq req() { + return new UpdateUserReq(); + } + + // ===== 校验路径(Task 6) ===== + + @Test + void update_unknownUserId_throws40401() { + BizException e = assertThrows(BizException.class, + () -> service.update(99999, req(), fx.adminId(), LoginTestSeeder.USER_ADMIN)); + assertEquals(ErrorCode.USER_NOT_FOUND, e.getCode()); + } + + @Test + void update_selfDeactivate_throws40302() { + UpdateUserReq r = req(); + r.setIsDeleted(true); + BizException e = assertThrows(BizException.class, + () -> service.update(fx.adminId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); + assertEquals(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE, e.getCode()); + } + + @Test + void update_userCodeConflict_throws40902() { + UpdateUserReq r = req(); + r.setUserCode("U001"); // alice's code + BizException e = assertThrows(BizException.class, + () -> service.update(fx.adminId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); + assertEquals(ErrorCode.CONFLICT_USERCODE, e.getCode()); + } + + @Test + void update_employeeIdNotFound_throws40004() { + UpdateUserReq r = req(); + r.setEmployeeId(99999); + BizException e = assertThrows(BizException.class, + () -> service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); + } + + @Test + void update_permissionCategoryNotFound_throws40004_rollsBack() { + UpdateUserReq r = req(); + r.setUserCode("U_NEW"); + r.setPermissionCategoryIds(List.of(99999)); + BizException e = assertThrows(BizException.class, + () -> service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); + // 回滚:sys_user.sUserCode 不应被改 + SysUser after = userMapper.selectById(fx.aliceId()); + assertEquals("U001", after.getSUserCode()); + } + + // ===== 字段写入(Task 7) ===== + + @Test + void update_userCode_only_persisted_otherFieldsUnchanged() { + UpdateUserReq r = req(); + r.setUserCode("U_NEW"); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + SysUser db = userMapper.selectById(fx.aliceId()); + assertEquals("U_NEW", db.getSUserCode()); + assertEquals(LoginTestSeeder.USER_OK, db.getSUsername(), "用户名不变"); + assertEquals(LoginTestSeeder.USER_ADMIN, db.getSUpdatedBy()); + assertNotNull(db.getTUpdatedDate()); + } + + @Test + void update_userType_language_canEditDocument() { + UpdateUserReq r = req(); + r.setUserType("SUPER_ADMIN"); + r.setLanguage("en-US"); + r.setCanEditDocument(true); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + SysUser db = userMapper.selectById(fx.aliceId()); + assertEquals("SUPER_ADMIN", db.getSUserType()); + assertEquals("en-US", db.getSLanguage()); + assertEquals(1, db.getICanEditDocument()); + } + + @Test + void update_employeeId_positiveInteger_setsToValue() { + UpdateUserReq r = req(); + r.setEmployeeId(fx.employeeId()); + service.update(fx.adminId(), r, fx.adminId() + 1000, LoginTestSeeder.USER_ADMIN); + // operatorUserId 不等于 adminId 才能避开自我停用守卫(本测试未触发,但保持参数有效) + SysUser db = userMapper.selectById(fx.adminId()); + assertEquals(fx.employeeId(), db.getIEmployeeId()); + } + + @Test + void update_employeeId_zero_setsToNull() { + UpdateUserReq r = req(); + r.setEmployeeId(0); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + SysUser db = userMapper.selectById(fx.aliceId()); + assertNull(db.getIEmployeeId()); + } + + @Test + void update_employeeId_unchanged_preservesOriginalValue() { + UpdateUserReq r = req(); + // 不设 employeeId + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + SysUser db = userMapper.selectById(fx.aliceId()); + assertEquals(fx.employeeId(), db.getIEmployeeId()); + } + + @Test + void update_isDeleted_true_marksUserDeleted() { + UpdateUserReq r = req(); + r.setIsDeleted(true); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + SysUser db = userMapper.selectById(fx.aliceId()); + assertEquals(1, db.getIIsDeleted()); + } + + @Test + void update_isDeleted_false_revivesUser() { + UpdateUserReq r = req(); + r.setIsDeleted(false); + service.update(fx.bobDeletedId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + SysUser db = userMapper.selectById(fx.bobDeletedId()); + assertEquals(0, db.getIIsDeleted()); + } + + @Test + void update_emptyRequest_onlyUpdatesAuditFields() { + service.update(fx.aliceId(), req(), fx.adminId(), LoginTestSeeder.USER_ADMIN); + SysUser db = userMapper.selectById(fx.aliceId()); + assertEquals(LoginTestSeeder.USER_ADMIN, db.getSUpdatedBy()); + assertNotNull(db.getTUpdatedDate()); + // 其他字段不变 + assertEquals("U001", db.getSUserCode()); + assertEquals("NORMAL", db.getSUserType()); + } + + @Test + void update_userCodeUnchangedSameAsSelf_returns200() { + UpdateUserReq r = req(); + r.setUserCode("U001"); // alice 自己当前的 userCode + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + assertEquals("U001", vo.getUserCode()); + } + + // ===== 权限分类增量差集(Task 8) ===== + + @Test + void update_permissionCategories_emptyList_clearsAll() { + // 先给 alice 加 2 个 + for (Integer pcId : fx.activePermissionCategoryIds()) { + SysUserPermissionCategory l = new SysUserPermissionCategory(); + l.setIUserId(fx.aliceId()); + l.setIPermissionCategoryId(pcId); + l.setSGrantedBy("system"); + upcMapper.insert(l); + } + UpdateUserReq r = req(); + r.setPermissionCategoryIds(List.of()); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + assertTrue(upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId()).isEmpty()); + } + + @org.springframework.beans.factory.annotation.Autowired + private com.xly.erp.module.usr.mapper.SysPermissionCategoryMapper permissionCategoryMapper; + + @Test + void update_permissionCategories_subsetDelta_addsAndRemoves() { + Integer pur = fx.activePermissionCategoryIds().get(0); + Integer sal = fx.activePermissionCategoryIds().get(1); + // 初始:alice = {PUR, SAL} + for (Integer pcId : List.of(pur, sal)) { + SysUserPermissionCategory l = new SysUserPermissionCategory(); + l.setIUserId(fx.aliceId()); + l.setIPermissionCategoryId(pcId); + l.setSGrantedBy("system"); + upcMapper.insert(l); + } + Integer salRowId = upcMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("iUserId", fx.aliceId()) + .eq("iPermissionCategoryId", sal)) + .get(0).getIIncrement(); + + // 新增分类 X(活跃) + com.xly.erp.module.usr.entity.SysPermissionCategory x = + new com.xly.erp.module.usr.entity.SysPermissionCategory(); + x.setSCategoryName("X"); + x.setSCategoryCode("X"); + x.setISortOrder(99); + x.setIIsDeleted(0); + permissionCategoryMapper.insert(x); + + UpdateUserReq r = req(); + r.setPermissionCategoryIds(List.of(sal, x.getIIncrement())); + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + // 最终 alice 应有 {SAL, X} + Set finalSet = new HashSet<>( + upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId())); + assertEquals(Set.of(sal, x.getIIncrement()), finalSet); + + // SAL 行 iIncrement 不变(差集而非全量替换) + Integer salRowIdAfter = upcMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("iUserId", fx.aliceId()) + .eq("iPermissionCategoryId", sal)) + .get(0).getIIncrement(); + assertEquals(salRowId, salRowIdAfter, "保留项的 iIncrement 应不变"); + } + + @Test + void update_permissionCategories_omitted_preservesExisting() { + Integer pur = fx.activePermissionCategoryIds().get(0); + SysUserPermissionCategory l = new SysUserPermissionCategory(); + l.setIUserId(fx.aliceId()); + l.setIPermissionCategoryId(pur); + l.setSGrantedBy("system"); + upcMapper.insert(l); + + // 请求不含 permissionCategoryIds + UpdateUserReq r = req(); + r.setUserCode("U_NEW"); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + assertEquals(1, upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId()).size()); + } + + @Test + void update_permissionCategories_duplicateInRequest_deduped() { + Integer pur = fx.activePermissionCategoryIds().get(0); + Integer sal = fx.activePermissionCategoryIds().get(1); + UpdateUserReq r = req(); + r.setPermissionCategoryIds(new ArrayList<>(List.of(pur, pur, sal))); + service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + + Set finalSet = new HashSet<>( + upcMapper.selectPermissionCategoryIdsByUserId(fx.aliceId())); + assertEquals(Set.of(pur, sal), finalSet); + } + + @Test + void update_returnsUserDetailVoReflectingFinalState() { + UpdateUserReq r = req(); + r.setUserType("SUPER_ADMIN"); + r.setLanguage("en-US"); + UserDetailVo vo = service.update(fx.aliceId(), r, fx.adminId(), LoginTestSeeder.USER_ADMIN); + assertEquals("SUPER_ADMIN", vo.getUserType()); + assertEquals("en-US", vo.getLanguage()); + assertEquals(LoginTestSeeder.USER_ADMIN, vo.getUpdatedBy()); + assertNotNull(vo.getUpdatedDate()); + } +}