Commit ff5af4717f312bc7afccf8f877c6e871f3ec78da

Authored by zichun
1 parent 38d8cfcc

feat(usr): 增加用户后端实现 REQ-USR-001

- UserPrincipal record + JwtAuthenticationFilter 注入用户上下文
- SecurityConfig 补充 authenticationEntryPoint 返回 401
- UserService/UserServiceImpl: 创建用户、获取员工列表、获取权限组
- UserController: POST /users、GET /users/staffs、GET /users/permission-groups
- UserServiceTest (6 cases) + UserControllerTest (5 cases) 全部通过
Showing 22 changed files with 1146 additions and 1 deletions
backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java 0 → 100644
  1 +package com.example.erp.common.constants;
  2 +
  3 +public final class UsrErrorCode {
  4 +
  5 + public static final int PERMISSION_DENIED = 40300;
  6 + public static final int USERNAME_EXISTS = 40901;
  7 + public static final int USER_CODE_EXISTS = 40902;
  8 +
  9 + private UsrErrorCode() {}
  10 +}
... ...
backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java
... ... @@ -32,9 +32,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
32 32 String token = header.substring(7);
33 33 try {
34 34 Claims claims = jwtUtil.parseAccessToken(token);
  35 + UserPrincipal principal = new UserPrincipal(
  36 + claims.getSubject(),
  37 + claims.get("username", String.class),
  38 + claims.get("userType", String.class),
  39 + claims.get("brandId", String.class));
35 40 UsernamePasswordAuthenticationToken auth =
36 41 new UsernamePasswordAuthenticationToken(
37   - claims.getSubject(), null, Collections.emptyList());
  42 + principal, null, Collections.emptyList());
38 43 SecurityContextHolder.getContext().setAuthentication(auth);
39 44 } catch (BizException ignored) {
40 45 // invalid token — no auth set; Spring Security will return 401
... ...
backend/src/main/java/com/example/erp/config/SecurityConfig.java
1 1 package com.example.erp.config;
2 2  
  3 +import jakarta.servlet.http.HttpServletResponse;
3 4 import lombok.RequiredArgsConstructor;
4 5 import org.springframework.context.annotation.Bean;
5 6 import org.springframework.context.annotation.Configuration;
... ... @@ -26,6 +27,9 @@ public class SecurityConfig {
26 27 .requestMatchers("/api/auth/**").permitAll()
27 28 .anyRequest().authenticated()
28 29 )
  30 + .exceptionHandling(ex -> ex
  31 + .authenticationEntryPoint((request, response, authException) ->
  32 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))
29 33 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
30 34 return http.build();
31 35 }
... ...
backend/src/main/java/com/example/erp/config/UserPrincipal.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +public record UserPrincipal(String userId, String username, String userType, String brandId) {}
... ...
backend/src/main/java/com/example/erp/module/usr/controller/UserController.java 0 → 100644
  1 +package com.example.erp.module.usr.controller;
  2 +
  3 +import com.example.erp.common.response.Result;
  4 +import com.example.erp.config.UserPrincipal;
  5 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  6 +import com.example.erp.module.usr.service.UserService;
  7 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  8 +import com.example.erp.module.usr.vo.StaffVO;
  9 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  10 +import jakarta.validation.Valid;
  11 +import lombok.RequiredArgsConstructor;
  12 +import org.springframework.security.core.annotation.AuthenticationPrincipal;
  13 +import org.springframework.web.bind.annotation.*;
  14 +
  15 +import java.util.List;
  16 +
  17 +@RestController
  18 +@RequestMapping("/api/usr")
  19 +@RequiredArgsConstructor
  20 +public class UserController {
  21 +
  22 + private final UserService userService;
  23 +
  24 + @PostMapping("/users")
  25 + public Result<UserCreateRespVO> createUser(
  26 + @Valid @RequestBody UserCreateReqDTO req,
  27 + @AuthenticationPrincipal UserPrincipal principal) {
  28 + return Result.ok(userService.createUser(req, principal));
  29 + }
  30 +
  31 + @GetMapping("/users/staffs")
  32 + public Result<List<StaffVO>> getStaffs(@AuthenticationPrincipal UserPrincipal principal) {
  33 + return Result.ok(userService.getStaffs(principal.brandId()));
  34 + }
  35 +
  36 + @GetMapping("/users/permission-groups")
  37 + public Result<List<PermissionGroupVO>> getPermissionGroups(
  38 + @AuthenticationPrincipal UserPrincipal principal) {
  39 + return Result.ok(userService.getPermissionGroups(principal.brandId()));
  40 + }
  41 +}
... ...
backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import jakarta.validation.constraints.Pattern;
  5 +import lombok.Getter;
  6 +import lombok.Setter;
  7 +
  8 +import java.util.List;
  9 +
  10 +@Getter
  11 +@Setter
  12 +public class UserCreateReqDTO {
  13 +
  14 + @NotBlank(message = "用户号不能为空")
  15 + private String userCode;
  16 +
  17 + @NotBlank(message = "用户名不能为空")
  18 + private String username;
  19 +
  20 + @NotBlank(message = "用户类型不能为空")
  21 + @Pattern(regexp = "普通用户|超级管理员", message = "用户类型无效")
  22 + private String userType;
  23 +
  24 + @NotBlank(message = "语言不能为空")
  25 + @Pattern(regexp = "中文|英文|繁体", message = "语言无效")
  26 + private String language;
  27 +
  28 + private boolean canEditDoc = false;
  29 +
  30 + private String employeeId;
  31 +
  32 + private List<String> permGroupIds;
  33 +}
... ...
backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("usr_permission_group")
  15 +public class PermissionGroupEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sGroupCode")
  33 + private String sGroupCode;
  34 +
  35 + @TableField("sGroupName")
  36 + private String sGroupName;
  37 +
  38 + @TableField("sCategory")
  39 + private String sCategory;
  40 +}
... ...
backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("tStaff")
  15 +public class StaffEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sStaffNo")
  33 + private String sStaffNo;
  34 +
  35 + @TableField("sStaffName")
  36 + private String sStaffName;
  37 +
  38 + @TableField("sDepartment")
  39 + private String sDepartment;
  40 +
  41 + @TableField("sCreatedBy")
  42 + private String sCreatedBy;
  43 +
  44 + @TableField("bDeleted")
  45 + private Integer bDeleted;
  46 +
  47 + @TableField("tDeletedDate")
  48 + private LocalDateTime tDeletedDate;
  49 +
  50 + @TableField("sDeletedBy")
  51 + private String sDeletedBy;
  52 +}
