Commit 95bdce3624b2252ddd090f3ee6bdf93b098594d0

Authored by zichun
1 parent c6d8e4e3

feat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003

backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java 0 → 100644
  1 +package com.xly.erp.modules.usr.dto;
  2 +
  3 +import java.time.LocalDateTime;
  4 +
  5 +/**
  6 + * 查询条件「已解析」内部载体(Service → Mapper)。REQ-USR-003 T3 / T4。
  7 + *
  8 + * <p>非对外契约:由 Service 把 {@link UserQueryDTO} 的中文 {@code queryField}/{@code matchType}/
  9 + * 原始 {@code queryValue} 解析归一为可直接拼 XML {@code <if>} 分支的结构,避免在 XML 内做中文
  10 + * 枚举判断与类型解析。{@code column} 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。</p>
  11 + *
  12 + * <p>分支种类 {@link Kind}:{@code NONE} 无过滤(全量分页);{@code TEXT} 文本 / 枚举
  13 + * ({@code LIKE}/{@code NOT LIKE}/{@code =});{@code BOOL} 布尔({@code =}/{@code <>});
  14 + * {@code DATE} 日期区间({@code >=}/{@code <},可取反)。</p>
  15 + */
  16 +public class UserQueryCondition {
  17 +
  18 + /** 过滤分支种类。 */
  19 + public enum Kind {
  20 + /** 无过滤。 */
  21 + NONE,
  22 + /** 文本 / 枚举模糊或精确。 */
  23 + TEXT,
  24 + /** 布尔等于 / 不等于。 */
  25 + BOOL,
  26 + /** 日期区间命中 / 取反。 */
  27 + DATE
  28 + }
  29 +
  30 + /** 目标列(白名单 token,如 {@code u.sUserName} / {@code e.sDepartment})。 */
  31 + private String column;
  32 + /** 分支种类。 */
  33 + private Kind kind = Kind.NONE;
  34 +
  35 + /** TEXT:匹配方式(包含 / 不包含 / 等于)。 */
  36 + private String matchType;
  37 + /** TEXT:已转义文本值(含 ESCAPE 转义后的 % _ \)。 */
  38 + private String textValue;
  39 +
  40 + /** BOOL:归一化布尔值(0 / 1)。 */
  41 + private Integer boolValue;
  42 + /** BOOL:true 表示不等于({@code <>}),false 表示等于({@code =})。 */
  43 + private boolean boolNegated;
  44 +
  45 + /** DATE:当日区间起(含)。 */
  46 + private LocalDateTime dateStart;
  47 + /** DATE:当日区间止(不含)。 */
  48 + private LocalDateTime dateEnd;
  49 + /** DATE:true 表示「不包含」(区间取反)。 */
  50 + private boolean dateNegated;
  51 +
  52 + public UserQueryCondition() {
  53 + }
  54 +
  55 + /** 无过滤条件(全量分页)。 */
  56 + public static UserQueryCondition none() {
  57 + return new UserQueryCondition();
  58 + }
  59 +
  60 + /** 文本 / 枚举条件:{@code matchType} ∈ {包含,不包含,等于},{@code value} 须由 Service 预先转义。 */
  61 + public static UserQueryCondition text(String column, String matchType, String value) {
  62 + UserQueryCondition c = new UserQueryCondition();
  63 + c.column = column;
  64 + c.kind = Kind.TEXT;
  65 + c.matchType = matchType;
  66 + c.textValue = value;
  67 + return c;
  68 + }
  69 +
  70 + /** 布尔条件:{@code negated=true} 表示「不包含」({@code <>})。 */
  71 + public static UserQueryCondition bool(String column, int value, boolean negated) {
  72 + UserQueryCondition c = new UserQueryCondition();
  73 + c.column = column;
  74 + c.kind = Kind.BOOL;
  75 + c.boolValue = value;
  76 + c.boolNegated = negated;
  77 + return c;
  78 + }
  79 +
  80 + /** 日期区间条件:{@code [start, end)};{@code negated=true} 表示「不包含」(区间取反)。 */
  81 + public static UserQueryCondition date(String column, LocalDateTime start, LocalDateTime end,
  82 + boolean negated) {
  83 + UserQueryCondition c = new UserQueryCondition();
  84 + c.column = column;
  85 + c.kind = Kind.DATE;
  86 + c.dateStart = start;
  87 + c.dateEnd = end;
  88 + c.dateNegated = negated;
  89 + return c;
  90 + }
  91 +
  92 + // ---- XML <if> 用便捷判定(getter 形式,OGNL 直接读取)----
  93 +
  94 + public boolean isText() {
  95 + return kind == Kind.TEXT;
  96 + }
  97 +
  98 + public boolean isBool() {
  99 + return kind == Kind.BOOL;
  100 + }
  101 +
  102 + public boolean isDate() {
  103 + return kind == Kind.DATE;
  104 + }
  105 +
  106 + /** 文本「等于」。 */
  107 + public boolean isTextEquals() {
  108 + return kind == Kind.TEXT && "等于".equals(matchType);
  109 + }
  110 +
  111 + /** 文本「不包含」。 */
  112 + public boolean isTextNotContains() {
  113 + return kind == Kind.TEXT && "不包含".equals(matchType);
  114 + }
  115 +
  116 + /** 文本「包含」(默认)。 */
  117 + public boolean isTextContains() {
  118 + return kind == Kind.TEXT && !"等于".equals(matchType) && !"不包含".equals(matchType);
  119 + }
  120 +
  121 + public String getColumn() {
  122 + return column;
  123 + }
  124 +
  125 + public Kind getKind() {
  126 + return kind;
  127 + }
  128 +
  129 + public String getMatchType() {
  130 + return matchType;
  131 + }
  132 +
  133 + public String getTextValue() {
  134 + return textValue;
  135 + }
  136 +
  137 + public Integer getBoolValue() {
  138 + return boolValue;
  139 + }
  140 +
  141 + public boolean isBoolNegated() {
  142 + return boolNegated;
  143 + }
  144 +
  145 + public LocalDateTime getDateStart() {
  146 + return dateStart;
  147 + }
  148 +
  149 + public LocalDateTime getDateEnd() {
  150 + return dateEnd;
  151 + }
  152 +
  153 + public boolean isDateNegated() {
  154 + return dateNegated;
  155 + }
  156 +}
