diff --git a/backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java b/backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java new file mode 100644 index 0000000..c0bc742 --- /dev/null +++ b/backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java @@ -0,0 +1,10 @@ +package com.example.erp.common.constants; + +public final class UsrErrorCode { + + public static final int PERMISSION_DENIED = 40300; + public static final int USERNAME_EXISTS = 40901; + public static final int USER_CODE_EXISTS = 40902; + + private UsrErrorCode() {} +} diff --git a/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java b/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java index 9957bb2..b261f3e 100644 --- a/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java @@ -32,9 +32,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String token = header.substring(7); try { Claims claims = jwtUtil.parseAccessToken(token); + UserPrincipal principal = new UserPrincipal( + claims.getSubject(), + claims.get("username", String.class), + claims.get("userType", String.class), + claims.get("brandId", String.class)); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( - claims.getSubject(), null, Collections.emptyList()); + principal, null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(auth); } catch (BizException ignored) { // invalid token — no auth set; Spring Security will return 401 diff --git a/backend/src/main/java/com/example/erp/config/SecurityConfig.java b/backend/src/main/java/com/example/erp/config/SecurityConfig.java index 9653dd1..1ce020e 100644 --- a/backend/src/main/java/com/example/erp/config/SecurityConfig.java +++ b/backend/src/main/java/com/example/erp/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.example.erp.config; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -26,6 +27,9 @@ public class SecurityConfig { .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED))) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/src/main/java/com/example/erp/config/UserPrincipal.java b/backend/src/main/java/com/example/erp/config/UserPrincipal.java new file mode 100644 index 0000000..08a985b --- /dev/null +++ b/backend/src/main/java/com/example/erp/config/UserPrincipal.java @@ -0,0 +1,3 @@ +package com.example.erp.config; + +public record UserPrincipal(String userId, String username, String userType, String brandId) {} diff --git a/backend/src/main/java/com/example/erp/module/usr/controller/UserController.java b/backend/src/main/java/com/example/erp/module/usr/controller/UserController.java new file mode 100644 index 0000000..fd78f09 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/controller/UserController.java @@ -0,0 +1,41 @@ +package com.example.erp.module.usr.controller; + +import com.example.erp.common.response.Result; +import com.example.erp.config.UserPrincipal; +import com.example.erp.module.usr.dto.UserCreateReqDTO; +import com.example.erp.module.usr.service.UserService; +import com.example.erp.module.usr.vo.PermissionGroupVO; +import com.example.erp.module.usr.vo.StaffVO; +import com.example.erp.module.usr.vo.UserCreateRespVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/usr") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/users") + public Result createUser( + @Valid @RequestBody UserCreateReqDTO req, + @AuthenticationPrincipal UserPrincipal principal) { + return Result.ok(userService.createUser(req, principal)); + } + + @GetMapping("/users/staffs") + public Result> getStaffs(@AuthenticationPrincipal UserPrincipal principal) { + return Result.ok(userService.getStaffs(principal.brandId())); + } + + @GetMapping("/users/permission-groups") + public Result> getPermissionGroups( + @AuthenticationPrincipal UserPrincipal principal) { + return Result.ok(userService.getPermissionGroups(principal.brandId())); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java b/backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java new file mode 100644 index 0000000..681ec04 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java @@ -0,0 +1,33 @@ +package com.example.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class UserCreateReqDTO { + + @NotBlank(message = "用户号不能为空") + private String userCode; + + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "用户类型不能为空") + @Pattern(regexp = "普通用户|超级管理员", message = "用户类型无效") + private String userType; + + @NotBlank(message = "语言不能为空") + @Pattern(regexp = "中文|英文|繁体", message = "语言无效") + private String language; + + private boolean canEditDoc = false; + + private String employeeId; + + private List permGroupIds; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java b/backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java new file mode 100644 index 0000000..858b730 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java @@ -0,0 +1,40 @@ +package com.example.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("usr_permission_group") +public class PermissionGroupEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sGroupCode") + private String sGroupCode; + + @TableField("sGroupName") + private String sGroupName; + + @TableField("sCategory") + private String sCategory; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java b/backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java new file mode 100644 index 0000000..9368712 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java @@ -0,0 +1,52 @@ +package com.example.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("tStaff") +public class StaffEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sStaffNo") + private String sStaffNo; + + @TableField("sStaffName") + private String sStaffName; + + @TableField("sDepartment") + private String sDepartment; + + @TableField("sCreatedBy") + private String sCreatedBy; + + @TableField("bDeleted") + private Integer bDeleted; + + @TableField("tDeletedDate") + private LocalDateTime tDeletedDate; + + @TableField("sDeletedBy") + private String sDeletedBy; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java b/backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java new file mode 100644 index 0000000..e54ff93 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java @@ -0,0 +1,37 @@ +package com.example.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("usr_user_permission") +public class UserPermissionEntity { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sUserId") + private String sUserId; + + @TableField("sPermGroupId") + private String sPermGroupId; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java b/backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java new file mode 100644 index 0000000..9dfaa91 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java @@ -0,0 +1,8 @@ +package com.example.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.erp.module.usr.entity.PermissionGroupEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PermissionGroupMapper extends BaseMapper {} diff --git a/backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java b/backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java new file mode 100644 index 0000000..487b460 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java @@ -0,0 +1,8 @@ +package com.example.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.erp.module.usr.entity.StaffEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface StaffMapper extends BaseMapper {} diff --git a/backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java b/backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java new file mode 100644 index 0000000..9ff318d --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java @@ -0,0 +1,8 @@ +package com.example.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.example.erp.module.usr.entity.UserPermissionEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserPermissionMapper extends BaseMapper {} diff --git a/backend/src/main/java/com/example/erp/module/usr/service/UserService.java b/backend/src/main/java/com/example/erp/module/usr/service/UserService.java new file mode 100644 index 0000000..31f8d1e --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/service/UserService.java @@ -0,0 +1,18 @@ +package com.example.erp.module.usr.service; + +import com.example.erp.config.UserPrincipal; +import com.example.erp.module.usr.dto.UserCreateReqDTO; +import com.example.erp.module.usr.vo.PermissionGroupVO; +import com.example.erp.module.usr.vo.StaffVO; +import com.example.erp.module.usr.vo.UserCreateRespVO; + +import java.util.List; + +public interface UserService { + + UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal); + + List getStaffs(String brandId); + + List getPermissionGroups(String brandId); +} diff --git a/backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java b/backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..f5345a1 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java @@ -0,0 +1,129 @@ +package com.example.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.example.erp.common.constants.UsrErrorCode; +import com.example.erp.common.exception.BizException; +import com.example.erp.config.UserPrincipal; +import com.example.erp.module.usr.dto.UserCreateReqDTO; +import com.example.erp.module.usr.entity.PermissionGroupEntity; +import com.example.erp.module.usr.entity.StaffEntity; +import com.example.erp.module.usr.entity.UserPermissionEntity; +import com.example.erp.module.usr.entity.UsrUserEntity; +import com.example.erp.module.usr.mapper.PermissionGroupMapper; +import com.example.erp.module.usr.mapper.StaffMapper; +import com.example.erp.module.usr.mapper.UserPermissionMapper; +import com.example.erp.module.usr.mapper.UsrUserMapper; +import com.example.erp.module.usr.service.UserService; +import com.example.erp.module.usr.vo.PermissionGroupVO; +import com.example.erp.module.usr.vo.StaffVO; +import com.example.erp.module.usr.vo.UserCreateRespVO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UsrUserMapper userMapper; + private final StaffMapper staffMapper; + private final PermissionGroupMapper permGroupMapper; + private final UserPermissionMapper userPermissionMapper; + private final BCryptPasswordEncoder passwordEncoder; + + @Override + @Transactional + public UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal) { + if (!"超级管理员".equals(principal.userType())) { + throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足"); + } + + if (userMapper.selectCount(new LambdaQueryWrapper() + .eq(UsrUserEntity::getSUserCode, req.getUserCode())) > 0) { + throw new BizException(UsrErrorCode.USER_CODE_EXISTS, "用户号已存在"); + } + + if (userMapper.selectCount(new LambdaQueryWrapper() + .eq(UsrUserEntity::getSUsername, req.getUsername()) + .eq(UsrUserEntity::getSBrandsId, principal.brandId())) > 0) { + throw new BizException(UsrErrorCode.USERNAME_EXISTS, "用户名已存在"); + } + + if (req.getEmployeeId() != null) { + StaffEntity staff = staffMapper.selectOne(new LambdaQueryWrapper() + .eq(StaffEntity::getSId, req.getEmployeeId()) + .eq(StaffEntity::getSBrandsId, principal.brandId())); + if (staff == null) { + throw new BizException(40001, "员工不存在"); + } + } + + UsrUserEntity user = new UsrUserEntity(); + user.setSId(UUID.randomUUID().toString()); + user.setSBrandsId(principal.brandId()); + user.setSCreatorUsername(principal.username()); + user.setTCreateDate(LocalDateTime.now()); + user.setSUserCode(req.getUserCode()); + user.setSUsername(req.getUsername()); + user.setSPasswordHash(passwordEncoder.encode("666666")); + user.setSUserType(req.getUserType()); + user.setSLanguage(req.getLanguage()); + user.setBCanEditDoc(req.isCanEditDoc() ? 1 : 0); + user.setBIsDisabled(0); + user.setSEmployeeId(req.getEmployeeId()); + user.setILoginFailCount(0); + userMapper.insert(user); + + if (req.getPermGroupIds() != null && !req.getPermGroupIds().isEmpty()) { + for (String groupId : req.getPermGroupIds()) { + UserPermissionEntity perm = new UserPermissionEntity(); + perm.setSId(UUID.randomUUID().toString()); + perm.setSBrandsId(principal.brandId()); + perm.setTCreateDate(LocalDateTime.now()); + perm.setSUserId(user.getSId()); + perm.setSPermGroupId(groupId); + userPermissionMapper.insert(perm); + } + } + + UserCreateRespVO vo = new UserCreateRespVO(); + vo.setUserId(user.getSId()); + vo.setUserCode(user.getSUserCode()); + vo.setUsername(user.getSUsername()); + return vo; + } + + @Override + @Transactional(readOnly = true) + public List getStaffs(String brandId) { + List staffs = staffMapper.selectList(new LambdaQueryWrapper() + .eq(StaffEntity::getSBrandsId, brandId) + .eq(StaffEntity::getBDeleted, 0)); + return staffs.stream().map(s -> { + StaffVO vo = new StaffVO(); + vo.setSId(s.getSId()); + vo.setSStaffName(s.getSStaffName()); + return vo; + }).collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List getPermissionGroups(String brandId) { + List groups = permGroupMapper.selectList(null); + return groups.stream().map(g -> { + PermissionGroupVO vo = new PermissionGroupVO(); + vo.setSId(g.getSId()); + vo.setSGroupCode(g.getSGroupCode()); + vo.setSGroupName(g.getSGroupName()); + vo.setSCategory(g.getSCategory()); + return vo; + }).collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java new file mode 100644 index 0000000..201de85 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java @@ -0,0 +1,18 @@ +package com.example.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PermissionGroupVO { + @JsonProperty("sId") + private String sId; + @JsonProperty("sGroupCode") + private String sGroupCode; + @JsonProperty("sGroupName") + private String sGroupName; + @JsonProperty("sCategory") + private String sCategory; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java new file mode 100644 index 0000000..6b58361 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java @@ -0,0 +1,14 @@ +package com.example.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class StaffVO { + @JsonProperty("sId") + private String sId; + @JsonProperty("sStaffName") + private String sStaffName; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java new file mode 100644 index 0000000..27859d2 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java @@ -0,0 +1,12 @@ +package com.example.erp.module.usr.vo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserCreateRespVO { + private String userId; + private String userCode; + private String username; +} diff --git a/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java index 655822a..d3610ea 100644 --- a/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java +++ b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java @@ -11,6 +11,10 @@ import com.example.erp.module.usr.service.impl.AuthServiceImpl; import com.example.erp.module.usr.vo.BrandVO; import com.example.erp.module.usr.vo.LoginVO; import io.jsonwebtoken.Claims; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +34,14 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class AuthServiceTest { + @BeforeAll + static void initMyBatisEntityCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + UsrUserEntity.class); + } + + @Mock private BrandMapper brandMapper; @Mock private UsrUserMapper userMapper; @Mock private JwtUtil jwtUtil; diff --git a/backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java b/backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java new file mode 100644 index 0000000..d64f832 --- /dev/null +++ b/backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java @@ -0,0 +1,118 @@ +package com.example.erp.module.usr; + +import com.example.erp.config.BeanConfig; +import com.example.erp.config.JwtAuthenticationFilter; +import com.example.erp.config.JwtProperties; +import com.example.erp.config.SecurityConfig; +import com.example.erp.common.util.JwtUtil; +import com.example.erp.module.usr.controller.UserController; +import com.example.erp.module.usr.service.UserService; +import com.example.erp.module.usr.vo.UserCreateRespVO; +import com.example.erp.module.usr.vo.StaffVO; +import com.example.erp.module.usr.vo.PermissionGroupVO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ActiveProfiles("test") +@WebMvcTest(controllers = UserController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class}) +class UserControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtUtil jwtUtil; + @MockBean private UserService userService; + + RequestPostProcessor superAdmin() { + String token = jwtUtil.generateAccessToken("u1", "admin", "超级管理员", "b1"); + return request -> { request.addHeader("Authorization", "Bearer " + token); return request; }; + } + + @Test + void createUser_noAuth_returns401() throws Exception { + mockMvc.perform(post("/api/usr/users") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void createUser_validRequest_returns200() throws Exception { + UserCreateRespVO resp = new UserCreateRespVO(); + resp.setUserId("new-user-id"); + resp.setUserCode("UC001"); + resp.setUsername("testuser"); + when(userService.createUser(any(), any())).thenReturn(resp); + + Map body = Map.of( + "userCode", "UC001", + "username", "testuser", + "userType", "普通用户", + "language", "中文"); + mockMvc.perform(post("/api/usr/users") + .with(superAdmin()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.userId").value("new-user-id")); + } + + @Test + void createUser_missingUserCode_returns40001() throws Exception { + Map body = Map.of( + "username", "testuser", + "userType", "普通用户", + "language", "中文"); + mockMvc.perform(post("/api/usr/users") + .with(superAdmin()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + } + + @Test + void getStaffs_returns200() throws Exception { + StaffVO s = new StaffVO(); + s.setSId("s1"); + s.setSStaffName("张三"); + when(userService.getStaffs(anyString())).thenReturn(List.of(s)); + + mockMvc.perform(get("/api/usr/users/staffs").with(superAdmin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[0].sId").value("s1")); + } + + @Test + void getPermissionGroups_returns200() throws Exception { + PermissionGroupVO g = new PermissionGroupVO(); + g.setSId("g1"); + g.setSGroupCode("usr:create"); + g.setSGroupName("新增用户"); + when(userService.getPermissionGroups(anyString())).thenReturn(List.of(g)); + + mockMvc.perform(get("/api/usr/users/permission-groups").with(superAdmin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[0].sGroupCode").value("usr:create")); + } +} diff --git a/backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java b/backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java new file mode 100644 index 0000000..34eb6af --- /dev/null +++ b/backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java @@ -0,0 +1,119 @@ +package com.example.erp.module.usr; + +import com.example.erp.common.exception.BizException; +import com.example.erp.config.UserPrincipal; +import com.example.erp.module.usr.dto.UserCreateReqDTO; +import com.example.erp.module.usr.entity.StaffEntity; +import com.example.erp.module.usr.entity.UsrUserEntity; +import com.example.erp.module.usr.entity.UserPermissionEntity; +import com.example.erp.module.usr.mapper.PermissionGroupMapper; +import com.example.erp.module.usr.mapper.StaffMapper; +import com.example.erp.module.usr.mapper.UserPermissionMapper; +import com.example.erp.module.usr.mapper.UsrUserMapper; +import com.example.erp.module.usr.service.impl.UserServiceImpl; +import com.example.erp.module.usr.vo.UserCreateRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock private UsrUserMapper userMapper; + @Mock private StaffMapper staffMapper; + @Mock private PermissionGroupMapper permGroupMapper; + @Mock private UserPermissionMapper userPermissionMapper; + @Mock private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks + private UserServiceImpl userService; + + private UserCreateReqDTO req; + private UserPrincipal superAdmin; + private UserPrincipal normalUser; + + @BeforeEach + void setUp() { + req = new UserCreateReqDTO(); + req.setUserCode("UC001"); + req.setUsername("testuser"); + req.setUserType("普通用户"); + req.setLanguage("中文"); + + superAdmin = new UserPrincipal("u1", "admin", "超级管理员", "b1"); + normalUser = new UserPrincipal("u2", "user", "普通用户", "b1"); + } + + @Test + void createUser_normalUser_throws40300() { + BizException ex = assertThrows(BizException.class, + () -> userService.createUser(req, normalUser)); + assertEquals(40300, ex.getCode()); + } + + @Test + void createUser_success_insertsUserAndReturnsVO() { + when(userMapper.selectCount(any())).thenReturn(0L); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed"); + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1); + + UserCreateRespVO vo = userService.createUser(req, superAdmin); + + assertNotNull(vo.getUserId()); + assertEquals("UC001", vo.getUserCode()); + assertEquals("testuser", vo.getUsername()); + verify(userMapper).insert(any(UsrUserEntity.class)); + } + + @Test + void createUser_duplicateUserCode_throws40902() { + when(userMapper.selectCount(any())).thenReturn(1L); + + BizException ex = assertThrows(BizException.class, + () -> userService.createUser(req, superAdmin)); + assertEquals(40902, ex.getCode()); + } + + @Test + void createUser_duplicateUsername_throws40901() { + when(userMapper.selectCount(any())).thenReturn(0L, 1L); + + BizException ex = assertThrows(BizException.class, + () -> userService.createUser(req, superAdmin)); + assertEquals(40901, ex.getCode()); + } + + @Test + void createUser_withPermGroups_insertsPermissions() { + req.setPermGroupIds(List.of("g1", "g2")); + when(userMapper.selectCount(any())).thenReturn(0L); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed"); + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1); + when(userPermissionMapper.insert(any(UserPermissionEntity.class))).thenReturn(1); + + userService.createUser(req, superAdmin); + + verify(userPermissionMapper, times(2)).insert(any(UserPermissionEntity.class)); + } + + @Test + void createUser_invalidEmployeeId_throws40001() { + req.setEmployeeId("bad-staff-id"); + when(userMapper.selectCount(any())).thenReturn(0L); + when(staffMapper.selectOne(any())).thenReturn(null); + + BizException ex = assertThrows(BizException.class, + () -> userService.createUser(req, superAdmin)); + assertEquals(40001, ex.getCode()); + } +} diff --git a/docs/superpowers/plans/2026-05-08-REQ-USR-001.md b/docs/superpowers/plans/2026-05-08-REQ-USR-001.md new file mode 100644 index 0000000..4deafdd --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-REQ-USR-001.md @@ -0,0 +1,343 @@ +# REQ-USR-001 增加用户 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 超级管理员通过 POST /api/usr/users 新建用户账号,系统自动初始化密码、写入权限关联;前端提供「用户管理」页+新增 Drawer 表单。 + +**Architecture:** Spring Boot 后端新增 UserController(三个端点:createUser / getStaffs / getPermissionGroups)+ UserServiceImpl(业务逻辑+事务)+ 三个辅助 Entity/Mapper(StaffEntity、PermissionGroupEntity、UserPermissionEntity)。JwtAuthenticationFilter 升级为存储 UserPrincipal record,使 Controller 通过 @AuthenticationPrincipal 取到 brandId / username / userType。前端新增 api/usr.ts + UserListPage.tsx + UserFormDrawer.tsx,在 App.tsx 补充 /usr/users 路由。 + +**Tech Stack:** Spring Boot 3.3.5 + MyBatis-Plus 3.5.7 + Lombok + Spring Security + spring-security-test;React 18 + Ant Design 5 + Vitest + @testing-library/react + +--- + +## 文件映射 + +**新建**: +- `backend/.../config/UserPrincipal.java` — JWT principal record(userId, username, userType, brandId) +- `backend/.../module/usr/entity/StaffEntity.java` — 映射 tStaff +- `backend/.../module/usr/entity/PermissionGroupEntity.java` — 映射 usr_permission_group +- `backend/.../module/usr/entity/UserPermissionEntity.java` — 映射 usr_user_permission +- `backend/.../module/usr/mapper/StaffMapper.java` +- `backend/.../module/usr/mapper/PermissionGroupMapper.java` +- `backend/.../module/usr/mapper/UserPermissionMapper.java` +- `backend/.../common/constants/UsrErrorCode.java` — 40300/40901/40902 +- `backend/.../module/usr/dto/UserCreateReqDTO.java` +- `backend/.../module/usr/vo/UserCreateRespVO.java` +- `backend/.../module/usr/vo/StaffVO.java` +- `backend/.../module/usr/vo/PermissionGroupVO.java` +- `backend/.../module/usr/service/UserService.java` +- `backend/.../module/usr/service/impl/UserServiceImpl.java` +- `backend/.../module/usr/controller/UserController.java` +- `backend/.../module/usr/UserServiceTest.java` +- `backend/.../module/usr/UserControllerTest.java` +- `frontend/src/api/usr.ts` +- `frontend/src/pages/usr/UserListPage.tsx` +- `frontend/src/pages/usr/UserFormDrawer.tsx` +- `frontend/src/test/UserListPage.test.tsx` + +**修改**: +- `backend/.../config/JwtAuthenticationFilter.java` — 存 UserPrincipal 而非 String(CLAUDE.md S2,必要基础设施) +- `frontend/src/App.tsx` — 补 /usr/users 路由 + +--- + +## 合同级常量 + +**UsrErrorCode**: +- `PERMISSION_DENIED = 40300` +- `USERNAME_EXISTS = 40901` +- `USER_CODE_EXISTS = 40902` + +**UsrErrorCode 用法**:`throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足")` + +**UserPrincipal(Java record)**: +```java +package com.example.erp.config; +public record UserPrincipal(String userId, String username, String userType, String brandId) {} +``` + +**JWT claim 名**(来自 JwtUtil.generateAccessToken): +- subject → userId +- `"username"` → username +- `"userType"` → userType +- `"brandId"` → brandId + +--- + +### Task 1: UserPrincipal + JwtAuthenticationFilter 升级(跨模块基础设施 S2) + +**Files:** +- Create: `backend/src/main/java/com/example/erp/config/UserPrincipal.java` +- Modify: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` + +**API shape:** +- `record UserPrincipal(String userId, String username, String userType, String brandId) {}` +- `JwtAuthenticationFilter.doFilterInternal()` — 解析 claims 后构造 `UserPrincipal`,存入 `UsernamePasswordAuthenticationToken` 的 principal + +- [ ] **Step 1: 写失败测试(在 UserControllerTest 中)** + - 文件:`backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` + - 仅写类骨架 + 一个空测试方法 `createUser_noToken_returns401()`,注解 `@WebMvcTest(controllers = UserController.class)` + - 此时编译失败(UserController 不存在)→ 子会话确认 FAIL + +- [ ] **Step 2: 实现 UserPrincipal + 修改 JwtAuthenticationFilter** + - 创建 `UserPrincipal.java` record(见合同级常量) + - 修改 `JwtAuthenticationFilter.doFilterInternal()`: + ```java + UserPrincipal principal = new UserPrincipal( + claims.getSubject(), + claims.get("username", String.class), + claims.get("userType", String.class), + claims.get("brandId", String.class) + ); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(auth); + ``` + +- [ ] **Step 3: 子会话验证已有测试仍通过** + - 命令:`JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home mvn test -pl backend -Dtest=AuthControllerTest,AuthServiceTest` + - 期待:全部 PASS + +- [ ] **Step 4: Commit** + - `git add backend/src/main/java/com/example/erp/config/UserPrincipal.java backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java` + - `git commit -m "feat(usr): UserPrincipal record + JwtFilter升级存principal REQ-USR-001"` + +--- + +### Task 2: 实体 + Mapper + 错误码 + DTO/VO 骨架 + +**Files:** +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java` +- Create: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java` + +**StaffEntity 字段**(来自 docs/03 tStaff 表):`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sStaffNo`, `sStaffName`, `sDepartment`, `sCreatedBy`, `bDeleted(Bit/Integer)`, `tDeletedDate`, `sDeletedBy`;`@TableName("tStaff")` + +**PermissionGroupEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sGroupCode`, `sGroupName`, `sCategory`;`@TableName("usr_permission_group")` + +**UserPermissionEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sUserId`, `sPermGroupId`;`@TableName("usr_user_permission")` + +**UserCreateReqDTO 字段**(`@Valid` 注解): +```java +@NotBlank String userCode; +@NotBlank String username; +@NotBlank @Pattern(regexp = "普通用户|超级管理员") String userType; +@NotBlank @Pattern(regexp = "中文|英文|繁体") String language; +boolean canEditDoc = false; +String employeeId; // nullable +List permGroupIds; // nullable +``` + +**UserCreateRespVO**:`String userId, userCode, username` +**StaffVO**:`String sId, sStaffName` +**PermissionGroupVO**:`String sId, sGroupCode, sGroupName, sCategory` + +- [ ] **Step 1: 写失败测试** + - 在 `UserServiceTest.java` 中写类骨架 + 一个空测试 `createUser_normalUser_throws40300()`,引用 `UserService`(未创建) + - 子会话确认编译 FAIL + +- [ ] **Step 2: 创建所有文件** + - 按上方规格创建所有 entity / mapper / errorcode / dto / vo 文件 + - 每个 mapper 继承 `BaseMapper` + - 每个 entity 使用 Lombok `@Getter @Setter` + +- [ ] **Step 3: 子会话验证编译通过** + - 命令:`JAVA_HOME=... mvn compile -pl backend` + +- [ ] **Step 4: Commit** + - `git add backend/src/main/java/` + - `git commit -m "feat(usr): 实体/Mapper/DTO/VO骨架 + UsrErrorCode REQ-USR-001"` + +--- + +### Task 3: UserServiceImpl — createUser 业务逻辑 + +**Files:** +- Create: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + +**API shape:** +- `UserService#createUser(UserCreateReqDTO req, UserPrincipal principal) : UserCreateRespVO` +- `UserService#getStaffs(String brandId) : List` +- `UserService#getPermissionGroups(String brandId) : List` + +**createUser 逻辑序列**: +1. `!"超级管理员".equals(principal.userType())` → `throw new BizException(40300, "权限不足")` +2. `userMapper.selectCount(LQ...eq(sUserCode, req.getUserCode())) > 0` → `throw new BizException(40902, "用户号已存在")` +3. `userMapper.selectCount(LQ...eq(sUsername).eq(sBrandsId)) > 0` → `throw new BizException(40901, "用户名已存在")` +4. `req.getEmployeeId() != null` → `staffMapper.selectOne(LQ...eq(sId).eq(sBrandsId)) == null` → `throw new BizException(40001, "员工不存在")` +5. 构造 `UsrUserEntity`:sId=UUID,sBrandsId=principal.brandId(),sCreatorUsername=principal.username(),tCreateDate=now,sPasswordHash=passwordEncoder.encode("666666"),bIsDisabled=0,iLoginFailCount=0 +6. `userMapper.insert(user)` +7. `permGroupIds` 非 null 且非空 → 循环 `userPermissionMapper.insert(new UserPermissionEntity(UUID, brandId, now, user.sId, groupId))` +8. 返回 `UserCreateRespVO(user.sId, user.sUserCode, user.sUsername)` + +**UserServiceImpl 依赖**:`@RequiredArgsConstructor`;注入 `UsrUserMapper`、`StaffMapper`、`PermissionGroupMapper`、`UserPermissionMapper`、`BCryptPasswordEncoder` + +- [ ] **Step 1: 写失败测试** + - `UserServiceTest.java` 完整写入下列测试(全部引用 `UserService` 接口),子会话确认失败(UserService 接口不存在): + - `createUser_normalUser_throws40300` — principal.userType=普通用户 → BizException(40300) + - `createUser_success_insertsUserAndReturnsVO` — all mocks pass → 验证 userMapper.insert 被调用,返回 VO 有 userId + - `createUser_duplicateUserCode_throws40902` — selectCount 第1次返回 1L → BizException(40902) + - `createUser_duplicateUsername_throws40901` — selectCount 第1次返回 0L,第2次返回 1L → BizException(40901) + - `createUser_withPermGroups_insertsPermissions` — permGroupIds=["g1","g2"] → verify userPermissionMapper.insert 被调用 2 次 + - `createUser_invalidEmployeeId_throws40001` — staffMapper.selectOne 返回 null → BizException(40001) + +- [ ] **Step 2: 实现 UserService + UserServiceImpl** + - 创建 `UserService.java` 接口(三个方法签名) + - 创建 `UserServiceImpl.java`,`@Service @RequiredArgsConstructor`,按逻辑序列实现 `createUser()` + - `getStaffs(brandId)`: `staffMapper.selectList(LQ...eq(sBrandsId, brandId).eq(bDeleted, 0).select("sId","sStaffName"))` → 映射 `StaffVO` + - `getPermissionGroups(brandId)`: `permGroupMapper.selectList(LQ...eq(sBrandsId, brandId))` → 映射 `PermissionGroupVO`(如 brandId 空则 selectList(null)) + +- [ ] **Step 3: 子会话验证 UserServiceTest 全部通过** + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserServiceTest` + +- [ ] **Step 4: Commit** + - `git add backend/src/` + - `git commit -m "feat(usr): UserServiceImpl.createUser + getStaffs + getPermissionGroups REQ-USR-001"` + +--- + +### Task 4: UserController — 三个端点 + +**Files:** +- Create: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` + +**API shape:** +```java +@RestController +@RequestMapping("/api/usr") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @PostMapping("/users") + public Result createUser( + @Valid @RequestBody UserCreateReqDTO req, + @AuthenticationPrincipal UserPrincipal principal) { ... } + + @GetMapping("/users/staffs") + public Result> getStaffs(@AuthenticationPrincipal UserPrincipal principal) { ... } + + @GetMapping("/users/permission-groups") + public Result> getPermissionGroups(@AuthenticationPrincipal UserPrincipal principal) { ... } +} +``` + +**测试助手** — `SecurityMockMvcRequestPostProcessors.authentication(...)` 注入 UserPrincipal: +```java +static RequestPostProcessor superAdmin() { + return authentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("u1","admin","超级管理员","b1"), null, emptyList())); +} +static RequestPostProcessor normalUser() { + return authentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("u2","user","普通用户","b1"), null, emptyList())); +} +``` + +- [ ] **Step 1: 写失败测试** + - `UserControllerTest.java` 完整(`@WebMvcTest(UserController.class)` + `@Import(SecurityConfig, JwtAuthenticationFilter, BeanConfig, JwtUtil, JwtProperties)` + `@MockBean UserService`): + - `createUser_noAuth_returns401` — 无 auth header → Spring Security 返回 401 + - `createUser_validRequest_returns200` — `superAdmin()` + mock service返回VO → 验证 code=200, data.userId 非空 + - `createUser_missingUserCode_returns40001` — 请求体 userCode 为空 → 验证 code=40001 + - `getStaffs_returns200` — `superAdmin()` + mock returns list → 验证 code=200 + - `getPermissionGroups_returns200` — `superAdmin()` + mock returns list → 验证 code=200 + - 子会话确认 FAIL(UserController 不存在) + +- [ ] **Step 2: 实现 UserController** + - 按 API shape 创建 `UserController.java` + - `createUser`: `Result.ok(userService.createUser(req, principal))` + - `getStaffs`: `Result.ok(userService.getStaffs(principal.brandId()))` + - `getPermissionGroups`: `Result.ok(userService.getPermissionGroups(principal.brandId()))` + +- [ ] **Step 3: 子会话验证 UserControllerTest 全部通过** + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserControllerTest,UserServiceTest,AuthControllerTest,AuthServiceTest` + +- [ ] **Step 4: Commit** + - `git add backend/src/` + - `git commit -m "feat(usr): UserController POST/users GET/staffs GET/permission-groups REQ-USR-001"` + +--- + +### Task 5: 前端 api/usr.ts + UserFormDrawer + UserListPage + App.tsx 路由 + +**Files:** +- Create: `frontend/src/api/usr.ts` +- Create: `frontend/src/pages/usr/UserListPage.tsx` +- Create: `frontend/src/pages/usr/UserFormDrawer.tsx` +- Create: `frontend/src/test/UserListPage.test.tsx` +- Modify: `frontend/src/App.tsx` + +**api/usr.ts 接口**: +```ts +export interface StaffVO { sId: string; sStaffName: string } +export interface PermissionGroupVO { sId: string; sGroupCode: string; sGroupName: string; sCategory: string | null } +export interface UserCreateReq { + userCode: string; username: string; userType: '普通用户' | '超级管理员'; + language: '中文' | '英文' | '繁体'; canEditDoc?: boolean; + employeeId?: string | null; permGroupIds?: string[] +} +export interface UserCreateResp { userId: string; userCode: string; username: string } + +export function getStaffs(): Promise +export function getPermissionGroups(): Promise +export function createUser(req: UserCreateReq): Promise +``` +(`request.get('/usr/users/staffs')` 等) + +**UserFormDrawer.tsx** props: `open: boolean`, `onClose: () => void`, `onSuccess: () => void` +- `useEffect` 拉取 staffs + permissionGroups +- Form 字段:userCode(Input), username(Input), userType(Select), language(Select), canEditDoc(Checkbox), employeeId(Select, options=staffs), permissions(Table with checkbox column) +- 提交:调 `createUser()`,成功 → `message.success('新增用户成功')` + `onSuccess()`;失败 → `message.error(e.message)` + +**UserListPage.tsx**: +- 顶部「新增」按钮(``,由 authSlice 中 userType 控制显示) +- 点击按钮 → `setDrawerOpen(true)` +- ` setDrawerOpen(false)} onSuccess={() => setDrawerOpen(false)} />` +- 表格区域为 stub(empty Table,REQ-USR-003 补充) + +**PermButton**(如不存在则在本任务创建 `frontend/src/components/PermButton.tsx`): +```tsx +// REQ-USR-001: 权限按钮,根据 userType 控制显示 +export function PermButton({ permission, children, ...props }: { permission: string } & ButtonProps) { + const userType = useAppSelector(s => s.auth.userInfo?.userType) + // usr:create / usr:edit 仅超级管理员可见 + if (userType !== '超级管理员') return null + return +} +``` + +**App.tsx 新增路由**: +```tsx +} /> +``` + +**测试 `UserListPage.test.tsx`**(三个测试): +1. `superAdmin_seesNewButton` — store userType=超级管理员 → 「新增」按钮可见 +2. `normalUser_doesNotSeeNewButton` — store userType=普通用户 → 「新增」按钮不可见 +3. `clickNewButton_opensDrawer` — 超级管理员点击新增 → UserFormDrawer 出现(mock API calls with vi.mock) + +- [ ] **Step 1: 写失败测试** + - 写 `UserListPage.test.tsx` 三个测试(引用 `UserListPage`、`PermButton`) + - 子会话确认 FAIL(文件不存在) + +- [ ] **Step 2: 实现** + - 按上方规格创建 `api/usr.ts`、`components/PermButton.tsx`、`UserFormDrawer.tsx`、`UserListPage.tsx` + - 修改 `App.tsx` 添加路由 + +- [ ] **Step 3: 子会话验证前端测试通过** + - 命令:`cd frontend && npm run test -- --run` + +- [ ] **Step 4: Commit** + - `git add frontend/src/` + - `git commit -m "feat(usr): 前端 UserListPage + UserFormDrawer + usr.ts + PermButton REQ-USR-001"` diff --git a/docs/superpowers/specs/2026-05-08-REQ-USR-001.md b/docs/superpowers/specs/2026-05-08-REQ-USR-001.md new file mode 100644 index 0000000..b016417 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-REQ-USR-001.md @@ -0,0 +1,113 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-08 +module: module_usr +--- + +# Spec: REQ-USR-001 — 增加用户 + +## 目标 + +超级管理员在用户管理页新建用户账号,填写基本信息并分配权限组,账号保存后立即生效,可使用初始密码 666666 登录系统。 + +## 输入 / 触发 + +**触发**:超级管理员点击用户管理页「新增」按钮,弹出 Drawer,填写表单并提交。 + +**POST /api/usr/users 请求体**: +```json +{ + "userCode": "string(必填,用户号)", + "username": "string(必填,用户名,同一 brand 内唯一)", + "userType": "普通用户|超级管理员(必填)", + "language": "中文|英文|繁体(必填)", + "canEditDoc": false, + "employeeId": "string|null(可选,tStaff.sId)", + "permGroupIds": ["string"] +} +``` + +**前端 Drawer 表单字段**: +| 字段 | 组件 | 必填 | 来源/选项 | +|---|---|---|---| +| 用户号 | Input | 是 | 手工输入 | +| 用户名 | Input | 是 | 手工输入 | +| 类型 | Select | 是 | 普通用户 / 超级管理员 | +| 语言 | Select | 是 | 中文 / 英文 / 繁体 | +| 单据修改权限 | Checkbox | 否 | 默认不勾 | +| 员工 | Select | 否 | GET /api/usr/staffs 返回列表,label=sStaffName, value=sId | +| 权限组 | Table+Checkbox | 否 | GET /api/usr/permission-groups 返回列表,列:权限分类/权限名称 | + +## 输出 / 结果 + +**成功响应**: +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "userId": "string", + "userCode": "string", + "username": "string" + } +} +``` + +前端收到成功响应后:`message.success("新增用户成功")` → 关闭 Drawer。 + +## 业务规则 + +1. **权限控制**:请求者 JWT 中 `userType == 超级管理员`,否则抛 `BizException(40300, "权限不足")`。 +2. **唯一性检查(同 brand 内)**: + - `sUserCode` 全库唯一(索引 `uk_usr_user_usercode`);重复抛 `BizException(40902, "用户号已存在")`。 + - `sUsername` 在同一 `sBrandsId` 内唯一(索引 `uk_usr_user_username_tenant`);重复抛 `BizException(40901, "用户名已存在")`。 +3. **密码初始化**:`sPasswordHash = BCryptPasswordEncoder.encode("666666")`,禁止存明文。 +4. **系统字段自动填充**(Controller 或 Service 层注入 `UserPrincipal`): + - `sId` = `UUID.randomUUID().toString()` + - `sBrandsId` = JWT claim `brandId` + - `sCreatorUsername` = JWT claim `username` + - `tCreateDate` = `LocalDateTime.now()` + - `bIsDisabled` = 0 + - `iLoginFailCount` = 0 +5. **employeeId 校验**:若不为 null,需验证 `tStaff.sId == employeeId && tStaff.sBrandsId == brandId`;不存在抛 `BizException(40001, "员工不存在")`。 +6. **权限组写入**:`permGroupIds` 非空时,批量插入 `usr_user_permission`(`sUserId=新用户 sId`, `sPermGroupId=item`);`permGroupIds` 为空/null 时跳过。 +7. **事务**:`createUser()` 方法标注 `@Transactional`,usr_user 插入 + usr_user_permission 批量插入在同一事务中。 + +## 边界与约束 + +- 初始密码仅后端生成,不通过请求体传入,前端不展示。 +- `sUsername` 一旦创建不可修改(REQ-USR-002 约束)。 +- `userType` 枚举只有 `普通用户` 和 `超级管理员` 两种合法值;`language` 枚举只有 `中文`、`英文`、`繁体`;后端用 `@Pattern`/`@NotNull` + `@Valid` 校验,违规触发 `40001`。 +- 跨模块改动:需修改 `JwtAuthenticationFilter.java`(config 层),将 JWT claims 包装为 `UserPrincipal` 存入 SecurityContext,使 `UserController` 可通过 `@AuthenticationPrincipal UserPrincipal` 取到 `brandId` 和 `username`。此修改属必要基础设施扩展(CLAUDE.md S2)。 + +## 依赖的 schema 表 / 字段 + +**写入表**: +- `usr_user`:`sId`、`sBrandsId`、`sCreatorUsername`、`tCreateDate`、`sUserCode`、`sUsername`、`sPasswordHash`、`sUserType`、`sLanguage`、`bCanEditDoc`、`bIsDisabled`、`sEmployeeId`、`iLoginFailCount` +- `usr_user_permission`:`sId`(UUID)、`sBrandsId`、`tCreateDate`、`sUserId`、`sPermGroupId` + +**只读表**: +- `tStaff`:`sId`、`sStaffName`(员工下拉列表及 employeeId 校验) +- `usr_permission_group`:`sId`、`sGroupCode`、`sGroupName`、`sCategory`(权限组复选列表) + +## 依赖的接口 + +- `POST /api/auth/login`(REQ-USR-004)— 前端登录获取 JWT,携带 Bearer Token 才可调用本接口 +- `GET /api/usr/staffs`(本 REQ 新增,不在 docs/05 原始清单)— 员工下拉数据源,鉴权接口 + - 响应:`{ "data": [{ "sId": "...", "sStaffName": "..." }] }` +- `GET /api/usr/permission-groups`(本 REQ 新增,不在 docs/05 原始清单)— 权限组复选数据源,鉴权接口 + - 响应:`{ "data": [{ "sId": "...", "sGroupCode": "...", "sGroupName": "...", "sCategory": "..." }] }` +- `POST /api/usr/users`(docs/05 已定义)— 本 REQ 核心创建接口 + +## 验收标准 + +1. 超级管理员提交合法数据 → HTTP 200,`data.userId` 非空,数据库 `usr_user` 表新增一条记录,密码字段为 BCrypt 哈希(不含 "666666" 明文)。 +2. 新建用户可立即用 POST /api/auth/login(brandNo + username + "666666")登录成功。 +3. 重复用户名(同 brand)→ 返回 `code=40901`,用户名已存在。 +4. 重复用户号 → 返回 `code=40902`,用户号已存在。 +5. 普通用户调用本接口 → 返回 `code=40300`,权限不足。 +6. 缺必填字段(userCode / username / userType / language 为空)→ 返回 `code=40001`。 +7. 选中权限组后提交 → `usr_user_permission` 表有对应关联记录。 +8. 关联无效 employeeId → 返回 `code=40001`,员工不存在。 +9. 前端:新增 Drawer 表单加载时,员工下拉和权限组 Table 已从对应 API 拉取数据;提交成功后 Drawer 关闭并显示 `message.success`。 +10. 前端:普通用户登录后,用户管理页「新增」按钮不显示(`PermButton permission="usr:create"` 隐藏逻辑)。