From 89786804fe775d7fd2e5c074f17bb1778b12090a Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 10:09:08 +0800 Subject: [PATCH] feat(usr): UserListService 白名单 + 动态查询 + 越界矫正 REQ-USR-004 --- backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java | 13 +++++++++++++ backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java create mode 100644 backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java create mode 100644 backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java b/backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java new file mode 100644 index 0000000..486664e --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/UserListService.java @@ -0,0 +1,13 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserQueryReq; +import com.xly.erp.module.usr.vo.UserListItemVo; + +public interface UserListService { + /** + * REQ-USR-004 GET /api/v1/users — 分页 + 多字段筛选 + 排序。 + * 白名单校验、越界矫正均由实现层完成。 + */ + PageResult list(UserQueryReq req); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java new file mode 100644 index 0000000..96ba212 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java @@ -0,0 +1,132 @@ +package com.xly.erp.module.usr.service.impl; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserQueryReq; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import com.xly.erp.module.usr.mapper.UserQueryParams; +import com.xly.erp.module.usr.service.UserListService; +import com.xly.erp.module.usr.vo.UserListItemVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class UserListServiceImpl implements UserListService { + + static final int DEFAULT_PAGE = 1; + static final int DEFAULT_SIZE = 20; + static final String DEFAULT_SORT_FIELD = "tCreateDate"; + static final String DEFAULT_SORT_ORDER = "desc"; + static final String DEFAULT_MATCH_MODE = "contains"; + + static final Set SORT_FIELDS = Set.of( + "tCreateDate", "tLastLoginDate", "sUsername", "sUserCode"); + + static final Set SORT_ORDERS = Set.of("asc", "desc"); + + static final Set MATCH_MODES = Set.of("contains", "notContains", "equals"); + + static final Set USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN"); + + static final Map QUERY_FIELD_TO_SQL = Map.ofEntries( + Map.entry("username", "u.sUsername"), + Map.entry("employeeName", "e.sEmployeeName"), + Map.entry("userCode", "u.sUserCode"), + Map.entry("departmentName", "d.sDepartmentName"), + Map.entry("userType", "u.sUserType"), + Map.entry("isDeleted", "u.iIsDeleted"), + Map.entry("lastLoginDate", "u.tLastLoginDate"), + Map.entry("createdBy", "u.sCreatedBy")); + + private final SysUserMapper userMapper; + + @Override + public PageResult list(UserQueryReq req) { + // 应用默认值 + int page = req.getPage() == null ? DEFAULT_PAGE : req.getPage(); + int size = req.getSize() == null ? DEFAULT_SIZE : req.getSize(); + String sortField = req.getSortField() == null ? DEFAULT_SORT_FIELD : req.getSortField(); + String sortOrder = req.getSortOrder() == null ? DEFAULT_SORT_ORDER : req.getSortOrder(); + String matchMode = req.getMatchMode() == null ? DEFAULT_MATCH_MODE : req.getMatchMode(); + + // 白名单校验 + if (!SORT_FIELDS.contains(sortField)) { + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "sortField 不在白名单"); + } + if (!SORT_ORDERS.contains(sortOrder)) { + throw new BizException(ErrorCode.BAD_REQUEST, "sortOrder 必须为 asc 或 desc"); + } + if (!MATCH_MODES.contains(matchMode)) { + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "matchMode 不在白名单"); + } + + String sqlQueryColumn = null; + String normalizedQueryValue = null; + if (req.getQueryField() != null && !req.getQueryField().isBlank()) { + sqlQueryColumn = QUERY_FIELD_TO_SQL.get(req.getQueryField()); + if (sqlQueryColumn == null) { + throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单"); + } + // 只有 queryField + queryValue 都提供且非空才应用条件 + if (req.getQueryValue() != null && !req.getQueryValue().isEmpty()) { + normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue()); + } else { + sqlQueryColumn = null; // 缺 queryValue 跳过条件 + } + } + + if (req.getUserType() != null && !USER_TYPES.contains(req.getUserType())) { + throw new BizException(ErrorCode.BAD_REQUEST, "userType 必须为 NORMAL 或 SUPER_ADMIN"); + } + + UserQueryParams p = new UserQueryParams(); + p.sqlSortField = sortField; + p.sqlSortOrder = sortOrder; + p.sqlQueryColumn = sqlQueryColumn; + p.matchMode = matchMode; + p.queryValue = normalizedQueryValue; + p.userType = req.getUserType(); + p.isDeleted = req.getIsDeleted() == null ? null : (req.getIsDeleted() ? 1 : 0); + p.limit = size; + p.offset = (page - 1) * size; + + long total = userMapper.countByQuery(p); + List records = userMapper.selectByQuery(p); + + // 越界矫正:当前页空但 total>0 → 重算最后一页 + int actualPage = page; + if (records.isEmpty() && total > 0) { + int lastPage = (int) ((total + size - 1) / size); + p.offset = (lastPage - 1) * size; + records = userMapper.selectByQuery(p); + actualPage = lastPage; + } + + return PageResult.builder() + .records(records) + .total(total) + .page(actualPage) + .size(size) + .build(); + } + + /** + * 对 isDeleted(int 0/1)和 lastLoginDate(datetime)做规范化;其他字段返回原值。 + * 非法值抛 40001。 + */ + private String normalizeQueryValue(String queryField, String raw) { + if ("isDeleted".equals(queryField)) { + if ("true".equalsIgnoreCase(raw) || "1".equals(raw)) return "1"; + if ("false".equalsIgnoreCase(raw) || "0".equals(raw)) return "0"; + throw new BizException(ErrorCode.BAD_REQUEST, + "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1"); + } + return raw; + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java new file mode 100644 index 0000000..7fdd252 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java @@ -0,0 +1,206 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.response.PageResult; +import com.xly.erp.module.usr.dto.UserQueryReq; +import com.xly.erp.module.usr.support.LoginTestSeeder; +import com.xly.erp.module.usr.vo.UserListItemVo; +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 static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class UserListServiceImplTest { + + @Autowired private UserListService service; + @Autowired private LoginTestSeeder seeder; + + @BeforeEach + void setUp() { + seeder.reset(); + } + + private UserQueryReq req() { + return new UserQueryReq(); + } + + @Test + void list_default_returnsAllUsers() { + PageResult result = service.list(req()); + assertEquals(3, result.getTotal()); + assertEquals(3, result.getRecords().size()); + assertEquals(1, result.getPage()); + assertEquals(20, result.getSize()); + } + + @Test + void list_sortByUsernameAsc() { + UserQueryReq r = req(); + r.setSortField("sUsername"); + r.setSortOrder("asc"); + PageResult result = service.list(r); + assertEquals(LoginTestSeeder.USER_ADMIN, result.getRecords().get(0).getUsername()); + } + + @Test + void list_sortFieldInvalid_throws40003() { + UserQueryReq r = req(); + r.setSortField("badField"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); + } + + @Test + void list_sortOrderInvalid_throws40001() { + UserQueryReq r = req(); + r.setSortOrder("foo"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); + } + + @Test + void list_queryFieldInvalid_throws40003() { + UserQueryReq r = req(); + r.setQueryField("badField"); + r.setQueryValue("x"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); + } + + @Test + void list_matchModeInvalid_throws40003() { + UserQueryReq r = req(); + r.setMatchMode("startsWith"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.INVALID_ENUM_PARAM, e.getCode()); + } + + @Test + void list_queryByUsernameContains() { + UserQueryReq r = req(); + r.setQueryField("username"); + r.setMatchMode("contains"); + r.setQueryValue("ali"); + PageResult result = service.list(r); + assertEquals(1, result.getTotal()); + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername()); + } + + @Test + void list_queryByEmployeeName_joinsCorrectly() { + UserQueryReq r = req(); + r.setQueryField("employeeName"); + r.setMatchMode("contains"); + r.setQueryValue("张"); + PageResult result = service.list(r); + assertEquals(1, result.getTotal()); + assertEquals("张三", result.getRecords().get(0).getEmployeeName()); + } + + @Test + void list_queryByDepartmentName_multiLevelJoin() { + UserQueryReq r = req(); + r.setQueryField("departmentName"); + r.setMatchMode("equals"); + r.setQueryValue("技术部"); + PageResult result = service.list(r); + assertEquals(1, result.getTotal()); + assertEquals("技术部", result.getRecords().get(0).getDepartmentName()); + } + + @Test + void list_queryByIsDeleted_true_returnsDeleted() { + UserQueryReq r = req(); + r.setQueryField("isDeleted"); + r.setMatchMode("equals"); + r.setQueryValue("true"); + PageResult result = service.list(r); + assertEquals(1, result.getTotal()); + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername()); + } + + @Test + void list_queryByIsDeleted_invalidValue_throws40001() { + UserQueryReq r = req(); + r.setQueryField("isDeleted"); + r.setQueryValue("maybe"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); + } + + @Test + void list_queryFieldWithoutValue_skipsCondition() { + UserQueryReq r = req(); + r.setQueryField("username"); + // no queryValue + PageResult result = service.list(r); + assertEquals(3, result.getTotal()); + } + + @Test + void list_explicitUserTypeFilter() { + UserQueryReq r = req(); + r.setUserType("NORMAL"); + PageResult result = service.list(r); + // alice (NORMAL active) + bob_deleted (NORMAL deleted) = 2 + assertEquals(2, result.getTotal()); + } + + @Test + void list_explicitUserTypeInvalid_throws40001() { + UserQueryReq r = req(); + r.setUserType("HACKER"); + BizException e = assertThrows(BizException.class, () -> service.list(r)); + assertEquals(ErrorCode.BAD_REQUEST, e.getCode()); + } + + @Test + void list_explicitIsDeletedFalse_filtersActive() { + UserQueryReq r = req(); + r.setIsDeleted(false); + PageResult result = service.list(r); + // alice + admin + assertEquals(2, result.getTotal()); + } + + @Test + void list_composedFilters_andSemantics() { + UserQueryReq r = req(); + r.setQueryField("username"); + r.setQueryValue("a"); + r.setUserType("NORMAL"); + r.setIsDeleted(false); + PageResult result = service.list(r); + // alice 是唯一 NORMAL + 启用 + 名字含 a 的 + assertEquals(1, result.getTotal()); + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername()); + } + + @Test + void list_pageBeyondTotal_returnsLastPage() { + UserQueryReq r = req(); + r.setPage(999); + r.setSize(10); + PageResult result = service.list(r); + assertEquals(3, result.getTotal()); + assertFalse(result.getRecords().isEmpty(), "越界应返回最后一页数据"); + assertEquals(1, result.getPage(), "actualPage 应矫正为最后一页 (3/10 → 1)"); + } + + @Test + void list_responseDoesNotIncludePasswordField() { + PageResult result = service.list(req()); + UserListItemVo vo = result.getRecords().get(0); + // UserListItemVo 不应有 password 相关字段——通过反射验证 + for (java.lang.reflect.Field f : vo.getClass().getDeclaredFields()) { + assertFalse(f.getName().toLowerCase().contains("password"), + "VO 字段不应含 password: " + f.getName()); + } + } +} -- libgit2 0.22.2