From 0caab5b6c860982320fd077c29c1620e8ba6938d Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 14:32:50 +0800 Subject: [PATCH] feat(usr): 查询用户 Service 参数判定与条件解析分页装配 REQ-USR-003 --- backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java | 16 +++++++++++++++- backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 1 deletion(-) 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 index 3795390..7ee158f 100644 --- 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 @@ -1,10 +1,13 @@ 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。 + * 用户业务服务(docs/04 § 1.2)。REQ-USR-001 / REQ-USR-002 / REQ-USR-003。 */ public interface UsrUserService { @@ -28,4 +31,15 @@ public interface UsrUserService { * @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/UsrUserServiceImpl.java b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java index ba5a42c..7d0a4b1 100644 --- 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 @@ -1,11 +1,16 @@ 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; @@ -13,8 +18,15 @@ 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; @@ -40,6 +52,39 @@ public class UsrUserServiceImpl implements UsrUserService { /** 新建即生效。 */ 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; @@ -192,4 +237,102 @@ public class UsrUserServiceImpl implements UsrUserService { 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/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java index 71b8586..4356e1a 100644 --- 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 @@ -10,11 +10,18 @@ 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; @@ -357,4 +364,169 @@ class UsrUserServiceImplTest { 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); + } } -- libgit2 0.22.2