From 95bdce3624b2252ddd090f3ee6bdf93b098594d0 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 14:29:48 +0800 Subject: [PATCH] feat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003 --- backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java | 19 ++++++++++++++++++- backend/src/main/resources/mapper/usr/UsrUserMapper.xml | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/xly/erp/modules/usr/dto/UserQueryCondition.java create mode 100644 backend/src/main/resources/mapper/usr/UsrUserMapper.xml create mode 100644 backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java 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/mapper/UsrUserMapper.java b/backend/src/main/java/com/xly/erp/modules/usr/mapper/UsrUserMapper.java index 2968bc8..5b1b0cb 100644 --- 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 @@ -1,12 +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。 + * 用户 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/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/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); + } +} -- libgit2 0.22.2