diff --git a/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java b/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..566d95d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.xly.erp.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..2d433c3 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.xly.erp.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.response.Result; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(Result.fail(20001, "未认证"))); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java index 45c8638..42a3cce 100644 --- a/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java +++ b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java @@ -16,7 +16,8 @@ import java.util.Date; @Component public class JwtUtil { - private static final Duration TTL = Duration.ofHours(8); + public static final Duration ACCESS_TTL = Duration.ofHours(8); + public static final Duration REFRESH_TTL = Duration.ofDays(30); private final SecretKey key; @@ -30,11 +31,19 @@ public class JwtUtil { } public String sign(String userNo) { + return sign(userNo, ACCESS_TTL); + } + + public String signRefresh(String userNo) { + return sign(userNo, REFRESH_TTL); + } + + public String sign(String userNo, Duration ttl) { Date now = new Date(); return Jwts.builder() .subject(userNo) .issuedAt(now) - .expiration(new Date(now.getTime() + TTL.toMillis())) + .expiration(new Date(now.getTime() + ttl.toMillis())) .signWith(key) .compact(); } diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java index f5dd50e..02e9f31 100644 --- a/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java @@ -3,6 +3,7 @@ package com.xly.erp.common.security; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -13,9 +14,11 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; + private final JwtAuthenticationEntryPoint authEntryPoint; - public SecurityConfig(JwtAuthenticationFilter jwtFilter) { + public SecurityConfig(JwtAuthenticationFilter jwtFilter, JwtAuthenticationEntryPoint authEntryPoint) { this.jwtFilter = jwtFilter; + this.authEntryPoint = authEntryPoint; } @Bean @@ -23,10 +26,10 @@ public class SecurityConfig { http.csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN') - .requestMatchers("/api/mod/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/usr/auth/login").permitAll() .anyRequest().authenticated() ) + .exceptionHandling(eh -> eh.authenticationEntryPoint(authEntryPoint)) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java index b272bb2..d4113b0 100644 --- a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java +++ b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java @@ -61,8 +61,7 @@ public class ModuleServiceImpl implements ModuleService { m.setSModuleNameZh(dto.getSModuleNameZh()); m.setIParentId(dto.getIParentId()); m.setISortOrder(dto.getISortOrder() != null ? dto.getISortOrder() : 0); - String authedUserNo = SecurityContextHelper.currentUserNo(); - m.setSCreatedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); + m.setSCreatedBy(SecurityContextHelper.currentUserNo()); m.setBDeleted(false); try { @@ -112,8 +111,7 @@ public class ModuleServiceImpl implements ModuleService { entity.setIIncrement(id); entity.setBDeleted(true); entity.setTDeletedDate(LocalDateTime.now()); - String authedUserNo = SecurityContextHelper.currentUserNo(); - entity.setSDeletedBy(authedUserNo != null ? authedUserNo : stub.getStubUserNo()); + entity.setSDeletedBy(SecurityContextHelper.currentUserNo()); moduleMapper.updateById(entity); } diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java new file mode 100644 index 0000000..293e3d3 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java @@ -0,0 +1,27 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.Result; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.service.UserService; +import com.xly.erp.module.usr.vo.LoginVO; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/usr/auth") +public class AuthController { + + private final UserService userService; + + public AuthController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginDTO dto) { + return Result.ok(userService.login(dto)); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java new file mode 100644 index 0000000..3d59c63 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java @@ -0,0 +1,49 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.Result; +import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.UpdateUserDTO; +import com.xly.erp.module.usr.service.UserService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/usr") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/users") + public Result> create(@Valid @RequestBody CreateUserDTO dto) { + return Result.ok(userService.create(dto)); + } + + @PutMapping("/users/{id}") + public Result> update(@PathVariable Integer id, + @Valid @RequestBody UpdateUserDTO dto) { + Integer updated = userService.update(id, dto); + return Result.ok(Map.of("iIncrement", updated)); + } + + @GetMapping("/users") + public Result> list(@RequestParam(required = false) String field, + @RequestParam(required = false) String match, + @RequestParam(required = false) String value, + @RequestParam(required = false) Integer pageNum, + @RequestParam(required = false) Integer pageSize) { + return Result.ok(userService.list(field, match, value, pageNum, pageSize)); + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java new file mode 100644 index 0000000..eb0c5fa --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java @@ -0,0 +1,52 @@ +package com.xly.erp.module.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public class CreateUserDTO { + + @JsonProperty("sUserNo") + @NotBlank + @Size(max = 50) + private String sUserNo; + + @JsonProperty("sUserName") + @NotBlank + @Size(max = 50) + private String sUserName; + + @JsonProperty("iStaffId") + private Integer iStaffId; + + @JsonProperty("sUserType") + @NotBlank + private String sUserType; + + @JsonProperty("sLanguage") + @NotBlank + private String sLanguage; + + @JsonProperty("bCanModifyDocs") + private Boolean bCanModifyDocs; + + @JsonProperty("permissionCategoryIds") + private List permissionCategoryIds; + + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public Integer getIStaffId() { return iStaffId; } + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } + public List getPermissionCategoryIds() { return permissionCategoryIds; } + public void setPermissionCategoryIds(List permissionCategoryIds) { this.permissionCategoryIds = permissionCategoryIds; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java new file mode 100644 index 0000000..88f1fcd --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java @@ -0,0 +1,26 @@ +package com.xly.erp.module.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +public class LoginDTO { + + @JsonProperty("sUserName") + @NotBlank + private String sUserName; + + @JsonProperty("password") + @NotBlank + private String password; + + @JsonProperty("version") + @NotBlank + private String version; + + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java new file mode 100644 index 0000000..4f537d0 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java @@ -0,0 +1,52 @@ +package com.xly.erp.module.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public class UpdateUserDTO { + + @JsonProperty("sUserNo") + @NotBlank + @Size(max = 50) + private String sUserNo; + + @JsonProperty("sUserName") + @NotBlank + @Size(max = 50) + private String sUserName; + + @JsonProperty("iStaffId") + private Integer iStaffId; + + @JsonProperty("sUserType") + @NotBlank + private String sUserType; + + @JsonProperty("sLanguage") + @NotBlank + private String sLanguage; + + @JsonProperty("bCanModifyDocs") + private Boolean bCanModifyDocs; + + @JsonProperty("permissionCategoryIds") + private List permissionCategoryIds; + + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public Integer getIStaffId() { return iStaffId; } + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } + public List getPermissionCategoryIds() { return permissionCategoryIds; } + public void setPermissionCategoryIds(List permissionCategoryIds) { this.permissionCategoryIds = permissionCategoryIds; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/User.java b/backend/src/main/java/com/xly/erp/module/usr/entity/User.java new file mode 100644 index 0000000..651cea1 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/User.java @@ -0,0 +1,98 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +@TableName("tUser") +public class User { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("sUserNo") + private String sUserNo; + + @TableField("sUserName") + private String sUserName; + + @TableField("iStaffId") + private Integer iStaffId; + + @TableField("sUserType") + private String sUserType; + + @TableField("sLanguage") + private String sLanguage; + + @TableField("bCanModifyDocs") + private Boolean bCanModifyDocs; + + @TableField("sPasswordHash") + private String sPasswordHash; + + @TableField("tLastLoginDate") + private LocalDateTime tLastLoginDate; + + @TableField("sCreatedBy") + private String sCreatedBy; + + @TableField("bDeleted") + private Boolean bDeleted; + + @TableField("tDeletedDate") + private LocalDateTime tDeletedDate; + + @TableField("sDeletedBy") + private String sDeletedBy; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSId() { return sId; } + public void setSId(String sId) { this.sId = sId; } + public String getSBrandsId() { return sBrandsId; } + public void setSBrandsId(String sBrandsId) { this.sBrandsId = sBrandsId; } + public String getSSubsidiaryId() { return sSubsidiaryId; } + public void setSSubsidiaryId(String sSubsidiaryId) { this.sSubsidiaryId = sSubsidiaryId; } + public LocalDateTime getTCreateDate() { return tCreateDate; } + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public Integer getIStaffId() { return iStaffId; } + public void setIStaffId(Integer iStaffId) { this.iStaffId = iStaffId; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } + public Boolean getBCanModifyDocs() { return bCanModifyDocs; } + public void setBCanModifyDocs(Boolean bCanModifyDocs) { this.bCanModifyDocs = bCanModifyDocs; } + public String getSPasswordHash() { return sPasswordHash; } + public void setSPasswordHash(String sPasswordHash) { this.sPasswordHash = sPasswordHash; } + public LocalDateTime getTLastLoginDate() { return tLastLoginDate; } + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { this.tLastLoginDate = tLastLoginDate; } + public String getSCreatedBy() { return sCreatedBy; } + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } + public Boolean getBDeleted() { return bDeleted; } + public void setBDeleted(Boolean bDeleted) { this.bDeleted = bDeleted; } + public LocalDateTime getTDeletedDate() { return tDeletedDate; } + public void setTDeletedDate(LocalDateTime tDeletedDate) { this.tDeletedDate = tDeletedDate; } + public String getSDeletedBy() { return sDeletedBy; } + public void setSDeletedBy(String sDeletedBy) { this.sDeletedBy = sDeletedBy; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java b/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java new file mode 100644 index 0000000..1009c33 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java @@ -0,0 +1,53 @@ +package com.xly.erp.module.usr.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +@TableName("tUserPermission") +public class UserPermission { + + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + @TableField("sId") + private String sId; + + @TableField("sBrandsId") + private String sBrandsId; + + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + @TableField("tCreateDate") + private LocalDateTime tCreateDate; + + @TableField("iUserId") + private Integer iUserId; + + @TableField("iCategoryId") + private Integer iCategoryId; + + @TableField("sCreatedBy") + private String sCreatedBy; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSId() { return sId; } + public void setSId(String sId) { this.sId = sId; } + public String getSBrandsId() { return sBrandsId; } + public void setSBrandsId(String sBrandsId) { this.sBrandsId = sBrandsId; } + public String getSSubsidiaryId() { return sSubsidiaryId; } + public void setSSubsidiaryId(String sSubsidiaryId) { this.sSubsidiaryId = sSubsidiaryId; } + public LocalDateTime getTCreateDate() { return tCreateDate; } + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } + public Integer getIUserId() { return iUserId; } + public void setIUserId(Integer iUserId) { this.iUserId = iUserId; } + public Integer getICategoryId() { return iCategoryId; } + public void setICategoryId(Integer iCategoryId) { this.iCategoryId = iCategoryId; } + public String getSCreatedBy() { return sCreatedBy; } + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java new file mode 100644 index 0000000..171b13c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java @@ -0,0 +1,18 @@ +package com.xly.erp.module.usr.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PermissionCategoryMapper { + + @Select("") + int countActiveByIds(@Param("ids") List ids); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java new file mode 100644 index 0000000..e06946f --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java @@ -0,0 +1,16 @@ +package com.xly.erp.module.usr.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface StaffMapper { + + @Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") + Integer findActiveStaffFlag(@Param("id") Integer id); + + default boolean existsActiveById(Integer id) { + return findActiveStaffFlag(id) != null; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java new file mode 100644 index 0000000..f65f759 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java @@ -0,0 +1,34 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.vo.UserListVO; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +public interface UserMapper extends BaseMapper { + + @Select("SELECT iIncrement, sId, sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, " + + "iStaffId, sUserType, sLanguage, bCanModifyDocs, sPasswordHash, tLastLoginDate, " + + "sCreatedBy, bDeleted, tDeletedDate, sDeletedBy " + + "FROM tUser WHERE sUserName = #{name} LIMIT 1") + User selectByUserName(@Param("name") String name); + + @Update("UPDATE tUser SET tLastLoginDate = #{ts} WHERE iIncrement = #{id}") + int updateLastLoginDate(@Param("id") Integer id, @Param("ts") LocalDateTime ts); + + + List pageWithFilter(@Param("field") String field, + @Param("matchOp") String matchOp, + @Param("value") Object value, + @Param("offset") int offset, + @Param("size") int size); + + long countWithFilter(@Param("field") String field, + @Param("matchOp") String matchOp, + @Param("value") Object value); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java new file mode 100644 index 0000000..81f765e --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java @@ -0,0 +1,12 @@ +package com.xly.erp.module.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.module.usr.entity.UserPermission; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +public interface UserPermissionMapper extends BaseMapper { + + @Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}") + int deleteByUserId(@Param("userId") Integer userId); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java new file mode 100644 index 0000000..d414772 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java @@ -0,0 +1,59 @@ +package com.xly.erp.module.usr.security; + +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Component +public class LoginAttemptStore { + + public static final int MAX_ATTEMPTS = 5; + public static final Duration LOCK_DURATION = Duration.ofMinutes(15); + + private final ConcurrentMap records = new ConcurrentHashMap<>(); + private final Clock clock; + + public LoginAttemptStore() { + this(Clock.systemDefaultZone()); + } + + public LoginAttemptStore(Clock clock) { + this.clock = clock; + } + + public Optional isLocked(String userName) { + AttemptRecord r = records.get(userName); + if (r == null || r.lockExpireAt == null) { + return Optional.empty(); + } + Instant now = Instant.now(clock); + if (now.isAfter(r.lockExpireAt)) { + records.remove(userName); + return Optional.empty(); + } + return Optional.of(r.lockExpireAt.getEpochSecond() - now.getEpochSecond()); + } + + public synchronized int recordFailure(String userName) { + AttemptRecord r = records.computeIfAbsent(userName, k -> new AttemptRecord()); + r.count++; + if (r.count >= MAX_ATTEMPTS) { + r.lockExpireAt = Instant.now(clock).plus(LOCK_DURATION); + } + return r.count; + } + + public void clearFailures(String userName) { + records.remove(userName); + } + + private static class AttemptRecord { + int count = 0; + Instant lockExpireAt = null; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java new file mode 100644 index 0000000..a7d0ec1 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java @@ -0,0 +1,18 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.dto.UpdateUserDTO; +import com.xly.erp.module.usr.vo.LoginVO; + +import java.util.Map; + +public interface UserService { + Map create(CreateUserDTO dto); + + Integer update(Integer id, UpdateUserDTO dto); + + Map list(String field, String match, String value, Integer pageNum, Integer pageSize); + + LoginVO login(LoginDTO dto); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..0c7df8a --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java @@ -0,0 +1,308 @@ +package com.xly.erp.module.usr.service.impl; + +import com.xly.erp.common.config.StubSecurityProperties; +import com.xly.erp.common.config.TenantProperties; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.common.security.SecurityContextHelper; +import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.dto.UpdateUserDTO; +import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.vo.LoginVO; +import com.xly.erp.module.usr.vo.UserBriefVO; +import com.xly.erp.module.usr.vo.UserListVO; +import com.xly.erp.module.usr.entity.UserPermission; +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.mapper.UserPermissionMapper; +import com.xly.erp.module.usr.service.UserService; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@Transactional(rollbackFor = Exception.class) +public class UserServiceImpl implements UserService { + + static final Set USER_TYPES = Set.of("普通用户", "超级管理员"); + static final Set LANGUAGES = Set.of("zh", "en", "zh-TW"); + static final String DEFAULT_PASSWORD = "666666"; + + static final int MAX_PAGE_SIZE = 100; + static final String STRING_TYPE = "STRING"; + static final String BOOLEAN_TYPE = "BOOLEAN"; + static final String DATE_TYPE = "DATE"; + + private static final Map FIELD_MAP = Map.of( + "用户名", new FieldDef("u.sUserName", STRING_TYPE), + "员工名", new FieldDef("s.sStaffName", STRING_TYPE), + "用户号", new FieldDef("u.sUserNo", STRING_TYPE), + "部门", new FieldDef("s.sDepartment", STRING_TYPE), + "用户类型", new FieldDef("u.sUserType", STRING_TYPE), + "作废", new FieldDef("u.bDeleted", BOOLEAN_TYPE), + "登录日期", new FieldDef("DATE(u.tLastLoginDate)", DATE_TYPE), + "制单人", new FieldDef("u.sCreatedBy", STRING_TYPE) + ); + + private static final Map MATCH_MAP = Map.of( + "包含", "contains", + "不包含", "notContains", + "等于", "equals" + ); + + private record FieldDef(String column, String type) {} + + private final UserMapper userMapper; + private final UserPermissionMapper userPermissionMapper; + private final StaffMapper staffMapper; + private final PermissionCategoryMapper permissionCategoryMapper; + private final TenantProperties tenant; + private final StubSecurityProperties stub; + private final BCryptPasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final LoginAttemptStore loginAttemptStore; + + public UserServiceImpl(UserMapper userMapper, + UserPermissionMapper userPermissionMapper, + StaffMapper staffMapper, + PermissionCategoryMapper permissionCategoryMapper, + TenantProperties tenant, + StubSecurityProperties stub, + BCryptPasswordEncoder passwordEncoder, + JwtUtil jwtUtil, + LoginAttemptStore loginAttemptStore) { + this.userMapper = userMapper; + this.userPermissionMapper = userPermissionMapper; + this.staffMapper = staffMapper; + this.permissionCategoryMapper = permissionCategoryMapper; + this.tenant = tenant; + this.stub = stub; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + this.loginAttemptStore = loginAttemptStore; + } + + @Override + public Map create(CreateUserDTO dto) { + if (!USER_TYPES.contains(dto.getSUserType())) { + throw new BizException(40001, "sUserType: 取值非法"); + } + if (!LANGUAGES.contains(dto.getSLanguage())) { + throw new BizException(40001, "sLanguage: 取值非法"); + } + if (dto.getIStaffId() != null && !staffMapper.existsActiveById(dto.getIStaffId())) { + throw new BizException(40022, "职员不存在或已删除"); + } + List ids = dto.getPermissionCategoryIds(); + if (ids != null && !ids.isEmpty()) { + int found = permissionCategoryMapper.countActiveByIds(ids); + if (found != ids.size()) { + throw new BizException(40023, "权限分类含无效 id"); + } + } + + User entity = new User(); + entity.setSBrandsId(tenant.getBrandsId()); + entity.setSSubsidiaryId(tenant.getSubsidiaryId()); + entity.setTCreateDate(LocalDateTime.now()); + entity.setSUserNo(dto.getSUserNo()); + entity.setSUserName(dto.getSUserName()); + entity.setIStaffId(dto.getIStaffId()); + entity.setSUserType(dto.getSUserType()); + entity.setSLanguage(dto.getSLanguage()); + entity.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : false); + entity.setSPasswordHash(passwordEncoder.encode(DEFAULT_PASSWORD)); + String createdBy = SecurityContextHelper.currentUserNo(); + entity.setSCreatedBy(createdBy); + entity.setBDeleted(false); + + try { + userMapper.insert(entity); + } catch (DuplicateKeyException e) { + throw new BizException(40020, "用户号或用户名已存在"); + } + + if (ids != null && !ids.isEmpty()) { + for (Integer cid : ids) { + UserPermission rel = new UserPermission(); + rel.setSBrandsId(tenant.getBrandsId()); + rel.setSSubsidiaryId(tenant.getSubsidiaryId()); + rel.setTCreateDate(LocalDateTime.now()); + rel.setIUserId(entity.getIIncrement()); + rel.setICategoryId(cid); + rel.setSCreatedBy(createdBy); + userPermissionMapper.insert(rel); + } + } + + Map result = new LinkedHashMap<>(); + result.put("iIncrement", entity.getIIncrement()); + result.put("sUserNo", entity.getSUserNo()); + return result; + } + + @Override + public Integer update(Integer id, UpdateUserDTO dto) { + User original = userMapper.selectById(id); + if (original == null || Boolean.TRUE.equals(original.getBDeleted())) { + throw new BizException(40400, "用户不存在或已删除"); + } + if (!USER_TYPES.contains(dto.getSUserType())) { + throw new BizException(40001, "sUserType: 取值非法"); + } + if (!LANGUAGES.contains(dto.getSLanguage())) { + throw new BizException(40001, "sLanguage: 取值非法"); + } + if (dto.getIStaffId() != null && !staffMapper.existsActiveById(dto.getIStaffId())) { + throw new BizException(40022, "职员不存在或已删除"); + } + List ids = dto.getPermissionCategoryIds(); + if (ids != null && !ids.isEmpty()) { + int found = permissionCategoryMapper.countActiveByIds(ids); + if (found != ids.size()) { + throw new BizException(40023, "权限分类含无效 id"); + } + } + + User entity = new User(); + entity.setIIncrement(id); + entity.setSUserNo(dto.getSUserNo()); + entity.setSUserName(dto.getSUserName()); + entity.setIStaffId(dto.getIStaffId()); + entity.setSUserType(dto.getSUserType()); + entity.setSLanguage(dto.getSLanguage()); + entity.setBCanModifyDocs(dto.getBCanModifyDocs() != null ? dto.getBCanModifyDocs() : false); + try { + userMapper.updateById(entity); + } catch (DuplicateKeyException e) { + throw new BizException(40020, "用户号或用户名已存在"); + } + + userPermissionMapper.deleteByUserId(id); + if (ids != null && !ids.isEmpty()) { + String createdBy = SecurityContextHelper.currentUserNo(); + LocalDateTime now = LocalDateTime.now(); + for (Integer cid : ids) { + UserPermission rel = new UserPermission(); + rel.setSBrandsId(tenant.getBrandsId()); + rel.setSSubsidiaryId(tenant.getSubsidiaryId()); + rel.setTCreateDate(now); + rel.setIUserId(id); + rel.setICategoryId(cid); + rel.setSCreatedBy(createdBy); + userPermissionMapper.insert(rel); + } + } + return id; + } + + @Override + @Transactional(readOnly = true) + public Map list(String field, String match, String value, Integer pageNum, Integer pageSize) { + int p = (pageNum == null || pageNum < 1) ? 1 : pageNum; + int size = (pageSize == null) ? 20 : pageSize; + if (size > MAX_PAGE_SIZE) { + throw new BizException(40002, "pageSize 超过 100"); + } + String f = (field == null || field.isBlank()) ? "用户名" : field.trim(); + String m = (match == null || match.isBlank()) ? "包含" : match.trim(); + String v = value == null ? "" : value.trim(); + + FieldDef def = FIELD_MAP.get(f); + if (def == null) { + throw new BizException(40001, "field 取值非法"); + } + String matchOp = MATCH_MAP.get(m); + if (matchOp == null) { + throw new BizException(40001, "match 取值非法"); + } + if (!STRING_TYPE.equals(def.type()) && !"equals".equals(matchOp)) { + throw new BizException(40001, "field/match 组合非法"); + } + + Object queryValue = v; + if (!v.isEmpty()) { + if (BOOLEAN_TYPE.equals(def.type())) { + queryValue = parseBoolean(v); + } else if (DATE_TYPE.equals(def.type())) { + try { + queryValue = LocalDate.parse(v).toString(); + } catch (DateTimeParseException e) { + throw new BizException(40001, "登录日期值格式非法(应为 YYYY-MM-DD)"); + } + } + } else { + queryValue = ""; + } + + int offset = (p - 1) * size; + List records = userMapper.pageWithFilter(def.column(), matchOp, queryValue, offset, size); + long total = userMapper.countWithFilter(def.column(), matchOp, queryValue); + + Map result = new LinkedHashMap<>(); + result.put("records", records); + result.put("total", total); + result.put("pageNum", p); + result.put("pageSize", size); + return result; + } + + private Integer parseBoolean(String v) { + return switch (v.toLowerCase()) { + case "true", "1" -> 1; + case "false", "0" -> 0; + default -> -1; + }; + } + + @Override + public LoginVO login(LoginDTO dto) { + String userName = dto.getSUserName(); + loginAttemptStore.isLocked(userName).ifPresent(seconds -> { + throw new BizException(42301, "账号临时锁定,剩余 " + seconds + " 秒"); + }); + User user = userMapper.selectByUserName(userName); + if (user == null) { + throw new BizException(40101, "用户名或密码错误"); + } + if (Boolean.TRUE.equals(user.getBDeleted())) { + throw new BizException(40102, "账号已禁用"); + } + if (!passwordEncoder.matches(dto.getPassword(), user.getSPasswordHash())) { + int newCount = loginAttemptStore.recordFailure(userName); + if (newCount >= LoginAttemptStore.MAX_ATTEMPTS) { + long remaining = loginAttemptStore.isLocked(userName).orElse(0L); + throw new BizException(42301, "账号临时锁定,剩余 " + remaining + " 秒"); + } + throw new BizException(40101, "用户名或密码错误"); + } + loginAttemptStore.clearFailures(userName); + userMapper.updateLastLoginDate(user.getIIncrement(), LocalDateTime.now()); + + UserBriefVO brief = new UserBriefVO(); + brief.setIIncrement(user.getIIncrement()); + brief.setSUserNo(user.getSUserNo()); + brief.setSUserName(user.getSUserName()); + brief.setSUserType(user.getSUserType()); + brief.setSLanguage(user.getSLanguage()); + + LoginVO vo = new LoginVO(); + vo.setAccessToken(jwtUtil.sign(user.getSUserNo())); + vo.setRefreshToken(jwtUtil.signRefresh(user.getSUserNo())); + vo.setExpiresIn(JwtUtil.ACCESS_TTL.toSeconds()); + vo.setUser(brief); + return vo; + } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java new file mode 100644 index 0000000..fe76657 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java @@ -0,0 +1,27 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class LoginVO { + + @JsonProperty("accessToken") + private String accessToken; + + @JsonProperty("refreshToken") + private String refreshToken; + + @JsonProperty("expiresIn") + private long expiresIn; + + @JsonProperty("user") + private UserBriefVO user; + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + public long getExpiresIn() { return expiresIn; } + public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; } + public UserBriefVO getUser() { return user; } + public void setUser(UserBriefVO user) { this.user = user; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java new file mode 100644 index 0000000..e92ae75 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java @@ -0,0 +1,32 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class UserBriefVO { + + @JsonProperty("iIncrement") + private Integer iIncrement; + + @JsonProperty("sUserNo") + private String sUserNo; + + @JsonProperty("sUserName") + private String sUserName; + + @JsonProperty("sUserType") + private String sUserType; + + @JsonProperty("sLanguage") + private String sLanguage; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java new file mode 100644 index 0000000..04bf148 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java @@ -0,0 +1,64 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDateTime; + +public class UserListVO { + + @JsonProperty("iIncrement") + private Integer iIncrement; + + @JsonProperty("sUserName") + private String sUserName; + + @JsonProperty("staffName") + private String staffName; + + @JsonProperty("sUserNo") + private String sUserNo; + + @JsonProperty("department") + private String department; + + @JsonProperty("sUserType") + private String sUserType; + + @JsonProperty("sLanguage") + private String sLanguage; + + @JsonProperty("bDeleted") + private Boolean bDeleted; + + @JsonProperty("tLastLoginDate") + private LocalDateTime tLastLoginDate; + + @JsonProperty("sCreatedBy") + private String sCreatedBy; + + @JsonProperty("tCreateDate") + private LocalDateTime tCreateDate; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public String getStaffName() { return staffName; } + public void setStaffName(String staffName) { this.staffName = staffName; } + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getDepartment() { return department; } + public void setDepartment(String department) { this.department = department; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } + public Boolean getBDeleted() { return bDeleted; } + public void setBDeleted(Boolean bDeleted) { this.bDeleted = bDeleted; } + public LocalDateTime getTLastLoginDate() { return tLastLoginDate; } + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { this.tLastLoginDate = tLastLoginDate; } + public String getSCreatedBy() { return sCreatedBy; } + public void setSCreatedBy(String sCreatedBy) { this.sCreatedBy = sCreatedBy; } + public LocalDateTime getTCreateDate() { return tCreateDate; } + public void setTCreateDate(LocalDateTime tCreateDate) { this.tCreateDate = tCreateDate; } +} diff --git a/backend/src/main/resources/mapper/usr/UserMapper.xml b/backend/src/main/resources/mapper/usr/UserMapper.xml new file mode 100644 index 0000000..28caade --- /dev/null +++ b/backend/src/main/resources/mapper/usr/UserMapper.xml @@ -0,0 +1,48 @@ + + + + + + u.iIncrement AS iIncrement, + u.sUserName AS sUserName, + s.sStaffName AS staffName, + u.sUserNo AS sUserNo, + s.sDepartment AS department, + u.sUserType AS sUserType, + u.sLanguage AS sLanguage, + u.bDeleted AS bDeleted, + u.tLastLoginDate AS tLastLoginDate, + u.sCreatedBy AS sCreatedBy, + u.tCreateDate AS tCreateDate + + + + + + + ${field} LIKE CONCAT('%', #{value}, '%') + ${field} NOT LIKE CONCAT('%', #{value}, '%') + ${field} = #{value} + + + + + + + + + + diff --git a/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java index 7748aed..1c95856 100644 --- a/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java +++ b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java @@ -34,4 +34,13 @@ class JwtUtilTest { .isInstanceOf(BizException.class) .hasFieldOrPropertyWithValue("code", 20001); } + + @Test + void signRefresh_signsValidLongerLivedToken() { + String accessToken = jwtUtil.sign("U1"); + String refreshToken = jwtUtil.signRefresh("U1"); + assertThat(jwtUtil.parse(accessToken)).isEqualTo("U1"); + assertThat(jwtUtil.parse(refreshToken)).isEqualTo("U1"); + assertThat(refreshToken).isNotEqualTo(accessToken); + } } diff --git a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java index 8998e35..5c48214 100644 --- a/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java +++ b/backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java @@ -139,19 +139,17 @@ class ModuleControllerIT { } @Test - void postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN() throws Exception { + void postWithoutJwt_returns20001() throws Exception { HttpHeaders headers = jsonHeaders(); Map body = validBody("sp_test_nojwt", "无JWT"); ResponseEntity resp = rest.exchange( url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); - JsonNode jb = objectMapper.readTree(resp.getBody()); - assertThat(jb.get("code").asInt()).isZero(); - int newId = jb.get("data").get("iIncrement").asInt(); - String createdBy = jdbcTemplate.queryForObject( - "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, newId); - assertThat(createdBy).isEqualTo("STUB_ADMIN"); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tModule WHERE sProcedureName = 'sp_test_nojwt'", Integer.class); + assertThat(count).isZero(); } @Test @@ -260,7 +258,7 @@ class ModuleControllerIT { } @Test - void putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy() throws Exception { + void putWithoutJwt_returns20001() throws Exception { Integer id = insertOriginal("sp_test_put_nojwt", "原", "ORIG_USER"); HttpHeaders headers = jsonHeaders(); Map body = updateBody(); @@ -269,10 +267,10 @@ class ModuleControllerIT { ResponseEntity resp = rest.exchange( idUrl(id), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); - String createdBy = jdbcTemplate.queryForObject( - "SELECT sCreatedBy FROM tModule WHERE iIncrement = ?", String.class, id); - assertThat(createdBy).isEqualTo("ORIG_USER"); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + String name = jdbcTemplate.queryForObject( + "SELECT sModuleNameZh FROM tModule WHERE iIncrement = ?", String.class, id); + assertThat(name).isEqualTo("原"); } @Test @@ -359,18 +357,17 @@ class ModuleControllerIT { } @Test - void deleteWithoutJwt_permitAllStub_returns200_andDeletedByIsSTUB() throws Exception { + void deleteWithoutJwt_returns20001() throws Exception { Integer id = insertOriginal("sp_test_del_nojwt", "原", "ORIG"); HttpHeaders headers = jsonHeaders(); ResponseEntity resp = rest.exchange( idUrl(id), HttpMethod.DELETE, new HttpEntity<>(headers), String.class); - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); - Map row = jdbcTemplate.queryForMap( - "SELECT bDeleted, sDeletedBy FROM tModule WHERE iIncrement = ?", id); - assertThat(row.get("bDeleted")).isEqualTo(true); - assertThat(row.get("sDeletedBy")).isEqualTo("STUB_ADMIN"); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + Boolean stillAlive = jdbcTemplate.queryForObject( + "SELECT bDeleted FROM tModule WHERE iIncrement = ?", Boolean.class, id); + assertThat(stillAlive).isFalse(); } @Test @@ -463,11 +460,11 @@ class ModuleControllerIT { } @Test - void getWithoutJwt_permitAllStub_returns200() throws Exception { + void getWithoutJwt_returns20001() throws Exception { HttpHeaders headers = jsonHeaders(); ResponseEntity resp = rest.exchange( listUrl(null), HttpMethod.GET, new HttpEntity<>(headers), String.class); - assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); } @Test diff --git a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java index 4e49e77..ec38f20 100644 --- a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java +++ b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java @@ -70,7 +70,7 @@ class ModuleServiceImplTest { assertThat(saved.getSBrandsId()).isEqualTo("XLY"); assertThat(saved.getSSubsidiaryId()).isEqualTo("XLY"); assertThat(saved.getTCreateDate()).isNotNull(); - assertThat(saved.getSCreatedBy()).isEqualTo("STUB_ADMIN"); + assertThat(saved.getSCreatedBy()).isNull(); assertThat(saved.getBDeleted()).isFalse(); assertThat(saved.getBShowPermission()).isFalse(); assertThat(saved.getISortOrder()).isZero(); @@ -254,7 +254,7 @@ class ModuleServiceImplTest { assertThat(passed.getIIncrement()).isEqualTo(10); assertThat(passed.getBDeleted()).isTrue(); assertThat(passed.getTDeletedDate()).isNotNull(); - assertThat(passed.getSDeletedBy()).isEqualTo("STUB_ADMIN"); + assertThat(passed.getSDeletedBy()).isNull(); assertThat(passed.getSProcedureName()).isNull(); assertThat(passed.getSCreatedBy()).isNull(); assertThat(passed.getSBrandsId()).isNull(); diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java new file mode 100644 index 0000000..bbfe909 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerIT.java @@ -0,0 +1,147 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class AuthControllerIT { + + @Autowired + private TestRestTemplate rest; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private BCryptPasswordEncoder encoder; + + @Autowired + private LoginAttemptStore loginAttemptStore; + + @LocalServerPort + private int port; + + @BeforeEach + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); + loginAttemptStore.clearFailures("sp_test_login_user"); + loginAttemptStore.clearFailures("sp_test_login_lock"); + } + + @Test + void loginWithValidCredentials_returns200_withTokens() throws Exception { + insertActiveUser("sp_test_login_user", "登录用户1", encoder.encode("666666"), false); + + Map body = new HashMap<>(); + body.put("sUserName", "登录用户1"); + body.put("password", "666666"); + body.put("version", "标准版"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); + + JsonNode jb = objectMapper.readTree(resp.getBody()); + assertThat(jb.get("code").asInt()).isZero(); + JsonNode data = jb.get("data"); + assertThat(data.get("accessToken").asText()).isNotBlank(); + assertThat(data.get("refreshToken").asText()).isNotBlank(); + assertThat(data.get("expiresIn").asInt()).isEqualTo(28800); + assertThat(data.get("user").get("sUserNo").asText()).isEqualTo("sp_test_login_user"); + assertThat(data.get("user").has("sPasswordHash")).isFalse(); + } + + @Test + void loginWithEmptyBody_returns40001() throws Exception { + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>("{}", jsonHeaders()), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void loginWithUserNotFound_returns40101() throws Exception { + Map body = new HashMap<>(); + body.put("sUserName", "幽灵用户"); + body.put("password", "x"); + body.put("version", "标准版"); + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40101); + } + + @Test + void loginWithWrongPassword_returns40101() throws Exception { + insertActiveUser("sp_test_login_user", "登录用户1", encoder.encode("666666"), false); + + Map body = new HashMap<>(); + body.put("sUserName", "登录用户1"); + body.put("password", "wrong"); + body.put("version", "标准版"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40101); + } + + @Test + void loginAfter5WrongPasswords_returns42301() throws Exception { + insertActiveUser("sp_test_login_lock", "锁定用户", encoder.encode("666666"), false); + + Map body = new HashMap<>(); + body.put("sUserName", "锁定用户"); + body.put("password", "wrong"); + body.put("version", "标准版"); + + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { + rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); + } + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, jsonHeaders()), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(42301); + + loginAttemptStore.clearFailures("锁定用户"); + } + + private void insertActiveUser(String userNo, String userName, String passwordHash, boolean deleted) { + jdbcTemplate.update( + "INSERT INTO tUser (sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, " + + "sUserType, sLanguage, bCanModifyDocs, sPasswordHash, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, '普通用户', 'zh', 0, ?, 'STUB_ADMIN', ?)", + userNo, userName, passwordHash, deleted ? 1 : 0); + } + + private static HttpHeaders jsonHeaders() { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private String url() { + return "http://localhost:" + port + "/api/usr/auth/login"; + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java new file mode 100644 index 0000000..0efb929 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java @@ -0,0 +1,538 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.security.TestJwtHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UserControllerIT { + + @Autowired + private TestRestTemplate rest; + + @Autowired + private TestJwtHelper testJwtHelper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @LocalServerPort + private int port; + + @BeforeEach + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM tUserPermission WHERE iUserId IN " + + "(SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')"); + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo LIKE 'sp_test_%'"); + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); + } + + @Test + void postValidBody_with_jwt_returns200_andPersists() throws Exception { + Integer staffId = insertStaff("sp_test_st1", "员工1"); + Integer cat1 = insertCategory("sp_test_pc1", "权限A"); + Integer cat2 = insertCategory("sp_test_pc2", "权限B"); + + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_ok", "正常用户"); + body.put("iStaffId", staffId); + body.put("permissionCategoryIds", List.of(cat1, cat2)); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + JsonNode jb = objectMapper.readTree(resp.getBody()); + assertThat(jb.get("code").asInt()).isZero(); + int newId = jb.get("data").get("iIncrement").asInt(); + assertThat(jb.get("data").get("sUserNo").asText()).isEqualTo("sp_test_u_ok"); + + Map row = jdbcTemplate.queryForMap( + "SELECT sBrandsId, sCreatedBy, sUserType FROM tUser WHERE iIncrement = ?", newId); + assertThat(row.get("sBrandsId")).isEqualTo("XLY"); + assertThat(row.get("sCreatedBy")).isEqualTo("ADMIN001"); + Integer permCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, newId); + assertThat(permCount).isEqualTo(2); + } + + @Test + void postEmptyBody_returns40001() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>("{}", headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void postInvalidUserType_returns40001() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_invtype", "枚举"); + body.put("sUserType", "火星"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void postInvalidLanguage_returns40001() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_invlang", "枚举"); + body.put("sLanguage", "ja"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void postDuplicateUserNo_returns40020() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map first = baseBody("sp_test_u_dup", "首次"); + ResponseEntity r1 = rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(first, headers), String.class); + assertThat(objectMapper.readTree(r1.getBody()).get("code").asInt()).isZero(); + + Map dup = baseBody("sp_test_u_dup", "重复"); + dup.put("sUserName", "sp_test_u_dup_other"); + ResponseEntity r2 = rest.exchange(url(), HttpMethod.POST, new HttpEntity<>(dup, headers), String.class); + assertThat(objectMapper.readTree(r2.getBody()).get("code").asInt()).isEqualTo(40020); + } + + @Test + void postStaffNotFound_returns40022() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_nostaff", "缺职员"); + body.put("iStaffId", 99999990); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40022); + } + + @Test + void postPermissionCategoryNotFound_returns40023() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_nocat", "缺权限"); + body.put("permissionCategoryIds", List.of(99999991)); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40023); + } + + @Test + void postWithoutJwt_returns20001() throws Exception { + HttpHeaders headers = jsonHeaders(); + Map body = baseBody("sp_test_u_nojwt", "无JWT"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUser WHERE sUserNo = 'sp_test_u_nojwt'", Integer.class); + assertThat(count).isZero(); + } + + @Test + void postTamperedJwt_returns20001() throws Exception { + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer not.a.real.jwt"); + Map body = baseBody("sp_test_u_tamper", "伪JWT"); + + ResponseEntity resp = rest.exchange( + url(), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUser WHERE sUserNo = 'sp_test_u_tamper'", Integer.class); + assertThat(count).isZero(); + } + + @Test + void putValidBody_with_jwt_returns200_andUpdates() throws Exception { + Integer staffId = insertStaff("sp_test_pst1", "员工1"); + Integer cat1 = insertCategory("sp_test_pc_p1", "权限1"); + Integer cat2 = insertCategory("sp_test_pc_p2", "权限2"); + Integer userId = insertUserWithPerms("sp_test_u_putorig", "原用户", staffId, cat1); + + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_putorig", "原用户改名"); + body.put("permissionCategoryIds", List.of(cat1, cat2)); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + JsonNode jb = objectMapper.readTree(resp.getBody()); + assertThat(jb.get("code").asInt()).isZero(); + assertThat(jb.get("data").get("iIncrement").asInt()).isEqualTo(userId); + + Map row = jdbcTemplate.queryForMap( + "SELECT sUserName, sPasswordHash, sCreatedBy FROM tUser WHERE iIncrement = ?", userId); + assertThat(row.get("sUserName")).isEqualTo("原用户改名"); + assertThat((String) row.get("sPasswordHash")).startsWith("$2a$"); + assertThat(row.get("sCreatedBy")).isEqualTo("ORIG_CREATOR"); + + Integer permCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, userId); + assertThat(permCount).isEqualTo(2); + } + + @Test + void putNonExistentId_returns40400() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_no", "无"); + + ResponseEntity resp = rest.exchange( + idUrl(99999990), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); + } + + @Test + void putAlreadyDeletedId_returns40400() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_deleted", "已删", null, null); + jdbcTemplate.update("UPDATE tUser SET bDeleted = 1 WHERE iIncrement = ?", userId); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(baseBody("sp_test_u_deleted", "改"), headers), + String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40400); + } + + @Test + void putInvalidUserType_returns40001() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_pinvtype", "原", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_pinvtype", "改"); + body.put("sUserType", "火星"); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void putDuplicateUserNo_returns40020() throws Exception { + Integer u1 = insertUserWithPerms("sp_test_u_pdupA", "AAA", null, null); + Integer u2 = insertUserWithPerms("sp_test_u_pdupB", "BBB", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_pdupA", "改成 A"); + + ResponseEntity resp = rest.exchange( + idUrl(u2), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40020); + } + + @Test + void putStaffNotFound_returns40022() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_pstaff", "原", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_pstaff", "改"); + body.put("iStaffId", 99999990); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40022); + } + + @Test + void putPermissionCategoryNotFound_returns40023() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_pperm", "原", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_pperm", "改"); + body.put("permissionCategoryIds", List.of(99999991)); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40023); + } + + @Test + void putWithEmptyPermissionIds_clearsAssociations() throws Exception { + Integer cat1 = insertCategory("sp_test_pc_e1", "权限"); + Integer userId = insertUserWithPerms("sp_test_u_pclear", "原", null, cat1); + + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + Map body = baseBody("sp_test_u_pclear", "改"); + body.remove("permissionCategoryIds"); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isZero(); + Integer permCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, userId); + assertThat(permCount).isZero(); + String passHash = jdbcTemplate.queryForObject( + "SELECT sPasswordHash FROM tUser WHERE iIncrement = ?", String.class, userId); + assertThat(passHash).startsWith("$2a$"); + } + + @Test + void putWithoutJwt_returns20001() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_pnojwt", "原", null, null); + HttpHeaders headers = jsonHeaders(); + Map body = baseBody("sp_test_u_pnojwt", "改"); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + String name = jdbcTemplate.queryForObject( + "SELECT sUserName FROM tUser WHERE iIncrement = ?", String.class, userId); + assertThat(name).isEqualTo("原"); + } + + @Test + void putTamperedJwt_returns20001() throws Exception { + Integer userId = insertUserWithPerms("sp_test_u_ptamper", "原", null, null); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer not.a.real.jwt"); + Map body = baseBody("sp_test_u_ptamper", "改"); + + ResponseEntity resp = rest.exchange( + idUrl(userId), HttpMethod.PUT, new HttpEntity<>(body, headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + String name = jdbcTemplate.queryForObject( + "SELECT sUserName FROM tUser WHERE iIncrement = ?", String.class, userId); + assertThat(name).isEqualTo("原"); + } + + @Test + void getDefaults_with_jwt_returns200_andList() throws Exception { + insertUserWithPerms("sp_test_lst_1", "查询1", null, null); + insertUserWithPerms("sp_test_lst_2", "查询2", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + JsonNode jb = objectMapper.readTree(resp.getBody()); + assertThat(jb.get("code").asInt()).isZero(); + assertThat(jb.get("data").get("records").isArray()).isTrue(); + assertThat(jb.get("data").get("total").asInt()).isPositive(); + assertThat(jb.get("data").get("pageSize").asInt()).isEqualTo(20); + } + + @Test + void getKeywordContains_filtersByUsername() throws Exception { + insertUserWithPerms("sp_test_lst_kw1", "包含查询用户", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl("?field=用户名&match=包含&value=包含查询"), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + JsonNode data = objectMapper.readTree(resp.getBody()).get("data"); + for (JsonNode node : data.get("records")) { + assertThat(node.get("sUserName").asText()).contains("包含查询"); + } + } + + @Test + void getKeywordEquals_filtersExact() throws Exception { + insertUserWithPerms("sp_test_lst_eq", "等于精确", null, null); + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl("?field=用户号&match=等于&value=sp_test_lst_eq"), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + JsonNode records = objectMapper.readTree(resp.getBody()).get("data").get("records"); + assertThat(records.size()).isEqualTo(1); + assertThat(records.get(0).get("sUserNo").asText()).isEqualTo("sp_test_lst_eq"); + } + + @Test + void getInvalidField_returns40001() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl("?field=未知"), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40001); + } + + @Test + void getPageSizeExceeds100_returns40002() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl("?pageSize=200"), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(40002); + } + + @Test + void getNoMatch_returnsEmptyArray() throws Exception { + String token = testJwtHelper.signFor("ADMIN001"); + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer " + token); + + ResponseEntity resp = rest.exchange( + listUrl("?field=用户号&match=等于&value=不存在的用户号XYZ"), HttpMethod.GET, new HttpEntity<>(headers), String.class); + + JsonNode data = objectMapper.readTree(resp.getBody()).get("data"); + assertThat(data.get("records").isArray()).isTrue(); + assertThat(data.get("records").size()).isZero(); + assertThat(data.get("total").asInt()).isZero(); + } + + @Test + void getWithoutJwt_returns20001() throws Exception { + HttpHeaders headers = jsonHeaders(); + ResponseEntity resp = rest.exchange( + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + } + + @Test + void getTamperedJwt_returns20001() throws Exception { + HttpHeaders headers = jsonHeaders(); + headers.set("Authorization", "Bearer not.a.real.jwt"); + ResponseEntity resp = rest.exchange( + listUrl(""), HttpMethod.GET, new HttpEntity<>(headers), String.class); + assertThat(objectMapper.readTree(resp.getBody()).get("code").asInt()).isEqualTo(20001); + } + + private String listUrl(String querySuffix) { + return "http://localhost:" + port + "/api/usr/users" + querySuffix; + } + + private String idUrl(Integer id) { + return "http://localhost:" + port + "/api/usr/users/" + id; + } + + private Integer insertUserWithPerms(String userNo, String userName, Integer staffId, Integer catId) { + jdbcTemplate.update( + "INSERT INTO tUser (sBrandsId, sSubsidiaryId, tCreateDate, sUserNo, sUserName, iStaffId, " + + "sUserType, sLanguage, bCanModifyDocs, sPasswordHash, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, ?, '普通用户', 'zh', 0, " + + "'$2a$10$origHashOrigHashOrigHashOrigHashOrigHashOrigHashOrig', 'ORIG_CREATOR', 0)", + userNo, userName, staffId); + Integer userId = jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tUser WHERE sUserNo = ?", Integer.class, userNo); + if (catId != null) { + jdbcTemplate.update( + "INSERT INTO tUserPermission (sBrandsId, sSubsidiaryId, tCreateDate, iUserId, iCategoryId, sCreatedBy) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'ORIG_CREATOR')", + userId, catId); + } + return userId; + } + + private Integer insertStaff(String staffNo, String name) { + jdbcTemplate.update( + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'STUB_ADMIN', 0)", staffNo, name); + return jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tStaff WHERE sStaffNo = ?", Integer.class, staffNo); + } + + private Integer insertCategory(String code, String name) { + jdbcTemplate.update( + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " + + "iSortOrder, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, 0, 'STUB_ADMIN', 0)", code, name); + return jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = ?", Integer.class, code); + } + + private static Map baseBody(String userNo, String userName) { + Map m = new HashMap<>(); + m.put("sUserNo", userNo); + m.put("sUserName", userName); + m.put("sUserType", "普通用户"); + m.put("sLanguage", "zh"); + m.put("bCanModifyDocs", false); + return m; + } + + private static HttpHeaders jsonHeaders() { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + return h; + } + + private String url() { + return "http://localhost:" + port + "/api/usr/users"; + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java b/backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java new file mode 100644 index 0000000..104d053 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java @@ -0,0 +1,51 @@ +package com.xly.erp.module.usr.mapper; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +class PermissionCategoryMapperIT { + + @Autowired + private PermissionCategoryMapper permissionCategoryMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); + } + + @Test + void countActiveByIds_returnsCorrectCount() { + Integer cat1 = insertCategory("sp_test_c1", "权限1", false); + Integer cat2 = insertCategory("sp_test_c2", "权限2", false); + Integer cat3 = insertCategory("sp_test_c3", "权限3", true); + + assertThat(permissionCategoryMapper.countActiveByIds(List.of(cat1, cat2, cat3))).isEqualTo(2); + assertThat(permissionCategoryMapper.countActiveByIds(List.of(cat1, 99999991))).isEqualTo(1); + assertThat(permissionCategoryMapper.countActiveByIds(List.of(99999991))).isZero(); + } + + private Integer insertCategory(String code, String name, boolean deleted) { + jdbcTemplate.update( + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " + + "iSortOrder, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, 0, 'STUB_ADMIN', ?)", + code, name, deleted ? 1 : 0); + return jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = ?", Integer.class, code); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java b/backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java new file mode 100644 index 0000000..52c74c4 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java @@ -0,0 +1,47 @@ +package com.xly.erp.module.usr.mapper; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +class StaffMapperIT { + + @Autowired + private StaffMapper staffMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + @AfterEach + void cleanup() { + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo LIKE 'sp_test_%'"); + } + + @Test + void existsActiveById_handlesAliveDeletedMissing() { + Integer aliveId = insertStaff("sp_test_st_alive", "活的", false); + Integer deletedId = insertStaff("sp_test_st_dead", "死的", true); + + assertThat(staffMapper.existsActiveById(aliveId)).isTrue(); + assertThat(staffMapper.existsActiveById(deletedId)).isFalse(); + assertThat(staffMapper.existsActiveById(99999990)).isFalse(); + } + + private Integer insertStaff(String staffNo, String staffName, boolean deleted) { + jdbcTemplate.update( + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), ?, ?, 'STUB_ADMIN', ?)", + staffNo, staffName, deleted ? 1 : 0); + return jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tStaff WHERE sStaffNo = ?", Integer.class, staffNo); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java b/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java new file mode 100644 index 0000000..4951c8a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java @@ -0,0 +1,214 @@ +package com.xly.erp.module.usr.mapper; + +import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.entity.UserPermission; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +class UserMapperIT { + + @Autowired + private UserMapper userMapper; + + @Autowired + private UserPermissionMapper userPermissionMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + @AfterEach + void cleanup() { + jdbcTemplate.update( + "DELETE FROM tUserPermission WHERE iUserId IN " + + "(SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')"); + jdbcTemplate.update("DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'"); + jdbcTemplate.update("DELETE FROM tPermissionCategory WHERE sCategoryCode LIKE 'sp_test_%'"); + } + + @Test + void insertAndSelectById_persistsAllStandardCols() { + User u = newUser("sp_test_u1", "用户1"); + int rows = userMapper.insert(u); + assertThat(rows).isEqualTo(1); + assertThat(u.getIIncrement()).isNotNull(); + + User loaded = userMapper.selectById(u.getIIncrement()); + assertThat(loaded.getSUserNo()).isEqualTo("sp_test_u1"); + assertThat(loaded.getSUserName()).isEqualTo("用户1"); + assertThat(loaded.getSBrandsId()).isEqualTo("XLY"); + assertThat(loaded.getSPasswordHash()).startsWith("$2a$"); + assertThat(loaded.getBDeleted()).isFalse(); + assertThat(loaded.getSUserType()).isEqualTo("普通用户"); + } + + @Test + void uniqueUserNoConstraint_rejectsDuplicate() { + userMapper.insert(newUser("sp_test_dup", "首次")); + User second = newUser("sp_test_dup", "第二次"); + second.setSUserName("sp_test_other_name"); + assertThatThrownBy(() -> userMapper.insert(second)) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void userPermissionInsert_persistsRowWithUserAndCategory() { + User u = newUser("sp_test_perm_user", "权限关联用户"); + userMapper.insert(u); + + jdbcTemplate.update( + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " + + "iSortOrder, sCreatedBy, bDeleted) " + + "VALUES ('XLY','XLY', NOW(), 'sp_test_pc', '权限分类1', 0, 'STUB_ADMIN', 0)"); + Integer catId = jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_pc'", Integer.class); + + UserPermission rel = new UserPermission(); + rel.setSBrandsId("XLY"); + rel.setSSubsidiaryId("XLY"); + rel.setTCreateDate(LocalDateTime.now()); + rel.setIUserId(u.getIIncrement()); + rel.setICategoryId(catId); + rel.setSCreatedBy("STUB_ADMIN"); + int rows = userPermissionMapper.insert(rel); + assertThat(rows).isEqualTo(1); + assertThat(rel.getIIncrement()).isNotNull(); + + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ? AND iCategoryId = ?", + Integer.class, u.getIIncrement(), catId); + assertThat(count).isEqualTo(1); + } + + @Test + void selectByUserName_returnsRowOrNull() { + User u = newUser("sp_test_byname", "命名用户"); + userMapper.insert(u); + + User loaded = userMapper.selectByUserName("命名用户"); + assertThat(loaded).isNotNull(); + assertThat(loaded.getSUserNo()).isEqualTo("sp_test_byname"); + + assertThat(userMapper.selectByUserName("不存在的用户名XYZ")).isNull(); + } + + @Test + void updateLastLoginDate_setsValue() { + User u = newUser("sp_test_lastlogin", "登录日期用户"); + userMapper.insert(u); + java.time.LocalDateTime ts = java.time.LocalDateTime.of(2026, 4, 30, 10, 0, 0); + + int rows = userMapper.updateLastLoginDate(u.getIIncrement(), ts); + assertThat(rows).isEqualTo(1); + + java.sql.Timestamp loaded = jdbcTemplate.queryForObject( + "SELECT tLastLoginDate FROM tUser WHERE iIncrement = ?", java.sql.Timestamp.class, u.getIIncrement()); + assertThat(loaded).isNotNull(); + assertThat(loaded.toLocalDateTime()).isEqualTo(ts); + } + + @Test + void pageWithFilter_filtersAndJoins() { + jdbcTemplate.update( + "INSERT INTO tStaff (sBrandsId, sSubsidiaryId, tCreateDate, sStaffNo, sStaffName, sDepartment, " + + "sCreatedBy, bDeleted) VALUES ('XLY','XLY', NOW(), 'sp_test_lst_st', '员工X', '研发', 'STUB', 0)"); + Integer staffId = jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tStaff WHERE sStaffNo = 'sp_test_lst_st'", Integer.class); + + User u1 = newUser("sp_test_lst_u1", "查询用户1"); + u1.setIStaffId(staffId); + userMapper.insert(u1); + User u2 = newUser("sp_test_lst_u2", "查询用户2"); + userMapper.insert(u2); + + java.util.List result = userMapper.pageWithFilter( + "u.sUserNo", "contains", "sp_test_lst_", 0, 10); + + assertThat(result).extracting(com.xly.erp.module.usr.vo.UserListVO::getSUserNo) + .contains("sp_test_lst_u1", "sp_test_lst_u2"); + com.xly.erp.module.usr.vo.UserListVO row1 = result.stream() + .filter(v -> "sp_test_lst_u1".equals(v.getSUserNo())).findFirst().orElseThrow(); + assertThat(row1.getStaffName()).isEqualTo("员工X"); + assertThat(row1.getDepartment()).isEqualTo("研发"); + com.xly.erp.module.usr.vo.UserListVO row2 = result.stream() + .filter(v -> "sp_test_lst_u2".equals(v.getSUserNo())).findFirst().orElseThrow(); + assertThat(row2.getStaffName()).isNull(); + + long total = userMapper.countWithFilter("u.sUserNo", "contains", "sp_test_lst_"); + assertThat(total).isGreaterThanOrEqualTo(2); + + jdbcTemplate.update("DELETE FROM tStaff WHERE sStaffNo = 'sp_test_lst_st'"); + } + + @Test + void userPermissionMapper_deleteByUserId_removesAllRowsForGivenUser() { + User user1 = newUser("sp_test_del_u1", "用户1"); + userMapper.insert(user1); + User user2 = newUser("sp_test_del_u2", "用户2"); + userMapper.insert(user2); + + jdbcTemplate.update( + "INSERT INTO tPermissionCategory (sBrandsId, sSubsidiaryId, tCreateDate, sCategoryCode, sCategoryName, " + + "iSortOrder, sCreatedBy, bDeleted) VALUES " + + "('XLY','XLY', NOW(), 'sp_test_del_c1', '权限C1', 0, 'STUB', 0)," + + "('XLY','XLY', NOW(), 'sp_test_del_c2', '权限C2', 0, 'STUB', 0)"); + Integer cat1 = jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_del_c1'", Integer.class); + Integer cat2 = jdbcTemplate.queryForObject( + "SELECT iIncrement FROM tPermissionCategory WHERE sCategoryCode = 'sp_test_del_c2'", Integer.class); + + insertUserPermission(user1.getIIncrement(), cat1); + insertUserPermission(user1.getIIncrement(), cat2); + insertUserPermission(user2.getIIncrement(), cat1); + + int affected = userPermissionMapper.deleteByUserId(user1.getIIncrement()); + assertThat(affected).isEqualTo(2); + + Integer u1Count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, user1.getIIncrement()); + Integer u2Count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tUserPermission WHERE iUserId = ?", Integer.class, user2.getIIncrement()); + assertThat(u1Count).isZero(); + assertThat(u2Count).isEqualTo(1); + } + + private void insertUserPermission(Integer userId, Integer catId) { + UserPermission rel = new UserPermission(); + rel.setSBrandsId("XLY"); + rel.setSSubsidiaryId("XLY"); + rel.setTCreateDate(LocalDateTime.now()); + rel.setIUserId(userId); + rel.setICategoryId(catId); + rel.setSCreatedBy("STUB_ADMIN"); + userPermissionMapper.insert(rel); + } + + private User newUser(String userNo, String userName) { + User u = new User(); + u.setSBrandsId("XLY"); + u.setSSubsidiaryId("XLY"); + u.setTCreateDate(LocalDateTime.now()); + u.setSUserNo(userNo); + u.setSUserName(userName); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash("$2a$10$stubhashstubhashstubhashstubhashstubhashstubhashstubhash"); + u.setSCreatedBy("STUB_ADMIN"); + u.setBDeleted(false); + return u; + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java b/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java new file mode 100644 index 0000000..ae1c543 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/security/LoginAttemptStoreTest.java @@ -0,0 +1,69 @@ +package com.xly.erp.module.usr.security; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoginAttemptStoreTest { + + @Test + void recordFailure_incrementsCount_andDoesNotLockBeforeMax() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 1; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + int c = store.recordFailure("u1"); + assertThat(c).isEqualTo(i); + } + assertThat(store.isLocked("u1")).isEmpty(); + } + + @Test + void recordFailure_locksAtMaxAttempts() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent().get().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LONG) + .isPositive(); + } + + @Test + void differentUserNames_areIsolated() { + LoginAttemptStore store = new LoginAttemptStore(); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent(); + assertThat(store.isLocked("u2")).isEmpty(); + } + + @Test + void lockExpiresAfterDuration() { + Clock fixed = Clock.fixed(Instant.parse("2026-04-30T09:00:00Z"), ZoneId.of("UTC")); + LoginAttemptStore store = new LoginAttemptStore(fixed); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store.recordFailure("u1"); + } + assertThat(store.isLocked("u1")).isPresent(); + + Clock later = Clock.fixed(Instant.parse("2026-04-30T09:20:00Z"), ZoneId.of("UTC")); + LoginAttemptStore store2 = new LoginAttemptStore(later); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + store2.recordFailure("u1"); + } + // Setup new store at later time and verify a fresh user with no records is unlocked + assertThat(new LoginAttemptStore(later).isLocked("u_fresh")).isEmpty(); + } + + @Test + void clearFailures_resetsCounter() { + LoginAttemptStore store = new LoginAttemptStore(); + store.recordFailure("u1"); + store.recordFailure("u1"); + store.clearFailures("u1"); + assertThat(store.recordFailure("u1")).isEqualTo(1); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java new file mode 100644 index 0000000..799002f --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java @@ -0,0 +1,569 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.config.StubSecurityProperties; +import com.xly.erp.common.config.TenantProperties; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.dto.UpdateUserDTO; +import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.vo.LoginVO; +import com.xly.erp.module.usr.entity.UserPermission; +import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; +import com.xly.erp.module.usr.mapper.StaffMapper; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.mapper.UserPermissionMapper; +import com.xly.erp.module.usr.service.impl.UserServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class UserServiceImplTest { + + private UserMapper userMapper; + private UserPermissionMapper userPermissionMapper; + private StaffMapper staffMapper; + private PermissionCategoryMapper permissionCategoryMapper; + private BCryptPasswordEncoder encoder; + private JwtUtil jwtUtil; + private LoginAttemptStore loginAttemptStore; + private UserServiceImpl service; + + @BeforeEach + void setUp() { + userMapper = mock(UserMapper.class); + userPermissionMapper = mock(UserPermissionMapper.class); + staffMapper = mock(StaffMapper.class); + permissionCategoryMapper = mock(PermissionCategoryMapper.class); + encoder = new BCryptPasswordEncoder(); + jwtUtil = new JwtUtil("f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"); + loginAttemptStore = new LoginAttemptStore(); + TenantProperties tenant = new TenantProperties(); + tenant.setBrandsId("XLY"); + tenant.setSubsidiaryId("XLY"); + StubSecurityProperties stub = new StubSecurityProperties(); + stub.setStubUserNo("STUB_ADMIN"); + + service = new UserServiceImpl(userMapper, userPermissionMapper, staffMapper, + permissionCategoryMapper, tenant, stub, encoder, jwtUtil, loginAttemptStore); + + lenient().when(userMapper.insert(any(User.class))).thenAnswer(inv -> { + User u = inv.getArgument(0); + u.setIIncrement(456); + return 1; + }); + lenient().when(userPermissionMapper.insert(any(UserPermission.class))).thenReturn(1); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void createWithValidDto_persistsUser_andUserPermissions() { + when(staffMapper.existsActiveById(7)).thenReturn(true); + when(permissionCategoryMapper.countActiveByIds(List.of(11, 22))).thenReturn(2); + + CreateUserDTO dto = baseDto(); + dto.setIStaffId(7); + dto.setPermissionCategoryIds(List.of(11, 22)); + + Map result = service.create(dto); + + assertThat(result).containsEntry("iIncrement", 456).containsEntry("sUserNo", "u001"); + ArgumentCaptor userCap = ArgumentCaptor.forClass(User.class); + verify(userMapper).insert(userCap.capture()); + User saved = userCap.getValue(); + assertThat(saved.getSBrandsId()).isEqualTo("XLY"); + assertThat(saved.getSCreatedBy()).isNull(); + assertThat(saved.getTCreateDate()).isNotNull(); + assertThat(saved.getSPasswordHash()).startsWith("$2a$"); + assertThat(saved.getBDeleted()).isFalse(); + assertThat(saved.getIStaffId()).isEqualTo(7); + assertThat(saved.getBCanModifyDocs()).isFalse(); + + ArgumentCaptor permCap = ArgumentCaptor.forClass(UserPermission.class); + verify(userPermissionMapper, times(2)).insert(permCap.capture()); + List rels = permCap.getAllValues(); + assertThat(rels).extracting(UserPermission::getICategoryId).containsExactly(11, 22); + assertThat(rels).allMatch(r -> r.getIUserId().equals(456)); + assertThat(rels).allMatch(r -> r.getSBrandsId().equals("XLY")); + } + + @Test + void createWithoutPermissionCategoryIds_skipsUserPermissionInserts() { + CreateUserDTO dto = baseDto(); + dto.setPermissionCategoryIds(null); + + service.create(dto); + + verify(userMapper, times(1)).insert(any(User.class)); + verify(userPermissionMapper, never()).insert(any(UserPermission.class)); + } + + @Test + void createWithInvalidUserType_throws40001() { + CreateUserDTO dto = baseDto(); + dto.setSUserType("火星"); + assertThatThrownBy(() -> service.create(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + verify(userMapper, never()).insert(any(User.class)); + } + + @Test + void createWithInvalidLanguage_throws40001() { + CreateUserDTO dto = baseDto(); + dto.setSLanguage("ja"); + assertThatThrownBy(() -> service.create(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + verify(userMapper, never()).insert(any(User.class)); + } + + @Test + void createWithStaffNotFound_throws40022() { + when(staffMapper.existsActiveById(99)).thenReturn(false); + CreateUserDTO dto = baseDto(); + dto.setIStaffId(99); + assertThatThrownBy(() -> service.create(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40022); + verify(userMapper, never()).insert(any(User.class)); + } + + @Test + void createWithSomeInvalidPermissionIds_throws40023() { + when(permissionCategoryMapper.countActiveByIds(List.of(1, 2, 3))).thenReturn(2); + CreateUserDTO dto = baseDto(); + dto.setPermissionCategoryIds(List.of(1, 2, 3)); + assertThatThrownBy(() -> service.create(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40023); + verify(userMapper, never()).insert(any(User.class)); + } + + @Test + void createWithDuplicateUserNo_throws40020() { + when(userMapper.insert(any(User.class))) + .thenThrow(new DuplicateKeyException("uk_user_no")); + CreateUserDTO dto = baseDto(); + assertThatThrownBy(() -> service.create(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40020); + } + + @Test + void createUsesAuthenticatedUserNoAsCreatedBy() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("ALICE", null, Collections.emptyList())); + CreateUserDTO dto = baseDto(); + + service.create(dto); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userMapper).insert(cap.capture()); + assertThat(cap.getValue().getSCreatedBy()).isEqualTo("ALICE"); + } + + @Test + void updateWithValidDto_invokesUpdateById_andRebuildsPermissions() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(staffMapper.existsActiveById(7)).thenReturn(true); + when(permissionCategoryMapper.countActiveByIds(List.of(11, 22))).thenReturn(2); + when(userMapper.updateById(any(User.class))).thenReturn(1); + when(userPermissionMapper.deleteByUserId(10)).thenReturn(3); + + UpdateUserDTO dto = baseUpdateDto(); + dto.setIStaffId(7); + dto.setPermissionCategoryIds(List.of(11, 22)); + + Integer result = service.update(10, dto); + + assertThat(result).isEqualTo(10); + ArgumentCaptor uc = ArgumentCaptor.forClass(User.class); + verify(userMapper).updateById(uc.capture()); + User passed = uc.getValue(); + assertThat(passed.getIIncrement()).isEqualTo(10); + assertThat(passed.getSUserNo()).isEqualTo("u_new"); + assertThat(passed.getSPasswordHash()).isNull(); + assertThat(passed.getSCreatedBy()).isNull(); + assertThat(passed.getTCreateDate()).isNull(); + assertThat(passed.getSBrandsId()).isNull(); + + verify(userPermissionMapper, times(1)).deleteByUserId(10); + verify(userPermissionMapper, times(2)).insert(any(UserPermission.class)); + } + + @Test + void updateWithTargetNotFound_throws40400() { + when(userMapper.selectById(99)).thenReturn(null); + assertThatThrownBy(() -> service.update(99, baseUpdateDto())) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40400); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithTargetAlreadyDeleted_throws40400() { + User deleted = stubExistingUser(10); + deleted.setBDeleted(true); + when(userMapper.selectById(10)).thenReturn(deleted); + assertThatThrownBy(() -> service.update(10, baseUpdateDto())) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40400); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithBCanModifyDocsNull_setsFalseInEntity() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(userMapper.updateById(any(User.class))).thenReturn(1); + UpdateUserDTO dto = baseUpdateDto(); + dto.setBCanModifyDocs(null); + + service.update(10, dto); + + ArgumentCaptor uc = ArgumentCaptor.forClass(User.class); + verify(userMapper).updateById(uc.capture()); + assertThat(uc.getValue().getBCanModifyDocs()).isFalse(); + } + + @Test + void updateWithInvalidUserType_throws40001() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + UpdateUserDTO dto = baseUpdateDto(); + dto.setSUserType("火星"); + assertThatThrownBy(() -> service.update(10, dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithInvalidLanguage_throws40001() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + UpdateUserDTO dto = baseUpdateDto(); + dto.setSLanguage("ja"); + assertThatThrownBy(() -> service.update(10, dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithStaffNotFound_throws40022() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(staffMapper.existsActiveById(99)).thenReturn(false); + UpdateUserDTO dto = baseUpdateDto(); + dto.setIStaffId(99); + assertThatThrownBy(() -> service.update(10, dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40022); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithSomeInvalidPermissionIds_throws40023() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(permissionCategoryMapper.countActiveByIds(List.of(1, 2, 3))).thenReturn(2); + UpdateUserDTO dto = baseUpdateDto(); + dto.setPermissionCategoryIds(List.of(1, 2, 3)); + assertThatThrownBy(() -> service.update(10, dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40023); + verify(userMapper, never()).updateById(any(User.class)); + } + + @Test + void updateWithDuplicateUserNo_throws40020() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(userMapper.updateById(any(User.class))) + .thenThrow(new DuplicateKeyException("uk_user_no")); + UpdateUserDTO dto = baseUpdateDto(); + assertThatThrownBy(() -> service.update(10, dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40020); + verify(userPermissionMapper, never()).deleteByUserId(any()); + } + + @Test + void updateWithEmptyPermissionIds_clearsExisting() { + when(userMapper.selectById(10)).thenReturn(stubExistingUser(10)); + when(userMapper.updateById(any(User.class))).thenReturn(1); + when(userPermissionMapper.deleteByUserId(10)).thenReturn(2); + UpdateUserDTO dto = baseUpdateDto(); + dto.setPermissionCategoryIds(null); + + service.update(10, dto); + + verify(userPermissionMapper, times(1)).deleteByUserId(10); + verify(userPermissionMapper, never()).insert(any(UserPermission.class)); + } + + @Test + void listWithDefaults_invokesMapperWithUserNameContainsEmpty() { + when(userMapper.pageWithFilter(eq("u.sUserName"), eq("contains"), eq(""), eq(0), eq(20))) + .thenReturn(List.of()); + when(userMapper.countWithFilter(eq("u.sUserName"), eq("contains"), eq(""))).thenReturn(0L); + + Map r = service.list(null, null, null, null, null); + + assertThat(r).containsEntry("total", 0L).containsEntry("pageNum", 1).containsEntry("pageSize", 20); + } + + @Test + void listWithEmptyValue_skipsFilterCondition() { + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); + + service.list("用户号", "等于", "", 1, 20); + + ArgumentCaptor valueCap = ArgumentCaptor.forClass(Object.class); + verify(userMapper).pageWithFilter(eq("u.sUserNo"), eq("equals"), valueCap.capture(), eq(0), eq(20)); + assertThat(valueCap.getValue()).isEqualTo(""); + } + + @Test + void listWithKeywordTrim() { + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); + + service.list("用户名", "包含", " abc ", 1, 10); + + verify(userMapper).pageWithFilter(eq("u.sUserName"), eq("contains"), eq("abc"), eq(0), eq(10)); + } + + @Test + void listReturnsEmptyRecords_whenMapperReturnsEmptyPage() { + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); + + Map r = service.list("用户名", "包含", "xyz", 1, 20); + + assertThat((List) r.get("records")).isEmpty(); + assertThat(r).containsEntry("total", 0L); + } + + @Test + void listWithInvalidField_throws40001() { + assertThatThrownBy(() -> service.list("未知字段", "包含", "x", 1, 20)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + } + + @Test + void listWithInvalidMatch_throws40001() { + assertThatThrownBy(() -> service.list("用户名", "未知方式", "x", 1, 20)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + } + + @Test + void listWithIncompatibleFieldMatch_throws40001() { + assertThatThrownBy(() -> service.list("作废", "包含", "true", 1, 20)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + } + + @Test + void listWithPageSizeExceeds100_throws40002() { + assertThatThrownBy(() -> service.list("用户名", "包含", "", 1, 101)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40002); + } + + @Test + void listWithInvalidLoginDateFormat_throws40001() { + assertThatThrownBy(() -> service.list("登录日期", "等于", "abc", 1, 20)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40001); + } + + @Test + void listWithBooleanFieldEqualsTrue_passesIntegerOne() { + when(userMapper.pageWithFilter(any(), any(), any(), anyInt(), anyInt())).thenReturn(List.of()); + when(userMapper.countWithFilter(any(), any(), any())).thenReturn(0L); + + service.list("作废", "等于", "true", 1, 20); + + verify(userMapper).pageWithFilter(eq("u.bDeleted"), eq("equals"), eq(1), eq(0), eq(20)); + } + + @Test + void loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate() { + User u = new User(); + u.setIIncrement(100); + u.setSUserNo("u100"); + u.setSUserName("login_ok"); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash(encoder.encode("666666")); + u.setBDeleted(false); + when(userMapper.selectByUserName("login_ok")).thenReturn(u); + + LoginDTO dto = new LoginDTO(); + dto.setSUserName("login_ok"); + dto.setPassword("666666"); + dto.setVersion("标准版"); + + LoginVO vo = service.login(dto); + + assertThat(vo.getAccessToken()).isNotBlank(); + assertThat(vo.getRefreshToken()).isNotBlank(); + assertThat(vo.getRefreshToken()).isNotEqualTo(vo.getAccessToken()); + assertThat(vo.getExpiresIn()).isEqualTo(28800); + assertThat(vo.getUser().getIIncrement()).isEqualTo(100); + assertThat(vo.getUser().getSUserNo()).isEqualTo("u100"); + verify(userMapper).updateLastLoginDate(eq(100), any()); + } + + @Test + void loginWithUserNotFound_throws40101() { + when(userMapper.selectByUserName("ghost")).thenReturn(null); + LoginDTO dto = loginDto("ghost", "x"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40101); + } + + @Test + void loginWithDeletedUser_throws40102() { + User u = stubLoginUser("dead"); + u.setBDeleted(true); + when(userMapper.selectByUserName("dead")).thenReturn(u); + LoginDTO dto = loginDto("dead", "666666"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40102); + } + + @Test + void loginWithWrongPassword_incrementsCounter_throws40101() { + when(userMapper.selectByUserName("u_wrong")).thenReturn(stubLoginUser("u_wrong")); + LoginDTO dto = loginDto("u_wrong", "wrong"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40101); + } + + @Test + void loginAfterMaxAttemptsReached_throws42301() { + when(userMapper.selectByUserName("u_lock")).thenReturn(stubLoginUser("u_lock")); + LoginDTO dto = loginDto("u_lock", "wrong"); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { + try { service.login(dto); } catch (BizException ignored) {} + } + // 第 5 次失败应触发锁定 → 42301 + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 42301); + } + + @Test + void loginWhileLocked_throws42301() { + when(userMapper.selectByUserName("u_locked2")).thenReturn(stubLoginUser("u_locked2")); + LoginDTO bad = loginDto("u_locked2", "wrong"); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + try { service.login(bad); } catch (BizException ignored) {} + } + // 锁定后即使密码正确也走 42301 + LoginDTO good = loginDto("u_locked2", "666666"); + assertThatThrownBy(() -> service.login(good)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 42301); + } + + @Test + void loginSuccess_clearsFailureCounter() { + when(userMapper.selectByUserName("u_clear")).thenReturn(stubLoginUser("u_clear")); + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + // 现在 2 次失败;正确登录后清空 + service.login(loginDto("u_clear", "666666")); + // 之后再 4 次错误应不锁(计数已重置) + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + } + // MAX-1 次错误后仍未锁 + assertThat(loginAttemptStore.isLocked("u_clear")).isEmpty(); + } + + private LoginDTO loginDto(String userName, String password) { + LoginDTO dto = new LoginDTO(); + dto.setSUserName(userName); + dto.setPassword(password); + dto.setVersion("标准版"); + return dto; + } + + private User stubLoginUser(String userName) { + User u = new User(); + u.setIIncrement(200); + u.setSUserNo("u200"); + u.setSUserName(userName); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash(encoder.encode("666666")); + u.setBDeleted(false); + return u; + } + + private UpdateUserDTO baseUpdateDto() { + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSUserNo("u_new"); + dto.setSUserName("用户新"); + dto.setSUserType("普通用户"); + dto.setSLanguage("zh"); + dto.setBCanModifyDocs(false); + return dto; + } + + private User stubExistingUser(Integer id) { + User u = new User(); + u.setIIncrement(id); + u.setSUserNo("u_orig"); + u.setSUserName("原用户"); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash("$2a$10$origHash"); + u.setSCreatedBy("ORIG_USER"); + u.setBDeleted(false); + return u; + } + + private CreateUserDTO baseDto() { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserNo("u001"); + dto.setSUserName("用户1"); + dto.setSUserType("普通用户"); + dto.setSLanguage("zh"); + dto.setBCanModifyDocs(false); + return dto; + } +} diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index e7749ba..78270f9 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -68,9 +68,9 @@ - module_usr 用户管理 - 依赖: — - 路径: backend/module/usr/, frontend/pages/usr/ - - MR: — + - MR: !2 - 功能: - - [ ] REQ-USR-001 用户新增 - - [ ] REQ-USR-002 用户修改 - - [ ] REQ-USR-003 用户查询 - - [ ] REQ-USR-004 用户登录 + - [x] REQ-USR-001 用户新增 + - [x] REQ-USR-002 用户修改 + - [x] REQ-USR-003 用户查询 + - [x] REQ-USR-004 用户登录 diff --git a/docs/superpowers/module-reports/2026-04-30-module_usr.md b/docs/superpowers/module-reports/2026-04-30-module_usr.md new file mode 100644 index 0000000..1cf8f02 --- /dev/null +++ b/docs/superpowers/module-reports/2026-04-30-module_usr.md @@ -0,0 +1,164 @@ +--- +module_id: module_usr +date: 2026-04-30 +git_range: master..HEAD (31 commits) +--- + +# 模块完成报告 — module_usr 用户管理 + +## ① 模块信息 +- 模块 ID: module_usr +- 模块名: 用户管理 +- 开发区间: 31 commits,~4149 行新增 +- 分支: `module-module_usr` +- **里程碑**:USR-004 完成 stub 闭环——整个项目 SecurityConfig 收紧到 `登录接口外全部 authenticated()` + +## ② REQ 完成清单 + +- [x] REQ-USR-001 — 用户新增 + - spec: docs/superpowers/specs/2026-04-30-REQ-USR-001.md + - plan: docs/superpowers/plans/2026-04-30-REQ-USR-001.md + - review: docs/superpowers/reviews/2026-04-30-REQ-USR-001.md +- [x] REQ-USR-002 — 用户修改 + - spec/plan/review: 同名 2026-04-30-REQ-USR-002 三件套 +- [x] REQ-USR-003 — 用户查询 + - spec/plan/review: 同名 2026-04-30-REQ-USR-003 三件套 +- [x] REQ-USR-004 — 用户登录(含 Stub 闭环) + - spec/plan/review: 同名 2026-04-30-REQ-USR-004 三件套 + +## ③ 文件变更表 + +| 文件 | 操作 | 说明 | +|---|---|---| +| backend/src/main/java/com/xly/erp/module/usr/entity/User.java | 新建 | 17 字段 1:1 映射 tUser | +| backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java | 新建 | 8 字段映射 tUserPermission | +| backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java | 新建 | USR-001 入参 | +| backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java | 新建 | USR-002 入参(无 sPasswordHash) | +| backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java | 新建 | USR-004 登录入参 | +| backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java | 新建 | USR-003 列表 VO(11 字段,含 LEFT JOIN tStaff) | +| backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java | 新建 | USR-004 用户简表 | +| backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java | 新建 | USR-004 登录响应 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java | 新建 + 多次扩展 | BaseMapper + 自定义查询 + 登录用方法 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java | 新建 + deleteByUserId | USR-001 + USR-002 | +| backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java | 新建 | 最小 existsActiveById | +| backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java | 新建 | 批量校验 countActiveByIds | +| backend/src/main/resources/mapper/usr/UserMapper.xml | 新建 | USR-003 动态 SQL(pageWithFilter / countWithFilter) | +| backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java | 新建 | USR-004 内存失败计数 + 锁定 | +| backend/src/main/java/com/xly/erp/module/usr/service/UserService.java | 新建 + 4 次扩展 | create/update/list/login | +| backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java | 新建 + 4 次扩展 | 全部业务逻辑 | +| backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java | 新建 | POST/PUT/GET /api/usr/users | +| backend/src/main/java/com/xly/erp/module/usr/controller/AuthController.java | 新建 | POST /api/usr/auth/login | +| backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java | 新建 | BCryptPasswordEncoder bean(独立于 SecurityConfig,避免 NONE 上下文丢失) | +| backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationEntryPoint.java | 新建 | USR-004 未认证返回 code=20001 | +| backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java | 修改 | USR-001 加 /api/usr/** permitAll → USR-004 收紧到仅 /api/usr/auth/login permitAll + entryPoint | +| backend/src/main/java/com/xly/erp/common/security/JwtUtil.java | 修改 | 加 signRefresh + 通用 sign(userNo, ttl) | +| backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java | 修改 | USR-004 移除 stub.getStubUserNo() fallback | +| backend/src/test/java/com/xly/erp/module/usr/**/* (5 文件) | 新建 | UserMapperIT(7) / StaffMapperIT(1) / PermissionCategoryMapperIT(1) / UserServiceImplTest(35) / UserControllerIT(27) / LoginAttemptStoreTest(5) / AuthControllerIT(5) | +| backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java | 修改 | USR-004 闭环:4 处 stub IT 期望从 200 改 20001 | +| backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java | 修改 | 加 signRefresh 验证 | +| docs/superpowers/specs|plans|reviews/2026-04-30-REQ-USR-{001..004}.md | 新建 | 12 份文档 | +| docs/superpowers/module-reports/module_usr-test-gate.md | 新建 | 本模块 test-gate 证据 | +| docs/08-模块任务管理.md | 修改 | 4 个 REQ checkbox 勾上 | + +## ④ 数据库使用表 + +- 读:`tUser` / `tStaff` / `tPermissionCategory`(各 REQ 的存在性校验 / JOIN 显示) +- 写: + - `tUser`:INSERT (USR-001) / UPDATE 可编辑列 (USR-002) / UPDATE tLastLoginDate (USR-004) + - `tUserPermission`:INSERT (USR-001) / DELETE+INSERT 重建 (USR-002) + +## ⑤ 测试结果 + +- `scripts/test.sh` 最终:**GREEN** +- 通过: 149 / 失败: 0 / 跳过: 0(前端段因 frontend/ 未初始化而 skip) +- 详见: docs/superpowers/module-reports/module_usr-test-gate.md +- 测试分布: + - SmokeTest: 1 + - 全局基础设施: GlobalExceptionHandlerTest 4 / JwtUtilTest 4 / JwtAuthenticationFilterTest 3 + - MOD 模块: ModuleMapperIT 5 / ModuleServiceImplTest 25 / ModuleControllerIT 26 + - USR 模块: UserMapperIT 7 / StaffMapperIT 1 / PermissionCategoryMapperIT 1 / LoginAttemptStoreTest 5 / UserServiceImplTest 35 / UserControllerIT 27 / AuthControllerIT 5 + +## ⑥ 本模块新增 Migration + +—(无 schema 改动;本模块 4 张表均在 V1 就位) + +## ⑦ 跨模块改动清单(软规则 S2) + +**1 处跨模块改动**(USR-004 stub 闭环必需,spec 已声明): + +- `backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java:create()/delete()` — 移除 `stub.getStubUserNo()` fallback +- `backend/src/test/java/com/xly/erp/module/mod/controller/ModuleControllerIT.java` — 4 处 `*WithoutJwt_permitAllStub_*` 用例改名为 `*WithoutJwt_returns20001`,期望 200 → 20001 +- `backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java` — 2 处 sCreatedBy 期望从 "STUB_ADMIN" 改为 null + +**原因 / 影响评估**: +- 原因:USR-004 是模块循环最后一个 REQ,按 docs/02 § 二 顺序正好在收尾。spec 把 stub 闭环作为 Phase B 纳入本 REQ;项目首次具备真正的 JWT 鉴权能力,必须把 MOD 模块 stub-friendly 的测试期望同步收紧 +- 影响评估:MOD 模块业务逻辑未变(仅 fallback 一行被删,行为等价于"sCreatedBy 必须由 SecurityContextHelper 提供"——authenticated 守卫保证非 null);MOD 模块的 IT 测试在改写后行为更接近生产实际(无 token 必拒),增强了回归质量 + +## ⑧ 偏离 spec 清单 + +- **REQ-USR-001 & spec § 实现范围 #3**:spec 写 BCryptPasswordEncoder bean 在 SecurityConfig 注册,实际移到独立 `PasswordEncoderConfig.java`。原因:SecurityConfig `@ConditionalOnWebApplication(SERVLET)` 在 webEnvironment=NONE 的 mapperIT 上下文不加载。良性设计修正——密码编码器应是领域基础设施而非 Web 安全基础设施。 +- **REQ-USR-002 & spec/plan § "controller 先 trim"**:实际实现 trim 在 service。功能等价;spec 文字与实现微差。 +- **REQ-USR-003 & plan § 文件变更清单**:未创建 `UserListQuery` DTO,5 参直接传 service。简化合理。 +- **REQ-USR-002 错误码 40022**:docs/05 § USR-002 错误码列表未列 40022;本实现复用 USR-001 已建立的语义(同字段两个接口错误码一致更优)。 +- **REQ-USR-004 LoginDTO.version `@NotBlank`**:spec § 输入说"仅记录, 不参与校验",实际加了 @NotBlank。微差。 + +## ⑨ AI reviewer 报告汇总 + +- REQ-USR-001: round 1 — approve +- REQ-USR-002: round 1 — approve +- REQ-USR-003: round 1 — approve +- REQ-USR-004: round 1 — approve(含 Phase B stub 闭环) + +4/4 round 1 一次性通过;4 份 review 共约 28 条 nice-to-have,无 must-fix。 + +## ⑩ 已知问题 + +整合自 4 份 review 的非阻塞 nice-to-have,按主题分组: + +**架构遗留(建议下一模块开始前 chore commit 一次性收口)** +1. `ModuleServiceImpl` / `UserServiceImpl` 移除 stub fallback 后 `private final StubSecurityProperties stub` 字段已无引用(但构造器仍注入);按 surgical-changes 原则建议删字段 + 构造器参数 + 测试 setUp +2. `JwtAuthenticationFilter` 仍带 `@Component`,与 SecurityConfig `addFilterBefore` 重叠注册;可改 @Bean + FilterRegistrationBean disable +3. `JwtUtil` 暴露两个 public 构造器;`JwtUtil(String secret)` 实质 test-only +4. 业务方法 (create/update/delete/listTree/login 5 处) 缺行内 `// REQ-USR-XXX` 锚点 +5. entity `Module` 类名与 `java.lang.Module` 冲突 +6. `application-dev.yml` 缺失(docs/09 § 二 列出) + +**测试覆盖增强(非阻塞)** +7. `LoginAttemptStoreTest.lockExpiresAfterDuration` 未驱动同 userName 跨过 lockExpireAt 验证 records.remove 自动清除 +8. PUT-stub-regression IT 只断单列 unchanged,建议同时断 sCreatedBy / tCreateDate +9. USR-001/002 permissionCategoryIds 重复 id 假阴性(IN 隐式去重,建议 service `distinct`) +10. USR-003 响应 records 缺直接 `has("sPasswordHash") == false` 断言 +11. USR-003 ORDER BY iIncrement DESC / tLastLoginDate JSON ISO 格式无显式断言 +12. `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 在 update / delete IT 端未端到端断言保留 +13. `iParentId` 指向 `bDeleted=1` 旧记录是否回 40021 未单独验证 +14. AuthControllerIT 字段级负例(仅缺 sUserName)未单独覆盖 + +**配置 / 校验** +15. application.yml `${JWT_SECRET}` 缺 fail-fast 默认值 +16. GlobalExceptionHandler `handleAny(Exception)` 把 4xx 框架异常转 50000 +17. DTO `iParentId` / `iSortOrder` 缺 `@PositiveOrZero` / `@Min` 约束 +18. `LoginDTO.version` `@NotBlank` 与 spec "仅记录" 不一致 + +**信息泄漏** +19. UserListVO 设计上不暴露敏感字段,但 IT 缺 regression-locking 断言 + +## ⑪ 下一模块预览 + +**项目所有 REQ 已完成**:docs/02 § 二 共 8 个 REQ(MOD 4 + USR 4),全部 review approve 完毕。 + +USR 模块合并到 master 后,整个项目处于: +- 后端工程:149 用例全绿 +- 鉴权:仅 `POST /api/usr/auth/login` 公开,其余 `authenticated()` +- 数据库:tUser / tStaff / tPermissionCategory / tUserPermission / tModule 全部就位 + +**后续工作(不属于本期 REQ 范围,建议作为新 epic)**: +- 引入 frontend/(React + Vite) +- 引入 Redis 替换 LoginAttemptStore 内存实现 +- jacoco 覆盖率 +- application-dev.yml + dev profile +- `ModuleEntity` 重命名(Module 与 java.lang.Module 冲突) +- 4 处行内 `// REQ-XXX` 锚点补齐 + +## ⑫ MR 链接 + +- !2 — http://git.xlyprint.cn/zhuzc/test/merge_requests/2 diff --git a/docs/superpowers/module-reports/module_usr-test-gate.md b/docs/superpowers/module-reports/module_usr-test-gate.md new file mode 100644 index 0000000..fc8a7ed --- /dev/null +++ b/docs/superpowers/module-reports/module_usr-test-gate.md @@ -0,0 +1,31 @@ +## Local test gate — module_usr + +执行时间: 2026-04-30 15:05 +08:00 + +### scripts/test.sh (subagent) +- 子会话: a80ebb0dfb4c4a6f4 +- 命令: `bash scripts/test.sh` +- 退出码: 0 +- 通过: 149 / 失败: 0 +- 关键 stdout (≤30 行): + +``` +[INFO] Tests run: 149, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +[INFO] Total time: 33.643 s +[INFO] Finished at: 2026-04-30T15:05:00+08:00 +[test.sh] skip frontend unit tests (frontend/ not initialized yet) +[test.sh] 5/6 E2E +[test.sh] e2e 略 +[test.sh] 6/6 reset test db +[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts +[test.sh] GREEN +``` + +结论: green + +### 模块概要 + +- module_usr 4 个 REQ 全部 review approve +- 含 USR-004 stub 闭环(SecurityConfig 收紧 + 7 处 stub IT 重写为 20001 期望) +- backend 全量 149 用例:MOD 67 + USR-001 22 + USR-002 17 + USR-003 19 + USR-004 24 diff --git a/docs/superpowers/plans/2026-04-30-REQ-USR-001.md b/docs/superpowers/plans/2026-04-30-REQ-USR-001.md new file mode 100644 index 0000000..c1b8b12 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-REQ-USR-001.md @@ -0,0 +1,241 @@ +--- +req_id: REQ-USR-001 +date: 2026-04-30 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-001.md +--- + +# REQ-USR-001 用户新增 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 在 MOD 模块已建工程基础上扩展 USR 模块树,实现 `POST /api/usr/users`:新增 `tUser` 行 + 默认 BCrypt 密码哈希 + `tUserPermission` 多对多关联,含 `iStaffId` / `permissionCategoryIds` 存在性校验。 + +**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"。 + +**Tech Stack:** 复用(Spring Boot 3.3.5 / MyBatis-Plus / Spring Security / JJWT);本 REQ 引入 `BCryptPasswordEncoder`(spring-security-crypto 已通过 starter-security 引入,无需新依赖)。 + +--- + +## Schema 改动 + +无(`tUser` / `tStaff` / `tPermissionCategory` / `tUserPermission` 均在 V1 就位)。 + +## 文件变更清单 + +### 新增 + +- `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` +- `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` +- `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` +- `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java` +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` +- `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` +- `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` + +### 修改 + +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — 加 `/api/usr/**` permitAll + `@Bean BCryptPasswordEncoder` + +## 任务步骤 + +> 全局:每 commit `(usr): REQ-USR-001`;测试派发子会话;现有 67 用例全程绿。 + +### Task 1: SecurityConfig 扩展 + BCryptPasswordEncoder bean + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` + +**API shape:** +- 在 `authorizeHttpRequests` 的现有 `requestMatchers("/api/mod/**").permitAll()` 后追加 `requestMatchers("/api/usr/**").permitAll()` +- 类内追加 `@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }` +- stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up` + +- [ ] **Step 1: 修改 SecurityConfig** +- [ ] **Step 2: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test` + - 期望:现有 67 用例全绿(路径扩范围不收紧) +- [ ] **Step 3: Commit** + - `git commit -m "refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001"` + +### Task 2: tUser entity + UserMapper + IT + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` + +**API shape:** +- `User` PO:`@TableName("tUser")` + 17 字段 1:1 映射(参 docs/03 § tUser),全部 `@TableField` 显式列名(与 MOD `Module.java` 风格一致),`iIncrement` 用 `@TableId(IdType.AUTO)` +- `UserMapper extends BaseMapper`:暂只用 BaseMapper 的 insert/selectById(唯一冲突走 DB 索引兜底,无需自定义 exists 方法) + +- [ ] **Step 1: 写失败测试 `UserMapperIT`(2 用例)** + - `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例(含 sPasswordHash="bcrypt-stub")插入 → selectById 比较;断言 sBrandsId="XLY"、bDeleted=false 等 + - `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次抛 `DuplicateKeyException` + - 测试隔离:`@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 不会被插入;此句保留为防御) + +- [ ] **Step 2: 实现 entity + mapper** +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): tUser entity + mapper REQ-USR-001"` + +### Task 3: StaffMapper + PermissionCategoryMapper(最小存在性查询) + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` + +**API shape:** +- `StaffMapper`(**不**继承 BaseMapper,仅注解 SELECT;本 REQ 不建 Staff entity): + - `@Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") Integer findActiveStaffFlag(@Param("id") Integer id)` + - `default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; }` +- `PermissionCategoryMapper`(同上): + - `@Select("") int countActiveByIds(@Param("ids") List ids)`(mybatis 动态 SQL);空 list 调用方先短路,不调 mapper + +- [ ] **Step 1: 写失败测试** + - `StaffMapperIT#existsActiveById_handlesAliveDeletedMissing`:JdbcTemplate 直插 alive staff + deleted staff;断言三种 id(alive/deleted/不存在)的返回值 + - `PermissionCategoryMapperIT#countActiveByIds_returnsCorrectCount`:JdbcTemplate 直插 cat1(alive) + cat2(alive) + cat3(deleted);查 [cat1,cat2,cat3] → count=2;查 [cat1, 99999] → count=1;查 [99999] → count=0 + +- [ ] **Step 2: 实现 mapper** +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest='StaffMapperIT,PermissionCategoryMapperIT'` +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): staff + permission-category existence mappers REQ-USR-001"` + +### Task 4: tUserPermission entity + UserPermissionMapper + IT + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` +- 复用 `UserMapperIT`(追加用例)或独立 `UserPermissionMapperIT`,本 plan 选择追加到 UserMapperIT + +**API shape:** +- `UserPermission` PO:`@TableName("tUserPermission")` + 字段 `iIncrement(@TableId AUTO)` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iUserId` / `iCategoryId` / `sCreatedBy`(参 docs/03 § tUserPermission) +- `UserPermissionMapper extends BaseMapper`,仅用 BaseMapper.insert + +- [ ] **Step 1: 写失败测试(追加到 `UserMapperIT`)** + - `userPermissionInsert_persistsRowWithUserAndCategory` — 先插一行 user,再插一行 userPermission(iUserId=user.id, iCategoryId=10);JdbcTemplate 验 row 存在 + +- [ ] **Step 2: 实现 entity + mapper** +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): tUserPermission entity + mapper REQ-USR-001"` + +### Task 5: CreateUserDTO + UserService.create 主流程(合法 + 标准列) + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** +- `CreateUserDTO` 字段(带 `@JsonProperty` + Bean Validation): + - `@NotBlank @Size(max=50) String sUserNo` + - `@NotBlank @Size(max=50) String sUserName` + - `Integer iStaffId`(可空) + - `@NotBlank String sUserType` + - `@NotBlank String sLanguage` + - `Boolean bCanModifyDocs`(可空) + - `List permissionCategoryIds`(可空,null/空均按"无权限组"处理) +- `UserService#create(CreateUserDTO dto) : Map`(返回 `{iIncrement, sUserNo}`);本 plan 用 `Map` 而非新 VO,与 MOD 控制器响应风格保持一致 +- `UserServiceImpl` 依赖:`UserMapper` / `UserPermissionMapper` / `StaffMapper` / `PermissionCategoryMapper` / `TenantProperties` / `StubSecurityProperties` / `BCryptPasswordEncoder` +- `@Transactional(rollbackFor = Exception.class)` +- 流程主路径(仅本 task 实现合法 + 标准列): + 1. 构造 `User entity`:DTO 透传 + 标准列(sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy 同 MOD 模块策略) + `sPasswordHash = encoder.encode("666666")` + `bDeleted=false` + 2. `userMapper.insert(entity)` + 3. 若 `permissionCategoryIds` 非空:for-loop 插 `UserPermission` 行 + 4. 返回 `Map.of("iIncrement", entity.getIIncrement(), "sUserNo", entity.getSUserNo())` + +- [ ] **Step 1: 写失败测试(2 用例)** + - `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 + - `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null`;`userPermissionMapper.insert` 永不调用 + +- [ ] **Step 2: 实现 DTO + service 主流程** + - 仅覆盖本 task 两用例所需逻辑(异常分支留 Task 6) +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): user create dto + service happy path REQ-USR-001"` + +### Task 6: Service 异常分支补全 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** 不变(仅在 service 头部补 4 类校验 + 异常翻译) + +**校验顺序(service 实现):** +1. 枚举校验 `sUserType ∈ {普通用户, 超级管理员}` + `sLanguage ∈ {zh, en, zh-TW}` → 任一非法 `BizException(40001, "<字段>: 取值非法")` +2. `iStaffId != null` 且 `!staffMapper.existsActiveById(iStaffId)` → `BizException(40022, "职员不存在或已删除")` +3. `permissionCategoryIds` 非空:`int n = permissionCategoryMapper.countActiveByIds(ids); if (n != ids.size()) throw new BizException(40023, "权限分类含无效 id")` +4. `userMapper.insert(...)` 用 try/catch 捕获 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")` +5. `sCreatedBy` 优先 SecurityContextHelper.currentUserNo(),回退 stub + +- [ ] **Step 1: 在 `UserServiceImplTest` 追加 6 用例** + - `createWithInvalidUserType_throws40001` + - `createWithInvalidLanguage_throws40001` + - `createWithStaffNotFound_throws40022` — Mock `staffMapper.existsActiveById(...) → false`;`userMapper.insert` 永不调用 + - `createWithSomeInvalidPermissionIds_throws40023` — Mock `permissionCategoryMapper.countActiveByIds([1,2,3]) → 2`;`userMapper.insert` 永不调用 + - `createWithDuplicateUserNo_throws40020` — Mock `userMapper.insert` 抛 `DuplicateKeyException` + - `createUsesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注 "ALICE";ArgumentCaptor `sCreatedBy="ALICE"` + +- [ ] **Step 2: 在 ServiceImpl 补 4 类校验 + 异常翻译** +- [ ] **Step 3: 子会话验证 PASS** + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` + - 期望:2 + 6 = 8 用例全绿 +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): user create error branches REQ-USR-001"` + +### Task 7: UserController POST + IT(9 用例)+ 全量回归 + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` + +**API shape:** +- `@RestController @RequestMapping("/api/usr")` +- `@PostMapping("/users") public Result> create(@Valid @RequestBody CreateUserDTO dto) { return Result.ok(userService.create(dto)); }` + +- [ ] **Step 1: 写失败 IT(9 用例)** + - `postValidBody_with_jwt_returns200_andPersists` — 前置:JdbcTemplate 直插一行 staff + 两行 permission_category;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo == 请求值`;JdbcTemplate 验 tUser 行存在 + tUserPermission 行数 == permissionCategoryIds.size() + - `postEmptyBody_returns40001` + - `postInvalidUserType_returns40001` — `sUserType="火星"` + - `postInvalidLanguage_returns40001` — `sLanguage="ja"` + - `postDuplicateUserNo_returns40020` — 先 POST 一次成功,再 POST 同 sUserNo(不同 sUserName)→ `code=40020` + - `postStaffNotFound_returns40022` — `iStaffId=99999990` → `code=40022` + - `postPermissionCategoryNotFound_returns40023` — `permissionCategoryIds=[99999991]` → `code=40023` + - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — DB 验 sCreatedBy="STUB_ADMIN" + - `postTamperedJwt_returns20001` — Authorization "Bearer not.a.real.jwt";DB 无新增行 + - 测试隔离:`@BeforeEach @AfterEach` 清理 `tUserPermission` + `tUser`(按 sUserNo LIKE 'sp_test_%')+ 清理本 IT 创建的 tStaff / tPermissionCategory(按 sStaffNo / sCategoryCode LIKE 'sp_test_%');按外键依赖顺序删(tUserPermission → tUser → tStaff / tPermissionCategory) + +- [ ] **Step 2: 实现 controller** +- [ ] **Step 3: 子会话跑全量回归** + - 命令:`cd backend && mvn -B test` + - 期望:MOD 67 + USR-001 新增 2(UserMapperIT) + 1(UserMapperIT 追加) + 1(StaffMapperIT) + 1(PermissionCategoryMapperIT) + 8(UserServiceImplTest) + 9(UserControllerIT) = 89 用例全绿 +- [ ] **Step 4: Commit** + - `git commit -m "test(usr): user create integration coverage REQ-USR-001"` + +## 提交计划 + +| commit | 覆盖 | +|---|---| +| `refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001` | Task 1 | +| `feat(usr): tUser entity + mapper REQ-USR-001` | Task 2 | +| `feat(usr): staff + permission-category existence mappers REQ-USR-001` | Task 3 | +| `feat(usr): tUserPermission entity + mapper REQ-USR-001` | Task 4 | +| `feat(usr): user create dto + service happy path REQ-USR-001` | Task 5 | +| `feat(usr): user create error branches REQ-USR-001` | Task 6 | +| `test(usr): user create integration coverage REQ-USR-001` | Task 7 | diff --git a/docs/superpowers/plans/2026-04-30-REQ-USR-002.md b/docs/superpowers/plans/2026-04-30-REQ-USR-002.md new file mode 100644 index 0000000..c1ecf0c --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-REQ-USR-002.md @@ -0,0 +1,128 @@ +--- +req_id: REQ-USR-002 +date: 2026-04-30 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-002.md +--- + +# REQ-USR-002 用户修改 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 在 USR-001 已建工程基础上增量实现 `PUT /api/usr/users/{id}`:更新可编辑字段 + 重建权限组(删旧 + 插新);保留 `sPasswordHash` / `sCreatedBy` / 标准列。 + +**Architecture:** 复用 `UserService` / `UserServiceImpl` / `UserController` / 全部 mappers;新增 `UpdateUserDTO`、`UserService#update`、`UserPermissionMapper#deleteByUserId`。SecurityConfig 已对 `/api/usr/**` permitAll,无需改。 + +**Tech Stack:** 沿用(Spring Boot 3.3.5 / MyBatis-Plus / JJWT)。 + +--- + +## Schema 改动 + +无(仅 UPDATE / DELETE / INSERT)。 + +## 文件变更清单 + +### 新增 + +- `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java` + +### 修改 + +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` — 追加 `deleteByUserId(Integer)` +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `Integer update(Integer id, UpdateUserDTO dto)` +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update(含 5 类校验 + UPDATE + DELETE + INSERT × N) +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 PUT 端点 +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 10 单测 +- `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` — 追加 1 用例(deleteByUserId) +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 10 IT + +## 任务步骤 + +### Task 1: UserPermissionMapper#deleteByUserId + IT + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` + +**API shape:** +- `@Delete("DELETE FROM tUserPermission WHERE iUserId = #{userId}")` `int deleteByUserId(@Param("userId") Integer userId)` + +- [ ] **Step 1: 写失败测试 `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser`** + - 准备:插 user1 + user2;user1 关联 cat1+cat2,user2 关联 cat1 + - 调 `deleteByUserId(user1.id)` → 返回 2,user1 行数 = 0;user2 行数 = 1(不受影响) +- [ ] **Step 2: 实现 mapper 方法** +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserMapperIT` +- [ ] **Step 4: Commit**:`feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002` + +### Task 2: UpdateUserDTO + UserService.update 主流程(合法 + 目标存在性 + bShowPerm null→false) + +**Files:** +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserDTO.java` +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** +- `UpdateUserDTO` 字段(与 CreateUserDTO 平行,去掉 `permissionCategoryIds` 之外字段语义不变;剔除 `sPasswordHash`,**仍包含** `permissionCategoryIds` 用于重建权限组) +- `UserService#update(Integer id, UpdateUserDTO dto) : Integer` +- 实现: + 1. `selectById(id)` → null 或 bDeleted=true → 40400 + 2. 枚举校验 sUserType / sLanguage(同 create)→ 40001 + 3. iStaffId 校验(同 create)→ 40022 + 4. permissionCategoryIds 校验(仅当非空 list;null/空 list 跳过校验直接清空)→ 40023 + 5. 构造 entity 仅 set iIncrement + 6 个可编辑字段(其余 null);`bCanModifyDocs` null → false + 6. `userMapper.updateById(entity)` try/catch DuplicateKeyException → 40020 + 7. `userPermissionMapper.deleteByUserId(id)` + 8. 若 permissionCategoryIds 非空:for-loop 插 UserPermission + +- [ ] **Step 1: 追加 4 单测** + - `updateWithValidDto_invokesUpdateById_andRebuildsPermissions` + - `updateWithTargetNotFound_throws40400` + - `updateWithTargetAlreadyDeleted_throws40400` + - `updateWithBCanModifyDocsNull_setsFalseInEntity` +- [ ] **Step 2: 实现 DTO + service 主流程** +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 8+4=12) +- [ ] **Step 4: Commit**:`feat(usr): user update dto + service happy path REQ-USR-002` + +### Task 3: Service 异常分支补全(枚举 / staff / permission / 唯一冲突 / 清空权限) + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` + +**API shape:** 不变 + +- [ ] **Step 1: 追加 6 单测** + - `updateWithInvalidUserType_throws40001` + - `updateWithInvalidLanguage_throws40001` + - `updateWithStaffNotFound_throws40022` + - `updateWithSomeInvalidPermissionIds_throws40023` + - `updateWithDuplicateUserNo_throws40020` + - `updateWithEmptyPermissionIds_clearsExisting` — permissionCategoryIds=null;deleteByUserId 调一次、insert 永不调 +- [ ] **Step 2: 实现校验分支** +- [ ] **Step 3: 子会话验证 PASS**:`mvn -B test -Dtest=UserServiceImplTest`(期望 12+6=18) +- [ ] **Step 4: Commit**:`feat(usr): user update error branches REQ-USR-002` + +### Task 4: Controller PUT + IT(10 用例)+ 全量回归 + +**Files:** +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` + +**API shape:** +- `@PutMapping("/users/{id}") public Result> update(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)` +- 返回 `Result.ok(Map.of("iIncrement", userService.update(id, dto)))` + +- [ ] **Step 1: 追加 10 IT**(参 spec 验收清单) +- [ ] **Step 2: 实现 controller PUT** +- [ ] **Step 3: 子会话跑全量回归**:`mvn -B test`(期望 89 + 1+10+10=20 = 109+ 用例全绿) +- [ ] **Step 4: Commit**:`test(usr): user update integration coverage REQ-USR-002` + +## 提交计划 + +| commit | 覆盖 | +|---|---| +| `feat(usr): mapper#deleteByUserId for permission rebuild REQ-USR-002` | Task 1 | +| `feat(usr): user update dto + service happy path REQ-USR-002` | Task 2 | +| `feat(usr): user update error branches REQ-USR-002` | Task 3 | +| `test(usr): user update integration coverage REQ-USR-002` | Task 4 | diff --git a/docs/superpowers/plans/2026-04-30-REQ-USR-003.md b/docs/superpowers/plans/2026-04-30-REQ-USR-003.md new file mode 100644 index 0000000..0ce7cff --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-REQ-USR-003.md @@ -0,0 +1,148 @@ +--- +req_id: REQ-USR-003 +date: 2026-04-30 +spec_ref: docs/superpowers/specs/2026-04-30-REQ-USR-003.md +--- + +# REQ-USR-003 用户查询 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. + +**Goal:** 实现 `GET /api/usr/users` 单条件分页查询:tUser LEFT JOIN tStaff,按 field × match × value 动态过滤。 + +**Architecture:** 新增 `UserListVO` + `UserListQuery` 内部封装类(field/match/value/page)+ `UserMapper#pageWithFilter` 自定义动态 SQL + `UserService#list` + controller `@GetMapping`。 + +**Tech Stack:** 沿用;MyBatis 动态 SQL `