Commit 8bf84c921d71963d9e1c902d13faa10255727b05

Authored by zichun
1 parent d931bbcb

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

- isDeleted/lastLoginDate 强制 matchMode=equals + lastLoginDate 类型归一化
- queryValue 判空升级为 isBlank
- docs/05 § REQ-USR-004 错误码补 sortField → 40003 + 40101
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserListServiceImpl.java
@@ -11,6 +11,9 @@ import com.xly.erp.module.usr.vo.UserListItemVo; @@ -11,6 +11,9 @@ import com.xly.erp.module.usr.vo.UserListItemVo;
11 import lombok.RequiredArgsConstructor; 11 import lombok.RequiredArgsConstructor;
12 import org.springframework.stereotype.Service; 12 import org.springframework.stereotype.Service;
13 13
  14 +import java.time.LocalDateTime;
  15 +import java.time.format.DateTimeFormatter;
  16 +import java.time.format.DateTimeParseException;
14 import java.util.List; 17 import java.util.List;
15 import java.util.Map; 18 import java.util.Map;
16 import java.util.Set; 19 import java.util.Set;
@@ -32,6 +35,9 @@ public class UserListServiceImpl implements UserListService { @@ -32,6 +35,9 @@ public class UserListServiceImpl implements UserListService {
32 35
33 static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals"); 36 static final Set<String> MATCH_MODES = Set.of("contains", "notContains", "equals");
34 37
  38 + /** spec § 业务规则 3:非字符串列(int/datetime)一律按 equals 处理。 */
  39 + static final Set<String> EQUALS_ONLY_FIELDS = Set.of("isDeleted", "lastLoginDate");
  40 +
35 static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN"); 41 static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN");
36 42
37 static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries( 43 static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries(
@@ -74,8 +80,12 @@ public class UserListServiceImpl implements UserListService { @@ -74,8 +80,12 @@ public class UserListServiceImpl implements UserListService {
74 throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单"); 80 throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单");
75 } 81 }
76 // 只有 queryField + queryValue 都提供且非空才应用条件 82 // 只有 queryField + queryValue 都提供且非空才应用条件
77 - if (req.getQueryValue() != null && !req.getQueryValue().isEmpty()) { 83 + if (req.getQueryValue() != null && !req.getQueryValue().isBlank()) {
78 normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue()); 84 normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue());
  85 + // spec § 业务规则 3:非字符串列一律强制 equals
  86 + if (EQUALS_ONLY_FIELDS.contains(req.getQueryField())) {
  87 + matchMode = "equals";
  88 + }
79 } else { 89 } else {
80 sqlQueryColumn = null; // 缺 queryValue 跳过条件 90 sqlQueryColumn = null; // 缺 queryValue 跳过条件
81 } 91 }
@@ -117,8 +127,10 @@ public class UserListServiceImpl implements UserListService { @@ -117,8 +127,10 @@ public class UserListServiceImpl implements UserListService {
117 } 127 }
118 128
119 /** 129 /**
120 - * 对 isDeleted(int 0/1)和 lastLoginDate(datetime)做规范化;其他字段返回原值。  
121 - * 非法值抛 40001。 130 + * 对非字符串列做规范化(spec § 业务规则 3):
  131 + * - isDeleted: true/1 → "1";false/0 → "0";其他抛 40001
  132 + * - lastLoginDate: 解析 ISO LOCAL_DATE_TIME 或 ISO LOCAL_DATE,统一格式为 'yyyy-MM-dd HH:mm:ss';非法抛 40001
  133 + * - 其他列返回原值
122 */ 134 */
123 private String normalizeQueryValue(String queryField, String raw) { 135 private String normalizeQueryValue(String queryField, String raw) {
124 if ("isDeleted".equals(queryField)) { 136 if ("isDeleted".equals(queryField)) {
@@ -127,6 +139,20 @@ public class UserListServiceImpl implements UserListService { @@ -127,6 +139,20 @@ public class UserListServiceImpl implements UserListService {
127 throw new BizException(ErrorCode.BAD_REQUEST, 139 throw new BizException(ErrorCode.BAD_REQUEST,
128 "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1"); 140 "queryField=isDeleted 时 queryValue 必须为 true / false / 0 / 1");
129 } 141 }
  142 + if ("lastLoginDate".equals(queryField)) {
  143 + try {
  144 + LocalDateTime dt;
  145 + if (raw.contains("T") || raw.contains(" ")) {
  146 + dt = LocalDateTime.parse(raw.replace(' ', 'T'));
  147 + } else {
  148 + dt = java.time.LocalDate.parse(raw).atStartOfDay();
  149 + }
  150 + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  151 + } catch (DateTimeParseException e) {
  152 + throw new BizException(ErrorCode.BAD_REQUEST,
  153 + "queryField=lastLoginDate 时 queryValue 必须为 ISO 日期或日期时间");
  154 + }
  155 + }
130 return raw; 156 return raw;
131 } 157 }
132 } 158 }
backend/src/test/java/com/xly/erp/module/usr/service/UserListServiceImplTest.java
@@ -194,6 +194,46 @@ class UserListServiceImplTest { @@ -194,6 +194,46 @@ class UserListServiceImplTest {
194 } 194 }
195 195
196 @Test 196 @Test
  197 + void list_queryByIsDeleted_matchModeContains_isForcedToEquals() {
  198 + // spec § 业务规则 3:isDeleted matchMode 强制 equals
  199 + UserQueryReq r = req();
  200 + r.setQueryField("isDeleted");
  201 + r.setMatchMode("contains");
  202 + r.setQueryValue("true");
  203 + PageResult<UserListItemVo> result = service.list(r);
  204 + // 应该等同于 equals true → 仅 bob_deleted
  205 + assertEquals(1, result.getTotal());
  206 + assertEquals(LoginTestSeeder.USER_DELETED, result.getRecords().get(0).getUsername());
  207 + }
  208 +
  209 + @Test
  210 + void list_queryByLastLoginDate_matchModeContains_isForcedToEquals_andDateNormalized() {
  211 + // 给 alice 写一个明确的 lastLoginDate
  212 + jdbc.update("UPDATE sys_user SET tLastLoginDate='2026-05-15 10:00:00' WHERE sUsername=?",
  213 + LoginTestSeeder.USER_OK);
  214 +
  215 + UserQueryReq r = req();
  216 + r.setQueryField("lastLoginDate");
  217 + r.setMatchMode("contains");
  218 + r.setQueryValue("2026-05-15 10:00:00");
  219 + PageResult<UserListItemVo> result = service.list(r);
  220 + assertEquals(1, result.getTotal());
  221 + assertEquals(LoginTestSeeder.USER_OK, result.getRecords().get(0).getUsername());
  222 + }
  223 +
  224 + @Test
  225 + void list_queryByLastLoginDate_invalidValue_throws40001() {
  226 + UserQueryReq r = req();
  227 + r.setQueryField("lastLoginDate");
  228 + r.setQueryValue("not-a-date");
  229 + BizException e = assertThrows(BizException.class, () -> service.list(r));
  230 + assertEquals(ErrorCode.BAD_REQUEST, e.getCode());
  231 + }
  232 +
  233 + @org.springframework.beans.factory.annotation.Autowired
  234 + private org.springframework.jdbc.core.JdbcTemplate jdbc;
  235 +
  236 + @Test
