diff --git a/backend/checkstyle.xml b/backend/checkstyle.xml new file mode 100644 index 0000000..37af12a --- /dev/null +++ b/backend/checkstyle.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..54b73cc --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.xly + erp-backend + 1.0.0 + jar + xly-erp-backend + 小羚羊 ERP 后端服务 + + + 17 + 17 + UTF-8 + 3.5.7 + 10.10.0 + 0.12.5 + 5.8.27 + 3.3.1 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-mysql + ${flyway.version} + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + cn.hutool + hutool-core + ${hutool.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + erp-backend + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + checkstyle.xml + true + true + false + + + + + diff --git a/backend/src/main/java/com/xly/erp/ErpApplication.java b/backend/src/main/java/com/xly/erp/ErpApplication.java new file mode 100644 index 0000000..fb9ddf7 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/ErpApplication.java @@ -0,0 +1,19 @@ +package com.xly.erp; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 小羚羊 ERP 后端启动类。 + * + *

REQ-USR-001: 项目首个后端 REQ,初始化 Maven 骨架与公共基础设施。

+ */ +@SpringBootApplication +@MapperScan("com.xly.erp.**.mapper") +public class ErpApplication { + + public static void main(String[] args) { + SpringApplication.run(ErpApplication.class, args); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java b/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java new file mode 100644 index 0000000..2a0d254 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/base/BaseEntity.java @@ -0,0 +1,79 @@ +package com.xly.erp.common.base; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 标准列公共字段基类(docs/03 标准列 + docs/04 § 3.4)。 + * + *

REQ-USR-001 T3:所有业务实体复用的公共列——整数主键、业务 ID、多租户隔离列、创建时间。 + * {@code tCreateDate} 在 INSERT 时由 MybatisPlusConfig 的 MetaObjectHandler 自动填充。

+ */ +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 整数主键 ID(标准列,自增)。 */ + @TableId(value = "iIncrement", type = IdType.AUTO) + private Integer iIncrement; + + /** 业务 ID(标准列,可空)。 */ + @TableField("sId") + private String sId; + + /** 品牌 ID,多租户隔离(标准列,DB 默认 1111111111)。 */ + @TableField("sBrandsId") + private String sBrandsId; + + /** 子公司 ID,组织层级隔离(标准列,DB 默认 1111111111)。 */ + @TableField("sSubsidiaryId") + private String sSubsidiaryId; + + /** 创建时间(标准列,INSERT 自动填充)。 */ + @TableField(value = "tCreateDate", fill = FieldFill.INSERT) + private LocalDateTime tCreateDate; + + 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; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java new file mode 100644 index 0000000..f8e1058 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/MybatisPlusConfig.java @@ -0,0 +1,48 @@ +package com.xly.erp.common.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import java.time.LocalDateTime; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis-Plus 配置(docs/04 § 3.4)。 + * + *

REQ-USR-001 T3:注册创建时间自动填充处理器(INSERT 填 tCreateDate), + * 并注册分页插件供后续 REQ(如 REQ-USR-003 查询)复用。

+ */ +@Configuration +public class MybatisPlusConfig { + + /** + * 分页插件(MySQL)。 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } + + /** + * 审计字段自动填充:INSERT 时若 tCreateDate 为空则填当前时间。 + */ + @Bean + public MetaObjectHandler metaObjectHandler() { + return new MetaObjectHandler() { + @Override + public void insertFill(MetaObject metaObject) { + strictInsertFill(metaObject, "tCreateDate", LocalDateTime.class, LocalDateTime.now()); + } + + @Override + public void updateFill(MetaObject metaObject) { + // 本阶段无更新审计字段需求。 + } + }; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java new file mode 100644 index 0000000..c644da3 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.xly.erp.common.config; + +import com.xly.erp.common.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 配置(docs/04 § 1.7)。 + * + *

REQ-USR-001 T3:无状态 JWT 认证;放行 /api/usr/login、swagger、actuator/health, + * 其余需认证;注册 BCryptPasswordEncoder Bean;JwtAuthenticationFilter 挂在 + * UsernamePasswordAuthenticationFilter 之前。

+ */ +@Configuration +public class SecurityConfig { + + /** + * 放行清单(无需 token 即可访问)。 + * + *

REQ-USR-001 放行 /api/usr/login;REQ-USR-004 T5 追加 /api/usr/companies(登录页版本下拉)。 + * 抽为常量供单测断言与过滤链共用,避免清单漂移。

+ */ + public static final String[] PERMIT_ALL_PATHS = { + "/api/usr/login", + "/api/usr/companies", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health", + }; + + /** + * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) + throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(eh -> eh + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .authorizeHttpRequests(auth -> auth + .requestMatchers(PERMIT_ALL_PATHS) + .permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java new file mode 100644 index 0000000..4059c4a --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java @@ -0,0 +1,29 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ResultCode; + +/** + * 业务异常(docs/04 § 1.5 SSoT)。 + * + *

REQ-USR-001 T2。业务错误统一抛本异常,由 {@link GlobalExceptionHandler} 捕获转 Result。

+ */ +public class BusinessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final ResultCode resultCode; + + public BusinessException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } + + public ResultCode getResultCode() { + return resultCode; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..327a87c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,60 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.Result; +import com.xly.erp.common.response.ResultCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理(docs/04 § 1.5 SSoT)。 + * + *

REQ-USR-001 T2:把 BusinessException / 参数校验失败 / 唯一键冲突 / 未捕获异常 + * 统一转为 {@link Result},失败响应不抛栈到前端。

+ */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 业务异常 → 对应错误码。 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException ex) { + return Result.fail(ex.getResultCode(), ex.getMessage()); + } + + /** + * Bean Validation(@Valid)失败 → 40001,message 取首个字段错误提示。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result handleValidationException(MethodArgumentNotValidException ex) { + FieldError fieldError = ex.getBindingResult().getFieldError(); + String message = fieldError != null + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : ResultCode.PARAM_INVALID.getMessage(); + return Result.fail(ResultCode.PARAM_INVALID, message); + } + + /** + * 唯一键冲突(并发兜底)→ 40901 用户名已存在。 + */ + @ExceptionHandler(DuplicateKeyException.class) + public Result handleDuplicateKeyException(DuplicateKeyException ex) { + return Result.fail(ResultCode.USERNAME_EXISTS, ResultCode.USERNAME_EXISTS.getMessage()); + } + + /** + * 兜底:未捕获异常记录 ERROR 日志(含栈),对外只返回通用错误码,不泄露内部细节。 + */ + @ExceptionHandler(Exception.class) + public Result handleException(Exception ex) { + LOG.error("系统异常", ex); + return Result.fail(ResultCode.SYSTEM_ERROR, ResultCode.SYSTEM_ERROR.getMessage()); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/PageResult.java b/backend/src/main/java/com/xly/erp/common/response/PageResult.java new file mode 100644 index 0000000..ca77a39 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/PageResult.java @@ -0,0 +1,76 @@ +package com.xly.erp.common.response; + +import java.io.Serializable; +import java.util.List; + +/** + * 通用分页响应体(docs/04 § 1.4 / § 3.2 SSoT)。 + * + *

REQ-USR-003 T1【本 REQ 新增公共契约】。分页接口统一返回 {@code Result>}, + * 含当前页数据 {@code records}、真实总记录数 {@code total}、当前页号 {@code pageNum} + * (数据越界钳制后的实际页号)、每页条数 {@code pageSize}。后续分页 REQ 复用。

+ * + * @param 当前页元素类型 + */ +public class PageResult implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 当前页数据。 */ + private List records; + /** 真实总记录数。 */ + private long total; + /** 当前页号(数据越界钳制后的实际页号)。 */ + private long pageNum; + /** 每页条数。 */ + private long pageSize; + + public PageResult() { + } + + public PageResult(List records, long total, long pageNum, long pageSize) { + this.records = records; + this.total = total; + this.pageNum = pageNum; + this.pageSize = pageSize; + } + + /** + * 静态工厂:从分页要素装配。 + */ + public static PageResult of(List records, long total, long pageNum, long pageSize) { + return new PageResult<>(records, total, pageNum, pageSize); + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } + + public long getPageNum() { + return pageNum; + } + + public void setPageNum(long pageNum) { + this.pageNum = pageNum; + } + + public long getPageSize() { + return pageSize; + } + + public void setPageSize(long pageSize) { + this.pageSize = pageSize; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/Result.java b/backend/src/main/java/com/xly/erp/common/response/Result.java new file mode 100644 index 0000000..37930c4 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/Result.java @@ -0,0 +1,73 @@ +package com.xly.erp.common.response; + +import java.io.Serializable; + +/** + * 统一响应体(docs/04 § 1.4 SSoT)。 + * + *

REQ-USR-001 T2。所有接口返回 {@code Result}:{@code code=0} 成功,非 0 为错误码。

+ * + * @param 业务数据类型 + */ +public class Result implements Serializable { + + private static final long serialVersionUID = 1L; + + private int code; + private String message; + private T data; + + public Result() { + } + + public Result(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + /** + * 成功且携带数据。 + */ + public static Result success(T data) { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } + + /** + * 成功且不携带数据。 + */ + public static Result success() { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + /** + * 失败,携带错误码与可读提示。 + */ + public static Result fail(ResultCode code, String message) { + return new Result<>(code.getCode(), message, null); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/ResultCode.java b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java new file mode 100644 index 0000000..b1bb4f9 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java @@ -0,0 +1,47 @@ +package com.xly.erp.common.response; + +/** + * 统一错误码枚举(docs/05 / spec § 6 SSoT)。 + * + *

REQ-USR-001 T2:一次性建好本枚举,含本 REQ 使用的 0/40001/40301/40901, + * 并预留后续 REQ 复用的 40101/40302/40401/42201,避免后续重复修改公共文件。

+ */ +public enum ResultCode { + + /** 成功。 */ + SUCCESS(0, "success"), + /** 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 */ + PARAM_INVALID(40001, "参数校验失败"), + /** 认证失败(用户名或密码错误;预留 REQ-USR-004)。 */ + UNAUTHORIZED(40101, "认证失败"), + /** 无权限(非管理员调用)。 */ + FORBIDDEN(40301, "无权限"), + /** 账号已禁用(预留 REQ-USR-004)。 */ + ACCOUNT_DISABLED(40302, "账号已禁用"), + /** 资源不存在(预留 REQ-USR-002)。 */ + NOT_FOUND(40401, "资源不存在"), + /** 用户名已存在(sUserName 全局唯一冲突)。 */ + USERNAME_EXISTS(40901, "用户名已存在"), + /** 分页参数非法(预留 REQ-USR-003)。 */ + PAGE_PARAM_INVALID(42201, "分页参数非法"), + /** 登录过于频繁(连续失败超阈值,账号临时锁定;REQ-USR-004)。 */ + LOGIN_RATE_LIMITED(42901, "请求过于频繁,请稍后重试"), + /** 系统内部错误(兜底)。 */ + SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"); + + private final int code; + private final String message; + + ResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e985496 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.xly.erp.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * JWT 认证过滤器。 + * + *

REQ-USR-001 T3:解析 {@code Authorization: Bearer },校验通过后把 + * sUserName 作为 principal、{@code TYPE_} 作为 authority 放入 SecurityContext。

+ */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String HEADER = "Authorization"; + private static final String PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + + public JwtAuthenticationFilter(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader(HEADER); + if (header != null && header.startsWith(PREFIX)) { + String token = header.substring(PREFIX.length()); + if (jwtUtil.validateToken(token)) { + String userName = jwtUtil.getUserName(token); + String userType = jwtUtil.getUserType(token); + List authorities = + List.of(new SimpleGrantedAuthority(SecurityUtil.TYPE_PREFIX + userType)); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userName, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + filterChain.doFilter(request, response); + } +} 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 new file mode 100644 index 0000000..b411fe6 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java @@ -0,0 +1,78 @@ +package com.xly.erp.common.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * JWT 工具(docs/04 § 1.7)。 + * + *

REQ-USR-001 T3:签发 / 解析含 sUserName + sUserType claim 的无状态 token; + * 密钥取自 config-vars.yaml secrets.jwt_secret(经 application.yml 注入),禁止硬编码。

+ */ +@Component +public class JwtUtil { + + /** claim:用户名。 */ + public static final String CLAIM_USER_NAME = "sUserName"; + /** claim:用户类型。 */ + public static final String CLAIM_USER_TYPE = "sUserType"; + + private final SecretKey secretKey; + private final long expireMillis; + + public JwtUtil(@Value("${jwt.secret}") String secret, + @Value("${jwt.expire-millis}") long expireMillis) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expireMillis = expireMillis; + } + + /** + * 签发 token,主体为用户名,并附带用户类型 claim。 + */ + public String generateToken(String userName, String userType) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expireMillis); + return Jwts.builder() + .subject(userName) + .claim(CLAIM_USER_NAME, userName) + .claim(CLAIM_USER_TYPE, userType) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + /** + * 校验 token 签名与有效期;非法 / 过期返回 false。 + */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (RuntimeException ex) { + return false; + } + } + + public String getUserName(String token) { + return parseClaims(token).get(CLAIM_USER_NAME, String.class); + } + + public String getUserType(String token) { + return parseClaims(token).get(CLAIM_USER_TYPE, String.class); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java b/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java new file mode 100644 index 0000000..3d62d75 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/SecurityUtil.java @@ -0,0 +1,48 @@ +package com.xly.erp.common.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * 安全上下文工具:取当前登录用户名 / 用户类型。 + * + *

REQ-USR-001 T3。用户名为 {@link Authentication#getName()}(principal), + * 用户类型存放在 authorities 中(以 {@code TYPE_} 前缀标识)。

+ */ +public final class SecurityUtil { + + /** 用户类型 authority 前缀。 */ + public static final String TYPE_PREFIX = "TYPE_"; + + private SecurityUtil() { + } + + /** + * 当前登录用户名(JWT 主体 sUserName);未认证返回 null。 + */ + public static String currentUserName() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + return auth.getName(); + } + + /** + * 当前登录用户类型(sUserType,用于管理员判定);取不到返回 null。 + */ + public static String currentUserType() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + for (GrantedAuthority authority : auth.getAuthorities()) { + String role = authority.getAuthority(); + if (role != null && role.startsWith(TYPE_PREFIX)) { + return role.substring(TYPE_PREFIX.length()); + } + } + return null; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java new file mode 100644 index 0000000..44259fb --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java @@ -0,0 +1,47 @@ +package com.xly.erp.modules.usr.controller; + +import com.xly.erp.common.response.Result; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.service.UsrAuthService; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +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; + +/** + * 认证 Controller(docs/05 REQ-USR-004 / spec § 2)。REQ-USR-004 T5。 + * + *

仅做参数校验 + 委派 Service,不写业务逻辑、不直接调 Mapper、无管理员前置 + * (登录 / 公司列表为放行端点)。

+ */ +@RestController +@RequestMapping("/api/usr") +public class UsrAuthController { + + private final UsrAuthService usrAuthService; + + public UsrAuthController(UsrAuthService usrAuthService) { + this.usrAuthService = usrAuthService; + } + + /** + * 登录认证(放行,无需 token):@Valid 校验入参后委派 Service。 + */ + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginDTO dto) { + return Result.success(usrAuthService.login(dto)); + } + + /** + * 公司下拉列表(放行,无参):供登录页「版本」下拉。 + */ + @GetMapping("/companies") + public Result> listCompanies() { + return Result.success(usrAuthService.listCompanies()); + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java new file mode 100644 index 0000000..775e7c6 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrUserController.java @@ -0,0 +1,74 @@ +package com.xly.erp.modules.usr.controller; + +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.common.response.Result; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.SecurityUtil; +import com.xly.erp.modules.usr.dto.CreateUserDTO; +import com.xly.erp.modules.usr.dto.UpdateUserDTO; +import com.xly.erp.modules.usr.dto.UserQueryDTO; +import com.xly.erp.modules.usr.service.UsrUserService; +import com.xly.erp.modules.usr.vo.UserVO; +import jakarta.validation.Valid; +import java.util.Map; +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.RestController; + +/** + * 用户管理 Controller(docs/04 § 1.2 / docs/05 REQ-USR-001)。 + * + *

仅做参数校验 + 管理员权限前置 + 委派 Service,不写业务逻辑、不直接调 Mapper。

+ */ +@RestController +@RequestMapping("/api/usr") +public class UsrUserController { + + /** 管理员判定口径(spec § 8 D2)。 */ + private static final String ADMIN_USER_TYPE = "超级管理员"; + + private final UsrUserService usrUserService; + + public UsrUserController(UsrUserService usrUserService) { + this.usrUserService = usrUserService; + } + + /** + * 新增用户:仅超级管理员可调用(spec § 3.9,先于业务校验)。 + */ + @PostMapping("/users") + public Result> createUser(@Valid @RequestBody CreateUserDTO dto) { + if (!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())) { + throw new BusinessException(ResultCode.FORBIDDEN); + } + Integer newId = usrUserService.createUser(dto); + return Result.success(Map.of("id", newId)); + } + + /** + * 修改用户:仅超级管理员可调用(spec § 3.9,先于业务校验)。REQ-USR-002。 + */ + @PutMapping("/users/{id}") + public Result> updateUser(@PathVariable Integer id, + @Valid @RequestBody UpdateUserDTO dto) { + if (!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())) { + throw new BusinessException(ResultCode.FORBIDDEN); + } + Integer updatedId = usrUserService.updateUser(id, dto); + return Result.success(Map.of("id", updatedId)); + } + + /** + * 查询用户(REQ-USR-003):任意已认证用户可调用(无管理员前置,spec § 8 D5)。 + * query 参数自动绑定到 {@link UserQueryDTO}(非 @RequestBody);仅 @Valid + 委派 Service。 + */ + @GetMapping("/users") + public Result> queryUsers(@Valid UserQueryDTO dto) { + return Result.success(usrUserService.queryUsers(dto)); + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java new file mode 100644 index 0000000..c5c3c39 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/CreateUserDTO.java @@ -0,0 +1,126 @@ +package com.xly.erp.modules.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 新增用户入参(docs/05 契约 / spec § 2.1)。 + * + *

REQ-USR-001 T4。Bean Validation 注解校验格式 / 必填 / 枚举; + * 关联 id 存在性、枚举兜底后越界判定在 Service 处理。

+ * + *

字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserName} 会被 + * Jackson 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对带前缀字段显式 + * {@link JsonProperty} 锁定 JSON 键名,确保反序列化绑定正确。

+ */ +public class CreateUserDTO { + + /** 用户名:必填,3-20 位字母 / 数字 / 下划线,全局唯一。 */ + @JsonProperty("sUserName") + @NotBlank(message = "用户名不能为空") + @Pattern(regexp = "^[A-Za-z0-9_]{3,20}$", message = "用户名须为 3-20 位字母、数字或下划线") + private String sUserName; + + /** 用户号:可选。 */ + @JsonProperty("sUserNo") + @Size(max = 50, message = "用户号长度不能超过 50") + private String sUserNo; + + /** 关联职员 ID:可选,存在性在 Service 校验。 */ + @JsonProperty("iEmployeeId") + private Integer iEmployeeId; + + /** 用户类型:可选,为空时 Service 兜底「普通用户」;非空须为合法枚举。 */ + @JsonProperty("sUserType") + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") + private String sUserType; + + /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ + @JsonProperty("sLanguage") + @NotBlank(message = "语言不能为空") + @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") + private String sLanguage; + + /** 单据修改权限:可选,0 / 1,为空时 Service 兜底 0。 */ + @JsonProperty("iCanModifyBill") + @Min(value = 0, message = "单据修改权限取值非法") + @Max(value = 1, message = "单据修改权限取值非法") + private Integer iCanModifyBill; + + /** 权限组 ID 列表:可选,元素存在性在 Service 校验。 */ + @JsonProperty("permissionIds") + private List permissionIds; + + /** 初始密码:可选,为空时 Service 兜底 666666。 */ + @JsonProperty("initialPassword") + private String initialPassword; + + public String getSUserName() { + return sUserName; + } + + public void setSUserName(String sUserName) { + this.sUserName = sUserName; + } + + public String getSUserNo() { + return sUserNo; + } + + public void setSUserNo(String sUserNo) { + this.sUserNo = sUserNo; + } + + public Integer getIEmployeeId() { + return iEmployeeId; + } + + public void setIEmployeeId(Integer iEmployeeId) { + this.iEmployeeId = iEmployeeId; + } + + 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 Integer getICanModifyBill() { + return iCanModifyBill; + } + + public void setICanModifyBill(Integer iCanModifyBill) { + this.iCanModifyBill = iCanModifyBill; + } + + public List getPermissionIds() { + return permissionIds; + } + + public void setPermissionIds(List permissionIds) { + this.permissionIds = permissionIds; + } + + public String getInitialPassword() { + return initialPassword; + } + + public void setInitialPassword(String initialPassword) { + this.initialPassword = initialPassword; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/LoginDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/LoginDTO.java new file mode 100644 index 0000000..8384d0b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/LoginDTO.java @@ -0,0 +1,58 @@ +package com.xly.erp.modules.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 登录入参(docs/05 契约 / spec § 2.1)。REQ-USR-004 T2。 + * + *

三字段均必填,校验失败由全局处理器统一转 40001。{@code sUserName} 匈牙利前缀字段的 + * getter({@code getSUserName})会被 Jackson 推断为属性名 {@code SUserName},与契约 JSON + * 键不符,故加 {@link JsonProperty} 锁定键名(与 CreateUserDTO/UserVO 同做法); + * {@code password}/{@code companyId} 为常规小驼峰,无需注解。

+ * + *

{@code password} 为登录明文,仅供服务端 BCrypt 比对,绝不落日志 / 回显。

+ */ +public class LoginDTO { + + /** 登录用户名:必填,最长 50。 */ + @JsonProperty("sUserName") + @NotBlank(message = "用户名不能为空") + @Size(max = 50, message = "用户名长度不能超过 50") + private String sUserName; + + /** 登录密码明文:必填,最长 100;服务端 BCrypt 比对,绝不落日志。 */ + @NotBlank(message = "密码不能为空") + @Size(max = 100, message = "密码长度不能超过 100") + private String password; + + /** 版本下拉选中的 usr_company.iIncrement:必填,仅存在性校验,不参与认证绑定。 */ + @NotNull(message = "版本不能为空") + private Integer companyId; + + 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 Integer getCompanyId() { + return companyId; + } + + public void setCompanyId(Integer companyId) { + this.companyId = companyId; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/UpdateUserDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/UpdateUserDTO.java new file mode 100644 index 0000000..6132167 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/UpdateUserDTO.java @@ -0,0 +1,120 @@ +package com.xly.erp.modules.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 修改用户入参(docs/05 契约 / spec § 2.1)。REQ-USR-002 T1。 + * + *

用于 {@code PUT /api/usr/users/{id}}:管理员修改已有用户基本信息。 + * 部分更新语义(spec § 8 D3)——可选字段({@code sUserNo} / {@code iEmployeeId} / + * {@code iCanModifyBill} / {@code iIsVoid})传 {@code null} = 本次不改该列(保持原值), + * 非 null 即覆盖;{@code permissionIds} 非 null 全量覆盖授权、{@code []} 清空、{@code null} 不改。

+ * + *

本 DTO 不含 {@code sUserName} / {@code sPassword} / 审计字段({@code tCreateDate} / + * {@code sCreator})/ 租户字段,这些字段为只读、本接口不接收(spec § 2.1 / § 3)。

+ * + *

字段为匈牙利前缀命名(与列名一致),其 getter 形如 {@code getSUserType} 会被 + * Jackson 推断为属性名 {@code SUserType},与契约 JSON 键不符;故对带前缀字段显式 + * {@link JsonProperty} 锁定 JSON 键名(与 {@code CreateUserDTO} 同样做法)。

+ */ +public class UpdateUserDTO { + + /** 用户号:可选;非 null 覆盖,null 不改。 */ + @JsonProperty("sUserNo") + @Size(max = 50, message = "用户号长度不能超过 50") + private String sUserNo; + + /** 关联职员 ID:可选,存在性在 Service 校验;非 null 覆盖,null 不改。 */ + @JsonProperty("iEmployeeId") + private Integer iEmployeeId; + + /** 用户类型:必填,取值 ∈ {普通用户, 超级管理员}。 */ + @JsonProperty("sUserType") + @NotBlank(message = "用户类型不能为空") + @Pattern(regexp = "^(普通用户|超级管理员)$", message = "用户类型取值非法") + private String sUserType; + + /** 界面语言:必填,取值 ∈ {中文, 英文, 繁体}。 */ + @JsonProperty("sLanguage") + @NotBlank(message = "语言不能为空") + @Pattern(regexp = "^(中文|英文|繁体)$", message = "语言取值非法") + private String sLanguage; + + /** 单据修改权限:可选,0 / 1;null 不改。 */ + @JsonProperty("iCanModifyBill") + @Min(value = 0, message = "单据修改权限取值非法") + @Max(value = 1, message = "单据修改权限取值非法") + private Integer iCanModifyBill; + + /** 作废 / 禁用标志:可选,0 正常 / 1 禁用;null 不改。 */ + @JsonProperty("iIsVoid") + @Min(value = 0, message = "作废标志取值非法") + @Max(value = 1, message = "作废标志取值非法") + private Integer iIsVoid; + + /** 权限组 ID 列表:可选;非 null 全量覆盖授权,[] 清空,null 不改。元素存在性在 Service 校验。 */ + @JsonProperty("permissionIds") + private List permissionIds; + + public String getSUserNo() { + return sUserNo; + } + + public void setSUserNo(String sUserNo) { + this.sUserNo = sUserNo; + } + + public Integer getIEmployeeId() { + return iEmployeeId; + } + + public void setIEmployeeId(Integer iEmployeeId) { + this.iEmployeeId = iEmployeeId; + } + + 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 Integer getICanModifyBill() { + return iCanModifyBill; + } + + public void setICanModifyBill(Integer iCanModifyBill) { + this.iCanModifyBill = iCanModifyBill; + } + + public Integer getIIsVoid() { + return iIsVoid; + } + + public void setIIsVoid(Integer iIsVoid) { + this.iIsVoid = iIsVoid; + } + + public List getPermissionIds() { + return permissionIds; + } + + public void setPermissionIds(List permissionIds) { + this.permissionIds = permissionIds; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java new file mode 100644 index 0000000..8ab735c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java @@ -0,0 +1,156 @@ +package com.xly.erp.modules.usr.dto; + +import java.time.LocalDateTime; + +/** + * 查询条件「已解析」内部载体(Service → Mapper)。REQ-USR-003 T3 / T4。 + * + *

非对外契约:由 Service 把 {@link UserQueryDTO} 的中文 {@code queryField}/{@code matchType}/ + * 原始 {@code queryValue} 解析归一为可直接拼 XML {@code } 分支的结构,避免在 XML 内做中文 + * 枚举判断与类型解析。{@code column} 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。

+ * + *

分支种类 {@link Kind}:{@code NONE} 无过滤(全量分页);{@code TEXT} 文本 / 枚举 + * ({@code LIKE}/{@code NOT LIKE}/{@code =});{@code BOOL} 布尔({@code =}/{@code <>}); + * {@code DATE} 日期区间({@code >=}/{@code <},可取反)。

+ */ +public class UserQueryCondition { + + /** 过滤分支种类。 */ + public enum Kind { + /** 无过滤。 */ + NONE, + /** 文本 / 枚举模糊或精确。 */ + TEXT, + /** 布尔等于 / 不等于。 */ + BOOL, + /** 日期区间命中 / 取反。 */ + DATE + } + + /** 目标列(白名单 token,如 {@code u.sUserName} / {@code e.sDepartment})。 */ + private String column; + /** 分支种类。 */ + private Kind kind = Kind.NONE; + + /** TEXT:匹配方式(包含 / 不包含 / 等于)。 */ + private String matchType; + /** TEXT:已转义文本值(含 ESCAPE 转义后的 % _ \)。 */ + private String textValue; + + /** BOOL:归一化布尔值(0 / 1)。 */ + private Integer boolValue; + /** BOOL:true 表示不等于({@code <>}),false 表示等于({@code =})。 */ + private boolean boolNegated; + + /** DATE:当日区间起(含)。 */ + private LocalDateTime dateStart; + /** DATE:当日区间止(不含)。 */ + private LocalDateTime dateEnd; + /** DATE:true 表示「不包含」(区间取反)。 */ + private boolean dateNegated; + + public UserQueryCondition() { + } + + /** 无过滤条件(全量分页)。 */ + public static UserQueryCondition none() { + return new UserQueryCondition(); + } + + /** 文本 / 枚举条件:{@code matchType} ∈ {包含,不包含,等于},{@code value} 须由 Service 预先转义。 */ + public static UserQueryCondition text(String column, String matchType, String value) { + UserQueryCondition c = new UserQueryCondition(); + c.column = column; + c.kind = Kind.TEXT; + c.matchType = matchType; + c.textValue = value; + return c; + } + + /** 布尔条件:{@code negated=true} 表示「不包含」({@code <>})。 */ + public static UserQueryCondition bool(String column, int value, boolean negated) { + UserQueryCondition c = new UserQueryCondition(); + c.column = column; + c.kind = Kind.BOOL; + c.boolValue = value; + c.boolNegated = negated; + return c; + } + + /** 日期区间条件:{@code [start, end)};{@code negated=true} 表示「不包含」(区间取反)。 */ + public static UserQueryCondition date(String column, LocalDateTime start, LocalDateTime end, + boolean negated) { + UserQueryCondition c = new UserQueryCondition(); + c.column = column; + c.kind = Kind.DATE; + c.dateStart = start; + c.dateEnd = end; + c.dateNegated = negated; + return c; + } + + // ---- XML 用便捷判定(getter 形式,OGNL 直接读取)---- + + public boolean isText() { + return kind == Kind.TEXT; + } + + public boolean isBool() { + return kind == Kind.BOOL; + } + + public boolean isDate() { + return kind == Kind.DATE; + } + + /** 文本「等于」。 */ + public boolean isTextEquals() { + return kind == Kind.TEXT && "等于".equals(matchType); + } + + /** 文本「不包含」。 */ + public boolean isTextNotContains() { + return kind == Kind.TEXT && "不包含".equals(matchType); + } + + /** 文本「包含」(默认)。 */ + public boolean isTextContains() { + return kind == Kind.TEXT && !"等于".equals(matchType) && !"不包含".equals(matchType); + } + + public String getColumn() { + return column; + } + + public Kind getKind() { + return kind; + } + + public String getMatchType() { + return matchType; + } + + public String getTextValue() { + return textValue; + } + + public Integer getBoolValue() { + return boolValue; + } + + public boolean isBoolNegated() { + return boolNegated; + } + + public LocalDateTime getDateStart() { + return dateStart; + } + + public LocalDateTime getDateEnd() { + return dateEnd; + } + + public boolean isDateNegated() { + return dateNegated; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryDTO.java b/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryDTO.java new file mode 100644 index 0000000..66f7057 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryDTO.java @@ -0,0 +1,77 @@ +package com.xly.erp.modules.usr.dto; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 查询用户入参(docs/05 契约 / spec § 2.1)。REQ-USR-003 T2。 + * + *

query 参数绑定(非 JSON body):{@code queryField}/{@code matchType}/{@code queryValue}/ + * {@code pageNum}/{@code pageSize} 均为小驼峰,query 参数名直接同名绑定,全部可选。

+ * + *

校验口径:{@code queryField}/{@code matchType} 走 {@link Pattern}(null 跳过,默认值由 + * Service 兜底),越界 → 40001;{@code queryValue} {@link Size}(max=100),超长 → 40001。 + * {@code pageNum}/{@code pageSize} **不**加 @Min/@Max(避免 @Valid 失败被全局处理器统一转 + * 40001,与 spec 要求的 42201 冲突,spec § 8 D8)——范围在 Service 入口显式判定抛 42201。

+ */ +public class UserQueryDTO { + + /** 查询字段;null 时 Service 兜底「用户名」。越界 → 40001。 */ + @Pattern(regexp = "^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$", + message = "查询字段取值非法") + private String queryField; + + /** 匹配方式;null 时 Service 兜底「包含」。越界 → 40001。 */ + @Pattern(regexp = "^(包含|不包含|等于)$", message = "匹配方式取值非法") + private String matchType; + + /** 查询值;null / trim 后空 = 不施加条件。 */ + @Size(max = 100, message = "查询值长度不能超过 100") + private String queryValue; + + /** 页码,从 1 起;范围在 Service 入口判定(42201)。 */ + private Integer pageNum; + + /** 每页条数,1..100;范围在 Service 入口判定(42201)。 */ + private Integer pageSize; + + public String getQueryField() { + return queryField; + } + + public void setQueryField(String queryField) { + this.queryField = queryField; + } + + public String getMatchType() { + return matchType; + } + + public void setMatchType(String matchType) { + this.matchType = matchType; + } + + public String getQueryValue() { + return queryValue; + } + + public void setQueryValue(String queryValue) { + this.queryValue = queryValue; + } + + public Integer getPageNum() { + return pageNum; + } + + public void setPageNum(Integer pageNum) { + this.pageNum = pageNum; + } + + public Integer getPageSize() { + return pageSize; + } + + public void setPageSize(Integer pageSize) { + this.pageSize = pageSize; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrCompany.java b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrCompany.java new file mode 100644 index 0000000..d5041aa --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrCompany.java @@ -0,0 +1,42 @@ +package com.xly.erp.modules.usr.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.xly.erp.common.base.BaseEntity; + +/** + * 公司实体,映射 {@code usr_company}(docs/03 SSoT)。REQ-USR-004 T3。 + * + *

继承 {@link BaseEntity} 复用标准列({@code iIncrement}/{@code sId}/租户列/{@code tCreateDate}); + * 业务列 {@code sCompanyName}(公司名称,登录版本下拉显示来源)、{@code sVersion}(版本/账套标识,可空)。 + * 仅供登录流程只读(companyId 存在性校验 + 公司下拉列表)。

+ */ +@TableName("usr_company") +public class UsrCompany extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 公司名称(登录页版本下拉的显示来源)。 */ + @TableField("sCompanyName") + private String sCompanyName; + + /** 版本 / 账套标识(可空)。 */ + @TableField("sVersion") + private String sVersion; + + public String getSCompanyName() { + return sCompanyName; + } + + public void setSCompanyName(String sCompanyName) { + this.sCompanyName = sCompanyName; + } + + public String getSVersion() { + return sVersion; + } + + public void setSVersion(String sVersion) { + this.sVersion = sVersion; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrEmployee.java b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrEmployee.java new file mode 100644 index 0000000..5c95dca --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrEmployee.java @@ -0,0 +1,52 @@ +package com.xly.erp.modules.usr.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.xly.erp.common.base.BaseEntity; + +/** + * 职员实体,映射 {@code usr_employee}(docs/03 SSoT)。 + * + *

REQ-USR-001 T4。本 REQ 仅用于校验 iEmployeeId 存在性(读)。

+ */ +@TableName("usr_employee") +public class UsrEmployee extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 职员 / 员工姓名。 */ + @TableField("sEmployeeName") + private String sEmployeeName; + + /** 员工编号。 */ + @TableField("sEmployeeNo") + private String sEmployeeNo; + + /** 所属部门。 */ + @TableField("sDepartment") + private String sDepartment; + + public String getSEmployeeName() { + return sEmployeeName; + } + + public void setSEmployeeName(String sEmployeeName) { + this.sEmployeeName = sEmployeeName; + } + + public String getSEmployeeNo() { + return sEmployeeNo; + } + + public void setSEmployeeNo(String sEmployeeNo) { + this.sEmployeeNo = sEmployeeNo; + } + + public String getSDepartment() { + return sDepartment; + } + + public void setSDepartment(String sDepartment) { + this.sDepartment = sDepartment; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrPermission.java b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrPermission.java new file mode 100644 index 0000000..2ed31db --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrPermission.java @@ -0,0 +1,52 @@ +package com.xly.erp.modules.usr.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.xly.erp.common.base.BaseEntity; + +/** + * 权限实体,映射 {@code usr_permission}(docs/03 SSoT)。 + * + *

REQ-USR-001 T4。本 REQ 仅用于校验 permissionIds 元素存在性(读)。

+ */ +@TableName("usr_permission") +public class UsrPermission extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 权限名称。 */ + @TableField("sPermissionName") + private String sPermissionName; + + /** 权限编码(系统内唯一)。 */ + @TableField("sPermissionCode") + private String sPermissionCode; + + /** 权限分类。 */ + @TableField("sPermissionCategory") + private String sPermissionCategory; + + public String getSPermissionName() { + return sPermissionName; + } + + public void setSPermissionName(String sPermissionName) { + this.sPermissionName = sPermissionName; + } + + public String getSPermissionCode() { + return sPermissionCode; + } + + public void setSPermissionCode(String sPermissionCode) { + this.sPermissionCode = sPermissionCode; + } + + public String getSPermissionCategory() { + return sPermissionCategory; + } + + public void setSPermissionCategory(String sPermissionCategory) { + this.sPermissionCategory = sPermissionCategory; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUser.java b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUser.java new file mode 100644 index 0000000..b833c67 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUser.java @@ -0,0 +1,140 @@ +package com.xly.erp.modules.usr.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.xly.erp.common.base.BaseEntity; +import java.time.LocalDateTime; + +/** + * 用户实体,映射 {@code usr_user}(docs/03 SSoT)。 + * + *

REQ-USR-001 T4。继承 {@link BaseEntity} 复用标准列;密码列 {@code sPassword} + * 标注 {@link JsonIgnore} 防止序列化输出。

+ */ +@TableName("usr_user") +public class UsrUser extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 用户名,登录账号,全局唯一。 */ + @TableField("sUserName") + private String sUserName; + + /** 用户号。 */ + @TableField("sUserNo") + private String sUserNo; + + /** 登录密码,BCrypt 哈希存储;禁止序列化输出。 */ + @JsonIgnore + @TableField("sPassword") + private String sPassword; + + /** 关联职员 ID(可选)。 */ + @TableField("iEmployeeId") + private Integer iEmployeeId; + + /** 用户类型:普通用户 / 超级管理员。 */ + @TableField("sUserType") + private String sUserType; + + /** 界面语言:中文 / 英文 / 繁体。 */ + @TableField("sLanguage") + private String sLanguage; + + /** 单据修改权限:0 否 / 1 是。 */ + @TableField("iCanModifyBill") + private Integer iCanModifyBill; + + /** 作废 / 禁用标志:0 正常 / 1 已作废。 */ + @TableField("iIsVoid") + private Integer iIsVoid; + + /** 最后登录时间。 */ + @TableField("tLastLoginDate") + private LocalDateTime tLastLoginDate; + + /** 制单人(创建该用户的操作员用户名)。 */ + @TableField("sCreator") + private String sCreator; + + public String getSUserName() { + return sUserName; + } + + public void setSUserName(String sUserName) { + this.sUserName = sUserName; + } + + public String getSUserNo() { + return sUserNo; + } + + public void setSUserNo(String sUserNo) { + this.sUserNo = sUserNo; + } + + public String getSPassword() { + return sPassword; + } + + public void setSPassword(String sPassword) { + this.sPassword = sPassword; + } + + public Integer getIEmployeeId() { + return iEmployeeId; + } + + public void setIEmployeeId(Integer iEmployeeId) { + this.iEmployeeId = iEmployeeId; + } + + 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 Integer getICanModifyBill() { + return iCanModifyBill; + } + + public void setICanModifyBill(Integer iCanModifyBill) { + this.iCanModifyBill = iCanModifyBill; + } + + public Integer getIIsVoid() { + return iIsVoid; + } + + public void setIIsVoid(Integer iIsVoid) { + this.iIsVoid = iIsVoid; + } + + public LocalDateTime getTLastLoginDate() { + return tLastLoginDate; + } + + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { + this.tLastLoginDate = tLastLoginDate; + } + + public String getSCreator() { + return sCreator; + } + + public void setSCreator(String sCreator) { + this.sCreator = sCreator; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUserPermission.java b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUserPermission.java new file mode 100644 index 0000000..efa2810 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/entity/UsrUserPermission.java @@ -0,0 +1,49 @@ +package com.xly.erp.modules.usr.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.xly.erp.common.base.BaseEntity; + +/** + * 用户权限关联实体,映射 {@code usr_user_permission}(docs/03 SSoT)。 + * + *

REQ-USR-001 T4。用户 ↔ 权限多对多授权;唯一索引 {@code uk_usr_user_permission} + * on (iUserId, iPermissionId)。

+ */ +@TableName("usr_user_permission") +public class UsrUserPermission extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 用户 ID,外键 -> usr_user.iIncrement。 */ + @TableField("iUserId") + private Integer iUserId; + + /** 权限 ID,外键 -> usr_permission.iIncrement。 */ + @TableField("iPermissionId") + private Integer iPermissionId; + + public UsrUserPermission() { + } + + public UsrUserPermission(Integer iUserId, Integer iPermissionId) { + this.iUserId = iUserId; + this.iPermissionId = iPermissionId; + } + + public Integer getIUserId() { + return iUserId; + } + + public void setIUserId(Integer iUserId) { + this.iUserId = iUserId; + } + + public Integer getIPermissionId() { + return iPermissionId; + } + + public void setIPermissionId(Integer iPermissionId) { + this.iPermissionId = iPermissionId; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapper.java new file mode 100644 index 0000000..c826fc7 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapper.java @@ -0,0 +1,15 @@ +package com.xly.erp.modules.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.modules.usr.entity.UsrCompany; +import org.apache.ibatis.annotations.Mapper; + +/** + * 公司 Mapper(MyBatis-Plus)。REQ-USR-004 T3。 + * + *

无自定义方法,复用 BaseMapper 的 {@code selectById}(companyId 存在性校验) + * 与 {@code selectList}(公司下拉列表)。

+ */ +@Mapper +public interface UsrCompanyMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrEmployeeMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrEmployeeMapper.java new file mode 100644 index 0000000..22029d5 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrEmployeeMapper.java @@ -0,0 +1,12 @@ +package com.xly.erp.modules.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.modules.usr.entity.UsrEmployee; +import org.apache.ibatis.annotations.Mapper; + +/** + * 职员 Mapper(MyBatis-Plus),本 REQ 用于存在性校验。REQ-USR-001 T4。 + */ +@Mapper +public interface UsrEmployeeMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrPermissionMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrPermissionMapper.java new file mode 100644 index 0000000..96bca28 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrPermissionMapper.java @@ -0,0 +1,12 @@ +package com.xly.erp.modules.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.modules.usr.entity.UsrPermission; +import org.apache.ibatis.annotations.Mapper; + +/** + * 权限 Mapper(MyBatis-Plus),本 REQ 用于存在性校验。REQ-USR-001 T4。 + */ +@Mapper +public interface UsrPermissionMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java new file mode 100644 index 0000000..5b1b0cb --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java @@ -0,0 +1,29 @@ +package com.xly.erp.modules.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.xly.erp.modules.usr.dto.UserQueryCondition; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.vo.UserVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 用户 Mapper(MyBatis-Plus)。REQ-USR-001 T4 / REQ-USR-003 T3。 + */ +@Mapper +public interface UsrUserMapper extends BaseMapper { + + /** + * 跨表分页查询(REQ-USR-003):{@code usr_user LEFT JOIN usr_employee} 取员工名 / 部门, + * 按已解析 {@link UserQueryCondition} 施加单字段单匹配过滤;MP 分页插件自动补 LIMIT 与 COUNT(*)。 + * + *

不 SELECT {@code sPassword} 与租户列;所有值用 {@code #{}} 预编译占位, + * 仅白名单列 token 经 {@code ${}} 注入(由 Service 从固定映射产出,防注入)。

+ * + * @param page 分页参数(current/size) + * @param cond 已解析查询条件 + * @return 当前页 UserVO 列表(含真实 total) + */ + IPage selectUserPage(IPage page, @Param("cond") UserQueryCondition cond); +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserPermissionMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserPermissionMapper.java new file mode 100644 index 0000000..f909fbb --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserPermissionMapper.java @@ -0,0 +1,12 @@ +package com.xly.erp.modules.usr.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户权限关联 Mapper(MyBatis-Plus)。REQ-USR-001 T4。 + */ +@Mapper +public interface UsrUserPermissionMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java new file mode 100644 index 0000000..62908af --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java @@ -0,0 +1,28 @@ +package com.xly.erp.modules.usr.service; + +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.util.List; + +/** + * 认证服务(spec § 3)。REQ-USR-004 T4。 + */ +public interface UsrAuthService { + + /** + * 登录认证:基础参数校验 → 查用户 → BCrypt 比对 → 判禁用 → companyId 存在性 → + * 签发 JWT + 更新登录时间;含连续失败限流。 + * + * @param dto 登录入参 + * @return 登录输出(token + 用户基础信息,绝不含密码) + */ + LoginVO login(LoginDTO dto); + + /** + * 公司下拉列表(登录页「版本」选项),只读无副作用。 + * + * @return 全部公司下拉项 + */ + List listCompanies(); +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java new file mode 100644 index 0000000..7ee158f --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java @@ -0,0 +1,45 @@ +package com.xly.erp.modules.usr.service; + +import com.xly.erp.common.response.PageResult; +import com.xly.erp.modules.usr.dto.CreateUserDTO; +import com.xly.erp.modules.usr.dto.UpdateUserDTO; +import com.xly.erp.modules.usr.dto.UserQueryDTO; +import com.xly.erp.modules.usr.vo.UserVO; + +/** + * 用户业务服务(docs/04 § 1.2)。REQ-USR-001 / REQ-USR-002 / REQ-USR-003。 + */ +public interface UsrUserService { + + /** + * 新增用户:用户名查重 → 默认值兜底 / 校验 → BCrypt 哈希密码 → 落库 → + * 关联职员 / 权限校验与授权写入。 + * + * @param dto 新增用户入参 + * @return 新建用户主键 iIncrement + */ + Integer createUser(CreateUserDTO dto); + + /** + * 修改用户(REQ-USR-002):校验目标用户存在 → 校验关联职员 / 权限存在 → + * 按"部分更新(null 不改)"语义更新 {@code usr_user} 主记录(不动 sUserName / + * sPassword / 审计列 / 租户列)→ 按"全量覆盖"语义重写该用户在 {@code usr_user_permission} + * 的授权。整体单事务。 + * + * @param id 目标用户主键 iIncrement + * @param dto 修改用户入参 + * @return 被修改用户主键 iIncrement(等于入参 id) + */ + Integer updateUser(Integer id, UpdateUserDTO dto); + + /** + * 查询用户(REQ-USR-003,纯只读):分页参数判定(42201)→ 单字段单匹配条件解析 + * (文本转义 / 枚举 / 布尔归一化 / 日期区间,非法 40001)→ {@code usr_user LEFT JOIN + * usr_employee} 分页查询 → 数据越界钳制到最后一页 → 装配 {@link PageResult}。 + * 空条件返回全量分页;响应不含密码 / 租户列。 + * + * @param dto 查询入参(全部可选) + * @return 分页结果 {@code PageResult} + */ + PageResult queryUsers(UserQueryDTO dto); +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java new file mode 100644 index 0000000..59dade8 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java @@ -0,0 +1,185 @@ +package com.xly.erp.modules.usr.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.service.UsrAuthService; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 认证业务实现(spec § 3)。REQ-USR-004 T4。 + * + *

认证判定顺序(spec § 3 规则 1 / § 8 D3,固定): + * ①限流窗判定(命中锁定 → 42901,置于最前)→ ②按 sUserName(trim) 查 usr_user,null → 40101(记一次失败) + * → ③BCrypt 比对密码,不匹配 → 40101(记一次失败,与②同码同 message 防枚举)→ ④iIsVoid=1 → 40302 + * (先验密码再判禁用,禁用不计入失败计数避免锁死)→ ⑤companyId 存在性校验,不存在 → 40001 + * → ⑥签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。

+ * + *

限流为进程内(内存)按用户名计数(spec § 8 D7),不依赖 Redis;阈值 / 窗口来自 + * application.yml auth.login.max-fail / auth.login.lock-seconds。密码明文绝不进日志 / 异常 message。

+ */ +@Service +public class UsrAuthServiceImpl implements UsrAuthService { + + private final UsrUserMapper usrUserMapper; + private final UsrCompanyMapper usrCompanyMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + /** 达到该连续失败次数后锁定。 */ + private final int maxFail; + /** 锁定窗秒数。 */ + private final long lockSeconds; + + /** 进程内按用户名的失败计数 / 锁定状态(线程安全)。 */ + private final ConcurrentHashMap attempts = new ConcurrentHashMap<>(); + + public UsrAuthServiceImpl(UsrUserMapper usrUserMapper, + UsrCompanyMapper usrCompanyMapper, + PasswordEncoder passwordEncoder, + JwtUtil jwtUtil, + @Value("${auth.login.max-fail:5}") int maxFail, + @Value("${auth.login.lock-seconds:300}") long lockSeconds) { + this.usrUserMapper = usrUserMapper; + this.usrCompanyMapper = usrCompanyMapper; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + this.maxFail = maxFail; + this.lockSeconds = lockSeconds; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginVO login(LoginDTO dto) { + String userName = dto.getSUserName() == null ? null : dto.getSUserName().trim(); + + // ① 限流窗判定(命中锁定 → 42901,置于最前)。 + if (isLocked(userName)) { + throw new BusinessException(ResultCode.LOGIN_RATE_LIMITED); + } + + // ② 按用户名查用户,未查到 → 40101(防枚举,统一文案)。 + UsrUser user = usrUserMapper.selectOne( + Wrappers.lambdaQuery().eq(UsrUser::getSUserName, userName)); + if (user == null) { + recordFailure(userName); + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + // ③ 先 BCrypt 比对密码,不匹配 → 40101(与②同码同 message)。 + if (!passwordEncoder.matches(dto.getPassword(), user.getSPassword())) { + recordFailure(userName); + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + // ④ 再判禁用 → 40302(先验密码再返禁用码;禁用不计入失败计数避免锁死禁用账号)。 + if (user.getIIsVoid() != null && user.getIIsVoid() == 1) { + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); + } + + // ⑤ companyId 存在性校验(不参与认证绑定),不存在 → 40001(中性提示,不含枚举信息)。 + if (usrCompanyMapper.selectById(dto.getCompanyId()) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID); + } + + // ⑥ 签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 装配 LoginVO。 + String token = jwtUtil.generateToken(user.getSUserName(), user.getSUserType()); + + UsrUser loginTimeUpdate = new UsrUser(); + loginTimeUpdate.setIIncrement(user.getIIncrement()); + loginTimeUpdate.setTLastLoginDate(LocalDateTime.now()); + usrUserMapper.updateById(loginTimeUpdate); + + attempts.remove(userName); + + return toLoginVO(token, user); + } + + @Override + @Transactional(readOnly = true) + public List listCompanies() { + List companies = usrCompanyMapper.selectList(null); + List result = new ArrayList<>(); + for (UsrCompany c : companies) { + CompanyOptionVO vo = new CompanyOptionVO(); + vo.setId(c.getIIncrement()); + vo.setSCompanyName(c.getSCompanyName()); + vo.setSVersion(c.getSVersion()); + result.add(vo); + } + return result; + } + + private LoginVO toLoginVO(String token, UsrUser user) { + LoginVO.UserInfo info = new LoginVO.UserInfo(); + info.setId(user.getIIncrement()); + info.setSUserName(user.getSUserName()); + info.setSUserType(user.getSUserType()); + info.setSLanguage(user.getSLanguage()); + + LoginVO vo = new LoginVO(); + vo.setToken(token); + vo.setUser(info); + return vo; + } + + /** 当前用户名是否处于锁定窗内(连续失败已达阈值且未过期)。 */ + private boolean isLocked(String userName) { + if (!StringUtils.hasText(userName)) { + return false; + } + LoginAttempt attempt = attempts.get(userName); + if (attempt == null) { + return false; + } + if (attempt.failCount >= maxFail && attempt.lockUntilEpochSec > nowEpochSec()) { + return true; + } + // 锁定窗已过:重置计数,允许再次尝试。 + if (attempt.lockUntilEpochSec > 0 && attempt.lockUntilEpochSec <= nowEpochSec()) { + attempts.remove(userName); + } + return false; + } + + /** 记一次失败:累计计数,达阈值则设置锁定到期时间。 */ + private void recordFailure(String userName) { + if (!StringUtils.hasText(userName)) { + return; + } + attempts.compute(userName, (k, prev) -> { + LoginAttempt a = prev == null ? new LoginAttempt() : prev; + a.failCount++; + if (a.failCount >= maxFail) { + a.lockUntilEpochSec = nowEpochSec() + lockSeconds; + } + return a; + }); + } + + private long nowEpochSec() { + return System.currentTimeMillis() / 1000L; + } + + /** 单个用户名的失败计数与锁定到期(epoch 秒)。 */ + private static final class LoginAttempt { + private int failCount; + private long lockUntilEpochSec; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java new file mode 100644 index 0000000..7d0a4b1 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java @@ -0,0 +1,338 @@ +package com.xly.erp.modules.usr.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.SecurityUtil; +import com.xly.erp.modules.usr.dto.CreateUserDTO; +import com.xly.erp.modules.usr.dto.UpdateUserDTO; +import com.xly.erp.modules.usr.dto.UserQueryCondition; +import com.xly.erp.modules.usr.dto.UserQueryDTO; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; +import com.xly.erp.modules.usr.service.UsrUserService; +import com.xly.erp.modules.usr.vo.UserVO; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 新增用户业务实现(spec § 3)。REQ-USR-001 T5 / T6。 + * + *

流程:用户名查重(40901)→ 关联职员存在性校验(40001)→ 默认值兜底与枚举越界校验(40001) + * → BCrypt 哈希密码 → 填审计字段并落库(DuplicateKey 兜底转 40901)→ 权限存在性校验(40001) + * → 去重批量授权写入。整体 {@code @Transactional}。

+ */ +@Service +public class UsrUserServiceImpl implements UsrUserService { + + /** 默认初始密码(config-vars admin_init.password 与 spec § 8 D5 一致)。 */ + private static final String DEFAULT_PASSWORD = "666666"; + /** 默认用户类型。 */ + private static final String DEFAULT_USER_TYPE = "普通用户"; + /** 默认单据修改权限。 */ + private static final int DEFAULT_CAN_MODIFY_BILL = 0; + /** 新建即生效。 */ + private static final int NOT_VOID = 0; + + // ---- REQ-USR-003 查询用户:常量 / 白名单 ---- + + /** 默认查询字段(spec § 2.1)。 */ + private static final String DEFAULT_QUERY_FIELD = "用户名"; + /** 默认匹配方式(spec § 2.1)。 */ + private static final String DEFAULT_MATCH_TYPE = "包含"; + /** 默认页码。 */ + private static final long DEFAULT_PAGE_NUM = 1L; + /** 默认每页条数。 */ + private static final long DEFAULT_PAGE_SIZE = 10L; + /** 每页条数上限(spec § 3.7)。 */ + private static final long MAX_PAGE_SIZE = 100L; + + /** 中文 queryField → 白名单列 token(带表别名,绝不拼接用户输入列名,防注入;spec § 3.4)。 */ + private static final Map FIELD_COLUMN = Map.of( + "用户名", "u.sUserName", + "员工名", "e.sEmployeeName", + "用户号", "u.sUserNo", + "部门", "e.sDepartment", + "用户类型", "u.sUserType", + "作废", "u.iIsVoid", + "登录日期", "u.tLastLoginDate", + "制单人", "u.sCreator"); + + /** 布尔字段集合(按 0/1 归一化)。 */ + private static final String FIELD_VOID = "作废"; + /** 日期字段集合(按当日区间解析)。 */ + private static final String FIELD_LOGIN_DATE = "登录日期"; + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATETIME_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final UsrUserMapper usrUserMapper; + private final UsrUserPermissionMapper usrUserPermissionMapper; + private final UsrEmployeeMapper usrEmployeeMapper; + private final UsrPermissionMapper usrPermissionMapper; + private final PasswordEncoder passwordEncoder; + + public UsrUserServiceImpl(UsrUserMapper usrUserMapper, + UsrUserPermissionMapper usrUserPermissionMapper, + UsrEmployeeMapper usrEmployeeMapper, + UsrPermissionMapper usrPermissionMapper, + PasswordEncoder passwordEncoder) { + this.usrUserMapper = usrUserMapper; + this.usrUserPermissionMapper = usrUserPermissionMapper; + this.usrEmployeeMapper = usrEmployeeMapper; + this.usrPermissionMapper = usrPermissionMapper; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Integer createUser(CreateUserDTO dto) { + // 1. 用户名查重(命中唯一索引前先查)。 + Long existing = usrUserMapper.selectCount( + Wrappers.lambdaQuery().eq(UsrUser::getSUserName, dto.getSUserName())); + if (existing != null && existing > 0) { + throw new BusinessException(ResultCode.USERNAME_EXISTS); + } + + // 2. 关联职员存在性校验(可选)。 + if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在"); + } + + // 3. 权限存在性校验 + 去重(可选)。 + List dedupedPermissionIds = null; + if (dto.getPermissionIds() != null && !dto.getPermissionIds().isEmpty()) { + dedupedPermissionIds = dto.getPermissionIds().stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + for (Integer permissionId : dedupedPermissionIds) { + if (usrPermissionMapper.selectById(permissionId) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId); + } + } + } + + // 4. 默认值兜底 + 枚举越界二次校验。 + String userType = StringUtils.hasText(dto.getSUserType()) ? dto.getSUserType() : DEFAULT_USER_TYPE; + if (!DEFAULT_USER_TYPE.equals(userType) && !"超级管理员".equals(userType)) { + throw new BusinessException(ResultCode.PARAM_INVALID, "用户类型取值非法"); + } + Integer canModifyBill = dto.getICanModifyBill() != null ? dto.getICanModifyBill() : DEFAULT_CAN_MODIFY_BILL; + if (canModifyBill != 0 && canModifyBill != 1) { + throw new BusinessException(ResultCode.PARAM_INVALID, "单据修改权限取值非法"); + } + String rawPassword = StringUtils.hasText(dto.getInitialPassword()) + ? dto.getInitialPassword() : DEFAULT_PASSWORD; + + // 5. 组装实体 + 审计字段。 + UsrUser user = new UsrUser(); + user.setSUserName(dto.getSUserName()); + user.setSUserNo(dto.getSUserNo()); + user.setIEmployeeId(dto.getIEmployeeId()); + user.setSUserType(userType); + user.setSLanguage(dto.getSLanguage()); + user.setICanModifyBill(canModifyBill); + user.setSPassword(passwordEncoder.encode(rawPassword)); + user.setIIsVoid(NOT_VOID); + user.setTLastLoginDate(null); + user.setSCreator(SecurityUtil.currentUserName()); + + // 6. 落库(并发唯一冲突兜底转 40901)。 + try { + usrUserMapper.insert(user); + } catch (DuplicateKeyException ex) { + throw new BusinessException(ResultCode.USERNAME_EXISTS); + } + + Integer newUserId = user.getIIncrement(); + + // 7. 权限批量授权写入。 + if (dedupedPermissionIds != null && !dedupedPermissionIds.isEmpty()) { + for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) { + usrUserPermissionMapper.insert(new UsrUserPermission(newUserId, permissionId)); + } + } + + return newUserId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Integer updateUser(Integer id, UpdateUserDTO dto) { + // 1. 目标用户存在性校验(不存在 → 40401,不写入任何表)。 + UsrUser existing = usrUserMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.NOT_FOUND); + } + + // 2. 关联职员存在性校验(可选)——置于主记录更新前,非法即整体回滚不留副作用。 + if (dto.getIEmployeeId() != null && usrEmployeeMapper.selectById(dto.getIEmployeeId()) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID, "关联职员不存在"); + } + + // 3. 权限存在性校验 + 去重(可选)——非 null 才参与全量覆盖,元素须存在。 + List dedupedPermissionIds = null; + if (dto.getPermissionIds() != null) { + dedupedPermissionIds = dto.getPermissionIds().stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + for (Integer permissionId : dedupedPermissionIds) { + if (usrPermissionMapper.selectById(permissionId) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID, "权限不存在: " + permissionId); + } + } + } + + // 4. 组装目标实体:仅 set 主键 + 非 null 可更新列。 + // 不 set sUserName / sPassword / sCreator / tCreateDate / 租户列, + // 依赖 MP updateById「null 字段不参与 SET」语义保持原值不被覆盖。 + UsrUser target = new UsrUser(); + target.setIIncrement(id); + target.setSUserType(dto.getSUserType()); + target.setSLanguage(dto.getSLanguage()); + if (dto.getSUserNo() != null) { + target.setSUserNo(dto.getSUserNo()); + } + if (dto.getIEmployeeId() != null) { + target.setIEmployeeId(dto.getIEmployeeId()); + } + if (dto.getICanModifyBill() != null) { + target.setICanModifyBill(dto.getICanModifyBill()); + } + if (dto.getIIsVoid() != null) { + target.setIIsVoid(dto.getIIsVoid()); + } + usrUserMapper.updateById(target); + + // 5. 权限组全量覆盖(permissionIds 非 null):先删该用户全部旧授权, + // 再对去重集合逐行插入(空集合则只删不插 = 清空;null 不进入本分支 = 不改)。 + if (dedupedPermissionIds != null) { + usrUserPermissionMapper.delete(Wrappers.lambdaQuery() + .eq(UsrUserPermission::getIUserId, id)); + for (Integer permissionId : new LinkedHashSet<>(dedupedPermissionIds)) { + usrUserPermissionMapper.insert(new UsrUserPermission(id, permissionId)); + } + } + + return id; + } + + @Override + @Transactional(readOnly = true) + public PageResult queryUsers(UserQueryDTO dto) { + // 1. 分页参数判定(先于一切;非法 → 42201,spec § 8 D8)。 + long pageNum = dto.getPageNum() != null ? dto.getPageNum() : DEFAULT_PAGE_NUM; + long pageSize = dto.getPageSize() != null ? dto.getPageSize() : DEFAULT_PAGE_SIZE; + if (pageNum < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new BusinessException(ResultCode.PAGE_PARAM_INVALID); + } + + // 2. 条件解析(单字段单匹配;空值不施加过滤)。 + UserQueryCondition cond = parseCondition(dto); + + // 3. 分页查询。 + IPage page = usrUserMapper.selectUserPage(new Page<>(pageNum, pageSize), cond); + long total = page.getTotal(); + + // 4. 数据越界钳制到最后一页(spec § 3.7)。 + long pages = total == 0 ? 1 : (total + pageSize - 1) / pageSize; + if (total == 0) { + return PageResult.of(page.getRecords(), 0L, DEFAULT_PAGE_NUM, pageSize); + } + if (pageNum > pages) { + // 用钳后页号回查一次,保证 records 为最后一页真实数据。 + page = usrUserMapper.selectUserPage(new Page<>(pages, pageSize), cond); + return PageResult.of(page.getRecords(), total, pages, pageSize); + } + return PageResult.of(page.getRecords(), total, pageNum, pageSize); + } + + /** + * 把 DTO 的中文 queryField / matchType / 原始 queryValue 解析归一为 Mapper 可直接拼 XML 的条件。 + * queryValue 为 null / trim 后空 → 无过滤;按字段类型分文本 / 枚举 / 布尔 / 日期解析(非法 → 40001)。 + */ + private UserQueryCondition parseCondition(UserQueryDTO dto) { + String value = dto.getQueryValue(); + if (value == null || value.trim().isEmpty()) { + return UserQueryCondition.none(); + } + String field = StringUtils.hasText(dto.getQueryField()) ? dto.getQueryField() : DEFAULT_QUERY_FIELD; + String matchType = StringUtils.hasText(dto.getMatchType()) ? dto.getMatchType() : DEFAULT_MATCH_TYPE; + String column = FIELD_COLUMN.get(field); + if (column == null) { + throw new BusinessException(ResultCode.PARAM_INVALID, "查询字段取值非法"); + } + String trimmed = value.trim(); + + if (FIELD_VOID.equals(field)) { + int boolValue = normalizeBool(trimmed); + boolean negated = "不包含".equals(matchType); + return UserQueryCondition.bool(column, boolValue, negated); + } + if (FIELD_LOGIN_DATE.equals(field)) { + LocalDateTime start = parseDayStart(trimmed); + LocalDateTime end = start.toLocalDate().plusDays(1).atStartOfDay(); + boolean negated = "不包含".equals(matchType); + return UserQueryCondition.date(column, start, end, negated); + } + // 文本 / 枚举:等于走 = 不转义;包含 / 不包含走 LIKE,对 % _ \ 转义。 + if ("等于".equals(matchType)) { + return UserQueryCondition.text(column, matchType, trimmed); + } + return UserQueryCondition.text(column, matchType, escapeLike(trimmed)); + } + + /** 布尔归一化:接受 0/1、是/否、true/false(spec § 8 D6);不可解析 → 40001。 */ + private int normalizeBool(String value) { + String v = value.trim().toLowerCase(); + if ("1".equals(v) || "是".equals(value.trim()) || "true".equals(v)) { + return 1; + } + if ("0".equals(v) || "否".equals(value.trim()) || "false".equals(v)) { + return 0; + } + throw new BusinessException(ResultCode.PARAM_INVALID, "作废取值不可解析"); + } + + /** 日期解析为当日起点:支持 yyyy-MM-dd 与 yyyy-MM-dd HH:mm:ss(spec § 8 D6);非法 → 40001。 */ + private LocalDateTime parseDayStart(String value) { + try { + if (value.length() > 10) { + LocalDateTime dt = LocalDateTime.parse(value, DATETIME_FMT); + return dt.toLocalDate().atStartOfDay(); + } + LocalDate d = LocalDate.parse(value, DATE_FMT); + return d.atTime(LocalTime.MIDNIGHT); + } catch (DateTimeParseException ex) { + throw new BusinessException(ResultCode.PARAM_INVALID, "登录日期取值非法"); + } + } + + /** LIKE 通配符转义:对 \ % _ 前置反斜杠(配合 XML 的 ESCAPE '\\',spec § 8 D3)。 */ + private String escapeLike(String value) { + return value.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/vo/CompanyOptionVO.java b/backend/src/main/java/com/xly/erp/modules/usr/vo/CompanyOptionVO.java new file mode 100644 index 0000000..d3cc70c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/vo/CompanyOptionVO.java @@ -0,0 +1,51 @@ +package com.xly.erp.modules.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.Serializable; + +/** + * 公司下拉项输出(spec § 2.3)。REQ-USR-004 T2。 + * + *

供登录页「版本」下拉展示。匈牙利前缀字段({@code sCompanyName}/{@code sVersion})的 + * getter 会被 Jackson 推断为大驼峰键,与契约不符,故加 {@link JsonProperty} 锁键; + * {@code id} 为常规小驼峰,无需注解。{@code sVersion} 可为 null。

+ */ +public class CompanyOptionVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 公司主键 iIncrement。 */ + private Integer id; + + /** 公司名称(usr_company.sCompanyName)。 */ + @JsonProperty("sCompanyName") + private String sCompanyName; + + /** 版本 / 账套标识(usr_company.sVersion,可 null)。 */ + @JsonProperty("sVersion") + private String sVersion; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getSCompanyName() { + return sCompanyName; + } + + public void setSCompanyName(String sCompanyName) { + this.sCompanyName = sCompanyName; + } + + public String getSVersion() { + return sVersion; + } + + public void setSVersion(String sVersion) { + this.sVersion = sVersion; + } +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/vo/LoginVO.java b/backend/src/main/java/com/xly/erp/modules/usr/vo/LoginVO.java new file mode 100644 index 0000000..54024b3 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/vo/LoginVO.java @@ -0,0 +1,92 @@ +package com.xly.erp.modules.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.Serializable; + +/** + * 登录输出(spec § 2.2 契约)。REQ-USR-004 T3。 + * + *

{@code token} 为 JWT(不含 {@code Bearer } 前缀);嵌套 {@code user} 为登录用户基础信息。 + * 严格不含 {@code sPassword} / 租户列。匈牙利前缀字段经 {@link JsonProperty} 锁小驼峰键。

+ */ +public class LoginVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** JWT 令牌(不含 Bearer 前缀,前端自行拼 Authorization 头)。 */ + private String token; + + /** 登录用户基础信息(嵌套对象 user{...})。 */ + private UserInfo user; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public UserInfo getUser() { + return user; + } + + public void setUser(UserInfo user) { + this.user = user; + } + + /** + * 登录用户基础信息(嵌套于 {@code user}),来源 usr_user,绝不含密码。 + */ + public static class UserInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 用户主键 iIncrement。 */ + private Integer id; + + /** 用户名。 */ + @JsonProperty("sUserName") + private String sUserName; + + /** 用户类型。 */ + @JsonProperty("sUserType") + private String sUserType; + + /** 界面语言。 */ + @JsonProperty("sLanguage") + private String sLanguage; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + 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/modules/usr/vo/UserVO.java b/backend/src/main/java/com/xly/erp/modules/usr/vo/UserVO.java new file mode 100644 index 0000000..6923ad3 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/vo/UserVO.java @@ -0,0 +1,152 @@ +package com.xly.erp.modules.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 查询用户输出(spec § 2.2 契约)。REQ-USR-003 T2。 + * + *

严格不含 {@code sPassword} 与租户列({@code sId}/{@code sBrandsId}/{@code sSubsidiaryId})。 + * 跨表字段 {@code employeeName}/{@code department} 来自 {@code usr_employee}(LEFT JOIN,可 null)。

+ * + *

带匈牙利前缀字段({@code sUserName} 等)的 getter 形如 {@code getSUserName} 会被 Jackson + * 推断为属性名 {@code SUserName},与契约 JSON 键不符;故对这些字段加 {@link JsonProperty} + * 锁定小驼峰键名(与 CreateUserDTO/UpdateUserDTO 同做法)。日期字段 {@code tLastLoginDate}/ + * {@code tCreateDate} 的 getter({@code getTLastLoginDate} 等)同样被 Jackson 推断为 + * {@code tlastLoginDate}(首段连续大写被小写化),与契约键不符,故一并加 @JsonProperty 锁键。 + * {@code employeeName}/{@code department}/{@code id} 为常规小驼峰,无需 @JsonProperty。

+ */ +public class UserVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 用户主键 iIncrement。 */ + private Integer id; + + /** 用户名。 */ + @JsonProperty("sUserName") + private String sUserName; + + /** 员工名(usr_employee.sEmployeeName,LEFT JOIN 可 null)。 */ + private String employeeName; + + /** 用户号。 */ + @JsonProperty("sUserNo") + private String sUserNo; + + /** 部门(usr_employee.sDepartment,LEFT JOIN 可 null)。 */ + private String department; + + /** 用户类型。 */ + @JsonProperty("sUserType") + private String sUserType; + + /** 界面语言。 */ + @JsonProperty("sLanguage") + private String sLanguage; + + /** 作废标志:0 正常 / 1 已作废。 */ + @JsonProperty("iIsVoid") + private Integer iIsVoid; + + /** 最后登录时间(可 null)。 */ + @JsonProperty("tLastLoginDate") + private LocalDateTime tLastLoginDate; + + /** 制单人。 */ + @JsonProperty("sCreator") + private String sCreator; + + /** 创建时间。 */ + @JsonProperty("tCreateDate") + private LocalDateTime tCreateDate; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getSUserName() { + return sUserName; + } + + public void setSUserName(String sUserName) { + this.sUserName = sUserName; + } + + public String getEmployeeName() { + return employeeName; + } + + public void setEmployeeName(String employeeName) { + this.employeeName = employeeName; + } + + 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 Integer getIIsVoid() { + return iIsVoid; + } + + public void setIIsVoid(Integer iIsVoid) { + this.iIsVoid = iIsVoid; + } + + public LocalDateTime getTLastLoginDate() { + return tLastLoginDate; + } + + public void setTLastLoginDate(LocalDateTime tLastLoginDate) { + this.tLastLoginDate = tLastLoginDate; + } + + public String getSCreator() { + return sCreator; + } + + public void setSCreator(String sCreator) { + this.sCreator = sCreator; + } + + public LocalDateTime getTCreateDate() { + return tCreateDate; + } + + public void setTCreateDate(LocalDateTime tCreateDate) { + this.tCreateDate = tCreateDate; + } +} diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml new file mode 100644 index 0000000..ef9f7d1 --- /dev/null +++ b/backend/src/main/resources/application-test.yml @@ -0,0 +1,17 @@ +# application-test.yml — 测试 profile。 +# 连同一测试库(xlyweberp_vibe_erp_test,由 setup-test-db.mjs DROP+CREATE 后由 Flyway apply V1)。 +# 测试期间 Flyway 自动 apply sql/migrations/ 下的 V1+;DB 凭据沿用主配置默认(来自 config-vars.yaml)。 +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST:118.178.19.35}:${DB_PORT:3318}/${DB_SCHEMA:xlyweberp_vibe_erp_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: ${DB_USER:xlyprint} + password: ${DB_PASSWORD:xlyXLYprint2016} + flyway: + enabled: true + locations: filesystem:../sql/migrations + baseline-on-migrate: true + +jwt: + secret: ${JWT_SECRET:a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2} + expire-millis: 43200000 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..91d440e --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,44 @@ +# application.yml — 小羚羊 ERP 后端主配置。 +# 端口 / 数据源 / MyBatis-Plus / Flyway / JWT。 +# 敏感值(DB 凭据 / JWT 密钥)通过环境变量注入,单一来源为仓库根 config-vars.yaml, +# 由工具脚本 / 部署环境导出为下列 env,禁止硬编码进源码。 +server: + port: ${BACKEND_HTTP_PORT:5172} + +spring: + application: + name: xly-erp-backend + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB_HOST:118.178.19.35}:${DB_PORT:3318}/${DB_SCHEMA:xlyweberp_vibe_erp_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: ${DB_USER:xlyprint} + password: ${DB_PASSWORD:xlyXLYprint2016} + flyway: + enabled: true + locations: filesystem:../sql/migrations + baseline-on-migrate: true + table: flyway_schema_history + +mybatis-plus: + configuration: + map-underscore-to-camel-case: false + global-config: + db-config: + id-type: auto + +# JWT 配置:密钥单一来源 config-vars.yaml secrets.jwt_secret,通过 env 注入。 +jwt: + secret: ${JWT_SECRET:a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2} + # 过期时间(毫秒),默认 12 小时 + expire-millis: ${JWT_EXPIRE_MILLIS:43200000} + +# 登录限流(REQ-USR-004 spec § 8 D7):进程内按用户名连续失败计数, +# 达 max-fail 次后锁定 lock-seconds 秒。config-vars 无该键,采用默认值并允许 env 覆盖。 +auth: + login: + max-fail: ${AUTH_LOGIN_MAX_FAIL:5} + lock-seconds: ${AUTH_LOGIN_LOCK_SECONDS:300} + +logging: + level: + com.xly.erp: INFO diff --git a/backend/src/main/resources/mapper/usr/UsrUserMapper.xml b/backend/src/main/resources/mapper/usr/UsrUserMapper.xml new file mode 100644 index 0000000..5527d4e --- /dev/null +++ b/backend/src/main/resources/mapper/usr/UsrUserMapper.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/test/java/com/xly/erp/ErpApplicationTests.java b/backend/src/test/java/com/xly/erp/ErpApplicationTests.java new file mode 100644 index 0000000..71776cf --- /dev/null +++ b/backend/src/test/java/com/xly/erp/ErpApplicationTests.java @@ -0,0 +1,21 @@ +package com.xly.erp; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * 骨架健康自检:证明 pom 依赖 / 启动类 / application.yml 自洽, + * Flyway 能对测试库 apply V1,MyBatis-Plus 装配成功。 + * + *

REQ-USR-001 T1。

+ */ +@SpringBootTest +@ActiveProfiles("test") +class ErpApplicationTests { + + @Test + void contextLoads() { + // Spring 上下文成功加载即通过。 + } +} diff --git a/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java new file mode 100644 index 0000000..2fa13af --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java @@ -0,0 +1,35 @@ +package com.xly.erp.common.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * REQ-USR-001 T3:BCryptPasswordEncoder Bean 行为(轻量单元,直接 new SecurityConfig 取 Bean)。 + */ +class SecurityConfigTest { + + @Test + void bcryptEncoderEncodesAndMatches() { + SecurityConfig config = new SecurityConfig(); + PasswordEncoder encoder = config.passwordEncoder(); + assertThat(encoder).isInstanceOf(BCryptPasswordEncoder.class); + + String hash = encoder.encode("666666"); + assertThat(hash).isNotEqualTo("666666"); + assertThat(encoder.matches("666666", hash)).isTrue(); + assertThat(encoder.matches("wrong", hash)).isFalse(); + } + + /** + * REQ-USR-004 T5:放行清单含 GET /api/usr/companies(与既有 /api/usr/login 同口径放行)。 + */ + @Test + void companiesEndpointPermitted() { + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/companies"); + // 不删除既有放行项:/api/usr/login 仍在清单。 + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/login"); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..0a5ae89 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,23 @@ +package com.xly.erp.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.xly.erp.common.response.Result; +import com.xly.erp.common.response.ResultCode; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T2:全局异常处理器把 BusinessException 转为统一 Result。 + */ +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void businessExceptionMapsToResult() { + BusinessException ex = new BusinessException(ResultCode.USERNAME_EXISTS, "用户名已存在"); + Result r = handler.handleBusinessException(ex); + assertThat(r.getCode()).isEqualTo(40901); + assertThat(r.getMessage()).isEqualTo("用户名已存在"); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/response/PageResultTest.java b/backend/src/test/java/com/xly/erp/common/response/PageResultTest.java new file mode 100644 index 0000000..b64d146 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/response/PageResultTest.java @@ -0,0 +1,34 @@ +package com.xly.erp.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-003 T1:通用分页响应体 PageResult。 + * + *

验证四字段(records/total/pageNum/pageSize)装配读回一致,空 records 允许。

+ */ +class PageResultTest { + + @Test + void ofAssemblesAllFields() { + List records = List.of("a", "b", "c"); + PageResult page = PageResult.of(records, 23L, 2L, 10L); + + assertThat(page.getRecords()).isEqualTo(records); + assertThat(page.getTotal()).isEqualTo(23L); + assertThat(page.getPageNum()).isEqualTo(2L); + assertThat(page.getPageSize()).isEqualTo(10L); + } + + @Test + void emptyRecordsAllowed() { + PageResult page = PageResult.of(List.of(), 0L, 1L, 10L); + + assertThat(page.getRecords()).isNotNull().isEmpty(); + assertThat(page.getTotal()).isZero(); + assertThat(page.getPageNum()).isEqualTo(1L); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java b/backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java new file mode 100644 index 0000000..28e0e84 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/response/ResultCodeLoginTest.java @@ -0,0 +1,28 @@ +package com.xly.erp.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-004 T1:登录相关错误码断言。 + * + *

新增登录限流码 {@code LOGIN_RATE_LIMITED=42901};并确认登录流程复用的既有码 + * {@code UNAUTHORIZED=40101} / {@code ACCOUNT_DISABLED=40302} / {@code PARAM_INVALID=40001} + * 取值不变(不重复定义、不漂移)。

+ */ +class ResultCodeLoginTest { + + @Test + void loginRateLimitedCodeIs42901() { + assertThat(ResultCode.LOGIN_RATE_LIMITED.getCode()).isEqualTo(42901); + assertThat(ResultCode.LOGIN_RATE_LIMITED.getMessage()).isNotBlank(); + } + + @Test + void existingLoginCodesPresent() { + assertThat(ResultCode.UNAUTHORIZED.getCode()).isEqualTo(40101); + assertThat(ResultCode.ACCOUNT_DISABLED.getCode()).isEqualTo(40302); + assertThat(ResultCode.PARAM_INVALID.getCode()).isEqualTo(40001); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/response/ResultTest.java b/backend/src/test/java/com/xly/erp/common/response/ResultTest.java new file mode 100644 index 0000000..f0a7469 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/response/ResultTest.java @@ -0,0 +1,26 @@ +package com.xly.erp.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T2:统一响应体 Result 行为校验。 + */ +class ResultTest { + + @Test + void successCarriesCodeZeroAndData() { + Result r = Result.success("hello"); + assertThat(r.getCode()).isEqualTo(0); + assertThat(r.getData()).isEqualTo("hello"); + } + + @Test + void failCarriesBusinessCodeAndMessage() { + Result r = Result.fail(ResultCode.USERNAME_EXISTS, "用户名已存在"); + assertThat(r.getCode()).isEqualTo(40901); + assertThat(r.getMessage()).isEqualTo("用户名已存在"); + assertThat(r.getData()).isNull(); + } +} 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 new file mode 100644 index 0000000..1808c43 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java @@ -0,0 +1,27 @@ +package com.xly.erp.common.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T3:JWT 签发 / 解析往返。 + * + *

用 config-vars 的 jwt_secret 签发含 sUserName + sUserType claim 的 token, + * 解析后能取回相同值且校验通过。

+ */ +class JwtUtilTest { + + private static final String SECRET = + "a3b7e8f1c4d6029e5b8f37a1c9d2e4068b5f1d3a7c0e9b2f48d6a1c5e7f9b3d2"; + + @Test + void generateThenParseRoundTrip() { + JwtUtil jwtUtil = new JwtUtil(SECRET, 43200000L); + String token = jwtUtil.generateToken("admin", "超级管理员"); + + assertThat(jwtUtil.validateToken(token)).isTrue(); + assertThat(jwtUtil.getUserName(token)).isEqualTo("admin"); + assertThat(jwtUtil.getUserType(token)).isEqualTo("超级管理员"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java b/backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java new file mode 100644 index 0000000..be6c9c8 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/AuthLoginConfigIT.java @@ -0,0 +1,31 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * REQ-USR-004 T6:登录限流配置项可解析(spec § 8 D7)。 + * + *

@SpringBootTest + test profile 下断言 auth.login.max-fail / auth.login.lock-seconds + * 已声明且能解析为整数(默认 5 / 300),确保 Service @Value 注入不会因缺键启动失败。

+ */ +@SpringBootTest +@ActiveProfiles("test") +class AuthLoginConfigIT { + + @Value("${auth.login.max-fail}") + private int maxFail; + + @Value("${auth.login.lock-seconds}") + private long lockSeconds; + + @Test + void loginConfigDefaultsBound() { + assertThat(maxFail).isEqualTo(5); + assertThat(lockSeconds).isEqualTo(300L); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java new file mode 100644 index 0000000..0147222 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrLoginIT.java @@ -0,0 +1,316 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +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.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * REQ-USR-004 T7:登录端到端验收回归(spec § 7 验收标准 1-12)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1); + * 真实 JwtUtil / BCryptPasswordEncoder。@AfterEach 按前缀清理 fixture + * (用户前缀 it_login_,公司名前缀 IT_LOGIN_CO_)。登录用户 sPassword 用真实 encode("666666")。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrLoginIT { + + private static final String USER_PREFIX = "it_login_"; + private static final String RL_USER_PREFIX = "it_login_rl_"; + private static final String CO_PREFIX = "IT_LOGIN_CO_"; + private static final String RAW_PWD = "666666"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrCompanyMapper usrCompanyMapper; + + private Integer companyId; + + @BeforeEach + void seedCompany() { + UsrCompany c = new UsrCompany(); + c.setSCompanyName(CO_PREFIX + "总部"); + c.setSVersion("企业版"); + usrCompanyMapper.insert(c); + companyId = c.getIIncrement(); + } + + @AfterEach + void cleanup() { + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, USER_PREFIX)); + usrCompanyMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrCompany::getSCompanyName, CO_PREFIX)); + } + + private Integer insertUser(String userName, int isVoid) { + UsrUser u = new UsrUser(); + u.setSUserName(userName); + u.setSPassword(passwordEncoder.encode(RAW_PWD)); + u.setSUserType("普通用户"); + u.setSLanguage("中文"); + u.setICanModifyBill(0); + u.setIIsVoid(isVoid); + u.setSCreator("it_login_seed"); + usrUserMapper.insert(u); + return u.getIIncrement(); + } + + private String loginBody(String userName, String password, Integer compId) throws Exception { + Map body = new HashMap<>(); + if (userName != null) { + body.put("sUserName", userName); + } + if (password != null) { + body.put("password", password); + } + if (compId != null) { + body.put("companyId", compId); + } + return objectMapper.writeValueAsString(body); + } + + private MvcResult doLogin(String userName, String password, Integer compId) throws Exception { + return mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(loginBody(userName, password, compId))) + .andReturn(); + } + + @Test + void ac1LoginSuccess() throws Exception { + String userName = USER_PREFIX + "ac1"; + Integer id = insertUser(userName, 0); + + MvcResult result = mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(loginBody(userName, RAW_PWD, companyId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.token").isNotEmpty()) + .andExpect(jsonPath("$.data.user.id").value(id)) + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")) + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) + .andReturn(); + + String resp = result.getResponse().getContentAsString(); + assertThat(resp).doesNotContain("sPassword"); + assertThat(resp.toLowerCase()).doesNotContain("password"); + assertThat(resp).doesNotContain(RAW_PWD); + } + + @Test + void ac2TokenAcceptedByProtectedApi() throws Exception { + String userName = USER_PREFIX + "ac2"; + insertUser(userName, 0); + MvcResult login = doLogin(userName, RAW_PWD, companyId); + String token = objectMapper.readTree(login.getResponse().getContentAsString()) + .path("data").path("token").asText(); + assertThat(token).isNotBlank(); + + // 用 token 调受保护接口(REQ-USR-003)→ 非 401。 + mockMvc.perform(get("/api/usr/users") + .param("pageNum", "1").param("pageSize", "10") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + @Test + void ac3LoginUpdatesLastLoginDate() throws Exception { + String userName = USER_PREFIX + "ac3"; + Integer id = insertUser(userName, 0); + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); + + doLogin(userName, RAW_PWD, companyId); + + LocalDateTime after = usrUserMapper.selectById(id).getTLastLoginDate(); + assertThat(after).isNotNull(); + } + + @Test + void ac4WrongPassword40101() throws Exception { + String userName = USER_PREFIX + "ac4"; + insertUser(userName, 0); + + MvcResult result = doLogin(userName, "wrongpwd", companyId); + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40101); + assertThat(resp.path("data").isNull()).isTrue(); + assertThat(result.getResponse().getContentAsString()).doesNotContain("密码错误"); + } + + @Test + void ac5UserNotFound40101SameAsWrongPassword() throws Exception { + String userName = USER_PREFIX + "ac5"; + insertUser(userName, 0); + + // 密码错误响应 + String wrongPwdMsg = objectMapper.readTree( + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()) + .path("message").asText(); + + // 用户不存在响应 + JsonNode notFound = objectMapper.readTree( + doLogin(USER_PREFIX + "ghost_never_exists", RAW_PWD, companyId) + .getResponse().getContentAsString()); + + assertThat(notFound.path("code").asInt()).isEqualTo(40101); + assertThat(notFound.path("message").asText()).isEqualTo(wrongPwdMsg); + } + + @Test + void ac6DisabledUser40302() throws Exception { + String userName = USER_PREFIX + "ac6"; + Integer id = insertUser(userName, 1); + + MvcResult result = doLogin(userName, RAW_PWD, companyId); + JsonNode resp = objectMapper.readTree(result.getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40302); + assertThat(resp.path("data").path("token").isMissingNode() + || resp.path("data").isNull()).isTrue(); + + // 禁用登录不更新 tLastLoginDate。 + assertThat(usrUserMapper.selectById(id).getTLastLoginDate()).isNull(); + } + + @Test + void ac7MissingParam40001() throws Exception { + String userName = USER_PREFIX + "ac7"; + insertUser(userName, 0); + + // 缺 sUserName + assertThat(objectMapper.readTree(doLogin(null, RAW_PWD, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + // 缺 password + assertThat(objectMapper.readTree(doLogin(userName, null, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + // 缺 companyId + assertThat(objectMapper.readTree(doLogin(userName, RAW_PWD, null) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(40001); + } + + @Test + void ac8IllegalCompanyId40001() throws Exception { + String userName = USER_PREFIX + "ac8"; + insertUser(userName, 0); + + JsonNode resp = objectMapper.readTree( + doLogin(userName, RAW_PWD, Integer.MAX_VALUE).getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40001); + } + + @Test + void ac9RateLimitAfter5Fails42901() throws Exception { + String userName = RL_USER_PREFIX + "ac9"; + insertUser(userName, 0); + + // 连续 5 次密码错误 → 每次 40101 + for (int i = 0; i < 5; i++) { + JsonNode resp = objectMapper.readTree( + doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString()); + assertThat(resp.path("code").asInt()).isEqualTo(40101); + } + // 第 6 次即使密码正确也 42901(锁定窗内) + JsonNode locked = objectMapper.readTree( + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()); + assertThat(locked.path("code").asInt()).isEqualTo(42901); + + // 成功登录后计数清零分支:用独立用户验证 失败<5 → 成功 → 再失败仍不触发 42901。 + String resetUser = RL_USER_PREFIX + "ac9_reset"; + insertUser(resetUser, 0); + for (int i = 0; i < 3; i++) { + doLogin(resetUser, "wrongpwd", companyId); + } + // 一次成功清零 + assertThat(objectMapper.readTree(doLogin(resetUser, RAW_PWD, companyId) + .getResponse().getContentAsString()).path("code").asInt()).isEqualTo(0); + // 再连续失败 5 次:第 5 次才到阈值,前几次仍 40101(验证计数已清零,未沿用旧计数) + JsonNode afterReset = objectMapper.readTree( + doLogin(resetUser, "wrongpwd", companyId).getResponse().getContentAsString()); + assertThat(afterReset.path("code").asInt()).isEqualTo(40101); + } + + @Test + void ac10CompaniesListNoToken() throws Exception { + mockMvc.perform(get("/api/usr/companies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[?(@.sCompanyName == '" + CO_PREFIX + "总部')]").exists()); + } + + @Test + void ac11PasswordNeverLeaks() throws Exception { + String userName = USER_PREFIX + "ac11"; + insertUser(userName, 0); + + String ok = doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString(); + String fail = doLogin(userName, "wrongpwd", companyId).getResponse().getContentAsString(); + + for (String resp : new String[] {ok, fail}) { + assertThat(resp).doesNotContain("sPassword"); + assertThat(resp.toLowerCase()).doesNotContain("password"); + assertThat(resp).doesNotContain(RAW_PWD); + } + } + + @Test + void ac12JwtHasExpiry() throws Exception { + String userName = USER_PREFIX + "ac12"; + insertUser(userName, 0); + + String token = objectMapper.readTree( + doLogin(userName, RAW_PWD, companyId).getResponse().getContentAsString()) + .path("data").path("token").asText(); + assertThat(token).isNotBlank(); + + // 解析 JWT payload(第二段 base64url)→ 断言含 exp 且晚于 iat(有限有效期)。 + String[] segments = token.split("\\."); + assertThat(segments.length).isEqualTo(3); + String payloadJson = new String( + Base64.getUrlDecoder().decode(segments[1]), StandardCharsets.UTF_8); + JsonNode payload = objectMapper.readTree(payloadJson); + assertThat(payload.has("exp")).isTrue(); + assertThat(payload.has("iat")).isTrue(); + assertThat(payload.path("exp").asLong()).isGreaterThan(payload.path("iat").asLong()); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java new file mode 100644 index 0000000..964f3f8 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java @@ -0,0 +1,206 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.entity.UsrPermission; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * REQ-USR-001 T8:新增用户端到端验收回归(spec § 7)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入; + * 真实 BCrypt 哈希 + 真实库写入做端到端确认。每个用例自管理 fixture 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrUserCreateIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrUserPermissionMapper usrUserPermissionMapper; + + @Autowired + private UsrPermissionMapper usrPermissionMapper; + + private static final String CALLER = "it_admin"; + + @AfterEach + void cleanup() { + // 删除本测试创建的用户(及其权限授权由外键 CASCADE 清理)与 fixture 权限。 + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, "it_user_")); + usrPermissionMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrPermission::getSPermissionCode, "IT_PERM_")); + } + + private String adminToken() { + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); + } + + private String normalToken() { + return "Bearer " + jwtUtil.generateToken("it_normal", "普通用户"); + } + + private Map baseBody(String userName) { + Map body = new HashMap<>(); + body.put("sUserName", userName); + body.put("sLanguage", "中文"); + return body; + } + + private int insertPermissionFixture(String codeSuffix) { + UsrPermission perm = new UsrPermission(); + perm.setSPermissionName("IT权限" + codeSuffix); + perm.setSPermissionCode("IT_PERM_" + codeSuffix); + usrPermissionMapper.insert(perm); + return perm.getIIncrement(); + } + + @Test + void ac1NormalCreatePersistsHashedPassword() throws Exception { + String userName = "it_user_ac1"; + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").isNumber()) + .andReturn(); + + Map resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + Map data = (Map) resp.get("data"); + Integer newId = (Integer) data.get("id"); + + UsrUser saved = usrUserMapper.selectById(newId); + assertThat(saved).isNotNull(); + assertThat(saved.getSPassword()).isNotEqualTo("666666"); + assertThat(passwordEncoder.matches("666666", saved.getSPassword())).isTrue(); + assertThat(saved.getIIsVoid()).isZero(); + assertThat(saved.getSCreator()).isEqualTo(CALLER); + assertThat(saved.getTCreateDate()).isNotNull(); + } + + @Test + void ac2DuplicateUserNameRollsBack() throws Exception { + String userName = "it_user_ac2"; + mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(0)); + + long before = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + + mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(40901)); + + long after = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + assertThat(after).isEqualTo(before).isEqualTo(1L); + } + + @Test + void ac4PermissionGrantWritesRows() throws Exception { + int permA = insertPermissionFixture("A"); + int permB = insertPermissionFixture("B"); + String userName = "it_user_ac4"; + Map body = baseBody(userName); + body.put("permissionIds", List.of(permA, permB, permA)); + + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + + Map resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + Integer newId = (Integer) ((Map) resp.get("data")).get("id"); + + long grants = usrUserPermissionMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUserPermission::getIUserId, newId)); + assertThat(grants).isEqualTo(2L); + } + + @Test + void ac5NonAdminForbidden() throws Exception { + String userName = "it_user_ac5"; + + // 普通用户 token → 40301 + mockMvc.perform(post("/api/usr/users") + .header("Authorization", normalToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(40301)); + + // 无 token → 401(安全链拦截,未认证) + mockMvc.perform(post("/api/usr/users") + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(status().isUnauthorized()); + + long created = usrUserMapper.selectCount(Wrappers.lambdaQuery() + .eq(UsrUser::getSUserName, userName)); + assertThat(created).isZero(); + } + + @Test + void ac7ResponseHasNoPassword() throws Exception { + String userName = "it_user_ac7"; + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(baseBody(userName)))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertThat(responseBody).doesNotContain("666666"); + assertThat(responseBody.toLowerCase()).doesNotContain("password"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java new file mode 100644 index 0000000..9e2ed6c --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java @@ -0,0 +1,296 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.entity.UsrEmployee; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import java.time.LocalDateTime; +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.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** + * REQ-USR-003 T6:查询用户端到端验收回归(spec § 7 AC1-AC13)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;查询无管理员限制 + * (任意已认证用户可调用,spec § 8 D5)。每个用例自管理 fixture 清理(命名前缀 + * {@code it3_user_} / {@code IT3职员} / {@code IT3_EMP_})。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrUserQueryIT { + + private static final String USER_PREFIX = "it3_user_"; + private static final String EMP_NAME_PREFIX = "IT3职员"; + private static final String EMP_NO_PREFIX = "IT3_EMP_"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrEmployeeMapper usrEmployeeMapper; + + @BeforeEach + void cleanupBefore() { + purge(); + } + + @AfterEach + void cleanupAfter() { + purge(); + } + + private void purge() { + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, USER_PREFIX)); + usrEmployeeMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrEmployee::getSEmployeeName, EMP_NAME_PREFIX)); + } + + private String token() { + return "Bearer " + jwtUtil.generateToken("it3_caller", "普通用户"); + } + + private Integer seedEmployee(String nameSuffix, String dept) { + UsrEmployee emp = new UsrEmployee(); + emp.setSEmployeeName(EMP_NAME_PREFIX + nameSuffix); + emp.setSEmployeeNo(EMP_NO_PREFIX + nameSuffix); + emp.setSDepartment(dept); + usrEmployeeMapper.insert(emp); + return emp.getIIncrement(); + } + + private UsrUser seedUser(String nameSuffix, String creator, String userType) { + UsrUser u = new UsrUser(); + u.setSUserName(USER_PREFIX + nameSuffix); + u.setSPassword("$2a$dummyhashvalue000000000000000000000"); + u.setSUserType(userType); + u.setSLanguage("中文"); + u.setICanModifyBill(0); + u.setIIsVoid(0); + u.setSCreator(creator); + usrUserMapper.insert(u); + return u; + } + + @Test + void ac1EmptyConditionFullPage() throws Exception { + seedUser("a1x", "it3_creator", "普通用户"); + seedUser("a1y", "it3_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("pageNum", "1").param("pageSize", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.pageNum").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(10)) + .andExpect(jsonPath("$.data.records").isArray()) + .andExpect(jsonPath("$.data.records[0].sUserName").exists()) + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); + } + + @Test + void ac2TextContains() throws Exception { + seedUser("contains_target", "it3_creator", "普通用户"); + seedUser("other_one", "it3_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "用户名").param("matchType", "包含") + .param("queryValue", "contains_target")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "contains_target')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "other_one')]").doesNotExist()); + } + + @Test + void ac3TextEquals() throws Exception { + seedUser("eq", "it3_creator", "普通用户"); + seedUser("eq_suffix", "it3_creator", "普通用户"); + String exact = USER_PREFIX + "eq"; + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "用户名").param("matchType", "等于") + .param("queryValue", exact)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].sUserName").value(exact)); + } + + @Test + void ac4TextNotContains() throws Exception { + seedUser("nc1", "it3_creator_keep", "普通用户"); + seedUser("nc2", "it3_creator_drop", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "制单人").param("matchType", "不包含") + .param("queryValue", "drop").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc1')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "nc2')]").doesNotExist()); + } + + @Test + void ac5EnumEquals() throws Exception { + seedUser("admin_u", "it3_creator", "超级管理员"); + seedUser("normal_u", "it3_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "用户类型").param("matchType", "等于") + .param("queryValue", "超级管理员").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "admin_u')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "normal_u')]").doesNotExist()); + } + + @Test + void ac6BoolFilter() throws Exception { + UsrUser voided = seedUser("voided", "it3_creator", "普通用户"); + voided.setIIsVoid(1); + usrUserMapper.updateById(voided); + seedUser("active", "it3_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "作废").param("queryValue", "1").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").doesNotExist()); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "作废").param("queryValue", "0").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "active')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "voided')]").doesNotExist()); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "作废").param("queryValue", "abc")) + .andExpect(jsonPath("$.code").value(40001)); + } + + @Test + void ac7DateFilter() throws Exception { + UsrUser dated = seedUser("dated", "it3_creator", "普通用户"); + dated.setTLastLoginDate(LocalDateTime.of(2026, 6, 1, 12, 0, 0)); + usrUserMapper.updateById(dated); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "登录日期").param("matchType", "等于") + .param("queryValue", "2026-06-01").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "dated')]").exists()); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "登录日期").param("matchType", "等于") + .param("queryValue", "2026-13-99")) + .andExpect(jsonPath("$.code").value(40001)); + } + + @Test + void ac8CrossTableEmployeeDept() throws Exception { + Integer empId = seedEmployee("fin", "财务部IT3"); + UsrUser linked = seedUser("linked", "it3_creator", "普通用户"); + linked.setIEmployeeId(empId); + usrUserMapper.updateById(linked); + seedUser("unlinked", "it3_creator", "普通用户"); + + // 全量:关联用户带员工名 / 部门,未关联用户两列为 null。 + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + + "linked')].department").value(org.hamcrest.Matchers.hasItem("财务部IT3"))) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + + "linked')].employeeName").value(org.hamcrest.Matchers.hasItem(EMP_NAME_PREFIX + "fin"))); + + // 按部门跨表过滤:仅返回所属部门含「财务」的用户。 + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "部门").param("matchType", "包含") + .param("queryValue", "财务").param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "linked')]").exists()) + .andExpect(jsonPath("$.data.records[?(@.sUserName=='" + USER_PREFIX + "unlinked')]").doesNotExist()); + } + + @Test + void ac9NoMatchEmptyList() throws Exception { + seedUser("present", "it3_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "用户名").param("matchType", "等于") + .param("queryValue", "it3_user_zzz_no_such_user_xyz")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.data.records").isEmpty()); + } + + @Test + void ac10DataOutOfRangeLastPage() throws Exception { + // 插 3 条同制单人 fixture,按制单人精确过滤 → total=3,pageSize=2 → 2 页;请求 page99 钳到末页 2。 + seedUser("clamp1", "it3_clamp_creator", "普通用户"); + seedUser("clamp2", "it3_clamp_creator", "普通用户"); + seedUser("clamp3", "it3_clamp_creator", "普通用户"); + + mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("queryField", "制单人").param("matchType", "等于") + .param("queryValue", "it3_clamp_creator") + .param("pageNum", "99").param("pageSize", "2")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.pageNum").value(2)) + .andExpect(jsonPath("$.data.records.length()").value(1)); + } + + @Test + void ac11PageParamInvalid() throws Exception { + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageNum", "0")) + .andExpect(jsonPath("$.code").value(42201)); + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "0")) + .andExpect(jsonPath("$.code").value(42201)); + mockMvc.perform(get("/api/usr/users").header("Authorization", token()).param("pageSize", "500")) + .andExpect(jsonPath("$.code").value(42201)); + } + + @Test + void ac12NoToken() throws Exception { + mockMvc.perform(get("/api/usr/users")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get("/api/usr/users").header("Authorization", "Bearer invalid.token.value")) + .andExpect(status().isUnauthorized()); + } + + @Test + void ac13PasswordNeverLeaks() throws Exception { + seedUser("leakcheck", "it3_creator", "普通用户"); + String body = mockMvc.perform(get("/api/usr/users").header("Authorization", token()) + .param("pageSize", "100")) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("sPassword"); + assertThat(body.toLowerCase()).doesNotContain("password"); + assertThat(body).doesNotContain("$2a$"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java new file mode 100644 index 0000000..3c3ee1a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java @@ -0,0 +1,304 @@ +package com.xly.erp.modules.usr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.entity.UsrPermission; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * REQ-USR-002 T5:修改用户端到端验收回归(spec § 7)。 + * + *

@SpringBootTest + 真实 MockMvc 安全链 + test profile 连测试库(Flyway 已 apply V1)。 + * 认证态通过真实 JwtUtil 签发 token 走 JwtAuthenticationFilter 注入;真实库读写做端到端确认。 + * 每个用例自管理 fixture 清理(命名前缀 {@code it2_user_} / {@code IT2_PERM_})。

+ * + *

边界说明(spec § 7):AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / + * REQ-USR-003 查询接口,尚未实现;本 IT 以「PUT iIsVoid=1 / 改 sUserType 后 selectById + * 读回库内 iIsVoid==1 / sUserType 为新值」做后端落库层等价验证,登录 / 查询联动留待对应 REQ + * 的 IT 覆盖。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UsrUserUpdateIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrUserPermissionMapper usrUserPermissionMapper; + + @Autowired + private UsrPermissionMapper usrPermissionMapper; + + private static final String CALLER = "it2_admin"; + + @AfterEach + void cleanup() { + usrUserMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrUser::getSUserName, "it2_user_")); + usrPermissionMapper.delete(Wrappers.lambdaQuery() + .likeRight(UsrPermission::getSPermissionCode, "IT2_PERM_")); + } + + private String adminToken() { + return "Bearer " + jwtUtil.generateToken(CALLER, "超级管理员"); + } + + private String normalToken() { + return "Bearer " + jwtUtil.generateToken("it2_normal", "普通用户"); + } + + private Map createBody(String userName) { + Map body = new HashMap<>(); + body.put("sUserName", userName); + body.put("sLanguage", "中文"); + return body; + } + + private int createUser(String userName) throws Exception { + MvcResult result = mockMvc.perform(post("/api/usr/users") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(createBody(userName)))) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + Map resp = objectMapper.readValue(result.getResponse().getContentAsString(), Map.class); + return (Integer) ((Map) resp.get("data")).get("id"); + } + + private int insertPermissionFixture(String codeSuffix) { + UsrPermission perm = new UsrPermission(); + perm.setSPermissionName("IT2权限" + codeSuffix); + perm.setSPermissionCode("IT2_PERM_" + codeSuffix); + usrPermissionMapper.insert(perm); + return perm.getIIncrement(); + } + + // ---------------- AC1:基本信息修改落库,且身份 / 密码 / 审计列不变 ---------------- + + @Test + void ac1UpdateBasicInfoPersists() throws Exception { + int id = createUser("it2_user_ac1"); + UsrUser before = usrUserMapper.selectById(id); + + Map body = new HashMap<>(); + body.put("sUserType", "超级管理员"); + body.put("sLanguage", "英文"); + body.put("iCanModifyBill", 1); + body.put("sUserNo", "NO-AC1"); + + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(id)); + + UsrUser after = usrUserMapper.selectById(id); + assertThat(after.getSUserType()).isEqualTo("超级管理员"); + assertThat(after.getSLanguage()).isEqualTo("英文"); + assertThat(after.getICanModifyBill()).isEqualTo(1); + assertThat(after.getSUserNo()).isEqualTo("NO-AC1"); + // 身份 / 密码 / 审计列字节级不变。 + assertThat(after.getSUserName()).isEqualTo(before.getSUserName()); + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); + assertThat(after.getSCreator()).isEqualTo(before.getSCreator()); + assertThat(after.getTCreateDate()).isEqualTo(before.getTCreateDate()); + } + + // ---------------- AC2:目标用户不存在 → 40401,无写入 ---------------- + + @Test + void ac2UpdateNonExistentReturns40401() throws Exception { + Map body = new HashMap<>(); + body.put("sUserType", "普通用户"); + body.put("sLanguage", "中文"); + + mockMvc.perform(put("/api/usr/users/2000000001") + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40401)); + + assertThat(usrUserMapper.selectById(2000000001)).isNull(); + } + + // ---------------- AC3:非法参数(不存在职员)→ 40001,整体回滚无副作用 ---------------- + + @Test + void ac3InvalidParamRollsBack() throws Exception { + int id = createUser("it2_user_ac3"); + UsrUser before = usrUserMapper.selectById(id); + + Map body = new HashMap<>(); + body.put("sUserType", "超级管理员"); + body.put("sLanguage", "英文"); + body.put("iEmployeeId", 2000000002); // 不存在的职员 + + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + + UsrUser after = usrUserMapper.selectById(id); + // 目标用户行各列与调用前一致(无副作用)。 + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); + assertThat(after.getIEmployeeId()).isEqualTo(before.getIEmployeeId()); + } + + // ---------------- AC6:权限组全量覆盖 / 清空 / 不改 ---------------- + + @Test + void ac6PermissionOverwrite() throws Exception { + int id = createUser("it2_user_ac6"); + int permA = insertPermissionFixture("A"); + int permB = insertPermissionFixture("B"); + int permC = insertPermissionFixture("C"); + // 预置该用户授权为 {a, c} + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); + usrUserPermissionMapper.insert(new UsrUserPermission(id, permC)); + + Map body = new HashMap<>(); + body.put("sUserType", "普通用户"); + body.put("sLanguage", "中文"); + body.put("permissionIds", List.of(permA, permB, permA)); // 去重后 {a, b} + + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(jsonPath("$.code").value(0)); + + List grants = usrUserPermissionMapper.selectList( + Wrappers.lambdaQuery().eq(UsrUserPermission::getIUserId, id)); + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) + .containsExactlyInAnyOrder(permA, permB); // c 被删、b 新增、a 去重一次 + + // 传 [] → 清空全部授权 + Map clearBody = new HashMap<>(); + clearBody.put("sUserType", "普通用户"); + clearBody.put("sLanguage", "中文"); + clearBody.put("permissionIds", List.of()); + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(clearBody))) + .andExpect(jsonPath("$.code").value(0)); + long afterClear = usrUserPermissionMapper.selectCount( + Wrappers.lambdaQuery().eq(UsrUserPermission::getIUserId, id)); + assertThat(afterClear).isZero(); + + // 预置一条授权后,不传 permissionIds → 授权不变 + usrUserPermissionMapper.insert(new UsrUserPermission(id, permA)); + Map noPermBody = new HashMap<>(); + noPermBody.put("sUserType", "普通用户"); + noPermBody.put("sLanguage", "中文"); + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(noPermBody))) + .andExpect(jsonPath("$.code").value(0)); + long afterNoChange = usrUserPermissionMapper.selectCount( + Wrappers.lambdaQuery().eq(UsrUserPermission::getIUserId, id)); + assertThat(afterNoChange).isEqualTo(1L); + } + + // ---------------- AC7:非管理员 / 无 token 被拦截,目标行未被修改 ---------------- + + @Test + void ac7NonAdminAndNoTokenBlocked() throws Exception { + int id = createUser("it2_user_ac7"); + UsrUser before = usrUserMapper.selectById(id); + + Map body = new HashMap<>(); + body.put("sUserType", "超级管理员"); + body.put("sLanguage", "英文"); + + // 普通用户 token → 40301 + mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", normalToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(jsonPath("$.code").value(40301)); + + // 无 token → 401(安全链拦截) + mockMvc.perform(put("/api/usr/users/" + id) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isUnauthorized()); + + UsrUser after = usrUserMapper.selectById(id); + assertThat(after.getSUserType()).isEqualTo(before.getSUserType()); + assertThat(after.getSLanguage()).isEqualTo(before.getSLanguage()); + } + + // ---------------- AC8:密码不变且不出现在响应;iIsVoid 落库(AC4/AC5 落库层等价验证) ---------------- + + @Test + void ac8PasswordUnchangedAndAbsentFromResponse() throws Exception { + int id = createUser("it2_user_ac8"); + UsrUser before = usrUserMapper.selectById(id); + + Map body = new HashMap<>(); + body.put("sUserType", "超级管理员"); // AC5 角色变更落库层等价验证 + body.put("sLanguage", "中文"); + body.put("iIsVoid", 1); // AC4 禁用落库层等价验证 + + MvcResult result = mockMvc.perform(put("/api/usr/users/" + id) + .header("Authorization", adminToken()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(body))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(id)) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertThat(responseBody.toLowerCase()).doesNotContain("password"); + + UsrUser after = usrUserMapper.selectById(id); + // 密码列字节级不变。 + assertThat(after.getSPassword()).isEqualTo(before.getSPassword()); + // AC4 / AC5 落库层等价验证:禁用状态 / 角色变更读回库内为新值。 + assertThat(after.getIIsVoid()).isEqualTo(1); + assertThat(after.getSUserType()).isEqualTo("超级管理员"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java new file mode 100644 index 0000000..6910b9b --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java @@ -0,0 +1,144 @@ +package com.xly.erp.modules.usr.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.exception.GlobalExceptionHandler; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.service.UsrAuthService; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * REQ-USR-004 T5:UsrAuthController(MockMvc standaloneSetup + 真实 GlobalExceptionHandler)。 + * + *

Service 用 Mockito mock;验证登录成功 code=0 + token + 嵌套 user、缺字段 40001、 + * 业务异常透传转码(40101/40302/42901)、公司列表 code=0。响应体不含密码。

+ */ +class UsrAuthControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UsrAuthService usrAuthService = Mockito.mock(UsrAuthService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + UsrAuthController controller = new UsrAuthController(usrAuthService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + private String validBody() throws Exception { + LoginDTO dto = new LoginDTO(); + dto.setSUserName("admin"); + dto.setPassword("666666"); + dto.setCompanyId(1); + return objectMapper.writeValueAsString(dto); + } + + private LoginVO sampleVO() { + LoginVO.UserInfo info = new LoginVO.UserInfo(); + info.setId(1); + info.setSUserName("admin"); + info.setSUserType("超级管理员"); + info.setSLanguage("中文"); + LoginVO vo = new LoginVO(); + vo.setToken("jwt.token.value"); + vo.setUser(info); + return vo; + } + + @Test + void loginReturnsCodeZeroWithTokenAndUser() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))).thenReturn(sampleVO()); + + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.token").value("jwt.token.value")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andExpect(jsonPath("$.data.user.sUserName").value("admin")) + .andExpect(jsonPath("$.data.user.sUserType").value("超级管理员")) + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()); + } + + @Test + void loginMissingFieldReturns40001() throws Exception { + // 缺 companyId(@NotNull 失败) + String body = "{\"sUserName\":\"admin\",\"password\":\"666666\"}"; + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + verify(usrAuthService, never()).login(any(LoginDTO.class)); + } + + @Test + void loginAuthFailReturns40101() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void loginDisabledReturns40302() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.ACCOUNT_DISABLED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40302)); + } + + @Test + void loginRateLimitedReturns42901() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.LOGIN_RATE_LIMITED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(42901)); + } + + @Test + void companiesReturnsCodeZeroList() throws Exception { + CompanyOptionVO vo = new CompanyOptionVO(); + vo.setId(3); + vo.setSCompanyName("总部"); + vo.setSVersion("企业版"); + when(usrAuthService.listCompanies()).thenReturn(List.of(vo)); + + mockMvc.perform(get("/api/usr/companies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].id").value(3)) + .andExpect(jsonPath("$.data[0].sCompanyName").value("总部")); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java new file mode 100644 index 0000000..1e8693d --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java @@ -0,0 +1,233 @@ +package com.xly.erp.modules.usr.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.exception.GlobalExceptionHandler; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.SecurityUtil; +import com.xly.erp.modules.usr.dto.CreateUserDTO; +import com.xly.erp.modules.usr.dto.UpdateUserDTO; +import com.xly.erp.modules.usr.dto.UserQueryDTO; +import com.xly.erp.modules.usr.service.UsrUserService; +import com.xly.erp.modules.usr.vo.UserVO; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * REQ-USR-001 T7:UsrUserController 与管理员权限前置。 + * + *

采用 MockMvc standaloneSetup 做 Controller 层单元测试(不加载 Spring 上下文, + * 避免 @MapperScan / Security 全量装配的切片摩擦),挂上真实 {@link GlobalExceptionHandler} + * 以验证 @Valid 失败转 40001、BusinessException 转 40301;UsrUserService 用 Mockito mock, + * 管理员判定通过 mock SecurityUtil 静态方法注入。

+ */ +class UsrUserControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UsrUserService usrUserService = Mockito.mock(UsrUserService.class); + private MockMvc mockMvc; + private MockedStatic securityUtilMock; + + @BeforeEach + void setUp() { + UsrUserController controller = new UsrUserController(usrUserService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + securityUtilMock = Mockito.mockStatic(SecurityUtil.class); + } + + @AfterEach + void tearDown() { + securityUtilMock.close(); + } + + private String validBody() throws Exception { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("good_user"); + dto.setSLanguage("中文"); + return objectMapper.writeValueAsString(dto); + } + + @Test + void adminCreateReturnsCodeZeroWithId() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); + when(usrUserService.createUser(any(CreateUserDTO.class))).thenReturn(101); + + mockMvc.perform(post("/api/usr/users") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(101)) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()); + } + + @Test + void nonAdminReturns40301() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); + + mockMvc.perform(post("/api/usr/users") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40301)); + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); + } + + @Test + void invalidBodyReturns40001() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("ab"); + dto.setSLanguage("中文"); + String body = objectMapper.writeValueAsString(dto); + + mockMvc.perform(post("/api/usr/users") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + verify(usrUserService, never()).createUser(any(CreateUserDTO.class)); + } + + // ---------------- REQ-USR-002 T4:修改用户 Controller + 管理员前置 ---------------- + + private String validUpdateBody() throws Exception { + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSUserType("普通用户"); + dto.setSLanguage("中文"); + return objectMapper.writeValueAsString(dto); + } + + @Test + void adminUpdateReturnsCodeZeroWithId() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); + when(usrUserService.updateUser(eq(55), any(UpdateUserDTO.class))).thenReturn(55); + + mockMvc.perform(put("/api/usr/users/55") + .contentType("application/json") + .content(validUpdateBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(55)) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()); + } + + @Test + void nonAdminUpdateReturns40301() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); + + mockMvc.perform(put("/api/usr/users/55") + .contentType("application/json") + .content(validUpdateBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40301)); + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); + } + + @Test + void invalidBodyUpdateReturns40001() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSLanguage("中文"); // 缺 sUserType → @NotBlank 失败 + String body = objectMapper.writeValueAsString(dto); + + mockMvc.perform(put("/api/usr/users/55") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + verify(usrUserService, never()).updateUser(any(), any(UpdateUserDTO.class)); + } + + @Test + void userNotFoundReturns40401() throws Exception { + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("超级管理员"); + when(usrUserService.updateUser(eq(404), any(UpdateUserDTO.class))) + .thenThrow(new BusinessException(ResultCode.NOT_FOUND)); + + mockMvc.perform(put("/api/usr/users/404") + .contentType("application/json") + .content(validUpdateBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40401)); + } + + // ---------------- REQ-USR-003 T5:查询用户 Controller GET /api/usr/users(无管理员前置)---------------- + + private PageResult onePageResult() { + UserVO vo = new UserVO(); + vo.setId(1); + vo.setSUserName("alice"); + vo.setSUserType("普通用户"); + vo.setSLanguage("中文"); + vo.setIIsVoid(0); + return PageResult.of(List.of(vo), 1L, 1L, 10L); + } + + @Test + void queryReturnsCodeZeroWithPageResult() throws Exception { + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); + + mockMvc.perform(get("/api/usr/users").param("pageNum", "1").param("pageSize", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.pageNum").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(10)) + .andExpect(jsonPath("$.data.records[0].sUserName").value("alice")) + .andExpect(jsonPath("$.data.records[0].sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()); + } + + @Test + void queryAllowsNonAdmin() throws Exception { + // 无管理员前置(spec § 8 D5):普通用户即可调用,Service 被调用。 + securityUtilMock.when(SecurityUtil::currentUserType).thenReturn("普通用户"); + when(usrUserService.queryUsers(any(UserQueryDTO.class))).thenReturn(onePageResult()); + + mockMvc.perform(get("/api/usr/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + verify(usrUserService).queryUsers(any(UserQueryDTO.class)); + } + + @Test + void queryIllegalEnumReturns40001() throws Exception { + mockMvc.perform(get("/api/usr/users").param("queryField", "身份证")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + verify(usrUserService, never()).queryUsers(any(UserQueryDTO.class)); + } + + @Test + void queryPageParamInvalidReturns42201() throws Exception { + when(usrUserService.queryUsers(any(UserQueryDTO.class))) + .thenThrow(new BusinessException(ResultCode.PAGE_PARAM_INVALID)); + + mockMvc.perform(get("/api/usr/users").param("pageNum", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(42201)); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java b/backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java new file mode 100644 index 0000000..bba1c66 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java @@ -0,0 +1,63 @@ +package com.xly.erp.modules.usr.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import jakarta.validation.ConstraintViolation; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T4:CreateUserDTO Bean Validation 校验。 + */ +class CreateUserDTOValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void setUp() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void tearDown() { + if (factory != null) { + factory.close(); + } + } + + @Test + void rejectsBadUserName() { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("ab"); + dto.setSLanguage("中文"); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("sUserName")); + } + + @Test + void rejectsIllegalLanguage() { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("good_user"); + dto.setSLanguage("日文"); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("sLanguage")); + } + + @Test + void acceptsMinimalValidBody() { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("good_user"); + dto.setSLanguage("中文"); + Set> violations = validator.validate(dto); + assertThat(violations).isEmpty(); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java b/backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java new file mode 100644 index 0000000..2fd40fc --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/dto/LoginDTOValidationTest.java @@ -0,0 +1,92 @@ +package com.xly.erp.modules.usr.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-004 T2:LoginDTO Bean Validation 与 JSON 反序列化键名。 + * + *

校验 {@code sUserName}/{@code password} 必填、长度上限,{@code companyId} 必填; + * 并验证 {@code @JsonProperty("sUserName")} 锁定反序列化键名(与 CreateUserDTO/UserVO 同做法)。

+ */ +class LoginDTOValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeAll + static void initValidator() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void closeValidator() { + factory.close(); + } + + private LoginDTO valid() { + LoginDTO dto = new LoginDTO(); + dto.setSUserName("admin"); + dto.setPassword("666666"); + dto.setCompanyId(1); + return dto; + } + + @Test + void acceptsValidLogin() { + Set> violations = validator.validate(valid()); + assertThat(violations).isEmpty(); + } + + @Test + void rejectsBlankUserName() { + LoginDTO dto = valid(); + dto.setSUserName(""); + assertThat(validator.validate(dto)).isNotEmpty(); + dto.setSUserName(null); + assertThat(validator.validate(dto)).isNotEmpty(); + } + + @Test + void rejectsBlankPassword() { + LoginDTO dto = valid(); + dto.setPassword(""); + assertThat(validator.validate(dto)).isNotEmpty(); + dto.setPassword(null); + assertThat(validator.validate(dto)).isNotEmpty(); + } + + @Test + void rejectsNullCompanyId() { + LoginDTO dto = valid(); + dto.setCompanyId(null); + assertThat(validator.validate(dto)).isNotEmpty(); + } + + @Test + void rejectsTooLongUserName() { + LoginDTO dto = valid(); + dto.setSUserName("a".repeat(51)); + assertThat(validator.validate(dto)).isNotEmpty(); + } + + @Test + void deserializesSUserNameJsonKey() throws Exception { + LoginDTO dto = objectMapper.readValue( + "{\"sUserName\":\"admin\",\"password\":\"x\",\"companyId\":1}", LoginDTO.class); + assertThat(dto.getSUserName()).isEqualTo("admin"); + assertThat(dto.getPassword()).isEqualTo("x"); + assertThat(dto.getCompanyId()).isEqualTo(1); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java b/backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java new file mode 100644 index 0000000..d042c45 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java @@ -0,0 +1,85 @@ +package com.xly.erp.modules.usr.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-002 T1:UpdateUserDTO Bean Validation 校验。 + * + *

用 {@link Validator} 直接 validate DTO,断言 violations。

+ */ +class UpdateUserDTOValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void setUp() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void tearDown() { + if (factory != null) { + factory.close(); + } + } + + private UpdateUserDTO minimalValid() { + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSUserType("普通用户"); + dto.setSLanguage("中文"); + return dto; + } + + @Test + void acceptsMinimalValidBody() { + Set> violations = validator.validate(minimalValid()); + assertThat(violations).isEmpty(); + } + + @Test + void rejectsBlankUserType() { + UpdateUserDTO dto = minimalValid(); + dto.setSUserType(" "); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("sUserType")); + } + + @Test + void rejectsIllegalLanguage() { + UpdateUserDTO dto = minimalValid(); + dto.setSLanguage("日文"); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("sLanguage")); + } + + @Test + void rejectsOutOfRangeIsVoid() { + UpdateUserDTO dto = minimalValid(); + dto.setIIsVoid(2); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("iIsVoid")); + } + + @Test + void rejectsOutOfRangeCanModifyBill() { + UpdateUserDTO dto = minimalValid(); + dto.setICanModifyBill(2); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("iCanModifyBill")); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java b/backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java new file mode 100644 index 0000000..e5b58ce --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java @@ -0,0 +1,81 @@ +package com.xly.erp.modules.usr.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-003 T2:UserQueryDTO Bean Validation 校验。 + * + *

queryField/matchType 走 @Pattern(null 跳过,默认值 Service 兜底);queryValue @Size(max=100); + * pageNum/pageSize 不加 @Min/@Max(范围在 Service 入口判定 42201,spec § 8 D8)。

+ */ +class UserQueryDTOValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + @BeforeAll + static void setUp() { + factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterAll + static void tearDown() { + if (factory != null) { + factory.close(); + } + } + + @Test + void acceptsAllNullAsValid() { + UserQueryDTO dto = new UserQueryDTO(); + Set> violations = validator.validate(dto); + assertThat(violations).isEmpty(); + } + + @Test + void acceptsLegalEnums() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setQueryField("登录日期"); + dto.setMatchType("不包含"); + dto.setQueryValue("2026-06-01"); + Set> violations = validator.validate(dto); + assertThat(violations).isEmpty(); + } + + @Test + void rejectsIllegalQueryField() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setQueryField("身份证"); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("queryField")); + } + + @Test + void rejectsIllegalMatchType() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setMatchType("大于"); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("matchType")); + } + + @Test + void rejectsTooLongQueryValue() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setQueryValue("x".repeat(101)); + Set> violations = validator.validate(dto); + assertThat(violations) + .anyMatch(v -> v.getPropertyPath().toString().equals("queryValue")); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java b/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java new file mode 100644 index 0000000..1940ecd --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrCompanyMapperTest.java @@ -0,0 +1,52 @@ +package com.xly.erp.modules.usr.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.xly.erp.modules.usr.entity.UsrCompany; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * REQ-USR-004 T3:UsrCompanyMapper(BaseMapper)连库验证实体 ↔ usr_company 列映射。 + * + *

@SpringBootTest 连测试库(Flyway 已 apply V1),@Transactional 测试回滚避免污染库。 + * 插 1 条公司 fixture(名前缀 IT4_CO_),验证 selectById / selectList 取回字段正确。

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class UsrCompanyMapperTest { + + @Autowired + private UsrCompanyMapper usrCompanyMapper; + + private UsrCompany insertFixture() { + UsrCompany c = new UsrCompany(); + c.setSCompanyName("IT4_CO_总部"); + c.setSVersion("企业版"); + usrCompanyMapper.insert(c); + return c; + } + + @Test + void selectByIdReturnsCompany() { + UsrCompany inserted = insertFixture(); + assertThat(inserted.getIIncrement()).isNotNull(); + + UsrCompany found = usrCompanyMapper.selectById(inserted.getIIncrement()); + assertThat(found).isNotNull(); + assertThat(found.getSCompanyName()).isEqualTo("IT4_CO_总部"); + assertThat(found.getSVersion()).isEqualTo("企业版"); + assertThat(found.getIIncrement()).isEqualTo(inserted.getIIncrement()); + } + + @Test + void selectListReturnsAll() { + UsrCompany inserted = insertFixture(); + assertThat(usrCompanyMapper.selectList(null)) + .anyMatch(c -> inserted.getIIncrement().equals(c.getIIncrement())); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java b/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java new file mode 100644 index 0000000..d95690a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java @@ -0,0 +1,112 @@ +package com.xly.erp.modules.usr.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.modules.usr.dto.UserQueryCondition; +import com.xly.erp.modules.usr.entity.UsrEmployee; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.vo.UserVO; +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.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * REQ-USR-003 T3:UsrUserMapper.selectUserPage 跨表分页查询(LEFT JOIN + 动态条件 XML)。 + * + *

@SpringBootTest 连测试库(Flyway 已 apply V1),@Transactional 测试回滚避免污染库。 + * 插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 usr_employee; + * 验证 LEFT JOIN 列映射、文本 LIKE 过滤、不返回密码。

+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class UsrUserMapperPageTest { + + private static final String EMP_NAME = "T3职员_财务"; + private static final String EMP_DEPT = "财务部T3"; + private static final String USER_LINKED = "t3_user_linked"; + private static final String USER_UNLINKED = "t3_user_unlinked"; + + @Autowired + private UsrUserMapper usrUserMapper; + + @Autowired + private UsrEmployeeMapper usrEmployeeMapper; + + private Integer empId; + + @BeforeEach + void seed() { + UsrEmployee emp = new UsrEmployee(); + emp.setSEmployeeName(EMP_NAME); + emp.setSEmployeeNo("T3_EMP_001"); + emp.setSDepartment(EMP_DEPT); + usrEmployeeMapper.insert(emp); + empId = emp.getIIncrement(); + + UsrUser linked = newUser(USER_LINKED); + linked.setIEmployeeId(empId); + usrUserMapper.insert(linked); + + UsrUser unlinked = newUser(USER_UNLINKED); + unlinked.setIEmployeeId(null); + usrUserMapper.insert(unlinked); + } + + @AfterEach + void cleanup() { + // @Transactional 已回滚;保留显式说明,无需额外清理。 + } + + private UsrUser newUser(String name) { + UsrUser u = new UsrUser(); + u.setSUserName(name); + u.setSPassword("$2a$dummyhash"); + u.setSUserType("普通用户"); + u.setSLanguage("中文"); + u.setICanModifyBill(0); + u.setIIsVoid(0); + u.setSCreator("t3_seed"); + return u; + } + + @Test + void pageReturnsLeftJoinedEmployeeColumns() { + IPage page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none()); + + assertThat(page.getTotal()).isGreaterThanOrEqualTo(2L); + UserVO linked = page.getRecords().stream() + .filter(v -> USER_LINKED.equals(v.getSUserName())).findFirst().orElseThrow(); + assertThat(linked.getEmployeeName()).isEqualTo(EMP_NAME); + assertThat(linked.getDepartment()).isEqualTo(EMP_DEPT); + + UserVO unlinked = page.getRecords().stream() + .filter(v -> USER_UNLINKED.equals(v.getSUserName())).findFirst().orElseThrow(); + assertThat(unlinked.getEmployeeName()).isNull(); + assertThat(unlinked.getDepartment()).isNull(); + } + + @Test + void pageAppliesTextLikeOnUserName() { + UserQueryCondition cond = UserQueryCondition.text("u.sUserName", "包含", "t3_user_linked"); + IPage page = usrUserMapper.selectUserPage(new Page<>(1, 10), cond); + + assertThat(page.getRecords()).extracting(UserVO::getSUserName).contains(USER_LINKED); + assertThat(page.getRecords()).noneMatch(v -> USER_UNLINKED.equals(v.getSUserName())); + assertThat(page.getTotal()).isEqualTo(1L); + } + + @Test + void pageNeverSelectsPassword() { + IPage page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none()); + assertThat(page.getRecords()).isNotEmpty(); + // UserVO 无密码属性 → 天然不含;额外确认元素类型与查询不抛错。 + assertThat(page.getRecords().get(0)).isInstanceOf(UserVO.class); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java new file mode 100644 index 0000000..c872a18 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java @@ -0,0 +1,261 @@ +package com.xly.erp.modules.usr.service; + +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.service.impl.UsrAuthServiceImpl; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +/** + * REQ-USR-004 T4:UsrAuthServiceImpl 认证核心(纯单元,Mockito)。 + * + *

桩 UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;限流阈值经测试构造器注入 + * maxFail=3、lockSeconds=300,便于断言锁定行为。覆盖认证判定顺序、防枚举同码同文案、 + * 限流锁定与成功清零、公司列表映射。

+ */ +class UsrAuthServiceImplTest { + + private UsrUserMapper usrUserMapper; + private UsrCompanyMapper usrCompanyMapper; + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; + private JwtUtil jwtUtil; + private UsrAuthServiceImpl service; + + private static final int MAX_FAIL = 3; + + @BeforeEach + void setUp() { + usrUserMapper = Mockito.mock(UsrUserMapper.class); + usrCompanyMapper = Mockito.mock(UsrCompanyMapper.class); + passwordEncoder = Mockito.mock( + org.springframework.security.crypto.password.PasswordEncoder.class); + jwtUtil = Mockito.mock(JwtUtil.class); + service = new UsrAuthServiceImpl(usrUserMapper, usrCompanyMapper, passwordEncoder, jwtUtil, + MAX_FAIL, 300); + } + + private LoginDTO dto(String userName) { + LoginDTO d = new LoginDTO(); + d.setSUserName(userName); + d.setPassword("666666"); + d.setCompanyId(1); + return d; + } + + private UsrUser activeUser(String userName) { + UsrUser u = new UsrUser(); + u.setIIncrement(42); + u.setSUserName(userName); + u.setSPassword("$2a$hash"); + u.setSUserType("超级管理员"); + u.setSLanguage("中文"); + u.setIIsVoid(0); + return u; + } + + private void stubUserFound(String userName) { + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(userName)); + } + + @Test + void successReturnsTokenAndUpdatesLoginTime() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); + when(jwtUtil.generateToken("admin", "超级管理员")).thenReturn("jwt.token"); + + LoginVO vo = service.login(dto("admin")); + + assertThat(vo.getToken()).isEqualTo("jwt.token"); + assertThat(vo.getUser().getId()).isEqualTo(42); + assertThat(vo.getUser().getSUserName()).isEqualTo("admin"); + assertThat(vo.getUser().getSUserType()).isEqualTo("超级管理员"); + assertThat(vo.getUser().getSLanguage()).isEqualTo("中文"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class); + verify(usrUserMapper, times(1)).updateById(captor.capture()); + UsrUser updated = captor.getValue(); + assertThat(updated.getTLastLoginDate()).isNotNull(); + assertThat(updated.getIIncrement()).isEqualTo(42); + // 仅更新主键 + tLastLoginDate,未覆写其它列(用户名/类型/语言/密码留 null)。 + assertThat(updated.getSUserName()).isNull(); + assertThat(updated.getSPassword()).isNull(); + } + + @Test + void userNotFoundThrows40101() { + when(usrUserMapper.selectOne(any())).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto("ghost"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED); + + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void wrongPasswordThrows40101() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED); + + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void notFoundAndWrongPasswordSameCodeAndMessage() { + // 用户不存在路径 + when(usrUserMapper.selectOne(any())).thenReturn(null); + BusinessException notFound = + (BusinessException) catchEx(() -> service.login(dto("ghost"))); + + // 密码错误路径 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser("admin")); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + BusinessException wrongPwd = + (BusinessException) catchEx(() -> service.login(dto("admin"))); + + assertThat(notFound.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + assertThat(wrongPwd.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + assertThat(notFound.getMessage()).isEqualTo(wrongPwd.getMessage()); + } + + @Test + void disabledUserThrows40302AfterPasswordOk() { + UsrUser disabled = activeUser("admin"); + disabled.setIIsVoid(1); + when(usrUserMapper.selectOne(any())).thenReturn(disabled); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.ACCOUNT_DISABLED); + + // 先验密码再判禁用:matches 被调用过。 + verify(passwordEncoder, atLeastOnce()).matches(eq("666666"), anyString()); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + } + + @Test + void illegalCompanyIdThrows40001() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + when(usrCompanyMapper.selectById(1)).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + } + + @Test + void rateLimitAfterMaxFailThrows42901() { + String user = "ratelimited"; + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + + for (int i = 0; i < MAX_FAIL; i++) { + catchEx(() -> service.login(dto(user))); + } + Mockito.clearInvocations(usrUserMapper, jwtUtil); + // 锁定后即使密码正确也 42901,且不进入查用户之后的认证逻辑、不签发 token。 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.login(dto(user))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.LOGIN_RATE_LIMITED); + + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void successResetsFailCounter() { + String user = "resetme"; + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); + when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("jwt.token"); + + // 先失败 2 次(< maxFail=3) + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + catchEx(() -> service.login(dto(user))); + catchEx(() -> service.login(dto(user))); + + // 一次成功登录清零 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + service.login(dto(user)); + + // 再连续失败到「成功前次数 + 1」=3 次,不应触发 42901(仍为 40101) + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + for (int i = 0; i < MAX_FAIL; i++) { + BusinessException ex = (BusinessException) catchEx(() -> service.login(dto(user))); + assertThat(ex.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + } + } + + @Test + void listCompaniesMapsAllRows() { + UsrCompany a = new UsrCompany(); + a.setIIncrement(1); + a.setSCompanyName("公司A"); + a.setSVersion("v1"); + UsrCompany b = new UsrCompany(); + b.setIIncrement(2); + b.setSCompanyName("公司B"); + b.setSVersion(null); + when(usrCompanyMapper.selectList(any())).thenReturn(List.of(a, b)); + + List result = service.listCompanies(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getId()).isEqualTo(1); + assertThat(result.get(0).getSCompanyName()).isEqualTo("公司A"); + assertThat(result.get(0).getSVersion()).isEqualTo("v1"); + assertThat(result.get(1).getId()).isEqualTo(2); + assertThat(result.get(1).getSVersion()).isNull(); + } + + private static Throwable catchEx(Runnable r) { + try { + r.run(); + } catch (Throwable t) { + return t; + } + throw new AssertionError("expected exception but none thrown"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java new file mode 100644 index 0000000..4356e1a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java @@ -0,0 +1,532 @@ +package com.xly.erp.modules.usr.service; + +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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.SecurityUtil; +import com.xly.erp.modules.usr.dto.CreateUserDTO; +import com.xly.erp.modules.usr.dto.UpdateUserDTO; +import com.xly.erp.modules.usr.dto.UserQueryCondition; +import com.xly.erp.modules.usr.dto.UserQueryDTO; +import com.xly.erp.modules.usr.vo.UserVO; +import java.time.LocalDateTime; +import com.xly.erp.modules.usr.entity.UsrEmployee; +import com.xly.erp.modules.usr.entity.UsrPermission; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.entity.UsrUserPermission; +import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper; +import com.xly.erp.modules.usr.mapper.UsrPermissionMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper; +import com.xly.erp.modules.usr.service.impl.UsrUserServiceImpl; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * REQ-USR-001 T5 / T6:新增用户 Service 单元测试(Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态)。 + */ +class UsrUserServiceImplTest { + + private UsrUserMapper usrUserMapper; + private UsrUserPermissionMapper usrUserPermissionMapper; + private UsrEmployeeMapper usrEmployeeMapper; + private UsrPermissionMapper usrPermissionMapper; + private PasswordEncoder passwordEncoder; + private UsrUserServiceImpl service; + private MockedStatic securityUtilMock; + + @BeforeEach + void setUp() { + usrUserMapper = Mockito.mock(UsrUserMapper.class); + usrUserPermissionMapper = Mockito.mock(UsrUserPermissionMapper.class); + usrEmployeeMapper = Mockito.mock(UsrEmployeeMapper.class); + usrPermissionMapper = Mockito.mock(UsrPermissionMapper.class); + passwordEncoder = Mockito.mock(PasswordEncoder.class); + service = new UsrUserServiceImpl(usrUserMapper, usrUserPermissionMapper, + usrEmployeeMapper, usrPermissionMapper, passwordEncoder); + securityUtilMock = Mockito.mockStatic(SecurityUtil.class); + securityUtilMock.when(SecurityUtil::currentUserName).thenReturn("admin"); + } + + @AfterEach + void tearDown() { + securityUtilMock.close(); + } + + private CreateUserDTO minimalDto() { + CreateUserDTO dto = new CreateUserDTO(); + dto.setSUserName("good_user"); + dto.setSLanguage("中文"); + return dto; + } + + @SuppressWarnings("unchecked") + private void stubNoExistingUser() { + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + } + + // ---------------- T5 ---------------- + + @Test + void createUserHashesPasswordAndSetsAuditFields() { + stubNoExistingUser(); + when(passwordEncoder.encode("666666")).thenReturn("$2a$hashed"); + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { + UsrUser u = inv.getArgument(0); + u.setIIncrement(101); + return 1; + }); + + Integer id = service.createUser(minimalDto()); + + assertThat(id).isEqualTo(101); + verify(passwordEncoder).encode("666666"); + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class); + verify(usrUserMapper).insert(captor.capture()); + UsrUser saved = captor.getValue(); + assertThat(saved.getSPassword()).isEqualTo("$2a$hashed"); + assertThat(saved.getSPassword()).isNotEqualTo("666666"); + assertThat(saved.getIIsVoid()).isZero(); + assertThat(saved.getSUserType()).isEqualTo("普通用户"); + assertThat(saved.getSCreator()).isEqualTo("admin"); + assertThat(saved.getTLastLoginDate()).isNull(); + } + + @Test + void duplicateUserNameThrows40901() { + when(usrUserMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + + assertThatThrownBy(() -> service.createUser(minimalDto())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.USERNAME_EXISTS); + verify(usrUserMapper, never()).insert(any(UsrUser.class)); + } + + @Test + void duplicateKeyExceptionTranslatesTo40901() { + stubNoExistingUser(); + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); + when(usrUserMapper.insert(any(UsrUser.class))).thenThrow(new DuplicateKeyException("dup")); + + assertThatThrownBy(() -> service.createUser(minimalDto())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.USERNAME_EXISTS); + } + + // ---------------- T6 ---------------- + + @Test + void nonExistentEmployeeThrows40001() { + stubNoExistingUser(); + when(usrEmployeeMapper.selectById(999)).thenReturn(null); + CreateUserDTO dto = minimalDto(); + dto.setIEmployeeId(999); + + assertThatThrownBy(() -> service.createUser(dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).insert(any(UsrUser.class)); + } + + @Test + void nonExistentPermissionThrows40001() { + stubNoExistingUser(); + when(usrPermissionMapper.selectById(5)).thenReturn(null); + CreateUserDTO dto = minimalDto(); + dto.setPermissionIds(List.of(5)); + + assertThatThrownBy(() -> service.createUser(dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).insert(any(UsrUser.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + @Test + void grantsDedupedPermissions() { + stubNoExistingUser(); + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); + UsrPermission permA = new UsrPermission(); + UsrPermission permB = new UsrPermission(); + when(usrPermissionMapper.selectById(10)).thenReturn(permA); + when(usrPermissionMapper.selectById(20)).thenReturn(permB); + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { + UsrUser u = inv.getArgument(0); + u.setIIncrement(202); + return 1; + }); + CreateUserDTO dto = minimalDto(); + dto.setPermissionIds(List.of(10, 10, 20)); + + Integer id = service.createUser(dto); + + assertThat(id).isEqualTo(202); + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUserPermission.class); + verify(usrUserPermissionMapper, times(2)).insert(captor.capture()); + List grants = captor.getAllValues(); + assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(202); + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) + .containsExactlyInAnyOrder(10, 20); + } + + // 防御:未使用的 employee mock 引用,确保导入有效(占位避免 checkstyle 未用 import)。 + @Test + void employeeMapperWiredForExistenceCheck() { + stubNoExistingUser(); + when(passwordEncoder.encode(any())).thenReturn("$2a$hashed"); + UsrEmployee emp = new UsrEmployee(); + when(usrEmployeeMapper.selectById(eq(7))).thenReturn(emp); + when(usrUserMapper.insert(any(UsrUser.class))).thenAnswer(inv -> { + ((UsrUser) inv.getArgument(0)).setIIncrement(303); + return 1; + }); + CreateUserDTO dto = minimalDto(); + dto.setIEmployeeId(7); + + assertThat(service.createUser(dto)).isEqualTo(303); + verify(usrEmployeeMapper).selectById(7); + } + + // ---------------- REQ-USR-002 T2:目标用户存在性 + 主记录部分更新 ---------------- + + private UpdateUserDTO minimalUpdateDto() { + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSUserType("普通用户"); + dto.setSLanguage("中文"); + return dto; + } + + @Test + void updateNonExistentUserThrows40401() { + when(usrUserMapper.selectById(404)).thenReturn(null); + + assertThatThrownBy(() -> service.updateUser(404, minimalUpdateDto())) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + @Test + void updateAppliesNonNullColumnsAndKeepsIdentityImmutable() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); + UpdateUserDTO dto = new UpdateUserDTO(); + dto.setSUserType("超级管理员"); + dto.setSLanguage("英文"); + dto.setICanModifyBill(1); + dto.setIIsVoid(1); + dto.setSUserNo("N9"); + + Integer returned = service.updateUser(55, dto); + + assertThat(returned).isEqualTo(55); + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class); + verify(usrUserMapper).updateById(captor.capture()); + UsrUser saved = captor.getValue(); + assertThat(saved.getIIncrement()).isEqualTo(55); + assertThat(saved.getSUserType()).isEqualTo("超级管理员"); + assertThat(saved.getSLanguage()).isEqualTo("英文"); + assertThat(saved.getICanModifyBill()).isEqualTo(1); + assertThat(saved.getIIsVoid()).isEqualTo(1); + assertThat(saved.getSUserNo()).isEqualTo("N9"); + // 身份 / 密码 / 审计列不参与 SET(保持 null,依赖 MP null 不更新语义)。 + assertThat(saved.getSUserName()).isNull(); + assertThat(saved.getSPassword()).isNull(); + assertThat(saved.getSCreator()).isNull(); + assertThat(saved.getTCreateDate()).isNull(); + } + + @Test + void nullOptionalColumnsAreNotOverwritten() { + when(usrUserMapper.selectById(77)).thenReturn(new UsrUser()); + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); + + service.updateUser(77, minimalUpdateDto()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class); + verify(usrUserMapper).updateById(captor.capture()); + UsrUser saved = captor.getValue(); + assertThat(saved.getIIncrement()).isEqualTo(77); + assertThat(saved.getSUserType()).isEqualTo("普通用户"); + assertThat(saved.getSLanguage()).isEqualTo("中文"); + // 可选列 DTO 为 null → 实体保持 null,MP 不 SET。 + assertThat(saved.getSUserNo()).isNull(); + assertThat(saved.getIEmployeeId()).isNull(); + assertThat(saved.getICanModifyBill()).isNull(); + assertThat(saved.getIIsVoid()).isNull(); + } + + // ---------------- REQ-USR-002 T3:关联职员存在性 + 权限组全量覆盖 ---------------- + + @Test + @SuppressWarnings("unchecked") + void nonExistentEmployeeOnUpdateThrows40001() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrEmployeeMapper.selectById(999)).thenReturn(null); + UpdateUserDTO dto = minimalUpdateDto(); + dto.setIEmployeeId(999); + + assertThatThrownBy(() -> service.updateUser(55, dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + @Test + @SuppressWarnings("unchecked") + void nonExistentPermissionOnUpdateThrows40001() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrPermissionMapper.selectById(7)).thenReturn(null); + UpdateUserDTO dto = minimalUpdateDto(); + dto.setPermissionIds(List.of(7)); + + assertThatThrownBy(() -> service.updateUser(55, dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + @Test + @SuppressWarnings("unchecked") + void permissionIdsOverwriteDeletesThenInserts() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); + when(usrPermissionMapper.selectById(10)).thenReturn(new UsrPermission()); + when(usrPermissionMapper.selectById(20)).thenReturn(new UsrPermission()); + UpdateUserDTO dto = minimalUpdateDto(); + dto.setPermissionIds(List.of(10, 10, 20)); + + service.updateUser(55, dto); + + verify(usrUserPermissionMapper, times(1)).delete(any(Wrapper.class)); + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUserPermission.class); + verify(usrUserPermissionMapper, times(2)).insert(captor.capture()); + List grants = captor.getAllValues(); + assertThat(grants).extracting(UsrUserPermission::getIUserId).containsOnly(55); + assertThat(grants).extracting(UsrUserPermission::getIPermissionId) + .containsExactlyInAnyOrder(10, 20); + } + + @Test + @SuppressWarnings("unchecked") + void emptyPermissionIdsClearsAll() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); + UpdateUserDTO dto = minimalUpdateDto(); + dto.setPermissionIds(List.of()); + + service.updateUser(55, dto); + + verify(usrUserPermissionMapper, times(1)).delete(any(Wrapper.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + @Test + @SuppressWarnings("unchecked") + void nullPermissionIdsLeavesGrantsUntouched() { + when(usrUserMapper.selectById(55)).thenReturn(new UsrUser()); + when(usrUserMapper.updateById(any(UsrUser.class))).thenReturn(1); + + service.updateUser(55, minimalUpdateDto()); + + verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class)); + verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class)); + } + + // ---------------- REQ-USR-003 T4:查询用户 参数判定 + 条件解析 + 分页装配 ---------------- + + /** 桩:selectUserPage 返回携带指定 records/total/size/current 的 Page。 */ + @SuppressWarnings("unchecked") + private void stubSelectUserPage(java.util.List records, long total, long size, long current) { + when(usrUserMapper.selectUserPage(any(IPage.class), any(UserQueryCondition.class))) + .thenAnswer(inv -> { + Page page = new Page<>(current, size); + page.setRecords(records); + page.setTotal(total); + return page; + }); + } + + private UserQueryDTO queryDto(String field, String matchType, String value) { + UserQueryDTO dto = new UserQueryDTO(); + dto.setQueryField(field); + dto.setMatchType(matchType); + dto.setQueryValue(value); + return dto; + } + + @Test + @SuppressWarnings("unchecked") + void pageParamTooSmallThrows42201() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setPageNum(0); + assertThatThrownBy(() -> service.queryUsers(dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); + + UserQueryDTO dto2 = new UserQueryDTO(); + dto2.setPageSize(0); + assertThatThrownBy(() -> service.queryUsers(dto2)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); + + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); + } + + @Test + @SuppressWarnings("unchecked") + void pageSizeOverMaxThrows42201() { + UserQueryDTO dto = new UserQueryDTO(); + dto.setPageSize(500); + assertThatThrownBy(() -> service.queryUsers(dto)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PAGE_PARAM_INVALID); + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); + } + + @Test + @SuppressWarnings("unchecked") + void blankQueryValueAppliesNoFilter() { + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + UserQueryDTO dto = queryDto("用户名", "包含", null); + + service.queryUsers(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserQueryCondition.class); + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); + UserQueryCondition cond = captor.getValue(); + assertThat(cond.getKind()).isEqualTo(UserQueryCondition.Kind.NONE); + assertThat(cond.getTextValue()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + void boolFieldUnparsableThrows40001() { + UserQueryDTO bad = queryDto("作废", "等于", "abc"); + assertThatThrownBy(() -> service.queryUsers(bad)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); + + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserQueryCondition.class); + + service.queryUsers(queryDto("作废", "等于", "1")); + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); + assertThat(captor.getValue().getBoolValue()).isEqualTo(1); + + service.queryUsers(queryDto("作废", "等于", "是")); + verify(usrUserMapper, times(2)).selectUserPage(any(IPage.class), captor.capture()); + assertThat(captor.getValue().getBoolValue()).isEqualTo(1); + } + + @Test + @SuppressWarnings("unchecked") + void dateFieldIllegalThrows40001() { + UserQueryDTO bad = queryDto("登录日期", "等于", "2026-13-99"); + assertThatThrownBy(() -> service.queryUsers(bad)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class)); + + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + service.queryUsers(queryDto("登录日期", "等于", "2026-06-01")); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserQueryCondition.class); + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); + UserQueryCondition cond = captor.getValue(); + assertThat(cond.getDateStart()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0, 0)); + assertThat(cond.getDateEnd()).isEqualTo(LocalDateTime.of(2026, 6, 2, 0, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void textLikeEscapesWildcards() { + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + service.queryUsers(queryDto("用户名", "包含", "a%_b")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserQueryCondition.class); + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture()); + UserQueryCondition cond = captor.getValue(); + assertThat(cond.isText()).isTrue(); + assertThat(cond.isTextContains()).isTrue(); + assertThat(cond.getTextValue()).contains("\\%").contains("\\_"); + } + + @Test + @SuppressWarnings("unchecked") + void dataPageOutOfRangeClampsToLastPage() { + UserVO vo = new UserVO(); + vo.setSUserName("last_page_user"); + stubSelectUserPage(java.util.List.of(vo), 23L, 10L, 99L); + UserQueryDTO dto = new UserQueryDTO(); + dto.setPageNum(99); + dto.setPageSize(10); + + PageResult result = service.queryUsers(dto); + + assertThat(result.getTotal()).isEqualTo(23L); + assertThat(result.getPageNum()).isEqualTo(3L); + assertThat(result.getRecords()).extracting(UserVO::getSUserName).contains("last_page_user"); + } + + @Test + @SuppressWarnings("unchecked") + void emptyResultReturnsZeroTotal() { + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + PageResult result = service.queryUsers(new UserQueryDTO()); + + assertThat(result.getRecords()).isEmpty(); + assertThat(result.getTotal()).isZero(); + assertThat(result.getPageNum()).isEqualTo(1L); + } + + @Test + @SuppressWarnings("unchecked") + void defaultsApplied() { + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L); + service.queryUsers(new UserQueryDTO()); + + ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(IPage.class); + verify(usrUserMapper).selectUserPage(pageCaptor.capture(), any(UserQueryCondition.class)); + IPage page = pageCaptor.getValue(); + assertThat(page.getCurrent()).isEqualTo(1L); + assertThat(page.getSize()).isEqualTo(10L); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java b/backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java new file mode 100644 index 0000000..baa59e5 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/vo/CompanyOptionVOJsonTest.java @@ -0,0 +1,33 @@ +package com.xly.erp.modules.usr.vo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-004 T2:CompanyOptionVO 序列化键名锁定。 + * + *

验证匈牙利前缀字段({@code sCompanyName}/{@code sVersion})经 {@code @JsonProperty} + * 序列化为契约小驼峰键,而非 Jackson 推断的大驼峰 {@code SCompanyName}。

+ */ +class CompanyOptionVOJsonTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void serializesContractKeys() throws Exception { + CompanyOptionVO vo = new CompanyOptionVO(); + vo.setId(7); + vo.setSCompanyName("小羚羊总部"); + vo.setSVersion("企业版"); + + String json = objectMapper.writeValueAsString(vo); + + assertThat(json).contains("\"id\":7"); + assertThat(json).contains("\"sCompanyName\":\"小羚羊总部\""); + assertThat(json).contains("\"sVersion\":\"企业版\""); + assertThat(json).doesNotContain("SCompanyName"); + assertThat(json).doesNotContain("SVersion"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java b/backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java new file mode 100644 index 0000000..754d708 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/vo/LoginVOJsonTest.java @@ -0,0 +1,43 @@ +package com.xly.erp.modules.usr.vo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-004 T3:LoginVO 序列化结构与键名(含嵌套 user,绝不含密码)。 + * + *

验证顶层 {@code token} + 嵌套对象 {@code user{id,sUserName,sUserType,sLanguage}}, + * 匈牙利前缀字段经 {@code @JsonProperty} 锁小驼峰键;响应体不含 {@code sPassword}/{@code password} + * /大驼峰键 {@code SUserName}(spec § 3 规则 9 / 验收 11)。

+ */ +class LoginVOJsonTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void serializesTokenAndNestedUserNoPassword() throws Exception { + LoginVO.UserInfo user = new LoginVO.UserInfo(); + user.setId(1); + user.setSUserName("admin"); + user.setSUserType("超级管理员"); + user.setSLanguage("中文"); + + LoginVO vo = new LoginVO(); + vo.setToken("t.t.t"); + vo.setUser(user); + + String json = objectMapper.writeValueAsString(vo); + + assertThat(json).contains("\"token\":\"t.t.t\""); + assertThat(json).contains("\"user\":"); + assertThat(json).contains("\"id\":1"); + assertThat(json).contains("\"sUserName\":\"admin\""); + assertThat(json).contains("\"sUserType\":\"超级管理员\""); + assertThat(json).contains("\"sLanguage\":\"中文\""); + assertThat(json).doesNotContain("sPassword"); + assertThat(json).doesNotContain("password"); + assertThat(json).doesNotContain("SUserName"); + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java b/backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java new file mode 100644 index 0000000..8debcc9 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java @@ -0,0 +1,56 @@ +package com.xly.erp.modules.usr.vo; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-003 T2:UserVO 序列化契约键名 + 不含密码 / 租户列。 + * + *

带匈牙利前缀字段加 @JsonProperty 锁键为小驼峰(sUserName 等); + * employeeName/department/id/日期为普通驼峰;严格无 sPassword/password/租户列。

+ */ +class UserVOJsonTest { + + // 注册 JavaTimeModule 模拟 Spring Boot 自动配置的 ObjectMapper(支持 LocalDateTime 序列化)。 + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void serializesContractKeysNoPassword() throws Exception { + UserVO vo = new UserVO(); + vo.setId(7); + vo.setSUserName("alice"); + vo.setEmployeeName("爱丽丝"); + vo.setSUserNo("U007"); + vo.setDepartment("财务部"); + vo.setSUserType("超级管理员"); + vo.setSLanguage("中文"); + vo.setIIsVoid(0); + vo.setTLastLoginDate(LocalDateTime.of(2026, 6, 1, 12, 0, 0)); + vo.setSCreator("admin"); + vo.setTCreateDate(LocalDateTime.of(2026, 5, 1, 9, 0, 0)); + + String json = objectMapper.writeValueAsString(vo); + + assertThat(json) + .contains("\"id\"") + .contains("\"sUserName\"") + .contains("\"employeeName\"") + .contains("\"sUserNo\"") + .contains("\"department\"") + .contains("\"sUserType\"") + .contains("\"sLanguage\"") + .contains("\"iIsVoid\"") + .contains("\"tLastLoginDate\"") + .contains("\"sCreator\"") + .contains("\"tCreateDate\""); + // 匈牙利前缀字段 @JsonProperty 锁键生效:不得出现 Jackson 默认推断的大写首字母键名。 + assertThat(json).doesNotContain("\"SUserName\""); + // 严格不含密码 / 租户列。 + assertThat(json).doesNotContain("sPassword").doesNotContain("password"); + assertThat(json).doesNotContain("sBrandsId").doesNotContain("sSubsidiaryId"); + } +} diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index c7c6a64..5d10c20 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -47,10 +47,10 @@ - 路径: `backend/src/main/java/com/xly/erp/modules/usr/**` - 里程碑: — - 功能: - - [ ] 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 登录用户 ## 三、Coding 阶段(前端整体) diff --git a/docs/superpowers/module-reports/2026-06-01-usr.md b/docs/superpowers/module-reports/2026-06-01-usr.md new file mode 100644 index 0000000..e006fe8 --- /dev/null +++ b/docs/superpowers/module-reports/2026-06-01-usr.md @@ -0,0 +1,169 @@ +# 模块完成报告 — USR 用户管理(backend) + +> 生成时间:2026-06-01 CST +> 模块 id:usr|阶段:后端(backend)|功能分支:`module-usr`(自默认分支 `master` 分叉) +> 本报告为标准化 12 节模块完成报告,供 milestone 标记前置。摘要级(不引入 diff 正文)。 + +--- + +## ① 模块概述 + +- **模块名称**:USR 用户管理(`com.xly.erp.modules.usr`)。 +- **业务范围**:企业内部管理系统的用户账号全生命周期与登录认证,覆盖用户新增、修改、分页查询、登录认证 4 个后端 REQ。 +- **依赖**:无(docs/08 § 二 标注「依赖: 无」),为整个 ERP 的基础模块。 +- **路径作用域**:`backend/src/main/java/com/xly/erp/modules/usr/**` + 公共基础设施 `com/xly/erp/common/**`(统一响应 / 异常 / 安全 / JWT / BaseEntity,本模块首次落地,供后续模块复用)。 +- **本阶段产出范围**:controller / service / repository(mapper) / DTO / VO / 校验 / REST 契约实现;不触 `frontend/`(UI 推迟到前端阶段);本模块未新增 SQL migration(复用 A4 生成的 `V1__initial_schema.sql`)。 + +--- + +## ② 需求覆盖(REQ 清单 + spec 摘要) + +本模块 4 个 REQ 全部完成并在 `docs/08-模块任务管理.md` § 二 勾选为 `[x]`: + +| REQ | 标题 | 端点 | spec 摘要 | +|---|---|---|---| +| REQ-USR-001 | 增加用户 | `POST /api/usr/users` | 管理员新建用户:用户名 3-20 位唯一、密码 BCrypt(默认 `666666`)、用户类型 / 语言枚举约束、可选关联职员 + 权限组授权(多对多);多表写入事务化;新建即生效(`iIsVoid=0`)。错误码 0/40001/40301/40901。 | +| REQ-USR-002 | 修改用户 | `PUT /api/usr/users/{id}` | 管理员改用户基础信息(用户号 / 关联职员 / 用户类型 / 语言 / 单据修改权限 / 作废标志 / 权限组);`sUserName` 与 `sPassword` 不可改;权限组**全量覆盖**语义;可选字段 null = 不更新(保守不清空)。错误码 0/40001/40301/40401。 | +| REQ-USR-003 | 查询用户 | `GET /api/usr/users` | 任意已认证用户的只读单条件分页查询(8 个查询字段 × 3 种匹配方式);跨表 LEFT JOIN `usr_employee` 取员工名 / 部门;文本模糊(LIKE 转义)/ 枚举 / 布尔 / 日期精确匹配;分页参数非法 → `42201`,数据越界返回最后一页;密码绝不返回。错误码 0/40001/42201(+401 未认证)。 | +| REQ-USR-004 | 登录用户 | `POST /api/usr/login`(+ 配套 `GET /api/usr/companies`) | 用户名 + 密码 + 版本(companyId) 认证:BCrypt 比对 → 签发带 exp 的无状态 JWT + 更新 `tLastLoginDate`;防账号枚举(不存在/密码错误同码同提示);禁用账号 `40302`;进程内登录限流 `42901`(默认 5 次/300 秒)。配套补齐只读公司列表端点支撑登录页「版本」下拉。错误码 0/40001/40101/40302/42901(+401)。 | + +> spec 全文:`docs/superpowers/specs/2026-06-01-REQ-USR-00{1,2,3,4}.md`。各 spec § 8 已逐条登记自主决策(见本报告 § ⑩)。 + +--- + +## ③ 文件变更摘要(git diff --stat,master...HEAD) + +- **提交数**:53 个 commit(`git log master..HEAD --oneline`)。 +- **变更规模**:85 个文件,+8280 / -5 行。 +- **新增生产代码**(`backend/src/main/java/com/xly/erp/`,A=新增): + - 公共基础设施 `common/`:`base/BaseEntity`、`config/{MybatisPlusConfig,SecurityConfig}`、`exception/{BusinessException,GlobalExceptionHandler}`、`response/{Result,ResultCode,PageResult}`、`security/{JwtUtil,JwtAuthenticationFilter,SecurityUtil}`、`ErpApplication`。 + - USR 模块 `modules/usr/`:controller(`UsrUserController`、`UsrAuthController`)、service(`UsrUserService(+Impl)`、`UsrAuthService(+Impl)`)、mapper(`UsrUser/UsrCompany/UsrEmployee/UsrPermission/UsrUserPermission` Mapper)、entity(`UsrUser/UsrCompany/UsrEmployee/UsrPermission/UsrUserPermission`)、dto(`CreateUserDTO/UpdateUserDTO/UserQueryDTO/UserQueryCondition/LoginDTO`)、vo(`UserVO/LoginVO/CompanyOptionVO`)。 + - 资源:`application.yml`、`application-test.yml`、`mapper/usr/UsrUserMapper.xml`(跨表分页查询)。 + - 构建:`backend/pom.xml`(Spring Boot + MyBatis-Plus + Flyway 10.10.0 + JWT,`java.version=17`)、`backend/checkstyle.xml`。 +- **新增测试代码**(`backend/src/test/java/`):25 个测试类(详见 § ⑤)。 +- **流程 / 文档**:`scripts/test.mjs`(M,新增 `ensureJava17()`)、`docs/08-模块任务管理.md`(M,4 个 REQ 勾选)、`docs/superpowers/{specs,plans,reviews}/2026-06-01-REQ-USR-00{1..4}*.md`(A)、`docs/superpowers/module-reports/usr-test-gate-r1.md`(A)。 +- **migration**:无新增(见 § ⑥)。 +- **跨模块**:无(见 § ⑦)。 + +--- + +## ④ 读写的数据表 + +本模块涉及 5 张表,全部由 `sql/migrations/V1__initial_schema.sql` 建好(A4 阶段从 docs/03 翻译),本模块**未做 schema 变更**: + +| 表 | 操作 | 涉及 REQ | 说明 | +|---|---|---|---| +| `usr_user` | 读 + 写 | 001(写) / 002(写) / 003(读) / 004(读+写 `tLastLoginDate`) | 用户主表,主键 `iIncrement`,唯一索引 `uk_usr_user_username`。 | +| `usr_user_permission` | 写 | 001(批量插入) / 002(全量覆盖) | 用户-权限多对多关联表,唯一索引 `uk_usr_user_permission(iUserId,iPermissionId)`。 | +| `usr_employee` | 读 | 001/002(存在性校验) / 003(LEFT JOIN 取员工名/部门) | 职员表;与 `usr_user` 外键 `fk_usr_user_employee` ON DELETE SET NULL。 | +| `usr_permission` | 读 | 001/002(权限 id 存在性校验) | 权限组表。 | +| `usr_company` | 读 | 004(版本下拉 + companyId 存在性校验) | 公司/版本支撑表,与 `usr_user` 无外键(仅登录时选择)。 | + +> 严格遵循 docs/04 § 3.4:仅经 Mapper 访问;查询不 SELECT `sPassword`;VO 不含密码列;SQL 参数化防注入,LIKE 通配符转义。 + +--- + +## ⑤ 测试与质量闸(test-gate) + +**flake 判定:无 flake。** test-gate attempt 序列仅 `r1`,且 r1 = **GREEN**(首跑即绿)。 + +> 说明:历史上曾在 JDK25 默认工具链下有 5 次 RED 记录(旧 r1–r5),其根因为 Mockito/Byte Buddy 不支持 JDK25 的 class file 版本(69),导致 `UsrAuthServiceImplTest` 对 `JwtUtil` 整片 `Mockito cannot mock` 报 error(终态 88 跑 / 9 error)。该批旧证据因属过期工具链问题已在 commit `282a524` 移除,并非当前 attempt 序列的一部分;工具链修复在 `dbc3454`(`scripts/test.mjs` 新增 `ensureJava17()`,把后端测试 JVM 的 `JAVA_HOME` 固定到 Java 17)。本模块当前 attempt 序列从修复后重新起算,r1 首跑即绿,故不构成「red→green flake」。 + +**本次 r1 证据**(`docs/superpowers/module-reports/usr-test-gate-r1.md`): + +- 执行命令:`node scripts/test.mjs`(内部 `ensureJava17()` → `/opt/homebrew/Cellar/openjdk@17/17.0.19/.../Home`)。 +- 工具链确认:Spring Boot 测试日志 `Starting ErpApplicationTests using Java 17.0.19`;Maven 3.9.15 / Java 17.0.19。 +- 5 级闸门:1/5 setup-db ✅、2/5 build(`mvn -q -B -DskipTests package`) ✅、3/5 lint(`mvn -q -B checkstyle:check`) ✅、4/5 unit+integration(`mvn -q -B test`) ✅、5/5 e2e(后端无 e2e,skip)✅。 +- 退出码 0,终态 `[test.mjs] GREEN`。 +- **测试统计(surefire-reports 汇总)**:`Tests run: 125, Failures: 0, Errors: 0, Skipped: 0`(25 个测试类)。曾整片报错的 `UsrAuthServiceImplTest`(含 `JwtUtil` mock)本次 9/9 全绿,证明根因消除。 + +**测试类清单(25)**: + +- 公共:`ErpApplicationTests`、`SecurityConfigTest`、`GlobalExceptionHandlerTest`、`ResultTest`、`ResultCodeLoginTest`、`PageResultTest`、`JwtUtilTest`。 +- DTO 校验:`CreateUserDTOValidationTest`、`UpdateUserDTOValidationTest`、`UserQueryDTOValidationTest`、`LoginDTOValidationTest`。 +- VO 序列化:`UserVOJsonTest`、`LoginVOJsonTest`、`CompanyOptionVOJsonTest`。 +- Mapper:`UsrUserMapperPageTest`、`UsrCompanyMapperTest`。 +- Service 单测:`UsrUserServiceImplTest`(24)、`UsrAuthServiceImplTest`(9)。 +- Controller 单测:`UsrUserControllerTest`(11)、`UsrAuthControllerTest`(6)。 +- 端到端 IT:`UsrUserCreateIT`(5)、`UsrUserUpdateIT`(6)、`UsrUserQueryIT`(13)、`UsrLoginIT`(12)、`AuthLoginConfigIT`(1)。 + +--- + +## ⑥ 数据库 migration + +**本模块未新增 migration。** + +- `git diff --name-only --diff-filter=A master...HEAD -- 'sql/migrations/V*.sql'` 结果为空。 +- 现有唯一 migration `sql/migrations/V1__initial_schema.sql` 由 A4 阶段 `db-init` 从 `docs/03-数据库设计文档.md`(schema SSoT)翻译生成,第一行:`-- Flyway migration V1 — initial schema for 小羚羊`。 +- 4 个 REQ 的 spec § 8 均明确决策「复用 V1,无 schema 变更」(4 张依赖表 `usr_user/usr_employee/usr_permission/usr_user_permission/usr_company` 结构与 docs/03 一致)。REQ-USR-004 仅对 `usr_user.tLastLoginDate` 做 UPDATE(DML 非 DDL)。 +- Flyway 依赖已在 `pom.xml` 声明(`flyway-core` + `flyway-mysql` 10.10.0),schema 由 Spring Boot / 测试启动时自动 apply(符合 CLAUDE.md Schema 演化规约)。 + +--- + +## ⑦ 跨模块改动 + +**无跨模块改动。** + +- `docs/superpowers/module-reports/usr-cross-module.md` 不存在(cross-module-log 未生成 = 本模块未触碰非当前模块的业务代码)。 +- 本模块新增的 `common/**` 公共基础设施(统一响应 / 异常 / 安全 / JWT / BaseEntity)属全局基础层首次落地,非「改动其他业务模块」,不计入跨模块改动。 + +--- + +## ⑧ 与规范 / 契约的偏离 + +**实现层无偏离**——4 个 REQ 的实现端点、请求/响应字段、错误码、分层、命名、统一响应、异常处理、事务、安全均与 docs/04 技术规范及 docs/05 API 契约一致(详见各 review 报告四维核对结论)。 + +仅记录 2 项**对 docs/05 契约的反向同步建议**(实现期已落地为代码,建议择机补入契约 SSoT,非阻塞、非违规): + +1. **新增端点 `GET /api/usr/companies`**(REQ-USR-004 spec § 8 D1):docs/05 仅定义 `POST /api/usr/login`,未单列公司列表端点。为使登录链路自洽(前端需先取 companyId),后端补齐该只读放行端点。建议补入 docs/05 § REQ-USR-004。 +2. **新增限流错误码 `42901`**(REQ-USR-004 spec § 8 D7):docs/05 未列该码,系为落地卡片「连续失败锁定/限流」要求引入。建议补入 docs/05 错误码段。 + +> 以上两项均已在 spec § 8 显式登记为「建议反向同步(非本 stage 强制)」,review 亦标为非阻塞建议。其余无任何偏离。 + +--- + +## ⑨ 评审与验证记录 + +每个 REQ 均有 round-1 review(裁决 approve)+ verify(证据验证)报告,路径 `docs/superpowers/reviews/2026-06-01-REQ-USR-00{1..4}{,-verify}.md`: + +| REQ | review 裁决 | 关键核对结论 | +|---|---|---| +| REQ-USR-001 | **approve**(issues=[]) | 四维(plan-alignment / quality / architecture / docs)无 must-fix;用户名查重 + DuplicateKey 兜底并发安全;密码 BCrypt + `@JsonIgnore` 不外泄;多表写事务回滚验证;24/24 测试绿。 | +| REQ-USR-002 | **approve** | 全量覆盖授权、null=不更新语义、`sUserName/sPassword` 不可改、审计字段只读均落地;权限差量覆盖与禁用实时生效端到端验证。 | +| REQ-USR-003 | **approve** | 单条件 8 字段 × 3 匹配、跨表 LEFT JOIN、LIKE 转义防注入、参数非法 `42201` / 数据越界返回最后一页、密码不返回均验证。 | +| REQ-USR-004 | **approve** | 认证判定顺序(限流→查用户→验密码→判禁用→校 companyId→签发+更新时间)与 spec § 8 D3 一致;防账号枚举(同码同提示);JWT 含 exp;进程内限流 `42901`;安全放行 `/api/usr/companies`。 | + +- 各 review 均附非阻塞口头建议(如限流计数与事务非原子、契约反向同步、冗余去重代码简化),无任何 must-fix。 +- verify 报告确认各 REQ 在通过工具链下测试全绿、验收标准(AC)逐条覆盖。 +- 模块级 test-gate(本报告 § ⑤)在锁定 JDK17 下 125/125 全绿,作为合并默认分支前的硬闸。 + +--- + +## ⑩ 自主决策汇总(decisions) + +本报告渲染过程中的自主决策见文末 `decisions[]`(test-gate 重跑判定、flake 归类等)。各 REQ 实现期的自主决策已在对应 spec § 8 逐条登记,要点汇总: + +- **语言取值锁定**:`sLanguage` ∈ {中文,英文,繁体},不取 docs/03 带审阅占位的默认值(REQ-001 D1)。 +- **管理员判定口径**:以 `sUserType=超级管理员` 视为有写权限(无独立角色表)(REQ-001 D2 / 002 D6)。 +- **修改语义**:可选字段 null = 不更新(保守不清空);权限组全量覆盖(REQ-002 D3/D4)。 +- **查询边界**:单条件查询;参数非法 `42201` vs 数据越界返回最后一页二分;LIKE 通配符转义(REQ-003 D1/D2/D3)。 +- **登录链路自洽**:补 `GET /api/usr/companies` 端点;进程内限流 `42901`(5 次/300 秒);先验密码再判禁用防枚举;JWT 默认 2 小时(REQ-004 D1/D3/D7/D10)。 +- **migration**:4 个 REQ 全部复用 V1,无新增(REQ-001~004 各 Dn)。 + +--- + +## ⑪ 已知限制 / 后续事项 + +1. **登录限流为进程内 MVP**:当前按 `sUserName` 在进程内存计数(默认 5 次失败 / 锁定 300 秒),分布式部署下不共享、不随 `@Transactional` 回滚(极小窗口)。技术栈虽列 Redis 但当前无连接配置,留待后续平滑替换为 Redis 原子计数(REQ-004 spec § 8 D7、review 非阻塞建议)。 +2. **契约反向同步待补**:`GET /api/usr/companies` 端点与 `42901` 错误码建议补入 `docs/05-API接口契约.md`(见 § ⑧)。 +3. **多租户列兜底**:`sBrandsId/sSubsidiaryId` 由 DB 默认值 `1111111111` 兜底,未引入运行时多租户上下文解析(按 spec 设计,当前阶段足够)。 +4. **冗余防御代码**:`UsrUserServiceImpl` 权限去重存在一处可简化的二次去重(review 口头建议,无功能影响)。 +5. **前端未实现**:FE-01~FE-04(登录页 / 主页框架 / 用户列表 / 用户单据)属前端阶段,docs/08 § 三 待办,不在本后端模块范围。 + +--- + +## ⑫ 结论 + +USR 用户管理后端模块 4 个 REQ(增 / 改 / 查 / 登录)全部实现并 approve,模块级硬测试闸在锁定 JDK17 工具链下 **125/125 全绿(r1,无 flake)**,工作树干净,无新增 migration、无跨模块改动、实现层无偏离(仅 2 项非阻塞契约反向同步建议)。满足里程碑 `milestone/usr` 标记的全部前置条件。 + +> 证据:`docs/superpowers/module-reports/usr-test-gate-r1.md`(GREEN)。 diff --git a/docs/superpowers/module-reports/usr-test-gate-r1.md b/docs/superpowers/module-reports/usr-test-gate-r1.md new file mode 100644 index 0000000..f75dc72 --- /dev/null +++ b/docs/superpowers/module-reports/usr-test-gate-r1.md @@ -0,0 +1,53 @@ +# usr 模块硬测试闸 —— attempt r1(GREEN) + +- **模块**: usr(用户/认证) +- **阶段**: backend test-gate(合并默认分支前的硬测试闸) +- **attempt**: r1 +- **判定**: ✅ **GREEN** +- **执行时间**: 2026-06-01 15:56–15:57 CST +- **执行命令**: `node scripts/test.mjs`(内部 `ensureJava17()` 固定 JDK17 后逐级执行 setup-db → build → lint → unit+integration → e2e) +- **功能分支 HEAD**: `282a524`(`chore(usr): 移除 JDK25 工具链导致的过期红色 test-gate 证据 r1-r5`) + +## 背景:根因与修复 + +历史上 usr 模块在 JDK25 默认工具链下连续 5 次 RED(旧 r1–r5 证据),根因为: + +- Maven Surefire fork 出的测试 JVM 沿用开发机默认 JDK(**Java 25**,class file 版本 69)。 +- Mockito 自带的 Byte Buddy 不支持过新的 class file 版本(仅到 Java 22 = 66),导致 `UsrAuthServiceImplTest.setUp` 阶段对 `com.xly.erp.common.security.JwtUtil` 整片 `Mockito cannot mock this class` 报 error。 +- 旧 r5 终态:`Tests run: 88, Failures: 0, Errors: 9, Skipped: 0`(9 个 error 全部来自 Mockito mock 失败)。 + +修复(已在功能分支落地): + +- `dbc3454 chore(infra): test.mjs 固定后端测试 JDK 为 Java 17` —— `scripts/test.mjs` 新增 `ensureJava17()`,在跑后端 Maven 前把进程树 `JAVA_HOME`/`PATH` 固定到 Java 17 运行时(不改全局 profile)。 +- `282a524 chore(usr): 移除 JDK25 工具链导致的过期红色 test-gate 证据 r1-r5` —— 移除过期红色证据,避免污染报告前置判定。 + +本次 r1 是修复落地后在 **JDK17 工具链**下的首次重跑。 + +## 工具链确认 + +- `/usr/libexec/java_home -v 17` → `/opt/homebrew/Cellar/openjdk@17/17.0.19/libexec/openjdk.jdk/Contents/Home` +- `ensureJava17()` 日志:`[test.mjs] 固定 JAVA_HOME=Java 17 → /opt/homebrew/Cellar/openjdk@17/17.0.19/libexec/openjdk.jdk/Contents/Home` +- Spring Boot 测试启动日志确认运行时:`Starting ErpApplicationTests using Java 17.0.19` +- Maven:Apache Maven 3.9.15 / Java version 17.0.19 + +## 5 级闸门执行结果 + +| 阶段 | 命令 | 结果 | +|-----|------|------| +| 1/5 setup test db | `node scripts/setup-test-db.mjs`(DROP+CREATE `xlyweberp_vibe_erp_test`,schema 由 Flyway 启动时 apply) | ✅ done | +| 2/5 build | `mvn -q -B -DskipTests package` | ✅ pass | +| 3/5 lint | `mvn -q -B checkstyle:check` | ✅ pass | +| 4/5 unit + integration | `mvn -q -B test` | ✅ pass | +| 5/5 e2e | 后端无 e2e(前端阶段单独执行) | ✅ skip(按 docs/04 §零约定) | + +退出码:`0`;test.mjs 终态输出:`[test.mjs] GREEN`。 + +## 测试统计(surefire-reports 汇总) + +- **总计**:`Tests run: 125, Failures: 0, Errors: 0, Skipped: 0`(25 个测试类)。 +- 关键回归:曾整片报错的 `UsrAuthServiceImplTest`(含 `JwtUtil` mock)本次 **9/9 全绿**,证明 Byte Buddy×JDK25 根因已消除。 +- 各 IT(`UsrLoginIT` 12、`UsrUserQueryIT` 13、`UsrUserUpdateIT` 6、`UsrUserCreateIT` 5、`AuthLoginConfigIT` 1)全部通过。 + +## 结论 + +usr 模块硬测试闸 r1 = **GREEN**。无 flake(本次为修复后首跑即绿;历史 RED 证据已随旧工具链一并移除,非本轮 attempt 序列)。报告阶段前置(末次 attempt green)已满足。 diff --git a/docs/superpowers/plans/2026-06-01-REQ-USR-001.md b/docs/superpowers/plans/2026-06-01-REQ-USR-001.md new file mode 100644 index 0000000..63a4d53 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-REQ-USR-001.md @@ -0,0 +1,197 @@ +# REQ-USR-001 增加用户 — 任务级 TDD 计划(后端) + +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / repository(mapper) / DTO / VO / entity / 校验 / 配置 / REST 契约实现)。**禁止**写 `frontend/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-001.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-001.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 + +--- + +## Goal(目标) + +实现后台管理员新建用户账号的唯一端点 `POST /api/usr/users`:接收 `CreateUserDTO`,校验参数与权限,对用户名查重,BCrypt 哈希初始密码,写入 `usr_user` 并按需为新用户批量授权 `usr_user_permission`,返回 `Result<{ id }>`。同时因这是项目首个后端 REQ,需先一次性搭好 `backend/` Maven 骨架与 `common/**` 公共基础设施(统一响应 / 异常 / 安全 / BaseEntity / 配置),供本 REQ 及后续 REQ 复用。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`(来自 `config-vars.yaml backend.base_package`): + +``` +backend/ +├── pom.xml # spring-boot 3 / mybatis-plus / mysql / flyway-core+flyway-mysql / spring-security / jjwt / mapstruct / hutool / bcrypt(随 spring-security) / spring-boot-starter-test +├── src/main/java/com/xly/erp/ +│ ├── ErpApplication.java # 启动类 @SpringBootApplication + @MapperScan("com.xly.erp.**.mapper") +│ ├── common/ +│ │ ├── response/Result.java # 统一响应体 Result +│ │ ├── response/ResultCode.java # 错误码枚举(0/40001/40301/40901/40101/40302/40401/42201) +│ │ ├── exception/BusinessException.java # 业务异常 +│ │ ├── exception/GlobalExceptionHandler.java # @RestControllerAdvice:BusinessException / MethodArgumentNotValidException / DuplicateKeyException / 兜底 +│ │ ├── base/BaseEntity.java # 标准列公共字段(iIncrement/sId/sBrandsId/sSubsidiaryId/tCreateDate)+ MP 自动填充元信息 +│ │ ├── config/MybatisPlusConfig.java # MP 配置(自动填充 MetaObjectHandler、可选分页插件) +│ │ ├── config/SecurityConfig.java # SecurityFilterChain:放行 /api/usr/login + swagger,其余需认证;注册 BCryptPasswordEncoder Bean;挂 JwtAuthenticationFilter +│ │ └── security/JwtAuthenticationFilter.java + JwtUtil.java + SecurityUtil.java # JWT 解析过滤器、JWT 工具、取当前登录用户名工具 +│ └── modules/usr/ +│ ├── controller/UsrUserController.java # 仅 @Valid + 委派 +│ ├── service/UsrUserService.java # 接口 +│ ├── service/impl/UsrUserServiceImpl.java # 业务实现 @Transactional +│ ├── mapper/UsrUserMapper.java # BaseMapper +│ ├── mapper/UsrUserPermissionMapper.java # BaseMapper +│ ├── mapper/UsrEmployeeMapper.java # BaseMapper(读校验) +│ ├── mapper/UsrPermissionMapper.java # BaseMapper(读校验) +│ ├── entity/UsrUser.java / UsrUserPermission.java / UsrEmployee.java / UsrPermission.java +│ └── dto/CreateUserDTO.java +├── src/main/resources/ +│ ├── application.yml # 端口/数据源/MP/Flyway locations/JWT(值引用 config-vars,不硬编码密钥) +│ └── application-test.yml # 测试 profile(连同一测试库;Flyway 自动 apply V1) +└── src/test/java/com/xly/erp/ # 测试,包结构镜像主代码 +``` + +- **跨模块**:本 REQ 仅触及 `modules/usr/**` 与 `common/**`。`common/**` 为首次创建的公共基础设施(非业务模块改动),属项目骨架初始化。 +- **数据访问**:只走 Mapper(MyBatis-Plus),查重 / 存在性校验用 `LambdaQueryWrapper` 或 MP 内置;Controller 禁止直接调 Mapper。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) + +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus;MySQL 8.x;Flyway 10.x(`flyway-core` + `flyway-mysql`,启动时自动 apply `sql/migrations/`)。 +- Spring Security + JWT(jjwt);`BCryptPasswordEncoder` 哈希密码;MapStruct(可选,本 REQ 转换简单可手写);Hutool。 +- 根包 `com.xly.erp`;端口 `5172`(`config-vars.yaml backend.http_port`);DB schema `xlyweberp_vibe_erp_test`;JWT 密钥取 `config-vars.yaml secrets.jwt_secret`。 +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 +- migration:**不新增**,复用 `V1__initial_schema.sql`(spec § 4 / D4)。 + +## 合同级常量(跨 task 必须一致) + +- REST:`POST /api/usr/users`。 +- 错误码(`ResultCode` 枚举,spec § 6 / docs/05):`SUCCESS=0`、`PARAM_INVALID=40001`、`FORBIDDEN=40301`、`USERNAME_EXISTS=40901`。(本枚举同时预留后续 REQ 用:`UNAUTHORIZED=40101`、`ACCOUNT_DISABLED=40302`、`NOT_FOUND=40401`、`PAGE_PARAM_INVALID=42201`,由本 REQ 一次性建好枚举,避免后续重复改公共文件。) +- 默认值:`initialPassword` 缺省 `666666`;`sUserType` 缺省 `普通用户`;`iCanModifyBill` 缺省 `0`;`sBrandsId`/`sSubsidiaryId` 由 DB 列默认 `1111111111` 兜底(实体不强制赋值)。 +- 枚举取值:`sUserType ∈ {普通用户, 超级管理员}`;`sLanguage ∈ {中文, 英文, 繁体}`(必填,spec D1:后端不强制业务默认)。 +- 系统字段:`tCreateDate`=当前时间;`sCreator`=当前登录用户名(SecurityContext 取 `sUserName`);`sPassword`=BCrypt(initialPassword);`iIsVoid=0`;`tLastLoginDate=null`。 + +## 关键签名(首次出现处给出,跨 task 保持一致) + +- `Result`:静态工厂 `Result.success(T data)`、`Result.success()`、`Result.fail(ResultCode code, String message)`;字段 `int code` / `String message` / `T data`。 +- `ResultCode`:枚举常量 `(int code, String message)`,含 `getCode()` / `getMessage()`。 +- `BusinessException extends RuntimeException`:构造 `BusinessException(ResultCode code)` 与 `BusinessException(ResultCode code, String message)`;暴露 `getResultCode()`。 +- `BaseEntity`:受保护公共字段 `Integer iIncrement`(`@TableId(type = IdType.AUTO)`)/ `String sId` / `String sBrandsId` / `String sSubsidiaryId` / `LocalDateTime tCreateDate`(`@TableField(fill = INSERT)`)。 +- `CreateUserDTO`:字段与校验注解见下「DTO 形状」。 +- `UsrUserService#createUser(CreateUserDTO dto)` 返回 `Integer`(新建用户主键 `iIncrement`)。 +- `UsrUserController#createUser(@Valid @RequestBody CreateUserDTO dto)` 返回 `Result>`(`data.id` = 新主键,键名 `id`)。 +- `SecurityUtil.currentUserName()` 返回 `String`(当前 JWT 主体的 `sUserName`)。 +- `SecurityUtil.currentUserType()` 返回 `String`(当前主体 `sUserType`,用于管理员判定)。 + +### DTO 形状(`CreateUserDTO`) + +| 字段 | 类型 | 校验注解 | +|---|---|---| +| `sUserName` | String | `@NotBlank` + `@Pattern(regexp="^[A-Za-z0-9_]{3,20}$")` | +| `sUserNo` | String | `@Size(max=50)` | +| `iEmployeeId` | Integer | —(存在性在 Service 校验) | +| `sUserType` | String | `@Pattern(regexp="^(普通用户|超级管理员)$")`(为空时 Service 兜底 `普通用户`,故不加 `@NotBlank`) | +| `sLanguage` | String | `@NotBlank` + `@Pattern(regexp="^(中文|英文|繁体)$")` | +| `iCanModifyBill` | Integer | `@Min(0)` + `@Max(1)`(为空时 Service 兜底 0) | +| `permissionIds` | `List` | —(元素存在性在 Service 校验) | +| `initialPassword` | String | `@Size(max=100)`(为空时 Service 兜底 `666666`) | + +> 注:`@Valid` 失败由 `GlobalExceptionHandler` 统一转 `40001`。枚举/格式越界既可由注解触发(如已传非法值),也由 Service 兜底前的显式校验保证;Service 对「关联 id 不存在」「枚举越界(兜底后再判)」抛 `BusinessException(PARAM_INVALID)`。 + +--- + +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) + +> 说明:T1-T3 为项目骨架初始化(首个 REQ 必需,否则无可编译/可测的工程)。骨架就绪后 T4 起进入本 REQ 业务红绿循环。每个 task 完成后单独 commit(业务类 commit subject 带 `REQ-USR-001`)。 + +### T1 — Maven 骨架 + 启动 + 健康自检(绿) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/ErpApplicationTests.java::contextLoads` —— Spring 上下文能加载(`@SpringBootTest`,激活 `test` profile)。意图:证明 pom 依赖 / 启动类 / `application.yml` 自洽、Flyway 能对测试库 apply V1、MyBatis-Plus 装配成功。 +- [ ] **实现**:`backend/pom.xml`(按 Tech Stack 声明依赖与构建命令对应插件,含 `checkstyle` 插件以支撑 lint 命令)、`backend/src/main/java/com/xly/erp/ErpApplication.java`、`backend/src/main/resources/application.yml`(端口 5172、数据源指向 `config-vars` 的 DB、`spring.flyway.locations=filesystem:../sql/migrations`、JWT 配置占位读 env/配置)、`application-test.yml`(test profile 连同一库)。 +- [ ] **验证**:子会话跑 `mvn -q -B test -Dtest=ErpApplicationTests`(或全量 `mvn -q -B test`)PASS。 +- [ ] **commit**:`chore(usr): 初始化 backend Maven 骨架与启动类` + +### T2 — 公共响应与异常基础设施 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/ResultTest.java::successCarriesCodeZeroAndData` + `failCarriesBusinessCodeAndMessage`;`backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java::businessExceptionMapsToResult` —— 校验 `Result.success(data).code==0`,`Result.fail(USERNAME_EXISTS,msg)` 带 `40901` 与 message;`GlobalExceptionHandler` 把 `BusinessException` 转为对应 `Result`(可用单元方式直接调 handler 方法断言返回 `Result`)。 +- [ ] **实现**:`common/response/Result.java`、`common/response/ResultCode.java`(含「合同级常量」全部错误码)、`common/exception/BusinessException.java`、`common/exception/GlobalExceptionHandler.java`(处理 `BusinessException`→对应码、`MethodArgumentNotValidException`→`40001`、`DuplicateKeyException`→`40901`、其余→通用系统错误且记 ERROR 日志不泄栈)。 +- [ ] **验证**:子会话跑相关测试类 PASS。 +- [ ] **commit**:`feat(usr): 统一响应体/错误码枚举/全局异常处理 REQ-USR-001` + +### T3 — BaseEntity + MP 自动填充 + Security/JWT 基础设施 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java::generateThenParseRoundTrip` —— 用 `config-vars` 的 `jwt_secret` 签发含 `sUserName` + `sUserType` claim 的 token,再解析能取回相同值且校验通过;`SecurityConfigTest`(轻量:断言 `BCryptPasswordEncoder` Bean 存在且 `encode/matches("666666")` 成立,可作 `@SpringBootTest` 注入或单元 new)。 +- [ ] **实现**:`common/base/BaseEntity.java`(标准列字段 + MP 注解)、`common/config/MybatisPlusConfig.java`(`MetaObjectHandler` 填充 `tCreateDate`;可注册分页插件供后续 REQ)、`common/config/SecurityConfig.java`(`SecurityFilterChain`:放行 `/api/usr/login`、swagger、actuator/health;其余 `authenticated()`;注册 `BCryptPasswordEncoder` Bean;将 `JwtAuthenticationFilter` 加在 `UsernamePasswordAuthenticationFilter` 之前;无状态 session)、`common/security/JwtUtil.java`、`common/security/JwtAuthenticationFilter.java`(解析 `Authorization: Bearer`,把 `sUserName`/`sUserType` 放入 `Authentication` 的 principal/authorities)、`common/security/SecurityUtil.java`(`currentUserName()` / `currentUserType()` 从 `SecurityContextHolder` 取)。 +- [ ] **验证**:子会话跑 `JwtUtilTest` + Security 相关测试 PASS。 +- [ ] **commit**:`feat(usr): BaseEntity 与 MP 自动填充、Security/JWT 基础设施 REQ-USR-001` + +### T4 — USR 实体 + Mapper + CreateUserDTO(含 Bean Validation) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/dto/CreateUserDTOValidationTest.java`: + - `::rejectsBadUserName` —— `sUserName="ab"`(<3 位)或含非法字符触发约束违反。 + - `::rejectsIllegalLanguage` —— `sLanguage="日文"` 违反 `@Pattern`。 + - `::acceptsMinimalValidBody` —— `{sUserName:"good_user", sLanguage:"中文"}` 无违反(`sUserType`/`iCanModifyBill`/`initialPassword` 允许空,Service 兜底)。 + (用 `jakarta.validation.Validator` 直接 validate DTO 断言 violations 数量。) +- [ ] **实现**:`modules/usr/entity/{UsrUser,UsrUserPermission,UsrEmployee,UsrPermission}.java`(`@TableName` 映射对应表,字段名与匈牙利前缀列名一致;`UsrUser`/关联表可继承 `BaseEntity` 复用标准列,业务列按 docs/03 列出,`sPassword` 标 `@TableField` 不参与序列化输出可用 VO 层控制——本 REQ 不返回实体)、`modules/usr/mapper/{UsrUserMapper,UsrUserPermissionMapper,UsrEmployeeMapper,UsrPermissionMapper}.java`(继承 `BaseMapper`)、`modules/usr/dto/CreateUserDTO.java`(按「DTO 形状」加校验注解)。 +- [ ] **验证**:子会话跑 `CreateUserDTOValidationTest` PASS。 +- [ ] **commit**:`feat(usr): 用户相关实体/Mapper/CreateUserDTO 校验 REQ-USR-001` + +### T5 — Service:用户名查重 + 密码哈希 + 落库(核心写) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(Mockito 单元,mock 4 个 Mapper + `BCryptPasswordEncoder` + `SecurityUtil`): + - `::createUserHashesPasswordAndSetsAuditFields` —— 给最小合法 DTO(不传 `initialPassword`),断言传给 `UsrUserMapper.insert` 的 `UsrUser`:`sPassword` 非明文 `666666` 且 `encoder.encode` 被以 `666666` 调用、`iIsVoid==0`、`sUserType=="普通用户"`、`sCreator==当前用户名`、`tLastLoginDate==null`,返回新主键。 + - `::duplicateUserNameThrows40901` —— 查重命中(mock 查到已存在)→ 抛 `BusinessException(USERNAME_EXISTS)`。 + - `::duplicateKeyExceptionTranslatesTo40901` —— `insert` 抛 `DuplicateKeyException` → Service 转 `BusinessException(USERNAME_EXISTS)`(并发兜底)。 +- [ ] **实现**:`modules/usr/service/UsrUserService.java`(接口 `Integer createUser(CreateUserDTO)`)+ `modules/usr/service/impl/UsrUserServiceImpl.java`:先按 `sUserName` 查重命中抛 `40901`;兜底默认值(`sUserType`/`iCanModifyBill`/`initialPassword`/`sLanguage` 非法再校验抛 `40001`);`BCryptPasswordEncoder.encode` 哈希;填审计字段;`insert` 捕获 `DuplicateKeyException` 转 `40901`;方法标 `@Transactional(rollbackFor = Exception.class)`。 +- [ ] **验证**:子会话跑 `UsrUserServiceImplTest`(上述三个用例)PASS。 +- [ ] **commit**:`feat(usr): 新增用户 Service 查重/哈希/落库 REQ-USR-001` + +### T6 — Service:关联职员/权限存在性校验 + 权限批量授权 +- [ ] **测试**:续 `UsrUserServiceImplTest`: + - `::nonExistentEmployeeThrows40001` —— 传 `iEmployeeId` 但 `UsrEmployeeMapper` 查不到 → `BusinessException(PARAM_INVALID)`,不 insert。 + - `::nonExistentPermissionThrows40001` —— `permissionIds` 含库中不存在 id → `40001`,回滚(不写 user_permission)。 + - `::grantsDedupedPermissions` —— `permissionIds=[a,a,b]`(均存在)→ 去重后对 `UsrUserPermissionMapper` 批量插入 2 行 `(newUserId,a)`/`(newUserId,b)`(断言插入次数/内容)。 +- [ ] **实现**:在 `UsrUserServiceImpl.createUser` 内:`iEmployeeId` 非空时校验 `usr_employee` 存在(不存在抛 `40001`);`permissionIds` 非空时去重并逐个/批量校验 `usr_permission` 存在(缺失抛 `40001`),用户落库后用 `iIncrement` 批量写 `usr_user_permission`;全程同一 `@Transactional`。 +- [ ] **验证**:子会话跑上述三用例 PASS。 +- [ ] **commit**:`feat(usr): 新增用户关联职员/权限校验与授权写入 REQ-USR-001` + +### T7 — Controller + 权限前置(管理员判定) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(`@WebMvcTest(UsrUserController.class)` + MockMvc,mock `UsrUserService` 与安全上下文): + - `::adminCreateReturnsCodeZeroWithId` —— 模拟管理员(`sUserType=超级管理员`)POST 合法 body → HTTP 200,响应 `code==0`,`data.id` 为返回主键;响应体不含 `sPassword`/明文密码。 + - `::nonAdminReturns40301` —— 模拟普通用户(`sUserType=普通用户`)→ `code==40301`,Service.createUser 不被调用。 + - `::invalidBodyReturns40001` —— `sUserName` 非法 → `code==40001`(经 `@Valid` + `GlobalExceptionHandler`)。 +- [ ] **实现**:`modules/usr/controller/UsrUserController.java`:`@PostMapping("/api/usr/users")`,`@Valid @RequestBody CreateUserDTO`;入口判定 `SecurityUtil.currentUserType()` 非 `超级管理员` 抛 `BusinessException(FORBIDDEN)`(先于业务校验,spec § 3.9);委派 `service.createUser`;返回 `Result.success(Map.of("id", newId))`。Controller 不直接调 Mapper、不写业务逻辑。 +- [ ] **验证**:子会话跑 `UsrUserControllerTest` PASS。 +- [ ] **commit**:`feat(usr): 新增用户 Controller 与管理员权限前置 REQ-USR-001` + +### T8 — 端到端验收回归(按 spec § 7 验收标准收口) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserCreateIT.java`(`@SpringBootTest` + MockMvc,test profile 连测试库,Flyway 已 apply V1;测试内预置/清理 fixture 数据)覆盖 spec § 7: + - `::ac1_normalCreatePersistsHashedPassword` —— 管理员合法 body → `code=0`,库中新增一行,`sPassword` 可被 `encoder.matches("666666", hash)` 通过,`iIsVoid=0`、`sCreator`=调用者、`tCreateDate` 已填。 + - `::ac2_duplicateUserNameRollsBack` —— 重复 `sUserName` → `40901`,无新增行。 + - `::ac4_permissionGrantWritesRows` —— `permissionIds=[a,b]`(先插 fixture 权限)→ `usr_user_permission` 新增 2 行。 + - `::ac5_nonAdminForbidden` —— 普通用户/无 token → `40301` / 401,不创建记录。 + - `::ac7_responseHasNoPassword` —— 成功响应体仅含 `data.id`,无密码字段。 + (IT 通过真实 BCrypt + 真实库写入做端到端确认;如环境无法连库,子会话需明确报告而非静默跳过。) +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如审计字段填充、权限去重边界),不引入新公共契约。 +- [ ] **验证**:子会话跑 `UsrUserCreateIT` PASS(连库);随后跑全量 `mvn -q -B test` 全绿,`mvn -q -B checkstyle:check` 通过。 +- [ ] **commit**:`test(usr): 新增用户端到端验收回归 REQ-USR-001` + +--- + +## 自审 + +### 占位符扫描 +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位(DB 文档 D1/D5 的「需用户审阅」遗留标记不在本后端 REQ 作用域,按 spec D1 取「`sLanguage` 必填、不强制业务默认」继续)。 + +### Spec coverage(spec 每节 → task 映射) +- § 1 Goal → T7(端点)+ 全部 task。 +- § 2.1 输入 / DTO 字段与校验 → T4(DTO 校验)+ T5/T6(Service 兜底与存在性)。 +- § 2.2 输出 `Result<{id}>` → T2(Result)+ T7(Controller 组装)。 +- § 3.1 用户名唯一(含并发 DuplicateKey 转 40901)→ T5。 +- § 3.2 密码默认 + BCrypt + 不落明文/不进响应 → T3(encoder)+ T5(哈希)+ T7/T8(响应不含密码)。 +- § 3.3 用户类型默认与约束 → T4(注解)+ T5(兜底/越界 40001)。 +- § 3.4 语言取值约束 → T4(`@Pattern`)。 +- § 3.5 关联职员存在性 → T6。 +- § 3.6 权限组多对多授权 + 去重 + 唯一索引 → T6。 +- § 3.7 新建即生效 `iIsVoid=0` → T5(审计/状态字段)+ T8(AC1)。 +- § 3.8 制单人/创建时间审计 → T3(MetaObjectHandler)+ T5(显式赋值 sCreator)。 +- § 3.9 权限前置(非管理员 40301,先于业务)→ T7。 +- § 4 约束(分层/包路径/命名/统一响应/异常/事务/认证/数据访问/配置/schema)→ T1-T7 分层落位,schema 复用 V1(无新 migration)。 +- § 5 Schema 引用 → T4(实体/Mapper 映射 4 表)。 +- § 6 错误码 → T2(ResultCode 枚举)。 +- § 7 验收标准 1-7 → T8(IT 覆盖 AC1/2/4/5/7;AC3 参数非法在 T4+T7,AC6 默认密码在 T5+T8 AC1)。 +- § 8 decisions(D1-D5)→ 已体现于「合同级常量」「DTO 形状」与不新增 migration(D4)。 + +### 类型一致性 +- `Result` / `ResultCode` / `BusinessException` 签名在 T2 定义,T5/T6/T7 一致引用。 +- `CreateUserDTO` 字段与校验在 T4 锁定,T5/T6/T7/T8 一致使用。 +- `UsrUserService#createUser(CreateUserDTO):Integer` 与 `UsrUserController#createUser` 返回 `Result`(`data.id`)跨 T5/T7 一致。 +- 错误码字面量 `0/40001/40301/40901` 与 docs/05、spec § 6 一致。 +- REST 路径 `/api/usr/users`、放行路径 `/api/usr/login` 与 docs/05 一致。 diff --git a/docs/superpowers/plans/2026-06-01-REQ-USR-002.md b/docs/superpowers/plans/2026-06-01-REQ-USR-002.md new file mode 100644 index 0000000..cbe735d --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-REQ-USR-002.md @@ -0,0 +1,172 @@ +# REQ-USR-002 修改用户 — 任务级 TDD 计划(后端) + +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / DTO / 校验 / REST 契约实现)。**禁止**写 `frontend/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-002.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-002.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 +> 本 REQ 复用 REQ-USR-001 已建的 backend 骨架与 `modules/usr/**`(controller / service / mapper / entity)、`common/**`(Result / ResultCode / BusinessException / GlobalExceptionHandler / SecurityUtil / JWT);**不**新建公共基础设施,**不**新增 migration。 + +--- + +## Goal(目标) + +实现后台管理员修改已有用户基本信息的唯一端点 `PUT /api/usr/users/{id}`:接收路径 `id` + `UpdateUserDTO`,前置管理员权限校验,校验目标用户存在、枚举/取值合法、关联职员与权限组 id 存在,按"部分更新(null 不改)"语义更新 `usr_user`(不动 `sUserName` / `sPassword` / 审计列 / 租户列),并按"全量覆盖"语义重写该用户在 `usr_user_permission` 的授权,整体单事务,返回 `Result<{ id }>`。变更立即生效(角色 / 禁用状态实时反映到查询与登录流程)。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**`,不跨模块、不动 `common/**`: + +``` +backend/src/main/java/com/xly/erp/modules/usr/ +├── controller/UsrUserController.java # 既有类,新增 @PutMapping("/users/{id}") updateUser;仅 @Valid + 管理员前置 + 委派 +├── service/UsrUserService.java # 既有接口,新增 updateUser(Integer id, UpdateUserDTO dto) +├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 updateUser 实现 @Transactional +├── mapper/UsrUserMapper.java # 既有,复用 BaseMapper(selectById/updateById/update) +├── mapper/UsrUserPermissionMapper.java # 既有,复用 BaseMapper(delete by iUserId / insert) +├── mapper/UsrEmployeeMapper.java # 既有,复用 selectById 做职员存在性校验 +├── mapper/UsrPermissionMapper.java # 既有,复用 selectById 做权限存在性校验 +├── entity/{UsrUser,UsrUserPermission,UsrEmployee,UsrPermission}.java # 既有,无需改 +└── dto/UpdateUserDTO.java # 【本 REQ 新增】修改用户入参 +``` + +- **跨模块**:无。本 REQ 全部落在 `modules/usr/**`,不动 `common/**`,不新增公共契约(错误码 `40401`/`40301`/`40001` 已在 REQ-USR-001 一次性建好的 `ResultCode` 枚举中预留,直接复用,**不改 `ResultCode`**)。 +- **数据访问**:只走 Mapper(MyBatis-Plus);存在性校验 / 删授权用 `LambdaQueryWrapper` 或 MP 内置(`selectById` / `selectCount` / `delete`);Controller 禁止直接调 Mapper。 +- **部分更新落库策略**:为实现"null 列不更新",更新主记录用 MP 的 `updateById`(MP 默认 null 字段不参与 SET,恰好匹配"传 null 不改该列"语义),目标实体只 set 非 null 的可更新列 + 主键 `iIncrement`;不 set `sUserName` / `sPassword` / `sCreator` / `tCreateDate` / 租户列,杜绝覆盖原值。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) + +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus;MySQL 8.x;Flyway 10.x(启动时自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / D5)。 +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证)。 +- 根包 `com.xly.erp`;端口与 DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。 +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 + +## 合同级常量(跨 task 必须一致) + +- REST:`PUT /api/usr/users/{id}`(`id` 为 `@PathVariable Integer`)。 +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**): + - `SUCCESS=0` — 成功,`data.id` = 被修改用户主键。 + - `PARAM_INVALID=40001` — 参数校验失败(字段格式 / 必填 / 枚举越界 / `iEmployeeId` 或 `permissionIds` 元素不存在 / `id` 非正整数)。 + - `FORBIDDEN=40301` — 无权限(非管理员调用)。 + - `NOT_FOUND=40401` — 用户不存在(路径 `id` 无对应 `usr_user` 记录)。 +- 管理员判定口径(与 REQ-USR-001 一致,spec § 8 D6):`SecurityUtil.currentUserType()` 等于常量 `超级管理员` 视为有权;否则抛 `BusinessException(FORBIDDEN)`。控制器内沿用既有 `ADMIN_USER_TYPE = "超级管理员"` 常量,不重复定义新口径。 +- 枚举取值:`sUserType ∈ {普通用户, 超级管理员}`;`sLanguage ∈ {中文, 英文, 繁体}`;`iCanModifyBill ∈ {0,1}`;`iIsVoid ∈ {0,1}`(0 正常 / 1 禁用)。 +- 部分更新语义(spec § 8 D3):可选字段(`sUserNo` / `iEmployeeId` / `iCanModifyBill` / `iIsVoid`)传 `null` = 本次不改该列(保持原值);非 null 即覆盖。 +- 权限覆盖语义(spec § 8 D4):`permissionIds` 非 null = 以该集合(去重)全量覆盖该用户授权(先删该用户全部旧授权再批量插入去重后的目标集合,结果 = 目标集合);`[]` = 清空全部授权;`null` = 不改动现有授权。 +- 只读 / 不接收字段(spec § 2.1 / § 3):`sUserName` / `sPassword` / `tCreateDate` / `sCreator` / `tLastLoginDate` / `iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` 一律不在本接口更新;`UpdateUserDTO` 不含这些字段(即便请求体携带也因 DTO 无该字段而被忽略)。 + +## 关键签名(首次出现处给出,跨 task 保持一致) + +- `UsrUserService#updateUser(Integer id, UpdateUserDTO dto)` 返回 `Integer`(被修改用户主键 `iIncrement`,等于入参 `id`)。 +- `UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)` 返回 `Result>`(`data.id` = 被修改主键,键名 `id`,与 REQ-USR-001 `createUser` 返回形状一致)。 +- 复用既有:`SecurityUtil.currentUserType()` → `String`;`Result.success(T)`;`BusinessException(ResultCode)` / `BusinessException(ResultCode, String)` 暴露 `getResultCode()`;Mapper 继承 `BaseMapper`(`selectById` / `updateById` / `selectCount` / `delete` / `insert`);`UsrUserPermission(Integer iUserId, Integer iPermissionId)` 构造器。 +- 实体 getter/setter 沿用匈牙利前缀风格(`getIIncrement` / `setSUserType` / `setIIsVoid` 等,已存在于 `UsrUser` / `UsrUserPermission`)。 + +### DTO 形状(`UpdateUserDTO`,置于 `modules/usr/dto`) + +> 字段为匈牙利前缀命名(与列名一致),getter 形如 `getSUserType` 会被 Jackson 推断为属性名 `SUserType`,与契约 JSON 键不符;故对带前缀字段显式 `@JsonProperty` 锁定 JSON 键名(与 `CreateUserDTO` 同样做法)。 + +| 字段 | 类型 | 校验注解 | 语义 | +|---|---|---|---| +| `sUserNo` | String | `@Size(max=50)` | 可选;非 null 覆盖,null 不改 | +| `iEmployeeId` | Integer | —(存在性在 Service 校验) | 可选;非 null 须存在于 `usr_employee` 否则 `40001`;null 不改 | +| `sUserType` | String | `@NotBlank` + `@Pattern(regexp="^(普通用户|超级管理员)$")` | 必填;越界 `40001` | +| `sLanguage` | String | `@NotBlank` + `@Pattern(regexp="^(中文|英文|繁体)$")` | 必填;越界 `40001` | +| `iCanModifyBill` | Integer | `@Min(0)` + `@Max(1)` | 可选;非 0/1 越界 `40001`;null 不改 | +| `iIsVoid` | Integer | `@Min(0)` + `@Max(1)` | 可选;0 正常 / 1 禁用;非 0/1 越界 `40001`;null 不改 | +| `permissionIds` | `List` | —(元素存在性在 Service 校验) | 可选;非 null 全量覆盖授权;`[]` 清空;null 不改 | + +> 注:`@Valid` 失败由既有 `GlobalExceptionHandler` 统一转 `40001`。`sUserType` / `sLanguage` 既由注解兜底,也在 Service 端不做额外越界放行;Service 对"目标用户不存在"抛 `40401`,对"关联 id 不存在"抛 `40001`。本 DTO 不含 `sUserName` / `sPassword` / 审计 / 租户字段。 + +--- + +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) + +> 业务类 commit subject 必须带 `REQ-USR-002` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 + +### T1 — `UpdateUserDTO` + Bean Validation +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/dto/UpdateUserDTOValidationTest.java`(用 `jakarta.validation.Validator` 直接 validate DTO 断言 violations 数量): + - `::acceptsMinimalValidBody` —— 仅 `{sUserType:"普通用户", sLanguage:"中文"}`(其余可选字段全 null)无违反。 + - `::rejectsBlankUserType` —— `sUserType` 为空白触发 `@NotBlank`。 + - `::rejectsIllegalLanguage` —— `sLanguage="日文"` 违反 `@Pattern`。 + - `::rejectsOutOfRangeIsVoid` —— `iIsVoid=2` 违反 `@Max(1)`;`::rejectsOutOfRangeCanModifyBill` —— `iCanModifyBill=2` 违反 `@Max(1)`。 +- [ ] **实现**:`modules/usr/dto/UpdateUserDTO.java`,按上「DTO 形状」加字段 + `@JsonProperty` + 校验注解 + getter/setter(不含 `sUserName`/`sPassword`/审计/租户字段)。 +- [ ] **验证**:子会话跑 `UpdateUserDTOValidationTest` PASS。 +- [ ] **commit**:`feat(usr): 修改用户入参 UpdateUserDTO 与校验 REQ-USR-002` + +### T2 — Service:目标用户存在性 + 主记录部分更新(核心写) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(续既有类,Mockito mock 4 Mapper + PasswordEncoder + SecurityUtil 静态): + - `::updateNonExistentUserThrows40401` —— `usrUserMapper.selectById(id)` 返回 null → 抛 `BusinessException(NOT_FOUND)`,且不调用 `updateById` / 不动权限表。 + - `::updateAppliesNonNullColumnsAndKeepsIdentityImmutable` —— 目标用户存在,DTO 设 `sUserType="超级管理员"` / `sLanguage="英文"` / `iCanModifyBill=1` / `iIsVoid=1` / `sUserNo="N9"`,断言传给 `usrUserMapper.updateById` 的 `UsrUser`:`iIncrement==id`、上述列为新值、且 `sUserName`/`sPassword`/`sCreator`/`tCreateDate` 未被赋值(保持 null,证明不参与 SET,依赖 MP null 不更新语义)。 + - `::nullOptionalColumnsAreNotOverwritten` —— DTO 仅含必填 `sUserType`/`sLanguage`,`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid` 均 null → 传给 `updateById` 的实体这些字段为 null(MP 不 SET,保持原值)。 +- [ ] **实现**:`UsrUserService.java` 新增 `Integer updateUser(Integer id, UpdateUserDTO dto)`;`UsrUserServiceImpl.java` 新增实现并标 `@Transactional(rollbackFor = Exception.class)`:先 `selectById(id)` 校验存在(null → `40401`);组装目标 `UsrUser` 仅 `setIIncrement(id)` + set 非 null 可更新列(`sUserType`/`sLanguage` 必填总 set,`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid` 仅在 DTO 非 null 时 set);`updateById`。 +- [ ] **验证**:子会话跑上述 3 个用例 PASS。 +- [ ] **commit**:`feat(usr): 修改用户主记录部分更新与存在性校验 REQ-USR-002` + +### T3 — Service:关联职员存在性 + 权限组全量覆盖 +- [ ] **测试**:续 `UsrUserServiceImplTest`: + - `::nonExistentEmployeeOnUpdateThrows40001` —— 目标用户存在但 DTO `iEmployeeId` 指向 `usrEmployeeMapper.selectById` 返回 null → `BusinessException(PARAM_INVALID)`,不 `updateById`、不动权限表(事务回滚语义)。 + - `::nonExistentPermissionOnUpdateThrows40001` —— `permissionIds` 含 `usrPermissionMapper.selectById` 返回 null 的 id → `40001`,不 `updateById`、不写权限表。 + - `::permissionIdsOverwriteDeletesThenInserts` —— `permissionIds=[a,a,b]`(均存在)→ 先对 `usrUserPermissionMapper.delete`(按 `iUserId=id`)调用 1 次清旧授权,再去重批量 `insert` 2 行 `(id,a)`/`(id,b)`(断言 delete 与 insert 次数/内容;用 `ArgumentCaptor` 验证 `iUserId==id`、`iPermissionId` 含 a/b 各一次)。 + - `::emptyPermissionIdsClearsAll` —— `permissionIds=[]` → `delete`(按 `iUserId=id`)被调用,`insert` 不被调用(清空)。 + - `::nullPermissionIdsLeavesGrantsUntouched` —— `permissionIds=null` → `delete` 与 `insert` 均不被调用(不改动授权)。 +- [ ] **实现**:在 `updateUser` 内(主记录更新前先做存在性校验,保证非法即整体回滚不留副作用):`iEmployeeId` 非 null 时校验 `usr_employee` 存在(缺失 `40001`);`permissionIds` 非 null 时去重并逐个校验 `usr_permission` 存在(缺失 `40001`);主记录 `updateById` 成功后,若 `permissionIds` 非 null 则按 `LambdaQueryWrapper.eq(UsrUserPermission::getIUserId, id)` 全量删除旧授权,再对去重集合逐行 `insert(new UsrUserPermission(id, permId))`(空集合则只删不插);全程同一 `@Transactional`。 +- [ ] **验证**:子会话跑上述 5 个用例 PASS。 +- [ ] **commit**:`feat(usr): 修改用户关联职员校验与权限组全量覆盖 REQ-USR-002` + +### T4 — Controller + 管理员权限前置 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(续既有类,MockMvc standaloneSetup + 真实 `GlobalExceptionHandler` + mock `UsrUserService` + mock `SecurityUtil` 静态): + - `::adminUpdateReturnsCodeZeroWithId` —— 管理员(`currentUserType="超级管理员"`)`PUT /api/usr/users/55` 带合法 body(`sUserType`/`sLanguage` 合法)→ HTTP 200,`code==0`,`data.id==55`;响应体不含 `sPassword`/`password`。 + - `::nonAdminUpdateReturns40301` —— 普通用户 → `code==40301`,`usrUserService.updateUser` 不被调用。 + - `::invalidBodyUpdateReturns40001` —— body 缺 `sUserType`(`@NotBlank` 失败)→ `code==40001`,Service 不被调用。 + - `::userNotFoundReturns40401` —— 管理员 + 合法 body,但 `usrUserService.updateUser` 抛 `BusinessException(NOT_FOUND)` → `code==40401`(验证 Controller 不吞业务异常、由全局处理器转码)。 +- [ ] **实现**:`UsrUserController.java` 新增 `@PutMapping("/users/{id}")` 方法 `updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO dto)`:入口判定 `!ADMIN_USER_TYPE.equals(SecurityUtil.currentUserType())` 抛 `BusinessException(FORBIDDEN)`(先于业务校验,spec § 3.9);委派 `usrUserService.updateUser(id, dto)`;返回 `Result.success(Map.of("id", id))`。Controller 不直接调 Mapper、不写业务逻辑。 +- [ ] **验证**:子会话跑 `UsrUserControllerTest`(既有 + 新增用例)PASS。 +- [ ] **commit**:`feat(usr): 修改用户 Controller 与管理员权限前置 REQ-USR-002` + +### T5 — 端到端验收回归(按 spec § 7 验收标准收口) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserUpdateIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;用真实 `JwtUtil` 签发 token 走安全链;`@AfterEach` 自清理本测试 fixture,命名前缀如 `it2_user_` / `IT2_PERM_` 便于 likeRight 清理;管理员 token 与普通用户 token 仿 `UsrUserCreateIT` 生成)覆盖 spec § 7: + - `::ac1UpdateBasicInfoPersists` —— 先经 `POST /api/usr/users` 建一个用户取 `id`(或直接 Mapper 插 fixture),再 `PUT /api/usr/users/{id}` 改 `sUserType`/`sLanguage`/`iCanModifyBill`/`sUserNo` → `code=0`、`data.id==id`;库中该行上述列为新值,且 `sUserName`/`sPassword`/`sCreator`/`tCreateDate` 字节级不变(保存改前后 `selectById` 比对)。 + - `::ac2UpdateNonExistentReturns40401` —— `PUT /api/usr/users/{很大且不存在的 id}` → `code=40401`,无任何写入。 + - `::ac3InvalidParamRollsBack` —— `iEmployeeId` 指向不存在职员(或 `permissionIds` 含不存在 id)→ `code=40001`,目标用户行各列与调用前一致(无副作用)。 + - `::ac6PermissionOverwrite` —— 预置该用户授权为 `{a,c}`(fixture 插 `usr_permission` a/b/c + `usr_user_permission`),`PUT` 传 `permissionIds=[a,b,a]` → 覆盖后该用户授权恰为 `{a,b}`(c 被删、b 新增、a 去重一次);另一用例或同用例追加:传 `permissionIds=[]` → 该用户授权清空;不传 `permissionIds` → 授权不变。 + - `::ac7NonAdminAndNoTokenBlocked` —— 普通用户 token → `code=40301`;无 token → HTTP 401;两种情况下目标用户行均未被修改。 + - `::ac8PasswordUnchangedAndAbsentFromResponse` —— 修改成功后 `sPassword` 列与改前字节级一致(`selectById` 比对哈希值相等);成功响应体仅含 `data.id`,不含 `sPassword` / 明文 / `password` 字段。 + - (AC4「禁用实时生效」、AC5「角色变更实时生效」依赖 REQ-USR-004 登录 / REQ-USR-003 查询接口,尚未实现;本 IT 以"`PUT iIsVoid=1` / 改 `sUserType` 后 `selectById` 读回库内 `iIsVoid==1` / `sUserType` 为新值"做后端落库层等价验证,登录/查询联动留待对应 REQ 的 IT 覆盖,在本测试注释中标注此边界。) +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如 MP `updateById` 对 null 字段的实际行为与预期不符时调整组装策略、权限删除 wrapper 边界),不引入新公共契约、不新增 migration。 +- [ ] **验证**:子会话跑 `UsrUserUpdateIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。 +- [ ] **commit**:`test(usr): 修改用户端到端验收回归 REQ-USR-002` + +--- + +## 自审 + +### 占位符扫描 +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本后端 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体})继续,不阻塞。 + +### Spec coverage(spec 每节 → task 映射) +- § 1 Goal(唯一端点 `PUT /api/usr/users/{id}`、`sUserName` 不可改、密码不改)→ T4(端点)+ T2(不动 `sUserName`/`sPassword`)+ 全部 task。 +- § 2.1 输入 / DTO 字段与校验 / 路径参数 `id` / Auth → T1(DTO 校验)+ T2/T3(Service 存在性与覆盖)+ T4(路径参数 + 管理员前置)。 +- § 2.1 忽略只读/不接收字段(`sUserName`/`sPassword`/审计/租户)→ T1(DTO 不含这些字段)+ T2(组装实体不 set 这些列)+ T5 AC8(密码不变)。 +- § 2.2 输出 `Result<{id}>`、不含敏感字段 → T4(Controller 组装)+ T5 AC8。 +- § 3.1 目标用户必须存在(`40401`,不写入)→ T2 + T5 AC2。 +- § 3.2 `sUserName` 不可改 → T1(DTO 无该字段)+ T2 + T5 AC1。 +- § 3.3 密码不改/不返回 → T2(不 set `sPassword`)+ T5 AC8。 +- § 3.4 用户类型必填 + 枚举约束(越界 `40001`)→ T1(`@NotBlank`+`@Pattern`)+ T4。 +- § 3.5 语言必填 + 枚举约束 → T1(`@NotBlank`+`@Pattern`)。 +- § 3.6 关联职员可选且需存在(`40001`)/ 部分更新语义 → T3 + T2(null 不改)。 +- § 3.7 作废/禁用实时生效(`iIsVoid ∈ {0,1}`,越界 `40001`)→ T1(`@Min/@Max`)+ T2(落库)+ T5(落库层等价验证,登录联动留 REQ-USR-004)。 +- § 3.8 权限组全量覆盖(`permissionIds` 非 null 覆盖 / `[]` 清空 / null 不改 / 去重 / 唯一索引)→ T3 + T5 AC6。 +- § 3.9 权限校验前置(非管理员 `40301`,先于业务)→ T4。 +- § 3.10 审计字段只读、不新增列/migration → T2(不 set 审计列)+ Tech Stack(不新增 migration)。 +- § 4 约束(分层/包路径/命名 `updateUser`/统一响应/异常/事务/认证/数据访问/配置/schema)→ T1-T4 分层落位,事务在 T2/T3,schema 复用 V1。 +- § 5 Schema 引用(写 `usr_user`/`usr_user_permission`、读 `usr_employee`/`usr_permission`)→ T2/T3(复用既有实体/Mapper)。 +- § 6 错误码(`0`/`40001`/`40401`/`40301`)→ 复用既有 `ResultCode`,T2(`40401`)/T3(`40001`)/T4(`40301`)。 +- § 7 验收标准 1-8 → T5(AC1/2/3/6/7/8 直接覆盖;AC4/AC5 以落库层等价验证 + 注释边界,联动留对应 REQ)。 +- § 8 decisions(D1-D6)→ D1(不改密码)T2、D2(`iIsVoid` 承载状态)T1/T2、D3(null 不改)T2、D4(全量覆盖)T3、D5(不新增 migration)Tech Stack、D6(管理员口径)T4 已体现。 + +### 类型一致性 +- `UsrUserService#updateUser(Integer id, UpdateUserDTO):Integer` 在 T2 定义,T3(实现续写)/T4(Controller 调用)/T5(IT)一致引用。 +- `UsrUserController#updateUser(@PathVariable Integer id, @Valid @RequestBody UpdateUserDTO)` 返回 `Result>`(`data.id`),与 REQ-USR-001 `createUser` 返回形状一致。 +- `UpdateUserDTO` 字段与校验注解在 T1 锁定,T2/T3/T4/T5 一致使用;字段命名与 `UsrUser` 列名匈牙利前缀一致,`@JsonProperty` 锁 JSON 键(与 `CreateUserDTO` 同风格)。 +- 错误码字面量 `0/40001/40301/40401` 复用既有 `ResultCode`,与 docs/05、spec § 6 一致,不新增枚举常量。 +- REST 路径 `PUT /api/usr/users/{id}`、管理员口径 `超级管理员` 与 docs/05、spec、既有 `UsrUserController.ADMIN_USER_TYPE` 一致。 +- Mapper 复用既有 `BaseMapper`(`selectById`/`updateById`/`delete`/`insert`/`selectCount`)+ `UsrUserPermission(Integer,Integer)` 构造器,无新增 Mapper 方法签名。 diff --git a/docs/superpowers/plans/2026-06-01-REQ-USR-003.md b/docs/superpowers/plans/2026-06-01-REQ-USR-003.md new file mode 100644 index 0000000..304485d --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-REQ-USR-003.md @@ -0,0 +1,256 @@ +# REQ-USR-003 查询用户 — 任务级 TDD 计划(后端) + +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / mapper XML / DTO / VO / 公共响应 / REST 契约实现)。**禁止**写 `frontend/**`。 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-003.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 +> 本 REQ 复用 REQ-USR-001/002 已建的 `modules/usr/**`(`UsrUserController` / `UsrUserService` / `UsrUserServiceImpl` / `UsrUserMapper` / `UsrEmployee`/`UsrUser` 实体)与 `common/**`(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `SecurityUtil` / JWT / `MybatisPlusConfig` 分页插件);**纯只读查询**,**不新增 migration**(spec § 4 / § 8 D9,仅读 `usr_user` / `usr_employee`,二表已在 `V1__initial_schema.sql` 建好)。 + +--- + +## Goal(目标) + +实现后台用户查询的唯一只读端点 `GET /api/usr/users`:接收 `UserQueryDTO`(`queryField` / `matchType` / `queryValue` / `pageNum` / `pageSize`,全部可选),按"单字段 + 单匹配方式"施加一个过滤条件(空值返回全量分页),`usr_user LEFT JOIN usr_employee` 取员工名 / 部门,分页装配 `PageResult` 返回 `Result>`(`code=0`)。查询**无任何写副作用**,响应**绝不返回 `sPassword` 或任何敏感字段**。分页参数非法(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`;查询参数越界(枚举不合法 / 超长 / 布尔值不可解析为 0/1 / 日期值非法)→ `40001`;数据层「请求页超总页数」钳制到最后一页返回 `code=0`。任意已认证用户可调用(无管理员限制,spec § 8 D5),无 / 失效 token → 401。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与新增公共分页响应体 `common/response/PageResult.java`(spec § 2.2 / docs/04 § 1.4 / § 3.2 引用但前序 REQ 未建),不跨业务模块: + +``` +backend/src/main/java/com/xly/erp/ +├── common/response/PageResult.java # 【本 REQ 新增】通用分页响应体(records/total/pageNum/pageSize),后续模块复用 +├── modules/usr/ +│ ├── controller/UsrUserController.java # 既有类,新增 @GetMapping("/users") queryUsers;仅 @Valid + 委派(本端点无管理员前置,spec § 8 D5) +│ ├── service/UsrUserService.java # 既有接口,新增 queryUsers(UserQueryDTO dto) +│ ├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 queryUsers 实现(参数判定 + 条件解析 + 分页装配) +│ ├── mapper/UsrUserMapper.java # 既有,新增自定义方法 selectUserPage(LEFT JOIN + 动态条件,走 XML) +│ ├── entity/{UsrUser,UsrEmployee}.java # 既有,只读,无需改 +│ ├── dto/UserQueryDTO.java # 【本 REQ 新增】查询入参 +│ └── vo/UserVO.java # 【本 REQ 新增】查询输出(不含 sPassword/租户列) +└── resources/mapper/usr/UsrUserMapper.xml # 【本 REQ 新增】queryUsers 复杂 SQL(LEFT JOIN + 动态条件 + resultMap → UserVO) +``` + +- **跨模块**:无。本 REQ 落在 `modules/usr/**` + 新增 `common/response/PageResult.java`(公共契约,非跨业务模块)。新增 `PageResult` 属于 docs/04 § 1.4 / § 3.2 已定义但尚未落盘的公共响应体,建在 `common/response`(与 `Result` / `ResultCode` 同包),后续分页 REQ 复用——需在《模块完成报告》留痕「新增公共分页响应体」(CLAUDE.md 跨模块改动留痕,虽非跨业务模块,新增公共契约一并记录)。 +- **数据访问**:只走 Mapper(MyBatis-Plus)。LEFT JOIN(`usr_user ⋈ usr_employee`)+ 动态单条件 + 分页属"复杂 SQL",按 docs/04 § 3.4 写 **Mapper XML**(`UsrUserMapper.selectUserPage(IPage page, ...)`),由 MP `PaginationInnerInterceptor`(`MybatisPlusConfig` 已注册)自动补 `LIMIT` 与 `COUNT(*)`。Controller 禁止直接调 Mapper。 +- **XML 扫描 / 列映射**:`application.yml` 未显式配 `mybatis-plus.mapper-locations`,MP 默认扫描 `classpath*:/mapper/**/*.xml`,故 XML 落在 `resources/mapper/usr/` 即被自动加载,**无需改 `application.yml`**;若 T4 子会话验证发现 XML 未加载,则最小补 `mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml`(仅此一行,记入 decisions)。因 `map-underscore-to-camel-case: false`,XML 用 `` 显式把列(含 `usr_employee` 别名列)映射到 `UserVO` 字段,不依赖驼峰自动映射。 +- **只读 / 不写**:`queryUsers` 标 `@Transactional(readOnly = true)`(spec § 3.1,可选但推荐);SQL 仅 `SELECT`,**不 SELECT `sPassword`**,不写任何表、不更新 `tLastLoginDate`、不落审计。 +- **安全过滤**:防注入用 `#{}` 预编译占位;LIKE 模糊查询对 `queryValue` 的 `%` `_` `\` 转义并 `ESCAPE '\\'`(spec § 8 D3),转义在 Service 端对 `queryValue` 预处理后传入 XML。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) + +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(分页插件 `PaginationInnerInterceptor` 已在 `MybatisPlusConfig` 注册);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / § 8 D9)。 +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证),但**无管理员前置**(任意已认证用户可调用,spec § 8 D5)。 +- 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。 +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 + +## 合同级常量(跨 task 必须一致) + +- REST:`GET /api/usr/users`(query 参数绑定到 `UserQueryDTO`,非 `@RequestBody`)。 +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**;`PARAM_INVALID=40001`、`PAGE_PARAM_INVALID=42201`、`SUCCESS=0` 已存在): + - `SUCCESS=0` — 成功,`data` = `PageResult`。 + - `PARAM_INVALID=40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值不可解析为 0/1 / 日期字段值非法)。 + - `PAGE_PARAM_INVALID=42201` — 分页参数非法(`pageNum<1` 或 `pageSize<1` 或 `pageSize>100`)。 + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。 +- 枚举取值(spec § 2.1 / § 3.4): + - `queryField ∈ {用户名, 员工名, 用户号, 部门, 用户类型, 作废, 登录日期, 制单人}`,默认 `用户名`。 + - `matchType ∈ {包含, 不包含, 等于}`,默认 `包含`。 +- 字段 → 列映射(中文 `queryField` → 查询列,spec § 3.4,写死于 Service 映射常量): + | queryField | 列 | 类型语义 | + |---|---|---| + | `用户名` | `usr_user.sUserName` | 文本 | + | `员工名` | `usr_employee.sEmployeeName` | 文本(跨表) | + | `用户号` | `usr_user.sUserNo` | 文本 | + | `部门` | `usr_employee.sDepartment` | 文本(跨表) | + | `用户类型` | `usr_user.sUserType` | 枚举 | + | `作废` | `usr_user.iIsVoid` | 布尔 0/1 | + | `登录日期` | `usr_user.tLastLoginDate` | 日期时间 | + | `制单人` | `usr_user.sCreator` | 文本 | +- 匹配语义(spec § 3.5): + - 文本字段:`包含` → `LIKE '%v%' ESCAPE '\\'`;`不包含` → `NOT LIKE '%v%' ESCAPE '\\'`;`等于` → `= v`。`v` 中 `%` `_` `\` 须转义。 + - 枚举字段(用户类型):`等于` → `= v`;`包含` → `LIKE '%v%'`、`不包含` → `NOT LIKE '%v%'`(容错允许,不报错)。 + - 布尔字段(作废):`queryValue` 归一化为 0/1(接受 `0`/`1`、`是`/`否`、`true`/`false`,spec § 8 D6),按 `等于`→`= 0/1`、`包含`→`= 0/1`、`不包含`→`<> 0/1`;不可解析 → `40001`。 + - 日期字段(登录日期):`queryValue` 解析为 `yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`(spec § 8 D6);`等于`/`包含` → 命中当日整天区间 `[day 00:00:00, day+1 00:00:00)`;`不包含` → 当日区间取反;非法 → `40001`。 +- 分页语义(spec § 3.7 / § 8 D1): + - 默认 `pageNum=1`、`pageSize=10`;上限 `pageSize=100`。 + - **参数非法**(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`,先于查询判定。 + - **数据越界**(`pageNum≥1` 合法但超实际总页数)→ 钳制到最后一页返回 `code=0`,`PageResult.pageNum` 回传钳制后实际页号,`total` 为真实总数;`total=0` 时返回空 `records`、`pageNum` 回传 1。 +- 空条件语义(spec § 3.2):`queryValue` 为 null / trim 后空串 → 不施加业务过滤,仅按分页返回全量;此时 `queryField` / `matchType` 不生效。 +- 跨表关联(spec § 3.6 / § 8 D7):`usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**;未关联职员的用户仍出现在结果,`employeeName` / `department` 为 null;`不包含` 对该列 null 行依标准 SQL 三值逻辑自然不命中(不写 `OR col IS NULL`)。 +- 敏感字段(spec § 3.9 / § 9):SQL 不 SELECT `sPassword`;`UserVO` 不含密码、不含租户列 `sId`/`sBrandsId`/`sSubsidiaryId`;输出列严格按下「VO 形状」。 + +## 关键签名(首次出现处给出,跨 task 保持一致) + +- `UsrUserService#queryUsers(UserQueryDTO dto)` 返回 `PageResult`。 +- `UsrUserController#queryUsers(@Valid UserQueryDTO dto)` 返回 `Result>`(query 参数自动绑定到 DTO,无 `@RequestBody`)。 +- `UsrUserMapper#selectUserPage(com.baomidou.mybatisplus.core.metadata.IPage page, @Param("cond") UserQueryCondition cond)` 返回 `IPage`(XML 实现;MP 分页插件补 LIMIT/COUNT)。其中 `UserQueryCondition` 为 Service 内部传给 Mapper 的「已解析」条件载体(携带目标列名 token、SQL 片段类型、转义后文本值 / 布尔值 / 日期区间起止),由 Service 把 `UserQueryDTO` 的中文 `queryField`/`matchType`/原始 `queryValue` 解析归一为可直接拼 XML `` 分支的结构——避免在 XML 内做中文枚举判断与类型解析。 + - 备选实现(若执行者认为更简单):用 `@Param` 直接传若干已解析标量(如 `column`、`matchOp`、`textValue`、`intValue`、`dateStart`、`dateEnd`、`isText`/`isBool`/`isDate` 标志)替代 `UserQueryCondition` 对象,二者择一、保持 XML `` 分支与 Service 解析结果一致即可。`column` 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。 +- 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` 暴露 `getResultCode()`;`UsrUserMapper extends BaseMapper`;实体 getter 匈牙利前缀(`getSUserName`/`getIIsVoid`/`getTLastLoginDate` 等)。 +- 实体 / VO getter-setter 沿用匈牙利前缀风格;`UserVO` 跨表字段用驼峰 `employeeName`/`department`(spec § 2.2 契约键名)。 + +### DTO 形状(`UserQueryDTO`,置于 `modules/usr/dto`) + +> query 参数绑定(非 JSON body)。带匈牙利前缀字段的 getter(如 `getSUserName` 类似情况此处不涉及)与查询参数键名需一致;`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize` 为普通小驼峰,query 参数名直接同名绑定,无需 `@JsonProperty`。 + +| 字段 | 类型 | 校验注解 | 默认 | 语义 | +|---|---|---|---|---| +| `queryField` | String | `@Pattern(regexp="^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$")`(null 跳过校验,默认值在 Service 兜底)| `用户名` | 查询字段;越界 `40001` | +| `matchType` | String | `@Pattern(regexp="^(包含|不包含|等于)$")`(null 跳过)| `包含` | 匹配方式;越界 `40001` | +| `queryValue` | String | `@Size(max=100)` | — | 查询值;null / trim 后空 = 不施加条件 | +| `pageNum` | Integer | —(范围在 Service 入口显式判定 `42201`,spec § 8 D8)| `1` | 页码,从 1 起 | +| `pageSize` | Integer | —(范围在 Service 入口显式判定 `42201`)| `10` | 每页条数,1..100 | + +> 注:`pageNum`/`pageSize` **不**用 `@Min/@Max`(避免 `@Valid` 失败被全局处理器统一转 `40001`,与 spec 要求的 `42201` 冲突,spec § 8 D8);改在 Service 入口显式判定范围并抛 `BusinessException(PAGE_PARAM_INVALID)`。`@Valid` 失败(`queryField`/`matchType`/`queryValue` 注解)由既有 `GlobalExceptionHandler` 统一转 `40001`。DTO 提供 getter/setter,默认值可在字段初始化或 Service 兜底(择一,保持 Service 对 null 的兜底逻辑一致)。 + +### VO 形状(`UserVO`,置于 `modules/usr/vo`,严格按 spec § 2.2,**不含 `sPassword` / 租户列**) + +| VO 字段 | 类型 | 来源列 | +|---|---|---| +| `id` | Integer | `usr_user.iIncrement` | +| `sUserName` | String | `usr_user.sUserName` | +| `employeeName` | String | `usr_employee.sEmployeeName`(LEFT JOIN,可 null) | +| `sUserNo` | String | `usr_user.sUserNo` | +| `department` | String | `usr_employee.sDepartment`(LEFT JOIN,可 null) | +| `sUserType` | String | `usr_user.sUserType` | +| `sLanguage` | String | `usr_user.sLanguage` | +| `iIsVoid` | Integer | `usr_user.iIsVoid` | +| `tLastLoginDate` | LocalDateTime | `usr_user.tLastLoginDate`(可 null) | +| `sCreator` | String | `usr_user.sCreator` | +| `tCreateDate` | LocalDateTime | `usr_user.tCreateDate` | + +> `UserVO` 提供全部字段 getter/setter;带匈牙利前缀字段(`sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iIsVoid`/`sCreator`)的 getter 形如 `getSUserName` 会被 Jackson 推断为 `SUserName`,与契约键名不符——对这些字段加 `@JsonProperty`(与 `CreateUserDTO`/`UpdateUserDTO` 同做法)锁定 JSON 键为 `sUserName` 等小驼峰;`employeeName`/`department`/`id`/`tLastLoginDate`/`tCreateDate` 普通驼峰无需 `@JsonProperty`。XML `` 把列映射到 VO 字段名。 + +### `PageResult` 形状(`common/response/PageResult.java`,docs/04 § 1.4 / § 3.2) + +| 字段 | 类型 | 语义 | +|---|---|---| +| `records` | `List` | 当前页数据 | +| `total` | `long` | 真实总记录数 | +| `pageNum` | `long` | 当前页号(数据越界钳制后的实际页号)| +| `pageSize` | `long` | 每页条数 | + +> 提供全字段 getter/setter + 全参构造器(或静态工厂 `of(records, total, pageNum, pageSize)`),便于 Service 从 MP `IPage` 装配。`implements Serializable`(与 `Result` 一致)。 + +--- + +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) + +> 业务类 commit subject 必须带 `REQ-USR-003` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 + +### T1 — `PageResult` 公共分页响应体 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/PageResultTest.java`: + - `::ofAssemblesAllFields` —— 用 `records=[..]`、`total=23`、`pageNum=2`、`pageSize=10` 构造(构造器或 `of`),断言四字段读回一致。 + - `::emptyRecordsAllowed` —— `records=[]`、`total=0`、`pageNum=1` 构造,断言 `records` 非 null 且为空、`total==0`。 +- [ ] **实现**:`common/response/PageResult.java`,按上「`PageResult` 形状」加 `records`/`total`/`pageNum`/`pageSize` 字段 + getter/setter + 全参构造器(或 `of` 静态工厂)+ `implements Serializable`。 +- [ ] **验证**:子会话跑 `PageResultTest` PASS。 +- [ ] **commit**:`feat(usr): 通用分页响应体 PageResult REQ-USR-003` + +### T2 — `UserQueryDTO` + `UserVO`(入参校验 + 输出 JSON 键) +- [ ] **测试**: + - `backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java`(用 `jakarta.validation.Validator` validate): + - `::acceptsAllNullAsValid` —— 全 null(含 `queryField`/`matchType`/`queryValue`)无违反(全部可选)。 + - `::acceptsLegalEnums` —— `queryField=登录日期`、`matchType=不包含` 无违反。 + - `::rejectsIllegalQueryField` —— `queryField=身份证` 违反 `@Pattern`。 + - `::rejectsIllegalMatchType` —— `matchType=大于` 违反 `@Pattern`。 + - `::rejectsTooLongQueryValue` —— `queryValue` 长度 101 违反 `@Size(max=100)`。 + - `backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java`(用 `ObjectMapper` 序列化): + - `::serializesContractKeysNoPassword` —— 构造一个填满字段的 `UserVO`,序列化后 JSON 含键 `id`/`sUserName`/`employeeName`/`sUserNo`/`department`/`sUserType`/`sLanguage`/`iIsVoid`/`tLastLoginDate`/`sCreator`/`tCreateDate`,且**不含** `sPassword`/`password`/`SUserName`(验证 `@JsonProperty` 锁键生效、无密码字段)。 +- [ ] **实现**:`modules/usr/dto/UserQueryDTO.java`(按「DTO 形状」字段 + `@Pattern`/`@Size` + getter/setter,`pageNum`/`pageSize` 不加 `@Min/@Max`);`modules/usr/vo/UserVO.java`(按「VO 形状」11 字段 + `@JsonProperty` 锁匈牙利前缀字段键名 + getter/setter,不含 `sPassword`/租户列)。 +- [ ] **验证**:子会话跑两测试 PASS。 +- [ ] **commit**:`feat(usr): 查询用户入参 UserQueryDTO 与输出 UserVO REQ-USR-003` + +### T3 — Mapper:`selectUserPage` 自定义查询(LEFT JOIN + 动态条件 XML) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(测试回滚,避免污染库)连测试库;`@Autowired UsrUserMapper`;测试内插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 `usr_employee`): + - `::pageReturnsLeftJoinedEmployeeColumns` —— 无过滤条件、`Page(1,10)` 调 `selectUserPage` → 返回 `IPage`,关联职员的用户行 `employeeName`/`department` 非 null 且等于 fixture 职员值;未关联职员的用户行两列为 null;`total≥2`。 + - `::pageAppliesTextLikeOnUserName` —— 条件为「`sUserName` 文本 `包含` fixture 用户名片段」→ 仅命中该用户、`total` 正确。 + - `::pageNeverSelectsPassword` —— 任意结果 `UserVO` 无密码字段(VO 无该属性即天然满足;额外断言 `selectUserPage` 不抛错且 records 元素为 `UserVO` 类型)。 + - (命名前缀如 `t3_user_` / `T3_EMP_` 便于 `@Transactional` 回滚兜底外再清理;若用 `@Transactional` 回滚则无需 `@AfterEach`。) +- [ ] **实现**: + - `modules/usr/mapper/UsrUserMapper.java` 新增方法签名 `IPage selectUserPage(IPage page, @Param("cond") UserQueryCondition cond)`(或备选标量 `@Param` 版,见「关键签名」)。 + - 新增 `resources/mapper/usr/UsrUserMapper.xml`:`namespace=com.xly.erp.modules.usr.mapper.UsrUserMapper`;`` 把 `u.iIncrement→id`、`u.sUserName→sUserName`、`e.sEmployeeName→employeeName`、`u.sUserNo→sUserNo`、`e.sDepartment→department`、`u.sUserType→sUserType`、`u.sLanguage→sLanguage`、`u.iIsVoid→iIsVoid`、`u.tLastLoginDate→tLastLoginDate`、`u.sCreator→sCreator`、`u.tCreateDate→tCreateDate` 映射;`