task-141(Java 菜单树与 Flyway): 冻结 Java 菜单树响应 DTO 契约

新增纯 Java AdminMenuTreeBuilder:Node(key/name/route/children) 树组装与
route 规整,column_key 与 route 分离,可离线单测。

TDD: Task141Test 8 用例先 RED 后 GREEN。
This commit is contained in:
2026-09-05 17:49:44 +08:00
parent 89c06b58c3
commit 4dd9ca7a22
2 changed files with 200 additions and 0 deletions
@@ -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 {
/** 冻结的树形节点 DTOkey=column_key、route=Vue path、children 有序。 */
public record Node(Long id, String key, String name, Integer sort, String route, List<Node> children) {
public Node {
children = children == null ? List.of() : List.copyOf(children);
}
}
private AdminMenuTreeBuilder() {
}
/** 由有效菜单集合组装树(仅 menu_type=admin)。空/无效输入回空列表。 */
public static List<Node> build(List<PermissionMenuItemVo> menus) {
if (menus == null || menus.isEmpty()) {
return List.of();
}
Map<Long, Node> nodes = new LinkedHashMap<>();
for (PermissionMenuItemVo menu : menus) {
Node node = toNode(menu);
if (node != null) {
nodes.put(node.id(), node);
}
}
List<Node> 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<Node> 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<Node> order = Comparator
.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) {
List<Node> sorted = new ArrayList<>(roots);
sorted.sort(order);
List<Node> 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<PermissionMenuItemVo> filterByMenuType(List<PermissionMenuItemVo> menus, String menuType) {
if (menus == null || menuType == null) {
return List.of();
}
List<PermissionMenuItemVo> out = new ArrayList<>();
for (PermissionMenuItemVo menu : menus) {
if (menu != null && menuType.equals(menu.getMenuType())) {
out.add(menu);
}
}
return out;
}
}
@@ -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<AdminMenuTreeBuilder.Node> 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<AdminMenuTreeBuilder.Node> 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<PermissionMenuItemVo> 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<AdminMenuTreeBuilder.Node> 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"));
}
}