Commit 7f728d422b4ec133150de72e84bd4f4e151afef4
Merge branch 'module-module_usr'
Showing
48 changed files
with
4314 additions
and
36 deletions
backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.config; | |
| 2 | + | |
| 3 | +import org.springframework.context.annotation.Bean; | |
| 4 | +import org.springframework.context.annotation.Configuration; | |
| 5 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 6 | + | |
| 7 | +@Configuration | |
| 8 | +public class PasswordEncoderConfig { | |
| 9 | + | |
| 10 | + @Bean | |
| 11 | + public BCryptPasswordEncoder bCryptPasswordEncoder() { | |
| 12 | + return new BCryptPasswordEncoder(); | |
| 13 | + } | |
| 14 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 4 | +import com.xly.erp.common.response.Result; | |
| 5 | +import jakarta.servlet.http.HttpServletRequest; | |
| 6 | +import jakarta.servlet.http.HttpServletResponse; | |
| 7 | +import org.springframework.security.core.AuthenticationException; | |
| 8 | +import org.springframework.security.web.AuthenticationEntryPoint; | |
| 9 | +import org.springframework.stereotype.Component; | |
| 10 | + | |
| 11 | +import java.io.IOException; | |
| 12 | +import java.nio.charset.StandardCharsets; | |
| 13 | + | |
| 14 | +@Component | |
| 15 | +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { | |
| 16 | + | |
| 17 | + private final ObjectMapper objectMapper; | |
| 18 | + | |
| 19 | + public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { | |
| 20 | + this.objectMapper = objectMapper; | |
| 21 | + } | |
| 22 | + | |
| 23 | + @Override | |
| 24 | + public void commence(HttpServletRequest request, HttpServletResponse response, | |
| 25 | + AuthenticationException authException) throws IOException { | |
| 26 | + response.setStatus(HttpServletResponse.SC_OK); | |
| 27 | + response.setContentType("application/json;charset=UTF-8"); | |
| 28 | + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); | |
| 29 | + response.getWriter().write(objectMapper.writeValueAsString(Result.fail(20001, "未认证"))); | |
| 30 | + } | |
| 31 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
| ... | ... | @@ -16,7 +16,8 @@ import java.util.Date; |
| 16 | 16 | @Component |
| 17 | 17 | public class JwtUtil { |
| 18 | 18 | |
| 19 | - private static final Duration TTL = Duration.ofHours(8); | |
| 19 | + public static final Duration ACCESS_TTL = Duration.ofHours(8); | |
| 20 | + public static final Duration REFRESH_TTL = Duration.ofDays(30); | |
| 20 | 21 | |
| 21 | 22 | private final SecretKey key; |
| 22 | 23 | |
| ... | ... | @@ -30,11 +31,19 @@ public class JwtUtil { |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | public String sign(String userNo) { |
| 34 | + return sign(userNo, ACCESS_TTL); | |
| 35 | + } | |
| 36 | + | |
| 37 | + public String signRefresh(String userNo) { | |
| 38 | + return sign(userNo, REFRESH_TTL); | |
| 39 | + } | |
| 40 | + | |
| 41 | + public String sign(String userNo, Duration ttl) { | |
| 33 | 42 | Date now = new Date(); |
| 34 | 43 | return Jwts.builder() |
| 35 | 44 | .subject(userNo) |
| 36 | 45 | .issuedAt(now) |
| 37 | - .expiration(new Date(now.getTime() + TTL.toMillis())) | |
| 46 | + .expiration(new Date(now.getTime() + ttl.toMillis())) | |
| 38 | 47 | .signWith(key) |
| 39 | 48 | .compact(); |
| 40 | 49 | } | ... | ... |
backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java
| ... | ... | @@ -3,6 +3,7 @@ package com.xly.erp.common.security; |
| 3 | 3 | import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; |
| 4 | 4 | import org.springframework.context.annotation.Bean; |
| 5 | 5 | import org.springframework.context.annotation.Configuration; |
| 6 | +import org.springframework.http.HttpMethod; | |
| 6 | 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
| 7 | 8 | import org.springframework.security.config.http.SessionCreationPolicy; |
| 8 | 9 | import org.springframework.security.web.SecurityFilterChain; |
| ... | ... | @@ -13,9 +14,11 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic |
| 13 | 14 | public class SecurityConfig { |
| 14 | 15 | |
| 15 | 16 | private final JwtAuthenticationFilter jwtFilter; |
| 17 | + private final JwtAuthenticationEntryPoint authEntryPoint; | |
| 16 | 18 | |
| 17 | - public SecurityConfig(JwtAuthenticationFilter jwtFilter) { | |
| 19 | + public SecurityConfig(JwtAuthenticationFilter jwtFilter, JwtAuthenticationEntryPoint authEntryPoint) { | |
| 18 | 20 | this.jwtFilter = jwtFilter; |
| 21 | + this.authEntryPoint = authEntryPoint; | |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | @Bean |
| ... | ... | @@ -23,10 +26,10 @@ public class SecurityConfig { |
| 23 | 26 | http.csrf(csrf -> csrf.disable()) |
| 24 | 27 | .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) |
| 25 | 28 | .authorizeHttpRequests(auth -> auth |
| 26 | - // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN') | |
| 27 | - .requestMatchers("/api/mod/**").permitAll() | |
| 29 | + .requestMatchers(HttpMethod.POST, "/api/usr/auth/login").permitAll() | |
| 28 | 30 | .anyRequest().authenticated() |
| 29 | 31 | ) |
| 32 | + .exceptionHandling(eh -> eh.authenticationEntryPoint(authEntryPoint)) | |
| 30 | 33 | .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); |
| 31 | 34 | return http.build(); |
| 32 | 35 | } | ... | ... |
backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
| ... | ... | @@ -61,8 +61,7 @@ public class ModuleServiceImpl implements ModuleService { |
| 61 | 61 | m.setSModuleNameZh(dto.getSModuleNameZh()); |
| 62 | 62 | m.setIParentId(dto.getIParentId()); |
| 63 | 63 | m.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0); |
| 64 | - String authedUserNo = SecurityContextHelper.currentUserNo(); | |
| 65 | - m.setSCreatedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); | |
| 64 | + m.setSCreatedBy(SecurityContextHelper.currentUserNo()); | |
| 66 | 65 | m.setBDeleted(false); |
| 67 | 66 | |
| 68 | 67 | try { |
| ... | ... | @@ -112,8 +111,7 @@ public class ModuleServiceImpl implements ModuleService { |
| 112 | 111 | entity.setIIncrement(id); |
| 113 | 112 | entity.setBDeleted(true); |
| 114 | 113 | entity.setTDeletedDate(LocalDateTime.now()); |
| 115 | - String authedUserNo = SecurityContextHelper.currentUserNo(); | |
| 116 | - entity.setSDeletedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); | |
| 114 | + entity.setSDeletedBy(SecurityContextHelper.currentUserNo()); | |
| 117 | 115 | moduleMapper.updateById(entity); |
| 118 | 116 | } |
| 119 | 117 | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.module.usr.dto.LoginDTO; | |
| 5 | +import com.xly.erp.module.usr.service.UserService; | |
| 6 | +import com.xly.erp.module.usr.vo.LoginVO; | |
| 7 | +import jakarta.validation.Valid; | |
| 8 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 9 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 10 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 11 | +import org.springframework.web.bind.annotation.RestController; | |
| 12 | + | |
| 13 | +@RestController | |
| 14 | +@RequestMapping("/api/usr/auth") | |
| 15 | +public class AuthController { | |
| 16 | + | |
| 17 | + private final UserService userService; | |
| 18 | + | |
| 19 | + public AuthController(UserService userService) { | |
| 20 | + this.userService = userService; | |
| 21 | + } | |
| 22 | + | |
| 23 | + @PostMapping("/login") | |
| 24 | + public Result<LoginVO> login(@Valid @RequestBody LoginDTO dto) { | |
| 25 | + return Result.ok(userService.login(dto)); | |
| 26 | + } | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.Result; | |
| 4 | +import com.xly.erp.module.usr.dto.CreateUserDTO; | |
| 5 | +import com.xly.erp.module.usr.dto.UpdateUserDTO; | |
| 6 | +import com.xly.erp.module.usr.service.UserService; | |
| 7 | +import jakarta.validation.Valid; | |
| 8 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 9 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 10 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 11 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 12 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 13 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 14 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 15 | +import org.springframework.web.bind.annotation.RestController; | |
| 16 | + | |
| 17 | +import java.util.Map; | |
| 18 | + | |
| 19 | +@RestController | |
| 20 | +@RequestMapping("/api/usr") | |
| 21 | +public class UserController { | |
| 22 | + | |
| 23 | + private final UserService userService; | |
| 24 | + | |
| 25 | + public UserController(UserService userService) { | |
| 26 | + this.userService = userService; | |
| 27 | + } | |
| 28 | + | |
| 29 | + @PostMapping("/users") | |
| 30 | + public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDTO dto) { | |
| 31 | + return Result.ok(userService.create(dto)); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @PutMapping("/users/{id}") | |
| 35 | + public Result<Map<String, Object>> update(@PathVariable Integer id, | |
| 36 | + @Valid @RequestBody UpdateUserDTO dto) { | |
| 37 | + Integer updated = userService.update(id, dto); | |
| 38 | + return Result.ok(Map.of("iIncrement", updated)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @GetMapping("/users") | |
| 42 | + public Result<Map<String, Object>> list(@RequestParam(required = false) String field, | |
| 43 | + @RequestParam(required = false) String match, | |
| 44 | + @RequestParam(required = false) String value, | |
| 45 | + @RequestParam(required = false) Integer pageNum, | |
| 46 | + @RequestParam(required = false) Integer pageSize) { | |
| 47 | + return Result.ok(userService.list(field, match, value, pageNum, pageSize)); | |
| 48 | + } | |
| 49 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +public class CreateUserDTO { | |
| 10 | + | |
| 11 | + @JsonProperty("sUserNo") | |
| 12 | + @NotBlank | |
| 13 | + @Size(max = 50) | |
| 14 | + private String sUserNo; | |
| 15 | + | |
| 16 | + @JsonProperty("sUserName") | |
| 17 | + @NotBlank | |
| 18 | + @Size(max = 50) | |
| 19 | + private String sUserName; | |
| 20 | + | |
| 21 | + @JsonProperty("iStaffId") | |
| 22 | + private Integer iStaffId; | |
| 23 | + | |
| 24 | + @JsonProperty("sUserType") | |
| 25 | + @NotBlank | |
| 26 | + private String sUserType; | |
| 27 | + | |
| 28 | + @JsonProperty("sLanguage") | |
| 29 | + @NotBlank | |
| 30 | + private String sLanguage; | |
| 31 | + | |
| 32 | + @JsonProperty("bCanModifyDocs") | |
| 33 | + private Boolean bCanModifyDocs; | |
| 34 | + | |
| 35 | + @JsonProperty("permissionCategoryIds") | |
| 36 | + private List<Integer> permissionCategoryIds; | |
| 37 | + | |
| 38 | + public String getSUserNo() { return sUserNo; } | |
| 39 | + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } | |
| 40 | + public String getSUserName() { return sUserName; } | |
| 41 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 42 | + public Integer getIStaffId() { return iStaffId; } | |
| 43 | + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } | |
| 44 | + public String getSUserType() { return sUserType; } | |
| 45 | + public void setSUserType(String sUserType) { this.sUserType = sUserType; } | |
| 46 | + public String getSLanguage() { return sLanguage; } | |
| 47 | + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } | |
| 48 | + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } | |
| 49 | + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } | |
| 50 | + public List<Integer> getPermissionCategoryIds() { return permissionCategoryIds; } | |
| 51 | + public void setPermissionCategoryIds(List<Integer> permissionCategoryIds) { this.permissionCategoryIds = permissionCategoryIds; } | |
| 52 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | + | |
| 6 | +public class LoginDTO { | |
| 7 | + | |
| 8 | + @JsonProperty("sUserName") | |
| 9 | + @NotBlank | |
| 10 | + private String sUserName; | |
| 11 | + | |
| 12 | + @JsonProperty("password") | |
| 13 | + @NotBlank | |
| 14 | + private String password; | |
| 15 | + | |
| 16 | + @JsonProperty("version") | |
| 17 | + @NotBlank | |
| 18 | + private String version; | |
| 19 | + | |
| 20 | + public String getSUserName() { return sUserName; } | |
| 21 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 22 | + public String getPassword() { return password; } | |
| 23 | + public void setPassword(String password) { this.password = password; } | |
| 24 | + public String getVersion() { return version; } | |
| 25 | + public void setVersion(String version) { this.version = version; } | |
| 26 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.dto; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +public class UpdateUserDTO { | |
| 10 | + | |
| 11 | + @JsonProperty("sUserNo") | |
| 12 | + @NotBlank | |
| 13 | + @Size(max = 50) | |
| 14 | + private String sUserNo; | |
| 15 | + | |
| 16 | + @JsonProperty("sUserName") | |
| 17 | + @NotBlank | |
| 18 | + @Size(max = 50) | |
| 19 | + private String sUserName; | |
| 20 | + | |
| 21 | + @JsonProperty("iStaffId") | |
| 22 | + private Integer iStaffId; | |
| 23 | + | |
| 24 | + @JsonProperty("sUserType") | |
| 25 | + @NotBlank | |
| 26 | + private String sUserType; | |
| 27 | + | |
| 28 | + @JsonProperty("sLanguage") | |
| 29 | + @NotBlank | |
| 30 | + private String sLanguage; | |
| 31 | + | |
| 32 | + @JsonProperty("bCanModifyDocs") | |
| 33 | + private Boolean bCanModifyDocs; | |
| 34 | + | |
| 35 | + @JsonProperty("permissionCategoryIds") | |
| 36 | + private List<Integer> permissionCategoryIds; | |
| 37 | + | |
| 38 | + public String getSUserNo() { return sUserNo; } | |
| 39 | + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } | |
| 40 | + public String getSUserName() { return sUserName; } | |
| 41 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 42 | + public Integer getIStaffId() { return iStaffId; } | |
| 43 | + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } | |
| 44 | + public String getSUserType() { return sUserType; } | |
| 45 | + public void setSUserType(String sUserType) { this.sUserType = sUserType; } | |
| 46 | + public String getSLanguage() { return sLanguage; } | |
| 47 | + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } | |
| 48 | + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } | |
| 49 | + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } | |
| 50 | + public List<Integer> getPermissionCategoryIds() { return permissionCategoryIds; } | |
| 51 | + public void setPermissionCategoryIds(List<Integer> permissionCategoryIds) { this.permissionCategoryIds = permissionCategoryIds; } | |
| 52 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/User.java
0 → 100644
| 1 | +package com.xly.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 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@TableName("tUser") | |
| 11 | +public class User { | |
| 12 | + | |
| 13 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 14 | + private Integer iIncrement; | |
| 15 | + | |
| 16 | + @TableField("sId") | |
| 17 | + private String sId; | |
| 18 | + | |
| 19 | + @TableField("sBrandsId") | |
| 20 | + private String sBrandsId; | |
| 21 | + | |
| 22 | + @TableField("sSubsidiaryId") | |
| 23 | + private String sSubsidiaryId; | |
| 24 | + | |
| 25 | + @TableField("tCreateDate") | |
| 26 | + private LocalDateTime tCreateDate; | |
| 27 | + | |
| 28 | + @TableField("sUserNo") | |
| 29 | + private String sUserNo; | |
| 30 | + | |
| 31 | + @TableField("sUserName") | |
| 32 | + private String sUserName; | |
| 33 | + | |
| 34 | + @TableField("iStaffId") | |
| 35 | + private Integer iStaffId; | |
| 36 | + | |
| 37 | + @TableField("sUserType") | |
| 38 | + private String sUserType; | |
| 39 | + | |
| 40 | + @TableField("sLanguage") | |
| 41 | + private String sLanguage; | |
| 42 | + | |
| 43 | + @TableField("bCanModifyDocs") | |
| 44 | + private Boolean bCanModifyDocs; | |
| 45 | + | |
| 46 | + @TableField("sPasswordHash") | |
| 47 | + private String sPasswordHash; | |
| 48 | + | |
| 49 | + @TableField("tLastLoginDate") | |
| 50 | + private LocalDateTime tLastLoginDate; | |
| 51 | + | |
| 52 | + @TableField("sCreatedBy") | |
| 53 | + private String sCreatedBy; | |
| 54 | + | |
| 55 | + @TableField("bDeleted") | |
| 56 | + private Boolean bDeleted; | |
| 57 | + | |
| 58 | + @TableField("tDeletedDate") | |
| 59 | + private LocalDateTime tDeletedDate; | |
| 60 | + | |
| 61 | + @TableField("sDeletedBy") | |
| 62 | + private String sDeletedBy; | |
| 63 | + | |
| 64 | + public Integer getIIncrement() { return iIncrement; } | |
| 65 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | |
| 66 | + public String getSId() { return sId; } | |
| 67 | + public void setSId(String sId) { this.sId = sId; } | |
| 68 | + public String getSBrandsId() { return sBrandsId; } | |
| 69 | + public void setSBrandsId(String sBrandsId) { this.sBrandsId = sBrandsId; } | |
| 70 | + public String getSSubsidiaryId() { return sSubsidiaryId; } | |
| 71 | + public void setSSubsidiaryId(String sSubsidiaryId) { this.sSubsidiaryId = sSubsidiaryId; } | |
| 72 | + public LocalDateTime getTCreateDate() { return tCreateDate; } | |
| 73 | + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } | |
| 74 | + public String getSUserNo() { return sUserNo; } | |
| 75 | + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } | |
| 76 | + public String getSUserName() { return sUserName; } | |
| 77 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 78 | + public Integer getIStaffId() { return iStaffId; } | |
| 79 | + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } | |
| 80 | + public String getSUserType() { return sUserType; } | |
| 81 | + public void setSUserType(String sUserType) { this.sUserType = sUserType; } | |
| 82 | + public String getSLanguage() { return sLanguage; } | |
| 83 | + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } | |
| 84 | + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } | |
| 85 | + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } | |
| 86 | + public String getSPasswordHash() { return sPasswordHash; } | |
| 87 | + public void setSPasswordHash(String sPasswordHash) { this.sPasswordHash = sPasswordHash; } | |
| 88 | + public LocalDateTime getTLastLoginDate() { return tLastLoginDate; } | |
| 89 | + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { this.tLastLoginDate = tLastLoginDate; } | |
| 90 | + public String getSCreatedBy() { return sCreatedBy; } | |
| 91 | + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } | |
| 92 | + public Boolean getBDeleted() { return bDeleted; } | |
| 93 | + public void setBDeleted(Boolean bDeleted) { this.bDeleted = bDeleted; } | |
| 94 | + public LocalDateTime getTDeletedDate() { return tDeletedDate; } | |
| 95 | + public void setTDeletedDate(LocalDateTime tDeletedDate) { this.tDeletedDate = tDeletedDate; } | |
| 96 | + public String getSDeletedBy() { return sDeletedBy; } | |
| 97 | + public void setSDeletedBy(String sDeletedBy) { this.sDeletedBy = sDeletedBy; } | |
| 98 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java
0 → 100644
| 1 | +package com.xly.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 | + | |
| 8 | +import java.time.LocalDateTime; | |
| 9 | + | |
| 10 | +@TableName("tUserPermission") | |
| 11 | +public class UserPermission { | |
| 12 | + | |
| 13 | + @TableId(value = "iIncrement", type = IdType.AUTO) | |
| 14 | + private Integer iIncrement; | |
| 15 | + | |
| 16 | + @TableField("sId") | |
| 17 | + private String sId; | |
| 18 | + | |
| 19 | + @TableField("sBrandsId") | |
| 20 | + private String sBrandsId; | |
| 21 | + | |
| 22 | + @TableField("sSubsidiaryId") | |
| 23 | + private String sSubsidiaryId; | |
| 24 | + | |
| 25 | + @TableField("tCreateDate") | |
| 26 | + private LocalDateTime tCreateDate; | |
| 27 | + | |
| 28 | + @TableField("iUserId") | |
| 29 | + private Integer iUserId; | |
| 30 | + | |
| 31 | + @TableField("iCategoryId") | |
| 32 | + private Integer iCategoryId; | |
| 33 | + | |
| 34 | + @TableField("sCreatedBy") | |
| 35 | + private String sCreatedBy; | |
| 36 | + | |
| 37 | + public Integer getIIncrement() { return iIncrement; } | |
| 38 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | |
| 39 | + public String getSId() { return sId; } | |
| 40 | + public void setSId(String sId) { this.sId = sId; } | |
| 41 | + public String getSBrandsId() { return sBrandsId; } | |
| 42 | + public void setSBrandsId(String sBrandsId) { this.sBrandsId = sBrandsId; } | |
| 43 | + public String getSSubsidiaryId() { return sSubsidiaryId; } | |
| 44 | + public void setSSubsidiaryId(String sSubsidiaryId) { this.sSubsidiaryId = sSubsidiaryId; } | |
| 45 | + public LocalDateTime getTCreateDate() { return tCreateDate; } | |
| 46 | + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } | |
| 47 | + public Integer getIUserId() { return iUserId; } | |
| 48 | + public void setIUserId(Integer iUserId) { this.iUserId = iUserId; } | |
| 49 | + public Integer getICategoryId() { return iCategoryId; } | |
| 50 | + public void setICategoryId(Integer iCategoryId) { this.iCategoryId = iCategoryId; } | |
| 51 | + public String getSCreatedBy() { return sCreatedBy; } | |
| 52 | + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } | |
| 53 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | +import org.apache.ibatis.annotations.Select; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +@Mapper | |
| 10 | +public interface PermissionCategoryMapper { | |
| 11 | + | |
| 12 | + @Select("<script>" | |
| 13 | + + "SELECT COUNT(1) FROM tPermissionCategory " | |
| 14 | + + "WHERE bDeleted = 0 AND iIncrement IN " | |
| 15 | + + "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>" | |
| 16 | + + "</script>") | |
| 17 | + int countActiveByIds(@Param("ids") List<Integer> ids); | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import org.apache.ibatis.annotations.Mapper; | |
| 4 | +import org.apache.ibatis.annotations.Param; | |
| 5 | +import org.apache.ibatis.annotations.Select; | |
| 6 | + | |
| 7 | +@Mapper | |
| 8 | +public interface StaffMapper { | |
| 9 | + | |
| 10 | + @Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") | |
| 11 | + Integer findActiveStaffFlag(@Param("id") Integer id); | |
| 12 | + | |
| 13 | + default boolean existsActiveById(Integer id) { | |
| 14 | + return findActiveStaffFlag(id) != null; | |
| 15 | + } | |
| 16 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.User; | |
| 5 | +import com.xly.erp.module.usr.vo.UserListVO; | |
| 6 | +import org.apache.ibatis.annotations.Param; | |
| 7 | +import org.apache.ibatis.annotations.Select; | |
| 8 | +import org.apache.ibatis.annotations.Update; | |
| 9 | + | |
| 10 | +import java.time.LocalDateTime; | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +public interface UserMapper extends BaseMapper<User> { | |
| 14 | + | |
| 15 | + @Select("SELECT iIncrement, sId, sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, " | |
| 16 | + + "iStaffId, sUserType, sLanguage, bCanModifyDocs, sPasswordHash, tLastLoginDate, " | |
| 17 | + + "sCreatedBy, bDeleted, tDeletedDate, sDeletedBy " | |
| 18 | + + "FROM tUser WHERE sUserName = #{name} LIMIT 1") | |
| 19 | + User selectByUserName(@Param("name") String name); | |
| 20 | + | |
| 21 | + @Update("UPDATE tUser SET tLastLoginDate = #{ts} WHERE iIncrement = #{id}") | |
| 22 | + int updateLastLoginDate(@Param("id") Integer id, @Param("ts") LocalDateTime ts); | |
| 23 | + | |
| 24 | + | |
| 25 | + List<UserListVO> pageWithFilter(@Param("field") String field, | |
| 26 | + @Param("matchOp") String matchOp, | |
| 27 | + @Param("value") Object value, | |
| 28 | + @Param("offset") int offset, | |
| 29 | + @Param("size") int size); | |
| 30 | + | |
| 31 | + long countWithFilter(@Param("field") String field, | |
| 32 | + @Param("matchOp") String matchOp, | |
| 33 | + @Param("value") Object value); | |
| 34 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.baomidou.mybatisplus.core.mapper.BaseMapper; | |
| 4 | +import com.xly.erp.module.usr.entity.UserPermission; | |
| 5 | +import org.apache.ibatis.annotations.Delete; | |
| 6 | +import org.apache.ibatis.annotations.Param; | |
| 7 | + | |
| 8 | +public interface UserPermissionMapper extends BaseMapper<UserPermission> { | |
| 9 | + | |
| 10 | + @Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}") | |
| 11 | + int deleteByUserId(@Param("userId") Integer userId); | |
| 12 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Component; | |
| 4 | + | |
| 5 | +import java.time.Clock; | |
| 6 | +import java.time.Duration; | |
| 7 | +import java.time.Instant; | |
| 8 | +import java.util.Optional; | |
| 9 | +import java.util.concurrent.ConcurrentHashMap; | |
| 10 | +import java.util.concurrent.ConcurrentMap; | |
| 11 | + | |
| 12 | +@Component | |
| 13 | +public class LoginAttemptStore { | |
| 14 | + | |
| 15 | + public static final int MAX_ATTEMPTS = 5; | |
| 16 | + public static final Duration LOCK_DURATION = Duration.ofMinutes(15); | |
| 17 | + | |
| 18 | + private final ConcurrentMap<String, AttemptRecord> records = new ConcurrentHashMap<>(); | |
| 19 | + private final Clock clock; | |
| 20 | + | |
| 21 | + public LoginAttemptStore() { | |
| 22 | + this(Clock.systemDefaultZone()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + public LoginAttemptStore(Clock clock) { | |
| 26 | + this.clock = clock; | |
| 27 | + } | |
| 28 | + | |
| 29 | + public Optional<Long> isLocked(String userName) { | |
| 30 | + AttemptRecord r = records.get(userName); | |
| 31 | + if (r == null || r.lockExpireAt == null) { | |
| 32 | + return Optional.empty(); | |
| 33 | + } | |
| 34 | + Instant now = Instant.now(clock); | |
| 35 | + if (now.isAfter(r.lockExpireAt)) { | |
| 36 | + records.remove(userName); | |
| 37 | + return Optional.empty(); | |
| 38 | + } | |
| 39 | + return Optional.of(r.lockExpireAt.getEpochSecond() - now.getEpochSecond()); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public synchronized int recordFailure(String userName) { | |
| 43 | + AttemptRecord r = records.computeIfAbsent(userName, k -> new AttemptRecord()); | |
| 44 | + r.count++; | |
| 45 | + if (r.count >= MAX_ATTEMPTS) { | |
| 46 | + r.lockExpireAt = Instant.now(clock).plus(LOCK_DURATION); | |
| 47 | + } | |
| 48 | + return r.count; | |
| 49 | + } | |
| 50 | + | |
| 51 | + public void clearFailures(String userName) { | |
| 52 | + records.remove(userName); | |
| 53 | + } | |
| 54 | + | |
| 55 | + private static class AttemptRecord { | |
| 56 | + int count = 0; | |
| 57 | + Instant lockExpireAt = null; | |
| 58 | + } | |
| 59 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.dto.CreateUserDTO; | |
| 4 | +import com.xly.erp.module.usr.dto.LoginDTO; | |
| 5 | +import com.xly.erp.module.usr.dto.UpdateUserDTO; | |
| 6 | +import com.xly.erp.module.usr.vo.LoginVO; | |
| 7 | + | |
| 8 | +import java.util.Map; | |
| 9 | + | |
| 10 | +public interface UserService { | |
| 11 | + Map<String, Object> create(CreateUserDTO dto); | |
| 12 | + | |
| 13 | + Integer update(Integer id, UpdateUserDTO dto); | |
| 14 | + | |
| 15 | + Map<String, Object> list(String field, String match, String value, Integer pageNum, Integer pageSize); | |
| 16 | + | |
| 17 | + LoginVO login(LoginDTO dto); | |
| 18 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service.impl; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | |
| 4 | +import com.xly.erp.common.config.TenantProperties; | |
| 5 | +import com.xly.erp.common.exception.BizException; | |
| 6 | +import com.xly.erp.common.security.JwtUtil; | |
| 7 | +import com.xly.erp.common.security.SecurityContextHelper; | |
| 8 | +import com.xly.erp.module.usr.dto.CreateUserDTO; | |
| 9 | +import com.xly.erp.module.usr.dto.LoginDTO; | |
| 10 | +import com.xly.erp.module.usr.dto.UpdateUserDTO; | |
| 11 | +import com.xly.erp.module.usr.entity.User; | |
| 12 | +import com.xly.erp.module.usr.security.LoginAttemptStore; | |
| 13 | +import com.xly.erp.module.usr.vo.LoginVO; | |
| 14 | +import com.xly.erp.module.usr.vo.UserBriefVO; | |
| 15 | +import com.xly.erp.module.usr.vo.UserListVO; | |
| 16 | +import com.xly.erp.module.usr.entity.UserPermission; | |
| 17 | +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; | |
| 18 | +import com.xly.erp.module.usr.mapper.StaffMapper; | |
| 19 | +import com.xly.erp.module.usr.mapper.UserMapper; | |
| 20 | +import com.xly.erp.module.usr.mapper.UserPermissionMapper; | |
| 21 | +import com.xly.erp.module.usr.service.UserService; | |
| 22 | +import org.springframework.dao.DuplicateKeyException; | |
| 23 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 24 | +import org.springframework.stereotype.Service; | |
| 25 | +import org.springframework.transaction.annotation.Transactional; | |
| 26 | + | |
| 27 | +import java.time.LocalDate; | |
| 28 | +import java.time.LocalDateTime; | |
| 29 | +import java.time.format.DateTimeParseException; | |
| 30 | +import java.util.LinkedHashMap; | |
| 31 | +import java.util.List; | |
| 32 | +import java.util.Map; | |
| 33 | +import java.util.Set; | |
| 34 | + | |
| 35 | +@Service | |
| 36 | +@Transactional(rollbackFor = Exception.class) | |
| 37 | +public class UserServiceImpl implements UserService { | |
| 38 | + | |
| 39 | + static final Set<String> USER_TYPES = Set.of("普通用户", "超级管理员"); | |
| 40 | + static final Set<String> LANGUAGES = Set.of("zh", "en", "zh-TW"); | |
| 41 | + static final String DEFAULT_PASSWORD = "666666"; | |
| 42 | + | |
| 43 | + static final int MAX_PAGE_SIZE = 100; | |
| 44 | + static final String STRING_TYPE = "STRING"; | |
| 45 | + static final String BOOLEAN_TYPE = "BOOLEAN"; | |
| 46 | + static final String DATE_TYPE = "DATE"; | |
| 47 | + | |
| 48 | + private static final Map<String, FieldDef> FIELD_MAP = Map.of( | |
| 49 | + "用户名", new FieldDef("u.sUserName", STRING_TYPE), | |
| 50 | + "员工名", new FieldDef("s.sStaffName", STRING_TYPE), | |
| 51 | + "用户号", new FieldDef("u.sUserNo", STRING_TYPE), | |
| 52 | + "部门", new FieldDef("s.sDepartment", STRING_TYPE), | |
| 53 | + "用户类型", new FieldDef("u.sUserType", STRING_TYPE), | |
| 54 | + "作废", new FieldDef("u.bDeleted", BOOLEAN_TYPE), | |
| 55 | + "登录日期", new FieldDef("DATE(u.tLastLoginDate)", DATE_TYPE), | |
| 56 | + "制单人", new FieldDef("u.sCreatedBy", STRING_TYPE) | |
| 57 | + ); | |
| 58 | + | |
| 59 | + private static final Map<String, String> MATCH_MAP = Map.of( | |
| 60 | + "包含", "contains", | |
| 61 | + "不包含", "notContains", | |
| 62 | + "等于", "equals" | |
| 63 | + ); | |
| 64 | + | |
| 65 | + private record FieldDef(String column, String type) {} | |
| 66 | + | |
| 67 | + private final UserMapper userMapper; | |
| 68 | + private final UserPermissionMapper userPermissionMapper; | |
| 69 | + private final StaffMapper staffMapper; | |
| 70 | + private final PermissionCategoryMapper permissionCategoryMapper; | |
| 71 | + private final TenantProperties tenant; | |
| 72 | + private final StubSecurityProperties stub; | |
| 73 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 74 | + private final JwtUtil jwtUtil; | |
| 75 | + private final LoginAttemptStore loginAttemptStore; | |
| 76 | + | |
| 77 | + public UserServiceImpl(UserMapper userMapper, | |
| 78 | + UserPermissionMapper userPermissionMapper, | |
| 79 | + StaffMapper staffMapper, | |
| 80 | + PermissionCategoryMapper permissionCategoryMapper, | |
| 81 | + TenantProperties tenant, | |
| 82 | + StubSecurityProperties stub, | |
| 83 | + BCryptPasswordEncoder passwordEncoder, | |
| 84 | + JwtUtil jwtUtil, | |
| 85 | + LoginAttemptStore loginAttemptStore) { | |
| 86 | + this.userMapper = userMapper; | |
| 87 | + this.userPermissionMapper = userPermissionMapper; | |
| 88 | + this.staffMapper = staffMapper; | |
| 89 | + this.permissionCategoryMapper = permissionCategoryMapper; | |
| 90 | + this.tenant = tenant; | |
| 91 | + this.stub = stub; | |
| 92 | + this.passwordEncoder = passwordEncoder; | |
| 93 | + this.jwtUtil = jwtUtil; | |
| 94 | + this.loginAttemptStore = loginAttemptStore; | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Override | |
| 98 | + public Map<String, Object> create(CreateUserDTO dto) { | |
| 99 | + if (!USER_TYPES.contains(dto.getSUserType())) { | |
| 100 | + throw new BizException(40001, "sUserType: 取值非法"); | |
| 101 | + } | |
| 102 | + if (!LANGUAGES.contains(dto.getSLanguage())) { | |
| 103 | + throw new BizException(40001, "sLanguage: 取值非法"); | |
| 104 | + } | |
| 105 | + if (dto.getIStaffId() != null && !staffMapper.existsActiveById(dto.getIStaffId())) { | |
| 106 | + throw new BizException(40022, "职员不存在或已删除"); | |
| 107 | + } | |
| 108 | + List<Integer> ids = dto.getPermissionCategoryIds(); | |
| 109 | + if (ids != null && !ids.isEmpty()) { | |
| 110 | + int found = permissionCategoryMapper.countActiveByIds(ids); | |
| 111 | + if (found != ids.size()) { | |
| 112 | + throw new BizException(40023, "权限分类含无效 id"); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + User entity = new User(); | |
| 117 | + entity.setSBrandsId(tenant.getBrandsId()); | |
| 118 | + entity.setSSubsidiaryId(tenant.getSubsidiaryId()); | |
| 119 | + entity.setTCreateDate(LocalDateTime.now()); | |
| 120 | + entity.setSUserNo(dto.getSUserNo()); | |
| 121 | + entity.setSUserName(dto.getSUserName()); | |
| 122 | + entity.setIStaffId(dto.getIStaffId()); | |
| 123 | + entity.setSUserType(dto.getSUserType()); | |
| 124 | + entity.setSLanguage(dto.getSLanguage()); | |
| 125 | + entity.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : false); | |
| 126 | + entity.setSPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD)); | |
| 127 | + String createdBy = SecurityContextHelper.currentUserNo(); | |
| 128 | + entity.setSCreatedBy(createdBy); | |
| 129 | + entity.setBDeleted(false); | |
| 130 | + | |
| 131 | + try { | |
| 132 | + userMapper.insert(entity); | |
| 133 | + } catch (DuplicateKeyException e) { | |
| 134 | + throw new BizException(40020, "用户号或用户名已存在"); | |
| 135 | + } | |
| 136 | + | |
| 137 | + if (ids != null && !ids.isEmpty()) { | |
| 138 | + for (Integer cid : ids) { | |
| 139 | + UserPermission rel = new UserPermission(); | |
| 140 | + rel.setSBrandsId(tenant.getBrandsId()); | |
| 141 | + rel.setSSubsidiaryId(tenant.getSubsidiaryId()); | |
| 142 | + rel.setTCreateDate(LocalDateTime.now()); | |
| 143 | + rel.setIUserId(entity.getIIncrement()); | |
| 144 | + rel.setICategoryId(cid); | |
| 145 | + rel.setSCreatedBy(createdBy); | |
| 146 | + userPermissionMapper.insert(rel); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + Map<String, Object> result = new LinkedHashMap<>(); | |
| 151 | + result.put("iIncrement", entity.getIIncrement()); | |
| 152 | + result.put("sUserNo", entity.getSUserNo()); | |
| 153 | + return result; | |
| 154 | + } | |
| 155 | + | |
| 156 | + @Override | |
| 157 | + public Integer update(Integer id, UpdateUserDTO dto) { | |
| 158 | + User original = userMapper.selectById(id); | |
| 159 | + if (original == null || Boolean.TRUE.equals(original.getBDeleted())) { | |
| 160 | + throw new BizException(40400, "用户不存在或已删除"); | |
| 161 | + } | |
| 162 | + if (!USER_TYPES.contains(dto.getSUserType())) { | |
| 163 | + throw new BizException(40001, "sUserType: 取值非法"); | |
| 164 | + } | |
| 165 | + if (!LANGUAGES.contains(dto.getSLanguage())) { | |
| 166 | + throw new BizException(40001, "sLanguage: 取值非法"); | |
| 167 | + } | |
| 168 | + if (dto.getIStaffId() != null && !staffMapper.existsActiveById(dto.getIStaffId())) { | |
| 169 | + throw new BizException(40022, "职员不存在或已删除"); | |
| 170 | + } | |
| 171 | + List<Integer> ids = dto.getPermissionCategoryIds(); | |
| 172 | + if (ids != null && !ids.isEmpty()) { | |
| 173 | + int found = permissionCategoryMapper.countActiveByIds(ids); | |
| 174 | + if (found != ids.size()) { | |
| 175 | + throw new BizException(40023, "权限分类含无效 id"); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + User entity = new User(); | |
| 180 | + entity.setIIncrement(id); | |
| 181 | + entity.setSUserNo(dto.getSUserNo()); | |
| 182 | + entity.setSUserName(dto.getSUserName()); | |
| 183 | + entity.setIStaffId(dto.getIStaffId()); | |
| 184 | + entity.setSUserType(dto.getSUserType()); | |
| 185 | + entity.setSLanguage(dto.getSLanguage()); | |
| 186 | + entity.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : false); | |
| 187 | + try { | |
| 188 | + userMapper.updateById(entity); | |
| 189 | + } catch (DuplicateKeyException e) { | |
| 190 | + throw new BizException(40020, "用户号或用户名已存在"); | |
| 191 | + } | |
| 192 | + | |
| 193 | + userPermissionMapper.deleteByUserId(id); | |
| 194 | + if (ids != null && !ids.isEmpty()) { | |
| 195 | + String createdBy = SecurityContextHelper.currentUserNo(); | |
| 196 | + LocalDateTime now = LocalDateTime.now(); | |
| 197 | + for (Integer cid : ids) { | |
| 198 | + UserPermission rel = new UserPermission(); | |
| 199 | + rel.setSBrandsId(tenant.getBrandsId()); | |
| 200 | + rel.setSSubsidiaryId(tenant.getSubsidiaryId()); | |
| 201 | + rel.setTCreateDate(now); | |
| 202 | + rel.setIUserId(id); | |
| 203 | + rel.setICategoryId(cid); | |
| 204 | + rel.setSCreatedBy(createdBy); | |
| 205 | + userPermissionMapper.insert(rel); | |
| 206 | + } | |
| 207 | + } | |
| 208 | + return id; | |
| 209 | + } | |
| 210 | + | |
| 211 | + @Override | |
| 212 | + @Transactional(readOnly = true) | |
| 213 | + public Map<String, Object> list(String field, String match, String value, Integer pageNum, Integer pageSize) { | |
| 214 | + int p = (pageNum == null || pageNum < 1) ? 1 : pageNum; | |
| 215 | + int size = (pageSize == null) ? 20 : pageSize; | |
| 216 | + if (size > MAX_PAGE_SIZE) { | |
| 217 | + throw new BizException(40002, "pageSize 超过 100"); | |
| 218 | + } | |
| 219 | + String f = (field == null || field.isBlank()) ? "用户名" : field.trim(); | |
| 220 | + String m = (match == null || match.isBlank()) ? "包含" : match.trim(); | |
| 221 | + String v = value == null ? "" : value.trim(); | |
| 222 | + | |
| 223 | + FieldDef def = FIELD_MAP.get(f); | |
| 224 | + if (def == null) { | |
| 225 | + throw new BizException(40001, "field 取值非法"); | |
| 226 | + } | |
| 227 | + String matchOp = MATCH_MAP.get(m); | |
| 228 | + if (matchOp == null) { | |
| 229 | + throw new BizException(40001, "match 取值非法"); | |
| 230 | + } | |
| 231 | + if (!STRING_TYPE.equals(def.type()) && !"equals".equals(matchOp)) { | |
| 232 | + throw new BizException(40001, "field/match 组合非法"); | |
| 233 | + } | |
| 234 | + | |
| 235 | + Object queryValue = v; | |
| 236 | + if (!v.isEmpty()) { | |
| 237 | + if (BOOLEAN_TYPE.equals(def.type())) { | |
| 238 | + queryValue = parseBoolean(v); | |
| 239 | + } else if (DATE_TYPE.equals(def.type())) { | |
| 240 | + try { | |
| 241 | + queryValue = LocalDate.parse(v).toString(); | |
| 242 | + } catch (DateTimeParseException e) { | |
| 243 | + throw new BizException(40001, "登录日期值格式非法(应为 YYYY-MM-DD)"); | |
| 244 | + } | |
| 245 | + } | |
| 246 | + } else { | |
| 247 | + queryValue = ""; | |
| 248 | + } | |
| 249 | + | |
| 250 | + int offset = (p - 1) * size; | |
| 251 | + List<UserListVO> records = userMapper.pageWithFilter(def.column(), matchOp, queryValue, offset, size); | |
| 252 | + long total = userMapper.countWithFilter(def.column(), matchOp, queryValue); | |
| 253 | + | |
| 254 | + Map<String, Object> result = new LinkedHashMap<>(); | |
| 255 | + result.put("records", records); | |
| 256 | + result.put("total", total); | |
| 257 | + result.put("pageNum", p); | |
| 258 | + result.put("pageSize", size); | |
| 259 | + return result; | |
| 260 | + } | |
| 261 | + | |
| 262 | + private Integer parseBoolean(String v) { | |
| 263 | + return switch (v.toLowerCase()) { | |
| 264 | + case "true", "1" -> 1; | |
| 265 | + case "false", "0" -> 0; | |
| 266 | + default -> -1; | |
| 267 | + }; | |
| 268 | + } | |
| 269 | + | |
| 270 | + @Override | |
| 271 | + public LoginVO login(LoginDTO dto) { | |
| 272 | + String userName = dto.getSUserName(); | |
| 273 | + loginAttemptStore.isLocked(userName).ifPresent(seconds -> { | |
| 274 | + throw new BizException(42301, "账号临时锁定,剩余 " + seconds + " 秒"); | |
| 275 | + }); | |
| 276 | + User user = userMapper.selectByUserName(userName); | |
| 277 | + if (user == null) { | |
| 278 | + throw new BizException(40101, "用户名或密码错误"); | |
| 279 | + } | |
| 280 | + if (Boolean.TRUE.equals(user.getBDeleted())) { | |
| 281 | + throw new BizException(40102, "账号已禁用"); | |
| 282 | + } | |
| 283 | + if (!passwordEncoder.matches(dto.getPassword(), user.getSPasswordHash())) { | |
| 284 | + int newCount = loginAttemptStore.recordFailure(userName); | |
| 285 | + if (newCount >= LoginAttemptStore.MAX_ATTEMPTS) { | |
| 286 | + long remaining = loginAttemptStore.isLocked(userName).orElse(0L); | |
| 287 | + throw new BizException(42301, "账号临时锁定,剩余 " + remaining + " 秒"); | |
| 288 | + } | |
| 289 | + throw new BizException(40101, "用户名或密码错误"); | |
| 290 | + } | |
| 291 | + loginAttemptStore.clearFailures(userName); | |
| 292 | + userMapper.updateLastLoginDate(user.getIIncrement(), LocalDateTime.now()); | |
| 293 | + | |
| 294 | + UserBriefVO brief = new UserBriefVO(); | |
| 295 | + brief.setIIncrement(user.getIIncrement()); | |
| 296 | + brief.setSUserNo(user.getSUserNo()); | |
| 297 | + brief.setSUserName(user.getSUserName()); | |
| 298 | + brief.setSUserType(user.getSUserType()); | |
| 299 | + brief.setSLanguage(user.getSLanguage()); | |
| 300 | + | |
| 301 | + LoginVO vo = new LoginVO(); | |
| 302 | + vo.setAccessToken(jwtUtil.sign(user.getSUserNo())); | |
| 303 | + vo.setRefreshToken(jwtUtil.signRefresh(user.getSUserNo())); | |
| 304 | + vo.setExpiresIn(JwtUtil.ACCESS_TTL.toSeconds()); | |
| 305 | + vo.setUser(brief); | |
| 306 | + return vo; | |
| 307 | + } | |
| 308 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | + | |
| 5 | +public class LoginVO { | |
| 6 | + | |
| 7 | + @JsonProperty("accessToken") | |
| 8 | + private String accessToken; | |
| 9 | + | |
| 10 | + @JsonProperty("refreshToken") | |
| 11 | + private String refreshToken; | |
| 12 | + | |
| 13 | + @JsonProperty("expiresIn") | |
| 14 | + private long expiresIn; | |
| 15 | + | |
| 16 | + @JsonProperty("user") | |
| 17 | + private UserBriefVO user; | |
| 18 | + | |
| 19 | + public String getAccessToken() { return accessToken; } | |
| 20 | + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } | |
| 21 | + public String getRefreshToken() { return refreshToken; } | |
| 22 | + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } | |
| 23 | + public long getExpiresIn() { return expiresIn; } | |
| 24 | + public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; } | |
| 25 | + public UserBriefVO getUser() { return user; } | |
| 26 | + public void setUser(UserBriefVO user) { this.user = user; } | |
| 27 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | + | |
| 5 | +public class UserBriefVO { | |
| 6 | + | |
| 7 | + @JsonProperty("iIncrement") | |
| 8 | + private Integer iIncrement; | |
| 9 | + | |
| 10 | + @JsonProperty("sUserNo") | |
| 11 | + private String sUserNo; | |
| 12 | + | |
| 13 | + @JsonProperty("sUserName") | |
| 14 | + private String sUserName; | |
| 15 | + | |
| 16 | + @JsonProperty("sUserType") | |
| 17 | + private String sUserType; | |
| 18 | + | |
| 19 | + @JsonProperty("sLanguage") | |
| 20 | + private String sLanguage; | |
| 21 | + | |
| 22 | + public Integer getIIncrement() { return iIncrement; } | |
| 23 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | |
| 24 | + public String getSUserNo() { return sUserNo; } | |
| 25 | + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } | |
| 26 | + public String getSUserName() { return sUserName; } | |
| 27 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 28 | + public String getSUserType() { return sUserType; } | |
| 29 | + public void setSUserType(String sUserType) { this.sUserType = sUserType; } | |
| 30 | + public String getSLanguage() { return sLanguage; } | |
| 31 | + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } | |
| 32 | +} | ... | ... |
backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.vo; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 4 | + | |
| 5 | +import java.time.LocalDateTime; | |
| 6 | + | |
| 7 | +public class UserListVO { | |
| 8 | + | |
| 9 | + @JsonProperty("iIncrement") | |
| 10 | + private Integer iIncrement; | |
| 11 | + | |
| 12 | + @JsonProperty("sUserName") | |
| 13 | + private String sUserName; | |
| 14 | + | |
| 15 | + @JsonProperty("staffName") | |
| 16 | + private String staffName; | |
| 17 | + | |
| 18 | + @JsonProperty("sUserNo") | |
| 19 | + private String sUserNo; | |
| 20 | + | |
| 21 | + @JsonProperty("department") | |
| 22 | + private String department; | |
| 23 | + | |
| 24 | + @JsonProperty("sUserType") | |
| 25 | + private String sUserType; | |
| 26 | + | |
| 27 | + @JsonProperty("sLanguage") | |
| 28 | + private String sLanguage; | |
| 29 | + | |
| 30 | + @JsonProperty("bDeleted") | |
| 31 | + private Boolean bDeleted; | |
| 32 | + | |
| 33 | + @JsonProperty("tLastLoginDate") | |
| 34 | + private LocalDateTime tLastLoginDate; | |
| 35 | + | |
| 36 | + @JsonProperty("sCreatedBy") | |
| 37 | + private String sCreatedBy; | |
| 38 | + | |
| 39 | + @JsonProperty("tCreateDate") | |
| 40 | + private LocalDateTime tCreateDate; | |
| 41 | + | |
| 42 | + public Integer getIIncrement() { return iIncrement; } | |
| 43 | + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } | |
| 44 | + public String getSUserName() { return sUserName; } | |
| 45 | + public void setSUserName(String sUserName) { this.sUserName = sUserName; } | |
| 46 | + public String getStaffName() { return staffName; } | |
| 47 | + public void setStaffName(String staffName) { this.staffName = staffName; } | |
| 48 | + public String getSUserNo() { return sUserNo; } | |
| 49 | + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } | |
| 50 | + public String getDepartment() { return department; } | |
| 51 | + public void setDepartment(String department) { this.department = department; } | |
| 52 | + public String getSUserType() { return sUserType; } | |
| 53 | + public void setSUserType(String sUserType) { this.sUserType = sUserType; } | |
| 54 | + public String getSLanguage() { return sLanguage; } | |
| 55 | + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } | |
| 56 | + public Boolean getBDeleted() { return bDeleted; } | |
| 57 | + public void setBDeleted(Boolean bDeleted) { this.bDeleted = bDeleted; } | |
| 58 | + public LocalDateTime getTLastLoginDate() { return tLastLoginDate; } | |
| 59 | + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { this.tLastLoginDate = tLastLoginDate; } | |
| 60 | + public String getSCreatedBy() { return sCreatedBy; } | |
| 61 | + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } | |
| 62 | + public LocalDateTime getTCreateDate() { return tCreateDate; } | |
| 63 | + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } | |
| 64 | +} | ... | ... |
backend/src/main/resources/mapper/usr/UserMapper.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8" ?> | |
| 2 | +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" | |
| 3 | + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |
| 4 | +<mapper namespace="com.xly.erp.module.usr.mapper.UserMapper"> | |
| 5 | + | |
| 6 | + <sql id="baseSelectColumns"> | |
| 7 | + u.iIncrement AS iIncrement, | |
| 8 | + u.sUserName AS sUserName, | |
| 9 | + s.sStaffName AS staffName, | |
| 10 | + u.sUserNo AS sUserNo, | |
| 11 | + s.sDepartment AS department, | |
| 12 | + u.sUserType AS sUserType, | |
| 13 | + u.sLanguage AS sLanguage, | |
| 14 | + u.bDeleted AS bDeleted, | |
| 15 | + u.tLastLoginDate AS tLastLoginDate, | |
| 16 | + u.sCreatedBy AS sCreatedBy, | |
| 17 | + u.tCreateDate AS tCreateDate | |
| 18 | + </sql> | |
| 19 | + | |
| 20 | + <sql id="filterClause"> | |
| 21 | + <where> | |
| 22 | + <if test="value != null and value != ''"> | |
| 23 | + <choose> | |
| 24 | + <when test="matchOp == 'contains'">${field} LIKE CONCAT('%', #{value}, '%')</when> | |
| 25 | + <when test="matchOp == 'notContains'">${field} NOT LIKE CONCAT('%', #{value}, '%')</when> | |
| 26 | + <when test="matchOp == 'equals'">${field} = #{value}</when> | |
| 27 | + </choose> | |
| 28 | + </if> | |
| 29 | + </where> | |
| 30 | + </sql> | |
| 31 | + | |
| 32 | + <select id="pageWithFilter" resultType="com.xly.erp.module.usr.vo.UserListVO"> | |
| 33 | + SELECT <include refid="baseSelectColumns"/> | |
| 34 | + FROM tUser u | |
| 35 | + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0 | |
| 36 | + <include refid="filterClause"/> | |
| 37 | + ORDER BY u.iIncrement DESC | |
| 38 | + LIMIT #{offset}, #{size} | |
| 39 | + </select> | |
| 40 | + | |
| 41 | + <select id="countWithFilter" resultType="long"> | |
| 42 | + SELECT COUNT(1) | |
| 43 | + FROM tUser u | |
| 44 | + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0 | |
| 45 | + <include refid="filterClause"/> | |
| 46 | + </select> | |
| 47 | + | |
| 48 | +</mapper> | ... | ... |
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
| ... | ... | @@ -34,4 +34,13 @@ class JwtUtilTest { |
| 34 | 34 | .isInstanceOf(BizException.class) |
| 35 | 35 | .hasFieldOrPropertyWithValue("code", 20001); |
| 36 | 36 | } |
| 37 | + | |
| 38 | + @Test | |
| 39 | + void signRefresh_signsValidLongerLivedToken() { | |
| 40 | + String accessToken = jwtUtil.sign("U1"); | |
| 41 | + String refreshToken = jwtUtil.signRefresh("U1"); | |
| 42 | + assertThat(jwtUtil.parse(accessToken)).isEqualTo("U1"); | |
| 43 | + assertThat(jwtUtil.parse(refreshToken)).isEqualTo("U1"); | |
| 44 | + assertThat(refreshToken).isNotEqualTo(accessToken); | |
| 45 | + } | |
| 37 | 46 | } | ... | ... |
backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java
| ... | ... | @@ -139,19 +139,17 @@ class ModuleControllerIT { |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | 141 | @Test |
| 142 | - void postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN() throws Exception { | |
| 142 | + void postWithoutJwt_returns20001() throws Exception { | |
| 143 | 143 | HttpHeaders headers = jsonHeaders(); |
| 144 | 144 | Map<String, Object> body = validBody("sp_test_nojwt", "无JWT"); |
| 145 | 145 | |
| 146 | 146 | ResponseEntity<String> resp = rest.exchange( |
| 147 | 147 | url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); |
| 148 | 148 | |
| 149 | - JsonNode jb = objectMapper.readTree(resp.getBody()); | |
| 150 | - assertThat(jb.get("code").asInt()).isZero(); | |
| 151 | - int newId = jb.get("data").get("iIncrement").asInt(); | |
| 152 | - String createdBy = jdbcTemplate.queryForObject( | |
| 153 | - "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, newId); | |
| 154 | - assertThat(createdBy).isEqualTo("STUB_ADMIN"); | |
| 149 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 150 | + Integer count = jdbcTemplate.queryForObject( | |
| 151 | + "SELECT COUNT(1) FROM tModule WHERE sProcedureName = 'sp_test_nojwt'", Integer.class); | |
| 152 | + assertThat(count).isZero(); | |
| 155 | 153 | } |
| 156 | 154 | |
| 157 | 155 | @Test |
| ... | ... | @@ -260,7 +258,7 @@ class ModuleControllerIT { |
| 260 | 258 | } |
| 261 | 259 | |
| 262 | 260 | @Test |
| 263 | - void putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy() throws Exception { | |
| 261 | + void putWithoutJwt_returns20001() throws Exception { | |
| 264 | 262 | Integer id = insertOriginal("sp_test_put_nojwt", "原", "ORIG_USER"); |
| 265 | 263 | HttpHeaders headers = jsonHeaders(); |
| 266 | 264 | Map<String, Object> body = updateBody(); |
| ... | ... | @@ -269,10 +267,10 @@ class ModuleControllerIT { |
| 269 | 267 | ResponseEntity<String> resp = rest.exchange( |
| 270 | 268 | idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); |
| 271 | 269 | |
| 272 | - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | |
| 273 | - String createdBy = jdbcTemplate.queryForObject( | |
| 274 | - "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, id); | |
| 275 | - assertThat(createdBy).isEqualTo("ORIG_USER"); | |
| 270 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 271 | + String name = jdbcTemplate.queryForObject( | |
| 272 | + "SELECT sModuleNameZh FROM tModule WHERE iIncrement = ?", String.class, id); | |
| 273 | + assertThat(name).isEqualTo("原"); | |
| 276 | 274 | } |
| 277 | 275 | |
| 278 | 276 | @Test |
| ... | ... | @@ -359,18 +357,17 @@ class ModuleControllerIT { |
| 359 | 357 | } |
| 360 | 358 | |
| 361 | 359 | @Test |
| 362 | - void deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB() throws Exception { | |
| 360 | + void deleteWithoutJwt_returns20001() throws Exception { | |
| 363 | 361 | Integer id = insertOriginal("sp_test_del_nojwt", "原", "ORIG"); |
| 364 | 362 | HttpHeaders headers = jsonHeaders(); |
| 365 | 363 | |
| 366 | 364 | ResponseEntity<String> resp = rest.exchange( |
| 367 | 365 | idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); |
| 368 | 366 | |
| 369 | - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | |
| 370 | - Map<String, Object> row = jdbcTemplate.queryForMap( | |
| 371 | - "SELECT bDeleted, sDeletedBy FROM tModule WHERE iIncrement = ?", id); | |
| 372 | - assertThat(row.get("bDeleted")).isEqualTo(true); | |
| 373 | - assertThat(row.get("sDeletedBy")).isEqualTo("STUB_ADMIN"); | |
| 367 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 368 | + Boolean stillAlive = jdbcTemplate.queryForObject( | |
| 369 | + "SELECT bDeleted FROM tModule WHERE iIncrement = ?", Boolean.class, id); | |
| 370 | + assertThat(stillAlive).isFalse(); | |
| 374 | 371 | } |
| 375 | 372 | |
| 376 | 373 | @Test |
| ... | ... | @@ -463,11 +460,11 @@ class ModuleControllerIT { |
| 463 | 460 | } |
| 464 | 461 | |
| 465 | 462 | @Test |
| 466 | - void getWithoutJwt_permitAllStub_returns200() throws Exception { | |
| 463 | + void getWithoutJwt_returns20001() throws Exception { | |
| 467 | 464 | HttpHeaders headers = jsonHeaders(); |
| 468 | 465 | ResponseEntity<String> resp = rest.exchange( |
| 469 | 466 | listUrl(null), HttpMethod.GET, new HttpEntity<>(headers), String.class); |
| 470 | - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | |
| 467 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 471 | 468 | } |
| 472 | 469 | |
| 473 | 470 | @Test | ... | ... |
backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
| ... | ... | @@ -70,7 +70,7 @@ class ModuleServiceImplTest { |
| 70 | 70 | assertThat(saved.getSBrandsId()).isEqualTo("XLY"); |
| 71 | 71 | assertThat(saved.getSSubsidiaryId()).isEqualTo("XLY"); |
| 72 | 72 | assertThat(saved.getTCreateDate()).isNotNull(); |
| 73 | - assertThat(saved.getSCreatedBy()).isEqualTo("STUB_ADMIN"); | |
| 73 | + assertThat(saved.getSCreatedBy()).isNull(); | |
| 74 | 74 | assertThat(saved.getBDeleted()).isFalse(); |
| 75 | 75 | assertThat(saved.getBShowPermission()).isFalse(); |
| 76 | 76 | assertThat(saved.getISortOrder()).isZero(); |
| ... | ... | @@ -254,7 +254,7 @@ class ModuleServiceImplTest { |
| 254 | 254 | assertThat(passed.getIIncrement()).isEqualTo(10); |
| 255 | 255 | assertThat(passed.getBDeleted()).isTrue(); |
| 256 | 256 | assertThat(passed.getTDeletedDate()).isNotNull(); |
| 257 | - assertThat(passed.getSDeletedBy()).isEqualTo("STUB_ADMIN"); | |
| 257 | + assertThat(passed.getSDeletedBy()).isNull(); | |
| 258 | 258 | assertThat(passed.getSProcedureName()).isNull(); |
| 259 | 259 | assertThat(passed.getSCreatedBy()).isNull(); |
| 260 | 260 | assertThat(passed.getSBrandsId()).isNull(); | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import com.xly.erp.module.usr.security.LoginAttemptStore; | |
| 6 | +import org.junit.jupiter.api.AfterEach; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 11 | +import org.springframework.boot.test.web.client.TestRestTemplate; | |
| 12 | +import org.springframework.boot.test.web.server.LocalServerPort; | |
| 13 | +import org.springframework.http.HttpEntity; | |
| 14 | +import org.springframework.http.HttpHeaders; | |
| 15 | +import org.springframework.http.HttpMethod; | |
| 16 | +import org.springframework.http.MediaType; | |
| 17 | +import org.springframework.http.ResponseEntity; | |
| 18 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 19 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 20 | +import org.springframework.test.context.ActiveProfiles; | |
| 21 | + | |
| 22 | +import java.util.HashMap; | |
| 23 | +import java.util.Map; | |
| 24 | + | |
| 25 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 26 | + | |
| 27 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | |
| 28 | +@ActiveProfiles("test") | |
| 29 | +class AuthControllerIT { | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + private TestRestTemplate rest; | |
| 33 | + | |
| 34 | + @Autowired | |
| 35 | + private JdbcTemplate jdbcTemplate; | |
| 36 | + | |
| 37 | + @Autowired | |
| 38 | + private ObjectMapper objectMapper; | |
| 39 | + | |
| 40 | + @Autowired | |
| 41 | + private BCryptPasswordEncoder encoder; | |
| 42 | + | |
| 43 | + @Autowired | |
| 44 | + private LoginAttemptStore loginAttemptStore; | |
| 45 | + | |
| 46 | + @LocalServerPort | |
| 47 | + private int port; | |
| 48 | + | |
| 49 | + @BeforeEach | |
| 50 | + @AfterEach | |
| 51 | + void cleanup() { | |
| 52 | + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); | |
| 53 | + loginAttemptStore.clearFailures("sp_test_login_user"); | |
| 54 | + loginAttemptStore.clearFailures("sp_test_login_lock"); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + void loginWithValidCredentials_returns200_withTokens() throws Exception { | |
| 59 | + insertActiveUser("sp_test_login_user", "登录用户1", encoder.encode("666666"), false); | |
| 60 | + | |
| 61 | + Map<String, Object> body = new HashMap<>(); | |
| 62 | + body.put("sUserName", "登录用户1"); | |
| 63 | + body.put("password", "666666"); | |
| 64 | + body.put("version", "标准版"); | |
| 65 | + | |
| 66 | + ResponseEntity<String> resp = rest.exchange( | |
| 67 | + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); | |
| 68 | + | |
| 69 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | |
| 70 | + assertThat(jb.get("code").asInt()).isZero(); | |
| 71 | + JsonNode data = jb.get("data"); | |
| 72 | + assertThat(data.get("accessToken").asText()).isNotBlank(); | |
| 73 | + assertThat(data.get("refreshToken").asText()).isNotBlank(); | |
| 74 | + assertThat(data.get("expiresIn").asInt()).isEqualTo(28800); | |
| 75 | + assertThat(data.get("user").get("sUserNo").asText()).isEqualTo("sp_test_login_user"); | |
| 76 | + assertThat(data.get("user").has("sPasswordHash")).isFalse(); | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test | |
| 80 | + void loginWithEmptyBody_returns40001() throws Exception { | |
| 81 | + ResponseEntity<String> resp = rest.exchange( | |
| 82 | + url(), HttpMethod.POST, new HttpEntity<>("{}", jsonHeaders()), String.class); | |
| 83 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + void loginWithUserNotFound_returns40101() throws Exception { | |
| 88 | + Map<String, Object> body = new HashMap<>(); | |
| 89 | + body.put("sUserName", "幽灵用户"); | |
| 90 | + body.put("password", "x"); | |
| 91 | + body.put("version", "标准版"); | |
| 92 | + ResponseEntity<String> resp = rest.exchange( | |
| 93 | + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); | |
| 94 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40101); | |
| 95 | + } | |
| 96 | + | |
| 97 | + @Test | |
| 98 | + void loginWithWrongPassword_returns40101() throws Exception { | |
| 99 | + insertActiveUser("sp_test_login_user", "登录用户1", encoder.encode("666666"), false); | |
| 100 | + | |
| 101 | + Map<String, Object> body = new HashMap<>(); | |
| 102 | + body.put("sUserName", "登录用户1"); | |
| 103 | + body.put("password", "wrong"); | |
| 104 | + body.put("version", "标准版"); | |
| 105 | + | |
| 106 | + ResponseEntity<String> resp = rest.exchange( | |
| 107 | + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); | |
| 108 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40101); | |
| 109 | + } | |
| 110 | + | |
| 111 | + @Test | |
| 112 | + void loginAfter5WrongPasswords_returns42301() throws Exception { | |
| 113 | + insertActiveUser("sp_test_login_lock", "锁定用户", encoder.encode("666666"), false); | |
| 114 | + | |
| 115 | + Map<String, Object> body = new HashMap<>(); | |
| 116 | + body.put("sUserName", "锁定用户"); | |
| 117 | + body.put("password", "wrong"); | |
| 118 | + body.put("version", "标准版"); | |
| 119 | + | |
| 120 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { | |
| 121 | + rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); | |
| 122 | + } | |
| 123 | + ResponseEntity<String> resp = rest.exchange( | |
| 124 | + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); | |
| 125 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(42301); | |
| 126 | + | |
| 127 | + loginAttemptStore.clearFailures("锁定用户"); | |
| 128 | + } | |
| 129 | + | |
| 130 | + private void insertActiveUser(String userNo, String userName, String passwordHash, boolean deleted) { | |
| 131 | + jdbcTemplate.update( | |
| 132 | + "INSERT INTO tUser (sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, " | |
| 133 | + + "sUserType, sLanguage, bCanModifyDocs, sPasswordHash, sCreatedBy, bDeleted) " | |
| 134 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, '普通用户', 'zh', 0, ?, 'STUB_ADMIN', ?)", | |
| 135 | + userNo, userName, passwordHash, deleted ? 1 : 0); | |
| 136 | + } | |
| 137 | + | |
| 138 | + private static HttpHeaders jsonHeaders() { | |
| 139 | + HttpHeaders h = new HttpHeaders(); | |
| 140 | + h.setContentType(MediaType.APPLICATION_JSON); | |
| 141 | + return h; | |
| 142 | + } | |
| 143 | + | |
| 144 | + private String url() { | |
| 145 | + return "http://localhost:" + port + "/api/usr/auth/login"; | |
| 146 | + } | |
| 147 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.controller; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | +import com.xly.erp.common.security.TestJwtHelper; | |
| 6 | +import org.junit.jupiter.api.AfterEach; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 11 | +import org.springframework.boot.test.web.client.TestRestTemplate; | |
| 12 | +import org.springframework.boot.test.web.server.LocalServerPort; | |
| 13 | +import org.springframework.http.HttpEntity; | |
| 14 | +import org.springframework.http.HttpHeaders; | |
| 15 | +import org.springframework.http.HttpMethod; | |
| 16 | +import org.springframework.http.MediaType; | |
| 17 | +import org.springframework.http.ResponseEntity; | |
| 18 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 19 | +import org.springframework.test.context.ActiveProfiles; | |
| 20 | + | |
| 21 | +import java.util.HashMap; | |
| 22 | +import java.util.List; | |
| 23 | +import java.util.Map; | |
| 24 | + | |
| 25 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 26 | + | |
| 27 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | |
| 28 | +@ActiveProfiles("test") | |
| 29 | +class UserControllerIT { | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + private TestRestTemplate rest; | |
| 33 | + | |
| 34 | + @Autowired | |
| 35 | + private TestJwtHelper testJwtHelper; | |
| 36 | + | |
| 37 | + @Autowired | |
| 38 | + private JdbcTemplate jdbcTemplate; | |
| 39 | + | |
| 40 | + @Autowired | |
| 41 | + private ObjectMapper objectMapper; | |
| 42 | + | |
| 43 | + @LocalServerPort | |
| 44 | + private int port; | |
| 45 | + | |
| 46 | + @BeforeEach | |
| 47 | + @AfterEach | |
| 48 | + void cleanup() { | |
| 49 | + jdbcTemplate.update("DELETE FROM tUserPermission WHERE iUserId IN " | |
| 50 | + + "(SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')"); | |
| 51 | + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); | |
| 52 | + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo LIKE 'sp_test_%'"); | |
| 53 | + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + void postValidBody_with_jwt_returns200_andPersists() throws Exception { | |
| 58 | + Integer staffId = insertStaff("sp_test_st1", "员工1"); | |
| 59 | + Integer cat1 = insertCategory("sp_test_pc1", "权限A"); | |
| 60 | + Integer cat2 = insertCategory("sp_test_pc2", "权限B"); | |
| 61 | + | |
| 62 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 63 | + HttpHeaders headers = jsonHeaders(); | |
| 64 | + headers.set("Authorization", "Bearer " + token); | |
| 65 | + Map<String, Object> body = baseBody("sp_test_u_ok", "正常用户"); | |
| 66 | + body.put("iStaffId", staffId); | |
| 67 | + body.put("permissionCategoryIds", List.of(cat1, cat2)); | |
| 68 | + | |
| 69 | + ResponseEntity<String> resp = rest.exchange( | |
| 70 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 71 | + | |
| 72 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | |
| 73 | + assertThat(jb.get("code").asInt()).isZero(); | |
| 74 | + int newId = jb.get("data").get("iIncrement").asInt(); | |
| 75 | + assertThat(jb.get("data").get("sUserNo").asText()).isEqualTo("sp_test_u_ok"); | |
| 76 | + | |
| 77 | + Map<String, Object> row = jdbcTemplate.queryForMap( | |
| 78 | + "SELECT sBrandsId, sCreatedBy, sUserType FROM tUser WHERE iIncrement = ?", newId); | |
| 79 | + assertThat(row.get("sBrandsId")).isEqualTo("XLY"); | |
| 80 | + assertThat(row.get("sCreatedBy")).isEqualTo("ADMIN001"); | |
| 81 | + Integer permCount = jdbcTemplate.queryForObject( | |
| 82 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, newId); | |
| 83 | + assertThat(permCount).isEqualTo(2); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Test | |
| 87 | + void postEmptyBody_returns40001() throws Exception { | |
| 88 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 89 | + HttpHeaders headers = jsonHeaders(); | |
| 90 | + headers.set("Authorization", "Bearer " + token); | |
| 91 | + | |
| 92 | + ResponseEntity<String> resp = rest.exchange( | |
| 93 | + url(), HttpMethod.POST, new HttpEntity<>("{}", headers), String.class); | |
| 94 | + | |
| 95 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + void postInvalidUserType_returns40001() throws Exception { | |
| 100 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 101 | + HttpHeaders headers = jsonHeaders(); | |
| 102 | + headers.set("Authorization", "Bearer " + token); | |
| 103 | + Map<String, Object> body = baseBody("sp_test_u_invtype", "枚举"); | |
| 104 | + body.put("sUserType", "火星"); | |
| 105 | + | |
| 106 | + ResponseEntity<String> resp = rest.exchange( | |
| 107 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 108 | + | |
| 109 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 110 | + } | |
| 111 | + | |
| 112 | + @Test | |
| 113 | + void postInvalidLanguage_returns40001() throws Exception { | |
| 114 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 115 | + HttpHeaders headers = jsonHeaders(); | |
| 116 | + headers.set("Authorization", "Bearer " + token); | |
| 117 | + Map<String, Object> body = baseBody("sp_test_u_invlang", "枚举"); | |
| 118 | + body.put("sLanguage", "ja"); | |
| 119 | + | |
| 120 | + ResponseEntity<String> resp = rest.exchange( | |
| 121 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 122 | + | |
| 123 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test | |
| 127 | + void postDuplicateUserNo_returns40020() throws Exception { | |
| 128 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 129 | + HttpHeaders headers = jsonHeaders(); | |
| 130 | + headers.set("Authorization", "Bearer " + token); | |
| 131 | + Map<String, Object> first = baseBody("sp_test_u_dup", "首次"); | |
| 132 | + ResponseEntity<String> r1 = rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(first, headers), String.class); | |
| 133 | + assertThat(objectMapper.readTree(r1.getBody()).get("code").asInt()).isZero(); | |
| 134 | + | |
| 135 | + Map<String, Object> dup = baseBody("sp_test_u_dup", "重复"); | |
| 136 | + dup.put("sUserName", "sp_test_u_dup_other"); | |
| 137 | + ResponseEntity<String> r2 = rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(dup, headers), String.class); | |
| 138 | + assertThat(objectMapper.readTree(r2.getBody()).get("code").asInt()).isEqualTo(40020); | |
| 139 | + } | |
| 140 | + | |
| 141 | + @Test | |
| 142 | + void postStaffNotFound_returns40022() throws Exception { | |
| 143 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 144 | + HttpHeaders headers = jsonHeaders(); | |
| 145 | + headers.set("Authorization", "Bearer " + token); | |
| 146 | + Map<String, Object> body = baseBody("sp_test_u_nostaff", "缺职员"); | |
| 147 | + body.put("iStaffId", 99999990); | |
| 148 | + | |
| 149 | + ResponseEntity<String> resp = rest.exchange( | |
| 150 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 151 | + | |
| 152 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40022); | |
| 153 | + } | |
| 154 | + | |
| 155 | + @Test | |
| 156 | + void postPermissionCategoryNotFound_returns40023() throws Exception { | |
| 157 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 158 | + HttpHeaders headers = jsonHeaders(); | |
| 159 | + headers.set("Authorization", "Bearer " + token); | |
| 160 | + Map<String, Object> body = baseBody("sp_test_u_nocat", "缺权限"); | |
| 161 | + body.put("permissionCategoryIds", List.of(99999991)); | |
| 162 | + | |
| 163 | + ResponseEntity<String> resp = rest.exchange( | |
| 164 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 165 | + | |
| 166 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40023); | |
| 167 | + } | |
| 168 | + | |
| 169 | + @Test | |
| 170 | + void postWithoutJwt_returns20001() throws Exception { | |
| 171 | + HttpHeaders headers = jsonHeaders(); | |
| 172 | + Map<String, Object> body = baseBody("sp_test_u_nojwt", "无JWT"); | |
| 173 | + | |
| 174 | + ResponseEntity<String> resp = rest.exchange( | |
| 175 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 176 | + | |
| 177 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 178 | + Integer count = jdbcTemplate.queryForObject( | |
| 179 | + "SELECT COUNT(1) FROM tUser WHERE sUserNo = 'sp_test_u_nojwt'", Integer.class); | |
| 180 | + assertThat(count).isZero(); | |
| 181 | + } | |
| 182 | + | |
| 183 | + @Test | |
| 184 | + void postTamperedJwt_returns20001() throws Exception { | |
| 185 | + HttpHeaders headers = jsonHeaders(); | |
| 186 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | |
| 187 | + Map<String, Object> body = baseBody("sp_test_u_tamper", "伪JWT"); | |
| 188 | + | |
| 189 | + ResponseEntity<String> resp = rest.exchange( | |
| 190 | + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |
| 191 | + | |
| 192 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 193 | + Integer count = jdbcTemplate.queryForObject( | |
| 194 | + "SELECT COUNT(1) FROM tUser WHERE sUserNo = 'sp_test_u_tamper'", Integer.class); | |
| 195 | + assertThat(count).isZero(); | |
| 196 | + } | |
| 197 | + | |
| 198 | + @Test | |
| 199 | + void putValidBody_with_jwt_returns200_andUpdates() throws Exception { | |
| 200 | + Integer staffId = insertStaff("sp_test_pst1", "员工1"); | |
| 201 | + Integer cat1 = insertCategory("sp_test_pc_p1", "权限1"); | |
| 202 | + Integer cat2 = insertCategory("sp_test_pc_p2", "权限2"); | |
| 203 | + Integer userId = insertUserWithPerms("sp_test_u_putorig", "原用户", staffId, cat1); | |
| 204 | + | |
| 205 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 206 | + HttpHeaders headers = jsonHeaders(); | |
| 207 | + headers.set("Authorization", "Bearer " + token); | |
| 208 | + Map<String, Object> body = baseBody("sp_test_u_putorig", "原用户改名"); | |
| 209 | + body.put("permissionCategoryIds", List.of(cat1, cat2)); | |
| 210 | + | |
| 211 | + ResponseEntity<String> resp = rest.exchange( | |
| 212 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 213 | + | |
| 214 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | |
| 215 | + assertThat(jb.get("code").asInt()).isZero(); | |
| 216 | + assertThat(jb.get("data").get("iIncrement").asInt()).isEqualTo(userId); | |
| 217 | + | |
| 218 | + Map<String, Object> row = jdbcTemplate.queryForMap( | |
| 219 | + "SELECT sUserName, sPasswordHash, sCreatedBy FROM tUser WHERE iIncrement = ?", userId); | |
| 220 | + assertThat(row.get("sUserName")).isEqualTo("原用户改名"); | |
| 221 | + assertThat((String) row.get("sPasswordHash")).startsWith("$2a$"); | |
| 222 | + assertThat(row.get("sCreatedBy")).isEqualTo("ORIG_CREATOR"); | |
| 223 | + | |
| 224 | + Integer permCount = jdbcTemplate.queryForObject( | |
| 225 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, userId); | |
| 226 | + assertThat(permCount).isEqualTo(2); | |
| 227 | + } | |
| 228 | + | |
| 229 | + @Test | |
| 230 | + void putNonExistentId_returns40400() throws Exception { | |
| 231 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 232 | + HttpHeaders headers = jsonHeaders(); | |
| 233 | + headers.set("Authorization", "Bearer " + token); | |
| 234 | + Map<String, Object> body = baseBody("sp_test_u_no", "无"); | |
| 235 | + | |
| 236 | + ResponseEntity<String> resp = rest.exchange( | |
| 237 | + idUrl(99999990), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 238 | + | |
| 239 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); | |
| 240 | + } | |
| 241 | + | |
| 242 | + @Test | |
| 243 | + void putAlreadyDeletedId_returns40400() throws Exception { | |
| 244 | + Integer userId = insertUserWithPerms("sp_test_u_deleted", "已删", null, null); | |
| 245 | + jdbcTemplate.update("UPDATE tUser SET bDeleted = 1 WHERE iIncrement = ?", userId); | |
| 246 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 247 | + HttpHeaders headers = jsonHeaders(); | |
| 248 | + headers.set("Authorization", "Bearer " + token); | |
| 249 | + | |
| 250 | + ResponseEntity<String> resp = rest.exchange( | |
| 251 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(baseBody("sp_test_u_deleted", "改"), headers), | |
| 252 | + String.class); | |
| 253 | + | |
| 254 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); | |
| 255 | + } | |
| 256 | + | |
| 257 | + @Test | |
| 258 | + void putInvalidUserType_returns40001() throws Exception { | |
| 259 | + Integer userId = insertUserWithPerms("sp_test_u_pinvtype", "原", null, null); | |
| 260 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 261 | + HttpHeaders headers = jsonHeaders(); | |
| 262 | + headers.set("Authorization", "Bearer " + token); | |
| 263 | + Map<String, Object> body = baseBody("sp_test_u_pinvtype", "改"); | |
| 264 | + body.put("sUserType", "火星"); | |
| 265 | + | |
| 266 | + ResponseEntity<String> resp = rest.exchange( | |
| 267 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 268 | + | |
| 269 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 270 | + } | |
| 271 | + | |
| 272 | + @Test | |
| 273 | + void putDuplicateUserNo_returns40020() throws Exception { | |
| 274 | + Integer u1 = insertUserWithPerms("sp_test_u_pdupA", "AAA", null, null); | |
| 275 | + Integer u2 = insertUserWithPerms("sp_test_u_pdupB", "BBB", null, null); | |
| 276 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 277 | + HttpHeaders headers = jsonHeaders(); | |
| 278 | + headers.set("Authorization", "Bearer " + token); | |
| 279 | + Map<String, Object> body = baseBody("sp_test_u_pdupA", "改成 A"); | |
| 280 | + | |
| 281 | + ResponseEntity<String> resp = rest.exchange( | |
| 282 | + idUrl(u2), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 283 | + | |
| 284 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40020); | |
| 285 | + } | |
| 286 | + | |
| 287 | + @Test | |
| 288 | + void putStaffNotFound_returns40022() throws Exception { | |
| 289 | + Integer userId = insertUserWithPerms("sp_test_u_pstaff", "原", null, null); | |
| 290 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 291 | + HttpHeaders headers = jsonHeaders(); | |
| 292 | + headers.set("Authorization", "Bearer " + token); | |
| 293 | + Map<String, Object> body = baseBody("sp_test_u_pstaff", "改"); | |
| 294 | + body.put("iStaffId", 99999990); | |
| 295 | + | |
| 296 | + ResponseEntity<String> resp = rest.exchange( | |
| 297 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 298 | + | |
| 299 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40022); | |
| 300 | + } | |
| 301 | + | |
| 302 | + @Test | |
| 303 | + void putPermissionCategoryNotFound_returns40023() throws Exception { | |
| 304 | + Integer userId = insertUserWithPerms("sp_test_u_pperm", "原", null, null); | |
| 305 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 306 | + HttpHeaders headers = jsonHeaders(); | |
| 307 | + headers.set("Authorization", "Bearer " + token); | |
| 308 | + Map<String, Object> body = baseBody("sp_test_u_pperm", "改"); | |
| 309 | + body.put("permissionCategoryIds", List.of(99999991)); | |
| 310 | + | |
| 311 | + ResponseEntity<String> resp = rest.exchange( | |
| 312 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 313 | + | |
| 314 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40023); | |
| 315 | + } | |
| 316 | + | |
| 317 | + @Test | |
| 318 | + void putWithEmptyPermissionIds_clearsAssociations() throws Exception { | |
| 319 | + Integer cat1 = insertCategory("sp_test_pc_e1", "权限"); | |
| 320 | + Integer userId = insertUserWithPerms("sp_test_u_pclear", "原", null, cat1); | |
| 321 | + | |
| 322 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 323 | + HttpHeaders headers = jsonHeaders(); | |
| 324 | + headers.set("Authorization", "Bearer " + token); | |
| 325 | + Map<String, Object> body = baseBody("sp_test_u_pclear", "改"); | |
| 326 | + body.remove("permissionCategoryIds"); | |
| 327 | + | |
| 328 | + ResponseEntity<String> resp = rest.exchange( | |
| 329 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 330 | + | |
| 331 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); | |
| 332 | + Integer permCount = jdbcTemplate.queryForObject( | |
| 333 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, userId); | |
| 334 | + assertThat(permCount).isZero(); | |
| 335 | + String passHash = jdbcTemplate.queryForObject( | |
| 336 | + "SELECT sPasswordHash FROM tUser WHERE iIncrement = ?", String.class, userId); | |
| 337 | + assertThat(passHash).startsWith("$2a$"); | |
| 338 | + } | |
| 339 | + | |
| 340 | + @Test | |
| 341 | + void putWithoutJwt_returns20001() throws Exception { | |
| 342 | + Integer userId = insertUserWithPerms("sp_test_u_pnojwt", "原", null, null); | |
| 343 | + HttpHeaders headers = jsonHeaders(); | |
| 344 | + Map<String, Object> body = baseBody("sp_test_u_pnojwt", "改"); | |
| 345 | + | |
| 346 | + ResponseEntity<String> resp = rest.exchange( | |
| 347 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 348 | + | |
| 349 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 350 | + String name = jdbcTemplate.queryForObject( | |
| 351 | + "SELECT sUserName FROM tUser WHERE iIncrement = ?", String.class, userId); | |
| 352 | + assertThat(name).isEqualTo("原"); | |
| 353 | + } | |
| 354 | + | |
| 355 | + @Test | |
| 356 | + void putTamperedJwt_returns20001() throws Exception { | |
| 357 | + Integer userId = insertUserWithPerms("sp_test_u_ptamper", "原", null, null); | |
| 358 | + HttpHeaders headers = jsonHeaders(); | |
| 359 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | |
| 360 | + Map<String, Object> body = baseBody("sp_test_u_ptamper", "改"); | |
| 361 | + | |
| 362 | + ResponseEntity<String> resp = rest.exchange( | |
| 363 | + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); | |
| 364 | + | |
| 365 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 366 | + String name = jdbcTemplate.queryForObject( | |
| 367 | + "SELECT sUserName FROM tUser WHERE iIncrement = ?", String.class, userId); | |
| 368 | + assertThat(name).isEqualTo("原"); | |
| 369 | + } | |
| 370 | + | |
| 371 | + @Test | |
| 372 | + void getDefaults_with_jwt_returns200_andList() throws Exception { | |
| 373 | + insertUserWithPerms("sp_test_lst_1", "查询1", null, null); | |
| 374 | + insertUserWithPerms("sp_test_lst_2", "查询2", null, null); | |
| 375 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 376 | + HttpHeaders headers = jsonHeaders(); | |
| 377 | + headers.set("Authorization", "Bearer " + token); | |
| 378 | + | |
| 379 | + ResponseEntity<String> resp = rest.exchange( | |
| 380 | + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 381 | + | |
| 382 | + JsonNode jb = objectMapper.readTree(resp.getBody()); | |
| 383 | + assertThat(jb.get("code").asInt()).isZero(); | |
| 384 | + assertThat(jb.get("data").get("records").isArray()).isTrue(); | |
| 385 | + assertThat(jb.get("data").get("total").asInt()).isPositive(); | |
| 386 | + assertThat(jb.get("data").get("pageSize").asInt()).isEqualTo(20); | |
| 387 | + } | |
| 388 | + | |
| 389 | + @Test | |
| 390 | + void getKeywordContains_filtersByUsername() throws Exception { | |
| 391 | + insertUserWithPerms("sp_test_lst_kw1", "包含查询用户", null, null); | |
| 392 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 393 | + HttpHeaders headers = jsonHeaders(); | |
| 394 | + headers.set("Authorization", "Bearer " + token); | |
| 395 | + | |
| 396 | + ResponseEntity<String> resp = rest.exchange( | |
| 397 | + listUrl("?field=用户名&match=包含&value=包含查询"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 398 | + | |
| 399 | + JsonNode data = objectMapper.readTree(resp.getBody()).get("data"); | |
| 400 | + for (JsonNode node : data.get("records")) { | |
| 401 | + assertThat(node.get("sUserName").asText()).contains("包含查询"); | |
| 402 | + } | |
| 403 | + } | |
| 404 | + | |
| 405 | + @Test | |
| 406 | + void getKeywordEquals_filtersExact() throws Exception { | |
| 407 | + insertUserWithPerms("sp_test_lst_eq", "等于精确", null, null); | |
| 408 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 409 | + HttpHeaders headers = jsonHeaders(); | |
| 410 | + headers.set("Authorization", "Bearer " + token); | |
| 411 | + | |
| 412 | + ResponseEntity<String> resp = rest.exchange( | |
| 413 | + listUrl("?field=用户号&match=等于&value=sp_test_lst_eq"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 414 | + | |
| 415 | + JsonNode records = objectMapper.readTree(resp.getBody()).get("data").get("records"); | |
| 416 | + assertThat(records.size()).isEqualTo(1); | |
| 417 | + assertThat(records.get(0).get("sUserNo").asText()).isEqualTo("sp_test_lst_eq"); | |
| 418 | + } | |
| 419 | + | |
| 420 | + @Test | |
| 421 | + void getInvalidField_returns40001() throws Exception { | |
| 422 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 423 | + HttpHeaders headers = jsonHeaders(); | |
| 424 | + headers.set("Authorization", "Bearer " + token); | |
| 425 | + | |
| 426 | + ResponseEntity<String> resp = rest.exchange( | |
| 427 | + listUrl("?field=未知"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 428 | + | |
| 429 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); | |
| 430 | + } | |
| 431 | + | |
| 432 | + @Test | |
| 433 | + void getPageSizeExceeds100_returns40002() throws Exception { | |
| 434 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 435 | + HttpHeaders headers = jsonHeaders(); | |
| 436 | + headers.set("Authorization", "Bearer " + token); | |
| 437 | + | |
| 438 | + ResponseEntity<String> resp = rest.exchange( | |
| 439 | + listUrl("?pageSize=200"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 440 | + | |
| 441 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40002); | |
| 442 | + } | |
| 443 | + | |
| 444 | + @Test | |
| 445 | + void getNoMatch_returnsEmptyArray() throws Exception { | |
| 446 | + String token = testJwtHelper.signFor("ADMIN001"); | |
| 447 | + HttpHeaders headers = jsonHeaders(); | |
| 448 | + headers.set("Authorization", "Bearer " + token); | |
| 449 | + | |
| 450 | + ResponseEntity<String> resp = rest.exchange( | |
| 451 | + listUrl("?field=用户号&match=等于&value=不存在的用户号XYZ"), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 452 | + | |
| 453 | + JsonNode data = objectMapper.readTree(resp.getBody()).get("data"); | |
| 454 | + assertThat(data.get("records").isArray()).isTrue(); | |
| 455 | + assertThat(data.get("records").size()).isZero(); | |
| 456 | + assertThat(data.get("total").asInt()).isZero(); | |
| 457 | + } | |
| 458 | + | |
| 459 | + @Test | |
| 460 | + void getWithoutJwt_returns20001() throws Exception { | |
| 461 | + HttpHeaders headers = jsonHeaders(); | |
| 462 | + ResponseEntity<String> resp = rest.exchange( | |
| 463 | + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 464 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 465 | + } | |
| 466 | + | |
| 467 | + @Test | |
| 468 | + void getTamperedJwt_returns20001() throws Exception { | |
| 469 | + HttpHeaders headers = jsonHeaders(); | |
| 470 | + headers.set("Authorization", "Bearer not.a.real.jwt"); | |
| 471 | + ResponseEntity<String> resp = rest.exchange( | |
| 472 | + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |
| 473 | + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); | |
| 474 | + } | |
| 475 | + | |
| 476 | + private String listUrl(String querySuffix) { | |
| 477 | + return "http://localhost:" + port + "/api/usr/users" + querySuffix; | |
| 478 | + } | |
| 479 | + | |
| 480 | + private String idUrl(Integer id) { | |
| 481 | + return "http://localhost:" + port + "/api/usr/users/" + id; | |
| 482 | + } | |
| 483 | + | |
| 484 | + private Integer insertUserWithPerms(String userNo, String userName, Integer staffId, Integer catId) { | |
| 485 | + jdbcTemplate.update( | |
| 486 | + "INSERT INTO tUser (sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, iStaffId, " | |
| 487 | + + "sUserType, sLanguage, bCanModifyDocs, sPasswordHash, sCreatedBy, bDeleted) " | |
| 488 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, ?, '普通用户', 'zh', 0, " | |
| 489 | + + "'$2a$10$origHashOrigHashOrigHashOrigHashOrigHashOrigHashOrig', 'ORIG_CREATOR', 0)", | |
| 490 | + userNo, userName, staffId); | |
| 491 | + Integer userId = jdbcTemplate.queryForObject( | |
| 492 | + "SELECT iIncrement FROM tUser WHERE sUserNo = ?", Integer.class, userNo); | |
| 493 | + if (catId != null) { | |
| 494 | + jdbcTemplate.update( | |
| 495 | + "INSERT INTO tUserPermission (sBrandsId, sSubsidiaryId, tCreateDate, iUserId, iCategoryId, sCreatedBy) " | |
| 496 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'ORIG_CREATOR')", | |
| 497 | + userId, catId); | |
| 498 | + } | |
| 499 | + return userId; | |
| 500 | + } | |
| 501 | + | |
| 502 | + private Integer insertStaff(String staffNo, String name) { | |
| 503 | + jdbcTemplate.update( | |
| 504 | + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sCreatedBy, bDeleted) " | |
| 505 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'STUB_ADMIN', 0)", staffNo, name); | |
| 506 | + return jdbcTemplate.queryForObject( | |
| 507 | + "SELECT iIncrement FROM tStaff WHERE sStaffNo = ?", Integer.class, staffNo); | |
| 508 | + } | |
| 509 | + | |
| 510 | + private Integer insertCategory(String code, String name) { | |
| 511 | + jdbcTemplate.update( | |
| 512 | + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " | |
| 513 | + + "iSortOrder, sCreatedBy, bDeleted) " | |
| 514 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, 0, 'STUB_ADMIN', 0)", code, name); | |
| 515 | + return jdbcTemplate.queryForObject( | |
| 516 | + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = ?", Integer.class, code); | |
| 517 | + } | |
| 518 | + | |
| 519 | + private static Map<String, Object> baseBody(String userNo, String userName) { | |
| 520 | + Map<String, Object> m = new HashMap<>(); | |
| 521 | + m.put("sUserNo", userNo); | |
| 522 | + m.put("sUserName", userName); | |
| 523 | + m.put("sUserType", "普通用户"); | |
| 524 | + m.put("sLanguage", "zh"); | |
| 525 | + m.put("bCanModifyDocs", false); | |
| 526 | + return m; | |
| 527 | + } | |
| 528 | + | |
| 529 | + private static HttpHeaders jsonHeaders() { | |
| 530 | + HttpHeaders h = new HttpHeaders(); | |
| 531 | + h.setContentType(MediaType.APPLICATION_JSON); | |
| 532 | + return h; | |
| 533 | + } | |
| 534 | + | |
| 535 | + private String url() { | |
| 536 | + return "http://localhost:" + port + "/api/usr/users"; | |
| 537 | + } | |
| 538 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.AfterEach; | |
| 4 | +import org.junit.jupiter.api.BeforeEach; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | + | |
| 11 | +import java.util.List; | |
| 12 | + | |
| 13 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 14 | + | |
| 15 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) | |
| 16 | +@ActiveProfiles("test") | |
| 17 | +class PermissionCategoryMapperIT { | |
| 18 | + | |
| 19 | + @Autowired | |
| 20 | + private PermissionCategoryMapper permissionCategoryMapper; | |
| 21 | + | |
| 22 | + @Autowired | |
| 23 | + private JdbcTemplate jdbcTemplate; | |
| 24 | + | |
| 25 | + @BeforeEach | |
| 26 | + @AfterEach | |
| 27 | + void cleanup() { | |
| 28 | + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Test | |
| 32 | + void countActiveByIds_returnsCorrectCount() { | |
| 33 | + Integer cat1 = insertCategory("sp_test_c1", "权限1", false); | |
| 34 | + Integer cat2 = insertCategory("sp_test_c2", "权限2", false); | |
| 35 | + Integer cat3 = insertCategory("sp_test_c3", "权限3", true); | |
| 36 | + | |
| 37 | + assertThat(permissionCategoryMapper.countActiveByIds(List.of(cat1, cat2, cat3))).isEqualTo(2); | |
| 38 | + assertThat(permissionCategoryMapper.countActiveByIds(List.of(cat1, 99999991))).isEqualTo(1); | |
| 39 | + assertThat(permissionCategoryMapper.countActiveByIds(List.of(99999991))).isZero(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + private Integer insertCategory(String code, String name, boolean deleted) { | |
| 43 | + jdbcTemplate.update( | |
| 44 | + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " | |
| 45 | + + "iSortOrder, sCreatedBy, bDeleted) " | |
| 46 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, 0, 'STUB_ADMIN', ?)", | |
| 47 | + code, name, deleted ? 1 : 0); | |
| 48 | + return jdbcTemplate.queryForObject( | |
| 49 | + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = ?", Integer.class, code); | |
| 50 | + } | |
| 51 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.AfterEach; | |
| 4 | +import org.junit.jupiter.api.BeforeEach; | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 7 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 8 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 9 | +import org.springframework.test.context.ActiveProfiles; | |
| 10 | + | |
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 12 | + | |
| 13 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) | |
| 14 | +@ActiveProfiles("test") | |
| 15 | +class StaffMapperIT { | |
| 16 | + | |
| 17 | + @Autowired | |
| 18 | + private StaffMapper staffMapper; | |
| 19 | + | |
| 20 | + @Autowired | |
| 21 | + private JdbcTemplate jdbcTemplate; | |
| 22 | + | |
| 23 | + @BeforeEach | |
| 24 | + @AfterEach | |
| 25 | + void cleanup() { | |
| 26 | + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo LIKE 'sp_test_%'"); | |
| 27 | + } | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + void existsActiveById_handlesAliveDeletedMissing() { | |
| 31 | + Integer aliveId = insertStaff("sp_test_st_alive", "活的", false); | |
| 32 | + Integer deletedId = insertStaff("sp_test_st_dead", "死的", true); | |
| 33 | + | |
| 34 | + assertThat(staffMapper.existsActiveById(aliveId)).isTrue(); | |
| 35 | + assertThat(staffMapper.existsActiveById(deletedId)).isFalse(); | |
| 36 | + assertThat(staffMapper.existsActiveById(99999990)).isFalse(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + private Integer insertStaff(String staffNo, String staffName, boolean deleted) { | |
| 40 | + jdbcTemplate.update( | |
| 41 | + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sCreatedBy, bDeleted) " | |
| 42 | + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'STUB_ADMIN', ?)", | |
| 43 | + staffNo, staffName, deleted ? 1 : 0); | |
| 44 | + return jdbcTemplate.queryForObject( | |
| 45 | + "SELECT iIncrement FROM tStaff WHERE sStaffNo = ?", Integer.class, staffNo); | |
| 46 | + } | |
| 47 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.mapper; | |
| 2 | + | |
| 3 | +import com.xly.erp.module.usr.entity.User; | |
| 4 | +import com.xly.erp.module.usr.entity.UserPermission; | |
| 5 | +import org.junit.jupiter.api.AfterEach; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 10 | +import org.springframework.dao.DuplicateKeyException; | |
| 11 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 12 | +import org.springframework.test.context.ActiveProfiles; | |
| 13 | + | |
| 14 | +import java.time.LocalDateTime; | |
| 15 | + | |
| 16 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 17 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 18 | + | |
| 19 | +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) | |
| 20 | +@ActiveProfiles("test") | |
| 21 | +class UserMapperIT { | |
| 22 | + | |
| 23 | + @Autowired | |
| 24 | + private UserMapper userMapper; | |
| 25 | + | |
| 26 | + @Autowired | |
| 27 | + private UserPermissionMapper userPermissionMapper; | |
| 28 | + | |
| 29 | + @Autowired | |
| 30 | + private JdbcTemplate jdbcTemplate; | |
| 31 | + | |
| 32 | + @BeforeEach | |
| 33 | + @AfterEach | |
| 34 | + void cleanup() { | |
| 35 | + jdbcTemplate.update( | |
| 36 | + "DELETE FROM tUserPermission WHERE iUserId IN " | |
| 37 | + + "(SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')"); | |
| 38 | + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); | |
| 39 | + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + void insertAndSelectById_persistsAllStandardCols() { | |
| 44 | + User u = newUser("sp_test_u1", "用户1"); | |
| 45 | + int rows = userMapper.insert(u); | |
| 46 | + assertThat(rows).isEqualTo(1); | |
| 47 | + assertThat(u.getIIncrement()).isNotNull(); | |
| 48 | + | |
| 49 | + User loaded = userMapper.selectById(u.getIIncrement()); | |
| 50 | + assertThat(loaded.getSUserNo()).isEqualTo("sp_test_u1"); | |
| 51 | + assertThat(loaded.getSUserName()).isEqualTo("用户1"); | |
| 52 | + assertThat(loaded.getSBrandsId()).isEqualTo("XLY"); | |
| 53 | + assertThat(loaded.getSPasswordHash()).startsWith("$2a$"); | |
| 54 | + assertThat(loaded.getBDeleted()).isFalse(); | |
| 55 | + assertThat(loaded.getSUserType()).isEqualTo("普通用户"); | |
| 56 | + } | |
| 57 | + | |
| 58 | + @Test | |
| 59 | + void uniqueUserNoConstraint_rejectsDuplicate() { | |
| 60 | + userMapper.insert(newUser("sp_test_dup", "首次")); | |
| 61 | + User second = newUser("sp_test_dup", "第二次"); | |
| 62 | + second.setSUserName("sp_test_other_name"); | |
| 63 | + assertThatThrownBy(() -> userMapper.insert(second)) | |
| 64 | + .isInstanceOf(DuplicateKeyException.class); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void userPermissionInsert_persistsRowWithUserAndCategory() { | |
| 69 | + User u = newUser("sp_test_perm_user", "权限关联用户"); | |
| 70 | + userMapper.insert(u); | |
| 71 | + | |
| 72 | + jdbcTemplate.update( | |
| 73 | + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " | |
| 74 | + + "iSortOrder, sCreatedBy, bDeleted) " | |
| 75 | + + "VALUES ('XLY','XLY', NOW(), 'sp_test_pc', '权限分类1', 0, 'STUB_ADMIN', 0)"); | |
| 76 | + Integer catId = jdbcTemplate.queryForObject( | |
| 77 | + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_pc'", Integer.class); | |
| 78 | + | |
| 79 | + UserPermission rel = new UserPermission(); | |
| 80 | + rel.setSBrandsId("XLY"); | |
| 81 | + rel.setSSubsidiaryId("XLY"); | |
| 82 | + rel.setTCreateDate(LocalDateTime.now()); | |
| 83 | + rel.setIUserId(u.getIIncrement()); | |
| 84 | + rel.setICategoryId(catId); | |
| 85 | + rel.setSCreatedBy("STUB_ADMIN"); | |
| 86 | + int rows = userPermissionMapper.insert(rel); | |
| 87 | + assertThat(rows).isEqualTo(1); | |
| 88 | + assertThat(rel.getIIncrement()).isNotNull(); | |
| 89 | + | |
| 90 | + Integer count = jdbcTemplate.queryForObject( | |
| 91 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ? AND iCategoryId = ?", | |
| 92 | + Integer.class, u.getIIncrement(), catId); | |
| 93 | + assertThat(count).isEqualTo(1); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + void selectByUserName_returnsRowOrNull() { | |
| 98 | + User u = newUser("sp_test_byname", "命名用户"); | |
| 99 | + userMapper.insert(u); | |
| 100 | + | |
| 101 | + User loaded = userMapper.selectByUserName("命名用户"); | |
| 102 | + assertThat(loaded).isNotNull(); | |
| 103 | + assertThat(loaded.getSUserNo()).isEqualTo("sp_test_byname"); | |
| 104 | + | |
| 105 | + assertThat(userMapper.selectByUserName("不存在的用户名XYZ")).isNull(); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Test | |
| 109 | + void updateLastLoginDate_setsValue() { | |
| 110 | + User u = newUser("sp_test_lastlogin", "登录日期用户"); | |
| 111 | + userMapper.insert(u); | |
| 112 | + java.time.LocalDateTime ts = java.time.LocalDateTime.of(2026, 4, 30, 10, 0, 0); | |
| 113 | + | |
| 114 | + int rows = userMapper.updateLastLoginDate(u.getIIncrement(), ts); | |
| 115 | + assertThat(rows).isEqualTo(1); | |
| 116 | + | |
| 117 | + java.sql.Timestamp loaded = jdbcTemplate.queryForObject( | |
| 118 | + "SELECT tLastLoginDate FROM tUser WHERE iIncrement = ?", java.sql.Timestamp.class, u.getIIncrement()); | |
| 119 | + assertThat(loaded).isNotNull(); | |
| 120 | + assertThat(loaded.toLocalDateTime()).isEqualTo(ts); | |
| 121 | + } | |
| 122 | + | |
| 123 | + @Test | |
| 124 | + void pageWithFilter_filtersAndJoins() { | |
| 125 | + jdbcTemplate.update( | |
| 126 | + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sDepartment, " | |
| 127 | + + "sCreatedBy, bDeleted) VALUES ('XLY','XLY', NOW(), 'sp_test_lst_st', '员工X', '研发', 'STUB', 0)"); | |
| 128 | + Integer staffId = jdbcTemplate.queryForObject( | |
| 129 | + "SELECT iIncrement FROM tStaff WHERE sStaffNo = 'sp_test_lst_st'", Integer.class); | |
| 130 | + | |
| 131 | + User u1 = newUser("sp_test_lst_u1", "查询用户1"); | |
| 132 | + u1.setIStaffId(staffId); | |
| 133 | + userMapper.insert(u1); | |
| 134 | + User u2 = newUser("sp_test_lst_u2", "查询用户2"); | |
| 135 | + userMapper.insert(u2); | |
| 136 | + | |
| 137 | + java.util.List<com.xly.erp.module.usr.vo.UserListVO> result = userMapper.pageWithFilter( | |
| 138 | + "u.sUserNo", "contains", "sp_test_lst_", 0, 10); | |
| 139 | + | |
| 140 | + assertThat(result).extracting(com.xly.erp.module.usr.vo.UserListVO::getSUserNo) | |
| 141 | + .contains("sp_test_lst_u1", "sp_test_lst_u2"); | |
| 142 | + com.xly.erp.module.usr.vo.UserListVO row1 = result.stream() | |
| 143 | + .filter(v -> "sp_test_lst_u1".equals(v.getSUserNo())).findFirst().orElseThrow(); | |
| 144 | + assertThat(row1.getStaffName()).isEqualTo("员工X"); | |
| 145 | + assertThat(row1.getDepartment()).isEqualTo("研发"); | |
| 146 | + com.xly.erp.module.usr.vo.UserListVO row2 = result.stream() | |
| 147 | + .filter(v -> "sp_test_lst_u2".equals(v.getSUserNo())).findFirst().orElseThrow(); | |
| 148 | + assertThat(row2.getStaffName()).isNull(); | |
| 149 | + | |
| 150 | + long total = userMapper.countWithFilter("u.sUserNo", "contains", "sp_test_lst_"); | |
| 151 | + assertThat(total).isGreaterThanOrEqualTo(2); | |
| 152 | + | |
| 153 | + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo = 'sp_test_lst_st'"); | |
| 154 | + } | |
| 155 | + | |
| 156 | + @Test | |
| 157 | + void userPermissionMapper_deleteByUserId_removesAllRowsForGivenUser() { | |
| 158 | + User user1 = newUser("sp_test_del_u1", "用户1"); | |
| 159 | + userMapper.insert(user1); | |
| 160 | + User user2 = newUser("sp_test_del_u2", "用户2"); | |
| 161 | + userMapper.insert(user2); | |
| 162 | + | |
| 163 | + jdbcTemplate.update( | |
| 164 | + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " | |
| 165 | + + "iSortOrder, sCreatedBy, bDeleted) VALUES " | |
| 166 | + + "('XLY','XLY', NOW(), 'sp_test_del_c1', '权限C1', 0, 'STUB', 0)," | |
| 167 | + + "('XLY','XLY', NOW(), 'sp_test_del_c2', '权限C2', 0, 'STUB', 0)"); | |
| 168 | + Integer cat1 = jdbcTemplate.queryForObject( | |
| 169 | + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_del_c1'", Integer.class); | |
| 170 | + Integer cat2 = jdbcTemplate.queryForObject( | |
| 171 | + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_del_c2'", Integer.class); | |
| 172 | + | |
| 173 | + insertUserPermission(user1.getIIncrement(), cat1); | |
| 174 | + insertUserPermission(user1.getIIncrement(), cat2); | |
| 175 | + insertUserPermission(user2.getIIncrement(), cat1); | |
| 176 | + | |
| 177 | + int affected = userPermissionMapper.deleteByUserId(user1.getIIncrement()); | |
| 178 | + assertThat(affected).isEqualTo(2); | |
| 179 | + | |
| 180 | + Integer u1Count = jdbcTemplate.queryForObject( | |
| 181 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, user1.getIIncrement()); | |
| 182 | + Integer u2Count = jdbcTemplate.queryForObject( | |
| 183 | + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, user2.getIIncrement()); | |
| 184 | + assertThat(u1Count).isZero(); | |
| 185 | + assertThat(u2Count).isEqualTo(1); | |
| 186 | + } | |
| 187 | + | |
| 188 | + private void insertUserPermission(Integer userId, Integer catId) { | |
| 189 | + UserPermission rel = new UserPermission(); | |
| 190 | + rel.setSBrandsId("XLY"); | |
| 191 | + rel.setSSubsidiaryId("XLY"); | |
| 192 | + rel.setTCreateDate(LocalDateTime.now()); | |
| 193 | + rel.setIUserId(userId); | |
| 194 | + rel.setICategoryId(catId); | |
| 195 | + rel.setSCreatedBy("STUB_ADMIN"); | |
| 196 | + userPermissionMapper.insert(rel); | |
| 197 | + } | |
| 198 | + | |
| 199 | + private User newUser(String userNo, String userName) { | |
| 200 | + User u = new User(); | |
| 201 | + u.setSBrandsId("XLY"); | |
| 202 | + u.setSSubsidiaryId("XLY"); | |
| 203 | + u.setTCreateDate(LocalDateTime.now()); | |
| 204 | + u.setSUserNo(userNo); | |
| 205 | + u.setSUserName(userName); | |
| 206 | + u.setSUserType("普通用户"); | |
| 207 | + u.setSLanguage("zh"); | |
| 208 | + u.setBCanModifyDocs(false); | |
| 209 | + u.setSPasswordHash("$2a$10$stubhashstubhashstubhashstubhashstubhashstubhashstubhash"); | |
| 210 | + u.setSCreatedBy("STUB_ADMIN"); | |
| 211 | + u.setBDeleted(false); | |
| 212 | + return u; | |
| 213 | + } | |
| 214 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.security; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | + | |
| 5 | +import java.time.Clock; | |
| 6 | +import java.time.Instant; | |
| 7 | +import java.time.ZoneId; | |
| 8 | + | |
| 9 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 10 | + | |
| 11 | +class LoginAttemptStoreTest { | |
| 12 | + | |
| 13 | + @Test | |
| 14 | + void recordFailure_incrementsCount_andDoesNotLockBeforeMax() { | |
| 15 | + LoginAttemptStore store = new LoginAttemptStore(); | |
| 16 | + for (int i = 1; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 17 | + int c = store.recordFailure("u1"); | |
| 18 | + assertThat(c).isEqualTo(i); | |
| 19 | + } | |
| 20 | + assertThat(store.isLocked("u1")).isEmpty(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + @Test | |
| 24 | + void recordFailure_locksAtMaxAttempts() { | |
| 25 | + LoginAttemptStore store = new LoginAttemptStore(); | |
| 26 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 27 | + store.recordFailure("u1"); | |
| 28 | + } | |
| 29 | + assertThat(store.isLocked("u1")).isPresent().get().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LONG) | |
| 30 | + .isPositive(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + void differentUserNames_areIsolated() { | |
| 35 | + LoginAttemptStore store = new LoginAttemptStore(); | |
| 36 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 37 | + store.recordFailure("u1"); | |
| 38 | + } | |
| 39 | + assertThat(store.isLocked("u1")).isPresent(); | |
| 40 | + assertThat(store.isLocked("u2")).isEmpty(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + void lockExpiresAfterDuration() { | |
| 45 | + Clock fixed = Clock.fixed(Instant.parse("2026-04-30T09:00:00Z"), ZoneId.of("UTC")); | |
| 46 | + LoginAttemptStore store = new LoginAttemptStore(fixed); | |
| 47 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 48 | + store.recordFailure("u1"); | |
| 49 | + } | |
| 50 | + assertThat(store.isLocked("u1")).isPresent(); | |
| 51 | + | |
| 52 | + Clock later = Clock.fixed(Instant.parse("2026-04-30T09:20:00Z"), ZoneId.of("UTC")); | |
| 53 | + LoginAttemptStore store2 = new LoginAttemptStore(later); | |
| 54 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 55 | + store2.recordFailure("u1"); | |
| 56 | + } | |
| 57 | + // Setup new store at later time and verify a fresh user with no records is unlocked | |
| 58 | + assertThat(new LoginAttemptStore(later).isLocked("u_fresh")).isEmpty(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 62 | + void clearFailures_resetsCounter() { | |
| 63 | + LoginAttemptStore store = new LoginAttemptStore(); | |
| 64 | + store.recordFailure("u1"); | |
| 65 | + store.recordFailure("u1"); | |
| 66 | + store.clearFailures("u1"); | |
| 67 | + assertThat(store.recordFailure("u1")).isEqualTo(1); | |
| 68 | + } | |
| 69 | +} | ... | ... |
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.module.usr.service; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.config.StubSecurityProperties; | |
| 4 | +import com.xly.erp.common.config.TenantProperties; | |
| 5 | +import com.xly.erp.common.exception.BizException; | |
| 6 | +import com.xly.erp.common.security.JwtUtil; | |
| 7 | +import com.xly.erp.module.usr.dto.CreateUserDTO; | |
| 8 | +import com.xly.erp.module.usr.dto.LoginDTO; | |
| 9 | +import com.xly.erp.module.usr.dto.UpdateUserDTO; | |
| 10 | +import com.xly.erp.module.usr.entity.User; | |
| 11 | +import com.xly.erp.module.usr.security.LoginAttemptStore; | |
| 12 | +import com.xly.erp.module.usr.vo.LoginVO; | |
| 13 | +import com.xly.erp.module.usr.entity.UserPermission; | |
| 14 | +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; | |
| 15 | +import com.xly.erp.module.usr.mapper.StaffMapper; | |
| 16 | +import com.xly.erp.module.usr.mapper.UserMapper; | |
| 17 | +import com.xly.erp.module.usr.mapper.UserPermissionMapper; | |
| 18 | +import com.xly.erp.module.usr.service.impl.UserServiceImpl; | |
| 19 | +import org.junit.jupiter.api.AfterEach; | |
| 20 | +import org.junit.jupiter.api.BeforeEach; | |
| 21 | +import org.junit.jupiter.api.Test; | |
| 22 | +import org.mockito.ArgumentCaptor; | |
| 23 | +import org.springframework.dao.DuplicateKeyException; | |
| 24 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
| 25 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 26 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 27 | + | |
| 28 | +import java.util.Collections; | |
| 29 | +import java.util.List; | |
| 30 | +import java.util.Map; | |
| 31 | + | |
| 32 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 33 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 34 | +import static org.mockito.ArgumentMatchers.any; | |
| 35 | +import static org.mockito.ArgumentMatchers.anyInt; | |
| 36 | +import static org.mockito.ArgumentMatchers.anyList; | |
| 37 | +import static org.mockito.ArgumentMatchers.eq; | |
| 38 | +import static org.mockito.Mockito.lenient; | |
| 39 | +import static org.mockito.Mockito.mock; | |
| 40 | +import static org.mockito.Mockito.never; | |
| 41 | +import static org.mockito.Mockito.times; | |
| 42 | +import static org.mockito.Mockito.verify; | |
| 43 | +import static org.mockito.Mockito.when; | |
| 44 | + | |
| 45 | +class UserServiceImplTest { | |
| 46 | + | |
| 47 | + private UserMapper userMapper; | |
| 48 | + private UserPermissionMapper userPermissionMapper; | |
| 49 | + private StaffMapper staffMapper; | |
| 50 | + private PermissionCategoryMapper permissionCategoryMapper; | |
| 51 | + private BCryptPasswordEncoder encoder; | |
| 52 | + private JwtUtil jwtUtil; | |
| 53 | + private LoginAttemptStore loginAttemptStore; | |
| 54 | + private UserServiceImpl service; | |
| 55 | + | |
| 56 | + @BeforeEach | |
| 57 | + void setUp() { | |
| 58 | + userMapper = mock(UserMapper.class); | |
| 59 | + userPermissionMapper = mock(UserPermissionMapper.class); | |
| 60 | + staffMapper = mock(StaffMapper.class); | |
| 61 | + permissionCategoryMapper = mock(PermissionCategoryMapper.class); | |
| 62 | + encoder = new BCryptPasswordEncoder(); | |
| 63 | + jwtUtil = new JwtUtil("f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"); | |
| 64 | + loginAttemptStore = new LoginAttemptStore(); | |
| 65 | + TenantProperties tenant = new TenantProperties(); | |
| 66 | + tenant.setBrandsId("XLY"); | |
| 67 | + tenant.setSubsidiaryId("XLY"); | |
| 68 | + StubSecurityProperties stub = new StubSecurityProperties(); | |
| 69 | + stub.setStubUserNo("STUB_ADMIN"); | |
| 70 | + | |
| 71 | + service = new UserServiceImpl(userMapper, userPermissionMapper, staffMapper, | |
| 72 | + permissionCategoryMapper, tenant, stub, encoder, jwtUtil, loginAttemptStore); | |
| 73 | + | |
| 74 | + lenient().when(userMapper.insert(any(User.class))).thenAnswer(inv -> { | |
| 75 | + User u = inv.getArgument(0); | |
| 76 | + u.setIIncrement(456); | |
| 77 | + return 1; | |
| 78 | + }); | |
| 79 | + lenient().when(userPermissionMapper.insert(any(UserPermission.class))).thenReturn(1); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @AfterEach | |
| 83 | + void clearContext() { | |
| 84 | + SecurityContextHolder.clearContext(); | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Test | |
| 88 | + void createWithValidDto_persistsUser_andUserPermissions() { | |
| 89 | + when(staffMapper.existsActiveById(7)).thenReturn(true); | |
| 90 | + when(permissionCategoryMapper.countActiveByIds(List.of(11, 22))).thenReturn(2); | |
| 91 | + | |
| 92 | + CreateUserDTO dto = baseDto(); | |
| 93 | + dto.setIStaffId(7); | |
| 94 | + dto.setPermissionCategoryIds(List.of(11, 22)); | |
| 95 | + | |
| 96 | + Map<String, Object> result = service.create(dto); | |
| 97 | + | |
| 98 | + assertThat(result).containsEntry("iIncrement", 456).containsEntry("sUserNo", "u001"); | |
| 99 | + ArgumentCaptor<User> userCap = ArgumentCaptor.forClass(User.class); | |
| 100 | + verify(userMapper).insert(userCap.capture()); | |
| 101 | + User saved = userCap.getValue(); | |
| 102 | + assertThat(saved.getSBrandsId()).isEqualTo("XLY"); | |
| 103 | + assertThat(saved.getSCreatedBy()).isNull(); | |
| 104 | + assertThat(saved.getTCreateDate()).isNotNull(); | |
| 105 | + assertThat(saved.getSPasswordHash()).startsWith("$2a$"); | |
| 106 | + assertThat(saved.getBDeleted()).isFalse(); | |
| 107 | + assertThat(saved.getIStaffId()).isEqualTo(7); | |
| 108 | + assertThat(saved.getBCanModifyDocs()).isFalse(); | |
| 109 | + | |
| 110 | + ArgumentCaptor<UserPermission> permCap = ArgumentCaptor.forClass(UserPermission.class); | |
| 111 | + verify(userPermissionMapper, times(2)).insert(permCap.capture()); | |
| 112 | + List<UserPermission> rels = permCap.getAllValues(); | |
| 113 | + assertThat(rels).extracting(UserPermission::getICategoryId).containsExactly(11, 22); | |
| 114 | + assertThat(rels).allMatch(r -> r.getIUserId().equals(456)); | |
| 115 | + assertThat(rels).allMatch(r -> r.getSBrandsId().equals("XLY")); | |
| 116 | + } | |
| 117 | + | |
| 118 | + @Test | |
| 119 | + void createWithoutPermissionCategoryIds_skipsUserPermissionInserts() { | |
| 120 | + CreateUserDTO dto = baseDto(); | |
| 121 | + dto.setPermissionCategoryIds(null); | |
| 122 | + | |
| 123 | + service.create(dto); | |
| 124 | + | |
| 125 | + verify(userMapper, times(1)).insert(any(User.class)); | |
| 126 | + verify(userPermissionMapper, never()).insert(any(UserPermission.class)); | |
| 127 | + } | |
| 128 | + | |
| 129 | + @Test | |
| 130 | + void createWithInvalidUserType_throws40001() { | |
| 131 | + CreateUserDTO dto = baseDto(); | |
| 132 | + dto.setSUserType("火星"); | |
| 133 | + assertThatThrownBy(() -> service.create(dto)) | |
| 134 | + .isInstanceOf(BizException.class) | |
| 135 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 136 | + verify(userMapper, never()).insert(any(User.class)); | |
| 137 | + } | |
| 138 | + | |
| 139 | + @Test | |
| 140 | + void createWithInvalidLanguage_throws40001() { | |
| 141 | + CreateUserDTO dto = baseDto(); | |
| 142 | + dto.setSLanguage("ja"); | |
| 143 | + assertThatThrownBy(() -> service.create(dto)) | |
| 144 | + .isInstanceOf(BizException.class) | |
| 145 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 146 | + verify(userMapper, never()).insert(any(User.class)); | |
| 147 | + } | |
| 148 | + | |
| 149 | + @Test | |
| 150 | + void createWithStaffNotFound_throws40022() { | |
| 151 | + when(staffMapper.existsActiveById(99)).thenReturn(false); | |
| 152 | + CreateUserDTO dto = baseDto(); | |
| 153 | + dto.setIStaffId(99); | |
| 154 | + assertThatThrownBy(() -> service.create(dto)) | |
| 155 | + .isInstanceOf(BizException.class) | |
| 156 | + .hasFieldOrPropertyWithValue("code", 40022); | |
| 157 | + verify(userMapper, never()).insert(any(User.class)); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test | |
| 161 | + void createWithSomeInvalidPermissionIds_throws40023() { | |
| 162 | + when(permissionCategoryMapper.countActiveByIds(List.of(1, 2, 3))).thenReturn(2); | |
| 163 | + CreateUserDTO dto = baseDto(); | |
| 164 | + dto.setPermissionCategoryIds(List.of(1, 2, 3)); | |
| 165 | + assertThatThrownBy(() -> service.create(dto)) | |
| 166 | + .isInstanceOf(BizException.class) | |
| 167 | + .hasFieldOrPropertyWithValue("code", 40023); | |
| 168 | + verify(userMapper, never()).insert(any(User.class)); | |
| 169 | + } | |
| 170 | + | |
| 171 | + @Test | |
| 172 | + void createWithDuplicateUserNo_throws40020() { | |
| 173 | + when(userMapper.insert(any(User.class))) | |
| 174 | + .thenThrow(new DuplicateKeyException("uk_user_no")); | |
| 175 | + CreateUserDTO dto = baseDto(); | |
| 176 | + assertThatThrownBy(() -> service.create(dto)) | |
| 177 | + .isInstanceOf(BizException.class) | |
| 178 | + .hasFieldOrPropertyWithValue("code", 40020); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Test | |
| 182 | + void createUsesAuthenticatedUserNoAsCreatedBy() { | |
| 183 | + SecurityContextHolder.getContext().setAuthentication( | |
| 184 | + new UsernamePasswordAuthenticationToken("ALICE", null, Collections.emptyList())); | |
| 185 | + CreateUserDTO dto = baseDto(); | |
| 186 | + | |
| 187 | + service.create(dto); | |
| 188 | + | |
| 189 | + ArgumentCaptor<User> cap = ArgumentCaptor.forClass(User.class); | |
| 190 | + verify(userMapper).insert(cap.capture()); | |
| 191 | + assertThat(cap.getValue().getSCreatedBy()).isEqualTo("ALICE"); | |
| 192 | + } | |
| 193 | + | |
| 194 | + @Test | |
| 195 | + void updateWithValidDto_invokesUpdateById_andRebuildsPermissions() { | |
| 196 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 197 | + when(staffMapper.existsActiveById(7)).thenReturn(true); | |
| 198 | + when(permissionCategoryMapper.countActiveByIds(List.of(11, 22))).thenReturn(2); | |
| 199 | + when(userMapper.updateById(any(User.class))).thenReturn(1); | |
| 200 | + when(userPermissionMapper.deleteByUserId(10)).thenReturn(3); | |
| 201 | + | |
| 202 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 203 | + dto.setIStaffId(7); | |
| 204 | + dto.setPermissionCategoryIds(List.of(11, 22)); | |
| 205 | + | |
| 206 | + Integer result = service.update(10, dto); | |
| 207 | + | |
| 208 | + assertThat(result).isEqualTo(10); | |
| 209 | + ArgumentCaptor<User> uc = ArgumentCaptor.forClass(User.class); | |
| 210 | + verify(userMapper).updateById(uc.capture()); | |
| 211 | + User passed = uc.getValue(); | |
| 212 | + assertThat(passed.getIIncrement()).isEqualTo(10); | |
| 213 | + assertThat(passed.getSUserNo()).isEqualTo("u_new"); | |
| 214 | + assertThat(passed.getSPasswordHash()).isNull(); | |
| 215 | + assertThat(passed.getSCreatedBy()).isNull(); | |
| 216 | + assertThat(passed.getTCreateDate()).isNull(); | |
| 217 | + assertThat(passed.getSBrandsId()).isNull(); | |
| 218 | + | |
| 219 | + verify(userPermissionMapper, times(1)).deleteByUserId(10); | |
| 220 | + verify(userPermissionMapper, times(2)).insert(any(UserPermission.class)); | |
| 221 | + } | |
| 222 | + | |
| 223 | + @Test | |
| 224 | + void updateWithTargetNotFound_throws40400() { | |
| 225 | + when(userMapper.selectById(99)).thenReturn(null); | |
| 226 | + assertThatThrownBy(() -> service.update(99, baseUpdateDto())) | |
| 227 | + .isInstanceOf(BizException.class) | |
| 228 | + .hasFieldOrPropertyWithValue("code", 40400); | |
| 229 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 230 | + } | |
| 231 | + | |
| 232 | + @Test | |
| 233 | + void updateWithTargetAlreadyDeleted_throws40400() { | |
| 234 | + User deleted = stubExistingUser(10); | |
| 235 | + deleted.setBDeleted(true); | |
| 236 | + when(userMapper.selectById(10)).thenReturn(deleted); | |
| 237 | + assertThatThrownBy(() -> service.update(10, baseUpdateDto())) | |
| 238 | + .isInstanceOf(BizException.class) | |
| 239 | + .hasFieldOrPropertyWithValue("code", 40400); | |
| 240 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 241 | + } | |
| 242 | + | |
| 243 | + @Test | |
| 244 | + void updateWithBCanModifyDocsNull_setsFalseInEntity() { | |
| 245 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 246 | + when(userMapper.updateById(any(User.class))).thenReturn(1); | |
| 247 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 248 | + dto.setBCanModifyDocs(null); | |
| 249 | + | |
| 250 | + service.update(10, dto); | |
| 251 | + | |
| 252 | + ArgumentCaptor<User> uc = ArgumentCaptor.forClass(User.class); | |
| 253 | + verify(userMapper).updateById(uc.capture()); | |
| 254 | + assertThat(uc.getValue().getBCanModifyDocs()).isFalse(); | |
| 255 | + } | |
| 256 | + | |
| 257 | + @Test | |
| 258 | + void updateWithInvalidUserType_throws40001() { | |
| 259 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 260 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 261 | + dto.setSUserType("火星"); | |
| 262 | + assertThatThrownBy(() -> service.update(10, dto)) | |
| 263 | + .isInstanceOf(BizException.class) | |
| 264 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 265 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 266 | + } | |
| 267 | + | |
| 268 | + @Test | |
| 269 | + void updateWithInvalidLanguage_throws40001() { | |
| 270 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 271 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 272 | + dto.setSLanguage("ja"); | |
| 273 | + assertThatThrownBy(() -> service.update(10, dto)) | |
| 274 | + .isInstanceOf(BizException.class) | |
| 275 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 276 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 277 | + } | |
| 278 | + | |
| 279 | + @Test | |
| 280 | + void updateWithStaffNotFound_throws40022() { | |
| 281 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 282 | + when(staffMapper.existsActiveById(99)).thenReturn(false); | |
| 283 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 284 | + dto.setIStaffId(99); | |
| 285 | + assertThatThrownBy(() -> service.update(10, dto)) | |
| 286 | + .isInstanceOf(BizException.class) | |
| 287 | + .hasFieldOrPropertyWithValue("code", 40022); | |
| 288 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 289 | + } | |
| 290 | + | |
| 291 | + @Test | |
| 292 | + void updateWithSomeInvalidPermissionIds_throws40023() { | |
| 293 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 294 | + when(permissionCategoryMapper.countActiveByIds(List.of(1, 2, 3))).thenReturn(2); | |
| 295 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 296 | + dto.setPermissionCategoryIds(List.of(1, 2, 3)); | |
| 297 | + assertThatThrownBy(() -> service.update(10, dto)) | |
| 298 | + .isInstanceOf(BizException.class) | |
| 299 | + .hasFieldOrPropertyWithValue("code", 40023); | |
| 300 | + verify(userMapper, never()).updateById(any(User.class)); | |
| 301 | + } | |
| 302 | + | |
| 303 | + @Test | |
| 304 | + void updateWithDuplicateUserNo_throws40020() { | |
| 305 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 306 | + when(userMapper.updateById(any(User.class))) | |
| 307 | + .thenThrow(new DuplicateKeyException("uk_user_no")); | |
| 308 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 309 | + assertThatThrownBy(() -> service.update(10, dto)) | |
| 310 | + .isInstanceOf(BizException.class) | |
| 311 | + .hasFieldOrPropertyWithValue("code", 40020); | |
| 312 | + verify(userPermissionMapper, never()).deleteByUserId(any()); | |
| 313 | + } | |
| 314 | + | |
| 315 | + @Test | |
| 316 | + void updateWithEmptyPermissionIds_clearsExisting() { | |
| 317 | + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); | |
| 318 | + when(userMapper.updateById(any(User.class))).thenReturn(1); | |
| 319 | + when(userPermissionMapper.deleteByUserId(10)).thenReturn(2); | |
| 320 | + UpdateUserDTO dto = baseUpdateDto(); | |
| 321 | + dto.setPermissionCategoryIds(null); | |
| 322 | + | |
| 323 | + service.update(10, dto); | |
| 324 | + | |
| 325 | + verify(userPermissionMapper, times(1)).deleteByUserId(10); | |
| 326 | + verify(userPermissionMapper, never()).insert(any(UserPermission.class)); | |
| 327 | + } | |
| 328 | + | |
| 329 | + @Test | |
| 330 | + void listWithDefaults_invokesMapperWithUserNameContainsEmpty() { | |
| 331 | + when(userMapper.pageWithFilter(eq("u.sUserName"), eq("contains"), eq(""), eq(0), eq(20))) | |
| 332 | + .thenReturn(List.of()); | |
| 333 | + when(userMapper.countWithFilter(eq("u.sUserName"), eq("contains"), eq(""))).thenReturn(0L); | |
| 334 | + | |
| 335 | + Map<String, Object> r = service.list(null, null, null, null, null); | |
| 336 | + | |
| 337 | + assertThat(r).containsEntry("total", 0L).containsEntry("pageNum", 1).containsEntry("pageSize", 20); | |
| 338 | + } | |
| 339 | + | |
| 340 | + @Test | |
| 341 | + void listWithEmptyValue_skipsFilterCondition() { | |
| 342 | + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); | |
| 343 | + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); | |
| 344 | + | |
| 345 | + service.list("用户号", "等于", "", 1, 20); | |
| 346 | + | |
| 347 | + ArgumentCaptor<Object> valueCap = ArgumentCaptor.forClass(Object.class); | |
| 348 | + verify(userMapper).pageWithFilter(eq("u.sUserNo"), eq("equals"), valueCap.capture(), eq(0), eq(20)); | |
| 349 | + assertThat(valueCap.getValue()).isEqualTo(""); | |
| 350 | + } | |
| 351 | + | |
| 352 | + @Test | |
| 353 | + void listWithKeywordTrim() { | |
| 354 | + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); | |
| 355 | + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); | |
| 356 | + | |
| 357 | + service.list("用户名", "包含", " abc ", 1, 10); | |
| 358 | + | |
| 359 | + verify(userMapper).pageWithFilter(eq("u.sUserName"), eq("contains"), eq("abc"), eq(0), eq(10)); | |
| 360 | + } | |
| 361 | + | |
| 362 | + @Test | |
| 363 | + void listReturnsEmptyRecords_whenMapperReturnsEmptyPage() { | |
| 364 | + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); | |
| 365 | + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); | |
| 366 | + | |
| 367 | + Map<String, Object> r = service.list("用户名", "包含", "xyz", 1, 20); | |
| 368 | + | |
| 369 | + assertThat((List<?>) r.get("records")).isEmpty(); | |
| 370 | + assertThat(r).containsEntry("total", 0L); | |
| 371 | + } | |
| 372 | + | |
| 373 | + @Test | |
| 374 | + void listWithInvalidField_throws40001() { | |
| 375 | + assertThatThrownBy(() -> service.list("未知字段", "包含", "x", 1, 20)) | |
| 376 | + .isInstanceOf(BizException.class) | |
| 377 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 378 | + } | |
| 379 | + | |
| 380 | + @Test | |
| 381 | + void listWithInvalidMatch_throws40001() { | |
| 382 | + assertThatThrownBy(() -> service.list("用户名", "未知方式", "x", 1, 20)) | |
| 383 | + .isInstanceOf(BizException.class) | |
| 384 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 385 | + } | |
| 386 | + | |
| 387 | + @Test | |
| 388 | + void listWithIncompatibleFieldMatch_throws40001() { | |
| 389 | + assertThatThrownBy(() -> service.list("作废", "包含", "true", 1, 20)) | |
| 390 | + .isInstanceOf(BizException.class) | |
| 391 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 392 | + } | |
| 393 | + | |
| 394 | + @Test | |
| 395 | + void listWithPageSizeExceeds100_throws40002() { | |
| 396 | + assertThatThrownBy(() -> service.list("用户名", "包含", "", 1, 101)) | |
| 397 | + .isInstanceOf(BizException.class) | |
| 398 | + .hasFieldOrPropertyWithValue("code", 40002); | |
| 399 | + } | |
| 400 | + | |
| 401 | + @Test | |
| 402 | + void listWithInvalidLoginDateFormat_throws40001() { | |
| 403 | + assertThatThrownBy(() -> service.list("登录日期", "等于", "abc", 1, 20)) | |
| 404 | + .isInstanceOf(BizException.class) | |
| 405 | + .hasFieldOrPropertyWithValue("code", 40001); | |
| 406 | + } | |
| 407 | + | |
| 408 | + @Test | |
| 409 | + void listWithBooleanFieldEqualsTrue_passesIntegerOne() { | |
| 410 | + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); | |
| 411 | + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); | |
| 412 | + | |
| 413 | + service.list("作废", "等于", "true", 1, 20); | |
| 414 | + | |
| 415 | + verify(userMapper).pageWithFilter(eq("u.bDeleted"), eq("equals"), eq(1), eq(0), eq(20)); | |
| 416 | + } | |
| 417 | + | |
| 418 | + @Test | |
| 419 | + void loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate() { | |
| 420 | + User u = new User(); | |
| 421 | + u.setIIncrement(100); | |
| 422 | + u.setSUserNo("u100"); | |
| 423 | + u.setSUserName("login_ok"); | |
| 424 | + u.setSUserType("普通用户"); | |
| 425 | + u.setSLanguage("zh"); | |
| 426 | + u.setSPasswordHash(encoder.encode("666666")); | |
| 427 | + u.setBDeleted(false); | |
| 428 | + when(userMapper.selectByUserName("login_ok")).thenReturn(u); | |
| 429 | + | |
| 430 | + LoginDTO dto = new LoginDTO(); | |
| 431 | + dto.setSUserName("login_ok"); | |
| 432 | + dto.setPassword("666666"); | |
| 433 | + dto.setVersion("标准版"); | |
| 434 | + | |
| 435 | + LoginVO vo = service.login(dto); | |
| 436 | + | |
| 437 | + assertThat(vo.getAccessToken()).isNotBlank(); | |
| 438 | + assertThat(vo.getRefreshToken()).isNotBlank(); | |
| 439 | + assertThat(vo.getRefreshToken()).isNotEqualTo(vo.getAccessToken()); | |
| 440 | + assertThat(vo.getExpiresIn()).isEqualTo(28800); | |
| 441 | + assertThat(vo.getUser().getIIncrement()).isEqualTo(100); | |
| 442 | + assertThat(vo.getUser().getSUserNo()).isEqualTo("u100"); | |
| 443 | + verify(userMapper).updateLastLoginDate(eq(100), any()); | |
| 444 | + } | |
| 445 | + | |
| 446 | + @Test | |
| 447 | + void loginWithUserNotFound_throws40101() { | |
| 448 | + when(userMapper.selectByUserName("ghost")).thenReturn(null); | |
| 449 | + LoginDTO dto = loginDto("ghost", "x"); | |
| 450 | + assertThatThrownBy(() -> service.login(dto)) | |
| 451 | + .isInstanceOf(BizException.class) | |
| 452 | + .hasFieldOrPropertyWithValue("code", 40101); | |
| 453 | + } | |
| 454 | + | |
| 455 | + @Test | |
| 456 | + void loginWithDeletedUser_throws40102() { | |
| 457 | + User u = stubLoginUser("dead"); | |
| 458 | + u.setBDeleted(true); | |
| 459 | + when(userMapper.selectByUserName("dead")).thenReturn(u); | |
| 460 | + LoginDTO dto = loginDto("dead", "666666"); | |
| 461 | + assertThatThrownBy(() -> service.login(dto)) | |
| 462 | + .isInstanceOf(BizException.class) | |
| 463 | + .hasFieldOrPropertyWithValue("code", 40102); | |
| 464 | + } | |
| 465 | + | |
| 466 | + @Test | |
| 467 | + void loginWithWrongPassword_incrementsCounter_throws40101() { | |
| 468 | + when(userMapper.selectByUserName("u_wrong")).thenReturn(stubLoginUser("u_wrong")); | |
| 469 | + LoginDTO dto = loginDto("u_wrong", "wrong"); | |
| 470 | + assertThatThrownBy(() -> service.login(dto)) | |
| 471 | + .isInstanceOf(BizException.class) | |
| 472 | + .hasFieldOrPropertyWithValue("code", 40101); | |
| 473 | + } | |
| 474 | + | |
| 475 | + @Test | |
| 476 | + void loginAfterMaxAttemptsReached_throws42301() { | |
| 477 | + when(userMapper.selectByUserName("u_lock")).thenReturn(stubLoginUser("u_lock")); | |
| 478 | + LoginDTO dto = loginDto("u_lock", "wrong"); | |
| 479 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { | |
| 480 | + try { service.login(dto); } catch (BizException ignored) {} | |
| 481 | + } | |
| 482 | + // 第 5 次失败应触发锁定 → 42301 | |
| 483 | + assertThatThrownBy(() -> service.login(dto)) | |
| 484 | + .isInstanceOf(BizException.class) | |
| 485 | + .hasFieldOrPropertyWithValue("code", 42301); | |
| 486 | + } | |
| 487 | + | |
| 488 | + @Test | |
| 489 | + void loginWhileLocked_throws42301() { | |
| 490 | + when(userMapper.selectByUserName("u_locked2")).thenReturn(stubLoginUser("u_locked2")); | |
| 491 | + LoginDTO bad = loginDto("u_locked2", "wrong"); | |
| 492 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { | |
| 493 | + try { service.login(bad); } catch (BizException ignored) {} | |
| 494 | + } | |
| 495 | + // 锁定后即使密码正确也走 42301 | |
| 496 | + LoginDTO good = loginDto("u_locked2", "666666"); | |
| 497 | + assertThatThrownBy(() -> service.login(good)) | |
| 498 | + .isInstanceOf(BizException.class) | |
| 499 | + .hasFieldOrPropertyWithValue("code", 42301); | |
| 500 | + } | |
| 501 | + | |
| 502 | + @Test | |
| 503 | + void loginSuccess_clearsFailureCounter() { | |
| 504 | + when(userMapper.selectByUserName("u_clear")).thenReturn(stubLoginUser("u_clear")); | |
| 505 | + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} | |
| 506 | + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} | |
| 507 | + // 现在 2 次失败;正确登录后清空 | |
| 508 | + service.login(loginDto("u_clear", "666666")); | |
| 509 | + // 之后再 4 次错误应不锁(计数已重置) | |
| 510 | + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { | |
| 511 | + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} | |
| 512 | + } | |
| 513 | + // MAX-1 次错误后仍未锁 | |
| 514 | + assertThat(loginAttemptStore.isLocked("u_clear")).isEmpty(); | |
| 515 | + } | |
| 516 | + | |
| 517 | + private LoginDTO loginDto(String userName, String password) { | |
| 518 | + LoginDTO dto = new LoginDTO(); | |
| 519 | + dto.setSUserName(userName); | |
| 520 | + dto.setPassword(password); | |
| 521 | + dto.setVersion("标准版"); | |
| 522 | + return dto; | |
| 523 | + } | |
| 524 | + | |
| 525 | + private User stubLoginUser(String userName) { | |
| 526 | + User u = new User(); | |
| 527 | + u.setIIncrement(200); | |
| 528 | + u.setSUserNo("u200"); | |
| 529 | + u.setSUserName(userName); | |
| 530 | + u.setSUserType("普通用户"); | |
| 531 | + u.setSLanguage("zh"); | |
| 532 | + u.setSPasswordHash(encoder.encode("666666")); | |
| 533 | + u.setBDeleted(false); | |
| 534 | + return u; | |
| 535 | + } | |
| 536 | + | |
| 537 | + private UpdateUserDTO baseUpdateDto() { | |
| 538 | + UpdateUserDTO dto = new UpdateUserDTO(); | |
| 539 | + dto.setSUserNo("u_new"); | |
| 540 | + dto.setSUserName("用户新"); | |
| 541 | + dto.setSUserType("普通用户"); | |
| 542 | + dto.setSLanguage("zh"); | |
| 543 | + dto.setBCanModifyDocs(false); | |
| 544 | + return dto; | |
| 545 | + } | |
| 546 | + | |
| 547 | + private User stubExistingUser(Integer id) { | |
| 548 | + User u = new User(); | |
| 549 | + u.setIIncrement(id); | |
| 550 | + u.setSUserNo("u_orig"); | |
| 551 | + u.setSUserName("原用户"); | |
| 552 | + u.setSUserType("普通用户"); | |
| 553 | + u.setSLanguage("zh"); | |
| 554 | + u.setSPasswordHash("$2a$10$origHash"); | |
| 555 | + u.setSCreatedBy("ORIG_USER"); | |
| 556 | + u.setBDeleted(false); | |
| 557 | + return u; | |
| 558 | + } | |
| 559 | + | |
| 560 | + private CreateUserDTO baseDto() { | |
| 561 | + CreateUserDTO dto = new CreateUserDTO(); | |
| 562 | + dto.setSUserNo("u001"); | |
| 563 | + dto.setSUserName("用户1"); | |
| 564 | + dto.setSUserType("普通用户"); | |
| 565 | + dto.setSLanguage("zh"); | |
| 566 | + dto.setBCanModifyDocs(false); | |
| 567 | + return dto; | |
| 568 | + } | |
| 569 | +} | ... | ... |
docs/08-模块任务管理.md
| ... | ... | @@ -68,9 +68,9 @@ |
| 68 | 68 | - module_usr 用户管理 |
| 69 | 69 | - 依赖: — |
| 70 | 70 | - 路径: backend/module/usr/, frontend/pages/usr/ |
| 71 | - - MR: — | |
| 71 | + - MR: !2 | |
| 72 | 72 | - 功能: |
| 73 | - - [ ] REQ-USR-001 用户新增 | |
| 74 | - - [ ] REQ-USR-002 用户修改 | |
| 75 | - - [ ] REQ-USR-003 用户查询 | |
| 76 | - - [ ] REQ-USR-004 用户登录 | |
| 73 | + - [x] REQ-USR-001 用户新增 | |
| 74 | + - [x] REQ-USR-002 用户修改 | |
| 75 | + - [x] REQ-USR-003 用户查询 | |
| 76 | + - [x] REQ-USR-004 用户登录 | ... | ... |
docs/superpowers/module-reports/2026-04-30-module_usr.md
0 → 100644
| 1 | +--- | |
| 2 | +module_id: module_usr | |
| 3 | +date: 2026-04-30 | |
| 4 | +git_range: master..HEAD (31 commits) | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# 模块完成报告 — module_usr 用户管理 | |
| 8 | + | |
| 9 | +## ① 模块信息 | |
| 10 | +- 模块 ID: module_usr | |
| 11 | +- 模块名: 用户管理 | |
| 12 | +- 开发区间: 31 commits,~4149 行新增 | |
| 13 | +- 分支: `module-module_usr` | |
| 14 | +- **里程碑**:USR-004 完成 stub 闭环——整个项目 SecurityConfig 收紧到 `登录接口外全部 authenticated()` | |
| 15 | + | |
| 16 | +## ② REQ 完成清单 | |
| 17 | + | |
| 18 | +- [x] REQ-USR-001 — 用户新增 | |
| 19 | + - spec: docs/superpowers/specs/2026-04-30-REQ-USR-001.md | |
| 20 | + - plan: docs/superpowers/plans/2026-04-30-REQ-USR-001.md | |
| 21 | + - review: docs/superpowers/reviews/2026-04-30-REQ-USR-001.md | |
| 22 | +- [x] REQ-USR-002 — 用户修改 | |
| 23 | + - spec/plan/review: 同名 2026-04-30-REQ-USR-002 三件套 | |
| 24 | +- [x] REQ-USR-003 — 用户查询 | |
| 25 | + - spec/plan/review: 同名 2026-04-30-REQ-USR-003 三件套 | |
| 26 | +- [x] REQ-USR-004 — 用户登录(含 Stub 闭环) | |
| 27 | + - spec/plan/review: 同名 2026-04-30-REQ-USR-004 三件套 | |
| 28 | + | |
| 29 | +## ③ 文件变更表 | |
| 30 | + | |
| 31 | +| 文件 | 操作 | 说明 | | |
| 32 | +|---|---|---| | |
| 33 | +| backend/src/main/java/com/xly/erp/module/usr/entity/User.java | 新建 | 17 字段 1:1 映射 tUser | | |
| 34 | +| backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java | 新建 | 8 字段映射 tUserPermission | | |
| 35 | +| backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java | 新建 | USR-001 入参 | | |
| 36 | +| backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java | 新建 | USR-002 入参(无 sPasswordHash) | | |
| 37 | +| backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java | 新建 | USR-004 登录入参 | | |
| 38 | +| backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java | 新建 | USR-003 列表 VO(11 字段,含 LEFT JOIN tStaff) | | |
| 39 | +| backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java | 新建 | USR-004 用户简表 | | |
| 40 | +| backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java | 新建 | USR-004 登录响应 | | |
| 41 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java | 新建 + 多次扩展 | BaseMapper + 自定义查询 + 登录用方法 | | |
| 42 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java | 新建 + deleteByUserId | USR-001 + USR-002 | | |
| 43 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java | 新建 | 最小 existsActiveById | | |
| 44 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java | 新建 | 批量校验 countActiveByIds | | |
| 45 | +| backend/src/main/resources/mapper/usr/UserMapper.xml | 新建 | USR-003 动态 SQL(pageWithFilter / countWithFilter) | | |
| 46 | +| backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java | 新建 | USR-004 内存失败计数 + 锁定 | | |
| 47 | +| backend/src/main/java/com/xly/erp/module/usr/service/UserService.java | 新建 + 4 次扩展 | create/update/list/login | | |
| 48 | +| backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java | 新建 + 4 次扩展 | 全部业务逻辑 | | |
| 49 | +| backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java | 新建 | POST/PUT/GET /api/usr/users | | |
| 50 | +| backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java | 新建 | POST /api/usr/auth/login | | |
| 51 | +| backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java | 新建 | BCryptPasswordEncoder bean(独立于 SecurityConfig,避免 NONE 上下文丢失) | | |
| 52 | +| backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java | 新建 | USR-004 未认证返回 code=20001 | | |
| 53 | +| backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java | 修改 | USR-001 加 /api/usr/** permitAll → USR-004 收紧到仅 /api/usr/auth/login permitAll + entryPoint | | |
| 54 | +| backend/src/main/java/com/xly/erp/common/security/JwtUtil.java | 修改 | 加 signRefresh + 通用 sign(userNo, ttl) | | |
| 55 | +| backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java | 修改 | USR-004 移除 stub.getStubUserNo() fallback | | |
| 56 | +| backend/src/test/java/com/xly/erp/module/usr/**/* (5 文件) | 新建 | UserMapperIT(7) / StaffMapperIT(1) / PermissionCategoryMapperIT(1) / UserServiceImplTest(35) / UserControllerIT(27) / LoginAttemptStoreTest(5) / AuthControllerIT(5) | | |
| 57 | +| backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java | 修改 | USR-004 闭环:4 处 stub IT 期望从 200 改 20001 | | |
| 58 | +| backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java | 修改 | 加 signRefresh 验证 | | |
| 59 | +| docs/superpowers/specs|plans|reviews/2026-04-30-REQ-USR-{001..004}.md | 新建 | 12 份文档 | | |
| 60 | +| docs/superpowers/module-reports/module_usr-test-gate.md | 新建 | 本模块 test-gate 证据 | | |
| 61 | +| docs/08-模块任务管理.md | 修改 | 4 个 REQ checkbox 勾上 | | |
| 62 | + | |
| 63 | +## ④ 数据库使用表 | |
| 64 | + | |
| 65 | +- 读:`tUser` / `tStaff` / `tPermissionCategory`(各 REQ 的存在性校验 / JOIN 显示) | |
| 66 | +- 写: | |
| 67 | + - `tUser`:INSERT (USR-001) / UPDATE 可编辑列 (USR-002) / UPDATE tLastLoginDate (USR-004) | |
| 68 | + - `tUserPermission`:INSERT (USR-001) / DELETE+INSERT 重建 (USR-002) | |
| 69 | + | |
| 70 | +## ⑤ 测试结果 | |
| 71 | + | |
| 72 | +- `scripts/test.sh` 最终:**GREEN** | |
| 73 | +- 通过: 149 / 失败: 0 / 跳过: 0(前端段因 frontend/ 未初始化而 skip) | |
| 74 | +- 详见: docs/superpowers/module-reports/module_usr-test-gate.md | |
| 75 | +- 测试分布: | |
| 76 | + - SmokeTest: 1 | |
| 77 | + - 全局基础设施: GlobalExceptionHandlerTest 4 / JwtUtilTest 4 / JwtAuthenticationFilterTest 3 | |
| 78 | + - MOD 模块: ModuleMapperIT 5 / ModuleServiceImplTest 25 / ModuleControllerIT 26 | |
| 79 | + - USR 模块: UserMapperIT 7 / StaffMapperIT 1 / PermissionCategoryMapperIT 1 / LoginAttemptStoreTest 5 / UserServiceImplTest 35 / UserControllerIT 27 / AuthControllerIT 5 | |
| 80 | + | |
| 81 | +## ⑥ 本模块新增 Migration | |
| 82 | + | |
| 83 | +—(无 schema 改动;本模块 4 张表均在 V1 就位) | |
| 84 | + | |
| 85 | +## ⑦ 跨模块改动清单(软规则 S2) | |
| 86 | + | |
| 87 | +**1 处跨模块改动**(USR-004 stub 闭环必需,spec 已声明): | |
| 88 | + | |
| 89 | +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:create()/delete()` — 移除 `stub.getStubUserNo()` fallback | |
| 90 | +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 4 处 `*WithoutJwt_permitAllStub_*` 用例改名为 `*WithoutJwt_returns20001`,期望 200 → 20001 | |
| 91 | +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 2 处 sCreatedBy 期望从 "STUB_ADMIN" 改为 null | |
| 92 | + | |
| 93 | +**原因 / 影响评估**: | |
| 94 | +- 原因:USR-004 是模块循环最后一个 REQ,按 docs/02 § 二 顺序正好在收尾。spec 把 stub 闭环作为 Phase B 纳入本 REQ;项目首次具备真正的 JWT 鉴权能力,必须把 MOD 模块 stub-friendly 的测试期望同步收紧 | |
| 95 | +- 影响评估:MOD 模块业务逻辑未变(仅 fallback 一行被删,行为等价于"sCreatedBy 必须由 SecurityContextHelper 提供"——authenticated 守卫保证非 null);MOD 模块的 IT 测试在改写后行为更接近生产实际(无 token 必拒),增强了回归质量 | |
| 96 | + | |
| 97 | +## ⑧ 偏离 spec 清单 | |
| 98 | + | |
| 99 | +- **REQ-USR-001 & spec § 实现范围 #3**:spec 写 BCryptPasswordEncoder bean 在 SecurityConfig 注册,实际移到独立 `PasswordEncoderConfig.java`。原因:SecurityConfig `@ConditionalOnWebApplication(SERVLET)` 在 webEnvironment=NONE 的 mapperIT 上下文不加载。良性设计修正——密码编码器应是领域基础设施而非 Web 安全基础设施。 | |
| 100 | +- **REQ-USR-002 & spec/plan § "controller 先 trim"**:实际实现 trim 在 service。功能等价;spec 文字与实现微差。 | |
| 101 | +- **REQ-USR-003 & plan § 文件变更清单**:未创建 `UserListQuery` DTO,5 参直接传 service。简化合理。 | |
| 102 | +- **REQ-USR-002 错误码 40022**:docs/05 § USR-002 错误码列表未列 40022;本实现复用 USR-001 已建立的语义(同字段两个接口错误码一致更优)。 | |
| 103 | +- **REQ-USR-004 LoginDTO.version `@NotBlank`**:spec § 输入说"仅记录, 不参与校验",实际加了 @NotBlank。微差。 | |
| 104 | + | |
| 105 | +## ⑨ AI reviewer 报告汇总 | |
| 106 | + | |
| 107 | +- REQ-USR-001: round 1 — approve | |
| 108 | +- REQ-USR-002: round 1 — approve | |
| 109 | +- REQ-USR-003: round 1 — approve | |
| 110 | +- REQ-USR-004: round 1 — approve(含 Phase B stub 闭环) | |
| 111 | + | |
| 112 | +4/4 round 1 一次性通过;4 份 review 共约 28 条 nice-to-have,无 must-fix。 | |
| 113 | + | |
| 114 | +## ⑩ 已知问题 | |
| 115 | + | |
| 116 | +整合自 4 份 review 的非阻塞 nice-to-have,按主题分组: | |
| 117 | + | |
| 118 | +**架构遗留(建议下一模块开始前 chore commit 一次性收口)** | |
| 119 | +1. `ModuleServiceImpl` / `UserServiceImpl` 移除 stub fallback 后 `private final StubSecurityProperties stub` 字段已无引用(但构造器仍注入);按 surgical-changes 原则建议删字段 + 构造器参数 + 测试 setUp | |
| 120 | +2. `JwtAuthenticationFilter` 仍带 `@Component`,与 SecurityConfig `addFilterBefore` 重叠注册;可改 @Bean + FilterRegistrationBean disable | |
| 121 | +3. `JwtUtil` 暴露两个 public 构造器;`JwtUtil(String secret)` 实质 test-only | |
| 122 | +4. 业务方法 (create/update/delete/listTree/login 5 处) 缺行内 `// REQ-USR-XXX` 锚点 | |
| 123 | +5. entity `Module` 类名与 `java.lang.Module` 冲突 | |
| 124 | +6. `application-dev.yml` 缺失(docs/09 § 二 列出) | |
| 125 | + | |
| 126 | +**测试覆盖增强(非阻塞)** | |
| 127 | +7. `LoginAttemptStoreTest.lockExpiresAfterDuration` 未驱动同 userName 跨过 lockExpireAt 验证 records.remove 自动清除 | |
| 128 | +8. PUT-stub-regression IT 只断单列 unchanged,建议同时断 sCreatedBy / tCreateDate | |
| 129 | +9. USR-001/002 permissionCategoryIds 重复 id 假阴性(IN 隐式去重,建议 service `distinct`) | |
| 130 | +10. USR-003 响应 records 缺直接 `has("sPasswordHash") == false` 断言 | |
| 131 | +11. USR-003 ORDER BY iIncrement DESC / tLastLoginDate JSON ISO 格式无显式断言 | |
| 132 | +12. `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 在 update / delete IT 端未端到端断言保留 | |
| 133 | +13. `iParentId` 指向 `bDeleted=1` 旧记录是否回 40021 未单独验证 | |
| 134 | +14. AuthControllerIT 字段级负例(仅缺 sUserName)未单独覆盖 | |
| 135 | + | |
| 136 | +**配置 / 校验** | |
| 137 | +15. application.yml `${JWT_SECRET}` 缺 fail-fast 默认值 | |
| 138 | +16. GlobalExceptionHandler `handleAny(Exception)` 把 4xx 框架异常转 50000 | |
| 139 | +17. DTO `iParentId` / `iSortOrder` 缺 `@PositiveOrZero` / `@Min` 约束 | |
| 140 | +18. `LoginDTO.version` `@NotBlank` 与 spec "仅记录" 不一致 | |
| 141 | + | |
| 142 | +**信息泄漏** | |
| 143 | +19. UserListVO 设计上不暴露敏感字段,但 IT 缺 regression-locking 断言 | |
| 144 | + | |
| 145 | +## ⑪ 下一模块预览 | |
| 146 | + | |
| 147 | +**项目所有 REQ 已完成**:docs/02 § 二 共 8 个 REQ(MOD 4 + USR 4),全部 review approve 完毕。 | |
| 148 | + | |
| 149 | +USR 模块合并到 master 后,整个项目处于: | |
| 150 | +- 后端工程:149 用例全绿 | |
| 151 | +- 鉴权:仅 `POST /api/usr/auth/login` 公开,其余 `authenticated()` | |
| 152 | +- 数据库:tUser / tStaff / tPermissionCategory / tUserPermission / tModule 全部就位 | |
| 153 | + | |
| 154 | +**后续工作(不属于本期 REQ 范围,建议作为新 epic)**: | |
| 155 | +- 引入 frontend/(React + Vite) | |
| 156 | +- 引入 Redis 替换 LoginAttemptStore 内存实现 | |
| 157 | +- jacoco 覆盖率 | |
| 158 | +- application-dev.yml + dev profile | |
| 159 | +- `ModuleEntity` 重命名(Module 与 java.lang.Module 冲突) | |
| 160 | +- 4 处行内 `// REQ-XXX` 锚点补齐 | |
| 161 | + | |
| 162 | +## ⑫ MR 链接 | |
| 163 | + | |
| 164 | +- !2 — http://git.xlyprint.cn/zhuzc/test/merge_requests/2 | ... | ... |
docs/superpowers/module-reports/module_usr-test-gate.md
0 → 100644
| 1 | +## Local test gate — module_usr | |
| 2 | + | |
| 3 | +执行时间: 2026-04-30 15:05 +08:00 | |
| 4 | + | |
| 5 | +### scripts/test.sh (subagent) | |
| 6 | +- 子会话: a80ebb0dfb4c4a6f4 | |
| 7 | +- 命令: `bash scripts/test.sh` | |
| 8 | +- 退出码: 0 | |
| 9 | +- 通过: 149 / 失败: 0 | |
| 10 | +- 关键 stdout (≤30 行): | |
| 11 | + | |
| 12 | +``` | |
| 13 | +[INFO] Tests run: 149, Failures: 0, Errors: 0, Skipped: 0 | |
| 14 | +[INFO] BUILD SUCCESS | |
| 15 | +[INFO] Total time: 33.643 s | |
| 16 | +[INFO] Finished at: 2026-04-30T15:05:00+08:00 | |
| 17 | +[test.sh] skip frontend unit tests (frontend/ not initialized yet) | |
| 18 | +[test.sh] 5/6 E2E | |
| 19 | +[test.sh] e2e 略 | |
| 20 | +[test.sh] 6/6 reset test db | |
| 21 | +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts | |
| 22 | +[test.sh] GREEN | |
| 23 | +``` | |
| 24 | + | |
| 25 | +结论: green | |
| 26 | + | |
| 27 | +### 模块概要 | |
| 28 | + | |
| 29 | +- module_usr 4 个 REQ 全部 review approve | |
| 30 | +- 含 USR-004 stub 闭环(SecurityConfig 收紧 + 7 处 stub IT 重写为 20001 期望) | |
| 31 | +- backend 全量 149 用例:MOD 67 + USR-001 22 + USR-002 17 + USR-003 19 + USR-004 24 | ... | ... |
docs/superpowers/plans/2026-04-30-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-04-30 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-001.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-001 用户新增 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | |
| 10 | + | |
| 11 | +**Goal:** 在 MOD 模块已建工程基础上扩展 USR 模块树,实现 `POST /api/usr/users`:新增 `tUser` 行 + 默认 BCrypt 密码哈希 + `tUserPermission` 多对多关联,含 `iStaffId` / `permissionCategoryIds` 存在性校验。 | |
| 12 | + | |
| 13 | +**Architecture:** 新建 `module/usr/` 平行模块树(entity/dto/mapper/service/controller)+ 在 `common/security/SecurityConfig` 加 `/api/usr/**` permitAll + 注册 `BCryptPasswordEncoder` bean。Staff / PermissionCategory 仅做存在性校验(最小化 mapper,不建 entity)。事务包"3 项校验 + INSERT user + INSERT user_permission × N"。 | |
| 14 | + | |
| 15 | +**Tech Stack:** 复用(Spring Boot 3.3.5 / MyBatis-Plus / Spring Security / JJWT);本 REQ 引入 `BCryptPasswordEncoder`(spring-security-crypto 已通过 starter-security 引入,无需新依赖)。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无(`tUser` / `tStaff` / `tPermissionCategory` / `tUserPermission` 均在 V1 就位)。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +### 新增 | |
| 26 | + | |
| 27 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` | |
| 28 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` | |
| 29 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` | |
| 30 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` | |
| 31 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` | |
| 32 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` | |
| 33 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java` | |
| 34 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | |
| 35 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | |
| 36 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | |
| 37 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` | |
| 38 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` | |
| 39 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` | |
| 40 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | |
| 41 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | |
| 42 | + | |
| 43 | +### 修改 | |
| 44 | + | |
| 45 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — 加 `/api/usr/**` permitAll + `@Bean BCryptPasswordEncoder` | |
| 46 | + | |
| 47 | +## 任务步骤 | |
| 48 | + | |
| 49 | +> 全局:每 commit `<type>(usr): <subject> REQ-USR-001`;测试派发子会话;现有 67 用例全程绿。 | |
| 50 | + | |
| 51 | +### Task 1: SecurityConfig 扩展 + BCryptPasswordEncoder bean | |
| 52 | + | |
| 53 | +**Files:** | |
| 54 | +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` | |
| 55 | + | |
| 56 | +**API shape:** | |
| 57 | +- 在 `authorizeHttpRequests` 的现有 `requestMatchers("/api/mod/**").permitAll()` 后追加 `requestMatchers("/api/usr/**").permitAll()` | |
| 58 | +- 类内追加 `@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }` | |
| 59 | +- stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up` | |
| 60 | + | |
| 61 | +- [ ] **Step 1: 修改 SecurityConfig** | |
| 62 | +- [ ] **Step 2: 子会话验证 PASS** | |
| 63 | + - 命令:`cd backend && mvn -B test` | |
| 64 | + - 期望:现有 67 用例全绿(路径扩范围不收紧) | |
| 65 | +- [ ] **Step 3: Commit** | |
| 66 | + - `git commit -m "refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001"` | |
| 67 | + | |
| 68 | +### Task 2: tUser entity + UserMapper + IT | |
| 69 | + | |
| 70 | +**Files:** | |
| 71 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` | |
| 72 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` | |
| 73 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` | |
| 74 | + | |
| 75 | +**API shape:** | |
| 76 | +- `User` PO:`@TableName("tUser")` + 17 字段 1:1 映射(参 docs/03 § tUser),全部 `@TableField` 显式列名(与 MOD `Module.java` 风格一致),`iIncrement` 用 `@TableId(IdType.AUTO)` | |
| 77 | +- `UserMapper extends BaseMapper<User>`:暂只用 BaseMapper 的 insert/selectById(唯一冲突走 DB 索引兜底,无需自定义 exists 方法) | |
| 78 | + | |
| 79 | +- [ ] **Step 1: 写失败测试 `UserMapperIT`(2 用例)** | |
| 80 | + - `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例(含 sPasswordHash="bcrypt-stub")插入 → selectById 比较;断言 sBrandsId="XLY"、bDeleted=false 等 | |
| 81 | + - `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次抛 `DuplicateKeyException` | |
| 82 | + - 测试隔离:`@BeforeEach @AfterEach` 用 `DELETE FROM tUserPermission WHERE iUserId IN (SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')` + `DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'`(避免外键孤儿,但 USR-001 的 tUserPermission 与 tUser 在本 IT 不会被插入;此句保留为防御) | |
| 83 | + | |
| 84 | +- [ ] **Step 2: 实现 entity + mapper** | |
| 85 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 86 | + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` | |
| 87 | +- [ ] **Step 4: Commit** | |
| 88 | + - `git commit -m "feat(usr): tUser entity + mapper REQ-USR-001"` | |
| 89 | + | |
| 90 | +### Task 3: StaffMapper + PermissionCategoryMapper(最小存在性查询) | |
| 91 | + | |
| 92 | +**Files:** | |
| 93 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` | |
| 94 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java` | |
| 95 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` | |
| 96 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` | |
| 97 | + | |
| 98 | +**API shape:** | |
| 99 | +- `StaffMapper`(**不**继承 BaseMapper,仅注解 SELECT;本 REQ 不建 Staff entity): | |
| 100 | + - `@Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") Integer findActiveStaffFlag(@Param("id") Integer id)` | |
| 101 | + - `default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; }` | |
| 102 | +- `PermissionCategoryMapper`(同上): | |
| 103 | + - `@Select("<script>SELECT COUNT(1) FROM tPermissionCategory WHERE bDeleted = 0 AND iIncrement IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>") int countActiveByIds(@Param("ids") List<Integer> ids)`(mybatis 动态 SQL);空 list 调用方先短路,不调 mapper | |
| 104 | + | |
| 105 | +- [ ] **Step 1: 写失败测试** | |
| 106 | + - `StaffMapperIT#existsActiveById_handlesAliveDeletedMissing`:JdbcTemplate 直插 alive staff + deleted staff;断言三种 id(alive/deleted/不存在)的返回值 | |
| 107 | + - `PermissionCategoryMapperIT#countActiveByIds_returnsCorrectCount`:JdbcTemplate 直插 cat1(alive) + cat2(alive) + cat3(deleted);查 [cat1,cat2,cat3] → count=2;查 [cat1, 99999] → count=1;查 [99999] → count=0 | |
| 108 | + | |
| 109 | +- [ ] **Step 2: 实现 mapper** | |
| 110 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 111 | + - 命令:`cd backend && mvn -B test -Dtest='StaffMapperIT,PermissionCategoryMapperIT'` | |
| 112 | +- [ ] **Step 4: Commit** | |
| 113 | + - `git commit -m "feat(usr): staff + permission-category existence mappers REQ-USR-001"` | |
| 114 | + | |
| 115 | +### Task 4: tUserPermission entity + UserPermissionMapper + IT | |
| 116 | + | |
| 117 | +**Files:** | |
| 118 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` | |
| 119 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` | |
| 120 | +- 复用 `UserMapperIT`(追加用例)或独立 `UserPermissionMapperIT`,本 plan 选择追加到 UserMapperIT | |
| 121 | + | |
| 122 | +**API shape:** | |
| 123 | +- `UserPermission` PO:`@TableName("tUserPermission")` + 字段 `iIncrement(@TableId AUTO)` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iUserId` / `iCategoryId` / `sCreatedBy`(参 docs/03 § tUserPermission) | |
| 124 | +- `UserPermissionMapper extends BaseMapper<UserPermission>`,仅用 BaseMapper.insert | |
| 125 | + | |
| 126 | +- [ ] **Step 1: 写失败测试(追加到 `UserMapperIT`)** | |
| 127 | + - `userPermissionInsert_persistsRowWithUserAndCategory` — 先插一行 user,再插一行 userPermission(iUserId=user.id, iCategoryId=10);JdbcTemplate 验 row 存在 | |
| 128 | + | |
| 129 | +- [ ] **Step 2: 实现 entity + mapper** | |
| 130 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 131 | + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` | |
| 132 | +- [ ] **Step 4: Commit** | |
| 133 | + - `git commit -m "feat(usr): tUserPermission entity + mapper REQ-USR-001"` | |
| 134 | + | |
| 135 | +### Task 5: CreateUserDTO + UserService.create 主流程(合法 + 标准列) | |
| 136 | + | |
| 137 | +**Files:** | |
| 138 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` | |
| 139 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | |
| 140 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | |
| 141 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | |
| 142 | + | |
| 143 | +**API shape:** | |
| 144 | +- `CreateUserDTO` 字段(带 `@JsonProperty` + Bean Validation): | |
| 145 | + - `@NotBlank @Size(max=50) String sUserNo` | |
| 146 | + - `@NotBlank @Size(max=50) String sUserName` | |
| 147 | + - `Integer iStaffId`(可空) | |
| 148 | + - `@NotBlank String sUserType` | |
| 149 | + - `@NotBlank String sLanguage` | |
| 150 | + - `Boolean bCanModifyDocs`(可空) | |
| 151 | + - `List<Integer> permissionCategoryIds`(可空,null/空均按"无权限组"处理) | |
| 152 | +- `UserService#create(CreateUserDTO dto) : Map<String, Object>`(返回 `{iIncrement, sUserNo}`);本 plan 用 `Map<String, Object>` 而非新 VO,与 MOD 控制器响应风格保持一致 | |
| 153 | +- `UserServiceImpl` 依赖:`UserMapper` / `UserPermissionMapper` / `StaffMapper` / `PermissionCategoryMapper` / `TenantProperties` / `StubSecurityProperties` / `BCryptPasswordEncoder` | |
| 154 | +- `@Transactional(rollbackFor = Exception.class)` | |
| 155 | +- 流程主路径(仅本 task 实现合法 + 标准列): | |
| 156 | + 1. 构造 `User entity`:DTO 透传 + 标准列(sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy 同 MOD 模块策略) + `sPasswordHash = encoder.encode("666666")` + `bDeleted=false` | |
| 157 | + 2. `userMapper.insert(entity)` | |
| 158 | + 3. 若 `permissionCategoryIds` 非空:for-loop 插 `UserPermission` 行 | |
| 159 | + 4. 返回 `Map.of("iIncrement", entity.getIIncrement(), "sUserNo", entity.getSUserNo())` | |
| 160 | + | |
| 161 | +- [ ] **Step 1: 写失败测试(2 用例)** | |
| 162 | + - `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers + encoder.encode 返回 "$2a$10$stub";ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert`;断言 user.sBrandsId="XLY"、sCreatedBy="STUB_ADMIN"、sPasswordHash 以 "$2a$" 开头;N 条权限关联含正确 iUserId / iCategoryId | |
| 163 | + - `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null`;`userPermissionMapper.insert` 永不调用 | |
| 164 | + | |
| 165 | +- [ ] **Step 2: 实现 DTO + service 主流程** | |
| 166 | + - 仅覆盖本 task 两用例所需逻辑(异常分支留 Task 6) | |
| 167 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 168 | + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` | |
| 169 | +- [ ] **Step 4: Commit** | |
| 170 | + - `git commit -m "feat(usr): user create dto + service happy path REQ-USR-001"` | |
| 171 | + | |
| 172 | +### Task 6: Service 异常分支补全 | |
| 173 | + | |
| 174 | +**Files:** | |
| 175 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | |
| 176 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | |
| 177 | + | |
| 178 | +**API shape:** 不变(仅在 service 头部补 4 类校验 + 异常翻译) | |
| 179 | + | |
| 180 | +**校验顺序(service 实现):** | |
| 181 | +1. 枚举校验 `sUserType ∈ {普通用户, 超级管理员}` + `sLanguage ∈ {zh, en, zh-TW}` → 任一非法 `BizException(40001, "<字段>: 取值非法")` | |
| 182 | +2. `iStaffId != null` 且 `!staffMapper.existsActiveById(iStaffId)` → `BizException(40022, "职员不存在或已删除")` | |
| 183 | +3. `permissionCategoryIds` 非空:`int n = permissionCategoryMapper.countActiveByIds(ids); if (n != ids.size()) throw new BizException(40023, "权限分类含无效 id")` | |
| 184 | +4. `userMapper.insert(...)` 用 try/catch 捕获 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")` | |
| 185 | +5. `sCreatedBy` 优先 SecurityContextHelper.currentUserNo(),回退 stub | |
| 186 | + | |
| 187 | +- [ ] **Step 1: 在 `UserServiceImplTest` 追加 6 用例** | |
| 188 | + - `createWithInvalidUserType_throws40001` | |
| 189 | + - `createWithInvalidLanguage_throws40001` | |
| 190 | + - `createWithStaffNotFound_throws40022` — Mock `staffMapper.existsActiveById(...) → false`;`userMapper.insert` 永不调用 | |
| 191 | + - `createWithSomeInvalidPermissionIds_throws40023` — Mock `permissionCategoryMapper.countActiveByIds([1,2,3]) → 2`;`userMapper.insert` 永不调用 | |
| 192 | + - `createWithDuplicateUserNo_throws40020` — Mock `userMapper.insert` 抛 `DuplicateKeyException` | |
| 193 | + - `createUsesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注 "ALICE";ArgumentCaptor `sCreatedBy="ALICE"` | |
| 194 | + | |
| 195 | +- [ ] **Step 2: 在 ServiceImpl 补 4 类校验 + 异常翻译** | |
| 196 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 197 | + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` | |
| 198 | + - 期望:2 + 6 = 8 用例全绿 | |
| 199 | +- [ ] **Step 4: Commit** | |
| 200 | + - `git commit -m "feat(usr): user create error branches REQ-USR-001"` | |
| 201 | + | |
| 202 | +### Task 7: UserController POST + IT(9 用例)+ 全量回归 | |
| 203 | + | |
| 204 | +**Files:** | |
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | |
| 206 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | |
| 207 | + | |
| 208 | +**API shape:** | |
| 209 | +- `@RestController @RequestMapping("/api/usr")` | |
| 210 | +- `@PostMapping("/users") public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDTO dto) { return Result.ok(userService.create(dto)); }` | |
| 211 | + | |
| 212 | +- [ ] **Step 1: 写失败 IT(9 用例)** | |
| 213 | + - `postValidBody_with_jwt_returns200_andPersists` — 前置:JdbcTemplate 直插一行 staff + 两行 permission_category;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo == 请求值`;JdbcTemplate 验 tUser 行存在 + tUserPermission 行数 == permissionCategoryIds.size() | |
| 214 | + - `postEmptyBody_returns40001` | |
| 215 | + - `postInvalidUserType_returns40001` — `sUserType="火星"` | |
| 216 | + - `postInvalidLanguage_returns40001` — `sLanguage="ja"` | |
| 217 | + - `postDuplicateUserNo_returns40020` — 先 POST 一次成功,再 POST 同 sUserNo(不同 sUserName)→ `code=40020` | |
| 218 | + - `postStaffNotFound_returns40022` — `iStaffId=99999990` → `code=40022` | |
| 219 | + - `postPermissionCategoryNotFound_returns40023` — `permissionCategoryIds=[99999991]` → `code=40023` | |
| 220 | + - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — DB 验 sCreatedBy="STUB_ADMIN" | |
| 221 | + - `postTamperedJwt_returns20001` — Authorization "Bearer not.a.real.jwt";DB 无新增行 | |
| 222 | + - 测试隔离:`@BeforeEach @AfterEach` 清理 `tUserPermission` + `tUser`(按 sUserNo LIKE 'sp_test_%')+ 清理本 IT 创建的 tStaff / tPermissionCategory(按 sStaffNo / sCategoryCode LIKE 'sp_test_%');按外键依赖顺序删(tUserPermission → tUser → tStaff / tPermissionCategory) | |
| 223 | + | |
| 224 | +- [ ] **Step 2: 实现 controller** | |
| 225 | +- [ ] **Step 3: 子会话跑全量回归** | |
| 226 | + - 命令:`cd backend && mvn -B test` | |
| 227 | + - 期望:MOD 67 + USR-001 新增 2(UserMapperIT) + 1(UserMapperIT 追加) + 1(StaffMapperIT) + 1(PermissionCategoryMapperIT) + 8(UserServiceImplTest) + 9(UserControllerIT) = 89 用例全绿 | |
| 228 | +- [ ] **Step 4: Commit** | |
| 229 | + - `git commit -m "test(usr): user create integration coverage REQ-USR-001"` | |
| 230 | + | |
| 231 | +## 提交计划 | |
| 232 | + | |
| 233 | +| commit | 覆盖 | | |
| 234 | +|---|---| | |
| 235 | +| `refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001` | Task 1 | | |
| 236 | +| `feat(usr): tUser entity + mapper REQ-USR-001` | Task 2 | | |
| 237 | +| `feat(usr): staff + permission-category existence mappers REQ-USR-001` | Task 3 | | |
| 238 | +| `feat(usr): tUserPermission entity + mapper REQ-USR-001` | Task 4 | | |
| 239 | +| `feat(usr): user create dto + service happy path REQ-USR-001` | Task 5 | | |
| 240 | +| `feat(usr): user create error branches REQ-USR-001` | Task 6 | | |
| 241 | +| `test(usr): user create integration coverage REQ-USR-001` | Task 7 | | ... | ... |
docs/superpowers/plans/2026-04-30-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-04-30 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-002.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-002 用户修改 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | |
| 10 | + | |
| 11 | +**Goal:** 在 USR-001 已建工程基础上增量实现 `PUT /api/usr/users/{id}`:更新可编辑字段 + 重建权限组(删旧 + 插新);保留 `sPasswordHash` / `sCreatedBy` / 标准列。 | |
| 12 | + | |
| 13 | +**Architecture:** 复用 `UserService` / `UserServiceImpl` / `UserController` / 全部 mappers;新增 `UpdateUserDTO`、`UserService#update`、`UserPermissionMapper#deleteByUserId`。SecurityConfig 已对 `/api/usr/**` permitAll,无需改。 | |
| 14 | + | |
| 15 | +**Tech Stack:** 沿用(Spring Boot 3.3.5 / MyBatis-Plus / JJWT)。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无(仅 UPDATE / DELETE / INSERT)。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +### 新增 | |
| 26 | + | |
| 27 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java` | |
| 28 | + | |
| 29 | +### 修改 | |
| 30 | + | |
| 31 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` — 追加 `deleteByUserId(Integer)` | |
| 32 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `Integer update(Integer id, UpdateUserDTO dto)` | |
| 33 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update(含 5 类校验 + UPDATE + DELETE + INSERT × N) | |
| 34 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 PUT 端点 | |
| 35 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 10 单测 | |
| 36 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` — 追加 1 用例(deleteByUserId) | |
| 37 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 10 IT | |
| 38 | + | |
| 39 | +## 任务步骤 | |
| 40 | + | |
| 41 | +### Task 1: UserPermissionMapper#deleteByUserId + IT | |
| 42 | + | |
| 43 | +**Files:** | |
| 44 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` | |
| 45 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` | |
| 46 | + | |
| 47 | +**API shape:** | |
| 48 | +- `@Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}")` `int deleteByUserId(@Param("userId") Integer userId)` | |
| 49 | + | |
| 50 | +- [ ] **Step 1: 写失败测试 `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser`** | |
| 51 | + - 准备:插 user1 + user2;user1 关联 cat1+cat2,user2 关联 cat1 | |
| 52 | + - 调 `deleteByUserId(user1.id)` → 返回 2,user1 行数 = 0;user2 行数 = 1(不受影响) | |
| 53 | +- [ ] **Step 2: 实现 mapper 方法** | |
| 54 | +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserMapperIT` | |
| 55 | +- [ ] **Step 4: Commit**:`feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002` | |
| 56 | + | |
| 57 | +### Task 2: UpdateUserDTO + UserService.update 主流程(合法 + 目标存在性 + bShowPerm null→false) | |
| 58 | + | |
| 59 | +**Files:** | |
| 60 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java` | |
| 61 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | |
| 62 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | |
| 63 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | |
| 64 | + | |
| 65 | +**API shape:** | |
| 66 | +- `UpdateUserDTO` 字段(与 CreateUserDTO 平行,去掉 `permissionCategoryIds` 之外字段语义不变;剔除 `sPasswordHash`,**仍包含** `permissionCategoryIds` 用于重建权限组) | |
| 67 | +- `UserService#update(Integer id, UpdateUserDTO dto) : Integer` | |
| 68 | +- 实现: | |
| 69 | + 1. `selectById(id)` → null 或 bDeleted=true → 40400 | |
| 70 | + 2. 枚举校验 sUserType / sLanguage(同 create)→ 40001 | |
| 71 | + 3. iStaffId 校验(同 create)→ 40022 | |
| 72 | + 4. permissionCategoryIds 校验(仅当非空 list;null/空 list 跳过校验直接清空)→ 40023 | |
| 73 | + 5. 构造 entity 仅 set iIncrement + 6 个可编辑字段(其余 null);`bCanModifyDocs` null → false | |
| 74 | + 6. `userMapper.updateById(entity)` try/catch DuplicateKeyException → 40020 | |
| 75 | + 7. `userPermissionMapper.deleteByUserId(id)` | |
| 76 | + 8. 若 permissionCategoryIds 非空:for-loop 插 UserPermission | |
| 77 | + | |
| 78 | +- [ ] **Step 1: 追加 4 单测** | |
| 79 | + - `updateWithValidDto_invokesUpdateById_andRebuildsPermissions` | |
| 80 | + - `updateWithTargetNotFound_throws40400` | |
| 81 | + - `updateWithTargetAlreadyDeleted_throws40400` | |
| 82 | + - `updateWithBCanModifyDocsNull_setsFalseInEntity` | |
| 83 | +- [ ] **Step 2: 实现 DTO + service 主流程** | |
| 84 | +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 8+4=12) | |
| 85 | +- [ ] **Step 4: Commit**:`feat(usr): user update dto + service happy path REQ-USR-002` | |
| 86 | + | |
| 87 | +### Task 3: Service 异常分支补全(枚举 / staff / permission / 唯一冲突 / 清空权限) | |
| 88 | + | |
| 89 | +**Files:** | |
| 90 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | |
| 91 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | |
| 92 | + | |
| 93 | +**API shape:** 不变 | |
| 94 | + | |
| 95 | +- [ ] **Step 1: 追加 6 单测** | |
| 96 | + - `updateWithInvalidUserType_throws40001` | |
| 97 | + - `updateWithInvalidLanguage_throws40001` | |
| 98 | + - `updateWithStaffNotFound_throws40022` | |
| 99 | + - `updateWithSomeInvalidPermissionIds_throws40023` | |
| 100 | + - `updateWithDuplicateUserNo_throws40020` | |
| 101 | + - `updateWithEmptyPermissionIds_clearsExisting` — permissionCategoryIds=null;deleteByUserId 调一次、insert 永不调 | |
| 102 | +- [ ] **Step 2: 实现校验分支** | |
| 103 | +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 12+6=18) | |
| 104 | +- [ ] **Step 4: Commit**:`feat(usr): user update error branches REQ-USR-002` | |
| 105 | + | |
| 106 | +### Task 4: Controller PUT + IT(10 用例)+ 全量回归 | |
| 107 | + | |
| 108 | +**Files:** | |
| 109 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | |
| 110 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | |
| 111 | + | |
| 112 | +**API shape:** | |
| 113 | +- `@PutMapping("/users/{id}") public Result<Map<String,Object>> update(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)` | |
| 114 | +- 返回 `Result.ok(Map.of("iIncrement", userService.update(id, dto)))` | |
| 115 | + | |
| 116 | +- [ ] **Step 1: 追加 10 IT**(参 spec 验收清单) | |
| 117 | +- [ ] **Step 2: 实现 controller PUT** | |
| 118 | +- [ ] **Step 3: 子会话跑全量回归**:`mvn -B test`(期望 89 + 1+10+10=20 = 109+ 用例全绿) | |
| 119 | +- [ ] **Step 4: Commit**:`test(usr): user update integration coverage REQ-USR-002` | |
| 120 | + | |
| 121 | +## 提交计划 | |
| 122 | + | |
| 123 | +| commit | 覆盖 | | |
| 124 | +|---|---| | |
| 125 | +| `feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002` | Task 1 | | |
| 126 | +| `feat(usr): user update dto + service happy path REQ-USR-002` | Task 2 | | |
| 127 | +| `feat(usr): user update error branches REQ-USR-002` | Task 3 | | |
| 128 | +| `test(usr): user update integration coverage REQ-USR-002` | Task 4 | | ... | ... |
docs/superpowers/plans/2026-04-30-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-04-30 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-003.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-003 用户查询 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `GET /api/usr/users` 单条件分页查询:tUser LEFT JOIN tStaff,按 field × match × value 动态过滤。 | |
| 12 | + | |
| 13 | +**Architecture:** 新增 `UserListVO` + `UserListQuery` 内部封装类(field/match/value/page)+ `UserMapper#pageWithFilter` 自定义动态 SQL + `UserService#list` + controller `@GetMapping`。 | |
| 14 | + | |
| 15 | +**Tech Stack:** 沿用;MyBatis 动态 SQL `<script>` + `<foreach>` / `<if>`。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +### 新增 | |
| 26 | + | |
| 27 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java` | |
| 28 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/UserListQuery.java`(内部封装规范化后的查询参数) | |
| 29 | + | |
| 30 | +### 修改 | |
| 31 | + | |
| 32 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `pageWithFilter` + `countWithFilter` | |
| 33 | +- `backend/src/main/resources/mapper/usr/UserMapper.xml` — 新建 XML(动态 SQL 复杂,注解 @Select 不易读) | |
| 34 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `list(...)` | |
| 35 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 list(字段映射 + 校验 + 调 mapper) | |
| 36 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 GET 端点 | |
| 37 | +- 测试:UserMapperIT(+1) / UserServiceImplTest(+10) / UserControllerIT(+8) | |
| 38 | + | |
| 39 | +## 任务步骤 | |
| 40 | + | |
| 41 | +### Task 1: UserListVO + UserMapper#pageWithFilter / countWithFilter + IT | |
| 42 | + | |
| 43 | +**Files:** | |
| 44 | +- Create: vo/UserListVO.java | |
| 45 | +- Modify: mapper/UserMapper.java + resources/mapper/usr/UserMapper.xml | |
| 46 | +- Modify: test/.../mapper/UserMapperIT.java | |
| 47 | + | |
| 48 | +**API shape:** | |
| 49 | +- `UserListVO` 11 字段(参 spec),@JsonProperty 锁定 JSON 名 | |
| 50 | +- `UserMapper.pageWithFilter`(XML)参数:`@Param("field") String physicalCol, @Param("match") String matchOp, @Param("value") Object value, @Param("offset") int offset, @Param("size") int size`;`physicalCol` 由 service 映射成 `u.sUserName` / `s.sStaffName` 等 | |
| 51 | +- `UserMapper.countWithFilter` 同参数(除 offset/size) | |
| 52 | + | |
| 53 | +**XML 关键结构**: | |
| 54 | +```xml | |
| 55 | +<select id="pageWithFilter" resultType="com.xly.erp.module.usr.vo.UserListVO"> | |
| 56 | + SELECT u.iIncrement, u.sUserName, s.sStaffName AS staffName, u.sUserNo, | |
| 57 | + s.sDepartment AS department, u.sUserType, u.sLanguage, | |
| 58 | + u.bDeleted, u.tLastLoginDate, u.sCreatedBy, u.tCreateDate | |
| 59 | + FROM tUser u | |
| 60 | + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0 | |
| 61 | + <where> | |
| 62 | + <if test="value != null and value != ''"> | |
| 63 | + <choose> | |
| 64 | + <when test="matchOp == 'contains'">${field} LIKE CONCAT('%', #{value}, '%')</when> | |
| 65 | + <when test="matchOp == 'notContains'">${field} NOT LIKE CONCAT('%', #{value}, '%')</when> | |
| 66 | + <when test="matchOp == 'equals'"> | |
| 67 | + <choose> | |
| 68 | + <when test="field == 'DATE(u.tLastLoginDate)'">${field} = #{value}</when> | |
| 69 | + <otherwise>${field} = #{value}</otherwise> | |
| 70 | + </choose> | |
| 71 | + </when> | |
| 72 | + </choose> | |
| 73 | + </if> | |
| 74 | + </where> | |
| 75 | + ORDER BY u.iIncrement DESC | |
| 76 | + LIMIT #{offset}, #{size} | |
| 77 | +</select> | |
| 78 | +``` | |
| 79 | +(XML 复杂度由 service 提供归一化的 `field`(物理列名 with prefix) / `matchOp`(英文常量) / `value` 后大幅简化) | |
| 80 | + | |
| 81 | +- [ ] **Step 1: 写失败 IT `pageWithFilter_filtersAndJoins`** | |
| 82 | +- [ ] **Step 2: 实现 entity/VO + mapper + XML** | |
| 83 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 84 | +- [ ] **Step 4: Commit**:`feat(usr): user list mapper + vo with join REQ-USR-003` | |
| 85 | + | |
| 86 | +### Task 2: UserListQuery + UserService.list 主流程(合法 + 字段映射) | |
| 87 | + | |
| 88 | +**Files:** | |
| 89 | +- Create: dto/UserListQuery.java(service 内部) | |
| 90 | +- Modify: service/UserService.java + impl/UserServiceImpl.java | |
| 91 | +- Modify: test/.../service/UserServiceImplTest.java | |
| 92 | + | |
| 93 | +**API shape:** | |
| 94 | +- `UserService#list(String field, String match, String value, Integer pageNum, Integer pageSize) : Map<String, Object>`(返回 records/total/pageNum/pageSize) | |
| 95 | +- 内部:归一化(默认值/trim)→ field/match 校验 → value 解析(日期/布尔)→ 调 mapper.countWithFilter + pageWithFilter → 装结果 Map | |
| 96 | + | |
| 97 | +**字段映射表**(service 静态常量): | |
| 98 | +``` | |
| 99 | +"用户名" -> ("u.sUserName", STRING) | |
| 100 | +"员工名" -> ("s.sStaffName", STRING) | |
| 101 | +"用户号" -> ("u.sUserNo", STRING) | |
| 102 | +"部门" -> ("s.sDepartment", STRING) | |
| 103 | +"用户类型" -> ("u.sUserType", STRING) | |
| 104 | +"作废" -> ("u.bDeleted", BOOLEAN) | |
| 105 | +"登录日期" -> ("DATE(u.tLastLoginDate)", DATE) | |
| 106 | +"制单人" -> ("u.sCreatedBy", STRING) | |
| 107 | +``` | |
| 108 | +match 映射:包含→`contains`,不包含→`notContains`,等于→`equals` | |
| 109 | + | |
| 110 | +**校验顺序**:pageSize 上限 → field 枚举 → match 枚举 → field/match 兼容(布尔/日期仅 equals)→ value 解析(日期) | |
| 111 | + | |
| 112 | +- [ ] **Step 1: 写失败测试 4 条主流程用例** | |
| 113 | + - listWithDefaults_invokesMapperWithUserNameContainsEmpty | |
| 114 | + - listWithEmptyValue_skipsFilterCondition | |
| 115 | + - listWithKeywordTrim | |
| 116 | + - listReturnsEmptyRecords_whenMapperReturnsEmptyPage | |
| 117 | +- [ ] **Step 2: 实现 service** | |
| 118 | +- [ ] **Step 3: 子会话验证 PASS** | |
| 119 | +- [ ] **Step 4: Commit**:`feat(usr): user list service + field/match mapping REQ-USR-003` | |
| 120 | + | |
| 121 | +### Task 3: Service 异常分支(field/match/兼容/pageSize/日期格式/布尔) | |
| 122 | + | |
| 123 | +- [ ] **Step 1: 追加 6 用例** | |
| 124 | + - listWithInvalidField_throws40001 | |
| 125 | + - listWithInvalidMatch_throws40001 | |
| 126 | + - listWithIncompatibleFieldMatch_throws40001 | |
| 127 | + - listWithPageSizeExceeds100_throws40002 | |
| 128 | + - listWithInvalidLoginDateFormat_throws40001 | |
| 129 | + - listWithBooleanFieldEqualsTrue_passesIntegerOne | |
| 130 | +- [ ] **Step 2: 实现校验分支** | |
| 131 | +- [ ] **Step 3: 子会话验证 PASS**(期望 18 + 4 + 6 = 28 用例) | |
| 132 | +- [ ] **Step 4: Commit**:`feat(usr): user list error branches REQ-USR-003` | |
| 133 | + | |
| 134 | +### Task 4: Controller GET + 8 IT + 全量回归 | |
| 135 | + | |
| 136 | +- [ ] **Step 1: 追加 8 IT**(参 spec 验收清单) | |
| 137 | +- [ ] **Step 2: 实现 controller GET** | |
| 138 | +- [ ] **Step 3: 子会话跑全量回归**(期望 ≥ 129 用例) | |
| 139 | +- [ ] **Step 4: Commit**:`test(usr): user list integration coverage REQ-USR-003` | |
| 140 | + | |
| 141 | +## 提交计划 | |
| 142 | + | |
| 143 | +| commit | 覆盖 | | |
| 144 | +|---|---| | |
| 145 | +| `feat(usr): user list mapper + vo with join REQ-USR-003` | Task 1 | | |
| 146 | +| `feat(usr): user list service + field/match mapping REQ-USR-003` | Task 2 | | |
| 147 | +| `feat(usr): user list error branches REQ-USR-003` | Task 3 | | |
| 148 | +| `test(usr): user list integration coverage REQ-USR-003` | Task 4 | | ... | ... |
docs/superpowers/plans/2026-04-30-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-04-30 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-004.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-004 用户登录 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | |
| 10 | + | |
| 11 | +**Goal:** Phase A 实现 POST /api/usr/auth/login + 失败计数锁定 + JWT 双 token;Phase B 闭环 stub permitAll → authenticated。 | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## Schema 改动 | |
| 16 | + | |
| 17 | +无。 | |
| 18 | + | |
| 19 | +## 文件变更清单 | |
| 20 | + | |
| 21 | +### 新增 | |
| 22 | + | |
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java | |
| 24 | +- backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java | |
| 25 | +- backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java | |
| 26 | +- backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java | |
| 27 | +- backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java | |
| 28 | +- backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java | |
| 29 | +- backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java | |
| 30 | +- backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java | |
| 31 | + | |
| 32 | +### 修改 | |
| 33 | + | |
| 34 | +- common/security/JwtUtil.java — 加 `signRefresh(userNo)` + 区分 access/refresh TTL | |
| 35 | +- common/security/SecurityConfig.java — 重写路径规则 + 注册 entryPoint | |
| 36 | +- module/usr/mapper/UserMapper.java — 加 `selectByUserName` + `updateLastLoginDate` | |
| 37 | +- module/usr/service/UserService.java + impl — 加 `login(LoginDTO)` | |
| 38 | +- module/mod/service/impl/ModuleServiceImpl.java — 移除 stub fallback | |
| 39 | +- module/usr/service/impl/UserServiceImpl.java — 移除 stub fallback(create + update) | |
| 40 | +- ModuleControllerIT (4 处) + UserControllerIT (3 处) — 改 stub 期望 | |
| 41 | + | |
| 42 | +--- | |
| 43 | + | |
| 44 | +## 任务步骤 | |
| 45 | + | |
| 46 | +### Phase A: 登录接口本体 | |
| 47 | + | |
| 48 | +#### Task 1: JwtUtil 加 signRefresh | |
| 49 | + | |
| 50 | +- 加 `Duration REFRESH_TTL = Duration.ofDays(30)` + `sign(userNo, ttl)` 通用方法 + `signRefresh(userNo)` 包装;现有 `sign(userNo)` 走 `Duration.ofHours(8)` | |
| 51 | +- 单测:`signRefresh_signsWithLongerTtl` | |
| 52 | +- Commit: `feat(usr): jwtutil signRefresh REQ-USR-004` | |
| 53 | + | |
| 54 | +#### Task 2: LoginAttemptStore + 单测 | |
| 55 | + | |
| 56 | +- 新建 `@Component LoginAttemptStore`:`recordFailure(userNo) → newCount` / `isLocked(userNo) → Optional<Long secondsRemaining>` / `clearFailures(userNo)`;常量 `MAX_ATTEMPTS=5` / `LOCK_DURATION=Duration.ofMinutes(15)` | |
| 57 | +- 单测 5 用例覆盖隔离 / 累加 / 锁定 / 自动解锁 / 清空 | |
| 58 | +- Commit: `feat(usr): login attempt store + lock logic REQ-USR-004` | |
| 59 | + | |
| 60 | +#### Task 3: UserMapper 加 selectByUserName + updateLastLoginDate | |
| 61 | + | |
| 62 | +- `@Select("SELECT ... FROM tUser WHERE sUserName = #{name}")` `User selectByUserName(String name)` | |
| 63 | +- `@Update("UPDATE tUser SET tLastLoginDate = #{ts} WHERE iIncrement = #{id}")` `int updateLastLoginDate(Integer id, LocalDateTime ts)` | |
| 64 | +- IT 2 用例 | |
| 65 | +- Commit: `feat(usr): mapper selectByUserName + updateLastLoginDate REQ-USR-004` | |
| 66 | + | |
| 67 | +#### Task 4: LoginDTO + LoginVO + UserBriefVO + UserService.login + 单测 | |
| 68 | + | |
| 69 | +- 6 个 service 单测(spec 列表) | |
| 70 | +- Commit: `feat(usr): login service + dto/vo REQ-USR-004` | |
| 71 | + | |
| 72 | +#### Task 5: AuthController + IT (5 用例) | |
| 73 | + | |
| 74 | +- 新建 `AuthController @RequestMapping("/api/usr/auth")`,`@PostMapping("/login")` | |
| 75 | +- IT 5 用例覆盖 spec | |
| 76 | +- Commit: `feat(usr): auth controller + login it REQ-USR-004` | |
| 77 | + | |
| 78 | +### Phase B: Stub 闭环 | |
| 79 | + | |
| 80 | +#### Task 6: SecurityConfig 收紧 + AuthenticationEntryPoint | |
| 81 | + | |
| 82 | +- `JwtAuthenticationEntryPoint`:未认证写 JSON `Result.fail(20001, "未认证")` + status 200 | |
| 83 | +- SecurityConfig: | |
| 84 | + - 移除 `/api/mod/**` + `/api/usr/**` permitAll | |
| 85 | + - 加 `/api/usr/auth/login` permitAll | |
| 86 | + - `anyRequest().authenticated()` | |
| 87 | + - `exceptionHandling(eh -> eh.authenticationEntryPoint(authEntryPoint))` | |
| 88 | +- 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 注释 | |
| 89 | +- Commit: `refactor(usr): tighten security to authenticated REQ-USR-004` | |
| 90 | + | |
| 91 | +#### Task 7: ModuleServiceImpl + UserServiceImpl 移除 stub fallback | |
| 92 | + | |
| 93 | +- `ModuleServiceImpl#create`:`sCreatedBy = SecurityContextHelper.currentUserNo()`,去掉 `?: stub.getStubUserNo()`;同 `UserServiceImpl#create` + `update` | |
| 94 | +- 不破坏 `StubSecurityProperties` bean 本身(仍可保留以防其他用途;本 REQ 仅停止 fallback 引用) | |
| 95 | +- 单测调整:MOD-001 `createWithValidDto_persistsWithStandardCols` 期望 sCreatedBy 不再是 STUB_ADMIN(而要在测试里手动 SecurityContextHolder.set principal);其他类似 | |
| 96 | +- Commit: `refactor(usr): remove stub fallback in services REQ-USR-004` | |
| 97 | + | |
| 98 | +#### Task 8: 修改现有 stub IT 期望 | |
| 99 | + | |
| 100 | +- ModuleControllerIT 4 条:`postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` / `putWithoutJwt_*` / `deleteWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001`,DB 无新增行 | |
| 101 | +- UserControllerIT 3 条:`postWithoutJwt_*` / `putWithoutJwt_*` / `getWithoutJwt_*` → 改期望 `code=20001` | |
| 102 | +- 全量回归绿 | |
| 103 | +- Commit: `test(usr): update stub regression to authenticated REQ-USR-004` | |
| 104 | + | |
| 105 | +## 提交计划 | |
| 106 | + | |
| 107 | +8 commits:6 feat + 2 refactor + 1 test(或合并为更少 commit 视进度)。 | ... | ... |
docs/superpowers/reviews/2026-04-30-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-04-30 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-001 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- docs/superpowers/specs/2026-04-30-REQ-USR-001.md § 实现范围与边界抉择 #3 + § 验收标准 工程验收 #2 写「BCryptPasswordEncoder bean 在 SecurityConfig 注册」,但实现已移到 `common/config/PasswordEncoderConfig.java`(理由:SecurityConfig 上 `@ConditionalOnWebApplication(SERVLET)` 在 `webEnvironment=NONE` 的 mapperIT/serviceTest 上下文不加载会让 encoder 缺失)。**正向设计修正**——密码编码器是领域基础设施而非 Web 安全基础设施。建议把 spec 这两处改为「BCryptPasswordEncoder 在 common/config/PasswordEncoderConfig 注册」,并在 PasswordEncoderConfig.java 类注释里写一行可追溯说明。 | |
| 19 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:104 — 循环里每次 `LocalDateTime.now()` 让 N 行 tUserPermission 的 `tCreateDate` 出现微秒级偏差。建议在 create 入口取一次 `LocalDateTime now`,user + 全部 permission 共享,更贴合「同一事务一次创建」的语义。 | |
| 20 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:69-75 — `permissionCategoryIds` 含重复 id(如 `[1,1,2]` 全部有效)时 SQL IN 隐式去重 → `countActiveByIds=2 ≠ ids.size=3` → 误抛 40023 假阴性。spec 没要求去重;建议 service 层先 `ids = ids.stream().distinct().toList()`,4 行修复。 | |
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:73-75 — `postValidBody_with_jwt_returns200_andPersists` 没断言响应 `data` 中不含 `sPasswordHash`。spec § 验收 工程 #5 明言「不返回 sPasswordHash」。建议加 `assertThat(jb.get("data").has("sPasswordHash")).isFalse()` 锁住该不变量,未来若有人改 Map.of 加字段不会无声泄漏。 | |
| 22 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:170-198 — `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` / `postTamperedJwt_returns20001` 沿袭 MOD 模块 stub 路径仍缺 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点。建议在 module-report 阶段统一一次性补齐(与 MOD-004 review 同处理)。 | |
| 23 | +- backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java:26 — stub 锚点放在 `/api/mod/**` 与 `/api/usr/**` 共同上方。USR-004 实际收紧时需要分两条 requestMatchers 各自指向 hasAuthority/anyRequest,建议在 USR-004 plan 阶段显式记一笔回填动作。 | |
| 24 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:59 — `create` 方法体缺 `// REQ-USR-001: 用户新增` 行内锚点(与 MOD 模块同源遗漏)。建议在 module-report 阶段一次性把 USR / MOD 两个模块的 REQ 行内锚点统一补齐。 | |
| 25 | + | |
| 26 | +## 反例 / 测试覆盖缺口 | |
| 27 | + | |
| 28 | +Spec 验收清单(service 8 + mapperIT 4 + controllerIT 9 + 工程 5)100% 落地,sourcing `.env.local` 后 `mvn -B test` 全量 89/89 全绿。 | |
| 29 | + | |
| 30 | +- BCryptPasswordEncoder 重定位是必要正向修正,被 mapperIT × 5(NONE 环境)+ serviceTest × 8 + controllerIT × 9(RANDOM_PORT 环境)双向覆盖。 | |
| 31 | +- 校验顺序(type/lang 枚举 → staff 存在 → permission 存在 → INSERT user catch DuplicateKey → INSERT permission × N)严格匹配 spec § 业务规则 1–6,`@Transactional(rollbackFor=Exception.class)` 包整个流程。 | |
| 32 | +- `permissionCategoryMapper.countActiveByIds` 由 service 短路空 list 保护,避免 SQL `IN ()` 错误。 | |
| 33 | +- N+1 INSERT tUserPermission 是 spec § 业务规则 #7 显式 YAGNI 取舍;docs/04 § 3.4「循环中禁止执行 DB 查询」语义针对 SELECT N+1 不是 INSERT。 | |
| 34 | +- `sPasswordHash` 由 service 返回 Map 仅 put 两个 key 不会泄漏(构造保证),但缺直接断言锁不变量。 | |
| 35 | +- BCrypt 每次 salt 不同——单测用真实 `BCryptPasswordEncoder` + `startsWith("$2a$")` 断言(非 mock),正确。 | |
| 36 | +- IT cleanup 顺序 tUserPermission → tUser → tStaff → tPermissionCategory 满足 FK(iUserId CASCADE / iStaffId SET NULL / iCategoryId RESTRICT),无外键孤儿。 | |
| 37 | +- SecurityContextHelper 匿名处理与 MOD 同款,stub 锚点缺失沿袭 MOD-004 已点出的范畴。 | |
| 38 | + | |
| 39 | +非阻塞缺口:spec 文字与实现 BCryptPasswordEncoder 位置不一致(见 nice-to-have #1);`sPasswordHash` regression 锁断言(#4);permissionCategoryIds 重复值假阴性(#3);行内 REQ 锚点 + stub 锚点统一补齐(留 module-report)。 | ... | ... |
docs/superpowers/reviews/2026-04-30-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-04-30 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-002 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:134-140 — 与 USR-001 review #3 同源:permissionCategoryIds=[1,1,2] 全有效时 SQL IN 隐式去重 → countActiveByIds=2 ≠ ids.size=3 → 误抛 40023 假阴性。建议 ids = ids.stream().distinct().toList() 4 行修复,或 module-report 阶段统一处理两处。 | |
| 19 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:200-229 — `putValidBody_with_jwt_returns200_andUpdates` 已断言 sPasswordHash / sCreatedBy 保留,漏 tCreateDate 与 sBrandsId 锁。spec § 验收 工程 #2 显列 'sPasswordHash / sCreatedBy / tCreateDate 在 PUT 前后字面相同'。建议 SELECT 加这两列断言保留原值。 | |
| 20 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:200-229 — 类似 USR-001 review #4:响应 data 缺 sPasswordHash 不变量没断言。建议加 `assertThat(jb.get("data").has("sPasswordHash")).isFalse()`。 | |
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java:305-317 — `updateWithEmptyPermissionIds_clearsExisting` 只覆盖 null 路径,未覆盖 `[]` 空 list 路径。spec § 输入末行明言 'null / 空 list → 清空' 是同等语义。建议补等价 case 或 parameterize。 | |
| 22 | +- backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java — 类头缺 JavaDoc 说明 sPasswordHash 故意剔除(spec 显列 'API 契约声明该字段不可改')。两行注释帮助未来读者理解 DTO 与 CreateUserDTO 字段差异。 | |
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:120 — `update` 方法体缺 `// REQ-USR-002: 用户修改` 行内锚点(与 USR-001 review #7 同源)。建议 module-report 阶段统一补齐 USR/MOD 所有 REQ 行内锚点。 | |
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:342-371 — `putWithoutJwt_permitAllStub_*` / `putTamperedJwt_*` 沿袭 stub 路径仍缺 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点;与上条统一 module-report 处理。 | |
| 25 | + | |
| 26 | +## 反例 / 测试覆盖缺口 | |
| 27 | + | |
| 28 | +Spec 验收清单(service 10 + mapperIT 1 + controllerIT 10)100% 落地,mvn 110/110 全绿。 | |
| 29 | + | |
| 30 | +- 重建权限组策略(先 deleteByUserId 后 batch INSERT)严格匹配 spec § 业务规则 #6,`@Transactional(rollbackFor = Exception.class)` 包整流程。 | |
| 31 | +- 流程顺序(selectById 存在性 → 枚举 → staff → permission → updateById catch DupKey → deleteByUserId → INSERT × N)严格匹配 spec § 业务规则 1–7。 | |
| 32 | +- updateById 抛 DupKey 后 deleteByUserId 不被调用由 `verify(userPermissionMapper, never()).deleteByUserId(any())` 单测显式锁定,事务回滚由 @Transactional 保证。 | |
| 33 | +- 不可改字段(sPasswordHash / sCreatedBy / tCreateDate / sBrandsId / sSubsidiaryId)依赖 MyBatis-Plus 默认 NOT_NULL 跳过 null 字段策略;service 单测用 ArgumentCaptor 断言这些字段在 entity 上为 null。 | |
| 34 | +- permissionCategoryIds null / 空 list 通过 `ids != null && !ids.isEmpty()` 短路同时跳过校验与 INSERT 但仍执行 deleteByUserId,正确实现「清空」语义。 | |
| 35 | +- LocalDateTime.now() 已 hoist 出 INSERT 循环(修正 USR-001 review nice-to-have #2)。 | |
| 36 | + | |
| 37 | +非阻塞缺口:permissionCategoryIds 重复 id 假阴性、sPasswordHash 响应不变量锁、tCreateDate/sBrandsId 保留断言、`[]` 空 list 等价路径覆盖、行内 REQ 锚点 + stub 锚点。前 4 条可在本 REQ 后续修补,后 2 条按既定路径留 module-report 统一处理。 | ... | ... |
docs/superpowers/reviews/2026-04-30-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-04-30 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-003 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:374-389 — `getDefaults_with_jwt_returns200_andList` 未断言 records[0] 不含 `sPasswordHash`。当前由 UserListVO 字段集(11 字段)静态保证,但缺防御性 IT。建议加 `assertThat(records.get(0).has("sPasswordHash")).isFalse()`。 | |
| 19 | +- backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java:119-122 — `pageWithFilter_filtersAndJoins` 只断言 `row2.staffName == null`,未断言 `row2.department == null`。两者都是 LEFT JOIN,建议补全。 | |
| 20 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:253-259 — `parseBoolean` 对非法 value(如 "abc")返回 -1,SQL `u.bDeleted = -1` → 0 结果,符合 spec line 91 "视为不命中",但缺单测固化。建议加 `listWithBooleanFieldEqualsInvalid_passesNegativeOne`。 | |
| 21 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:374-389 — `tLastLoginDate` JSON 序列化格式无端到端断言。LocalDateTime 默认 Jackson 行为依赖 `spring.jackson.serialization.write-dates-as-timestamps`;如全局未显式 ISO 配置,可能回归出 `[2026,4,30,9,0]` 数组形式。建议加 `assertThat(records.get(0).get("tCreateDate").asText()).matches("\\d{4}-\\d{2}-\\d{2}T.*")`。 | |
| 22 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:374-389 — 默认 ORDER BY iIncrement DESC 无显式断言。建议在 getDefaults 中加 records 中 iIncrement 单调递减的断言形成回归保护。 | |
| 23 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — Controller IT 中 'String token = ... HttpHeaders headers = ... headers.set(Authorization,...)' 模板代码重复 ~17 次。沿用 USR-001/002 dedupe 主题。建议抽 `private HttpHeaders authedJsonHeaders(String userNo)` helper。 | |
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:392-405 — `getKeywordContains_filtersByUsername` 只插入 1 条匹配,但断言 forEach 检查每条含'包含查询'。如果 records 为空 forEach 0 次会 vacuously pass。建议加 `assertThat(records.size()).isPositive()`。 | |
| 25 | +- Plan § 文件变更清单 line 28 明列 'Create: dto/UserListQuery.java',实际实现把 5 参直接传 service。无 DTO 反而更简洁(局部变量足够)。属于 § 通用准则 §2 Simplicity First 合理体现。建议在 module-report § ⑤ 记一笔设计抉择回顾,不需要回填 DTO。 | |
| 26 | + | |
| 27 | +## 反例 / 测试覆盖缺口 | |
| 28 | + | |
| 29 | +Spec 接受标准 19 条全部映射到测试代码(service 10 + mapperIT 1 + controllerIT 8),mvn test 129/129 全绿。 | |
| 30 | + | |
| 31 | +- **SQL 注入安全姿态**:`${field}` / `${matchOp}` 都来自 service 端 FIELD_MAP / MATCH_MAP 白名单常量;外部用户输入只用作 Map.get 的 key,不命中即 40001;用户输入 `value` 走 `#{value}` 参数化。无注入风险。 | |
| 32 | +- **field × match 兼容矩阵**(布尔/日期仅 equals)实现于 UserServiceImpl line 222,`listWithIncompatibleFieldMatch_throws40001` 覆盖。 | |
| 33 | +- **不过滤 bDeleted=0** 是 spec 显式设计抉择(作废本身是查询字段),代码与 spec 一致。 | |
| 34 | +- **计划偏离**:UserListQuery DTO 未创建(plan 列了,实现把 5 参直接传 service)—— 简化合理,无 must-fix。 | |
| 35 | + | |
| 36 | +非阻塞缺口(VO 敏感字段断言 / JSON 时间格式断言 / 排序断言 / IT dedupe / count assertion)均为健壮性补强,不阻塞 approve。 | ... | ... |
docs/superpowers/reviews/2026-04-30-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-04-30 | |
| 4 | +round: 1 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-004 — round 1 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +## Nice-to-have | |
| 17 | + | |
| 18 | +- backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:33 + UserServiceImpl.java:72 — 移除 fallback 后 `private final StubSecurityProperties stub` 字段已无引用。按 CLAUDE.md surgical-changes 原则建议删除字段 + 构造器参数(测试 setUp 也需同步)。本期保留亦可,留做后续 housekeeping。 | |
| 19 | +- backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java:44-59 — `lockExpiresAfterDuration` 只验证另一个 fresh user 在 T+20min 未锁,未驱动同 userName 跨过 lockExpireAt 验证 records.remove 自动清除路径。spec line 117 `loginAfterLockExpired_resetsCounter` 要求;建议加测试用 Clock 推进到 T+15min 以上的同 key 路径。 | |
| 20 | +- ModuleControllerIT / UserControllerIT 的 PUT 无 JWT 闭环回归断言只覆盖单列(sModuleNameZh / sUserName)unchanged;建议同时断 sCreatedBy / tCreateDate 也保持以增强回归覆盖。 | |
| 21 | +- backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java:17 — `version` 字段标 `@NotBlank`,但 spec § 输入 写"version 仅记录, 不参与校验"。建议二选一:去 @NotBlank 或更新 spec 标注必填。 | |
| 22 | +- backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java `loginWithEmptyBody_returns40001` 发空对象同时触发 3 个 @NotBlank;建议细粒度补一个仅缺 sUserName 的负例,验证 GlobalExceptionHandler 的字段定位文案。 | |
| 23 | +- AuthControllerIT.loginAfter5WrongPasswords (line 127) 的 `loginAttemptStore.clearFailures("锁定用户")` 在测试体末尾才执行;assertion 失败时跳过会污染跨测试状态。建议挪到 @AfterEach 或用唯一 userName。 | |
| 24 | + | |
| 25 | +## 反例 / 测试覆盖缺口 | |
| 26 | + | |
| 27 | +Phase A: 5 controller IT + 9 service 单测 + 5 store 单测 + 2 mapper IT 全部到位。 | |
| 28 | +Phase B: 4 ModuleControllerIT + 3 UserControllerIT stub 闭环全部更新。 | |
| 29 | +- SecurityConfig 收紧到仅 `POST /api/usr/auth/login` permitAll,其余 authenticated;JwtAuthenticationEntryPoint 把未认证转 `code=20001`。 | |
| 30 | +- service 层 `stub.getStubUserNo()` 引用和 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点全部从代码中清除(grep 0 结果)。 | |
| 31 | +- JWT 双 token:accessToken 8h / refreshToken 30d,二者 subject 相同但内容不同。 | |
| 32 | +- 锁定路径:密码正确但锁定中仍返回 42301(spec § 业务规则 #4 ✓)。 | |
| 33 | +- 信息泄漏防护:用户不存在 / 密码错均返回 40101(不区分)。 | |
| 34 | +- LoginVO 排除 sPasswordHash,AuthControllerIT 显式断言 `data.user.has("sPasswordHash")` 为 false。 | |
| 35 | +- 149/149 全绿(mvn test via scripts/test.sh)。 | |
| 36 | + | |
| 37 | +非阻塞缺口:见 nice-to-have 6 项,建议 module-report 阶段一次性批量补齐。 | ... | ... |
docs/superpowers/specs/2026-04-30-REQ-USR-001.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-001 | |
| 3 | +date: 2026-04-30 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-001 — 用户新增 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +录入新用户基本信息 + 初始化默认密码(`666666` 的 BCrypt 哈希)+ 同步建立用户与权限分类的多对多关联,返回新用户 `iIncrement` + `sUserNo`。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +### HTTP 接口(docs/05 § REQ-USR-001) | |
| 16 | + | |
| 17 | +- Method / Path: `POST /api/usr/users` | |
| 18 | +- Auth: 必需(沿用 MOD 模块 stub:本 REQ 在 SecurityConfig 加 `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`) | |
| 19 | +- Permission: 仅超级管理员(stub 期不强制) | |
| 20 | + | |
| 21 | +### 请求 DTO `CreateUserDTO` | |
| 22 | + | |
| 23 | +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | | |
| 24 | +|---|---|---|---|---| | |
| 25 | +| `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` | | |
| 26 | +| `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` | | |
| 27 | +| `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中 `tStaff` 中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022` | | |
| 28 | +| `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001`(沿用 docs/05 错误码列表) | | |
| 29 | +| `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]`(代码值,前端做 i18n 标签映射)内;非法 → `40001` | | |
| 30 | +| `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` | | |
| 31 | +| `permissionCategoryIds` | `List<Integer>` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`。空 list / null → 不建关联 | | |
| 32 | + | |
| 33 | +### 鉴权与上下文 | |
| 34 | + | |
| 35 | +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sCreatedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD 模块同策略)。 | |
| 36 | + | |
| 37 | +## 输出 / 结果 | |
| 38 | + | |
| 39 | +### 成功响应 | |
| 40 | + | |
| 41 | +```json | |
| 42 | +{ "code": 0, "msg": "ok", "data": { "iIncrement": 456, "sUserNo": "u001" } } | |
| 43 | +``` | |
| 44 | + | |
| 45 | +### 持久化效果 | |
| 46 | + | |
| 47 | +事务内两步: | |
| 48 | + | |
| 49 | +1. INSERT `tUser`:DTO 字段 + 标准列 + `sPasswordHash = bcrypt("666666")` + `tLastLoginDate=NULL` + `bDeleted=0` | |
| 50 | +2. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId=新用户.iIncrement, iCategoryId=id, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置默认)` | |
| 51 | + | |
| 52 | +| `tUser` 字段 | 来源 | | |
| 53 | +|---|---| | |
| 54 | +| `iIncrement` | DB 自增 | | |
| 55 | +| `sId` | NULL | | |
| 56 | +| `sBrandsId` / `sSubsidiaryId` | `TenantProperties`(XLY/XLY) | | |
| 57 | +| `tCreateDate` | `LocalDateTime.now()` | | |
| 58 | +| `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 | | |
| 59 | +| `sPasswordHash` | `BCryptPasswordEncoder.encode("666666")`(每次 hash salt 不同) | | |
| 60 | +| `tLastLoginDate` | NULL(USR-004 登录时更新) | | |
| 61 | +| `sCreatedBy` | JWT principal 或 stub | | |
| 62 | +| `bDeleted` | `0` | | |
| 63 | +| `tDeletedDate` / `sDeletedBy` | NULL | | |
| 64 | + | |
| 65 | +## 业务规则 | |
| 66 | + | |
| 67 | +1. **唯一性策略**:DB `uk_user_no` + `uk_user_name` 包含已软删行;本期不支持"软删后用户号复用"——若 sUserNo 历史上有过删除记录,再次创建会被 DB 唯一索引拒绝。docs/03 § tUser 业务注记说"应用层保证(仅约束未删除部分)"是设计意向,本 REQ 实现保持与 V1 schema 一致:依赖 DB 唯一索引兜底,service 层捕获 `DuplicateKeyException` → `BizException(40020,"用户号或用户名已存在")`,不再做"先查后插"二次校验(避免竞态)。 | |
| 68 | +2. **iStaffId 校验**:非 null 时调 `staffMapper.existsActiveById(iStaffId)`;false → `BizException(40022,"职员不存在或已删除")`。 | |
| 69 | +3. **permissionCategoryIds 校验**:非空时一次性查 `permissionCategoryMapper.countActiveByIds(ids)`,若返回数 != ids.size → `BizException(40023,"权限分类含无效 id")`。**避免 N+1**(一条 IN 查询)。 | |
| 70 | +4. **sUserType / sLanguage 枚举**:service 入口处用 `Set.contains` 校验;非法 → `BizException(40001, "<字段名>: 取值非法")`。 | |
| 71 | +5. **密码哈希**:使用 Spring Security 的 `BCryptPasswordEncoder`(已通过 starter-security 引入),强度默认 10。`@Bean BCryptPasswordEncoder` 在 `SecurityConfig` 注册(仅本 REQ 引入,避免循环依赖,参 § 实现范围抉择)。 | |
| 72 | +6. **事务**:service 上 `@Transactional(rollbackFor = Exception.class)`,包"校验 + INSERT user + INSERT user_permission * N"。任一步骤失败回滚,不留残行。 | |
| 73 | +7. **批量插入策略**:本 REQ 用 `for (Integer id : ids) userPermissionMapper.insert(rec)` 简单循环。permissionCategoryIds 典型 < 50,N+1 影响可接受。后续若性能瓶颈再改 batch INSERT。 | |
| 74 | + | |
| 75 | +## 边界与约束 | |
| 76 | + | |
| 77 | +- **必填项缺失** → `40001` | |
| 78 | +- **`sUserType` / `sLanguage` 非枚举** → `40001` | |
| 79 | +- **`sUserNo` / `sUserName` 唯一冲突** → `40020` | |
| 80 | +- **`iStaffId` 不存在 / 已软删** → `40022` | |
| 81 | +- **`permissionCategoryIds` 含无效 id / 已软删** → `40023` | |
| 82 | +- **JWT 伪造** → `20001` | |
| 83 | +- **JWT 缺失** → permitAll stub(USR-004 后改 401) | |
| 84 | +- **不返回 `sPasswordHash`**:响应 data 仅含 `iIncrement` + `sUserNo`,避免哈希泄漏 | |
| 85 | + | |
| 86 | +## 实现范围与边界抉择 | |
| 87 | + | |
| 88 | +1. **复用 MOD 模块工程**:无新增 pom 依赖;`backend/src/main/java/com/xly/erp/module/usr/` 全新模块树,与 `module/mod/` 平行。 | |
| 89 | +2. **SecurityConfig 路径扩展**:在现有 `/api/mod/**` permitAll 同位加 `/api/usr/**` permitAll,stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`(USR-004 时整段一次性收紧)。 | |
| 90 | +3. **`BCryptPasswordEncoder` 注册位置**:本 REQ 在 `SecurityConfig` 加 `@Bean BCryptPasswordEncoder`,service 通过构造器注入。USR-004 登录接口同样依赖此 bean,无重复定义。 | |
| 91 | +4. **Staff / PermissionCategory 仅做存在性校验**:本 REQ 不建 `Staff` / `PermissionCategory` 完整 entity,仅建 `StaffMapper` / `PermissionCategoryMapper` 两个最小化接口(注解 SELECT 1 / SELECT COUNT)。后续 USR-002/003 真正用到完整字段时再补 entity。 | |
| 92 | +5. **唯一冲突处理走 DB 索引兜底**:与 MOD-001 `sProcedureName` 风格一致;不做"先查后插"避免竞态。 | |
| 93 | +6. **批量插入 `tUserPermission` 暂用循环**:本 REQ 数据量小,YAGNI;性能问题后续 REQ 出现时再优化。 | |
| 94 | +7. **测试用 stub JWT 注入**:复用 `TestJwtHelper`,无需新建。 | |
| 95 | + | |
| 96 | +## 依赖的 schema 表 / 字段 | |
| 97 | + | |
| 98 | +写入: | |
| 99 | +- `tUser`:14 个字段(除 `tDeletedDate` / `sDeletedBy`) | |
| 100 | +- `tUserPermission`:`iUserId` / `iCategoryId` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` | |
| 101 | + | |
| 102 | +读取(仅校验存在性): | |
| 103 | +- `tStaff`:`iIncrement` + `bDeleted` | |
| 104 | +- `tPermissionCategory`:`iIncrement` + `bDeleted` | |
| 105 | + | |
| 106 | +依赖索引:`tUser.uk_user_no` / `uk_user_name` 兜底唯一冲突;`tStaff.iIncrement` PK;`tPermissionCategory.iIncrement` PK。 | |
| 107 | + | |
| 108 | +外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` 在 INSERT 时由 DB 兜底(service 提前校验给更友好错误码)。 | |
| 109 | + | |
| 110 | +## 依赖的接口 | |
| 111 | + | |
| 112 | +无(USR-001 是用户域 CRUD 起点)。 | |
| 113 | + | |
| 114 | +## 验收标准 | |
| 115 | + | |
| 116 | +### 单元测试(`UserServiceImplTest`,Mockito) | |
| 117 | + | |
| 118 | +- [x] `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers,ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert × N`;断言:`sBrandsId="XLY"` / `sCreatedBy="STUB_ADMIN"` / `tCreateDate != null` / `sPasswordHash` 是 BCrypt 格式(以 `$2a$` 或 `$2b$` 开头)/ N 条权限关联含正确 `iUserId` / `iCategoryId` | |
| 119 | +- [x] `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null` / 空 list → `userPermissionMapper.insert` 永不调用 | |
| 120 | +- [x] `createWithInvalidUserType_throws40001` | |
| 121 | +- [x] `createWithInvalidLanguage_throws40001` | |
| 122 | +- [x] `createWithStaffNotFound_throws40022` — `staffMapper.existsActiveById(...)=false` | |
| 123 | +- [x] `createWithSomeInvalidPermissionIds_throws40023` — `permissionCategoryMapper.countActiveByIds([1,2,3])=2`,期望抛 40023;`userMapper.insert` 永不调用 | |
| 124 | +- [x] `createWithDuplicateUserNo_throws40020` — `userMapper.insert` 抛 `DuplicateKeyException` | |
| 125 | +- [x] `createUsesAuthenticatedUserNoAsCreatedBy` — SecurityContextHolder 注 "ALICE",断言 `sCreatedBy="ALICE"` | |
| 126 | + | |
| 127 | +### Mapper IT(`UserMapperIT`,真实 DB) | |
| 128 | + | |
| 129 | +- [x] `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例插入,`selectById` 比较;`sPasswordHash` 非空且 BCrypt 格式 | |
| 130 | +- [x] `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次 `DuplicateKeyException` | |
| 131 | + | |
| 132 | +### Mapper IT(`StaffMapperIT` + `PermissionCategoryMapperIT`,最小化) | |
| 133 | + | |
| 134 | +- [x] `staffMapper#existsActiveById_handlesAliveDeletedMissing` | |
| 135 | +- [x] `permissionCategoryMapper#countActiveByIds_returnsCorrectCount` | |
| 136 | + | |
| 137 | +### 集成测试(`UserControllerIT`) | |
| 138 | + | |
| 139 | +- [x] `postValidBody_with_jwt_returns200_andPersists` — 直插一条职员 + 两条权限分类作为前置数据;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo` 等于请求;JdbcTemplate 验证 `tUser` + `tUserPermission` 行 | |
| 140 | +- [x] `postEmptyBody_returns40001` | |
| 141 | +- [x] `postInvalidUserType_returns40001` | |
| 142 | +- [x] `postInvalidLanguage_returns40001` | |
| 143 | +- [x] `postDuplicateUserNo_returns40020` | |
| 144 | +- [x] `postStaffNotFound_returns40022` | |
| 145 | +- [x] `postPermissionCategoryNotFound_returns40023` | |
| 146 | +- [x] `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` | |
| 147 | +- [x] `postTamperedJwt_returns20001` | |
| 148 | + | |
| 149 | +### 工程验收 | |
| 150 | + | |
| 151 | +- [x] `cd backend && mvn -B test` 全绿(67 + USR-001 新增 8(svc) + 4(mapperIT) + 9(controllerIT) = 88 用例) | |
| 152 | +- [x] `BCryptPasswordEncoder` bean 在 `SecurityConfig` 注册,service 通过构造器注入 | |
| 153 | +- [x] SecurityConfig 路径白名单含 `/api/usr/**` | |
| 154 | +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持 | |
| 155 | +- [x] `tUser.sPasswordHash` 在响应中**不**回显 | ... | ... |
docs/superpowers/specs/2026-04-30-REQ-USR-002.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-002 | |
| 3 | +date: 2026-04-30 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-002 — 用户修改 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +在不破坏唯一性的前提下,更新已有用户的可编辑字段(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`)+ 全量重建用户的权限组关联(`tUserPermission`)。`sPasswordHash`、`tCreateDate`、`sCreatedBy`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +### HTTP 接口(docs/05 § REQ-USR-002) | |
| 16 | + | |
| 17 | +- Method / Path: `PUT /api/usr/users/{id}`(path `{id}` = `tUser.iIncrement`) | |
| 18 | +- Auth: 必需(沿用 USR-001 stub:路径已在 SecurityConfig `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`) | |
| 19 | + | |
| 20 | +### 请求 DTO `UpdateUserDTO` | |
| 21 | + | |
| 22 | +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | | |
| 23 | +|---|---|---|---|---| | |
| 24 | +| `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` | | |
| 25 | +| `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` | | |
| 26 | +| `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022`(沿用 USR-001 错误码语义;docs/05 § USR-002 未单列 40022,本实现复用 USR-001 已建立的语义) | | |
| 27 | +| `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001` | | |
| 28 | +| `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]` 内;非法 → `40001` | | |
| 29 | +| `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` | | |
| 30 | +| `permissionCategoryIds` | `List<Integer>` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`(同 USR-001)。null / 空 list → 清空该用户权限组(删全部 tUserPermission) | | |
| 31 | + | |
| 32 | +> **`sPasswordHash` 显式从 DTO 中剔除**——API 契约声明该字段不可改;密码修改走独立接口(未来 REQ)。 | |
| 33 | + | |
| 34 | +### 鉴权与上下文 | |
| 35 | + | |
| 36 | +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 → `code=20001`;缺失 → permitAll 透传。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 | |
| 37 | + | |
| 38 | +## 输出 / 结果 | |
| 39 | + | |
| 40 | +### 成功响应 | |
| 41 | + | |
| 42 | +```json | |
| 43 | +{ "code": 0, "msg": "ok", "data": { "iIncrement": 456 } } | |
| 44 | +``` | |
| 45 | + | |
| 46 | +### 持久化效果 | |
| 47 | + | |
| 48 | +事务内三步: | |
| 49 | + | |
| 50 | +1. UPDATE `tUser` SET `<可编辑列>` WHERE `iIncrement = {id}`(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`) | |
| 51 | +2. DELETE FROM `tUserPermission` WHERE `iUserId = {id}`(清空旧关联) | |
| 52 | +3. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId={id}, iCategoryId=cid, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置)` | |
| 53 | + | |
| 54 | +| `tUser` 字段 | 更新策略 | | |
| 55 | +|---|---| | |
| 56 | +| `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 | | |
| 57 | +| 其他字段(`sPasswordHash` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId`) | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | | |
| 58 | + | |
| 59 | +> 复用 MOD-002 / USR-001 已建立的"NOT_NULL 跳过 null 不动其他字段"策略;`bCanModifyDocs` DTO null 时 service 先展开为 `false` 再赋值。 | |
| 60 | + | |
| 61 | +## 业务规则 | |
| 62 | + | |
| 63 | +1. **目标存在性**:`SELECT * FROM tUser WHERE iIncrement = {id}`;行不存在 **或** `bDeleted=true` → `BizException(40400, "用户不存在或已删除")`。 | |
| 64 | +2. **枚举校验**:`sUserType` / `sLanguage` 复用 USR-001 的 `Set.contains` 校验;非法 → `BizException(40001, "<字段>: 取值非法")`。 | |
| 65 | +3. **iStaffId 校验**(非 null 时):`staffMapper.existsActiveById(iStaffId) == false` → `BizException(40022, "职员不存在或已删除")`。 | |
| 66 | +4. **permissionCategoryIds 校验**(非空时):`permissionCategoryMapper.countActiveByIds(ids) != ids.size()` → `BizException(40023, "权限分类含无效 id")`。 | |
| 67 | +5. **唯一冲突**:依赖 DB 唯一索引兜底;`userMapper.updateById` 抛 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")`。 | |
| 68 | +6. **重建权限组**:先 `userPermissionMapper.deleteByUserId(id)` 全量删除,再 for-loop 插入新关联(即使 `permissionCategoryIds` 为空 / null 也执行删除,等价"清空")。 | |
| 69 | +7. **事务**:`@Transactional(rollbackFor = Exception.class)` 包"5 类校验 + UPDATE user + DELETE permission + INSERT permission × N",任一步失败回滚。 | |
| 70 | + | |
| 71 | +## 边界与约束 | |
| 72 | + | |
| 73 | +- **必填项缺失** → `40001` | |
| 74 | +- **`sUserType` / `sLanguage` 非枚举** → `40001` | |
| 75 | +- **`sUserNo` / `sUserName` 唯一冲突** → `40020` | |
| 76 | +- **目标 id 不存在 / 已软删** → `40400` | |
| 77 | +- **iStaffId 不存在 / 已软删** → `40022` | |
| 78 | +- **permissionCategoryIds 含无效 id** → `40023` | |
| 79 | +- **JWT 伪造** → `20001` | |
| 80 | +- **JWT 缺失** → permitAll stub | |
| 81 | +- **`sPasswordHash` 不被覆盖**:DTO 不暴露字段;entity 上 sPasswordHash=null 由 NOT_NULL 跳过 | |
| 82 | + | |
| 83 | +## 实现范围与边界抉择 | |
| 84 | + | |
| 85 | +1. **复用 USR-001 工程**:所有 mapper / entity / dto 已就位;本 REQ 仅在 `UserService` / `UserServiceImpl` / `UserController` 上做增量 + `UserPermissionMapper` 追加 `deleteByUserId`。 | |
| 86 | +2. **错误码 40022 语义复用**:docs/05 § USR-002 错误码列表只列 40001/40020/40400,未单列 40022。本 spec 选择复用 USR-001 已建立的语义,与"同字段两个接口错误码一致"原则相符(同 MOD-002 复用 MOD-001 40010 的处理)。 | |
| 87 | +3. **重建权限组策略**:选"先全删再插入"而非"diff 增量更新"——典型模块权限数 < 50,diff 实现复杂度收益不匹配。 | |
| 88 | +4. **iStaffId 不强校验外键 SET NULL**:DB 已 `ON DELETE SET NULL`,service 提前校验存在性给更友好错误码。 | |
| 89 | + | |
| 90 | +## 依赖的 schema 表 / 字段 | |
| 91 | + | |
| 92 | +写入: | |
| 93 | +- `tUser`:6 个可编辑字段(其余依赖 NOT_NULL 跳过) | |
| 94 | +- `tUserPermission`:`iIncrement` 自增 + 6 字段(先全删后批量插入) | |
| 95 | + | |
| 96 | +读取(仅校验存在性): | |
| 97 | +- `tUser`(selectById 校验目标) | |
| 98 | +- `tStaff` / `tPermissionCategory`(同 USR-001) | |
| 99 | + | |
| 100 | +依赖外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` / `tUserPermission` 对 `tUser` 与 `tPermissionCategory` 的外键。 | |
| 101 | + | |
| 102 | +## 依赖的接口 | |
| 103 | + | |
| 104 | +无(仅本 REQ 内部使用 USR-001 已实现的 mapper + 新增 `userPermissionMapper.deleteByUserId`)。 | |
| 105 | + | |
| 106 | +## 验收标准 | |
| 107 | + | |
| 108 | +### 单元测试(追加到 `UserServiceImplTest`) | |
| 109 | + | |
| 110 | +- [x] `updateWithValidDto_invokesUpdateById_andRebuildsPermissions` — Mock `selectById(10)=alive`、`existsActiveById`、`countActiveByIds`;ArgumentCaptor 抓 `userMapper.updateById` + `userPermissionMapper.deleteByUserId(10)` 调用一次 + `userPermissionMapper.insert × N`;断言传入 entity 的 `iIncrement=10` / 可编辑字段被透传 / `sPasswordHash` 等不可改字段为 null | |
| 111 | +- [x] `updateWithTargetNotFound_throws40400` | |
| 112 | +- [x] `updateWithTargetAlreadyDeleted_throws40400` | |
| 113 | +- [x] `updateWithInvalidUserType_throws40001` | |
| 114 | +- [x] `updateWithInvalidLanguage_throws40001` | |
| 115 | +- [x] `updateWithStaffNotFound_throws40022` | |
| 116 | +- [x] `updateWithSomeInvalidPermissionIds_throws40023` | |
| 117 | +- [x] `updateWithDuplicateUserNo_throws40020` — Mock `userMapper.updateById` 抛 `DuplicateKeyException` | |
| 118 | +- [x] `updateWithEmptyPermissionIds_clearsExisting` — `permissionCategoryIds=null` → `deleteByUserId` 调用一次,`insert` 永不调用 | |
| 119 | +- [x] `updateWithBCanModifyDocsNull_setsFalseInEntity` | |
| 120 | + | |
| 121 | +### Mapper IT(追加到 `UserMapperIT`) | |
| 122 | + | |
| 123 | +- [x] `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser` — 直插 user + 2 行 permission;调 `deleteByUserId` → tUserPermission 中该 user 的行 == 0;其他 user 不受影响 | |
| 124 | + | |
| 125 | +### 集成测试(`UserControllerIT`,追加 8 用例) | |
| 126 | + | |
| 127 | +- [x] `putValidBody_with_jwt_returns200_andUpdates` — 直插 user + 2 行 permission;PUT 改 sUserName 同时改 permissionCategoryIds;DB 验:可编辑字段更新;sPasswordHash / sCreatedBy 保留原值;tUserPermission 行数 == 新 ids.size() | |
| 128 | +- [x] `putNonExistentId_returns40400` | |
| 129 | +- [x] `putAlreadyDeletedId_returns40400` | |
| 130 | +- [x] `putInvalidUserType_returns40001` | |
| 131 | +- [x] `putDuplicateUserNo_returns40020` — 先存在 user1(sUserNo=A) + user2(sUserNo=B);PUT user2 改 sUserNo=A → `code=40020` | |
| 132 | +- [x] `putStaffNotFound_returns40022` | |
| 133 | +- [x] `putPermissionCategoryNotFound_returns40023` | |
| 134 | +- [x] `putWithEmptyPermissionIds_clearsAssociations` — 直插 user + 2 行 permission;PUT 不带 permissionCategoryIds;DB 查该 user 的 tUserPermission == 0;sPasswordHash 保留原值 | |
| 135 | +- [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` | |
| 136 | +- [x] `putTamperedJwt_returns20001` | |
| 137 | + | |
| 138 | +### 工程验收 | |
| 139 | + | |
| 140 | +- [x] `cd backend && mvn -B test` 全绿(89 + 新增 ≥ 19 = ≥ 108 用例) | |
| 141 | +- [x] DB 中 `sPasswordHash` / `sCreatedBy` / `tCreateDate` 在 PUT 前后字面相同 | |
| 142 | +- [x] tUserPermission 行集与请求 ids 完全等价(删旧 + 插新) | ... | ... |
docs/superpowers/specs/2026-04-30-REQ-USR-003.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-003 | |
| 3 | +date: 2026-04-30 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-003 — 用户查询 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +按 `field` × `match` × `value` 单条件检索用户分页列表,输出含 tUser + LEFT JOIN tStaff 字段的 11 列扁平 VO。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +### HTTP 接口(docs/05 § REQ-USR-003) | |
| 16 | + | |
| 17 | +- Method / Path: `GET /api/usr/users` | |
| 18 | +- Auth: 必需(沿用 USR-001 stub:路径已 `/api/usr/**` permitAll) | |
| 19 | + | |
| 20 | +### Query 参数 | |
| 21 | + | |
| 22 | +| 参数 | 类型 | 必填 | 取值 | 默认 | | |
| 23 | +|---|---|---|---|---| | |
| 24 | +| `field` | `String` | 否 | 枚举:`用户名` / `员工名` / `用户号` / `部门` / `用户类型` / `作废` / `登录日期` / `制单人` | `用户名` | | |
| 25 | +| `match` | `String` | 否 | 枚举:`包含` / `不包含` / `等于` | `包含` | | |
| 26 | +| `value` | `String` | 否 | 任意(含布尔/日期字面) | 空 → 不加过滤条件 | | |
| 27 | +| `pageNum` | `Integer` | 否 | ≥ 1 | `1` | | |
| 28 | +| `pageSize` | `Integer` | 否 | 1..100 | `20` | | |
| 29 | + | |
| 30 | +### field × match 兼容矩阵 | |
| 31 | + | |
| 32 | +| field 物理列 | 类型 | 允许的 match | | |
| 33 | +|---|---|---| | |
| 34 | +| 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 制单人 | 字符串 | 包含 / 不包含 / 等于 | | |
| 35 | +| 作废 | 布尔(tinyint) | 仅 `等于`(解析 value: `true`/`false`/`1`/`0` → 1/0) | | |
| 36 | +| 登录日期 | 日期 | 仅 `等于`(按 `YYYY-MM-DD` 精确日期匹配 `DATE(tLastLoginDate) = ?`) | | |
| 37 | + | |
| 38 | +非法组合(如 `field=作废 & match=包含`)→ `BizException(40001, "field/match 取值或组合非法")`。 | |
| 39 | + | |
| 40 | +## 输出 / 结果 | |
| 41 | + | |
| 42 | +### 成功响应 | |
| 43 | + | |
| 44 | +```json | |
| 45 | +{ | |
| 46 | + "code": 0, | |
| 47 | + "msg": "ok", | |
| 48 | + "data": { | |
| 49 | + "records": [ | |
| 50 | + { | |
| 51 | + "iIncrement": 1, "sUserName": "u1", "staffName": "员工A", "sUserNo": "001", | |
| 52 | + "department": "IT", "sUserType": "普通用户", "sLanguage": "zh", | |
| 53 | + "bDeleted": false, "tLastLoginDate": "2026-04-30T09:00:00", | |
| 54 | + "sCreatedBy": "STUB_ADMIN", "tCreateDate": "2026-04-30T08:00:00" | |
| 55 | + } | |
| 56 | + ], | |
| 57 | + "total": 42, "pageNum": 1, "pageSize": 20 | |
| 58 | + } | |
| 59 | +} | |
| 60 | +``` | |
| 61 | + | |
| 62 | +### VO `UserListVO`(11 字段) | |
| 63 | + | |
| 64 | +| 字段 | 类型 | 来源 | | |
| 65 | +|---|---|---| | |
| 66 | +| `iIncrement` | `Integer` | `tUser.iIncrement` | | |
| 67 | +| `sUserName` | `String` | `tUser.sUserName` | | |
| 68 | +| `staffName` | `String` | `tStaff.sStaffName`(LEFT JOIN,未关联时为 null) | | |
| 69 | +| `sUserNo` | `String` | `tUser.sUserNo` | | |
| 70 | +| `department` | `String` | `tStaff.sDepartment`(LEFT JOIN) | | |
| 71 | +| `sUserType` | `String` | `tUser.sUserType` | | |
| 72 | +| `sLanguage` | `String` | `tUser.sLanguage` | | |
| 73 | +| `bDeleted` | `Boolean` | `tUser.bDeleted` | | |
| 74 | +| `tLastLoginDate` | `LocalDateTime` | `tUser.tLastLoginDate` | | |
| 75 | +| `sCreatedBy` | `String` | `tUser.sCreatedBy` | | |
| 76 | +| `tCreateDate` | `LocalDateTime` | `tUser.tCreateDate` | | |
| 77 | + | |
| 78 | +> **不返回** `sPasswordHash` / `iStaffId` / `sBrandsId` / `sSubsidiaryId` / `sId` / 软删除审计字段(spec § 边界与约束 显式排除敏感字段)。 | |
| 79 | +> | |
| 80 | +> **不过滤** `bDeleted=0`:因为「作废」是用户可主动查询的字段;docs/03 § tUser 业务注记的"默认过滤 bDeleted=0"针对常规列表场景,本 REQ 的"作废"字段查询是显式例外。 | |
| 81 | + | |
| 82 | +## 业务规则 | |
| 83 | + | |
| 84 | +1. **参数归一化**:`field` / `match` 缺失或空 → 默认 `用户名` / `包含`;`value` trim,空串当无过滤;`pageNum` < 1 → 1;`pageSize` 缺失 → 20。 | |
| 85 | +2. **pageSize 上限**:`pageSize > 100` → `BizException(40002, "pageSize 超过 100")`。 | |
| 86 | +3. **field/match 校验**: | |
| 87 | + - `field` 不在枚举内 → `BizException(40001, "field 取值非法")` | |
| 88 | + - `match` 不在枚举内 → `BizException(40001, "match 取值非法")` | |
| 89 | + - field=作废/登录日期 时 match 必须是 `等于`;否则 → `BizException(40001, "field/match 组合非法")` | |
| 90 | +4. **value 解析**(match=等于 + 非字符串列): | |
| 91 | + - field=作废:value ∈ `[true, false, 1, 0]` → 1/0;其他 → 视为不命中(结果空);空 value → 不加过滤 | |
| 92 | + - field=登录日期:value 必须可被 `LocalDate.parse(value)` 解析为 `YYYY-MM-DD`;不能解析 → `BizException(40001, "登录日期值格式非法(应为 YYYY-MM-DD)")`;空 value → 不加过滤 | |
| 93 | +5. **SQL 拼装**: | |
| 94 | + ```sql | |
| 95 | + SELECT u.iIncrement, u.sUserName, s.sStaffName AS staffName, u.sUserNo, | |
| 96 | + s.sDepartment AS department, u.sUserType, u.sLanguage, | |
| 97 | + u.bDeleted, u.tLastLoginDate, u.sCreatedBy, u.tCreateDate | |
| 98 | + FROM tUser u | |
| 99 | + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0 | |
| 100 | + WHERE <动态条件> | |
| 101 | + ORDER BY u.iIncrement DESC | |
| 102 | + LIMIT #{offset}, #{pageSize} | |
| 103 | + ``` | |
| 104 | + - 字符串列:`WHERE col LIKE CONCAT('%', #{value}, '%')`(包含)/ `NOT LIKE`(不包含)/ `= #{value}`(等于) | |
| 105 | + - 布尔列:`WHERE u.bDeleted = #{boolValue}` | |
| 106 | + - 日期列:`WHERE DATE(u.tLastLoginDate) = #{dateValue}` | |
| 107 | +6. **总数查询**:另一条 `SELECT COUNT(1) FROM tUser u LEFT JOIN tStaff s ON ... WHERE <同条件>`。 | |
| 108 | +7. **只读**:service 上 `@Transactional(readOnly = true)`。 | |
| 109 | + | |
| 110 | +## 边界与约束 | |
| 111 | + | |
| 112 | +- **field/match 非法或组合非法** → `40001` | |
| 113 | +- **pageSize > 100** → `40002` | |
| 114 | +- **登录日期 value 格式错** → `40001` | |
| 115 | +- **value 空** → 不加过滤,返回全部(按 field/match 仍校验合法性) | |
| 116 | +- **空结果** → `records=[]` + `total=0`;HTTP 200 / `code=0` | |
| 117 | +- **JWT 伪造** → `20001` | |
| 118 | +- **JWT 缺失** → permitAll stub | |
| 119 | +- **`sPasswordHash` 不返回**(VO 不含字段) | |
| 120 | + | |
| 121 | +## 实现范围与边界抉择 | |
| 122 | + | |
| 123 | +1. **复用 USR-001/002 工程**:不新增 mapper;UserMapper 追加自定义 SQL 方法 + 新 VO 即可。 | |
| 124 | +2. **field 枚举映射用 Java enum 或 Map 常量**:选 `Map<String, FieldDef>` 简化(无需新 enum 类);`FieldDef` 内含物理列名 + 类型 + 允许的 match 集合。 | |
| 125 | +3. **不支持多字段同时查询**:spec 仅描述单 field × match × value;docs/05 同款。多字段后续 REQ 再补。 | |
| 126 | +4. **不支持自定义排序**:固定 `ORDER BY u.iIncrement DESC`;docs/05 未要求 sort 参数。 | |
| 127 | +5. **使用 MyBatis-Plus `IPage<T>`**:mapper 方法 `IPage<UserListVO> pageWithFilter(IPage<UserListVO> page, ...)`;service 把 IPage 转为响应 Map。 | |
| 128 | + | |
| 129 | +## 依赖的 schema 表 / 字段 | |
| 130 | + | |
| 131 | +读取: | |
| 132 | +- `tUser`:iIncrement / sUserName / sUserNo / sUserType / sLanguage / bDeleted / tLastLoginDate / sCreatedBy / tCreateDate / iStaffId(用于 JOIN) | |
| 133 | +- `tStaff`:iIncrement / sStaffName / sDepartment / bDeleted(用于 JOIN ON) | |
| 134 | + | |
| 135 | +依赖索引: | |
| 136 | +- `tUser.uk_user_no` / `uk_user_name`(不直接命中 LIKE 但 ORDER BY iIncrement 走主键) | |
| 137 | +- `tStaff.iIncrement` PK 兜底 JOIN | |
| 138 | + | |
| 139 | +## 依赖的接口 | |
| 140 | + | |
| 141 | +无。 | |
| 142 | + | |
| 143 | +## 验收标准 | |
| 144 | + | |
| 145 | +### 单元测试(追加到 `UserServiceImplTest`) | |
| 146 | + | |
| 147 | +- [x] `listWithDefaults_invokesMapperWithUserNameContainsEmpty` — 全空参数;mapper 入参 field=用户名 / match=包含 / value="" / page=1 / size=20 | |
| 148 | +- [x] `listWithEmptyValue_skipsFilterCondition` | |
| 149 | +- [x] `listWithInvalidField_throws40001` | |
| 150 | +- [x] `listWithInvalidMatch_throws40001` | |
| 151 | +- [x] `listWithIncompatibleFieldMatch_throws40001` — field=作废 + match=包含 | |
| 152 | +- [x] `listWithPageSizeExceeds100_throws40002` | |
| 153 | +- [x] `listWithInvalidLoginDateFormat_throws40001` — field=登录日期 / match=等于 / value="abc" | |
| 154 | +- [x] `listWithBooleanFieldEqualsTrue_passesIntegerOne` — field=作废 / match=等于 / value="true" | |
| 155 | +- [x] `listWithKeywordTrim` — value=" abc " → 传给 mapper 时 trim 为 "abc" | |
| 156 | +- [x] `listReturnsEmptyRecords_whenMapperReturnsEmptyPage` | |
| 157 | + | |
| 158 | +### Mapper IT(追加到 `UserMapperIT`) | |
| 159 | + | |
| 160 | +- [x] `userMapper#pageWithFilter_filtersAndJoins` — JdbcTemplate 直插 staff + 2 user(一个有 iStaffId,一个无);调 `pageWithFilter` field=用户名 match=包含 value="";返回 records=[user1, user2],user1.staffName == "员工X",user2.staffName == null | |
| 161 | + | |
| 162 | +### 集成测试(`UserControllerIT`,追加 8 用例) | |
| 163 | + | |
| 164 | +- [x] `getDefaults_with_jwt_returns200_andList` | |
| 165 | +- [x] `getKeywordContains_filtersByUsername` | |
| 166 | +- [x] `getKeywordEquals_filtersExact` | |
| 167 | +- [x] `getInvalidField_returns40001` | |
| 168 | +- [x] `getPageSizeExceeds100_returns40002` | |
| 169 | +- [x] `getNoMatch_returnsEmptyArray` | |
| 170 | +- [x] `getWithoutJwt_permitAllStub_returns200` | |
| 171 | +- [x] `getTamperedJwt_returns20001` | |
| 172 | + | |
| 173 | +### 工程验收 | |
| 174 | + | |
| 175 | +- [x] `mvn -B test` 全绿(110 + 新增 ≥ 19 = ≥ 129 用例) | |
| 176 | +- [x] 响应 records 不含 sPasswordHash 字段 | |
| 177 | +- [x] LEFT JOIN 在用户无 iStaffId 时 staffName/department 为 null | ... | ... |
docs/superpowers/specs/2026-04-30-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-04-30 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-004 — 用户登录 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +实现 `POST /api/usr/auth/login` 登录接口(公开),含密码 BCrypt 校验、失败计数 + 临时锁定、JWT 双 token 签发、`tLastLoginDate` 更新。**同时**收尾整个项目的 stub permitAll,把 SecurityConfig 收紧为 "登录接口外全部 authenticated()",并完成 service 层 stub fallback 的移除。 | |
| 12 | + | |
| 13 | +## 输入 / 触发 | |
| 14 | + | |
| 15 | +### HTTP 接口(docs/05 § REQ-USR-004) | |
| 16 | + | |
| 17 | +- Method / Path: `POST /api/usr/auth/login` | |
| 18 | +- Auth: **无**(路径独立 permitAll) | |
| 19 | + | |
| 20 | +### 请求 DTO `LoginDTO` | |
| 21 | + | |
| 22 | +| 字段 | 类型 | 校验 | | |
| 23 | +|---|---|---| | |
| 24 | +| `sUserName` | `String` | `@NotBlank` | | |
| 25 | +| `password` | `String` | `@NotBlank` | | |
| 26 | +| `version` | `String` | `@NotBlank`(仅记录,不参与校验:合法值 `标准版` / `-`) | | |
| 27 | + | |
| 28 | +## 输出 / 结果 | |
| 29 | + | |
| 30 | +### 成功响应 | |
| 31 | + | |
| 32 | +```json | |
| 33 | +{ | |
| 34 | + "code": 0, "msg": "ok", | |
| 35 | + "data": { | |
| 36 | + "accessToken": "<jwt>", "refreshToken": "<jwt>", | |
| 37 | + "expiresIn": 28800, | |
| 38 | + "user": { "iIncrement": 1, "sUserNo": "001", "sUserName": "u1", "sUserType": "普通用户", "sLanguage": "zh" } | |
| 39 | + } | |
| 40 | +} | |
| 41 | +``` | |
| 42 | + | |
| 43 | +### VO `LoginVO`:`accessToken` / `refreshToken` / `expiresIn` / `user`(`UserBriefVO`) | |
| 44 | + | |
| 45 | +### 持久化效果 | |
| 46 | + | |
| 47 | +成功登录:`UPDATE tUser SET tLastLoginDate = NOW() WHERE iIncrement = ?`。失败:仅更新内存中失败计数,不入 DB。 | |
| 48 | + | |
| 49 | +## 业务规则 | |
| 50 | + | |
| 51 | +1. **必填校验**:`sUserName` / `password` 空 → `BizException(40001, "<字段>: 不能为空")`(实际由 Bean Validation @NotBlank 触发,GlobalExceptionHandler 转 40001)。 | |
| 52 | +2. **用户查询**:`SELECT * FROM tUser WHERE sUserName = ?`;不存在 → `BizException(40101, "用户名或密码错误")`(不区分用户名/密码错,防泄漏)。 | |
| 53 | +3. **账号禁用**:`user.bDeleted=1` → `BizException(40102, "账号已禁用")`。 | |
| 54 | +4. **锁定检查**:从 `LoginAttemptStore`(内存 `ConcurrentHashMap`)查该 sUserName 的失败记录;若在锁定期 → `BizException(42301, "账号临时锁定,剩余 <N> 秒")`,N = (lockExpireAt - now).seconds。 | |
| 55 | +5. **密码校验**:`bCryptPasswordEncoder.matches(password, user.sPasswordHash)`; | |
| 56 | + - 失败:失败计数 +1;若计数 ≥ `MAX_ATTEMPTS=5` → 设 `lockExpireAt = now + LOCK_DURATION=15分钟` → 抛 `BizException(42301, ...)`;否则抛 `BizException(40101, "用户名或密码错误")` | |
| 57 | + - 成功:清空该 sUserName 的失败计数;UPDATE tLastLoginDate;签发 access + refresh token;返回 LoginVO | |
| 58 | +6. **Token 签发**:`accessToken = jwtUtil.sign(sUserNo)`(默认 8 小时);`refreshToken = jwtUtil.signRefresh(sUserNo)`(30 天);`expiresIn = 28800`(access TTL 秒数常量)。 | |
| 59 | +7. **`LoginAttemptStore`** 内存实现:`Map<String, AttemptRecord{int count, Instant firstFailAt, Instant lockExpireAt}>`;线程安全;锁定到期自动放行(下次登录尝试时若 `now > lockExpireAt` 则视为未锁,重置计数)。 | |
| 60 | + | |
| 61 | +## 边界与约束 | |
| 62 | + | |
| 63 | +- **必填项空** → `40001` | |
| 64 | +- **用户名不存在 / 密码错(未达阈值)** → `40101` | |
| 65 | +- **账号 bDeleted** → `40102` | |
| 66 | +- **锁定中** → `42301` + 剩余秒数 | |
| 67 | +- **`sPasswordHash` 不返回**(VO 字段集排除) | |
| 68 | +- **不入 DB 的失败计数**:本期内存实现;docs/04 § 零 技术栈列了 Redis,未来引入时替换 | |
| 69 | +- **不引入 Redis**:本 REQ 暂不增加运行时依赖 | |
| 70 | + | |
| 71 | +## 实现范围与边界抉择(含 Stub 闭环) | |
| 72 | + | |
| 73 | +### Phase A — 登录接口本体 | |
| 74 | + | |
| 75 | +1. 新建 `LoginDTO` / `LoginVO` / `UserBriefVO` / `LoginAttemptStore`(@Component) | |
| 76 | +2. `JwtUtil` 加 `signRefresh(userNo)` 方法 | |
| 77 | +3. `UserMapper` 加 `selectByUserName` + `updateLastLoginDate` | |
| 78 | +4. `UserService` 加 `login(LoginDTO)` + 实现 | |
| 79 | +5. 新建 `AuthController` 处理 `/api/usr/auth/login` | |
| 80 | + | |
| 81 | +### Phase B — Stub 闭环(**关键里程碑**) | |
| 82 | + | |
| 83 | +收紧 SecurityConfig + 移除 service 层 stub fallback + 修复现有 IT 测试期望: | |
| 84 | + | |
| 85 | +1. `SecurityConfig`: | |
| 86 | + - 移除 `/api/mod/**` + `/api/usr/**` permitAll | |
| 87 | + - 改为 `/api/usr/auth/login` permitAll + `anyRequest().authenticated()` | |
| 88 | + - 注册 `AuthenticationEntryPoint` 把未认证 → JSON `Result(20001, "未认证")`(让 docs/05 § 全局约定 § 鉴权"缺失或失效返回 2xxxx" 落地) | |
| 89 | +2. `ModuleServiceImpl#create` 移除 `stub.getStubUserNo()` fallback:`sCreatedBy = SecurityContextHelper.currentUserNo()` 必须非 null(authenticated() 保证;若 null 抛兜底异常) | |
| 90 | +3. `UserServiceImpl#create` / `update` 同样移除 fallback | |
| 91 | +4. **现有 stub IT 测试更新**(约 8 处): | |
| 92 | + - `*WithoutJwt_permitAllStub_*` 系列(MOD-001/002/003 + USR-001/002):原期望 200 + sCreatedBy=STUB_ADMIN,改期望 `code=20001`,DB 无新行 | |
| 93 | + - `getWithoutJwt_permitAllStub_returns200`(MOD-004 + USR-003):改期望 `code=20001` | |
| 94 | + - 移除 `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点(grep -r 全局批量删) | |
| 95 | + | |
| 96 | +> Phase B 是 USR-004 完成的前提,模块完成报告 § ⑩ 列出的"鉴权 stub 收尾"将一次性闭环。 | |
| 97 | + | |
| 98 | +## 依赖的 schema 表 / 字段 | |
| 99 | + | |
| 100 | +读:`tUser.*`(按 sUserName 查全行) | |
| 101 | +写:`tUser.tLastLoginDate` | |
| 102 | + | |
| 103 | +## 依赖的接口 | |
| 104 | + | |
| 105 | +无。 | |
| 106 | + | |
| 107 | +## 验收标准 | |
| 108 | + | |
| 109 | +### 单元测试(`UserServiceImplTest` / `LoginAttemptStoreTest` 新建) | |
| 110 | + | |
| 111 | +- [x] `loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate` | |
| 112 | +- [x] `loginWithUserNotFound_throws40101` | |
| 113 | +- [x] `loginWithDeletedUser_throws40102` | |
| 114 | +- [x] `loginWithWrongPassword_incrementsCounter_throws40101` | |
| 115 | +- [x] `loginAfterMaxAttemptsReached_throws42301_withRemainingSeconds` | |
| 116 | +- [x] `loginWhileLocked_throws42301` | |
| 117 | +- [x] `loginAfterLockExpired_resetsCounter` | |
| 118 | +- [x] `loginAttemptStore_isolation` — 不同 sUserName 互不影响 | |
| 119 | +- [x] `loginSuccess_clearsFailureCounter` | |
| 120 | + | |
| 121 | +### Mapper IT(追加到 `UserMapperIT`) | |
| 122 | + | |
| 123 | +- [x] `selectByUserName_returnsRowOrNull` | |
| 124 | +- [x] `updateLastLoginDate_setsValue` | |
| 125 | + | |
| 126 | +### 集成测试(`AuthControllerIT` 新建) | |
| 127 | + | |
| 128 | +- [x] `loginWithValidCredentials_returns200_withTokens` | |
| 129 | +- [x] `loginWithEmptyBody_returns40001` | |
| 130 | +- [x] `loginWithUserNotFound_returns40101` | |
| 131 | +- [x] `loginWithWrongPassword_returns40101` | |
| 132 | +- [x] `loginAfter5WrongPasswords_returns42301` | |
| 133 | + | |
| 134 | +### Stub 闭环 IT 更新(**Phase B**,~8 处) | |
| 135 | + | |
| 136 | +修改原"无 JWT 期望 200" 用例,新期望 `code=20001`,DB 无新增行。具体清单: | |
| 137 | + | |
| 138 | +- ModuleControllerIT:postWithoutJwt / putWithoutJwt / deleteWithoutJwt / getWithoutJwt(4 处) | |
| 139 | +- UserControllerIT:postWithoutJwt / putWithoutJwt / getWithoutJwt(3 处) | |
| 140 | + | |
| 141 | +### 工程验收 | |
| 142 | + | |
| 143 | +- [x] `mvn -B test` 全绿(约 145+ 用例) | |
| 144 | +- [x] SecurityConfig 路径只有 `/api/usr/auth/login` permitAll;其他全 authenticated | |
| 145 | +- [x] 全局 grep `// REQ-MOD-001 stub: see USR-004 follow-up` 返回 0 结果 | |
| 146 | +- [x] grep `stub.getStubUserNo()` 返回 0 结果(service 层) | |
| 147 | +- [x] `AuthenticationEntryPoint` 配置:未认证 → `code=20001` | ... | ... |