菜单修改优化
This commit is contained in:
+158
-15
@@ -1,17 +1,23 @@
|
||||
package com.nanri.aiimage.modules.permission.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuCreateRequest;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.PermissionMenuUpdateRequest;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.ImageVideoDataPermissionUpdateRequest;
|
||||
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
|
||||
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.UserColumnIdsVo;
|
||||
import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -22,6 +28,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -30,54 +39,188 @@ import java.util.List;
|
||||
@Tag(name = "菜单权限管理", description = "统一维护软件端与后台管理端的菜单权限配置")
|
||||
public class PermissionMenuController {
|
||||
|
||||
@Value("${aiimage.security.internal-token:}")
|
||||
private String internalToken;
|
||||
|
||||
@Value("${aiimage.security.internal-token-file:}")
|
||||
private String internalTokenFile;
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
|
||||
@GetMapping("/permission-menus")
|
||||
@Operation(summary = "查询菜单权限列表")
|
||||
public ApiResponse<List<PermissionMenuItemVo>> listMenus(
|
||||
public ApiResponse<List<PermissionMenuItemVo>> listMenus(HttpServletRequest request,
|
||||
@Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) {
|
||||
requireAdmin(request);
|
||||
return ApiResponse.success(permissionMenuService.list(menuType));
|
||||
}
|
||||
|
||||
@PostMapping("/permission-menus")
|
||||
@Operation(summary = "新增菜单权限项")
|
||||
public ApiResponse<PermissionMenuItemVo> createMenu(@Valid @RequestBody PermissionMenuCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", permissionMenuService.create(request));
|
||||
public ApiResponse<PermissionMenuItemVo> createMenu(HttpServletRequest httpRequest,
|
||||
@Valid @RequestBody PermissionMenuCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", permissionMenuService.create(
|
||||
requireAdmin(httpRequest), request));
|
||||
}
|
||||
|
||||
@PutMapping("/permission-menus/{id}")
|
||||
@Operation(summary = "编辑菜单权限项")
|
||||
public ApiResponse<PermissionMenuItemVo> updateMenu(@PathVariable Long id,
|
||||
public ApiResponse<PermissionMenuItemVo> updateMenu(HttpServletRequest httpRequest,
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody PermissionMenuUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", permissionMenuService.update(id, request));
|
||||
return ApiResponse.success("更新成功", permissionMenuService.update(
|
||||
requireAdmin(httpRequest), id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/permission-menus/{id}")
|
||||
@Operation(summary = "删除菜单权限项")
|
||||
public ApiResponse<Void> deleteMenu(@PathVariable Long id) {
|
||||
permissionMenuService.delete(id);
|
||||
public ApiResponse<Void> deleteMenu(HttpServletRequest httpRequest, @PathVariable Long id) {
|
||||
permissionMenuService.delete(requireAdmin(httpRequest), id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
|
||||
@GetMapping("/permission-users/{userId}/columns")
|
||||
@Operation(summary = "查询用户菜单权限 ID 列表")
|
||||
public ApiResponse<UserColumnIdsVo> getUserColumnIds(@PathVariable Long userId,
|
||||
@Operation(summary = "查询用户直接授权菜单 ID 列表",
|
||||
description = "columnIds 仅表示数据库保存的直接授权,所有角色都不包含递归或虚拟权限")
|
||||
public ApiResponse<UserColumnIdsVo> getUserColumnIds(HttpServletRequest httpRequest,
|
||||
@PathVariable Long userId,
|
||||
@RequestParam(required = false) String menuType) {
|
||||
return ApiResponse.success(permissionMenuService.getUserColumnIds(userId, menuType));
|
||||
return ApiResponse.success(permissionMenuService.getUserColumnIds(
|
||||
requireAdmin(httpRequest), userId, menuType));
|
||||
}
|
||||
|
||||
@PutMapping("/permission-users/{userId}/columns")
|
||||
@Operation(summary = "更新用户菜单权限")
|
||||
public ApiResponse<Void> updateUserColumnIds(@PathVariable Long userId,
|
||||
public ApiResponse<Void> updateUserColumnIds(HttpServletRequest httpRequest,
|
||||
@PathVariable Long userId,
|
||||
@RequestParam(required = false) String menuType,
|
||||
@RequestBody(required = false) UserColumnPermissionUpdateRequest request) {
|
||||
permissionMenuService.updateUserColumnPermissions(userId, request);
|
||||
permissionMenuService.updateUserColumnPermissions(
|
||||
requireAdmin(httpRequest), userId, request, menuType);
|
||||
return ApiResponse.success("保存成功", null);
|
||||
}
|
||||
|
||||
@GetMapping("/permission-users/{userId}/column-permissions")
|
||||
@Operation(summary = "查询用户菜单权限详情")
|
||||
public ApiResponse<List<PermissionMenuItemVo>> getUserColumnPermissions(@PathVariable Long userId,
|
||||
@Operation(summary = "查询用户有效菜单权限详情",
|
||||
description = "返回直接授权及其递归展开的全部子菜单;不会改变数据库中的直接授权")
|
||||
public ApiResponse<List<PermissionMenuItemVo>> getUserColumnPermissions(HttpServletRequest httpRequest,
|
||||
@PathVariable Long userId,
|
||||
@RequestParam(required = false) String menuType) {
|
||||
return ApiResponse.success(permissionMenuService.getUserColumnPermissions(userId, menuType));
|
||||
return ApiResponse.success(permissionMenuService.getUserColumnPermissions(
|
||||
requireUser(httpRequest), userId, menuType));
|
||||
}
|
||||
|
||||
@GetMapping("/image-video-task-permissions")
|
||||
@Operation(summary = "查询视频任务数据权限用户")
|
||||
public ApiResponse<List<ImageVideoDataPermissionUserVo>> listImageVideoDataPermissionUsers(
|
||||
HttpServletRequest request) {
|
||||
return ApiResponse.success(permissionMenuService.listImageVideoDataPermissionUsers(requireAdmin(request)));
|
||||
}
|
||||
|
||||
@PutMapping("/image-video-task-permissions")
|
||||
@Operation(summary = "更新视频任务数据权限用户")
|
||||
public ApiResponse<Integer> updateImageVideoDataPermissionUsers(
|
||||
HttpServletRequest request,
|
||||
@RequestBody(required = false) ImageVideoDataPermissionUpdateRequest body) {
|
||||
int grantedCount = permissionMenuService.updateImageVideoDataPermissionUsers(
|
||||
requireAdmin(request), body == null ? List.of() : body.getUserIds());
|
||||
return ApiResponse.success("视频任务权限已更新", grantedCount);
|
||||
}
|
||||
|
||||
private AdminUserEntity requireAdmin(HttpServletRequest request) {
|
||||
try {
|
||||
return adminAuthSupport.requireAdmin(request);
|
||||
} catch (com.nanri.aiimage.common.exception.BusinessException authFailure) {
|
||||
AdminUserEntity internalOperator = resolveInternalOperator(request, true);
|
||||
if (internalOperator != null) {
|
||||
return internalOperator;
|
||||
}
|
||||
throw authFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private AdminUserEntity requireUser(HttpServletRequest request) {
|
||||
try {
|
||||
return adminAuthSupport.requireUser(request);
|
||||
} catch (com.nanri.aiimage.common.exception.BusinessException authFailure) {
|
||||
AdminUserEntity internalOperator = resolveInternalOperator(request, false);
|
||||
if (internalOperator != null) {
|
||||
return internalOperator;
|
||||
}
|
||||
throw authFailure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flask's legacy session cannot mint a Java JWT. It may identify its
|
||||
* already-authenticated user only with a shared secret supplied explicitly
|
||||
* or through the per-user local token file. Admin routes still revalidate
|
||||
* the database role, and the superAdmin query flag is deliberately ignored.
|
||||
*/
|
||||
private AdminUserEntity resolveInternalOperator(HttpServletRequest request, boolean requireAdmin) {
|
||||
String suppliedToken = request.getHeader("X-Internal-Token");
|
||||
if (!isTrustedInternalRequest(request, suppliedToken)) {
|
||||
return null;
|
||||
}
|
||||
String rawOperatorId = request.getParameter("operatorId");
|
||||
if (rawOperatorId == null || rawOperatorId.isBlank()) {
|
||||
rawOperatorId = request.getParameter("operator_id");
|
||||
}
|
||||
if (rawOperatorId == null || rawOperatorId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Long operatorId = Long.parseLong(rawOperatorId.trim());
|
||||
return requireAdmin
|
||||
? permissionMenuService.requireAdminOperator(operatorId)
|
||||
: permissionMenuService.requireUserOperator(operatorId);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTrustedInternalRequest(HttpServletRequest request, String suppliedToken) {
|
||||
String expectedToken = resolveExpectedInternalToken();
|
||||
if (expectedToken.isBlank() || suppliedToken == null || suppliedToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return expectedToken.equals(suppliedToken);
|
||||
}
|
||||
|
||||
private String resolveExpectedInternalToken() {
|
||||
if (internalToken != null && !internalToken.isBlank()) {
|
||||
return internalToken.trim();
|
||||
}
|
||||
Path path = resolveInternalTokenFile();
|
||||
if (path == null || !Files.isRegularFile(path)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return Files.readString(path, StandardCharsets.UTF_8).trim();
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveInternalTokenFile() {
|
||||
String configuredPath = internalTokenFile == null ? "" : internalTokenFile.trim();
|
||||
if (!configuredPath.isEmpty()) {
|
||||
if (configuredPath.equals("~") || configuredPath.startsWith("~/") || configuredPath.startsWith("~\\")) {
|
||||
String userHome = System.getProperty("user.home", "").trim();
|
||||
if (userHome.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
configuredPath = configuredPath.length() == 1
|
||||
? userHome
|
||||
: Path.of(userHome, configuredPath.substring(2)).toString();
|
||||
}
|
||||
Path configuredTokenPath = Path.of(configuredPath);
|
||||
return configuredTokenPath.isAbsolute() ? configuredTokenPath.normalize() : null;
|
||||
}
|
||||
String userHome = System.getProperty("user.home", "").trim();
|
||||
return userHome.isEmpty()
|
||||
? null
|
||||
: Path.of(userHome, ".aiimage", "internal-token").toAbsolutePath().normalize();
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.permission.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ImageVideoDataPermissionUpdateRequest {
|
||||
@JsonAlias("user_ids")
|
||||
private List<Long> userIds;
|
||||
}
|
||||
+5
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.permission.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -12,6 +13,10 @@ public class PermissionMenuCreateRequest {
|
||||
@NotBlank(message = "菜单标识不能为空")
|
||||
private String columnKey;
|
||||
|
||||
/** 直接父菜单;null 表示根菜单。 */
|
||||
@JsonAlias("parent_id")
|
||||
private Long parentId;
|
||||
|
||||
@NotBlank(message = "菜单类型不能为空")
|
||||
private String menuType;
|
||||
|
||||
|
||||
+5
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.permission.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -12,6 +13,10 @@ public class PermissionMenuUpdateRequest {
|
||||
@NotBlank(message = "菜单标识不能为空")
|
||||
private String columnKey;
|
||||
|
||||
/** 直接父菜单;null 表示根菜单。 */
|
||||
@JsonAlias("parent_id")
|
||||
private Long parentId;
|
||||
|
||||
@NotBlank(message = "菜单类型不能为空")
|
||||
private String menuType;
|
||||
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.permission.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -15,6 +16,8 @@ public class PermissionMenuEntity {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String columnKey;
|
||||
@TableField("parent_id")
|
||||
private Long parentId;
|
||||
private String menuType;
|
||||
private String routePath;
|
||||
private Integer sortOrder;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.permission.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoDataPermissionUserVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String role;
|
||||
private boolean granted;
|
||||
}
|
||||
+4
@@ -12,6 +12,10 @@ public class PermissionMenuItemVo {
|
||||
private String name;
|
||||
@JsonProperty("column_key")
|
||||
private String columnKey;
|
||||
@JsonProperty("parent_id")
|
||||
private Long parentId;
|
||||
@JsonProperty("root_column_key")
|
||||
private String rootColumnKey;
|
||||
@JsonProperty("menu_type")
|
||||
private String menuType;
|
||||
@JsonProperty("route_path")
|
||||
|
||||
+4
@@ -7,5 +7,9 @@ import java.util.List;
|
||||
@Data
|
||||
public class UserColumnIdsVo {
|
||||
|
||||
/**
|
||||
* 仅返回数据库中保存的直接授权 ID;不包含递归子菜单,
|
||||
* 也不会将超级管理员的虚拟全量权限写入响应。
|
||||
*/
|
||||
private List<Long> columnIds;
|
||||
}
|
||||
|
||||
+71
-4
@@ -22,8 +22,29 @@ public class PermissionMenuSchemaInitializer {
|
||||
private static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
|
||||
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
|
||||
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
|
||||
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130),
|
||||
new DefaultAppMenu("取款", "withdraw", "withdraw", 140)
|
||||
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
|
||||
);
|
||||
|
||||
private static final List<DefaultAppChildMenu> DEFAULT_APP_CHILD_MENUS = List.of(
|
||||
new DefaultAppChildMenu("采集数据", "collect-data", "collect-data", 111, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("品牌检测", "brand", "brand", 112, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("外观专利检测", "appearance-patent", "appearance-patent", 113, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("货源查询", "similar-asin", "similar-asin", 114, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("数据去重", "dedupe", "dedupe", 115, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("数据拆分", "split", "split", 116, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("格式转换", "convert", "convert", 117, "brand_front_tools"),
|
||||
new DefaultAppChildMenu("上架", "publish", "publish", 121, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("删除ASIN", "delete-brand", "delete-brand", 122, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("商品风险解决", "product-risk", "product-risk", 123, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("定时匹配", "shop-match", "shop-match", 124, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("跟价", "pricing", "price-track", 125, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("巡店删除", "patrol-delete", "patrol-delete", 126, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("查询ASIN", "query-asin", "query-asin", 127, "brand_operation_tools"),
|
||||
// Keep this key for compatibility with the existing Java/Flask module.
|
||||
new DefaultAppChildMenu("店铺数据抓取", "shop_data_crawl", "shop-data-crawl", 128, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("取款", "withdraw", "withdraw", 129, "brand_operation_tools"),
|
||||
new DefaultAppChildMenu("采购", "purchase", "purchase", 131, "brand_logistics_tools"),
|
||||
new DefaultAppChildMenu("ERP", "erp", "erp", 132, "brand_logistics_tools")
|
||||
);
|
||||
|
||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||
@@ -52,11 +73,13 @@ public class PermissionMenuSchemaInitializer {
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL COMMENT '菜单标题',
|
||||
column_key VARCHAR(64) NOT NULL COMMENT '菜单唯一标识',
|
||||
parent_id INT NULL COMMENT '直接父菜单 ID',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_column_key (column_key)
|
||||
)
|
||||
""");
|
||||
executeQuietly("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key");
|
||||
executeQuietly("ALTER TABLE columns ADD COLUMN parent_id INT NULL COMMENT '直接父菜单 ID' AFTER id");
|
||||
executeQuietly("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type");
|
||||
executeQuietly("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path");
|
||||
executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''");
|
||||
@@ -64,6 +87,8 @@ public class PermissionMenuSchemaInitializer {
|
||||
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
|
||||
ensureDefaultAdminMenus();
|
||||
ensureDefaultAppMenus();
|
||||
ensureDefaultAppChildMenus();
|
||||
ensureInternalDataPermissions();
|
||||
executeQuietly("""
|
||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||
user_id INT NOT NULL,
|
||||
@@ -86,7 +111,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||
executeQuietly("""
|
||||
UPDATE columns
|
||||
SET sort_order = %d
|
||||
SET sort_order = %d, parent_id = NULL
|
||||
WHERE column_key = '%s' AND (sort_order IS NULL OR sort_order = 0)
|
||||
""".formatted(menu.sortOrder(), menu.columnKey()));
|
||||
}
|
||||
@@ -103,12 +128,50 @@ public class PermissionMenuSchemaInitializer {
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||
executeQuietly("""
|
||||
UPDATE columns
|
||||
SET sort_order = %d, route_path = '%s', menu_type = 'app'
|
||||
SET sort_order = %d, route_path = '%s', menu_type = 'app', parent_id = NULL
|
||||
WHERE column_key = '%s'
|
||||
""".formatted(menu.sortOrder(), menu.routePath(), menu.columnKey()));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDefaultAppChildMenus() {
|
||||
// Older migrations used underscore keys for a few app pages. Normalize
|
||||
// them once so the frontend item key and permission key stay aligned.
|
||||
executeQuietly("UPDATE columns SET column_key = 'collect-data' WHERE column_key = 'collect_data'");
|
||||
executeQuietly("UPDATE columns SET column_key = 'appearance-patent' WHERE column_key = 'appearance_patent'");
|
||||
executeQuietly("UPDATE columns SET column_key = 'similar-asin' WHERE column_key = 'similar_asin'");
|
||||
|
||||
for (DefaultAppChildMenu menu : DEFAULT_APP_CHILD_MENUS) {
|
||||
executeQuietly("""
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '%s', '%s', 'app', '%s', %d, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = '%s'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns existing WHERE existing.column_key = '%s'
|
||||
)
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(),
|
||||
menu.parentKey(), menu.columnKey()));
|
||||
executeQuietly("""
|
||||
UPDATE columns child
|
||||
JOIN columns parent ON parent.column_key = '%s'
|
||||
SET child.name = '%s', child.menu_type = 'app', child.route_path = '%s',
|
||||
child.sort_order = %d, child.parent_id = parent.id
|
||||
WHERE child.column_key = '%s'
|
||||
""".formatted(menu.parentKey(), menu.name(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureInternalDataPermissions() {
|
||||
executeQuietly("""
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||
SELECT '视频任务数据查看', 'admin_image_video_task_data', 'internal', 'image-video-task-data', 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_image_video_task_data'
|
||||
)
|
||||
""");
|
||||
}
|
||||
|
||||
private void executeQuietly(String sql) {
|
||||
try {
|
||||
jdbcTemplate.execute(sql);
|
||||
@@ -122,4 +185,8 @@ public class PermissionMenuSchemaInitializer {
|
||||
|
||||
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||
}
|
||||
|
||||
private record DefaultAppChildMenu(String name, String columnKey, String routePath, int sortOrder,
|
||||
String parentKey) {
|
||||
}
|
||||
}
|
||||
|
||||
+508
-72
@@ -12,22 +12,34 @@ import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdate
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity;
|
||||
import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo;
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Menu hierarchy and user-column permissions.
|
||||
*
|
||||
* <p>The assignment table stores direct grants only. A parent grant is
|
||||
* expanded to all descendants when effective permissions are read or checked;
|
||||
* descendants are never inserted as a side effect of an update.</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PermissionMenuService {
|
||||
@@ -40,28 +52,37 @@ public class PermissionMenuService {
|
||||
private final UserColumnPermissionMapper userColumnPermissionMapper;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
|
||||
/** Returns the flat menu catalog, including each item's direct parent ID. */
|
||||
public List<PermissionMenuItemVo> list(String menuType) {
|
||||
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(isValidMenuType(menuType), PermissionMenuEntity::getMenuType, normalizeMenuType(menuType))
|
||||
.orderByAsc(PermissionMenuEntity::getSortOrder)
|
||||
.orderByAsc(PermissionMenuEntity::getId);
|
||||
return permissionMenuMapper.selectList(query).stream()
|
||||
.map(this::toItemVo)
|
||||
.toList();
|
||||
List<PermissionMenuEntity> menus = loadMenus(menuType);
|
||||
return toItemVos(menus, menus);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PermissionMenuItemVo create(PermissionMenuCreateRequest request) {
|
||||
return create(null, request);
|
||||
}
|
||||
|
||||
/** Controller-facing variant; the caller may pass the authenticated admin. */
|
||||
@Transactional
|
||||
public PermissionMenuItemVo create(AdminUserEntity operator, PermissionMenuCreateRequest request) {
|
||||
ensureAdminOperatorIfPresent(operator);
|
||||
if (request == null) {
|
||||
throw new BusinessException("菜单参数不能为空");
|
||||
}
|
||||
String name = normalizeRequired(request.getName(), "菜单名称不能为空");
|
||||
String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空");
|
||||
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
||||
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
||||
Long parentId = normalizeParentId(request.getParentId());
|
||||
ensureParentValid(parentId, null, menuType);
|
||||
ensureUniqueColumnKey(columnKey, null);
|
||||
ensureUniqueRoutePath(menuType, routePath, null);
|
||||
|
||||
PermissionMenuEntity entity = new PermissionMenuEntity();
|
||||
entity.setName(name);
|
||||
entity.setColumnKey(columnKey);
|
||||
entity.setParentId(parentId);
|
||||
entity.setMenuType(menuType);
|
||||
entity.setRoutePath(routePath);
|
||||
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), null));
|
||||
@@ -71,16 +92,30 @@ public class PermissionMenuService {
|
||||
|
||||
@Transactional
|
||||
public PermissionMenuItemVo update(Long id, PermissionMenuUpdateRequest request) {
|
||||
return update(null, id, request);
|
||||
}
|
||||
|
||||
/** Controller-facing variant; validates a parent change against the full ancestor chain. */
|
||||
@Transactional
|
||||
public PermissionMenuItemVo update(AdminUserEntity operator, Long id, PermissionMenuUpdateRequest request) {
|
||||
ensureAdminOperatorIfPresent(operator);
|
||||
PermissionMenuEntity entity = getMenuById(id);
|
||||
if (request == null) {
|
||||
throw new BusinessException("菜单参数不能为空");
|
||||
}
|
||||
String name = normalizeRequired(request.getName(), "菜单名称不能为空");
|
||||
String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空");
|
||||
String menuType = normalizeMenuTypeRequired(request.getMenuType());
|
||||
String routePath = normalizeRequired(request.getRoutePath(), "菜单路由不能为空");
|
||||
Long parentId = normalizeParentId(request.getParentId());
|
||||
ensureParentValid(parentId, id, menuType);
|
||||
ensureMenuTypeChangeAllowed(entity, menuType);
|
||||
ensureUniqueColumnKey(columnKey, id);
|
||||
ensureUniqueRoutePath(menuType, routePath, id);
|
||||
|
||||
entity.setName(name);
|
||||
entity.setColumnKey(columnKey);
|
||||
entity.setParentId(parentId);
|
||||
entity.setMenuType(menuType);
|
||||
entity.setRoutePath(routePath);
|
||||
entity.setSortOrder(resolveSortOrder(request.getSortOrder(), id));
|
||||
@@ -90,102 +125,224 @@ public class PermissionMenuService {
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
delete(null, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a leaf menu only. Refusing a parent deletion prevents orphaned
|
||||
* hierarchy rows and makes the direct-grant meaning deterministic.
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(AdminUserEntity operator, Long id) {
|
||||
ensureAdminOperatorIfPresent(operator);
|
||||
PermissionMenuEntity entity = getMenuById(id);
|
||||
Long childCount = permissionMenuMapper.selectCount(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getParentId, entity.getId()));
|
||||
if (childCount != null && childCount > 0) {
|
||||
throw new BusinessException("请先删除该菜单下的子菜单");
|
||||
}
|
||||
// Do this explicitly even when the database FK is configured, so old
|
||||
// installations without the FK are cleaned up as well.
|
||||
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getColumnId, entity.getId()));
|
||||
permissionMenuMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
/** Returns only persisted direct grant IDs (never recursively expanded). */
|
||||
public UserColumnIdsVo getUserColumnIds(Long userId, String menuType) {
|
||||
return getUserColumnIds(null, userId, menuType);
|
||||
}
|
||||
|
||||
/** Controller-facing variant with target-user scope checks. */
|
||||
public UserColumnIdsVo getUserColumnIds(AdminUserEntity operator, Long userId, String menuType) {
|
||||
AdminUserEntity user = getUserById(userId);
|
||||
List<Long> columnIds;
|
||||
if (isSuperAdmin(user)) {
|
||||
columnIds = list(menuType).stream().map(PermissionMenuItemVo::getId).toList();
|
||||
} else {
|
||||
List<Long> assignedIds = userColumnPermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId))
|
||||
.stream()
|
||||
.map(UserColumnPermissionEntity::getColumnId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (assignedIds.isEmpty()) {
|
||||
columnIds = List.of();
|
||||
} else if (isValidMenuType(menuType)) {
|
||||
Set<Long> allowedIds = list(menuType).stream()
|
||||
.map(PermissionMenuItemVo::getId)
|
||||
.collect(Collectors.toSet());
|
||||
columnIds = assignedIds.stream().filter(allowedIds::contains).toList();
|
||||
} else {
|
||||
columnIds = assignedIds;
|
||||
}
|
||||
}
|
||||
ensureTargetAccessible(operator, user);
|
||||
|
||||
List<PermissionMenuEntity> menus = loadMenus(menuType);
|
||||
Map<Long, PermissionMenuEntity> menuMap = menus.stream()
|
||||
.filter(menu -> menu.getId() != null)
|
||||
.collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity(), (left, right) -> left));
|
||||
UserColumnIdsVo vo = new UserColumnIdsVo();
|
||||
vo.setColumnIds(columnIds);
|
||||
vo.setColumnIds(sortDirectIds(loadDirectColumnIds(userId), menuMap));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective menu items: each directly granted menu plus every
|
||||
* descendant in the same menu type. The response is flat and sorted.
|
||||
*/
|
||||
public List<PermissionMenuItemVo> getUserColumnPermissions(Long userId, String menuType) {
|
||||
return getUserColumnPermissions(null, userId, menuType);
|
||||
}
|
||||
|
||||
/** Controller-facing variant with target-user scope checks. */
|
||||
public List<PermissionMenuItemVo> getUserColumnPermissions(AdminUserEntity operator,
|
||||
Long userId,
|
||||
String menuType) {
|
||||
AdminUserEntity user = getUserById(userId);
|
||||
ensureTargetAccessible(operator, user);
|
||||
List<PermissionMenuEntity> menus = loadMenus(menuType);
|
||||
if (isSuperAdmin(user)) {
|
||||
return list(menuType);
|
||||
return toItemVos(menus, menus);
|
||||
}
|
||||
List<Long> assignedIds = userColumnPermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId))
|
||||
.stream()
|
||||
.map(UserColumnPermissionEntity::getColumnId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (assignedIds.isEmpty()) {
|
||||
Set<Long> directIds = new LinkedHashSet<>(loadDirectColumnIds(userId));
|
||||
if (directIds.isEmpty() || menus.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<PermissionMenuEntity> menus = permissionMenuMapper.selectBatchIds(assignedIds);
|
||||
Map<Long, PermissionMenuEntity> menuMap = menus.stream()
|
||||
.collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity()));
|
||||
String safeMenuType = normalizeMenuType(menuType);
|
||||
return assignedIds.stream()
|
||||
.map(menuMap::get)
|
||||
.filter(menu -> menu != null)
|
||||
.filter(menu -> safeMenuType == null || safeMenuType.equals(menu.getMenuType()))
|
||||
.sorted(Comparator
|
||||
.comparing(PermissionMenuEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(PermissionMenuEntity::getId, Comparator.nullsLast(Long::compareTo)))
|
||||
.map(this::toItemVo)
|
||||
Set<Long> effectiveIds = expandDescendantIds(directIds, menus);
|
||||
List<PermissionMenuEntity> effectiveMenus = menus.stream()
|
||||
.filter(menu -> menu.getId() != null && effectiveIds.contains(menu.getId()))
|
||||
.toList();
|
||||
return toItemVos(effectiveMenus, menus);
|
||||
}
|
||||
|
||||
/** Effective authorization check used by backend callers that need a boolean decision. */
|
||||
public boolean hasEffectiveColumnPermission(Long userId, Long columnId) {
|
||||
if (columnId == null || columnId <= 0) {
|
||||
return false;
|
||||
}
|
||||
AdminUserEntity user = getUserById(userId);
|
||||
PermissionMenuEntity target = permissionMenuMapper.selectById(columnId);
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
if (isSuperAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
List<PermissionMenuEntity> menus = loadMenus(target.getMenuType());
|
||||
return expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(userId)), menus)
|
||||
.contains(columnId);
|
||||
}
|
||||
|
||||
public List<ImageVideoDataPermissionUserVo> listImageVideoDataPermissionUsers(AdminUserEntity operator) {
|
||||
ensureSuperAdminOperator(operator);
|
||||
PermissionMenuEntity dataPermission = requireImageVideoDataPermission();
|
||||
Set<Long> grantedUserIds = userColumnPermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getColumnId, dataPermission.getId()))
|
||||
.stream()
|
||||
.map(UserColumnPermissionEntity::getUserId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toSet());
|
||||
return adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.orderByAsc(AdminUserEntity::getUsername)
|
||||
.orderByAsc(AdminUserEntity::getId))
|
||||
.stream()
|
||||
.filter(user -> !isSuperAdmin(user))
|
||||
.map(user -> toImageVideoDataPermissionUserVo(user, grantedUserIds.contains(user.getId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int updateImageVideoDataPermissionUsers(AdminUserEntity operator, List<Long> userIds) {
|
||||
ensureSuperAdminOperator(operator);
|
||||
PermissionMenuEntity dataPermission = requireImageVideoDataPermission();
|
||||
List<Long> requestedIds = normalizeColumnIds(userIds);
|
||||
List<AdminUserEntity> users = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>());
|
||||
Map<Long, AdminUserEntity> grantableUsers = users.stream()
|
||||
.filter(user -> user.getId() != null && !isSuperAdmin(user))
|
||||
.collect(Collectors.toMap(AdminUserEntity::getId, Function.identity(), (left, right) -> left));
|
||||
if (requestedIds.stream().anyMatch(id -> !grantableUsers.containsKey(id))) {
|
||||
throw new BusinessException("包含不存在或不可授权的用户");
|
||||
}
|
||||
|
||||
userColumnPermissionMapper.deleteByMap(Map.of("column_id", dataPermission.getId()));
|
||||
for (Long userId : requestedIds) {
|
||||
UserColumnPermissionEntity grant = new UserColumnPermissionEntity();
|
||||
grant.setUserId(userId);
|
||||
grant.setColumnId(dataPermission.getId());
|
||||
userColumnPermissionMapper.insert(grant);
|
||||
}
|
||||
return requestedIds.size();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateUserColumnPermissions(Long userId, UserColumnPermissionUpdateRequest request) {
|
||||
getUserById(userId);
|
||||
updateUserColumnPermissions(null, userId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces direct grants. A non-super admin may only grant effective menus
|
||||
* that the operator already has, and only to themself or a directly-created
|
||||
* normal user.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateUserColumnPermissions(AdminUserEntity operator,
|
||||
Long userId,
|
||||
UserColumnPermissionUpdateRequest request) {
|
||||
updateUserColumnPermissions(operator, userId, request, null);
|
||||
}
|
||||
|
||||
/** Replaces direct grants only within menuType; a blank type keeps legacy full-replacement behavior. */
|
||||
@Transactional
|
||||
public void updateUserColumnPermissions(AdminUserEntity operator,
|
||||
Long userId,
|
||||
UserColumnPermissionUpdateRequest request,
|
||||
String menuType) {
|
||||
AdminUserEntity target = getUserById(userId);
|
||||
ensureTargetAccessible(operator, target);
|
||||
List<Long> requestedIds = request == null ? List.of() : normalizeColumnIds(request.getColumnIds());
|
||||
if (!requestedIds.isEmpty()) {
|
||||
Long validCount = permissionMenuMapper.selectCount(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.in(PermissionMenuEntity::getId, requestedIds));
|
||||
if (validCount == null || validCount != requestedIds.size()) {
|
||||
throw new BusinessException("存在无效的菜单权限项");
|
||||
String normalizedType = menuType == null || menuType.isBlank()
|
||||
? null
|
||||
: normalizeMenuTypeRequired(menuType);
|
||||
Set<Long> scopedMenuIds = new LinkedHashSet<>();
|
||||
if (normalizedType == null) {
|
||||
if (!requestedIds.isEmpty()) {
|
||||
Long validCount = permissionMenuMapper.selectCount(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.in(PermissionMenuEntity::getId, requestedIds));
|
||||
if (validCount == null || validCount != requestedIds.size()) {
|
||||
throw new BusinessException("存在无效的菜单权限项");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scopedMenuIds.addAll(loadMenus(normalizedType).stream()
|
||||
.map(PermissionMenuEntity::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
if (!scopedMenuIds.containsAll(requestedIds)) {
|
||||
throw new BusinessException("存在无效或不属于当前类型的菜单权限项");
|
||||
}
|
||||
}
|
||||
|
||||
PermissionMenuEntity imageVideoDataPermission = permissionMenuMapper.selectOne(
|
||||
new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY)
|
||||
.last("LIMIT 1"));
|
||||
if (imageVideoDataPermission != null) {
|
||||
requestedIds = new ArrayList<>(requestedIds);
|
||||
PermissionMenuEntity imageVideoDataPermission = normalizedType == null
|
||||
? findImageVideoDataPermission()
|
||||
: null;
|
||||
Long protectedId = imageVideoDataPermission == null ? null : imageVideoDataPermission.getId();
|
||||
List<Long> grantIds = requestedIds;
|
||||
if (normalizedType == null && protectedId != null) {
|
||||
grantIds = requestedIds.stream()
|
||||
.filter(id -> !protectedId.equals(id))
|
||||
.toList();
|
||||
}
|
||||
Set<Long> operatorEffectiveIds = ensureGrantable(operator, grantIds);
|
||||
LinkedHashSet<Long> finalGrantIds = new LinkedHashSet<>(grantIds);
|
||||
|
||||
if (normalizedType == null && protectedId != null) {
|
||||
Long existingCount = userColumnPermissionMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId)
|
||||
.eq(UserColumnPermissionEntity::getColumnId, imageVideoDataPermission.getId()));
|
||||
requestedIds.remove(imageVideoDataPermission.getId());
|
||||
.eq(UserColumnPermissionEntity::getColumnId, protectedId));
|
||||
if (existingCount != null && existingCount > 0) {
|
||||
requestedIds.add(imageVideoDataPermission.getId());
|
||||
finalGrantIds.add(protectedId);
|
||||
}
|
||||
}
|
||||
if (operatorEffectiveIds != null) {
|
||||
loadDirectColumnIds(userId).stream()
|
||||
.filter(id -> normalizedType == null || scopedMenuIds.contains(id))
|
||||
.filter(id -> !operatorEffectiveIds.contains(id))
|
||||
.forEach(finalGrantIds::add);
|
||||
}
|
||||
|
||||
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId));
|
||||
for (Long columnId : requestedIds) {
|
||||
LambdaUpdateWrapper<UserColumnPermissionEntity> delete =
|
||||
new LambdaUpdateWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId);
|
||||
if (normalizedType == null) {
|
||||
userColumnPermissionMapper.delete(delete);
|
||||
} else if (!scopedMenuIds.isEmpty()) {
|
||||
userColumnPermissionMapper.delete(delete.in(
|
||||
UserColumnPermissionEntity::getColumnId, scopedMenuIds));
|
||||
}
|
||||
for (Long columnId : finalGrantIds) {
|
||||
UserColumnPermissionEntity entity = new UserColumnPermissionEntity();
|
||||
entity.setUserId(userId);
|
||||
entity.setColumnId(columnId);
|
||||
@@ -193,6 +350,75 @@ public class PermissionMenuService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<PermissionMenuEntity> loadMenus(String menuType) {
|
||||
String normalizedType = normalizeMenuType(menuType);
|
||||
LambdaQueryWrapper<PermissionMenuEntity> query = new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(normalizedType != null, PermissionMenuEntity::getMenuType, normalizedType)
|
||||
.orderByAsc(PermissionMenuEntity::getSortOrder)
|
||||
.orderByAsc(PermissionMenuEntity::getId);
|
||||
List<PermissionMenuEntity> rows = permissionMenuMapper.selectList(query);
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return rows.stream()
|
||||
.filter(row -> row != null && row.getId() != null)
|
||||
.sorted(menuComparator())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Long> loadDirectColumnIds(Long userId) {
|
||||
List<UserColumnPermissionEntity> rows = userColumnPermissionMapper.selectList(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId));
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return rows.stream()
|
||||
.map(UserColumnPermissionEntity::getColumnId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Long> sortDirectIds(List<Long> ids, Map<Long, PermissionMenuEntity> menuMap) {
|
||||
return ids.stream()
|
||||
.filter(menuMap::containsKey)
|
||||
.sorted(Comparator.comparing(menuMap::get, menuComparator()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Set<Long> expandDescendantIds(Set<Long> directIds, List<PermissionMenuEntity> menus) {
|
||||
Map<Long, List<PermissionMenuEntity>> childrenByParent = new HashMap<>();
|
||||
Map<Long, PermissionMenuEntity> menuMap = new HashMap<>();
|
||||
for (PermissionMenuEntity menu : menus) {
|
||||
if (menu.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
menuMap.put(menu.getId(), menu);
|
||||
if (menu.getParentId() != null) {
|
||||
childrenByParent.computeIfAbsent(menu.getParentId(), ignored -> new ArrayList<>()).add(menu);
|
||||
}
|
||||
}
|
||||
childrenByParent.values().forEach(children -> children.sort(menuComparator()));
|
||||
|
||||
LinkedHashSet<Long> effective = new LinkedHashSet<>();
|
||||
ArrayDeque<Long> queue = new ArrayDeque<>();
|
||||
for (Long directId : directIds) {
|
||||
if (directId != null && menuMap.containsKey(directId) && effective.add(directId)) {
|
||||
queue.addLast(directId);
|
||||
}
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
Long parentId = queue.removeFirst();
|
||||
for (PermissionMenuEntity child : childrenByParent.getOrDefault(parentId, List.of())) {
|
||||
if (effective.add(child.getId())) {
|
||||
queue.addLast(child.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
private PermissionMenuEntity getMenuById(Long id) {
|
||||
PermissionMenuEntity entity = permissionMenuMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -209,8 +435,175 @@ public class PermissionMenuService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Resolves and validates an operator supplied by a trusted internal proxy. */
|
||||
public AdminUserEntity requireAdminOperator(Long userId) {
|
||||
AdminUserEntity operator = getUserById(userId);
|
||||
ensureAdminOperatorIfPresent(operator);
|
||||
return operator;
|
||||
}
|
||||
|
||||
/** Resolves any authenticated user supplied by a trusted internal proxy. */
|
||||
public AdminUserEntity requireUserOperator(Long userId) {
|
||||
return getUserById(userId);
|
||||
}
|
||||
|
||||
private void ensureAdminOperatorIfPresent(AdminUserEntity operator) {
|
||||
if (operator == null) {
|
||||
return;
|
||||
}
|
||||
if (!isAdmin(operator)) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureTargetAccessible(AdminUserEntity operator, AdminUserEntity target) {
|
||||
if (operator == null) {
|
||||
return;
|
||||
}
|
||||
boolean self = operator.getId() != null && operator.getId().equals(target.getId());
|
||||
if (self || isSuperAdmin(operator)) {
|
||||
return;
|
||||
}
|
||||
if (!isAdmin(operator)) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
boolean ownedNormal = isNormalUser(target)
|
||||
&& operator.getId() != null
|
||||
&& operator.getId().equals(target.getCreatedById());
|
||||
if (!ownedNormal) {
|
||||
throw new BusinessException(403, "普通管理员只能管理自己创建的普通用户");
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> ensureGrantable(AdminUserEntity operator, List<Long> requestedIds) {
|
||||
if (operator == null || isSuperAdmin(operator)) {
|
||||
return null;
|
||||
}
|
||||
List<PermissionMenuEntity> menus = loadMenus(null);
|
||||
Set<Long> effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus);
|
||||
Set<Long> denied = requestedIds.stream()
|
||||
.filter(id -> !effective.contains(id))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (!denied.isEmpty()) {
|
||||
throw new BusinessException(403, "普通管理员只能分配自己已有的菜单权限");
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
private boolean isAdmin(AdminUserEntity user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
String role = normalizeRole(user.getRole());
|
||||
if (!role.isEmpty()) {
|
||||
return "super_admin".equals(role) || "admin".equals(role);
|
||||
}
|
||||
return Integer.valueOf(1).equals(user.getIsAdmin());
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(AdminUserEntity user) {
|
||||
return user != null && "super_admin".equalsIgnoreCase(normalizeRequired(user.getRole(), ""));
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
String role = normalizeRole(user.getRole());
|
||||
if ("super_admin".equals(role)) {
|
||||
return true;
|
||||
}
|
||||
return role.isEmpty()
|
||||
&& Integer.valueOf(1).equals(user.getIsAdmin())
|
||||
&& user.getCreatedById() == null;
|
||||
}
|
||||
|
||||
private boolean isNormalUser(AdminUserEntity user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
String role = normalizeRole(user.getRole());
|
||||
if (!role.isEmpty()) {
|
||||
return !"admin".equals(role) && !"super_admin".equals(role);
|
||||
}
|
||||
return !Integer.valueOf(1).equals(user.getIsAdmin());
|
||||
}
|
||||
|
||||
private PermissionMenuEntity findImageVideoDataPermission() {
|
||||
return permissionMenuMapper.selectOne(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private PermissionMenuEntity requireImageVideoDataPermission() {
|
||||
PermissionMenuEntity permission = findImageVideoDataPermission();
|
||||
if (permission == null || permission.getId() == null) {
|
||||
throw new BusinessException("视频任务数据权限尚未初始化");
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
private void ensureSuperAdminOperator(AdminUserEntity operator) {
|
||||
ensureAdminOperatorIfPresent(operator);
|
||||
if (operator == null || !isSuperAdmin(operator)) {
|
||||
throw new BusinessException(403, "仅超级管理员可以配置视频任务权限");
|
||||
}
|
||||
}
|
||||
|
||||
private ImageVideoDataPermissionUserVo toImageVideoDataPermissionUserVo(AdminUserEntity user,
|
||||
boolean granted) {
|
||||
ImageVideoDataPermissionUserVo vo = new ImageVideoDataPermissionUserVo();
|
||||
vo.setId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
String role = normalizeRole(user.getRole());
|
||||
vo.setRole(role.isEmpty()
|
||||
? (Integer.valueOf(1).equals(user.getIsAdmin()) ? "admin" : "normal")
|
||||
: role);
|
||||
vo.setGranted(granted);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void ensureParentValid(Long parentId, Long currentId, String menuType) {
|
||||
if (parentId == null) {
|
||||
return;
|
||||
}
|
||||
if (currentId != null && currentId.equals(parentId)) {
|
||||
throw new BusinessException("父级菜单不能选择自己");
|
||||
}
|
||||
PermissionMenuEntity parent = permissionMenuMapper.selectById(parentId);
|
||||
if (parent == null) {
|
||||
throw new BusinessException("父级菜单不存在");
|
||||
}
|
||||
String parentType = normalizeMenuType(parent.getMenuType());
|
||||
if (!menuType.equals(parentType)) {
|
||||
throw new BusinessException("父级菜单必须与当前菜单属于同一类型");
|
||||
}
|
||||
|
||||
Set<Long> visited = new HashSet<>();
|
||||
PermissionMenuEntity cursor = parent;
|
||||
while (cursor != null && cursor.getId() != null) {
|
||||
if (!visited.add(cursor.getId())) {
|
||||
throw new BusinessException("菜单层级存在循环");
|
||||
}
|
||||
if (currentId != null && currentId.equals(cursor.getId())) {
|
||||
throw new BusinessException("父级菜单不能选择自己的子级");
|
||||
}
|
||||
Long ancestorId = cursor.getParentId();
|
||||
if (ancestorId == null) {
|
||||
break;
|
||||
}
|
||||
cursor = permissionMenuMapper.selectById(ancestorId);
|
||||
if (cursor == null) {
|
||||
throw new BusinessException("父级菜单不存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureMenuTypeChangeAllowed(PermissionMenuEntity entity, String menuType) {
|
||||
if (menuType.equals(normalizeMenuType(entity.getMenuType()))) {
|
||||
return;
|
||||
}
|
||||
Long childCount = permissionMenuMapper.selectCount(new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getParentId, entity.getId()));
|
||||
if (childCount != null && childCount > 0) {
|
||||
throw new BusinessException("请先移出该菜单下的子菜单再修改菜单类型");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureUniqueColumnKey(String columnKey, Long excludeId) {
|
||||
@@ -240,8 +633,12 @@ public class PermissionMenuService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean isValidMenuType(String menuType) {
|
||||
return normalizeMenuType(menuType) != null;
|
||||
private Long normalizeParentId(Long value) {
|
||||
return value == null || value <= 0 ? null : value;
|
||||
}
|
||||
|
||||
private String normalizeRole(String role) {
|
||||
return role == null ? "" : role.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeMenuTypeRequired(String menuType) {
|
||||
@@ -260,6 +657,8 @@ public class PermissionMenuService {
|
||||
if (MENU_TYPE_APP.equals(normalized) || MENU_TYPE_ADMIN.equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
// Keep the internal data permission visible to legacy callers that do
|
||||
// not pass a menu type, but never treat an unknown filter as a type.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -276,11 +675,34 @@ public class PermissionMenuService {
|
||||
return new ArrayList<>(uniqueIds);
|
||||
}
|
||||
|
||||
private Comparator<PermissionMenuEntity> menuComparator() {
|
||||
return Comparator
|
||||
.comparing(PermissionMenuEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(PermissionMenuEntity::getId, Comparator.nullsLast(Long::compareTo));
|
||||
}
|
||||
|
||||
private List<PermissionMenuItemVo> toItemVos(List<PermissionMenuEntity> visibleMenus,
|
||||
List<PermissionMenuEntity> hierarchyMenus) {
|
||||
Map<Long, PermissionMenuEntity> menuMap = hierarchyMenus.stream()
|
||||
.filter(menu -> menu.getId() != null)
|
||||
.collect(Collectors.toMap(PermissionMenuEntity::getId, Function.identity(), (left, right) -> left));
|
||||
return visibleMenus.stream()
|
||||
.map(menu -> toItemVo(menu, menuMap))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private PermissionMenuItemVo toItemVo(PermissionMenuEntity entity) {
|
||||
return toItemVos(List.of(entity), loadMenus(entity.getMenuType())).getFirst();
|
||||
}
|
||||
|
||||
private PermissionMenuItemVo toItemVo(PermissionMenuEntity entity,
|
||||
Map<Long, PermissionMenuEntity> menuMap) {
|
||||
PermissionMenuItemVo vo = new PermissionMenuItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setName(entity.getName());
|
||||
vo.setColumnKey(entity.getColumnKey());
|
||||
vo.setParentId(entity.getParentId());
|
||||
vo.setRootColumnKey(resolveRootColumnKey(entity, menuMap));
|
||||
vo.setMenuType(entity.getMenuType());
|
||||
vo.setRoutePath(entity.getRoutePath());
|
||||
vo.setSortOrder(entity.getSortOrder());
|
||||
@@ -288,6 +710,20 @@ public class PermissionMenuService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String resolveRootColumnKey(PermissionMenuEntity entity,
|
||||
Map<Long, PermissionMenuEntity> menuMap) {
|
||||
PermissionMenuEntity root = entity;
|
||||
Set<Long> visited = new HashSet<>();
|
||||
while (root.getId() != null && visited.add(root.getId()) && root.getParentId() != null) {
|
||||
PermissionMenuEntity parent = menuMap.get(root.getParentId());
|
||||
if (parent == null) {
|
||||
break;
|
||||
}
|
||||
root = parent;
|
||||
}
|
||||
return root.getColumnKey();
|
||||
}
|
||||
|
||||
private Integer resolveSortOrder(Integer sortOrder, Long excludeId) {
|
||||
if (sortOrder != null) {
|
||||
return sortOrder;
|
||||
|
||||
Reference in New Issue
Block a user