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);
+ }
+}