Commit 0caab5b6c860982320fd077c29c1620e8ba6938d

Authored by zichun
1 parent 95bdce36

feat(usr): 查询用户 Service 参数判定与条件解析分页装配 REQ-USR-003

backend/src/main/java/com/xly/erp/modules/usr/service/UsrUserService.java
1 1 package com.xly.erp.modules.usr.service;
2 2  
  3 +import com.xly.erp.common.response.PageResult;
3 4 import com.xly.erp.modules.usr.dto.CreateUserDTO;
4 5 import com.xly.erp.modules.usr.dto.UpdateUserDTO;
  6 +import com.xly.erp.modules.usr.dto.UserQueryDTO;
  7 +import com.xly.erp.modules.usr.vo.UserVO;
5 8  
6 9 /**
7   - * 用户业务服务(docs/04 § 1.2)。REQ-USR-001 / REQ-USR-002
  10 + * 用户业务服务(docs/04 § 1.2)。REQ-USR-001 / REQ-USR-002 / REQ-USR-003
8 11 */
9 12 public interface UsrUserService {
10 13  
... ... @@ -28,4 +31,15 @@ public interface UsrUserService {
28 31 * @return 被修改用户主键 iIncrement(等于入参 id)
29 32 */
30 33 Integer updateUser(Integer id, UpdateUserDTO dto);
  34 +
  35 + /**
  36 + * 查询用户(REQ-USR-003,纯只读):分页参数判定(42201)→ 单字段单匹配条件解析
  37 + * (文本转义 / 枚举 / 布尔归一化 / 日期区间,非法 40001)→ {@code usr_user LEFT JOIN
  38 + * usr_employee} 分页查询 → 数据越界钳制到最后一页 → 装配 {@link PageResult}。
  39 + * 空条件返回全量分页;响应不含密码 / 租户列。
  40 + *
  41 + * @param dto 查询入参(全部可选)
  42 + * @return 分页结果 {@code PageResult<UserVO>}
  43 + */
  44 + PageResult<UserVO> queryUsers(UserQueryDTO dto);
31 45 }
... ...
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrUserServiceImpl.java
1 1 package com.xly.erp.modules.usr.service.impl;
2 2  
  3 +import com.baomidou.mybatisplus.core.metadata.IPage;
3 4 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  5 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4 6 import com.xly.erp.common.exception.BusinessException;
  7 +import com.xly.erp.common.response.PageResult;
5 8 import com.xly.erp.common.response.ResultCode;
6 9 import com.xly.erp.common.security.SecurityUtil;
7 10 import com.xly.erp.modules.usr.dto.CreateUserDTO;
8 11 import com.xly.erp.modules.usr.dto.UpdateUserDTO;
  12 +import com.xly.erp.modules.usr.dto.UserQueryCondition;
  13 +import com.xly.erp.modules.usr.dto.UserQueryDTO;
9 14 import com.xly.erp.modules.usr.entity.UsrUser;
10 15 import com.xly.erp.modules.usr.entity.UsrUserPermission;
11 16 import com.xly.erp.modules.usr.mapper.UsrEmployeeMapper;
... ... @@ -13,8 +18,15 @@ import com.xly.erp.modules.usr.mapper.UsrPermissionMapper;
13 18 import com.xly.erp.modules.usr.mapper.UsrUserMapper;
14 19 import com.xly.erp.modules.usr.mapper.UsrUserPermissionMapper;
15 20 import com.xly.erp.modules.usr.service.UsrUserService;
  21 +import com.xly.erp.modules.usr.vo.UserVO;
  22 +import java.time.LocalDate;
  23 +import java.time.LocalDateTime;
  24 +import java.time.LocalTime;
  25 +import java.time.format.DateTimeFormatter;
  26 +import java.time.format.DateTimeParseException;
16 27 import java.util.LinkedHashSet;
17 28 import java.util.List;
  29 +import java.util.Map;
18 30 import org.springframework.dao.DuplicateKeyException;
19 31 import org.springframework.security.crypto.password.PasswordEncoder;
20 32 import org.springframework.stereotype.Service;
... ... @@ -40,6 +52,39 @@ public class UsrUserServiceImpl implements UsrUserService {
40 52 /** 新建即生效。 */
41 53 private static final int NOT_VOID = 0;
42 54  
  55 + // ---- REQ-USR-003 查询用户:常量 / 白名单 ----
  56 +
  57 + /** 默认查询字段(spec § 2.1)。 */
  58 + private static final String DEFAULT_QUERY_FIELD = "用户名";
  59 + /** 默认匹配方式(spec § 2.1)。 */
  60 + private static final String DEFAULT_MATCH_TYPE = "包含";
  61 + /** 默认页码。 */
  62 + private static final long DEFAULT_PAGE_NUM = 1L;
  63 + /** 默认每页条数。 */
  64 + private static final long DEFAULT_PAGE_SIZE = 10L;
  65 + /** 每页条数上限(spec § 3.7)。 */
  66 + private static final long MAX_PAGE_SIZE = 100L;
  67 +
  68 + /** 中文 queryField → 白名单列 token(带表别名,绝不拼接用户输入列名,防注入;spec § 3.4)。 */
  69 + private static final Map<String, String> FIELD_COLUMN = Map.of(
  70 + "用户名", "u.sUserName",
  71 + "员工名", "e.sEmployeeName",
  72 + "用户号", "u.sUserNo",
  73 + "部门", "e.sDepartment",
  74 + "用户类型", "u.sUserType",
  75 + "作废", "u.iIsVoid",
  76 + "登录日期", "u.tLastLoginDate",
  77 + "制单人", "u.sCreator");
  78 +
  79 + /** 布尔字段集合(按 0/1 归一化)。 */
  80 + private static final String FIELD_VOID = "作废";
  81 + /** 日期字段集合(按当日区间解析)。 */
  82 + private static final String FIELD_LOGIN_DATE = "登录日期";
  83 +
  84 + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
  85 + private static final DateTimeFormatter DATETIME_FMT =
  86 + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  87 +
43 88 private final UsrUserMapper usrUserMapper;
44 89 private final UsrUserPermissionMapper usrUserPermissionMapper;
45 90 private final UsrEmployeeMapper usrEmployeeMapper;
... ... @@ -192,4 +237,102 @@ public class UsrUserServiceImpl implements UsrUserService {
192 237  
193 238 return id;
194 239 }
  240 +
  241 + @Override
  242 + @Transactional(readOnly = true)
  243 + public PageResult<UserVO> queryUsers(UserQueryDTO dto) {
  244 + // 1. 分页参数判定(先于一切;非法 → 42201,spec § 8 D8)。
  245 + long pageNum = dto.getPageNum() != null ? dto.getPageNum() : DEFAULT_PAGE_NUM;
  246 + long pageSize = dto.getPageSize() != null ? dto.getPageSize() : DEFAULT_PAGE_SIZE;
  247 + if (pageNum < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
  248 + throw new BusinessException(ResultCode.PAGE_PARAM_INVALID);
  249 + }
  250 +
  251 + // 2. 条件解析(单字段单匹配;空值不施加过滤)。
  252 + UserQueryCondition cond = parseCondition(dto);
  253 +
  254 + // 3. 分页查询。
  255 + IPage<UserVO> page = usrUserMapper.selectUserPage(new Page<>(pageNum, pageSize), cond);
  256 + long total = page.getTotal();
  257 +
  258 + // 4. 数据越界钳制到最后一页(spec § 3.7)。
  259 + long pages = total == 0 ? 1 : (total + pageSize - 1) / pageSize;
  260 + if (total == 0) {
  261 + return PageResult.of(page.getRecords(), 0L, DEFAULT_PAGE_NUM, pageSize);
  262 + }
  263 + if (pageNum > pages) {
  264 + // 用钳后页号回查一次,保证 records 为最后一页真实数据。
  265 + page = usrUserMapper.selectUserPage(new Page<>(pages, pageSize), cond);
  266 + return PageResult.of(page.getRecords(), total, pages, pageSize);
  267 + }
  268 + return PageResult.of(page.getRecords(), total, pageNum, pageSize);
  269 + }
  270 +
  271 + /**
  272 + * 把 DTO 的中文 queryField / matchType / 原始 queryValue 解析归一为 Mapper 可直接拼 XML 的条件。
  273 + * queryValue 为 null / trim 后空 → 无过滤;按字段类型分文本 / 枚举 / 布尔 / 日期解析(非法 → 40001)。
  274 + */
  275 + private UserQueryCondition parseCondition(UserQueryDTO dto) {
  276 + String value = dto.getQueryValue();
  277 + if (value == null || value.trim().isEmpty()) {
  278 + return UserQueryCondition.none();
  279 + }
  280 + String field = StringUtils.hasText(dto.getQueryField()) ? dto.getQueryField() : DEFAULT_QUERY_FIELD;
  281 + String matchType = StringUtils.hasText(dto.getMatchType()) ? dto.getMatchType() : DEFAULT_MATCH_TYPE;
  282 + String column = FIELD_COLUMN.get(field);
  283 + if (column == null) {
  284 + throw new BusinessException(ResultCode.PARAM_INVALID, "查询字段取值非法");
  285 + }
  286 + String trimmed = value.trim();
  287 +
  288 + if (FIELD_VOID.equals(field)) {
  289 + int boolValue = normalizeBool(trimmed);
  290 + boolean negated = "不包含".equals(matchType);
  291 + return UserQueryCondition.bool(column, boolValue, negated);
  292 + }
  293 + if (FIELD_LOGIN_DATE.equals(field)) {
  294 + LocalDateTime start = parseDayStart(trimmed);
  295 + LocalDateTime end = start.toLocalDate().plusDays(1).atStartOfDay();
  296 + boolean negated = "不包含".equals(matchType);
  297 + return UserQueryCondition.date(column, start, end, negated);
  298 + }
  299 + // 文本 / 枚举:等于走 = 不转义;包含 / 不包含走 LIKE,对 % _ \ 转义。
  300 + if ("等于".equals(matchType)) {
  301 + return UserQueryCondition.text(column, matchType, trimmed);
  302 + }
  303 + return UserQueryCondition.text(column, matchType, escapeLike(trimmed));
  304 + }
  305 +
  306 + /** 布尔归一化:接受 0/1、是/否、true/false(spec § 8 D6);不可解析 → 40001。 */
  307 + private int normalizeBool(String value) {
  308 + String v = value.trim().toLowerCase();
  309 + if ("1".equals(v) || "是".equals(value.trim()) || "true".equals(v)) {
  310 + return 1;
  311 + }
  312 + if ("0".equals(v) || "否".equals(value.trim()) || "false".equals(v)) {
  313 + return 0;
  314 + }
  315 + throw new BusinessException(ResultCode.PARAM_INVALID, "作废取值不可解析");
  316 + }
  317 +
  318 + /** 日期解析为当日起点:支持 yyyy-MM-dd 与 yyyy-MM-dd HH:mm:ss(spec § 8 D6);非法 → 40001。 */
  319 + private LocalDateTime parseDayStart(String value) {
  320 + try {
  321 + if (value.length() > 10) {
  322 + LocalDateTime dt = LocalDateTime.parse(value, DATETIME_FMT);
  323 + return dt.toLocalDate().atStartOfDay();
  324 + }
  325 + LocalDate d = LocalDate.parse(value, DATE_FMT);
  326 + return d.atTime(LocalTime.MIDNIGHT);
  327 + } catch (DateTimeParseException ex) {
  328 + throw new BusinessException(ResultCode.PARAM_INVALID, "登录日期取值非法");
  329 + }
  330 + }
  331 +
  332 + /** LIKE 通配符转义:对 \ % _ 前置反斜杠(配合 XML 的 ESCAPE '\\',spec § 8 D3)。 */
  333 + private String escapeLike(String value) {
  334 + return value.replace("\\", "\\\\")
  335 + .replace("%", "\\%")
  336 + .replace("_", "\\_");
  337 + }
195 338 }
... ...
backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java
... ... @@ -10,11 +10,18 @@ import static org.mockito.Mockito.verify;
10 10 import static org.mockito.Mockito.when;
11 11  
12 12 import com.baomidou.mybatisplus.core.conditions.Wrapper;
  13 +import com.baomidou.mybatisplus.core.metadata.IPage;
  14 +import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
13 15 import com.xly.erp.common.exception.BusinessException;
  16 +import com.xly.erp.common.response.PageResult;
14 17 import com.xly.erp.common.response.ResultCode;
15 18 import com.xly.erp.common.security.SecurityUtil;
16 19 import com.xly.erp.modules.usr.dto.CreateUserDTO;
17 20 import com.xly.erp.modules.usr.dto.UpdateUserDTO;
  21 +import com.xly.erp.modules.usr.dto.UserQueryCondition;
  22 +import com.xly.erp.modules.usr.dto.UserQueryDTO;
  23 +import com.xly.erp.modules.usr.vo.UserVO;
  24 +import java.time.LocalDateTime;
18 25 import com.xly.erp.modules.usr.entity.UsrEmployee;
19 26 import com.xly.erp.modules.usr.entity.UsrPermission;
20 27 import com.xly.erp.modules.usr.entity.UsrUser;
... ... @@ -357,4 +364,169 @@ class UsrUserServiceImplTest {
357 364 verify(usrUserPermissionMapper, never()).delete(any(Wrapper.class));
358 365 verify(usrUserPermissionMapper, never()).insert(any(UsrUserPermission.class));
359 366 }
  367 +
  368 + // ---------------- REQ-USR-003 T4:查询用户 参数判定 + 条件解析 + 分页装配 ----------------
  369 +
  370 + /** 桩:selectUserPage 返回携带指定 records/total/size/current 的 Page。 */
  371 + @SuppressWarnings("unchecked")
  372 + private void stubSelectUserPage(java.util.List<UserVO> records, long total, long size, long current) {
  373 + when(usrUserMapper.selectUserPage(any(IPage.class), any(UserQueryCondition.class)))
  374 + .thenAnswer(inv -> {
  375 + Page<UserVO> page = new Page<>(current, size);
  376 + page.setRecords(records);
  377 + page.setTotal(total);
  378 + return page;
  379 + });
  380 + }
  381 +
  382 + private UserQueryDTO queryDto(String field, String matchType, String value) {
  383 + UserQueryDTO dto = new UserQueryDTO();
  384 + dto.setQueryField(field);
  385 + dto.setMatchType(matchType);
  386 + dto.setQueryValue(value);
  387 + return dto;
  388 + }
  389 +
  390 + @Test
  391 + @SuppressWarnings("unchecked")
  392 + void pageParamTooSmallThrows42201() {
  393 + UserQueryDTO dto = new UserQueryDTO();
  394 + dto.setPageNum(0);
  395 + assertThatThrownBy(() -> service.queryUsers(dto))
  396 + .isInstanceOf(BusinessException.class)
  397 + .extracting(e -> ((BusinessException) e).getResultCode())
  398 + .isEqualTo(ResultCode.PAGE_PARAM_INVALID);
  399 +
  400 + UserQueryDTO dto2 = new UserQueryDTO();
  401 + dto2.setPageSize(0);
  402 + assertThatThrownBy(() -> service.queryUsers(dto2))
  403 + .isInstanceOf(BusinessException.class)
  404 + .extracting(e -> ((BusinessException) e).getResultCode())
  405 + .isEqualTo(ResultCode.PAGE_PARAM_INVALID);
  406 +
  407 + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class));
  408 + }
  409 +
  410 + @Test
  411 + @SuppressWarnings("unchecked")
  412 + void pageSizeOverMaxThrows42201() {
  413 + UserQueryDTO dto = new UserQueryDTO();
  414 + dto.setPageSize(500);
  415 + assertThatThrownBy(() -> service.queryUsers(dto))
  416 + .isInstanceOf(BusinessException.class)
  417 + .extracting(e -> ((BusinessException) e).getResultCode())
  418 + .isEqualTo(ResultCode.PAGE_PARAM_INVALID);
  419 + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class));
  420 + }
  421 +
  422 + @Test
  423 + @SuppressWarnings("unchecked")
  424 + void blankQueryValueAppliesNoFilter() {
  425 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  426 + UserQueryDTO dto = queryDto("用户名", "包含", null);
  427 +
  428 + service.queryUsers(dto);
  429 +
  430 + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class);
  431 + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture());
  432 + UserQueryCondition cond = captor.getValue();
  433 + assertThat(cond.getKind()).isEqualTo(UserQueryCondition.Kind.NONE);
  434 + assertThat(cond.getTextValue()).isNull();
  435 + }
  436 +
  437 + @Test
  438 + @SuppressWarnings("unchecked")
  439 + void boolFieldUnparsableThrows40001() {
  440 + UserQueryDTO bad = queryDto("作废", "等于", "abc");
  441 + assertThatThrownBy(() -> service.queryUsers(bad))
  442 + .isInstanceOf(BusinessException.class)
  443 + .extracting(e -> ((BusinessException) e).getResultCode())
  444 + .isEqualTo(ResultCode.PARAM_INVALID);
  445 + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class));
  446 +
  447 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  448 + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class);
  449 +
  450 + service.queryUsers(queryDto("作废", "等于", "1"));
  451 + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture());
  452 + assertThat(captor.getValue().getBoolValue()).isEqualTo(1);
  453 +
  454 + service.queryUsers(queryDto("作废", "等于", "是"));
  455 + verify(usrUserMapper, times(2)).selectUserPage(any(IPage.class), captor.capture());
  456 + assertThat(captor.getValue().getBoolValue()).isEqualTo(1);
  457 + }
  458 +
  459 + @Test
  460 + @SuppressWarnings("unchecked")
  461 + void dateFieldIllegalThrows40001() {
  462 + UserQueryDTO bad = queryDto("登录日期", "等于", "2026-13-99");
  463 + assertThatThrownBy(() -> service.queryUsers(bad))
  464 + .isInstanceOf(BusinessException.class)
  465 + .extracting(e -> ((BusinessException) e).getResultCode())
  466 + .isEqualTo(ResultCode.PARAM_INVALID);
  467 + verify(usrUserMapper, never()).selectUserPage(any(IPage.class), any(UserQueryCondition.class));
  468 +
  469 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  470 + service.queryUsers(queryDto("登录日期", "等于", "2026-06-01"));
  471 + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class);
  472 + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture());
  473 + UserQueryCondition cond = captor.getValue();
  474 + assertThat(cond.getDateStart()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0, 0));
  475 + assertThat(cond.getDateEnd()).isEqualTo(LocalDateTime.of(2026, 6, 2, 0, 0, 0));
  476 + }
  477 +
  478 + @Test
  479 + @SuppressWarnings("unchecked")
  480 + void textLikeEscapesWildcards() {
  481 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  482 + service.queryUsers(queryDto("用户名", "包含", "a%_b"));
  483 +
  484 + ArgumentCaptor<UserQueryCondition> captor = ArgumentCaptor.forClass(UserQueryCondition.class);
  485 + verify(usrUserMapper).selectUserPage(any(IPage.class), captor.capture());
  486 + UserQueryCondition cond = captor.getValue();
  487 + assertThat(cond.isText()).isTrue();
  488 + assertThat(cond.isTextContains()).isTrue();
  489 + assertThat(cond.getTextValue()).contains("\\%").contains("\\_");
  490 + }
  491 +
  492 + @Test
  493 + @SuppressWarnings("unchecked")
  494 + void dataPageOutOfRangeClampsToLastPage() {
  495 + UserVO vo = new UserVO();
  496 + vo.setSUserName("last_page_user");
  497 + stubSelectUserPage(java.util.List.of(vo), 23L, 10L, 99L);
  498 + UserQueryDTO dto = new UserQueryDTO();
  499 + dto.setPageNum(99);
  500 + dto.setPageSize(10);
  501 +
  502 + PageResult<UserVO> result = service.queryUsers(dto);
  503 +
  504 + assertThat(result.getTotal()).isEqualTo(23L);
  505 + assertThat(result.getPageNum()).isEqualTo(3L);
  506 + assertThat(result.getRecords()).extracting(UserVO::getSUserName).contains("last_page_user");
  507 + }
  508 +
  509 + @Test
  510 + @SuppressWarnings("unchecked")
  511 + void emptyResultReturnsZeroTotal() {
  512 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  513 + PageResult<UserVO> result = service.queryUsers(new UserQueryDTO());
  514 +
  515 + assertThat(result.getRecords()).isEmpty();
  516 + assertThat(result.getTotal()).isZero();
  517 + assertThat(result.getPageNum()).isEqualTo(1L);
  518 + }
  519 +
  520 + @Test
  521 + @SuppressWarnings("unchecked")
  522 + void defaultsApplied() {
  523 + stubSelectUserPage(java.util.List.of(), 0L, 10L, 1L);
  524 + service.queryUsers(new UserQueryDTO());
  525 +
  526 + ArgumentCaptor<IPage> pageCaptor = ArgumentCaptor.forClass(IPage.class);
  527 + verify(usrUserMapper).selectUserPage(pageCaptor.capture(), any(UserQueryCondition.class));
  528 + IPage<?> page = pageCaptor.getValue();
  529 + assertThat(page.getCurrent()).isEqualTo(1L);
  530 + assertThat(page.getSize()).isEqualTo(10L);
  531 + }
360 532 }
... ...