Commit 8bf84c921d71963d9e1c902d13faa10255727b05
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
Showing
3 changed files
with
72 additions
and
5 deletions
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 | 11 | import lombok.RequiredArgsConstructor; |
| 12 | 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 | 17 | import java.util.List; |
| 15 | 18 | import java.util.Map; |
| 16 | 19 | import java.util.Set; |
| ... | ... | @@ -32,6 +35,9 @@ public class UserListServiceImpl implements UserListService { |
| 32 | 35 | |
| 33 | 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 | 41 | static final Set<String> USER_TYPES = Set.of("NORMAL", "SUPER_ADMIN"); |
| 36 | 42 | |
| 37 | 43 | static final Map<String, String> QUERY_FIELD_TO_SQL = Map.ofEntries( |
| ... | ... | @@ -74,8 +80,12 @@ public class UserListServiceImpl implements UserListService { |
| 74 | 80 | throw new BizException(ErrorCode.INVALID_ENUM_PARAM, "queryField 不在白名单"); |
| 75 | 81 | } |
| 76 | 82 | // 只有 queryField + queryValue 都提供且非空才应用条件 |
| 77 | - if (req.getQueryValue() != null && !req.getQueryValue().isEmpty()) { | |
| 83 | + if (req.getQueryValue() != null && !req.getQueryValue().isBlank()) { | |
| 78 | 84 | normalizedQueryValue = normalizeQueryValue(req.getQueryField(), req.getQueryValue()); |
| 85 | + // spec § 业务规则 3:非字符串列一律强制 equals | |
| 86 | + if (EQUALS_ONLY_FIELDS.contains(req.getQueryField())) { | |
| 87 | + matchMode = "equals"; | |
| 88 | + } | |
| 79 | 89 | } else { |
| 80 | 90 | sqlQueryColumn = null; // 缺 queryValue 跳过条件 |
| 81 | 91 | } |
| ... | ... | @@ -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 | 135 | private String normalizeQueryValue(String queryField, String raw) { |
| 124 | 136 | if ("isDeleted".equals(queryField)) { |
| ... | ... | @@ -127,6 +139,20 @@ public class UserListServiceImpl implements UserListService { |
| 127 | 139 | throw new BizException(ErrorCode.BAD_REQUEST, |
| 128 | 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 | 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 | 194 | } |
| 195 | 195 | |
| 196 | 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 | 237 | void list_responseDoesNotIncludePasswordField() { |
| 198 | 238 | PageResult<UserListItemVo> result = service.list(req()); |
| 199 | 239 | UserListItemVo vo = result.getRecords().get(0); | ... | ... |
docs/05-API接口契约.md
| ... | ... | @@ -124,6 +124,7 @@ BasePath: `/api/v1` |
| 124 | 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 | 130 | - `40301` — 当前用户非超级管理员,无权调用 | ... | ... |