Commit f9a9070e48f9fda6fdae97f57c5410d1d51849e6
1 parent
c8a87494
feat(mod): query module tree service REQ-MOD-004
Showing
3 changed files
with
209 additions
and
0 deletions
backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java
| 1 | 1 | package com.xly.erp.module.mod.service; |
| 2 | 2 | |
| 3 | 3 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 4 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | |
| 4 | 5 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 5 | 6 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 7 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | |
| 6 | 8 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 7 | 9 | |
| 10 | +import java.util.List; | |
| 11 | + | |
| 8 | 12 | public interface ModuleService { |
| 9 | 13 | /** REQ-MOD-001 模块新增 */ |
| 10 | 14 | ModuleVO create(ModuleCreateDTO dto); |
| ... | ... | @@ -14,4 +18,7 @@ public interface ModuleService { |
| 14 | 18 | |
| 15 | 19 | /** REQ-MOD-003 模块软删除 */ |
| 16 | 20 | ModuleDeleteResultVO delete(Integer id); |
| 21 | + | |
| 22 | + /** REQ-MOD-004 模块树查询(可选 keyword 模糊匹配 + 祖先链) */ | |
| 23 | + List<ModuleTreeNodeVO> tree(ModuleQueryDTO query); | |
| 17 | 24 | } | ... | ... |
backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java
| ... | ... | @@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| 5 | 5 | import com.xly.erp.common.exception.BizException; |
| 6 | 6 | import com.xly.erp.common.response.ErrorCode; |
| 7 | 7 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 8 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | |
| 8 | 9 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 9 | 10 | import com.xly.erp.module.mod.entity.ModuleEntity; |
| 10 | 11 | import com.xly.erp.module.mod.mapper.ModuleMapper; |
| 11 | 12 | import com.xly.erp.module.mod.service.ModuleService; |
| 12 | 13 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 14 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | |
| 13 | 15 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 14 | 16 | import lombok.RequiredArgsConstructor; |
| 15 | 17 | import org.springframework.dao.DuplicateKeyException; |
| ... | ... | @@ -17,6 +19,14 @@ import org.springframework.stereotype.Service; |
| 17 | 19 | import org.springframework.transaction.annotation.Transactional; |
| 18 | 20 | |
| 19 | 21 | import java.time.LocalDateTime; |
| 22 | +import java.util.ArrayList; | |
| 23 | +import java.util.Comparator; | |
| 24 | +import java.util.HashMap; | |
| 25 | +import java.util.HashSet; | |
| 26 | +import java.util.List; | |
| 27 | +import java.util.Map; | |
| 28 | +import java.util.Set; | |
| 29 | +import java.util.stream.Collectors; | |
| 20 | 30 | |
| 21 | 31 | @Service |
| 22 | 32 | @RequiredArgsConstructor |
| ... | ... | @@ -165,4 +175,84 @@ public class ModuleServiceImpl implements ModuleService { |
| 165 | 175 | |
| 166 | 176 | return ModuleDeleteResultVO.of(id, true); |
| 167 | 177 | } |
| 178 | + | |
| 179 | + /** REQ-MOD-004 模块树查询 */ | |
| 180 | + @Override | |
| 181 | + @Transactional(readOnly = true) | |
| 182 | + public List<ModuleTreeNodeVO> tree(ModuleQueryDTO query) { | |
| 183 | + // 1. 拉取所有未软删除模块 | |
| 184 | + List<ModuleEntity> all = moduleMapper.selectList( | |
| 185 | + new LambdaQueryWrapper<ModuleEntity>().eq(ModuleEntity::getBDeleted, false)); | |
| 186 | + if (all.isEmpty()) { | |
| 187 | + return new ArrayList<>(); | |
| 188 | + } | |
| 189 | + | |
| 190 | + // 2. id → entity 索引 | |
| 191 | + Map<Integer, ModuleEntity> byId = all.stream() | |
| 192 | + .collect(Collectors.toMap(ModuleEntity::getIIncrement, e -> e)); | |
| 193 | + | |
| 194 | + // 3. 计算 survivors 集合 | |
| 195 | + String keyword = query == null ? null : query.getKeyword(); | |
| 196 | + Set<Integer> survivorIds; | |
| 197 | + if (keyword == null || keyword.isEmpty()) { | |
| 198 | + survivorIds = byId.keySet(); | |
| 199 | + } else { | |
| 200 | + survivorIds = new HashSet<>(); | |
| 201 | + // 命中节点 | |
| 202 | + List<ModuleEntity> hits = all.stream() | |
| 203 | + .filter(e -> e.getSModuleNameZh() != null && e.getSModuleNameZh().contains(keyword)) | |
| 204 | + .toList(); | |
| 205 | + for (ModuleEntity hit : hits) { | |
| 206 | + survivorIds.add(hit.getIIncrement()); | |
| 207 | + // 沿父链向上收集祖先(深度上限 5) | |
| 208 | + Integer parentId = hit.getIParentId(); | |
| 209 | + int depth = 0; | |
| 210 | + while (parentId != null && depth < 5) { | |
| 211 | + if (!survivorIds.add(parentId)) { | |
| 212 | + break; // 已存在,提前结束避免循环 | |
| 213 | + } | |
| 214 | + ModuleEntity parent = byId.get(parentId); | |
| 215 | + if (parent == null) break; | |
| 216 | + parentId = parent.getIParentId(); | |
| 217 | + depth++; | |
| 218 | + } | |
| 219 | + } | |
| 220 | + if (survivorIds.isEmpty()) { | |
| 221 | + return new ArrayList<>(); | |
| 222 | + } | |
| 223 | + } | |
| 224 | + | |
| 225 | + // 4. 转 VO + 按 (iSortOrder, iIncrement) 排序 | |
| 226 | + Comparator<ModuleTreeNodeVO> cmp = Comparator | |
| 227 | + .comparingInt((ModuleTreeNodeVO v) -> v.getISortOrder() == null ? 0 : v.getISortOrder()) | |
| 228 | + .thenComparingInt(ModuleTreeNodeVO::getIIncrement); | |
| 229 | + | |
| 230 | + Map<Integer, ModuleTreeNodeVO> nodeById = new HashMap<>(); | |
| 231 | + for (Integer id : survivorIds) { | |
| 232 | + ModuleEntity e = byId.get(id); | |
| 233 | + if (e != null) { | |
| 234 | + nodeById.put(id, ModuleTreeNodeVO.from(e)); | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + // 5. 挂 children + 收集 roots | |
| 239 | + List<ModuleTreeNodeVO> roots = new ArrayList<>(); | |
| 240 | + for (ModuleTreeNodeVO node : nodeById.values()) { | |
| 241 | + Integer pid = node.getIParentId(); | |
| 242 | + if (pid == null || !nodeById.containsKey(pid)) { | |
| 243 | + // 父不存在于 survivors(被过滤掉或本身就是根)→ 提为根 | |
| 244 | + roots.add(node); | |
| 245 | + } else { | |
| 246 | + nodeById.get(pid).getChildren().add(node); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + // 6. 排序:根节点 + 每个节点的 children | |
| 251 | + roots.sort(cmp); | |
| 252 | + for (ModuleTreeNodeVO node : nodeById.values()) { | |
| 253 | + node.getChildren().sort(cmp); | |
| 254 | + } | |
| 255 | + | |
| 256 | + return roots; | |
| 257 | + } | |
| 168 | 258 | } | ... | ... |
backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java
| ... | ... | @@ -4,11 +4,13 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; |
| 4 | 4 | import com.xly.erp.common.exception.BizException; |
| 5 | 5 | import com.xly.erp.common.response.ErrorCode; |
| 6 | 6 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 7 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | |
| 7 | 8 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 8 | 9 | import com.xly.erp.module.mod.entity.ModuleEntity; |
| 9 | 10 | import com.xly.erp.module.mod.mapper.ModuleMapper; |
| 10 | 11 | import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; |
| 11 | 12 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 13 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | |
| 12 | 14 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 13 | 15 | import org.junit.jupiter.api.Test; |
| 14 | 16 | import org.junit.jupiter.api.extension.ExtendWith; |
| ... | ... | @@ -20,6 +22,9 @@ import org.mockito.junit.jupiter.MockitoExtension; |
| 20 | 22 | import org.springframework.dao.DuplicateKeyException; |
| 21 | 23 | |
| 22 | 24 | import java.time.LocalDateTime; |
| 25 | +import java.util.Arrays; | |
| 26 | +import java.util.Collections; | |
| 27 | +import java.util.List; | |
| 23 | 28 | |
| 24 | 29 | import static org.assertj.core.api.Assertions.assertThat; |
| 25 | 30 | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| ... | ... | @@ -391,4 +396,111 @@ class ModuleServiceImplTest { |
| 391 | 396 | .extracting(e -> ((BizException) e).getCode()) |
| 392 | 397 | .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); |
| 393 | 398 | } |
| 399 | + | |
| 400 | + // ============================================================ | |
| 401 | + // REQ-MOD-004 tree 系列 | |
| 402 | + // ============================================================ | |
| 403 | + | |
| 404 | + private ModuleEntity buildModule(int id, Integer parentId, String name, int sortOrder) { | |
| 405 | + ModuleEntity e = new ModuleEntity(); | |
| 406 | + e.setIIncrement(id); | |
| 407 | + e.setIParentId(parentId); | |
| 408 | + e.setSModuleNameZh(name); | |
| 409 | + e.setSDisplayType("前端业务"); | |
| 410 | + e.setSManageDeptEn("IT"); | |
| 411 | + e.setSProcedureName("sp_" + id); | |
| 412 | + e.setSModuleType("MOD"); | |
| 413 | + e.setBShowPermission(false); | |
| 414 | + e.setISortOrder(sortOrder); | |
| 415 | + e.setBDeleted(false); | |
| 416 | + e.setTCreateDate(LocalDateTime.now()); | |
| 417 | + return e; | |
| 418 | + } | |
| 419 | + | |
| 420 | + @Test | |
| 421 | + void tree_emptyDb_returnsEmptyList() { | |
| 422 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); | |
| 423 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | |
| 424 | + assertThat(result).isEmpty(); | |
| 425 | + } | |
| 426 | + | |
| 427 | + @Test | |
| 428 | + void tree_singleRoot_returnsOneNodeWithEmptyChildren() { | |
| 429 | + when(moduleMapper.selectList(any(Wrapper.class))) | |
| 430 | + .thenReturn(Arrays.asList(buildModule(1, null, "系统配置", 0))); | |
| 431 | + | |
| 432 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | |
| 433 | + assertThat(result).hasSize(1); | |
| 434 | + assertThat(result.get(0).getIIncrement()).isEqualTo(1); | |
| 435 | + assertThat(result.get(0).getChildren()).isNotNull().isEmpty(); | |
| 436 | + } | |
| 437 | + | |
| 438 | + @Test | |
| 439 | + void tree_multiLevel_buildsNestedStructureSortedByISortOrder() { | |
| 440 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | |
| 441 | + buildModule(1, null, "RootB", 2), | |
| 442 | + buildModule(2, null, "RootA", 1), | |
| 443 | + buildModule(3, 1, "ChildOfB", 0) | |
| 444 | + )); | |
| 445 | + | |
| 446 | + List<ModuleTreeNodeVO> result = service.tree(new ModuleQueryDTO()); | |
| 447 | + | |
| 448 | + // 根节点按 sortOrder 升序:RootA (sort=1) 在前,RootB (sort=2) 在后 | |
| 449 | + assertThat(result).hasSize(2); | |
| 450 | + assertThat(result.get(0).getIIncrement()).isEqualTo(2); // RootA | |
| 451 | + assertThat(result.get(1).getIIncrement()).isEqualTo(1); // RootB | |
| 452 | + // RootB 的 children 含 ChildOfB | |
| 453 | + assertThat(result.get(1).getChildren()).hasSize(1); | |
| 454 | + assertThat(result.get(1).getChildren().get(0).getIIncrement()).isEqualTo(3); | |
| 455 | + // RootA 是叶子 | |
| 456 | + assertThat(result.get(0).getChildren()).isEmpty(); | |
| 457 | + } | |
| 458 | + | |
| 459 | + @Test | |
| 460 | + void tree_keywordHit_includesAncestorChain() { | |
| 461 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | |
| 462 | + buildModule(1, null, "系统配置", 0), | |
| 463 | + buildModule(2, 1, "用户管理", 0), | |
| 464 | + buildModule(3, 2, "登录", 0), | |
| 465 | + buildModule(99, null, "无关模块", 0) | |
| 466 | + )); | |
| 467 | + | |
| 468 | + ModuleQueryDTO q = new ModuleQueryDTO(); | |
| 469 | + q.setKeyword("登录"); | |
| 470 | + List<ModuleTreeNodeVO> result = service.tree(q); | |
| 471 | + | |
| 472 | + // 应返回 1 → 2 → 3 三层链;不含 99 | |
| 473 | + assertThat(result).hasSize(1); | |
| 474 | + ModuleTreeNodeVO root = result.get(0); | |
| 475 | + assertThat(root.getIIncrement()).isEqualTo(1); | |
| 476 | + assertThat(root.getChildren()).hasSize(1); | |
| 477 | + ModuleTreeNodeVO mid = root.getChildren().get(0); | |
| 478 | + assertThat(mid.getIIncrement()).isEqualTo(2); | |
| 479 | + assertThat(mid.getChildren()).hasSize(1); | |
| 480 | + assertThat(mid.getChildren().get(0).getIIncrement()).isEqualTo(3); | |
| 481 | + } | |
| 482 | + | |
| 483 | + @Test | |
| 484 | + void tree_keywordNoMatch_returnsEmptyList() { | |
| 485 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( | |
| 486 | + buildModule(1, null, "系统配置", 0), | |
| 487 | + buildModule(2, null, "权限分配", 0) | |
| 488 | + )); | |
| 489 | + | |
| 490 | + ModuleQueryDTO q = new ModuleQueryDTO(); | |
| 491 | + q.setKeyword("不存在的关键词"); | |
| 492 | + List<ModuleTreeNodeVO> result = service.tree(q); | |
| 493 | + assertThat(result).isEmpty(); | |
| 494 | + } | |
| 495 | + | |
| 496 | + @Test | |
| 497 | + void tree_softDeletedExcluded_passesBDeletedZeroToMapper() { | |
| 498 | + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); | |
| 499 | + | |
| 500 | + service.tree(new ModuleQueryDTO()); | |
| 501 | + | |
| 502 | + // 验证调用了 selectList 一次(mapper 端用 wrapper.eq(bDeleted, false) 已是 service 实现细节, | |
| 503 | + // 这里仅验证 service 调用了 selectList,wrapper 形状由 IT 在真实 DB 上 cross-check) | |
| 504 | + verify(moduleMapper).selectList(any(Wrapper.class)); | |
| 505 | + } | |
| 394 | 506 | } | ... | ... |