197 void list_responseDoesNotIncludePasswordField() { 237 void list_responseDoesNotIncludePasswordField() {
198 PageResult<UserListItemVo> result = service.list(req()); 238 PageResult<UserListItemVo> result = service.list(req());
199 UserListItemVo vo = result.getRecords().get(0); 239 UserListItemVo vo = result.getRecords().get(0);
docs/05-API接口契约.md
@@ -124,6 +124,7 @@ BasePath: `/api/v1` @@ -124,6 +124,7 @@ BasePath: `/api/v1`
124 - **响应**: `PageResult<UserListItemVo>`,`UserListItemVo { userId, username, employeeName, userCode, departmentName, userType, language, isDeleted, lastLoginDate, createdBy, createdDate }`(密码字段不返回) 124 - **响应**: `PageResult<UserListItemVo>`,`UserListItemVo { userId, username, employeeName, userCode, departmentName, userType, language, isDeleted, lastLoginDate, createdBy, createdDate }`(密码字段不返回)
125 125
126 #### 错误码 126 #### 错误码
127 -- `40001` — 分页参数越界或类型错误  
128 -- `40003` — `queryField` / `matchMode` 不在白名单 127 +- `40001` — 分页参数越界或类型错误(page<1 / size 越界 / sortOrder 非 asc-desc / userType 非枚举 / lastLoginDate 入参非法日期 / isDeleted 入参非布尔)
  128 +- `40003` — `sortField` / `queryField` / `matchMode` 不在白名单
  129 +- `40101` — 未携带或无效 Token
129 - `40301` — 当前用户非超级管理员,无权调用 130 - `40301` — 当前用户非超级管理员,无权调用