实现系统管理菜单树增强接口

This commit is contained in:
andy
2026-07-16 11:57:25 +07:00
parent 93ecfb08a9
commit 5390b8f71b
12 changed files with 1042 additions and 10 deletions

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.platform.navigation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 管理后台单个菜单树排序调整项。只允许调整父级和排序。
*/
public record AdminMenuTreeOrderItemRequest(
/** 被调整菜单内部 ID 字符串。 */
@JsonProperty("menu_id")
String menuId,
/** 新父菜单内部 ID 字符串;根菜单传 null。 */
@JsonProperty("parent_id")
String parentId,
/** 新排序号;为空时按请求顺序生成稳定排序号。 */
@JsonProperty("sort_order")
Integer sortOrder
) {
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.platform.navigation.common.request;
import java.util.List;
/**
* 管理后台批量调整菜单父级和排序请求。
*/
public record AdminMenuTreeOrderUpdateRequest(
/** 批量调整项列表。 */
List<AdminMenuTreeOrderItemRequest> items
) {
}

View File

@@ -0,0 +1,57 @@
package cn.nianxx.thhotel.platform.navigation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 管理后台菜单树节点结果。字段沿用菜单结果,并补充子节点列表。
*/
public record AdminMenuTreeNodeResult(
/** 菜单内部 ID 字符串。 */
String id,
/** 父菜单内部 ID 字符串。 */
@JsonProperty("parent_id")
String parentId,
/** 稳定菜单代码。 */
@JsonProperty("menu_code")
String menuCode,
/** 菜单展示名称。 */
@JsonProperty("menu_name")
String menuName,
/** 菜单类型。 */
@JsonProperty("menu_type")
String menuType,
/** 前端路由路径。 */
@JsonProperty("route_path")
String routePath,
/** 前端组件标识。 */
@JsonProperty("component_key")
String componentKey,
/** 前端图标标识。 */
@JsonProperty("icon_key")
String iconKey,
/** 菜单入口权限码。 */
@JsonProperty("permission_code")
String permissionCode,
/** 排序号。 */
@JsonProperty("sort_order")
Integer sortOrder,
/** 是否菜单可见。 */
Boolean visible,
/** 菜单状态。 */
@JsonProperty("menu_status")
String menuStatus,
/** 是否当前前端已知路由。 */
@JsonProperty("known_route")
Boolean knownRoute,
/** 创建 UTC 时间。 */
@JsonProperty("created_at")
OffsetDateTime createdAt,
/** 更新 UTC 时间。 */
@JsonProperty("updated_at")
OffsetDateTime updatedAt,
/** 子菜单节点。 */
List<AdminMenuTreeNodeResult> children
) {
}

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.platform.navigation.common.result;
import java.util.List;
/**
* 管理后台完整菜单树结果。warnings 用于返回脏数据降级提示,避免查询 500。
*/
public record AdminMenuTreeResult(
/** 根级菜单节点列表。 */
List<AdminMenuTreeNodeResult> items,
/** 菜单树脏数据或降级处理提示。 */
List<String> warnings
) {
}

View File

@@ -3,8 +3,10 @@ package cn.nianxx.thhotel.platform.navigation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService;
@@ -51,6 +53,15 @@ public class AdminMenuController {
return menuManagementService.queryMenus(keyword, menuStatus, pageNum, pageSize);
}
/**
* 查询完整菜单树,需要菜单管理权限;只读查询不写管理审计。
*/
@GetMapping(value = "/tree", produces = MediaType.APPLICATION_JSON_VALUE)
public AdminMenuTreeResult listMenuTree() {
authorizationService.requirePermission(PlatformPermissionCode.SYSTEM_MENU_MANAGE.name());
return menuManagementService.queryMenuTree();
}
/**
* 查询菜单详情,需要菜单管理权限。
*/
@@ -70,6 +81,17 @@ public class AdminMenuController {
return menuManagementService.createMenu(request, actor);
}
/**
* 批量调整菜单树父级和排序,需要菜单管理权限,并写入管理审计。
*/
@PutMapping(value = "/tree-order", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public AdminMenuTreeResult updateMenuTreeOrder(
@RequestBody(required = false) AdminMenuTreeOrderUpdateRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.SYSTEM_MENU_MANAGE.name());
return menuManagementService.updateMenuTreeOrder(request, actor);
}
/**
* 编辑菜单配置,需要菜单管理权限,菜单代码不允许修改。
*/

View File

@@ -1,9 +1,11 @@
package cn.nianxx.thhotel.platform.navigation.service;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
import cn.nianxx.thhotel.platform.common.result.PlatformPageResult;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
/**
@@ -25,6 +27,11 @@ public interface AdminMenuManagementService {
*/
AdminMenuResult getMenu(String menuId);
/**
* 查询完整菜单树,不分页。
*/
AdminMenuTreeResult queryMenuTree();
/**
* 新增菜单定义。
*/
@@ -34,4 +41,11 @@ public interface AdminMenuManagementService {
* 编辑菜单定义,菜单代码不允许修改。
*/
AdminMenuResult updateMenu(String menuId, AdminMenuUpdateRequest request, AuthenticatedUserContext actor);
/**
* 批量调整菜单父级和排序。
*/
AdminMenuTreeResult updateMenuTreeOrder(
AdminMenuTreeOrderUpdateRequest request,
AuthenticatedUserContext actor);
}

View File

@@ -11,8 +11,12 @@ import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.navigation.common.enums.PlatformMenuStatus;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuCreateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderItemRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuTreeOrderUpdateRequest;
import cn.nianxx.thhotel.platform.navigation.common.request.AdminMenuUpdateRequest;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuResult;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeNodeResult;
import cn.nianxx.thhotel.platform.navigation.common.result.AdminMenuTreeResult;
import cn.nianxx.thhotel.platform.navigation.domain.PlatformMenuEntity;
import cn.nianxx.thhotel.platform.navigation.repository.PlatformNavigationRepository;
import cn.nianxx.thhotel.platform.navigation.service.AdminMenuManagementService;
@@ -21,7 +25,15 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -36,6 +48,9 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
private static final int DEFAULT_PAGE_NUM = 1;
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final int GENERATED_SORT_ORDER_STEP = 100;
private static final String TARGET_TYPE_PLATFORM_MENU = "PLATFORM_MENU";
private static final String ACTION_UPDATE_MENU_TREE_ORDER = "UPDATE_MENU_TREE_ORDER";
private static final Set<String> KNOWN_ROUTES = Set.of(
"/reservation/orders",
"/reservation/tasks",
@@ -99,6 +114,14 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
.orElseThrow(() -> notFound("菜单不存在。"));
}
/**
* 查询完整菜单树。脏父级数据会降级为根级异常节点并返回 warnings避免前端管理页 500。
*/
@Override
public AdminMenuTreeResult queryMenuTree() {
return buildMenuTree(navigationRepository.listAllMenus());
}
/**
* 新增菜单定义。菜单代码作为稳定业务键,一旦创建不提供修改入口。
*/
@@ -148,6 +171,74 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
return after;
}
/**
* 批量调整菜单父级和排序。只更新 parent_id、sort_order 和 updated_at并逐项写管理审计。
*/
@Override
@Transactional
public AdminMenuTreeResult updateMenuTreeOrder(
AdminMenuTreeOrderUpdateRequest request,
AuthenticatedUserContext actor) {
if (request == null || request.items() == null || request.items().isEmpty()) {
throw invalidRequest("菜单树排序请求不能为空。");
}
List<PlatformMenuEntity> allMenus = navigationRepository.listAllMenus();
Map<Long, PlatformMenuEntity> menuById = menuById(allMenus);
Map<Long, Long> proposedParentById = new HashMap<>();
for (PlatformMenuEntity menu : allMenus) {
proposedParentById.put(menu.getId(), menu.getParentId());
}
Set<Long> requestedMenuIds = new HashSet<>();
List<MenuTreeOrderChange> changes = new ArrayList<>();
for (int index = 0; index < request.items().size(); index++) {
AdminMenuTreeOrderItemRequest item = request.items().get(index);
if (item == null) {
throw invalidRequest("菜单树排序项不能为空。");
}
Long menuId = parseId(item.menuId(), "菜单 ID 不合法。");
Long parentId = parseNullableId(item.parentId(), "父菜单 ID 不合法。");
if (!requestedMenuIds.add(menuId)) {
throw invalidRequest("菜单树排序请求中存在重复菜单 ID。");
}
PlatformMenuEntity menu = menuById.get(menuId);
if (menu == null) {
throw notFound("菜单不存在。");
}
if (parentId != null && !menuById.containsKey(parentId)) {
throw invalidRequest("父菜单不存在。");
}
if (menuId.equals(parentId)) {
throw invalidRequest("不能把菜单设为自己的父级。");
}
Integer sortOrder = item.sortOrder() == null ? (index + 1) * GENERATED_SORT_ORDER_STEP : item.sortOrder();
proposedParentById.put(menuId, parentId);
changes.add(new MenuTreeOrderChange(
menu,
treeOrderSnapshot(menu.getParentId(), menu.getSortOrder()),
treeOrderSnapshot(parentId, sortOrder),
parentId,
sortOrder));
}
validateNoMenuTreeCycle(proposedParentById);
LocalDateTime now = nowUtc();
for (MenuTreeOrderChange change : changes) {
change.menu().setParentId(change.parentId());
change.menu().setSortOrder(change.sortOrder());
change.menu().setUpdatedAt(now);
navigationRepository.updateMenu(change.menu());
audit(
actor,
TARGET_TYPE_PLATFORM_MENU,
stringId(change.menu().getId()),
ACTION_UPDATE_MENU_TREE_ORDER,
change.beforeSnapshot(),
change.afterSnapshot());
}
return buildMenuTree(navigationRepository.listAllMenus());
}
private AdminMenuResult toResult(PlatformMenuEntity menu) {
return new AdminMenuResult(
stringId(menu.getId()),
@@ -167,6 +258,131 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
UtcTimeFormatter.toUtcOffsetDateTime(menu.getUpdatedAt()));
}
private AdminMenuTreeResult buildMenuTree(List<PlatformMenuEntity> menus) {
List<PlatformMenuEntity> sortedMenus = menus == null
? List.of()
: menus.stream().filter(Objects::nonNull).sorted(menuComparator()).toList();
Map<Long, PlatformMenuEntity> menuById = menuById(sortedMenus);
Map<Long, List<PlatformMenuEntity>> childrenByParentId = new HashMap<>();
List<PlatformMenuEntity> roots = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (PlatformMenuEntity menu : sortedMenus) {
Long parentId = menu.getParentId();
if (parentId == null) {
roots.add(menu);
} else if (!menuById.containsKey(parentId)) {
roots.add(menu);
warnings.add("菜单 " + stringId(menu.getId()) + " 的 parent_id=" + parentId
+ " 不存在,已按根级异常节点返回。");
} else {
childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(menu);
}
}
sortMenus(roots);
childrenByParentId.values().forEach(this::sortMenus);
Set<Long> emittedMenuIds = new HashSet<>();
List<AdminMenuTreeNodeResult> items = new ArrayList<>();
for (PlatformMenuEntity root : roots) {
items.add(toTreeNode(root, childrenByParentId, new HashSet<>(), emittedMenuIds, warnings));
}
for (PlatformMenuEntity menu : sortedMenus) {
if (menu.getId() != null && !emittedMenuIds.contains(menu.getId())) {
warnings.add("菜单 " + stringId(menu.getId()) + " 未能从根节点连接,已按根级异常节点返回。");
items.add(toTreeNode(menu, childrenByParentId, new HashSet<>(), emittedMenuIds, warnings));
}
}
return new AdminMenuTreeResult(items, warnings);
}
private AdminMenuTreeNodeResult toTreeNode(
PlatformMenuEntity menu,
Map<Long, List<PlatformMenuEntity>> childrenByParentId,
Set<Long> visitingMenuIds,
Set<Long> emittedMenuIds,
List<String> warnings) {
Long menuId = menu.getId();
if (menuId != null) {
emittedMenuIds.add(menuId);
visitingMenuIds.add(menuId);
}
List<AdminMenuTreeNodeResult> children = new ArrayList<>();
for (PlatformMenuEntity child : childrenByParentId.getOrDefault(menuId, List.of())) {
if (child.getId() != null && visitingMenuIds.contains(child.getId())) {
warnings.add("菜单 " + stringId(child.getId()) + " 存在循环父级,已截断循环子节点。");
continue;
}
children.add(toTreeNode(child, childrenByParentId, visitingMenuIds, emittedMenuIds, warnings));
}
if (menuId != null) {
visitingMenuIds.remove(menuId);
}
AdminMenuResult base = toResult(menu);
return new AdminMenuTreeNodeResult(
base.id(),
base.parentId(),
base.menuCode(),
base.menuName(),
base.menuType(),
base.routePath(),
base.componentKey(),
base.iconKey(),
base.permissionCode(),
base.sortOrder(),
base.visible(),
base.menuStatus(),
base.knownRoute(),
base.createdAt(),
base.updatedAt(),
children);
}
private Map<Long, PlatformMenuEntity> menuById(List<PlatformMenuEntity> menus) {
Map<Long, PlatformMenuEntity> menuById = new HashMap<>();
for (PlatformMenuEntity menu : menus) {
if (menu != null && menu.getId() != null) {
menuById.put(menu.getId(), menu);
}
}
return menuById;
}
private void validateNoMenuTreeCycle(Map<Long, Long> parentById) {
for (Long menuId : parentById.keySet()) {
Set<Long> path = new HashSet<>();
Long current = menuId;
while (current != null) {
if (!path.add(current)) {
throw invalidRequest("菜单树不能形成循环。");
}
Long parentId = parentById.get(current);
if (parentId == null || !parentById.containsKey(parentId)) {
break;
}
current = parentId;
}
}
}
private Map<String, Object> treeOrderSnapshot(Long parentId, Integer sortOrder) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("parent_id", stringId(parentId));
snapshot.put("sort_order", sortOrder);
return snapshot;
}
private void sortMenus(List<PlatformMenuEntity> menus) {
menus.sort(menuComparator());
}
private Comparator<PlatformMenuEntity> menuComparator() {
return Comparator
.comparing(PlatformMenuEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(PlatformMenuEntity::getMenuName, Comparator.nullsLast(String::compareTo))
.thenComparing(PlatformMenuEntity::getId, Comparator.nullsLast(Long::compareTo));
}
private int normalizePageNum(Integer pageNum) {
return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum;
}
@@ -186,8 +402,12 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
}
private Long parseId(String id, String message) {
String normalized = trimToNull(id);
if (normalized == null) {
throw invalidRequest(message);
}
try {
return Long.valueOf(id);
return Long.valueOf(normalized);
} catch (NumberFormatException exception) {
throw new AdminOperationException(HttpStatus.BAD_REQUEST, "ADMIN_INVALID_REQUEST", message);
}
@@ -322,4 +542,13 @@ public class AdminMenuManagementServiceImpl implements AdminMenuManagementServic
"系统管理审计序列化失败。");
}
}
private record MenuTreeOrderChange(
PlatformMenuEntity menu,
Map<String, Object> beforeSnapshot,
Map<String, Object> afterSnapshot,
Long parentId,
Integer sortOrder
) {
}
}