... ...
backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java
1 1 package com.xly.erp.modules.usr.mapper;
2 2  
3 3 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  4 +import com.baomidou.mybatisplus.core.metadata.IPage;
  5 +import com.xly.erp.modules.usr.dto.UserQueryCondition;
4 6 import com.xly.erp.modules.usr.entity.UsrUser;
  7 +import com.xly.erp.modules.usr.vo.UserVO;
5 8 import org.apache.ibatis.annotations.Mapper;
  9 +import org.apache.ibatis.annotations.Param;
6 10  
7 11 /**
8   - * 用户 Mapper(MyBatis-Plus)。REQ-USR-001 T4
  12 + * 用户 Mapper(MyBatis-Plus)。REQ-USR-001 T4 / REQ-USR-003 T3
9 13 */
10 14 @Mapper
11 15 public interface UsrUserMapper extends BaseMapper<UsrUser> {
  16 +
  17 + /**
  18 + * 跨表分页查询(REQ-USR-003):{@code usr_user LEFT JOIN usr_employee} 取员工名 / 部门,
  19 + * 按已解析 {@link UserQueryCondition} 施加单字段单匹配过滤;MP 分页插件自动补 LIMIT 与 COUNT(*)。
  20 + *
  21 + * <p>不 SELECT {@code sPassword} 与租户列;所有值用 {@code #{}} 预编译占位,
  22 + * 仅白名单列 token 经 {@code ${}} 注入(由 Service 从固定映射产出,防注入)。</p>
  23 + *
  24 + * @param page 分页参数(current/size)
  25 + * @param cond 已解析查询条件
  26 + * @return 当前页 UserVO 列表(含真实 total)
  27 + */
  28 + IPage<UserVO> selectUserPage(IPage<UserVO> page, @Param("cond") UserQueryCondition cond);
12 29 }
... ...
backend/src/main/resources/mapper/usr/UsrUserMapper.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  3 + "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  4 +<!-- REQ-USR-003 T3:查询用户跨表分页 SQL(LEFT JOIN + 动态单条件)。
  5 + map-underscore-to-camel-case=false,故用显式 resultMap 映射列别名到 UserVO 字段;
  6 + 不 SELECT u.sPassword 与租户列;值用 #{} 预编译占位,列 token 由 Service 白名单产出。 -->
  7 +<mapper namespace="com.xly.erp.modules.usr.mapper.UsrUserMapper">
  8 +
  9 + <resultMap id="userVOMap" type="com.xly.erp.modules.usr.vo.UserVO">
  10 + <id property="id" column="id"/>
  11 + <result property="sUserName" column="sUserName"/>
  12 + <result property="employeeName" column="employeeName"/>
  13 + <result property="sUserNo" column="sUserNo"/>
  14 + <result property="department" column="department"/>
  15 + <result property="sUserType" column="sUserType"/>
  16 + <result property="sLanguage" column="sLanguage"/>
  17 + <result property="iIsVoid" column="iIsVoid"/>
  18 + <result property="tLastLoginDate" column="tLastLoginDate"/>
  19 + <result property="sCreator" column="sCreator"/>
  20 + <result property="tCreateDate" column="tCreateDate"/>
  21 + </resultMap>
  22 +
  23 + <select id="selectUserPage" resultMap="userVOMap">
  24 + SELECT
  25 + u.iIncrement AS id,
  26 + u.sUserName AS sUserName,
  27 + e.sEmployeeName AS employeeName,
  28 + u.sUserNo AS sUserNo,
  29 + e.sDepartment AS department,
  30 + u.sUserType AS sUserType,
  31 + u.sLanguage AS sLanguage,
  32 + u.iIsVoid AS iIsVoid,
  33 + u.tLastLoginDate AS tLastLoginDate,
  34 + u.sCreator AS sCreator,
  35 + u.tCreateDate AS tCreateDate
  36 + FROM usr_user u
  37 + LEFT JOIN usr_employee e ON u.iEmployeeId = e.iIncrement
  38 + <where>
  39 + <if test="cond != null and cond.text">
  40 + <choose>
  41 + <when test="cond.textEquals">
  42 + ${cond.column} = #{cond.textValue}
  43 + </when>
  44 + <when test="cond.textNotContains">
  45 + ${cond.column} NOT LIKE CONCAT('%', #{cond.textValue}, '%') ESCAPE '\\'
  46 + </when>
  47 + <otherwise>
  48 + ${cond.column} LIKE CONCAT('%', #{cond.textValue}, '%') ESCAPE '\\'
  49 + </otherwise>
  50 + </choose>
  51 + </if>
  52 + <if test="cond != null and cond.bool">
  53 + <choose>
  54 + <when test="cond.boolNegated">
  55 + ${cond.column} &lt;&gt; #{cond.boolValue}
  56 + </when>
  57 + <otherwise>
  58 + ${cond.column} = #{cond.boolValue}
  59 + </otherwise>
  60 + </choose>
  61 + </if>
  62 + <if test="cond != null and cond.date">
  63 + <choose>
  64 + <when test="cond.dateNegated">
  65 + (${cond.column} &lt; #{cond.dateStart} OR ${cond.column} &gt;= #{cond.dateEnd})
  66 + </when>
  67 + <otherwise>
  68 + ${cond.column} &gt;= #{cond.dateStart} AND ${cond.column} &lt; #{cond.dateEnd}
  69 + </otherwise>
  70 + </choose>
  71 + </if>
  72 + </where>
  73 + ORDER BY u.iIncrement
  74 + </select>
  75 +
  76 +</mapper>
... ...
backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java 0 → 100644
  1 +package com.xly.erp.modules.usr.mapper;
  2 +
  3 +import static org.assertj.core.api.Assertions.assertThat;
  4 +
  5 +import com.baomidou.mybatisplus.core.metadata.IPage;
  6 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  7 +import com.xly.erp.modules.usr.dto.UserQueryCondition;
  8 +import com.xly.erp.modules.usr.entity.UsrEmployee;
  9 +import com.xly.erp.modules.usr.entity.UsrUser;
  10 +import com.xly.erp.modules.usr.vo.UserVO;
  11 +import org.junit.jupiter.api.AfterEach;
  12 +import org.junit.jupiter.api.BeforeEach;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.springframework.beans.factory.annotation.Autowired;
  15 +import org.springframework.boot.test.context.SpringBootTest;
  16 +import org.springframework.test.context.ActiveProfiles;
  17 +import org.springframework.transaction.annotation.Transactional;
  18 +
  19 +/**
  20 + * REQ-USR-003 T3:UsrUserMapper.selectUserPage 跨表分页查询(LEFT JOIN + 动态条件 XML)。
  21 + *
  22 + * <p>@SpringBootTest 连测试库(Flyway 已 apply V1),@Transactional 测试回滚避免污染库。
  23 + * 插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 usr_employee;
  24 + * 验证 LEFT JOIN 列映射、文本 LIKE 过滤、不返回密码。</p>
  25 + */
  26 +@SpringBootTest
  27 +@ActiveProfiles("test")
  28 +@Transactional
  29 +class UsrUserMapperPageTest {
  30 +
  31 + private static final String EMP_NAME = "T3职员_财务";
  32 + private static final String EMP_DEPT = "财务部T3";
  33 + private static final String USER_LINKED = "t3_user_linked";
  34 + private static final String USER_UNLINKED = "t3_user_unlinked";
  35 +
  36 + @Autowired
  37 + private UsrUserMapper usrUserMapper;
  38 +
  39 + @Autowired
  40 + private UsrEmployeeMapper usrEmployeeMapper;
  41 +
  42 + private Integer empId;
  43 +
  44 + @BeforeEach
  45 + void seed() {
  46 + UsrEmployee emp = new UsrEmployee();
  47 + emp.setSEmployeeName(EMP_NAME);
  48 + emp.setSEmployeeNo("T3_EMP_001");
  49 + emp.setSDepartment(EMP_DEPT);
  50 + usrEmployeeMapper.insert(emp);
  51 + empId = emp.getIIncrement();
  52 +
  53 + UsrUser linked = newUser(USER_LINKED);
  54 + linked.setIEmployeeId(empId);
  55 + usrUserMapper.insert(linked);
  56 +
  57 + UsrUser unlinked = newUser(USER_UNLINKED);
  58 + unlinked.setIEmployeeId(null);
  59 + usrUserMapper.insert(unlinked);
  60 + }
  61 +
  62 + @AfterEach
  63 + void cleanup() {
  64 + // @Transactional 已回滚;保留显式说明,无需额外清理。
  65 + }
  66 +
  67 + private UsrUser newUser(String name) {
  68 + UsrUser u = new UsrUser();
  69 + u.setSUserName(name);
  70 + u.setSPassword("$2a$dummyhash");
  71 + u.setSUserType("普通用户");
  72 + u.setSLanguage("中文");
  73 + u.setICanModifyBill(0);
  74 + u.setIIsVoid(0);
  75 + u.setSCreator("t3_seed");
  76 + return u;
  77 + }
  78 +
  79 + @Test
  80 + void pageReturnsLeftJoinedEmployeeColumns() {
  81 + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none());
  82 +
  83 + assertThat(page.getTotal()).isGreaterThanOrEqualTo(2L);
  84 + UserVO linked = page.getRecords().stream()
  85 + .filter(v -> USER_LINKED.equals(v.getSUserName())).findFirst().orElseThrow();
  86 + assertThat(linked.getEmployeeName()).isEqualTo(EMP_NAME);
  87 + assertThat(linked.getDepartment()).isEqualTo(EMP_DEPT);
  88 +
  89 + UserVO unlinked = page.getRecords().stream()
  90 + .filter(v -> USER_UNLINKED.equals(v.getSUserName())).findFirst().orElseThrow();
  91 + assertThat(unlinked.getEmployeeName()).isNull();
  92 + assertThat(unlinked.getDepartment()).isNull();
  93 + }
  94 +
  95 + @Test
  96 + void pageAppliesTextLikeOnUserName() {
  97 + UserQueryCondition cond = UserQueryCondition.text("u.sUserName", "包含", "t3_user_linked");
  98 + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 10), cond);
  99 +
  100 + assertThat(page.getRecords()).extracting(UserVO::getSUserName).contains(USER_LINKED);
  101 + assertThat(page.getRecords()).noneMatch(v -> USER_UNLINKED.equals(v.getSUserName()));
  102 + assertThat(page.getTotal()).isEqualTo(1L);
  103 + }
  104 +
  105 + @Test
  106 + void pageNeverSelectsPassword() {
  107 + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(1, 100), UserQueryCondition.none());
  108 + assertThat(page.getRecords()).isNotEmpty();
  109 + // UserVO 无密码属性 → 天然不含;额外确认元素类型与查询不抛错。
  110 + assertThat(page.getRecords().get(0)).isInstanceOf(UserVO.class);
  111 + }
  112 +}
... ...