... ...
backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java 0 → 100644
  1 +package com.example.erp.module.usr.entity;
  2 +
  3 +import com.baomidou.mybatisplus.annotation.IdType;
  4 +import com.baomidou.mybatisplus.annotation.TableField;
  5 +import com.baomidou.mybatisplus.annotation.TableId;
  6 +import com.baomidou.mybatisplus.annotation.TableName;
  7 +import lombok.Getter;
  8 +import lombok.Setter;
  9 +
  10 +import java.time.LocalDateTime;
  11 +
  12 +@Getter
  13 +@Setter
  14 +@TableName("usr_user_permission")
  15 +public class UserPermissionEntity {
  16 +
  17 + @TableId(value = "iIncrement", type = IdType.AUTO)
  18 + private Integer iIncrement;
  19 +
  20 + @TableField("sId")
  21 + private String sId;
  22 +
  23 + @TableField("sBrandsId")
  24 + private String sBrandsId;
  25 +
  26 + @TableField("sSubsidiaryId")
  27 + private String sSubsidiaryId;
  28 +
  29 + @TableField("tCreateDate")
  30 + private LocalDateTime tCreateDate;
  31 +
  32 + @TableField("sUserId")
  33 + private String sUserId;
  34 +
  35 + @TableField("sPermGroupId")
  36 + private String sPermGroupId;
  37 +}
... ...
backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.PermissionGroupEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface PermissionGroupMapper extends BaseMapper<PermissionGroupEntity> {}
... ...
backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.StaffEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface StaffMapper extends BaseMapper<StaffEntity> {}
... ...
backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java 0 → 100644
  1 +package com.example.erp.module.usr.mapper;
  2 +
  3 +import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  5 +import org.apache.ibatis.annotations.Mapper;
  6 +
  7 +@Mapper
  8 +public interface UserPermissionMapper extends BaseMapper<UserPermissionEntity> {}
... ...
backend/src/main/java/com/example/erp/module/usr/service/UserService.java 0 → 100644
  1 +package com.example.erp.module.usr.service;
  2 +
  3 +import com.example.erp.config.UserPrincipal;
  4 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  5 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  6 +import com.example.erp.module.usr.vo.StaffVO;
  7 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  8 +
  9 +import java.util.List;
  10 +
  11 +public interface UserService {
  12 +
  13 + UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal);
  14 +
  15 + List<StaffVO> getStaffs(String brandId);
  16 +
  17 + List<PermissionGroupVO> getPermissionGroups(String brandId);
  18 +}
... ...
backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java 0 → 100644
  1 +package com.example.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.example.erp.common.constants.UsrErrorCode;
  5 +import com.example.erp.common.exception.BizException;
  6 +import com.example.erp.config.UserPrincipal;
  7 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  8 +import com.example.erp.module.usr.entity.PermissionGroupEntity;
  9 +import com.example.erp.module.usr.entity.StaffEntity;
  10 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  11 +import com.example.erp.module.usr.entity.UsrUserEntity;
  12 +import com.example.erp.module.usr.mapper.PermissionGroupMapper;
  13 +import com.example.erp.module.usr.mapper.StaffMapper;
  14 +import com.example.erp.module.usr.mapper.UserPermissionMapper;
  15 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  16 +import com.example.erp.module.usr.service.UserService;
  17 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  18 +import com.example.erp.module.usr.vo.StaffVO;
  19 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  20 +import lombok.RequiredArgsConstructor;
  21 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  22 +import org.springframework.stereotype.Service;
  23 +import org.springframework.transaction.annotation.Transactional;
  24 +
  25 +import java.time.LocalDateTime;
  26 +import java.util.List;
  27 +import java.util.UUID;
  28 +import java.util.stream.Collectors;
  29 +
  30 +@Service
  31 +@RequiredArgsConstructor
  32 +public class UserServiceImpl implements UserService {
  33 +
  34 + private final UsrUserMapper userMapper;
  35 + private final StaffMapper staffMapper;
  36 + private final PermissionGroupMapper permGroupMapper;
  37 + private final UserPermissionMapper userPermissionMapper;
  38 + private final BCryptPasswordEncoder passwordEncoder;
  39 +
  40 + @Override
  41 + @Transactional
  42 + public UserCreateRespVO createUser(UserCreateReqDTO req, UserPrincipal principal) {
  43 + if (!"超级管理员".equals(principal.userType())) {
  44 + throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足");
  45 + }
  46 +
  47 + if (userMapper.selectCount(new LambdaQueryWrapper<UsrUserEntity>()
  48 + .eq(UsrUserEntity::getSUserCode, req.getUserCode())) > 0) {
  49 + throw new BizException(UsrErrorCode.USER_CODE_EXISTS, "用户号已存在");
  50 + }
  51 +
  52 + if (userMapper.selectCount(new LambdaQueryWrapper<UsrUserEntity>()
  53 + .eq(UsrUserEntity::getSUsername, req.getUsername())
  54 + .eq(UsrUserEntity::getSBrandsId, principal.brandId())) > 0) {
  55 + throw new BizException(UsrErrorCode.USERNAME_EXISTS, "用户名已存在");
  56 + }
  57 +
  58 + if (req.getEmployeeId() != null) {
  59 + StaffEntity staff = staffMapper.selectOne(new LambdaQueryWrapper<StaffEntity>()
  60 + .eq(StaffEntity::getSId, req.getEmployeeId())
  61 + .eq(StaffEntity::getSBrandsId, principal.brandId()));
  62 + if (staff == null) {
  63 + throw new BizException(40001, "员工不存在");
  64 + }
  65 + }
  66 +
  67 + UsrUserEntity user = new UsrUserEntity();
  68 + user.setSId(UUID.randomUUID().toString());
  69 + user.setSBrandsId(principal.brandId());
  70 + user.setSCreatorUsername(principal.username());
  71 + user.setTCreateDate(LocalDateTime.now());
  72 + user.setSUserCode(req.getUserCode());
  73 + user.setSUsername(req.getUsername());
  74 + user.setSPasswordHash(passwordEncoder.encode("666666"));
  75 + user.setSUserType(req.getUserType());
  76 + user.setSLanguage(req.getLanguage());
  77 + user.setBCanEditDoc(req.isCanEditDoc() ? 1 : 0);
  78 + user.setBIsDisabled(0);
  79 + user.setSEmployeeId(req.getEmployeeId());
  80 + user.setILoginFailCount(0);
  81 + userMapper.insert(user);
  82 +
  83 + if (req.getPermGroupIds() != null && !req.getPermGroupIds().isEmpty()) {
  84 + for (String groupId : req.getPermGroupIds()) {
  85 + UserPermissionEntity perm = new UserPermissionEntity();
  86 + perm.setSId(UUID.randomUUID().toString());
  87 + perm.setSBrandsId(principal.brandId());
  88 + perm.setTCreateDate(LocalDateTime.now());
  89 + perm.setSUserId(user.getSId());
  90 + perm.setSPermGroupId(groupId);
  91 + userPermissionMapper.insert(perm);
  92 + }
  93 + }
  94 +
  95 + UserCreateRespVO vo = new UserCreateRespVO();
  96 + vo.setUserId(user.getSId());
  97 + vo.setUserCode(user.getSUserCode());
  98 + vo.setUsername(user.getSUsername());
  99 + return vo;
  100 + }
  101 +
  102 + @Override
  103 + @Transactional(readOnly = true)
  104 + public List<StaffVO> getStaffs(String brandId) {
  105 + List<StaffEntity> staffs = staffMapper.selectList(new LambdaQueryWrapper<StaffEntity>()
  106 + .eq(StaffEntity::getSBrandsId, brandId)
  107 + .eq(StaffEntity::getBDeleted, 0));
  108 + return staffs.stream().map(s -> {
  109 + StaffVO vo = new StaffVO();
  110 + vo.setSId(s.getSId());
  111 + vo.setSStaffName(s.getSStaffName());
  112 + return vo;
  113 + }).collect(Collectors.toList());
  114 + }
  115 +
  116 + @Override
  117 + @Transactional(readOnly = true)
  118 + public List<PermissionGroupVO> getPermissionGroups(String brandId) {
  119 + List<PermissionGroupEntity> groups = permGroupMapper.selectList(null);
  120 + return groups.stream().map(g -> {
  121 + PermissionGroupVO vo = new PermissionGroupVO();
  122 + vo.setSId(g.getSId());
  123 + vo.setSGroupCode(g.getSGroupCode());
  124 + vo.setSGroupName(g.getSGroupName());
  125 + vo.setSCategory(g.getSCategory());
  126 + return vo;
  127 + }).collect(Collectors.toList());
  128 + }
  129 +}
