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 | package com.xly.erp.module.mod.service; | 1 | package com.xly.erp.module.mod.service; |
| 2 | 2 | ||
| 3 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; | 3 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 4 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 4 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | 5 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 5 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | 6 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 7 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 6 | import com.xly.erp.module.mod.vo.ModuleVO; | 8 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 7 | 9 | ||
| 10 | +import java.util.List; | ||
| 11 | + | ||
| 8 | public interface ModuleService { | 12 | public interface ModuleService { |
| 9 | /** REQ-MOD-001 模块新增 */ | 13 | /** REQ-MOD-001 模块新增 */ |
| 10 | ModuleVO create(ModuleCreateDTO dto); | 14 | ModuleVO create(ModuleCreateDTO dto); |
| @@ -14,4 +18,7 @@ public interface ModuleService { | @@ -14,4 +18,7 @@ public interface ModuleService { | ||
| 14 | 18 | ||
| 15 | /** REQ-MOD-003 模块软删除 */ | 19 | /** REQ-MOD-003 模块软删除 */ |
| 16 | ModuleDeleteResultVO delete(Integer id); | 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,11 +5,13 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | ||
| 5 | import com.xly.erp.common.exception.BizException; | 5 | import com.xly.erp.common.exception.BizException; |
| 6 | import com.xly.erp.common.response.ErrorCode; | 6 | import com.xly.erp.common.response.ErrorCode; |
| 7 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; | 7 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 8 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 8 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | 9 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 9 | import com.xly.erp.module.mod.entity.ModuleEntity; | 10 | import com.xly.erp.module.mod.entity.ModuleEntity; |
| 10 | import com.xly.erp.module.mod.mapper.ModuleMapper; | 11 | import com.xly.erp.module.mod.mapper.ModuleMapper; |
| 11 | import com.xly.erp.module.mod.service.ModuleService; | 12 | import com.xly.erp.module.mod.service.ModuleService; |
| 12 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | 13 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 14 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 13 | import com.xly.erp.module.mod.vo.ModuleVO; | 15 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 14 | import lombok.RequiredArgsConstructor; | 16 | import lombok.RequiredArgsConstructor; |
| 15 | import org.springframework.dao.DuplicateKeyException; | 17 | import org.springframework.dao.DuplicateKeyException; |
| @@ -17,6 +19,14 @@ import org.springframework.stereotype.Service; | @@ -17,6 +19,14 @@ import org.springframework.stereotype.Service; | ||
| 17 | import org.springframework.transaction.annotation.Transactional; | 19 | import org.springframework.transaction.annotation.Transactional; |
| 18 | 20 | ||
| 19 | import java.time.LocalDateTime; | 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 | @Service | 31 | @Service |
| 22 | @RequiredArgsConstructor | 32 | @RequiredArgsConstructor |
| @@ -165,4 +175,84 @@ public class ModuleServiceImpl implements ModuleService { | @@ -165,4 +175,84 @@ public class ModuleServiceImpl implements ModuleService { | ||
| 165 | 175 | ||
| 166 | return ModuleDeleteResultVO.of(id, true); | 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,11 +4,13 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; | ||
| 4 | import com.xly.erp.common.exception.BizException; | 4 | import com.xly.erp.common.exception.BizException; |
| 5 | import com.xly.erp.common.response.ErrorCode; | 5 | import com.xly.erp.common.response.ErrorCode; |
| 6 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; | 6 | import com.xly.erp.module.mod.dto.ModuleCreateDTO; |
| 7 | +import com.xly.erp.module.mod.dto.ModuleQueryDTO; | ||
| 7 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; | 8 | import com.xly.erp.module.mod.dto.ModuleUpdateDTO; |
| 8 | import com.xly.erp.module.mod.entity.ModuleEntity; | 9 | import com.xly.erp.module.mod.entity.ModuleEntity; |
| 9 | import com.xly.erp.module.mod.mapper.ModuleMapper; | 10 | import com.xly.erp.module.mod.mapper.ModuleMapper; |
| 10 | import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; | 11 | import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; |
| 11 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; | 12 | import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; |
| 13 | +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; | ||
| 12 | import com.xly.erp.module.mod.vo.ModuleVO; | 14 | import com.xly.erp.module.mod.vo.ModuleVO; |
| 13 | import org.junit.jupiter.api.Test; | 15 | import org.junit.jupiter.api.Test; |
| 14 | import org.junit.jupiter.api.extension.ExtendWith; | 16 | import org.junit.jupiter.api.extension.ExtendWith; |
| @@ -20,6 +22,9 @@ import org.mockito.junit.jupiter.MockitoExtension; | @@ -20,6 +22,9 @@ import org.mockito.junit.jupiter.MockitoExtension; | ||
| 20 | import org.springframework.dao.DuplicateKeyException; | 22 | import org.springframework.dao.DuplicateKeyException; |
| 21 | 23 | ||
| 22 | import java.time.LocalDateTime; | 24 | import java.time.LocalDateTime; |
| 25 | +import java.util.Arrays; | ||
| 26 | +import java.util.Collections; | ||
| 27 | +import java.util.List; | ||
| 23 | 28 | ||
| 24 | import static org.assertj.core.api.Assertions.assertThat; | 29 | import static org.assertj.core.api.Assertions.assertThat; |
| 25 | import static org.assertj.core.api.Assertions.assertThatThrownBy; | 30 | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| @@ -391,4 +396,111 @@ class ModuleServiceImplTest { | @@ -391,4 +396,111 @@ class ModuleServiceImplTest { | ||
| 391 | .extracting(e -> ((BizException) e).getCode()) | 396 | .extracting(e -> ((BizException) e).getCode()) |
| 392 | .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); | 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 | } |