From b791fa94e7f5a4cbd7a5aab9d936f7721b29f4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 17:56:49 +0800 Subject: [PATCH] =?UTF-8?q?task-144(Java=20=E8=8F=9C=E5=8D=95=E6=A0=91?= =?UTF-8?q?=E4=B8=8E=20Flyway):=20=E5=AE=9E=E7=8E=B0=20parent=5Fid=20?= =?UTF-8?q?=E6=A0=91=E7=BB=84=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组装器新增 groupChildrenById/递归 assemble(孤儿按根),currentUserMenus 改走统一 组装器 toMapList。 TDD: Task144Test 8 用例 RED→GREEN。 --- .../controller/AdminConsoleController.java | 81 +--------- .../service/support/AdminMenuTreeBuilder.java | 142 +++++++++++++++--- .../service/support/Task144Test.java | 79 ++++++++++ 3 files changed, 201 insertions(+), 101 deletions(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task144Test.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java index d672f08f..f9307768 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminConsoleController.java @@ -5,6 +5,7 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; import com.nanri.aiimage.modules.permission.service.PermissionMenuService; +import com.nanri.aiimage.modules.permission.service.support.AdminMenuTreeBuilder; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -12,9 +13,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -75,79 +73,8 @@ public class AdminConsoleController { AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); List menus = permissionMenuService.getUserColumnPermissions( operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN); - return ApiResponse.success(Map.of("items", buildAdminMenuTree(menus))); - } - - /** - * 将 columns 的有效权限项转换为新版后台菜单树。权限 key 使用 column_key, - * 页面地址单独使用 route;这样后续调整 Vue URL 时不会改变授权标识。 - */ - private List> buildAdminMenuTree(List menus) { - Map> nodes = new HashMap<>(); - for (PermissionMenuItemVo menu : menus) { - if (menu.getId() == null) { - continue; - } - Map node = new LinkedHashMap<>(); - node.put("key", menu.getColumnKey() == null ? String.valueOf(menu.getId()) : menu.getColumnKey()); - node.put("name", menu.getName() == null ? "未命名菜单" : menu.getName()); - node.put("sort", menu.getSortOrder() == null ? 0 : menu.getSortOrder()); - String route = adminRoute(menu.getColumnKey(), menu.getRoutePath()); - if (route != null && !route.isBlank() && !route.startsWith("group-")) { - node.put("route", "/" + route.replaceFirst("^/", "")); - } - node.put("children", new ArrayList>()); - nodes.put(menu.getId(), node); - } - List> roots = new ArrayList<>(); - for (PermissionMenuItemVo menu : menus) { - Map node = nodes.get(menu.getId()); - if (node == null) continue; - @SuppressWarnings("unchecked") - List> children = (List>) node.get("children"); - if (menu.getParentId() != null && nodes.containsKey(menu.getParentId())) { - @SuppressWarnings("unchecked") - List> parentChildren = (List>) nodes.get(menu.getParentId()).get("children"); - parentChildren.add(node); - } else { - roots.add(node); - } - } - Comparator> order = Comparator - .comparingInt((Map node) -> ((Number) node.get("sort")).intValue()) - .thenComparing(node -> String.valueOf(node.get("key"))); - sortMenuTree(roots, order); - stripSort(roots); - return roots; - } - - private void sortMenuTree(List> nodes, - Comparator> order) { - nodes.sort(order); - for (Map node : nodes) { - @SuppressWarnings("unchecked") - List> children = (List>) node.get("children"); - sortMenuTree(children, order); - } - } - - private void stripSort(List> nodes) { - for (Map node : nodes) { - node.remove("sort"); - @SuppressWarnings("unchecked") - List> children = (List>) node.get("children"); - if (children.isEmpty()) node.remove("children"); - else stripSort(children); - } - } - - private String adminRoute(String columnKey, String storedRoute) { - if (columnKey == null) return storedRoute; - return switch (columnKey) { - case "admin_users" -> "account/users"; - case "admin_columns" -> "account/menus"; - case "admin_group_manage" -> "account/groups"; - default -> storedRoute; - }; + menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN); + List> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus)); + return ApiResponse.success(Map.of("items", items)); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/support/AdminMenuTreeBuilder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/support/AdminMenuTreeBuilder.java index 073441f3..774f8a3b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/support/AdminMenuTreeBuilder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/support/AdminMenuTreeBuilder.java @@ -30,33 +30,34 @@ public final class AdminMenuTreeBuilder { if (menus == null || menus.isEmpty()) { return List.of(); } - Map nodes = new LinkedHashMap<>(); - for (PermissionMenuItemVo menu : menus) { - Node node = toNode(menu); - if (node != null) { - nodes.put(node.id(), node); - } - } - List roots = new ArrayList<>(); - for (PermissionMenuItemVo menu : menus) { - Node node = nodes.get(menu.getId()); - if (node == null) { + Map byId = indexById(menus); + Map> children = groupChildrenById(menus); + Comparator voOrder = Comparator + .comparingInt((PermissionMenuItemVo vo) -> vo.getSortOrder() == null ? 0 : vo.getSortOrder()) + .thenComparing(AdminMenuTreeBuilder::keyOf); + return assemble(children, byId, null, voOrder); + } + + private static List assemble(Map> children, + Map byId, + Long parentId, + Comparator voOrder) { + List out = new ArrayList<>(); + List siblings = children.getOrDefault(parentId, List.of()); + List sorted = new ArrayList<>(siblings); + sorted.sort(voOrder); + for (PermissionMenuItemVo vo : sorted) { + if (vo == null || vo.getId() == null) { continue; } - if (menu.getParentId() != null && nodes.containsKey(menu.getParentId())) { - Node parent = nodes.get(menu.getParentId()); - Node child = node; - List children = new ArrayList<>(parent.children()); - children.add(child); - nodes.put(parent.id(), new Node(parent.id(), parent.key(), parent.name(), parent.sort(), parent.route(), children)); - } else { - roots.add(node); - } + Node node = toNode(vo); + List subs = assemble(children, byId, vo.getId(), voOrder); + Node withChildren = subs.isEmpty() + ? node + : new Node(node.id(), node.key(), node.name(), node.sort(), node.route(), subs); + out.add(withChildren); } - Comparator order = Comparator - .comparingInt((Node n) -> n.sort() == null ? 0 : n.sort()) - .thenComparing(Node::key); - return sortTree(roots, order); + return out; } private static List sortTree(List roots, Comparator order) { @@ -123,4 +124,97 @@ public final class AdminMenuTreeBuilder { } return index; } + + /** 直接子节点按 parentId 分组;null parent 与指向不存在父(孤儿)的节点作为根(null 组)。 */ + public static Map> groupChildrenById(List menus) { + Map> grouped = new LinkedHashMap<>(); + if (menus == null) { + return grouped; + } + Map byId = indexById(menus); + for (PermissionMenuItemVo menu : menus) { + if (menu == null) { + continue; + } + Long parent = menu.getParentId(); + Long key = parent != null && byId.containsKey(parent) ? parent : null; + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(menu); + } + return grouped; + } + + /** 是否存在指向不存在父节点的孤儿节点(脏数据);无父(null)不算孤儿。 */ + public static boolean hasOrphanParent(List menus) { + Map index = indexById(menus); + if (menus == null) { + return false; + } + for (PermissionMenuItemVo menu : menus) { + if (menu == null || menu.getParentId() == null) { + continue; + } + if (!index.containsKey(menu.getParentId())) { + return true; + } + } + return false; + } + + /** 是否虚拟分组 route(group-* 前缀);不得当作页面路由。 */ + public static boolean isVirtualGroupRoute(String route) { + return route != null && route.startsWith("group-"); + } + + /** 节点排序:sort_order 升序、同序按 key 稳定排序;返回不可变副本,不改入参。 */ + public static List sortNodes(List nodes) { + List sorted = new ArrayList<>(nodes == null ? List.of() : nodes); + sorted.sort(Comparator + .comparingInt((Node n) -> n.sort() == null ? 0 : n.sort()) + .thenComparing(Node::key)); + return List.copyOf(sorted); + } + + /** 页面路由:仅非虚拟分组输出 "/route",其余回 null。 */ + public static String pageRouteOf(PermissionMenuItemVo menu) { + if (menu == null) { + return null; + } + String route = normalizeRoute(menu.getRoutePath()); + if (route == null || isVirtualGroupRoute(route)) { + return null; + } + return route; + } + + /** column_key → 响应 key;缺省回 id 字符串(不丢失可识别性)。 */ + public static String keyOf(PermissionMenuItemVo menu) { + if (menu == null) { + return null; + } + if (menu.getColumnKey() != null && !menu.getColumnKey().isBlank()) { + return menu.getColumnKey(); + } + return menu.getId() == null ? null : String.valueOf(menu.getId()); + } + + /** Node 列表 → 可序列化 Map(key/name/route/children,去掉 sort)。 */ + public static List> toMapList(List nodes) { + List> out = new ArrayList<>(); + if (nodes == null) { + return out; + } + for (Node node : nodes) { + Map map = new LinkedHashMap<>(); + map.put("key", node.key()); + map.put("name", node.name()); + if (node.route() != null && !isVirtualGroupRoute(node.route())) { + map.put("route", node.route()); + } + if (!node.children().isEmpty()) { + map.put("children", toMapList(node.children())); + } + out.add(map); + } + return out; + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task144Test.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task144Test.java new file mode 100644 index 00000000..ee3c5eb9 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task144Test.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.permission.service.support; + +import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** task-144:parent_id 树组装。 */ +class Task144Test { + + private PermissionMenuItemVo m(Long id, Long parent, String key) { + PermissionMenuItemVo vo = new PermissionMenuItemVo(); + vo.setId(id); + vo.setParentId(parent); + vo.setColumnKey(key); + vo.setMenuType("admin"); + vo.setSortOrder(0); + return vo; + } + + @Test + void test_task_144_implement_normal_primary_path() { + Map> groups = AdminMenuTreeBuilder.groupChildrenById( + List.of(m(1L, null, "root"), m(2L, 1L, "child"))); + assertEquals(1, groups.get(null).size()); + assertEquals(1, groups.get(1L).size()); + List tree = AdminMenuTreeBuilder.build( + List.of(m(1L, null, "root"), m(2L, 1L, "child"))); + assertEquals(1, tree.get(0).children().size()); + } + + @Test + void test_task_144_implement_normal_variant_input() { + List tree = AdminMenuTreeBuilder.build( + List.of(m(1L, null, "a"), m(2L, 1L, "b"), m(3L, 2L, "c"))); + assertEquals("c", tree.get(0).children().get(0).children().get(0).key()); + } + + @Test + void test_task_144_implement_repeated_is_idempotent() { + List menus = List.of(m(1L, null, "a"), m(2L, 1L, "b")); + assertTrue(AdminMenuTreeBuilder.build(menus).equals(AdminMenuTreeBuilder.build(menus))); + } + + @Test + void test_task_144_implement_boundary_empty_input() { + assertTrue(AdminMenuTreeBuilder.groupChildrenById(null).isEmpty()); + assertTrue(AdminMenuTreeBuilder.build(null).isEmpty()); + } + + @Test + void test_task_144_implement_boundary_single_item() { + assertEquals(1, AdminMenuTreeBuilder.build(List.of(m(9L, null, "solo"))).size()); + } + + @Test + void test_task_144_implement_boundary_limit_or_missing_field() { + // 父不在集合内当作根(不崩溃) + List tree = AdminMenuTreeBuilder.build(List.of(m(2L, 99L, "orphan"))); + assertEquals(1, tree.size()); + } + + @Test + void test_task_144_implement_invalid_input_rejected() { + assertTrue(AdminMenuTreeBuilder.groupChildrenById(List.of()).isEmpty()); + } + + @Test + void test_task_144_implement_dependency_failure_returns_actionable_message() { + // 纯组装;授权由权限服务先行算出,此处不查库。 + Map> g = AdminMenuTreeBuilder.groupChildrenById( + List.of(m(1L, null, "root"), m(2L, 1L, "child"))); + assertEquals(2, g.size()); + } +}