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 new file mode 100644 index 00000000..b4f8ded3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/support/AdminMenuTreeBuilder.java @@ -0,0 +1,112 @@ +package com.nanri.aiimage.modules.permission.service.support; + +import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 后台菜单树纯组装器(module 08):column_key→key、route_path→route、parent 树、排序等均为纯逻辑, + * 便于离线单测;树形结果不含 sort 冗余字段。不与当前请求/UA/查询参数联动。 + */ +public final class AdminMenuTreeBuilder { + + /** 冻结的树形节点 DTO:key=column_key、route=Vue path、children 有序。 */ + public record Node(Long id, String key, String name, Integer sort, String route, List children) { + public Node { + children = children == null ? List.of() : List.copyOf(children); + } + } + + private AdminMenuTreeBuilder() { + } + + /** 由有效菜单集合组装树(仅 menu_type=admin)。空/无效输入回空列表。 */ + public static List build(List menus) { + 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) { + 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); + } + } + Comparator order = Comparator + .comparingInt((Node n) -> n.sort() == null ? 0 : n.sort()) + .thenComparing(Node::key); + return sortTree(roots, order); + } + + private static List sortTree(List roots, Comparator order) { + List sorted = new ArrayList<>(roots); + sorted.sort(order); + List result = new ArrayList<>(sorted.size()); + for (Node node : sorted) { + Node withChildren = node.children().isEmpty() + ? node + : new Node(node.id(), node.key(), node.name(), node.sort(), node.route(), sortTree(node.children(), order)); + result.add(withChildren); + } + return result; + } + + /** 单节点映射;缺 id 返回 null;分组/页面节点 route 由 stored route 规整。 */ + public static Node toNode(PermissionMenuItemVo menu) { + if (menu == null || menu.getId() == null) { + return null; + } + String key = menu.getColumnKey() == null ? String.valueOf(menu.getId()) : menu.getColumnKey(); + String name = menu.getName() == null || menu.getName().isBlank() ? "未命名菜单" : menu.getName(); + Integer sort = menu.getSortOrder() == null ? 0 : menu.getSortOrder(); + String route = normalizeRoute(menu.getRoutePath()); + return new Node(menu.getId(), key, name, sort, route, new ArrayList<>()); + } + + /** route 规整:去空白、非 "/" 前缀补 "/",分组占位(group-*)保留原值不当作页面。 */ + public static String normalizeRoute(String storedRoute) { + if (storedRoute == null) { + return null; + } + String route = storedRoute.trim(); + if (route.isEmpty() || route.startsWith("group-")) { + return route.isEmpty() ? null : route; + } + return route.startsWith("/") ? route : "/" + route; + } + + /** 只保留指定菜单域(menu_type=admin)的菜单项;null/空回空列表。 */ + public static List filterByMenuType(List menus, String menuType) { + if (menus == null || menuType == null) { + return List.of(); + } + List out = new ArrayList<>(); + for (PermissionMenuItemVo menu : menus) { + if (menu != null && menuType.equals(menu.getMenuType())) { + out.add(menu); + } + } + return out; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task141Test.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task141Test.java new file mode 100644 index 00000000..05915976 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/support/Task141Test.java @@ -0,0 +1,88 @@ +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** task-141:冻结 Java 菜单树响应 DTO 契约(key/name/route/children)。 */ +class Task141Test { + + private PermissionMenuItemVo menu(Long id, Long parentId, String key, String name, + Integer sort, String route, String menuType) { + PermissionMenuItemVo vo = new PermissionMenuItemVo(); + vo.setId(id); + vo.setParentId(parentId); + vo.setColumnKey(key); + vo.setName(name); + vo.setSortOrder(sort); + vo.setRoutePath(route); + vo.setMenuType(menuType); + return vo; + } + + @Test + void test_task_141_freeze_java_dto_normal_primary_path() { + List tree = AdminMenuTreeBuilder.build(List.of( + menu(1L, null, "admin_users", "账号管理", 1, "account/users", "admin"), + menu(2L, null, "admin_columns", "菜单管理", 2, "account/menus", "admin"))); + assertEquals(2, tree.size()); + assertEquals("admin_users", tree.get(0).key()); + assertEquals("/account/users", tree.get(0).route()); + assertEquals("账号管理", tree.get(0).name()); + } + + @Test + void test_task_141_freeze_java_dto_normal_variant_input() { + List tree = AdminMenuTreeBuilder.build(List.of( + menu(10L, 1L, "child", "子页", 1, "child/page", "admin"), + menu(1L, null, "parent", "父级", 0, null, "admin"))); + assertEquals(1, tree.size()); + assertEquals(1, tree.get(0).children().size()); + assertEquals("child", tree.get(0).children().get(0).key()); + } + + @Test + void test_task_141_freeze_java_dto_normal_repeated_is_idempotent() { + List menus = List.of(menu(1L, null, "a", "A", 0, "a/p", "admin")); + assertEquals(AdminMenuTreeBuilder.build(menus), AdminMenuTreeBuilder.build(menus)); + } + + @Test + void test_task_141_freeze_java_dto_boundary_empty_input() { + assertTrue(AdminMenuTreeBuilder.build(null).isEmpty()); + assertTrue(AdminMenuTreeBuilder.build(List.of()).isEmpty()); + } + + @Test + void test_task_141_freeze_java_dto_boundary_single_item() { + List tree = AdminMenuTreeBuilder.build( + List.of(menu(1L, null, "only", "单页", 0, "page/x", "admin"))); + assertEquals(1, tree.size()); + assertEquals("/page/x", tree.get(0).route()); + } + + @Test + void test_task_141_freeze_java_dto_boundary_limit_or_missing_field() { + assertTrue(AdminMenuTreeBuilder.build(List.of(menu(null, null, "no-id", "X", 0, "p", "admin"))).isEmpty()); + assertTrue(AdminMenuTreeBuilder.build(List.of(menu(null, null, null, "X", 0, "p", "admin"))).isEmpty()); + } + + @Test + void test_task_141_freeze_java_dto_invalid_input_rejected() { + // 分组占位 route 不作为页面路由 + AdminMenuTreeBuilder.Node node = AdminMenuTreeBuilder.toNode(menu(5L, null, "g", "分组", 0, "group-1", "admin")); + assertNotNull(node); + assertEquals("group-1", node.route()); + } + + @Test + void test_task_141_freeze_java_dto_dependency_failure_returns_actionable_message() { + // 纯逻辑:不引用 http/当前会话;key/route 分离(column_key 保持,route 独立)。 + assertEquals("/account/users", AdminMenuTreeBuilder.normalizeRoute("account/users")); + } +}