... ...
backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class PermissionGroupVO {
  10 + @JsonProperty("sId")
  11 + private String sId;
  12 + @JsonProperty("sGroupCode")
  13 + private String sGroupCode;
  14 + @JsonProperty("sGroupName")
  15 + private String sGroupName;
  16 + @JsonProperty("sCategory")
  17 + private String sCategory;
  18 +}
... ...
backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class StaffVO {
  10 + @JsonProperty("sId")
  11 + private String sId;
  12 + @JsonProperty("sStaffName")
  13 + private String sStaffName;
  14 +}
... ...
backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class UserCreateRespVO {
  9 + private String userId;
  10 + private String userCode;
  11 + private String username;
  12 +}
... ...
backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java
... ... @@ -11,6 +11,10 @@ import com.example.erp.module.usr.service.impl.AuthServiceImpl;
11 11 import com.example.erp.module.usr.vo.BrandVO;
12 12 import com.example.erp.module.usr.vo.LoginVO;
13 13 import io.jsonwebtoken.Claims;
  14 +import com.baomidou.mybatisplus.core.MybatisConfiguration;
  15 +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
  16 +import org.apache.ibatis.builder.MapperBuilderAssistant;
  17 +import org.junit.jupiter.api.BeforeAll;
14 18 import org.junit.jupiter.api.BeforeEach;
15 19 import org.junit.jupiter.api.Test;
16 20 import org.junit.jupiter.api.extension.ExtendWith;
... ... @@ -30,6 +34,14 @@ import static org.mockito.Mockito.*;
30 34 @ExtendWith(MockitoExtension.class)
31 35 class AuthServiceTest {
32 36  
  37 + @BeforeAll
  38 + static void initMyBatisEntityCache() {
  39 + TableInfoHelper.initTableInfo(
  40 + new MapperBuilderAssistant(new MybatisConfiguration(), ""),
  41 + UsrUserEntity.class);
  42 + }
  43 +
  44 +
33 45 @Mock private BrandMapper brandMapper;
34 46 @Mock private UsrUserMapper userMapper;
35 47 @Mock private JwtUtil jwtUtil;
... ...
backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.config.BeanConfig;
  4 +import com.example.erp.config.JwtAuthenticationFilter;
  5 +import com.example.erp.config.JwtProperties;
  6 +import com.example.erp.config.SecurityConfig;
  7 +import com.example.erp.common.util.JwtUtil;
  8 +import com.example.erp.module.usr.controller.UserController;
  9 +import com.example.erp.module.usr.service.UserService;
  10 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  11 +import com.example.erp.module.usr.vo.StaffVO;
  12 +import com.example.erp.module.usr.vo.PermissionGroupVO;
  13 +import com.fasterxml.jackson.databind.ObjectMapper;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.springframework.beans.factory.annotation.Autowired;
  16 +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
  17 +import org.springframework.boot.test.mock.mockito.MockBean;
  18 +import org.springframework.context.annotation.Import;
  19 +import org.springframework.http.MediaType;
  20 +import org.springframework.test.context.ActiveProfiles;
  21 +import org.springframework.test.web.servlet.MockMvc;
  22 +import org.springframework.test.web.servlet.request.RequestPostProcessor;
  23 +
  24 +import java.util.List;
  25 +import java.util.Map;
  26 +
  27 +import static org.mockito.ArgumentMatchers.any;
  28 +import static org.mockito.ArgumentMatchers.anyString;
  29 +import static org.mockito.Mockito.when;
  30 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
  31 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  32 +
  33 +@ActiveProfiles("test")
  34 +@WebMvcTest(controllers = UserController.class)
  35 +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class})
  36 +class UserControllerTest {
  37 +
  38 + @Autowired private MockMvc mockMvc;
  39 + @Autowired private ObjectMapper objectMapper;
  40 + @Autowired private JwtUtil jwtUtil;
  41 + @MockBean private UserService userService;
  42 +
  43 + RequestPostProcessor superAdmin() {
  44 + String token = jwtUtil.generateAccessToken("u1", "admin", "超级管理员", "b1");
  45 + return request -> { request.addHeader("Authorization", "Bearer " + token); return request; };
  46 + }
  47 +
  48 + @Test
  49 + void createUser_noAuth_returns401() throws Exception {
  50 + mockMvc.perform(post("/api/usr/users")
  51 + .contentType(MediaType.APPLICATION_JSON)
  52 + .content("{}"))
  53 + .andExpect(status().isUnauthorized());
  54 + }
  55 +
  56 + @Test
  57 + void createUser_validRequest_returns200() throws Exception {
  58 + UserCreateRespVO resp = new UserCreateRespVO();
  59 + resp.setUserId("new-user-id");
  60 + resp.setUserCode("UC001");
  61 + resp.setUsername("testuser");
  62 + when(userService.createUser(any(), any())).thenReturn(resp);
  63 +
  64 + Map<String, Object> body = Map.of(
  65 + "userCode", "UC001",
  66 + "username", "testuser",
  67 + "userType", "普通用户",
  68 + "language", "中文");
  69 + mockMvc.perform(post("/api/usr/users")
  70 + .with(superAdmin())
  71 + .contentType(MediaType.APPLICATION_JSON)
  72 + .content(objectMapper.writeValueAsString(body)))
  73 + .andExpect(status().isOk())
  74 + .andExpect(jsonPath("$.code").value(200))
  75 + .andExpect(jsonPath("$.data.userId").value("new-user-id"));
  76 + }
  77 +
  78 + @Test
  79 + void createUser_missingUserCode_returns40001() throws Exception {
  80 + Map<String, Object> body = Map.of(
  81 + "username", "testuser",
  82 + "userType", "普通用户",
  83 + "language", "中文");
  84 + mockMvc.perform(post("/api/usr/users")
  85 + .with(superAdmin())
  86 + .contentType(MediaType.APPLICATION_JSON)
  87 + .content(objectMapper.writeValueAsString(body)))
  88 + .andExpect(status().isOk())
  89 + .andExpect(jsonPath("$.code").value(40001));
  90 + }
  91 +
  92 + @Test
  93 + void getStaffs_returns200() throws Exception {
  94 + StaffVO s = new StaffVO();
  95 + s.setSId("s1");
  96 + s.setSStaffName("张三");
  97 + when(userService.getStaffs(anyString())).thenReturn(List.of(s));
  98 +
  99 + mockMvc.perform(get("/api/usr/users/staffs").with(superAdmin()))
  100 + .andExpect(status().isOk())
  101 + .andExpect(jsonPath("$.code").value(200))
  102 + .andExpect(jsonPath("$.data[0].sId").value("s1"));
  103 + }
  104 +
  105 + @Test
  106 + void getPermissionGroups_returns200() throws Exception {
  107 + PermissionGroupVO g = new PermissionGroupVO();
  108 + g.setSId("g1");
  109 + g.setSGroupCode("usr:create");
  110 + g.setSGroupName("新增用户");
  111 + when(userService.getPermissionGroups(anyString())).thenReturn(List.of(g));
  112 +
  113 + mockMvc.perform(get("/api/usr/users/permission-groups").with(superAdmin()))
  114 + .andExpect(status().isOk())
  115 + .andExpect(jsonPath("$.code").value(200))
  116 + .andExpect(jsonPath("$.data[0].sGroupCode").value("usr:create"));
  117 + }
  118 +}
