Commit 89786804fe775d7fd2e5c074f17bb1778b12090a

Authored by zichun
1 parent bda3515f

feat(usr): UserListService 白名单 + 动态查询 + 越界矫正 REQ-USR-004

backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.response.PageResult;
  4 +import com.xly.erp.module.usr.dto.UserQueryReq;
  5 +import com.xly.erp.module.usr.vo.UserListItemVo;
  6 +
  7 +public interface UserListService {
  8 + /**
  9 + * REQ-USR-004 GET /api/v1/users — 分页 + 多字段筛选 + 排序。
  10 + * 白名单校验、越界矫正均由实现层完成。
  11 + */
  12 + PageResult<UserListItemVo> list(UserQueryReq req);
  13 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.response.PageResult;
  6 +import com.xly.erp.module.usr.dto.UserQueryReq;
  7 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  8 +import com.xly.erp.module.usr.mapper.UserQueryParams;
  9 +import com.xly.erp.module.usr.service.UserListService;
  10 +import com.xly.erp.module.usr.vo.UserListItemVo;
  11 +import lombok.RequiredArgsConstructor;
  12 +import org.springframework.stereotype.Service;
  13 +
  14 +import java.util.List;
  15 +import java.util.Map;
  16 +import java.util.Set;
  17 +
  18 +@Service
  19 +@RequiredArgsConstructor
  20 +public class UserListServiceImpl implements UserListService {
  21 +
  22 + static final int DEFAULT_PAGE = 1;
  23 + static final int DEFAULT_SIZE = 20;
  24 + static final String DEFAULT_SORT_FIELD = "tCreateDate";
  25 + static final String DEFAULT_SORT_ORDER = "desc";
  26 + static final String DEFAULT_MATCH_MODE = "contains";
  27 +
  28 + static final Set<String> SORT_FIELDS = Set.of(
  29 + "tCreateDate", "tLastLoginDate", "sUsername", "sUserCode");
  30 +
  31 + static final Set<String> SORT_ORDERS = Set.of("asc", "desc");
  32 +
  33 + static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals");
  34 +
  35 + static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN");
  36 +
  37 + static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries(
  38 + Map.entry("username", "u.sUsername"),
  39 + Map.entry("employeeName", "e.sEmployeeName"),
  40 + Map.entry("userCode", "u.sUserCode"),
  41 + Map.entry("departmentName", "d.sDepartmentName"),
  42 + Map.entry("userType", "u.sUserType"),
  43 + Map.entry("isDeleted", "u.iIsDeleted"),
  44 + Map.entry("lastLoginDate", "u.tLastLoginDate"),
  45 + Map.entry("createdBy", "u.sCreatedBy"));
  46 +
  47 + private final SysUserMapper userMapper;
  48 +
  49 + @Override
  50 + public PageResult<UserListItemVo> list(UserQueryReq req) {
  51 + // 应用默认值
  52 + int page = req.getPage() == null ? DEFAULT_PAGE : req.getPage();
  53 + int size = req.getSize() == null ? DEFAULT_SIZE : req.getSize();
  54 + String sortField = req.getSortField() == null ? DEFAULT_SORT_FIELD : req.getSortField();
  55 + String sortOrder = req.getSortOrder() == null ? DEFAULT_SORT_ORDER : req.getSortOrder();
  56 + String matchMode = req.getMatchMode() == null ? DEFAULT_MATCH_MODE : req.getMatchMode();
  57 +
  58 + // 白名单校验
  59 + if (!SORT_FIELDS.contains(sortField)) {
  60 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "sortField 不在白名单");
  61 + }
  62 + if (!SORT_ORDERS.contains(sortOrder)) {
  63 + throw new BizException(ErrorCode.BAD_REQUEST, "sortOrder 必须为 asc 或 desc");
  64 + }
  65 + if (!MATCH_MODES.contains(matchMode)) {
  66 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "matchMode 不在白名单");
  67 + }
  68 +
  69 + String sqlQueryColumn = null;
  70 + String normalizedQueryValue = null;
  71 + if (req.getQueryField() != null && !req.getQueryField().isBlank()) {
  72 + sqlQueryColumn = QUERY_FIELD_TO_SQL.get(req.getQueryField());
  73 + if (sqlQueryColumn == null) {
  74 + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单");
  75 + }
  76 + // 只有 queryField + queryValue 都提供且非空才应用条件
  77 + if (req.getQueryValue() != null && !req.getQueryValue().isEmpty()) {
  78 + normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue());
  79 + } else {
  80 + sqlQueryColumn = null; // 缺 queryValue 跳过条件
  81 + }
  82 + }
  83 +
  84 + if (req.getUserType() != null && !USER_TYPES.contains(req.getUserType())) {
  85 + throw new BizException(ErrorCode.BAD_REQUEST, "userType 必须为 NORMAL 或 SUPER_ADMIN");
  86 + }
  87 +
  88 + UserQueryParams p = new UserQueryParams();
  89 + p.sqlSortField = sortField;
  90 + p.sqlSortOrder = sortOrder;
  91 + p.sqlQueryColumn = sqlQueryColumn;
  92 + p.matchMode = matchMode;
  93 + p.queryValue = normalizedQueryValue;
  94 + p.userType = req.getUserType();
  95 + p.isDeleted = req.getIsDeleted() == null ? null : (req.getIsDeleted() ? 1 : 0);
  96 + p.limit = size;
  97 + p.offset = (page - 1) * size;
  98 +
  99 + long total = userMapper.countByQuery(p);
  100 + List<UserListItemVo> records = userMapper.selectByQuery(p);
  101 +
  102 + // 越界矫正:当前页空但 total>0 → 重算最后一页
  103 + int actualPage = page;
  104 + if (records.isEmpty() && total > 0) {
  105 + int lastPage = (int) ((total + size - 1) / size);
  106 + p.offset = (lastPage - 1) * size;
  107 + records = userMapper.selectByQuery(p);
  108 + actualPage = lastPage;
  109 + }
  110 +
  111 + return PageResult.<UserListItemVo>builder()
  112 + .records(records)
  113 + .total(total)
  114 + .page(actualPage)
  115 + .size(size)
  116 + .build();
  117 + }
  118 +
  119 + /**
  120 + * 对 isDeleted(int 0/1)和 lastLoginDate(datetime)做规范化;其他字段返回原值。
  121 + * 非法值抛 40001。
  122 + */
  123 + private String normalizeQueryValue(String queryField, String raw) {
  124 + if ("isDeleted".equals(queryField)) {
  125 + if ("true".equalsIgnoreCase(raw) || "1".equals(raw)) return "1";
  126 + if ("false".equalsIgnoreCase(raw) || "0".equals(raw)) return "0";
  127 + throw new BizException(ErrorCode.BAD_REQUEST,
  128 + "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1");
  129 + }
  130 + return raw;
  131 + }
  132 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.common.response.PageResult;
  6 +import com.xly.erp.module.usr.dto.UserQueryReq;
  7 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  8 +import com.xly.erp.module.usr.vo.UserListItemVo;
  9 +import org.junit.jupiter.api.BeforeEach;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.springframework.beans.factory.annotation.Autowired;
  12 +import org.springframework.boot.test.context.SpringBootTest;
  13 +import org.springframework.test.context.ActiveProfiles;
  14 +
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +@SpringBootTest
  18 +@ActiveProfiles("test")
  19 +class UserListServiceImplTest {
  20 +
  21 + @Autowired private UserListService service;
  22 + @Autowired private LoginTestSeeder seeder;
  23 +
  24 + @BeforeEach
  25 + void setUp() {
  26 + seeder.reset();
  27 + }
  28 +
  29 + private UserQueryReq req() {
  30 + return new UserQueryReq();
  31 + }
  32 +
  33 + @Test
  34 + void list_default_returnsAllUsers() {
  35 + PageResult<UserListItemVo> result = service.list(req());
  36 + assertEquals(3, result.getTotal());
  37 + assertEquals(3, result.getRecords().size());
  38 + assertEquals(1, result.getPage());
  39 + assertEquals(20, result.getSize());
  40 + }
  41 +
  42 + @Test
  43 + void list_sortByUsernameAsc() {
  44 + UserQueryReq r = req();
  45 + r.setSortField("sUsername");
  46 + r.setSortOrder("asc");
  47 + PageResult<UserListItemVo> result = service.list(r);
  48 + assertEquals(LoginTestSeeder.USER_ADMIN, result.getRecords().get(0).getUsername());
  49 + }
  50 +
  51 + @Test
  52 + void list_sortFieldInvalid_throws40003() {
  53 + UserQueryReq r = req();
  54 + r.setSortField("badField");
  55 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  56 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  57 + }
  58 +
  59 + @Test
  60 + void list_sortOrderInvalid_throws40001() {
  61 + UserQueryReq r = req();
  62 + r.setSortOrder("foo");
  63 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  64 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  65 + }
  66 +
  67 + @Test
  68 + void list_queryFieldInvalid_throws40003() {
  69 + UserQueryReq r = req();
  70 + r.setQueryField("badField");
  71 + r.setQueryValue("x");
  72 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  73 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  74 + }
  75 +
  76 + @Test
  77 + void list_matchModeInvalid_throws40003() {
  78 + UserQueryReq r = req();
  79 + r.setMatchMode("startsWith");
  80 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  81 + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode());
  82 + }
  83 +
  84 + @Test
  85 + void list_queryByUsernameContains() {
  86 + UserQueryReq r = req();
  87 + r.setQueryField("username");
  88 + r.setMatchMode("contains");
  89 + r.setQueryValue("ali");
  90 + PageResult<UserListItemVo> result = service.list(r);
  91 + assertEquals(1, result.getTotal());
  92 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  93 + }
  94 +
  95 + @Test
  96 + void list_queryByEmployeeName_joinsCorrectly() {
  97 + UserQueryReq r = req();
  98 + r.setQueryField("employeeName");
  99 + r.setMatchMode("contains");
  100 + r.setQueryValue("张");
  101 + PageResult<UserListItemVo> result = service.list(r);
  102 + assertEquals(1, result.getTotal());
  103 + assertEquals("张三", result.getRecords().get(0).getEmployeeName());
  104 + }
  105 +
  106 + @Test
  107 + void list_queryByDepartmentName_multiLevelJoin() {
  108 + UserQueryReq r = req();
  109 + r.setQueryField("departmentName");
  110 + r.setMatchMode("equals");
  111 + r.setQueryValue("技术部");
  112 + PageResult<UserListItemVo> result = service.list(r);
  113 + assertEquals(1, result.getTotal());
  114 + assertEquals("技术部", result.getRecords().get(0).getDepartmentName());
  115 + }
  116 +
  117 + @Test
  118 + void list_queryByIsDeleted_true_returnsDeleted() {
  119 + UserQueryReq r = req();
  120 + r.setQueryField("isDeleted");
  121 + r.setMatchMode("equals");
  122 + r.setQueryValue("true");
  123 + PageResult<UserListItemVo> result = service.list(r);
  124 + assertEquals(1, result.getTotal());
  125 + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername());
  126 + }
  127 +
  128 + @Test
  129 + void list_queryByIsDeleted_invalidValue_throws40001() {
  130 + UserQueryReq r = req();
  131 + r.setQueryField("isDeleted");
  132 + r.setQueryValue("maybe");
  133 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  134 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  135 + }
  136 +
  137 + @Test
  138 + void list_queryFieldWithoutValue_skipsCondition() {
  139 + UserQueryReq r = req();
  140 + r.setQueryField("username");
  141 + // no queryValue
  142 + PageResult<UserListItemVo> result = service.list(r);
  143 + assertEquals(3, result.getTotal());
  144 + }
  145 +
  146 + @Test
  147 + void list_explicitUserTypeFilter() {
  148 + UserQueryReq r = req();
  149 + r.setUserType("NORMAL");
  150 + PageResult<UserListItemVo> result = service.list(r);
  151 + // alice (NORMAL active) + bob_deleted (NORMAL deleted) = 2
  152 + assertEquals(2, result.getTotal());
  153 + }
  154 +
  155 + @Test
  156 + void list_explicitUserTypeInvalid_throws40001() {
  157 + UserQueryReq r = req();
  158 + r.setUserType("HACKER");
  159 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  160 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  161 + }
  162 +
  163 + @Test
  164 + void list_explicitIsDeletedFalse_filtersActive() {
  165 + UserQueryReq r = req();
  166 + r.setIsDeleted(false);
  167 + PageResult<UserListItemVo> result = service.list(r);
  168 + // alice + admin
  169 + assertEquals(2, result.getTotal());
  170 + }
  171 +
  172 + @Test
  173 + void list_composedFilters_andSemantics() {
  174 + UserQueryReq r = req();
  175 + r.setQueryField("username");
  176 + r.setQueryValue("a");
  177 + r.setUserType("NORMAL");
  178 + r.setIsDeleted(false);
  179 + PageResult<UserListItemVo> result = service.list(r);
  180 + // alice 是唯一 NORMAL + 启用 + 名字含 a 的
  181 + assertEquals(1, result.getTotal());
  182 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  183 + }
  184 +
  185 + @Test
  186 + void list_pageBeyondTotal_returnsLastPage() {
  187 + UserQueryReq r = req();
  188 + r.setPage(999);
  189 + r.setSize(10);
  190 + PageResult<UserListItemVo> result = service.list(r);
  191 + assertEquals(3, result.getTotal());
  192 + assertFalse(result.getRecords().isEmpty(), "越界应返回最后一页数据");
  193 + assertEquals(1, result.getPage(), "actualPage 应矫正为最后一页 (3/10 → 1)");
  194 + }
  195 +
  196 + @Test
  197 + void list_responseDoesNotIncludePasswordField() {
  198 + PageResult<UserListItemVo> result = service.list(req());
  199 + UserListItemVo vo = result.getRecords().get(0);
  200 + // UserListItemVo 不应有 password 相关字段——通过反射验证
  201 + for (java.lang.reflect.Field f : vo.getClass().getDeclaredFields()) {
  202 + assertFalse(f.getName().toLowerCase().contains("password"),
  203 + "VO 字段不应含 password: " + f.getName());
  204 + }
  205 + }
  206 +}
... ...