Commit 318287d839713c7cc4a7f7482b011ec9f8a6e4cd

Authored by zichun
1 parent 6c3fcaba

feat(usr): 新增用户 Service 查重/哈希/落库 REQ-USR-001

含 T5 用户名查重(40901)/BCrypt 哈希/审计字段/DuplicateKey 并发兜底,
以及 T6 关联职员/权限存在性校验与去重批量授权(同一实现, 同测试类验证)。
backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service;
  2 +
  3 +import com.xly.erp.modules.usr.dto.CreateUserDTO;
  4 +
  5 +/**
  6 + * 用户业务服务(docs/04 § 1.2)。REQ-USR-001。
  7 + */
  8 +public interface UsrUserService {
  9 +
  10 + /**
  11 + * 新增用户:用户名查重 → 默认值兜底 / 校验 → BCrypt 哈希密码 → 落库 →
  12 + * 关联职员 / 权限校验与授权写入。
  13 + *
  14 + * @param dto 新增用户入参
  15 + * @return 新建用户主键 iIncrement
  16 + */
  17 + Integer createUser(CreateUserDTO dto);
  18 +}
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  4 +import com.xly.erp.common.exception.BusinessException;
  5 +import com.xly.erp.common.response.ResultCode;
  6 +import com.xly.erp.common.security.SecurityUtil;
  7 +import com.xly.erp.modules.usr.dto.CreateUserDTO;
  8 +import com.xly.erp.modules.usr.entity.UsrUser;
  9 +import com.xly.erp.modules.usr.entity.UsrUserPermission;
  10 +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper;
  11 +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
  12 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  13 +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
  14 +import com.xly.erp.modules.usr.service.UsrUserService;
  15 +import java.util.LinkedHashSet;
  16 +import java.util.List;
  17 +import org.springframework.dao.DuplicateKeyException;
  18 +import org.springframework.security.crypto.password.PasswordEncoder;
  19 +import org.springframework.stereotype.Service;
  20 +import org.springframework.transaction.annotation.Transactional;
  21 +import org.springframework.util.StringUtils;
  22 +
  23 +/**
  24 + * 新增用户业务实现(spec § 3)。REQ-USR-001 T5 / T6。
  25 + *
  26 + * <p>流程:用户名查重(40901)→ 关联职员存在性校验(40001)→ 默认值兜底与枚举越界校验(40001)
  27 + * → BCrypt 哈希密码 → 填审计字段并落库(DuplicateKey 兜底转 40901)→ 权限存在性校验(40001)
  28 + * → 去重批量授权写入。整体 {@code @Transactional}。</p>
  29 + */
  30 +@Service
  31 +public class UsrUserServiceImpl implements UsrUserService {
  32 +
  33 + /** 默认初始密码(config-vars admin_init.password 与 spec § 8 D5 一致)。 */
  34 + private static final String DEFAULT_PASSWORD = "666666";
  35 + /** 默认用户类型。 */
  36 + private static final String DEFAULT_USER_TYPE = "普通用户";
  37 + /** 默认单据修改权限。 */
  38 + private static final int DEFAULT_CAN_MODIFY_BILL = 0;
  39 + /** 新建即生效。 */
  40 + private static final int NOT_VOID = 0;
  41 +
  42 + private final UsrUserMapper usrUserMapper;
  43 + private final UsrUserPermissionMapper usrUserPermissionMapper;
  44 + private final UsrEmployeeMapper usrEmployeeMapper;
  45 + private final UsrPermissionMapper usrPermissionMapper;
  46 + private final PasswordEncoder passwordEncoder;
  47 +
  48 + public UsrUserServiceImpl(UsrUserMapper usrUserMapper,
  49 + UsrUserPermissionMapper usrUserPermissionMapper,
  50 + UsrEmployeeMapper usrEmployeeMapper,
  51 + UsrPermissionMapper usrPermissionMapper,
  52 + PasswordEncoder passwordEncoder) {
  53 + this.usrUserMapper = usrUserMapper;
  54 + this.usrUserPermissionMapper = usrUserPermissionMapper;
  55 + this.usrEmployeeMapper = usrEmployeeMapper;
  56 + this.usrPermissionMapper = usrPermissionMapper;
  57 + this.passwordEncoder = passwordEncoder;
  58 + }
  59 +
  60 + @Override
  61 + @Transactional(rollbackFor = Exception.class)
  62 + public Integer createUser(CreateUserDTO dto) {
  63 + // 1. 用户名查重(命中唯一索引前先查)。
  64 + Long existing = usrUserMapper.selectCount(
  65 + Wrappers.<UsrUser>lambdaQuery().eq(UsrUser::getSUserName, dto.getSUserName()));
  66 + if (existing != null && existing > 0) {
  67 + throw new BusinessException(ResultCode.USERNAME_EXISTS);
  68 + }
  69 +
  70 + // 2. 关联职员存在性校验(可选)。
  71 + if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) {
  72 + throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在");
  73 + }
  74 +
  75 + // 3. 权限存在性校验 + 去重(可选)。
  76 + List<Integer> dedupedPermissionIds = null;
  77 + if (dto.getPermissionIds() != null && !dto.getPermissionIds().isEmpty()) {
  78 + dedupedPermissionIds = dto.getPermissionIds().stream()
  79 + .filter(java.util.Objects::nonNull)
  80 + .distinct()
  81 + .toList();
  82 + for (Integer permissionId : dedupedPermissionIds) {
  83 + if (usrPermissionMapper.selectById(permissionId) == null) {
  84 + throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId);
  85 + }
  86 + }
  87 + }
  88 +
  89 + // 4. 默认值兜底 + 枚举越界二次校验。
  90 + String userType = StringUtils.hasText(dto.getSUserType()) ? dto.getSUserType() : DEFAULT_USER_TYPE;
  91 + if (!DEFAULT_USER_TYPE.equals(userType) && !"超级管理员".equals(userType)) {
  92 + throw new BusinessException(ResultCode.PARAM_INVALID, "用户类型取值非法");
  93 + }
  94 + Integer canModifyBill = dto.getICanModifyBill() != null ? dto.getICanModifyBill() : DEFAULT_CAN_MODIFY_BILL;
  95 + if (canModifyBill != 0 && canModifyBill != 1) {
  96 + throw new BusinessException(ResultCode.PARAM_INVALID, "单据修改权限取值非法");
  97 + }
  98 + String rawPassword = StringUtils.hasText(dto.getInitialPassword())
  99 + ? dto.getInitialPassword() : DEFAULT_PASSWORD;
  100 +
  101 + // 5. 组装实体 + 审计字段。
  102 + UsrUser user = new UsrUser();
  103 + user.setSUserName(dto.getSUserName());
  104 + user.setSUserNo(dto.getSUserNo());
  105 + user.setIEmployeeId(dto.getIEmployeeId());
  106 + user.setSUserType(userType);
  107 + user.setSLanguage(dto.getSLanguage());
  108 + user.setICanModifyBill(canModifyBill);
  109 + user.setSPassword(passwordEncoder.encode(rawPassword));
  110 + user.setIIsVoid(NOT_VOID);
  111 + user.setTLastLoginDate(null);
  112 + user.setSCreator(SecurityUtil.currentUserName());
  113 +
  114 + // 6. 落库(并发唯一冲突兜底转 40901)。
  115 + try {
  116 + usrUserMapper.insert(user);
  117 + } catch (DuplicateKeyException ex) {
  118 + throw new BusinessException(ResultCode.USERNAME_EXISTS);
  119 + }
  120 +
  121 + Integer newUserId = user.getIIncrement();
  122 +
  123 + // 7. 权限批量授权写入。
  124 + if (dedupedPermissionIds != null && !dedupedPermissionIds.isEmpty()) {
  125 + for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) {
  126 + usrUserPermissionMapper.insert(new UsrUserPermission(newUserId, permissionId));
  127 + }
  128 + }
  129 +
  130 + return newUserId;
  131 + }
  132 +}
backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service;
  2 +
  3 +import static org.assertj.core.api.Assertions.assertThat;
  4 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  5 +import static org.mockito.ArgumentMatchers.any;
  6 +import static org.mockito.ArgumentMatchers.eq;
  7 +import static org.mockito.Mockito.never;
  8 +import static org.mockito.Mockito.times;
  9 +import static org.mockito.Mockito.verify;
  10 +import static org.mockito.Mockito.when;
  11 +
  12 +import com.baomidou.mybatisplus.core.conditions.Wrapper;
  13 +import com.xly.erp.common.exception.BusinessException;
  14 +import com.xly.erp.common.response.ResultCode;
  15 +import com.xly.erp.common.security.SecurityUtil;
  16 +import com.xly.erp.modules.usr.dto.CreateUserDTO;
  17 +import com.xly.erp.modules.usr.entity.UsrEmployee;
  18 +import com.xly.erp.modules.usr.entity.UsrPermission;
  19 +import com.xly.erp.modules.usr.entity.UsrUser;
  20 +import com.xly.erp.modules.usr.entity.UsrUserPermission;
  21 +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper;
  22 +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
  23 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  24 +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
  25 +import com.xly.erp.modules.usr.service.impl.UsrUserServiceImpl;
  26 +import java.util.List;
  27 +import org.junit.jupiter.api.AfterEach;
  28 +import org.junit.jupiter.api.BeforeEach;
  29 +import org.junit.jupiter.api.Test;
  30 +import org.mockito.ArgumentCaptor;
  31 +import org.mockito.MockedStatic;
  32 +import org.mockito.Mockito;
  33 +import org.springframework.dao.DuplicateKeyException;
  34 +import org.springframework.security.crypto.password.PasswordEncoder;
  35 +
  36 +/**
  37 + * REQ-USR-001 T5 / T6:新增用户 Service 单元测试(Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态)。
  38 + */
  39 +class UsrUserServiceImplTest {
  40 +
  41 + private UsrUserMapper usrUserMapper;
  42 + private UsrUserPermissionMapper usrUserPermissionMapper;
  43 + private UsrEmployeeMapper usrEmployeeMapper;
  44 + private UsrPermissionMapper usrPermissionMapper;
  45 + private PasswordEncoder passwordEncoder;
  46 + private UsrUserServiceImpl service;
  47 + private MockedStatic<SecurityUtil> securityUtilMock;
  48 +
  49 + @BeforeEach
  50 + void setUp() {
  51 + usrUserMapper = Mockito.mock(UsrUserMapper.class);
  52 + usrUserPermissionMapper = Mockito.mock(UsrUserPermissionMapper.class);
  53 + usrEmployeeMapper = Mockito.mock(UsrEmployeeMapper.class);
  54 + usrPermissionMapper = Mockito.mock(UsrPermissionMapper.class);
  55 + passwordEncoder = Mockito.mock(PasswordEncoder.class);
  56 + service = new UsrUserServiceImpl(usrUserMapper, usrUserPermissionMapper,
  57 + usrEmployeeMapper, usrPermissionMapper, passwordEncoder);
  58 + securityUtilMock = Mockito.mockStatic(SecurityUtil.class);
  59 + securityUtilMock.when(SecurityUtil::currentUserName).thenReturn("admin");
  60 + }
  61 +
  62 + @AfterEach
  63 + void tearDown() {
  64 + securityUtilMock.close();
  65 + }
  66 +
  67 + private CreateUserDTO minimalDto() {
  68 + CreateUserDTO dto = new CreateUserDTO();
  69 + dto.setSUserName("good_user");
  70 + dto.setSLanguage("中文");
  71 + return dto;
  72 + }
  73 +
  74 + @SuppressWarnings("unchecked")
  75 + private void stubNoExistingUser() {
  76 + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
  77 + }
  78 +
  79 + // ---------------- T5 ----------------
  80 +
  81 + @Test
  82 + void createUserHashesPasswordAndSetsAuditFields() {
  83 + stubNoExistingUser();
  84 + when(passwordEncoder.encode("666666")).thenReturn("$2a$hashed");
  85 + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
  86 + UsrUser u = inv.getArgument(0);
  87 + u.setIIncrement(101);
  88 + return 1;
  89 + });
  90 +
  91 + Integer id = service.createUser(minimalDto());
  92 +
  93 + assertThat(id).isEqualTo(101);
  94 + verify(passwordEncoder).encode("666666");
  95 + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class);
  96 + verify(usrUserMapper).insert(captor.capture());
  97 + UsrUser saved = captor.getValue();
  98 + assertThat(saved.getSPassword()).isEqualTo("$2a$hashed");
  99 + assertThat(saved.getSPassword()).isNotEqualTo("666666");
  100 + assertThat(saved.getIIsVoid()).isZero();
  101 + assertThat(saved.getSUserType()).isEqualTo("普通用户");
  102 + assertThat(saved.getSCreator()).isEqualTo("admin");
  103 + assertThat(saved.getTLastLoginDate()).isNull();
  104 + }
  105 +
  106 + @Test
  107 + void duplicateUserNameThrows40901() {
  108 + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
  109 +
  110 + assertThatThrownBy(() -> service.createUser(minimalDto()))
  111 + .isInstanceOf(BusinessException.class)
  112 + .extracting(e -> ((BusinessException) e).getResultCode())
  113 + .isEqualTo(ResultCode.USERNAME_EXISTS);
  114 + verify(usrUserMapper, never()).insert(any(UsrUser.class));
  115 + }
  116 +
  117 + @Test
  118 + void duplicateKeyExceptionTranslatesTo40901() {
  119 + stubNoExistingUser();
  120 + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
  121 + when(usrUserMapper.insert(any(UsrUser.class))).thenThrow(new DuplicateKeyException("dup"));
  122 +
  123 + assertThatThrownBy(() -> service.createUser(minimalDto()))
  124 + .isInstanceOf(BusinessException.class)
  125 + .extracting(e -> ((BusinessException) e).getResultCode())
  126 + .isEqualTo(ResultCode.USERNAME_EXISTS);
  127 + }
  128 +
  129 + // ---------------- T6 ----------------
  130 +
  131 + @Test
  132 + void nonExistentEmployeeThrows40001() {
  133 + stubNoExistingUser();
  134 + when(usrEmployeeMapper.selectById(999)).thenReturn(null);
  135 + CreateUserDTO dto = minimalDto();
  136 + dto.setIEmployeeId(999);
  137 +
  138 + assertThatThrownBy(() -> service.createUser(dto))
  139 + .isInstanceOf(BusinessException.class)
  140 + .extracting(e -> ((BusinessException) e).getResultCode())
  141 + .isEqualTo(ResultCode.PARAM_INVALID);
  142 + verify(usrUserMapper, never()).insert(any(UsrUser.class));
  143 + }
  144 +
  145 + @Test
  146 + void nonExistentPermissionThrows40001() {
  147 + stubNoExistingUser();
  148 + when(usrPermissionMapper.selectById(5)).thenReturn(null);
  149 + CreateUserDTO dto = minimalDto();
  150 + dto.setPermissionIds(List.of(5));
  151 +
  152 + assertThatThrownBy(() -> service.createUser(dto))
  153 + .isInstanceOf(BusinessException.class)
  154 + .extracting(e -> ((BusinessException) e).getResultCode())
  155 + .isEqualTo(ResultCode.PARAM_INVALID);
  156 + verify(usrUserMapper, never()).insert(any(UsrUser.class));
  157 + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class));
  158 + }
  159 +
  160 + @Test
  161 + void grantsDedupedPermissions() {
  162 + stubNoExistingUser();
  163 + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
  164 + UsrPermission permA = new UsrPermission();
  165 + UsrPermission permB = new UsrPermission();
  166 + when(usrPermissionMapper.selectById(10)).thenReturn(permA);
  167 + when(usrPermissionMapper.selectById(20)).thenReturn(permB);
  168 + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
  169 + UsrUser u = inv.getArgument(0);
  170 + u.setIIncrement(202);
  171 + return 1;
  172 + });
  173 + CreateUserDTO dto = minimalDto();
  174 + dto.setPermissionIds(List.of(10, 10, 20));
  175 +
  176 + Integer id = service.createUser(dto);
  177 +
  178 + assertThat(id).isEqualTo(202);
  179 + ArgumentCaptor<UsrUserPermission> captor = ArgumentCaptor.forClass(UsrUserPermission.class);
  180 + verify(usrUserPermissionMapper, times(2)).insert(captor.capture());
  181 + List<UsrUserPermission> grants = captor.getAllValues();
  182 + assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(202);
  183 + assertThat(grants).extracting(UsrUserPermission::getIPermissionId)
  184 + .containsExactlyInAnyOrder(10, 20);
  185 + }
  186 +
  187 + // 防御:未使用的 employee mock 引用,确保导入有效(占位避免 checkstyle 未用 import)。
  188 + @Test
  189 + void employeeMapperWiredForExistenceCheck() {
  190 + stubNoExistingUser();
  191 + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
  192 + UsrEmployee emp = new UsrEmployee();
  193 + when(usrEmployeeMapper.selectById(eq(7))).thenReturn(emp);
  194 + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
  195 + ((UsrUser) inv.getArgument(0)).setIIncrement(303);
  196 + return 1;
  197 + });
  198 + CreateUserDTO dto = minimalDto();
  199 + dto.setIEmployeeId(7);
  200 +
  201 + assertThat(service.createUser(dto)).isEqualTo(303);
  202 + verify(usrEmployeeMapper).selectById(7);
  203 + }
  204 +}