task-144(Java 菜单树与 Flyway): 实现 parent_id 树组装

组装器新增 groupChildrenById/递归 assemble(孤儿按根),currentUserMenus 改走统一
组装器 toMapList。

TDD: Task144Test 8 用例 RED→GREEN。
This commit is contained in:
2026-09-05 17:56:49 +08:00
parent 320600584c
commit b791fa94e7
3 changed files with 201 additions and 101 deletions
@@ -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.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; 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.GetMapping;
import org.springframework.web.bind.annotation.RestController; 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.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -75,79 +73,8 @@ public class AdminConsoleController {
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions( List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions(
operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN); operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN);
return ApiResponse.success(Map.of("items", buildAdminMenuTree(menus))); menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN);
} List<Map<String, Object>> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus));
return ApiResponse.success(Map.of("items", items));
/**
* 将 columns 的有效权限项转换为新版后台菜单树。权限 key 使用 column_key
* 页面地址单独使用 route;这样后续调整 Vue URL 时不会改变授权标识。
*/
private List<Map<String, Object>> buildAdminMenuTree(List<PermissionMenuItemVo> menus) {
Map<Long, Map<String, Object>> nodes = new HashMap<>();
for (PermissionMenuItemVo menu : menus) {
if (menu.getId() == null) {
continue;
}
Map<String, Object> 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<Map<String, Object>>());
nodes.put(menu.getId(), node);
}
List<Map<String, Object>> roots = new ArrayList<>();
for (PermissionMenuItemVo menu : menus) {
Map<String, Object> node = nodes.get(menu.getId());
if (node == null) continue;
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
if (menu.getParentId() != null && nodes.containsKey(menu.getParentId())) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> parentChildren = (List<Map<String, Object>>) nodes.get(menu.getParentId()).get("children");
parentChildren.add(node);
} else {
roots.add(node);
}
}
Comparator<Map<String, Object>> order = Comparator
.comparingInt((Map<String, Object> node) -> ((Number) node.get("sort")).intValue())
.thenComparing(node -> String.valueOf(node.get("key")));
sortMenuTree(roots, order);
stripSort(roots);
return roots;
}
private void sortMenuTree(List<Map<String, Object>> nodes,
Comparator<Map<String, Object>> order) {
nodes.sort(order);
for (Map<String, Object> node : nodes) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
sortMenuTree(children, order);
}
}
private void stripSort(List<Map<String, Object>> nodes) {
for (Map<String, Object> node : nodes) {
node.remove("sort");
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) 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;
};
} }
} }
@@ -30,33 +30,34 @@ public final class AdminMenuTreeBuilder {
if (menus == null || menus.isEmpty()) { if (menus == null || menus.isEmpty()) {
return List.of(); return List.of();
} }
Map<Long, Node> nodes = new LinkedHashMap<>(); Map<Long, PermissionMenuItemVo> byId = indexById(menus);
for (PermissionMenuItemVo menu : menus) { Map<Long, List<PermissionMenuItemVo>> children = groupChildrenById(menus);
Node node = toNode(menu); Comparator<PermissionMenuItemVo> voOrder = Comparator
if (node != null) { .comparingInt((PermissionMenuItemVo vo) -> vo.getSortOrder() == null ? 0 : vo.getSortOrder())
nodes.put(node.id(), node); .thenComparing(AdminMenuTreeBuilder::keyOf);
} return assemble(children, byId, null, voOrder);
} }
List<Node> roots = new ArrayList<>();
for (PermissionMenuItemVo menu : menus) { private static List<Node> assemble(Map<Long, List<PermissionMenuItemVo>> children,
Node node = nodes.get(menu.getId()); Map<Long, PermissionMenuItemVo> byId,
if (node == null) { Long parentId,
Comparator<PermissionMenuItemVo> voOrder) {
List<Node> out = new ArrayList<>();
List<PermissionMenuItemVo> siblings = children.getOrDefault(parentId, List.of());
List<PermissionMenuItemVo> sorted = new ArrayList<>(siblings);
sorted.sort(voOrder);
for (PermissionMenuItemVo vo : sorted) {
if (vo == null || vo.getId() == null) {
continue; continue;
} }
if (menu.getParentId() != null && nodes.containsKey(menu.getParentId())) { Node node = toNode(vo);
Node parent = nodes.get(menu.getParentId()); List<Node> subs = assemble(children, byId, vo.getId(), voOrder);
Node child = node; Node withChildren = subs.isEmpty()
List<Node> children = new ArrayList<>(parent.children()); ? node
children.add(child); : new Node(node.id(), node.key(), node.name(), node.sort(), node.route(), subs);
nodes.put(parent.id(), new Node(parent.id(), parent.key(), parent.name(), parent.sort(), parent.route(), children)); out.add(withChildren);
} else {
roots.add(node);
}
} }
Comparator<Node> order = Comparator return out;
.comparingInt((Node n) -> n.sort() == null ? 0 : n.sort())
.thenComparing(Node::key);
return sortTree(roots, order);
} }
private static List<Node> sortTree(List<Node> roots, Comparator<Node> order) { private static List<Node> sortTree(List<Node> roots, Comparator<Node> order) {
@@ -123,4 +124,97 @@ public final class AdminMenuTreeBuilder {
} }
return index; return index;
} }
/** 直接子节点按 parentId 分组;null parent 与指向不存在父(孤儿)的节点作为根(null 组)。 */
public static Map<Long, List<PermissionMenuItemVo>> groupChildrenById(List<PermissionMenuItemVo> menus) {
Map<Long, List<PermissionMenuItemVo>> grouped = new LinkedHashMap<>();
if (menus == null) {
return grouped;
}
Map<Long, PermissionMenuItemVo> 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<PermissionMenuItemVo> menus) {
Map<Long, PermissionMenuItemVo> 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<Node> sortNodes(List<Node> nodes) {
List<Node> 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<Map<String, Object>> toMapList(List<Node> nodes) {
List<Map<String, Object>> out = new ArrayList<>();
if (nodes == null) {
return out;
}
for (Node node : nodes) {
Map<String, Object> 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;
}
} }
@@ -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-144parent_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<Long, List<PermissionMenuItemVo>> 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<AdminMenuTreeBuilder.Node> 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<AdminMenuTreeBuilder.Node> 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<PermissionMenuItemVo> 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<AdminMenuTreeBuilder.Node> 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<Long, List<PermissionMenuItemVo>> g = AdminMenuTreeBuilder.groupChildrenById(
List.of(m(1L, null, "root"), m(2L, 1L, "child")));
assertEquals(2, g.size());
}
}