Commit f9a9070e48f9fda6fdae97f57c5410d1d51849e6

Authored by zichun
1 parent c8a87494

feat(mod): query module tree service REQ-MOD-004

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 }