Commit ff5af4717f312bc7afccf8f877c6e871f3ec78da
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
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
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
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"` 隐藏逻辑)。 | ... | ... |