diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java
new file mode 100644
index 0000000..4235f84
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java
@@ -0,0 +1,18 @@
+package com.xly.erp.modules.usr.service;
+
+import com.xly.erp.modules.usr.dto.CreateUserDTO;
+
+/**
+ * 用户业务服务(docs/04 § 1.2)。REQ-USR-001。
+ */
+public interface UsrUserService {
+
+ /**
+ * 新增用户:用户名查重 → 默认值兜底 / 校验 → BCrypt 哈希密码 → 落库 →
+ * 关联职员 / 权限校验与授权写入。
+ *
+ * @param dto 新增用户入参
+ * @return 新建用户主键 iIncrement
+ */
+ Integer createUser(CreateUserDTO dto);
+}
diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java
new file mode 100644
index 0000000..33e9a3b
--- /dev/null
+++ b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java
@@ -0,0 +1,132 @@
+package com.xly.erp.modules.usr.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.xly.erp.common.exception.BusinessException;
+import com.xly.erp.common.response.ResultCode;
+import com.xly.erp.common.security.SecurityUtil;
+import com.xly.erp.modules.usr.dto.CreateUserDTO;
+import com.xly.erp.modules.usr.entity.UsrUser;
+import com.xly.erp.modules.usr.entity.UsrUserPermission;
+import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper;
+import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
+import com.xly.erp.modules.usr.service.UsrUserService;
+import java.util.LinkedHashSet;
+import java.util.List;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * 新增用户业务实现(spec § 3)。REQ-USR-001 T5 / T6。
+ *
+ *
流程:用户名查重(40901)→ 关联职员存在性校验(40001)→ 默认值兜底与枚举越界校验(40001)
+ * → BCrypt 哈希密码 → 填审计字段并落库(DuplicateKey 兜底转 40901)→ 权限存在性校验(40001)
+ * → 去重批量授权写入。整体 {@code @Transactional}。
+ */
+@Service
+public class UsrUserServiceImpl implements UsrUserService {
+
+ /** 默认初始密码(config-vars admin_init.password 与 spec § 8 D5 一致)。 */
+ private static final String DEFAULT_PASSWORD = "666666";
+ /** 默认用户类型。 */
+ private static final String DEFAULT_USER_TYPE = "普通用户";
+ /** 默认单据修改权限。 */
+ private static final int DEFAULT_CAN_MODIFY_BILL = 0;
+ /** 新建即生效。 */
+ private static final int NOT_VOID = 0;
+
+ private final UsrUserMapper usrUserMapper;
+ private final UsrUserPermissionMapper usrUserPermissionMapper;
+ private final UsrEmployeeMapper usrEmployeeMapper;
+ private final UsrPermissionMapper usrPermissionMapper;
+ private final PasswordEncoder passwordEncoder;
+
+ public UsrUserServiceImpl(UsrUserMapper usrUserMapper,
+ UsrUserPermissionMapper usrUserPermissionMapper,
+ UsrEmployeeMapper usrEmployeeMapper,
+ UsrPermissionMapper usrPermissionMapper,
+ PasswordEncoder passwordEncoder) {
+ this.usrUserMapper = usrUserMapper;
+ this.usrUserPermissionMapper = usrUserPermissionMapper;
+ this.usrEmployeeMapper = usrEmployeeMapper;
+ this.usrPermissionMapper = usrPermissionMapper;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Integer createUser(CreateUserDTO dto) {
+ // 1. 用户名查重(命中唯一索引前先查)。
+ Long existing = usrUserMapper.selectCount(
+ Wrappers.lambdaQuery().eq(UsrUser::getSUserName, dto.getSUserName()));
+ if (existing != null && existing > 0) {
+ throw new BusinessException(ResultCode.USERNAME_EXISTS);
+ }
+
+ // 2. 关联职员存在性校验(可选)。
+ if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) {
+ throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在");
+ }
+
+ // 3. 权限存在性校验 + 去重(可选)。
+ List dedupedPermissionIds = null;
+ if (dto.getPermissionIds() != null && !dto.getPermissionIds().isEmpty()) {
+ dedupedPermissionIds = dto.getPermissionIds().stream()
+ .filter(java.util.Objects::nonNull)
+ .distinct()
+ .toList();
+ for (Integer permissionId : dedupedPermissionIds) {
+ if (usrPermissionMapper.selectById(permissionId) == null) {
+ throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId);
+ }
+ }
+ }
+
+ // 4. 默认值兜底 + 枚举越界二次校验。
+ String userType = StringUtils.hasText(dto.getSUserType()) ? dto.getSUserType() : DEFAULT_USER_TYPE;
+ if (!DEFAULT_USER_TYPE.equals(userType) && !"超级管理员".equals(userType)) {
+ throw new BusinessException(ResultCode.PARAM_INVALID, "用户类型取值非法");
+ }
+ Integer canModifyBill = dto.getICanModifyBill() != null ? dto.getICanModifyBill() : DEFAULT_CAN_MODIFY_BILL;
+ if (canModifyBill != 0 && canModifyBill != 1) {
+ throw new BusinessException(ResultCode.PARAM_INVALID, "单据修改权限取值非法");
+ }
+ String rawPassword = StringUtils.hasText(dto.getInitialPassword())
+ ? dto.getInitialPassword() : DEFAULT_PASSWORD;
+
+ // 5. 组装实体 + 审计字段。
+ UsrUser user = new UsrUser();
+ user.setSUserName(dto.getSUserName());
+ user.setSUserNo(dto.getSUserNo());
+ user.setIEmployeeId(dto.getIEmployeeId());
+ user.setSUserType(userType);
+ user.setSLanguage(dto.getSLanguage());
+ user.setICanModifyBill(canModifyBill);
+ user.setSPassword(passwordEncoder.encode(rawPassword));
+ user.setIIsVoid(NOT_VOID);
+ user.setTLastLoginDate(null);
+ user.setSCreator(SecurityUtil.currentUserName());
+
+ // 6. 落库(并发唯一冲突兜底转 40901)。
+ try {
+ usrUserMapper.insert(user);
+ } catch (DuplicateKeyException ex) {
+ throw new BusinessException(ResultCode.USERNAME_EXISTS);
+ }
+
+ Integer newUserId = user.getIIncrement();
+
+ // 7. 权限批量授权写入。
+ if (dedupedPermissionIds != null && !dedupedPermissionIds.isEmpty()) {
+ for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) {
+ usrUserPermissionMapper.insert(new UsrUserPermission(newUserId, permissionId));
+ }
+ }
+
+ return newUserId;
+ }
+}
diff --git a/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java
new file mode 100644
index 0000000..d9dc279
--- /dev/null
+++ b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java
@@ -0,0 +1,204 @@
+package com.xly.erp.modules.usr.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.xly.erp.common.exception.BusinessException;
+import com.xly.erp.common.response.ResultCode;
+import com.xly.erp.common.security.SecurityUtil;
+import com.xly.erp.modules.usr.dto.CreateUserDTO;
+import com.xly.erp.modules.usr.entity.UsrEmployee;
+import com.xly.erp.modules.usr.entity.UsrPermission;
+import com.xly.erp.modules.usr.entity.UsrUser;
+import com.xly.erp.modules.usr.entity.UsrUserPermission;
+import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper;
+import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserMapper;
+import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
+import com.xly.erp.modules.usr.service.impl.UsrUserServiceImpl;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+/**
+ * REQ-USR-001 T5 / T6:新增用户 Service 单元测试(Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态)。
+ */
+class UsrUserServiceImplTest {
+
+ private UsrUserMapper usrUserMapper;
+ private UsrUserPermissionMapper usrUserPermissionMapper;
+ private UsrEmployeeMapper usrEmployeeMapper;
+ private UsrPermissionMapper usrPermissionMapper;
+ private PasswordEncoder passwordEncoder;
+ private UsrUserServiceImpl service;
+ private MockedStatic securityUtilMock;
+
+ @BeforeEach
+ void setUp() {
+ usrUserMapper = Mockito.mock(UsrUserMapper.class);
+ usrUserPermissionMapper = Mockito.mock(UsrUserPermissionMapper.class);
+ usrEmployeeMapper = Mockito.mock(UsrEmployeeMapper.class);
+ usrPermissionMapper = Mockito.mock(UsrPermissionMapper.class);
+ passwordEncoder = Mockito.mock(PasswordEncoder.class);
+ service = new UsrUserServiceImpl(usrUserMapper, usrUserPermissionMapper,
+ usrEmployeeMapper, usrPermissionMapper, passwordEncoder);
+ securityUtilMock = Mockito.mockStatic(SecurityUtil.class);
+ securityUtilMock.when(SecurityUtil::currentUserName).thenReturn("admin");
+ }
+
+ @AfterEach
+ void tearDown() {
+ securityUtilMock.close();
+ }
+
+ private CreateUserDTO minimalDto() {
+ CreateUserDTO dto = new CreateUserDTO();
+ dto.setSUserName("good_user");
+ dto.setSLanguage("中文");
+ return dto;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void stubNoExistingUser() {
+ when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+ }
+
+ // ---------------- T5 ----------------
+
+ @Test
+ void createUserHashesPasswordAndSetsAuditFields() {
+ stubNoExistingUser();
+ when(passwordEncoder.encode("666666")).thenReturn("$2a$hashed");
+ when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
+ UsrUser u = inv.getArgument(0);
+ u.setIIncrement(101);
+ return 1;
+ });
+
+ Integer id = service.createUser(minimalDto());
+
+ assertThat(id).isEqualTo(101);
+ verify(passwordEncoder).encode("666666");
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class);
+ verify(usrUserMapper).insert(captor.capture());
+ UsrUser saved = captor.getValue();
+ assertThat(saved.getSPassword()).isEqualTo("$2a$hashed");
+ assertThat(saved.getSPassword()).isNotEqualTo("666666");
+ assertThat(saved.getIIsVoid()).isZero();
+ assertThat(saved.getSUserType()).isEqualTo("普通用户");
+ assertThat(saved.getSCreator()).isEqualTo("admin");
+ assertThat(saved.getTLastLoginDate()).isNull();
+ }
+
+ @Test
+ void duplicateUserNameThrows40901() {
+ when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+
+ assertThatThrownBy(() -> service.createUser(minimalDto()))
+ .isInstanceOf(BusinessException.class)
+ .extracting(e -> ((BusinessException) e).getResultCode())
+ .isEqualTo(ResultCode.USERNAME_EXISTS);
+ verify(usrUserMapper, never()).insert(any(UsrUser.class));
+ }
+
+ @Test
+ void duplicateKeyExceptionTranslatesTo40901() {
+ stubNoExistingUser();
+ when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
+ when(usrUserMapper.insert(any(UsrUser.class))).thenThrow(new DuplicateKeyException("dup"));
+
+ assertThatThrownBy(() -> service.createUser(minimalDto()))
+ .isInstanceOf(BusinessException.class)
+ .extracting(e -> ((BusinessException) e).getResultCode())
+ .isEqualTo(ResultCode.USERNAME_EXISTS);
+ }
+
+ // ---------------- T6 ----------------
+
+ @Test
+ void nonExistentEmployeeThrows40001() {
+ stubNoExistingUser();
+ when(usrEmployeeMapper.selectById(999)).thenReturn(null);
+ CreateUserDTO dto = minimalDto();
+ dto.setIEmployeeId(999);
+
+ assertThatThrownBy(() -> service.createUser(dto))
+ .isInstanceOf(BusinessException.class)
+ .extracting(e -> ((BusinessException) e).getResultCode())
+ .isEqualTo(ResultCode.PARAM_INVALID);
+ verify(usrUserMapper, never()).insert(any(UsrUser.class));
+ }
+
+ @Test
+ void nonExistentPermissionThrows40001() {
+ stubNoExistingUser();
+ when(usrPermissionMapper.selectById(5)).thenReturn(null);
+ CreateUserDTO dto = minimalDto();
+ dto.setPermissionIds(List.of(5));
+
+ assertThatThrownBy(() -> service.createUser(dto))
+ .isInstanceOf(BusinessException.class)
+ .extracting(e -> ((BusinessException) e).getResultCode())
+ .isEqualTo(ResultCode.PARAM_INVALID);
+ verify(usrUserMapper, never()).insert(any(UsrUser.class));
+ verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class));
+ }
+
+ @Test
+ void grantsDedupedPermissions() {
+ stubNoExistingUser();
+ when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
+ UsrPermission permA = new UsrPermission();
+ UsrPermission permB = new UsrPermission();
+ when(usrPermissionMapper.selectById(10)).thenReturn(permA);
+ when(usrPermissionMapper.selectById(20)).thenReturn(permB);
+ when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
+ UsrUser u = inv.getArgument(0);
+ u.setIIncrement(202);
+ return 1;
+ });
+ CreateUserDTO dto = minimalDto();
+ dto.setPermissionIds(List.of(10, 10, 20));
+
+ Integer id = service.createUser(dto);
+
+ assertThat(id).isEqualTo(202);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUserPermission.class);
+ verify(usrUserPermissionMapper, times(2)).insert(captor.capture());
+ List grants = captor.getAllValues();
+ assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(202);
+ assertThat(grants).extracting(UsrUserPermission::getIPermissionId)
+ .containsExactlyInAnyOrder(10, 20);
+ }
+
+ // 防御:未使用的 employee mock 引用,确保导入有效(占位避免 checkstyle 未用 import)。
+ @Test
+ void employeeMapperWiredForExistenceCheck() {
+ stubNoExistingUser();
+ when(passwordEncoder.encode(any())).thenReturn("$2a$hashed");
+ UsrEmployee emp = new UsrEmployee();
+ when(usrEmployeeMapper.selectById(eq(7))).thenReturn(emp);
+ when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> {
+ ((UsrUser) inv.getArgument(0)).setIIncrement(303);
+ return 1;
+ });
+ CreateUserDTO dto = minimalDto();
+ dto.setIEmployeeId(7);
+
+ assertThat(service.createUser(dto)).isEqualTo(303);
+ verify(usrEmployeeMapper).selectById(7);
+ }
+}