... ...
backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.config.UserPrincipal;
  5 +import com.example.erp.module.usr.dto.UserCreateReqDTO;
  6 +import com.example.erp.module.usr.entity.StaffEntity;
  7 +import com.example.erp.module.usr.entity.UsrUserEntity;
  8 +import com.example.erp.module.usr.entity.UserPermissionEntity;
  9 +import com.example.erp.module.usr.mapper.PermissionGroupMapper;
  10 +import com.example.erp.module.usr.mapper.StaffMapper;
  11 +import com.example.erp.module.usr.mapper.UserPermissionMapper;
  12 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  13 +import com.example.erp.module.usr.service.impl.UserServiceImpl;
  14 +import com.example.erp.module.usr.vo.UserCreateRespVO;
  15 +import org.junit.jupiter.api.BeforeEach;
  16 +import org.junit.jupiter.api.Test;
  17 +import org.junit.jupiter.api.extension.ExtendWith;
  18 +import org.mockito.InjectMocks;
  19 +import org.mockito.Mock;
  20 +import org.mockito.junit.jupiter.MockitoExtension;
  21 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  22 +
  23 +import java.util.List;
  24 +
  25 +import static org.junit.jupiter.api.Assertions.*;
  26 +import static org.mockito.ArgumentMatchers.*;
  27 +import static org.mockito.Mockito.*;
  28 +
  29 +@ExtendWith(MockitoExtension.class)
  30 +class UserServiceTest {
  31 +
  32 + @Mock private UsrUserMapper userMapper;
  33 + @Mock private StaffMapper staffMapper;
  34 + @Mock private PermissionGroupMapper permGroupMapper;
  35 + @Mock private UserPermissionMapper userPermissionMapper;
  36 + @Mock private BCryptPasswordEncoder passwordEncoder;
  37 +
  38 + @InjectMocks
  39 + private UserServiceImpl userService;
  40 +
  41 + private UserCreateReqDTO req;
  42 + private UserPrincipal superAdmin;
  43 + private UserPrincipal normalUser;
  44 +
  45 + @BeforeEach
  46 + void setUp() {
  47 + req = new UserCreateReqDTO();
  48 + req.setUserCode("UC001");
  49 + req.setUsername("testuser");
  50 + req.setUserType("普通用户");
  51 + req.setLanguage("中文");
  52 +
  53 + superAdmin = new UserPrincipal("u1", "admin", "超级管理员", "b1");
  54 + normalUser = new UserPrincipal("u2", "user", "普通用户", "b1");
  55 + }
  56 +
  57 + @Test
  58 + void createUser_normalUser_throws40300() {
  59 + BizException ex = assertThrows(BizException.class,
  60 + () -> userService.createUser(req, normalUser));
  61 + assertEquals(40300, ex.getCode());
  62 + }
  63 +
  64 + @Test
  65 + void createUser_success_insertsUserAndReturnsVO() {
  66 + when(userMapper.selectCount(any())).thenReturn(0L);
  67 + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed");
  68 + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1);
  69 +
  70 + UserCreateRespVO vo = userService.createUser(req, superAdmin);
  71 +
  72 + assertNotNull(vo.getUserId());
  73 + assertEquals("UC001", vo.getUserCode());
  74 + assertEquals("testuser", vo.getUsername());
  75 + verify(userMapper).insert(any(UsrUserEntity.class));
  76 + }
  77 +
  78 + @Test
  79 + void createUser_duplicateUserCode_throws40902() {
  80 + when(userMapper.selectCount(any())).thenReturn(1L);
  81 +
  82 + BizException ex = assertThrows(BizException.class,
  83 + () -> userService.createUser(req, superAdmin));
  84 + assertEquals(40902, ex.getCode());
  85 + }
  86 +
  87 + @Test
  88 + void createUser_duplicateUsername_throws40901() {
  89 + when(userMapper.selectCount(any())).thenReturn(0L, 1L);
  90 +
  91 + BizException ex = assertThrows(BizException.class,
  92 + () -> userService.createUser(req, superAdmin));
  93 + assertEquals(40901, ex.getCode());
  94 + }
  95 +
  96 + @Test
  97 + void createUser_withPermGroups_insertsPermissions() {
  98 + req.setPermGroupIds(List.of("g1", "g2"));
  99 + when(userMapper.selectCount(any())).thenReturn(0L);
  100 + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$hashed");
  101 + when(userMapper.insert(any(UsrUserEntity.class))).thenReturn(1);
  102 + when(userPermissionMapper.insert(any(UserPermissionEntity.class))).thenReturn(1);
  103 +
  104 + userService.createUser(req, superAdmin);
  105 +
  106 + verify(userPermissionMapper, times(2)).insert(any(UserPermissionEntity.class));
  107 + }
  108 +
  109 + @Test
  110 + void createUser_invalidEmployeeId_throws40001() {
  111 + req.setEmployeeId("bad-staff-id");
  112 + when(userMapper.selectCount(any())).thenReturn(0L);
  113 + when(staffMapper.selectOne(any())).thenReturn(null);
  114 +
  115 + BizException ex = assertThrows(BizException.class,
  116 + () -> userService.createUser(req, superAdmin));
  117 + assertEquals(40001, ex.getCode());
  118 + }
  119 +}
