task-144(Java 菜单树与 Flyway): 实现 parent_id 树组装
组装器新增 groupChildrenById/递归 assemble(孤儿按根),currentUserMenus 改走统一 组装器 toMapList。 TDD: Task144Test 8 用例 RED→GREEN。
This commit is contained in:
+4
-77
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-24
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
@@ -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<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user