Commit f53689c3439f1d4bf2b937a4c3610112a8373d2c

Authored by zichun
1 parent df28ae66

fix(usr): 修复 review round 1 must-fix REQ-USR-003

- HIGH 修注入:UserQueryDTO 移除 column 字段,
  改成 service 局部变量 + UserMapper @Param("column") 单独传入,
  防止 GET query-string 通过 setter 绑定绕过白名单。
- HIGH 修 spec § 6:service 在 queryField=='deleted' 时
  把 queryValue 标准化为 '0' / '1';UserMapper.xml 加 deleted
  专用 CAST(#{queryValue} AS UNSIGNED) 分支处理 MySQL bit(1)
  与字符串隐式比较的不一致;恢复 get_filterByDeletedTrue IT。
- MEDIUM 修 XML deleted 边界:仅当 queryField=='deleted' 且
  queryValue 非空时让用户控制 bDeleted 取值,否则保留默认过滤。
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java
1 package com.xly.erp.module.usr.dto; 1 package com.xly.erp.module.usr.dto;
2 2
3 -import com.fasterxml.jackson.annotation.JsonIgnore;  
4 import jakarta.validation.constraints.Max; 3 import jakarta.validation.constraints.Max;
5 import jakarta.validation.constraints.Min; 4 import jakarta.validation.constraints.Min;
6 import jakarta.validation.constraints.Pattern; 5 import jakarta.validation.constraints.Pattern;
@@ -18,7 +17,7 @@ public class UserQueryDTO { @@ -18,7 +17,7 @@ public class UserQueryDTO {
18 @Max(100) 17 @Max(100)
19 private Integer pageSize = 20; 18 private Integer pageSize = 20;
20 19
21 - /** 可空:缺省视为不过滤;服务层白名单校验后映射到 column 字段 */ 20 + /** 可空:缺省视为不过滤;服务层白名单映射为 SQL 列名后通过 mapper @Param 单独传入 */
22 @Pattern(regexp = "^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$", 21 @Pattern(regexp = "^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$",
23 message = "queryField 非法") 22 message = "queryField 非法")
24 private String queryField; 23 private String queryField;
@@ -30,9 +29,4 @@ public class UserQueryDTO { @@ -30,9 +29,4 @@ public class UserQueryDTO {
30 /** 可空:缺省视为不过滤 */ 29 /** 可空:缺省视为不过滤 */
31 @Size(max = 100) 30 @Size(max = 100)
32 private String queryValue; 31 private String queryValue;
33 -  
34 - /** 服务层白名单映射后的实际 SQL 列名(如 "u.sUserName")。  
35 - * Jackson 忽略——前端不能也无法直接传入;XML mapper 用 ${query.column} 渲染。 */  
36 - @JsonIgnore  
37 - private String column;  
38 } 32 }
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java
@@ -9,6 +9,11 @@ import org.apache.ibatis.annotations.Param; @@ -9,6 +9,11 @@ import org.apache.ibatis.annotations.Param;
9 9
10 public interface UserMapper extends BaseMapper<UserEntity> { 10 public interface UserMapper extends BaseMapper<UserEntity> {
11 11
12 - /** REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。 */  
13 - IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query); 12 + /**
  13 + * REQ-USR-003 用户列表查询:跨表 JOIN tStaff,按 query 过滤 + 分页。XML 实现。
  14 + * @param column service 层白名单映射后的 SQL 列字符串(如 "u.sUserName");外部输入绝不直接走这里。
  15 + */
  16 + IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page,
  17 + @Param("query") UserQueryDTO query,
  18 + @Param("column") String column);
14 } 19 }
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java
@@ -187,13 +187,15 @@ public class UserServiceImpl implements UserService { @@ -187,13 +187,15 @@ public class UserServiceImpl implements UserService {
187 @Override 187 @Override
188 @Transactional(readOnly = true) 188 @Transactional(readOnly = true)
189 public PageResult<UserListItemVO> search(UserQueryDTO query) { 189 public PageResult<UserListItemVO> search(UserQueryDTO query) {
190 - // 1. queryField 白名单 + 列映射(防 SQL 注入) 190 + // 1. queryField 白名单 + 列映射(防 SQL 注入)。
  191 + // column 是 service 内部局部变量,通过 mapper @Param("column") 单独传入;不写回 DTO,
  192 + // 避免 GET query-string 绑定(@JsonIgnore 仅对 Jackson 生效,无法防 setter 注入)。
  193 + String column = null;
191 if (query.getQueryField() != null && !query.getQueryField().isEmpty()) { 194 if (query.getQueryField() != null && !query.getQueryField().isEmpty()) {
192 - String mapped = QUERY_COLUMN_MAP.get(query.getQueryField());  
193 - if (mapped == null) { 195 + column = QUERY_COLUMN_MAP.get(query.getQueryField());
  196 + if (column == null) {
194 throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField()); 197 throw new BizException(ErrorCode.PARAM_INVALID, "queryField 非法: " + query.getQueryField());
195 } 198 }
196 - query.setColumn(mapped);  
197 } 199 }
198 200
199 // 2. matchType 白名单 201 // 2. matchType 白名单
@@ -202,13 +204,26 @@ public class UserServiceImpl implements UserService { @@ -202,13 +204,26 @@ public class UserServiceImpl implements UserService {
202 throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType()); 204 throw new BizException(ErrorCode.PARAM_INVALID, "matchType 非法: " + query.getMatchType());
203 } 205 }
204 206
205 - // 3. 默认值兜底 207 + // 3. spec § 业务规则 6:deleted 字段值标准化('true'/'1' → '1';'false'/'0' → '0';其它非法)
  208 + if ("deleted".equals(query.getQueryField())
  209 + && query.getQueryValue() != null && !query.getQueryValue().isEmpty()) {
  210 + String v = query.getQueryValue().trim().toLowerCase();
  211 + if ("true".equals(v) || "1".equals(v)) {
  212 + query.setQueryValue("1");
  213 + } else if ("false".equals(v) || "0".equals(v)) {
  214 + query.setQueryValue("0");
  215 + } else {
  216 + throw new BizException(ErrorCode.PARAM_INVALID, "deleted queryValue 仅支持 true/false/1/0");
  217 + }
  218 + }
  219 +
  220 + // 4. 默认值兜底
206 int pageNum = query.getPageNum() == null ? 1 : query.getPageNum(); 221 int pageNum = query.getPageNum() == null ? 1 : query.getPageNum();
207 int pageSize = query.getPageSize() == null ? 20 : query.getPageSize(); 222 int pageSize = query.getPageSize() == null ? 20 : query.getPageSize();
208 223
209 - // 4. MP 分页查询 224 + // 5. MP 分页查询
210 IPage<UserListItemVO> page = new Page<>(pageNum, pageSize); 225 IPage<UserListItemVO> page = new Page<>(pageNum, pageSize);
211 - IPage<UserListItemVO> result = userMapper.searchUsers(page, query); 226 + IPage<UserListItemVO> result = userMapper.searchUsers(page, query, column);
212 227
213 return PageResult.of(result); 228 return PageResult.of(result);
214 } 229 }
backend/src/main/resources/mapper/usr/UserMapper.xml
@@ -3,7 +3,8 @@ @@ -3,7 +3,8 @@
3 <mapper namespace="com.xly.erp.module.usr.mapper.UserMapper"> 3 <mapper namespace="com.xly.erp.module.usr.mapper.UserMapper">
4 4
5 <!-- REQ-USR-003 用户列表查询:LEFT JOIN tStaff + 动态 WHERE。 5 <!-- REQ-USR-003 用户列表查询:LEFT JOIN tStaff + 动态 WHERE。
6 - query.column 由 service 层白名单映射,绝不接受用户原始输入。 --> 6 + column 由 service 层白名单映射后通过 @Param("column") 单独传入,
  7 + 绝不接受 DTO 中可被 GET query-string 绑定的字段。 -->
7 <select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO"> 8 <select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
8 SELECT 9 SELECT
9 u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo, 10 u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
@@ -12,20 +13,26 @@ @@ -12,20 +13,26 @@
12 FROM tUser u 13 FROM tUser u
13 LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0 14 LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
14 <where> 15 <where>
15 - <if test="query.queryField != 'deleted'"> 16 + <!-- 默认过滤已软删除:仅当 queryField=='deleted' 且 queryValue 非空时让用户控制 bDeleted 取值 -->
  17 + <if test="query.queryField != 'deleted' or query.queryValue == null or query.queryValue == ''">
16 u.bDeleted = 0 18 u.bDeleted = 0
17 </if> 19 </if>
18 - <if test="query.column != null and query.column != '' and query.queryValue != null and query.queryValue != ''"> 20 + <if test="column != null and column != '' and query.queryValue != null and query.queryValue != ''">
19 AND 21 AND
20 <choose> 22 <choose>
  23 + <!-- deleted 是 bit(1) 列,MySQL 与字符串 '1'/'0' 隐式比较不可靠;
  24 + service 已把 queryValue 标准化为 '0' / '1',此处显式 CAST 成整数。 -->
  25 + <when test="query.queryField == 'deleted'">
  26 + ${column} = CAST(#{query.queryValue} AS UNSIGNED)
  27 + </when>
21 <when test="query.matchType == 'equals'"> 28 <when test="query.matchType == 'equals'">
22 - ${query.column} = #{query.queryValue} 29 + ${column} = #{query.queryValue}
23 </when> 30 </when>
24 <when test="query.matchType == 'notContains'"> 31 <when test="query.matchType == 'notContains'">
25 - ${query.column} NOT LIKE CONCAT('%', #{query.queryValue}, '%') 32 + ${column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')
26 </when> 33 </when>
27 <otherwise> 34 <otherwise>
28 - ${query.column} LIKE CONCAT('%', #{query.queryValue}, '%') 35 + ${column} LIKE CONCAT('%', #{query.queryValue}, '%')
29 </otherwise> 36 </otherwise>
30 </choose> 37 </choose>
31 </if> 38 </if>
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java
@@ -422,13 +422,23 @@ class UserControllerIT { @@ -422,13 +422,23 @@ class UserControllerIT {
422 .andExpect(jsonPath("$.data.list[0].sStaffName").value(staffName)); 422 .andExpect(jsonPath("$.data.list[0].sStaffName").value(staffName));
423 } 423 }
424 424
425 - // get_filterByDeletedTrue_returnsOnlyDeleted 暂时移除  
426 - // 原因:MyBatis-Plus PaginationInnerInterceptor 与 MySQL bit(1) 列在 String "1" 隐式比较时  
427 - // count 与 main SELECT 行为不一致(count=1 但 list=[]);spec § 业务规则 6 的  
428 - // 值标准化('true'/'false' → '1'/'0')也未实现。  
429 - // 计划:留作 REQ-USR-004 时统一处理 + 后续 docs sweep 重做。  
430 - // service 单测 search_invalidQueryField / search_passesMappedColumnToMapper 已覆盖白名单 + 列映射核心;  
431 - // mapper IT searchUsers_emptyFilter / searchUsers_filterByUserName 已覆盖 SQL 路径骨架。 425 + @Test
  426 + void get_filterByDeletedTrue_returnsOnlyDeleted() throws Exception {
  427 + // 插一个用户后软删
  428 + Integer userId = insertUser("del_" + System.nanoTime(), null, List.of());
  429 + UserEntity patch = new UserEntity();
  430 + patch.setIIncrement(userId);
  431 + patch.setBDeleted(true);
  432 + userMapper.updateById(patch);
  433 +
  434 + mockMvc.perform(get("/api/users")
  435 + .param("queryField", "deleted")
  436 + .param("matchType", "equals")
  437 + .param("queryValue", "true"))
  438 + .andExpect(status().isOk())
  439 + .andExpect(jsonPath("$.code").value(200))
  440 + .andExpect(jsonPath("$.data.list[?(@.iIncrement==" + userId + ")]").exists());
  441 + }
432 442
433 @Test 443 @Test
434 void get_pagination_returnsCorrectSlice() throws Exception { 444 void get_pagination_returnsCorrectSlice() throws Exception {
backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java
@@ -59,7 +59,7 @@ class UserMapperSearchIT { @@ -59,7 +59,7 @@ class UserMapperSearchIT {
59 insertUser("bob_" + System.nanoTime(), null); 59 insertUser("bob_" + System.nanoTime(), null);
60 60
61 UserQueryDTO query = new UserQueryDTO(); 61 UserQueryDTO query = new UserQueryDTO();
62 - IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query); 62 + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, null);
63 63
64 assertThat(result.getTotal()).isGreaterThanOrEqualTo(2L); 64 assertThat(result.getTotal()).isGreaterThanOrEqualTo(2L);
65 assertThat(result.getRecords()).extracting(UserListItemVO::getSUserName) 65 assertThat(result.getRecords()).extracting(UserListItemVO::getSUserName)
@@ -74,11 +74,11 @@ class UserMapperSearchIT { @@ -74,11 +74,11 @@ class UserMapperSearchIT {
74 74
75 UserQueryDTO query = new UserQueryDTO(); 75 UserQueryDTO query = new UserQueryDTO();
76 query.setQueryField("username"); 76 query.setQueryField("username");
77 - query.setColumn("u.sUserName");  
78 query.setMatchType("contains"); 77 query.setMatchType("contains");
79 query.setQueryValue(alicePrefix); 78 query.setQueryValue(alicePrefix);
80 79
81 - IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query); 80 + // column 由 service 层映射;mapper IT 直接传 "u.sUserName"
  81 + IPage<UserListItemVO> result = userMapper.searchUsers(new Page<>(1, 50), query, "u.sUserName");
82 82
83 assertThat(result.getRecords()).hasSize(1); 83 assertThat(result.getRecords()).hasSize(1);
84 assertThat(result.getRecords().get(0).getSUserName()).startsWith(alicePrefix); 84 assertThat(result.getRecords().get(0).getSUserName()).startsWith(alicePrefix);
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java
@@ -425,7 +425,7 @@ class UserServiceImplTest { @@ -425,7 +425,7 @@ class UserServiceImplTest {
425 UserQueryDTO query = new UserQueryDTO(); 425 UserQueryDTO query = new UserQueryDTO();
426 IPage<UserListItemVO> emptyPage = new Page<>(1, 20); 426 IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
427 emptyPage.setTotal(0L); 427 emptyPage.setTotal(0L);
428 - when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class))).thenReturn(emptyPage); 428 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
429 429
430 PageResult<UserListItemVO> result = service.search(query); 430 PageResult<UserListItemVO> result = service.search(query);
431 assertThat(result.getTotal()).isZero(); 431 assertThat(result.getTotal()).isZero();
@@ -460,13 +460,13 @@ class UserServiceImplTest { @@ -460,13 +460,13 @@ class UserServiceImplTest {
460 query.setQueryField("username"); 460 query.setQueryField("username");
461 query.setQueryValue("alice"); 461 query.setQueryValue("alice");
462 IPage<UserListItemVO> emptyPage = new Page<>(1, 20); 462 IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
463 - when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class))).thenReturn(emptyPage); 463 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
464 464
465 service.search(query); 465 service.search(query);
466 466
467 - ArgumentCaptor<UserQueryDTO> cap = ArgumentCaptor.forClass(UserQueryDTO.class);  
468 - verify(userMapper).searchUsers(any(IPage.class), cap.capture());  
469 - assertThat(cap.getValue().getColumn()).isEqualTo("u.sUserName"); 467 + ArgumentCaptor<String> colCap = ArgumentCaptor.forClass(String.class);
  468 + verify(userMapper).searchUsers(any(IPage.class), any(UserQueryDTO.class), colCap.capture());
  469 + assertThat(colCap.getValue()).isEqualTo("u.sUserName");
470 } 470 }
471 471
472 @Test 472 @Test
@@ -475,14 +475,30 @@ class UserServiceImplTest { @@ -475,14 +475,30 @@ class UserServiceImplTest {
475 query.setPageNum(null); 475 query.setPageNum(null);
476 query.setPageSize(null); 476 query.setPageSize(null);
477 IPage<UserListItemVO> emptyPage = new Page<>(1, 20); 477 IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
478 - when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class))).thenReturn(emptyPage); 478 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
479 479
480 service.search(query); 480 service.search(query);
481 481
482 ArgumentCaptor<IPage> pageCap = ArgumentCaptor.forClass(IPage.class); 482 ArgumentCaptor<IPage> pageCap = ArgumentCaptor.forClass(IPage.class);
483 - verify(userMapper).searchUsers(pageCap.capture(), any(UserQueryDTO.class)); 483 + verify(userMapper).searchUsers(pageCap.capture(), any(UserQueryDTO.class), any());
484 IPage<?> page = pageCap.getValue(); 484 IPage<?> page = pageCap.getValue();
485 assertThat(page.getCurrent()).isEqualTo(1L); 485 assertThat(page.getCurrent()).isEqualTo(1L);
486 assertThat(page.getSize()).isEqualTo(20L); 486 assertThat(page.getSize()).isEqualTo(20L);
487 } 487 }
  488 +
  489 + @Test
  490 + void search_deletedQueryValueTrue_normalizedToOne() {
  491 + UserQueryDTO query = new UserQueryDTO();
  492 + query.setQueryField("deleted");
  493 + query.setQueryValue("true");
  494 + query.setMatchType("equals");
  495 + IPage<UserListItemVO> emptyPage = new Page<>(1, 20);
  496 + when(userMapper.searchUsers(any(IPage.class), any(UserQueryDTO.class), any())).thenReturn(emptyPage);
  497 +
  498 + service.search(query);
  499 +
  500 + ArgumentCaptor<UserQueryDTO> cap = ArgumentCaptor.forClass(UserQueryDTO.class);
  501 + verify(userMapper).searchUsers(any(IPage.class), cap.capture(), any());
  502 + assertThat(cap.getValue().getQueryValue()).isEqualTo("1");
  503 + }
488 } 504 }