... ...
docs/superpowers/plans/2026-05-08-REQ-USR-001.md 0 → 100644
  1 +# REQ-USR-001 增加用户 Implementation Plan
  2 +
  3 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  4 +
  5 +**Goal:** 超级管理员通过 POST /api/usr/users 新建用户账号,系统自动初始化密码、写入权限关联;前端提供「用户管理」页+新增 Drawer 表单。
  6 +
  7 +**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 路由。
  8 +
  9 +**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
  10 +
  11 +---
  12 +
  13 +## 文件映射
  14 +
  15 +**新建**:
  16 +- `backend/.../config/UserPrincipal.java` — JWT principal record(userId, username, userType, brandId)
  17 +- `backend/.../module/usr/entity/StaffEntity.java` — 映射 tStaff
  18 +- `backend/.../module/usr/entity/PermissionGroupEntity.java` — 映射 usr_permission_group
  19 +- `backend/.../module/usr/entity/UserPermissionEntity.java` — 映射 usr_user_permission
  20 +- `backend/.../module/usr/mapper/StaffMapper.java`
  21 +- `backend/.../module/usr/mapper/PermissionGroupMapper.java`
  22 +- `backend/.../module/usr/mapper/UserPermissionMapper.java`
  23 +- `backend/.../common/constants/UsrErrorCode.java` — 40300/40901/40902
  24 +- `backend/.../module/usr/dto/UserCreateReqDTO.java`
  25 +- `backend/.../module/usr/vo/UserCreateRespVO.java`
  26 +- `backend/.../module/usr/vo/StaffVO.java`
  27 +- `backend/.../module/usr/vo/PermissionGroupVO.java`
  28 +- `backend/.../module/usr/service/UserService.java`
  29 +- `backend/.../module/usr/service/impl/UserServiceImpl.java`
  30 +- `backend/.../module/usr/controller/UserController.java`
  31 +- `backend/.../module/usr/UserServiceTest.java`
  32 +- `backend/.../module/usr/UserControllerTest.java`
  33 +- `frontend/src/api/usr.ts`
  34 +- `frontend/src/pages/usr/UserListPage.tsx`
  35 +- `frontend/src/pages/usr/UserFormDrawer.tsx`
  36 +- `frontend/src/test/UserListPage.test.tsx`
  37 +
  38 +**修改**:
  39 +- `backend/.../config/JwtAuthenticationFilter.java` — 存 UserPrincipal 而非 String(CLAUDE.md S2,必要基础设施)
  40 +- `frontend/src/App.tsx` — 补 /usr/users 路由
  41 +
  42 +---
  43 +
  44 +## 合同级常量
  45 +
  46 +**UsrErrorCode**:
  47 +- `PERMISSION_DENIED = 40300`
  48 +- `USERNAME_EXISTS = 40901`
  49 +- `USER_CODE_EXISTS = 40902`
  50 +
  51 +**UsrErrorCode 用法**:`throw new BizException(UsrErrorCode.PERMISSION_DENIED, "权限不足")`
  52 +
  53 +**UserPrincipal(Java record)**:
  54 +```java
  55 +package com.example.erp.config;
  56 +public record UserPrincipal(String userId, String username, String userType, String brandId) {}
  57 +```
  58 +
  59 +**JWT claim 名**(来自 JwtUtil.generateAccessToken):
  60 +- subject → userId
  61 +- `"username"` → username
  62 +- `"userType"` → userType
  63 +- `"brandId"` → brandId
  64 +
  65 +---
  66 +
  67 +### Task 1: UserPrincipal + JwtAuthenticationFilter 升级(跨模块基础设施 S2)
  68 +
  69 +**Files:**
  70 +- Create: `backend/src/main/java/com/example/erp/config/UserPrincipal.java`
  71 +- Modify: `backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  72 +
  73 +**API shape:**
  74 +- `record UserPrincipal(String userId, String username, String userType, String brandId) {}`
  75 +- `JwtAuthenticationFilter.doFilterInternal()` — 解析 claims 后构造 `UserPrincipal`,存入 `UsernamePasswordAuthenticationToken` 的 principal
  76 +
  77 +- [ ] **Step 1: 写失败测试(在 UserControllerTest 中)**
  78 + - 文件:`backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  79 + - 仅写类骨架 + 一个空测试方法 `createUser_noToken_returns401()`,注解 `@WebMvcTest(controllers = UserController.class)`
  80 + - 此时编译失败(UserController 不存在)→ 子会话确认 FAIL
  81 +
  82 +- [ ] **Step 2: 实现 UserPrincipal + 修改 JwtAuthenticationFilter**
  83 + - 创建 `UserPrincipal.java` record(见合同级常量)
  84 + - 修改 `JwtAuthenticationFilter.doFilterInternal()`:
  85 + ```java
  86 + UserPrincipal principal = new UserPrincipal(
  87 + claims.getSubject(),
  88 + claims.get("username", String.class),
  89 + claims.get("userType", String.class),
  90 + claims.get("brandId", String.class)
  91 + );
  92 + UsernamePasswordAuthenticationToken auth =
  93 + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList());
  94 + SecurityContextHolder.getContext().setAuthentication(auth);
  95 + ```
  96 +
  97 +- [ ] **Step 3: 子会话验证已有测试仍通过**
  98 + - 命令:`JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home mvn test -pl backend -Dtest=AuthControllerTest,AuthServiceTest`
  99 + - 期待:全部 PASS
  100 +
  101 +- [ ] **Step 4: Commit**
  102 + - `git add backend/src/main/java/com/example/erp/config/UserPrincipal.java backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java`
  103 + - `git commit -m "feat(usr): UserPrincipal record + JwtFilter升级存principal REQ-USR-001"`
  104 +
  105 +---
  106 +
  107 +### Task 2: 实体 + Mapper + 错误码 + DTO/VO 骨架
  108 +
  109 +**Files:**
  110 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/StaffEntity.java`
  111 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/PermissionGroupEntity.java`
  112 +- Create: `backend/src/main/java/com/example/erp/module/usr/entity/UserPermissionEntity.java`
  113 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/StaffMapper.java`
  114 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/PermissionGroupMapper.java`
  115 +- Create: `backend/src/main/java/com/example/erp/module/usr/mapper/UserPermissionMapper.java`
  116 +- Create: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java`
  117 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserCreateReqDTO.java`
  118 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserCreateRespVO.java`
  119 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/StaffVO.java`
  120 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/PermissionGroupVO.java`
  121 +
  122 +**StaffEntity 字段**(来自 docs/03 tStaff 表):`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sStaffNo`, `sStaffName`, `sDepartment`, `sCreatedBy`, `bDeleted(Bit/Integer)`, `tDeletedDate`, `sDeletedBy`;`@TableName("tStaff")`
  123 +
  124 +**PermissionGroupEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sGroupCode`, `sGroupName`, `sCategory`;`@TableName("usr_permission_group")`
  125 +
  126 +**UserPermissionEntity 字段**:`iIncrement(PK)`, `sId`, `sBrandsId`, `sSubsidiaryId`, `tCreateDate`, `sUserId`, `sPermGroupId`;`@TableName("usr_user_permission")`
  127 +
  128 +**UserCreateReqDTO 字段**(`@Valid` 注解):
  129 +```java
  130 +@NotBlank String userCode;
  131 +@NotBlank String username;
  132 +@NotBlank @Pattern(regexp = "普通用户|超级管理员") String userType;
  133 +@NotBlank @Pattern(regexp = "中文|英文|繁体") String language;
  134 +boolean canEditDoc = false;
  135 +String employeeId; // nullable
  136 +List<String> permGroupIds; // nullable
  137 +```
  138 +
  139 +**UserCreateRespVO**:`String userId, userCode, username`
  140 +**StaffVO**:`String sId, sStaffName`
  141 +**PermissionGroupVO**:`String sId, sGroupCode, sGroupName, sCategory`
  142 +
  143 +- [ ] **Step 1: 写失败测试**
  144 + - 在 `UserServiceTest.java` 中写类骨架 + 一个空测试 `createUser_normalUser_throws40300()`,引用 `UserService`(未创建)
  145 + - 子会话确认编译 FAIL
  146 +
  147 +- [ ] **Step 2: 创建所有文件**
  148 + - 按上方规格创建所有 entity / mapper / errorcode / dto / vo 文件
  149 + - 每个 mapper 继承 `BaseMapper<T>`
  150 + - 每个 entity 使用 Lombok `@Getter @Setter`
  151 +
  152 +- [ ] **Step 3: 子会话验证编译通过**
  153 + - 命令:`JAVA_HOME=... mvn compile -pl backend`
  154 +
  155 +- [ ] **Step 4: Commit**
  156 + - `git add backend/src/main/java/`
  157 + - `git commit -m "feat(usr): 实体/Mapper/DTO/VO骨架 + UsrErrorCode REQ-USR-001"`
  158 +
  159 +---
  160 +
  161 +### Task 3: UserServiceImpl — createUser 业务逻辑
  162 +
  163 +**Files:**
  164 +- Create: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  165 +- Create: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  166 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  167 +
  168 +**API shape:**
  169 +- `UserService#createUser(UserCreateReqDTO req, UserPrincipal principal) : UserCreateRespVO`
  170 +- `UserService#getStaffs(String brandId) : List<StaffVO>`
  171 +- `UserService#getPermissionGroups(String brandId) : List<PermissionGroupVO>`
  172 +
  173 +**createUser 逻辑序列**:
  174 +1. `!"超级管理员".equals(principal.userType())` → `throw new BizException(40300, "权限不足")`
  175 +2. `userMapper.selectCount(LQ...eq(sUserCode, req.getUserCode())) > 0` → `throw new BizException(40902, "用户号已存在")`
  176 +3. `userMapper.selectCount(LQ...eq(sUsername).eq(sBrandsId)) > 0` → `throw new BizException(40901, "用户名已存在")`
  177 +4. `req.getEmployeeId() != null` → `staffMapper.selectOne(LQ...eq(sId).eq(sBrandsId)) == null` → `throw new BizException(40001, "员工不存在")`
  178 +5. 构造 `UsrUserEntity`:sId=UUID,sBrandsId=principal.brandId(),sCreatorUsername=principal.username(),tCreateDate=now,sPasswordHash=passwordEncoder.encode("666666"),bIsDisabled=0,iLoginFailCount=0
  179 +6. `userMapper.insert(user)`
  180 +7. `permGroupIds` 非 null 且非空 → 循环 `userPermissionMapper.insert(new UserPermissionEntity(UUID, brandId, now, user.sId, groupId))`
  181 +8. 返回 `UserCreateRespVO(user.sId, user.sUserCode, user.sUsername)`
  182 +
  183 +**UserServiceImpl 依赖**:`@RequiredArgsConstructor`;注入 `UsrUserMapper`、`StaffMapper`、`PermissionGroupMapper`、`UserPermissionMapper`、`BCryptPasswordEncoder`
  184 +
  185 +- [ ] **Step 1: 写失败测试**
  186 + - `UserServiceTest.java` 完整写入下列测试(全部引用 `UserService` 接口),子会话确认失败(UserService 接口不存在):
  187 + - `createUser_normalUser_throws40300` — principal.userType=普通用户 → BizException(40300)
  188 + - `createUser_success_insertsUserAndReturnsVO` — all mocks pass → 验证 userMapper.insert 被调用,返回 VO 有 userId
  189 + - `createUser_duplicateUserCode_throws40902` — selectCount 第1次返回 1L → BizException(40902)
  190 + - `createUser_duplicateUsername_throws40901` — selectCount 第1次返回 0L,第2次返回 1L → BizException(40901)
  191 + - `createUser_withPermGroups_insertsPermissions` — permGroupIds=["g1","g2"] → verify userPermissionMapper.insert 被调用 2 次
  192 + - `createUser_invalidEmployeeId_throws40001` — staffMapper.selectOne 返回 null → BizException(40001)
  193 +
  194 +- [ ] **Step 2: 实现 UserService + UserServiceImpl**
  195 + - 创建 `UserService.java` 接口(三个方法签名)
  196 + - 创建 `UserServiceImpl.java`,`@Service @RequiredArgsConstructor`,按逻辑序列实现 `createUser()`
  197 + - `getStaffs(brandId)`: `staffMapper.selectList(LQ...eq(sBrandsId, brandId).eq(bDeleted, 0).select("sId","sStaffName"))` → 映射 `StaffVO`
  198 + - `getPermissionGroups(brandId)`: `permGroupMapper.selectList(LQ...eq(sBrandsId, brandId))` → 映射 `PermissionGroupVO`(如 brandId 空则 selectList(null))
  199 +
  200 +- [ ] **Step 3: 子会话验证 UserServiceTest 全部通过**
  201 + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserServiceTest`
  202 +
  203 +- [ ] **Step 4: Commit**
  204 + - `git add backend/src/`
  205 + - `git commit -m "feat(usr): UserServiceImpl.createUser + getStaffs + getPermissionGroups REQ-USR-001"`
  206 +
  207 +---
  208 +
  209 +### Task 4: UserController — 三个端点
  210 +
  211 +**Files:**
  212 +- Create: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  213 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  214 +
  215 +**API shape:**
  216 +```java
  217 +@RestController
  218 +@RequestMapping("/api/usr")
  219 +@RequiredArgsConstructor
  220 +public class UserController {
  221 + private final UserService userService;
  222 +
  223 + @PostMapping("/users")
  224 + public Result<UserCreateRespVO> createUser(
  225 + @Valid @RequestBody UserCreateReqDTO req,
  226 + @AuthenticationPrincipal UserPrincipal principal) { ... }
  227 +
  228 + @GetMapping("/users/staffs")
  229 + public Result<List<StaffVO>> getStaffs(@AuthenticationPrincipal UserPrincipal principal) { ... }
  230 +
  231 + @GetMapping("/users/permission-groups")
  232 + public Result<List<PermissionGroupVO>> getPermissionGroups(@AuthenticationPrincipal UserPrincipal principal) { ... }
  233 +}
  234 +```
  235 +
  236 +**测试助手** — `SecurityMockMvcRequestPostProcessors.authentication(...)` 注入 UserPrincipal:
  237 +```java
  238 +static RequestPostProcessor superAdmin() {
  239 + return authentication(new UsernamePasswordAuthenticationToken(
  240 + new UserPrincipal("u1","admin","超级管理员","b1"), null, emptyList()));
  241 +}
  242 +static RequestPostProcessor normalUser() {
  243 + return authentication(new UsernamePasswordAuthenticationToken(
  244 + new UserPrincipal("u2","user","普通用户","b1"), null, emptyList()));
  245 +}
  246 +```
  247 +
  248 +- [ ] **Step 1: 写失败测试**
  249 + - `UserControllerTest.java` 完整(`@WebMvcTest(UserController.class)` + `@Import(SecurityConfig, JwtAuthenticationFilter, BeanConfig, JwtUtil, JwtProperties)` + `@MockBean UserService`):
  250 + - `createUser_noAuth_returns401` — 无 auth header → Spring Security 返回 401
  251 + - `createUser_validRequest_returns200` — `superAdmin()` + mock service返回VO → 验证 code=200, data.userId 非空
  252 + - `createUser_missingUserCode_returns40001` — 请求体 userCode 为空 → 验证 code=40001
  253 + - `getStaffs_returns200` — `superAdmin()` + mock returns list → 验证 code=200
  254 + - `getPermissionGroups_returns200` — `superAdmin()` + mock returns list → 验证 code=200
  255 + - 子会话确认 FAIL(UserController 不存在)
  256 +
  257 +- [ ] **Step 2: 实现 UserController**
  258 + - 按 API shape 创建 `UserController.java`
  259 + - `createUser`: `Result.ok(userService.createUser(req, principal))`
  260 + - `getStaffs`: `Result.ok(userService.getStaffs(principal.brandId()))`
  261 + - `getPermissionGroups`: `Result.ok(userService.getPermissionGroups(principal.brandId()))`
  262 +
  263 +- [ ] **Step 3: 子会话验证 UserControllerTest 全部通过**
  264 + - 命令:`JAVA_HOME=... mvn test -pl backend -Dtest=UserControllerTest,UserServiceTest,AuthControllerTest,AuthServiceTest`
  265 +
  266 +- [ ] **Step 4: Commit**
  267 + - `git add backend/src/`
  268 + - `git commit -m "feat(usr): UserController POST/users GET/staffs GET/permission-groups REQ-USR-001"`
  269 +
  270 +---
  271 +
  272 +### Task 5: 前端 api/usr.ts + UserFormDrawer + UserListPage + App.tsx 路由
  273 +
  274 +**Files:**
  275 +- Create: `frontend/src/api/usr.ts`
  276 +- Create: `frontend/src/pages/usr/UserListPage.tsx`
  277 +- Create: `frontend/src/pages/usr/UserFormDrawer.tsx`
  278 +- Create: `frontend/src/test/UserListPage.test.tsx`
  279 +- Modify: `frontend/src/App.tsx`
  280 +
  281 +**api/usr.ts 接口**:
  282 +```ts
  283 +export interface StaffVO { sId: string; sStaffName: string }
  284 +export interface PermissionGroupVO { sId: string; sGroupCode: string; sGroupName: string; sCategory: string | null }
  285 +export interface UserCreateReq {
  286 + userCode: string; username: string; userType: '普通用户' | '超级管理员';
  287 + language: '中文' | '英文' | '繁体'; canEditDoc?: boolean;
  288 + employeeId?: string | null; permGroupIds?: string[]
  289 +}
  290 +export interface UserCreateResp { userId: string; userCode: string; username: string }
  291 +
  292 +export function getStaffs(): Promise<StaffVO[]>
  293 +export function getPermissionGroups(): Promise<PermissionGroupVO[]>
  294 +export function createUser(req: UserCreateReq): Promise<UserCreateResp>
  295 +```
  296 +(`request.get('/usr/users/staffs')` 等)
  297 +
  298 +**UserFormDrawer.tsx** props: `open: boolean`, `onClose: () => void`, `onSuccess: () => void`
  299 +- `useEffect` 拉取 staffs + permissionGroups
  300 +- Form 字段:userCode(Input), username(Input), userType(Select), language(Select), canEditDoc(Checkbox), employeeId(Select, options=staffs), permissions(Table with checkbox column)
  301 +- 提交:调 `createUser()`,成功 → `message.success('新增用户成功')` + `onSuccess()`;失败 → `message.error(e.message)`
  302 +
  303 +**UserListPage.tsx**:
  304 +- 顶部「新增」按钮(`<PermButton permission="usr:create">`,由 authSlice 中 userType 控制显示)
  305 +- 点击按钮 → `setDrawerOpen(true)`
  306 +- `<UserFormDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} onSuccess={() => setDrawerOpen(false)} />`
  307 +- 表格区域为 stub(empty Table,REQ-USR-003 补充)
  308 +
  309 +**PermButton**(如不存在则在本任务创建 `frontend/src/components/PermButton.tsx`):
  310 +```tsx
  311 +// REQ-USR-001: 权限按钮,根据 userType 控制显示
  312 +export function PermButton({ permission, children, ...props }: { permission: string } & ButtonProps) {
  313 + const userType = useAppSelector(s => s.auth.userInfo?.userType)
  314 + // usr:create / usr:edit 仅超级管理员可见
  315 + if (userType !== '超级管理员') return null
  316 + return <Button {...props}>{children}</Button>
  317 +}
  318 +```
  319 +
  320 +**App.tsx 新增路由**:
  321 +```tsx
  322 +<Route path="/usr/users" element={<PrivateRoute><UserListPage /></PrivateRoute>} />
  323 +```
  324 +
  325 +**测试 `UserListPage.test.tsx`**(三个测试):
  326 +1. `superAdmin_seesNewButton` — store userType=超级管理员 → 「新增」按钮可见
  327 +2. `normalUser_doesNotSeeNewButton` — store userType=普通用户 → 「新增」按钮不可见
  328 +3. `clickNewButton_opensDrawer` — 超级管理员点击新增 → UserFormDrawer 出现(mock API calls with vi.mock)
  329 +
  330 +- [ ] **Step 1: 写失败测试**
  331 + - 写 `UserListPage.test.tsx` 三个测试(引用 `UserListPage`、`PermButton`)
  332 + - 子会话确认 FAIL(文件不存在)
  333 +
  334 +- [ ] **Step 2: 实现**
  335 + - 按上方规格创建 `api/usr.ts`、`components/PermButton.tsx`、`UserFormDrawer.tsx`、`UserListPage.tsx`
  336 + - 修改 `App.tsx` 添加路由
  337 +
  338 +- [ ] **Step 3: 子会话验证前端测试通过**
  339 + - 命令:`cd frontend && npm run test -- --run`
  340 +
  341 +- [ ] **Step 4: Commit**
  342 + - `git add frontend/src/`
  343 + - `git commit -m "feat(usr): 前端 UserListPage + UserFormDrawer + usr.ts + PermButton REQ-USR-001"`
... ...
docs/superpowers/specs/2026-05-08-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-08
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 增加用户
  8 +
  9 +## 目标
  10 +
  11 +超级管理员在用户管理页新建用户账号,填写基本信息并分配权限组,账号保存后立即生效,可使用初始密码 666666 登录系统。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**触发**:超级管理员点击用户管理页「新增」按钮,弹出 Drawer,填写表单并提交。
  16 +
  17 +**POST /api/usr/users 请求体**:
  18 +```json
  19 +{
  20 + "userCode": "string(必填,用户号)",
  21 + "username": "string(必填,用户名,同一 brand 内唯一)",
  22 + "userType": "普通用户|超级管理员(必填)",
  23 + "language": "中文|英文|繁体(必填)",
  24 + "canEditDoc": false,
  25 + "employeeId": "string|null(可选,tStaff.sId)",
  26 + "permGroupIds": ["string"]
  27 +}
  28 +```
  29 +
  30 +**前端 Drawer 表单字段**:
  31 +| 字段 | 组件 | 必填 | 来源/选项 |
  32 +|---|---|---|---|
  33 +| 用户号 | Input | 是 | 手工输入 |
  34 +| 用户名 | Input | 是 | 手工输入 |
  35 +| 类型 | Select | 是 | 普通用户 / 超级管理员 |
  36 +| 语言 | Select | 是 | 中文 / 英文 / 繁体 |
  37 +| 单据修改权限 | Checkbox | 否 | 默认不勾 |
  38 +| 员工 | Select | 否 | GET /api/usr/staffs 返回列表,label=sStaffName, value=sId |
  39 +| 权限组 | Table+Checkbox | 否 | GET /api/usr/permission-groups 返回列表,列:权限分类/权限名称 |
  40 +
  41 +## 输出 / 结果
  42 +
  43 +**成功响应**:
  44 +```json
  45 +{
  46 + "code": 200,
  47 + "message": "操作成功",
  48 + "data": {
  49 + "userId": "string",
  50 + "userCode": "string",
  51 + "username": "string"
  52 + }
  53 +}
  54 +```
  55 +
  56 +前端收到成功响应后:`message.success("新增用户成功")` → 关闭 Drawer。
  57 +
  58 +## 业务规则
  59 +
  60 +1. **权限控制**:请求者 JWT 中 `userType == 超级管理员`,否则抛 `BizException(40300, "权限不足")`。
  61 +2. **唯一性检查(同 brand 内)**:
  62 + - `sUserCode` 全库唯一(索引 `uk_usr_user_usercode`);重复抛 `BizException(40902, "用户号已存在")`。
  63 + - `sUsername` 在同一 `sBrandsId` 内唯一(索引 `uk_usr_user_username_tenant`);重复抛 `BizException(40901, "用户名已存在")`。
  64 +3. **密码初始化**:`sPasswordHash = BCryptPasswordEncoder.encode("666666")`,禁止存明文。
  65 +4. **系统字段自动填充**(Controller 或 Service 层注入 `UserPrincipal`):
  66 + - `sId` = `UUID.randomUUID().toString()`
  67 + - `sBrandsId` = JWT claim `brandId`
  68 + - `sCreatorUsername` = JWT claim `username`
  69 + - `tCreateDate` = `LocalDateTime.now()`
  70 + - `bIsDisabled` = 0
  71 + - `iLoginFailCount` = 0
  72 +5. **employeeId 校验**:若不为 null,需验证 `tStaff.sId == employeeId && tStaff.sBrandsId == brandId`;不存在抛 `BizException(40001, "员工不存在")`。
  73 +6. **权限组写入**:`permGroupIds` 非空时,批量插入 `usr_user_permission`(`sUserId=新用户 sId`, `sPermGroupId=item`);`permGroupIds` 为空/null 时跳过。
  74 +7. **事务**:`createUser()` 方法标注 `@Transactional`,usr_user 插入 + usr_user_permission 批量插入在同一事务中。
  75 +
  76 +## 边界与约束
  77 +
  78 +- 初始密码仅后端生成,不通过请求体传入,前端不展示。
  79 +- `sUsername` 一旦创建不可修改(REQ-USR-002 约束)。
  80 +- `userType` 枚举只有 `普通用户` 和 `超级管理员` 两种合法值;`language` 枚举只有 `中文`、`英文`、`繁体`;后端用 `@Pattern`/`@NotNull` + `@Valid` 校验,违规触发 `40001`。
  81 +- 跨模块改动:需修改 `JwtAuthenticationFilter.java`(config 层),将 JWT claims 包装为 `UserPrincipal` 存入 SecurityContext,使 `UserController` 可通过 `@AuthenticationPrincipal UserPrincipal` 取到 `brandId` 和 `username`。此修改属必要基础设施扩展(CLAUDE.md S2)。
  82 +
  83 +## 依赖的 schema 表 / 字段
  84 +
  85 +**写入表**:
  86 +- `usr_user`:`sId`、`sBrandsId`、`sCreatorUsername`、`tCreateDate`、`sUserCode`、`sUsername`、`sPasswordHash`、`sUserType`、`sLanguage`、`bCanEditDoc`、`bIsDisabled`、`sEmployeeId`、`iLoginFailCount`
  87 +- `usr_user_permission`:`sId`(UUID)、`sBrandsId`、`tCreateDate`、`sUserId`、`sPermGroupId`
  88 +
  89 +**只读表**:
  90 +- `tStaff`:`sId`、`sStaffName`(员工下拉列表及 employeeId 校验)
  91 +- `usr_permission_group`:`sId`、`sGroupCode`、`sGroupName`、`sCategory`(权限组复选列表)
  92 +
  93 +## 依赖的接口
  94 +
  95 +- `POST /api/auth/login`(REQ-USR-004)— 前端登录获取 JWT,携带 Bearer Token 才可调用本接口
  96 +- `GET /api/usr/staffs`(本 REQ 新增,不在 docs/05 原始清单)— 员工下拉数据源,鉴权接口
  97 + - 响应:`{ "data": [{ "sId": "...", "sStaffName": "..." }] }`
  98 +- `GET /api/usr/permission-groups`(本 REQ 新增,不在 docs/05 原始清单)— 权限组复选数据源,鉴权接口
  99 + - 响应:`{ "data": [{ "sId": "...", "sGroupCode": "...", "sGroupName": "...", "sCategory": "..." }] }`
  100 +- `POST /api/usr/users`(docs/05 已定义)— 本 REQ 核心创建接口
  101 +
  102 +## 验收标准
  103 +
  104 +1. 超级管理员提交合法数据 → HTTP 200,`data.userId` 非空,数据库 `usr_user` 表新增一条记录,密码字段为 BCrypt 哈希(不含 "666666" 明文)。
  105 +2. 新建用户可立即用 POST /api/auth/login(brandNo + username + "666666")登录成功。
  106 +3. 重复用户名(同 brand)→ 返回 `code=40901`,用户名已存在。
  107 +4. 重复用户号 → 返回 `code=40902`,用户号已存在。
  108 +5. 普通用户调用本接口 → 返回 `code=40300`,权限不足。
  109 +6. 缺必填字段(userCode / username / userType / language 为空)→ 返回 `code=40001`。
  110 +7. 选中权限组后提交 → `usr_user_permission` 表有对应关联记录。
  111 +8. 关联无效 employeeId → 返回 `code=40001`,员工不存在。
  112 +9. 前端:新增 Drawer 表单加载时,员工下拉和权限组 Table 已从对应 API 拉取数据;提交成功后 Drawer 关闭并显示 `message.success`。
  113 +10. 前端:普通用户登录后,用户管理页「新增」按钮不显示(`PermButton permission="usr:create"` 隐藏逻辑)。
... ...