align(用户菜单授权): 创建/编辑用户菜单权限补齐前端客户端(app)菜单并按类型分区落库
用户管理菜单权限此前只含后台 admin(单类型):前端授权树/回显仅拉 menuType=admin, 后端 replaceDirectPermissions 也固定用 ADMIN 作用域写。现改为同时覆盖 后台(admin)+前端客户端(app): - 前端 user-menu-auth 节点携带 type,api 并行拉取 admin/app 两类可授权树与已授权 id 合并去重,树内两类根并存(账号与权限/视频/前端工具/运营工具/后勤工具等) - 后端 PermissionMenuService 新增 resolveMenuTypes(按 id 解析类型);AdminUserService create/update 把勾选 id 拆 admin/app 分区整树替换(internal 不纳入) - 前端 1556 测试全绿 + build 通过;后端单测(AdminUserServiceTest/PermissionMenuServiceTest/Controller/PermissionMenuResolveTypeTest) EXIT 0 - 本地 E2E:创建用户写入 admin=7+app=3 → 按类型回读 admin[7]/app[3],删除正常
This commit is contained in:
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
APP_MENU_TYPE,
|
||||
buildMenuOptionTree,
|
||||
buildUserColumnPermissionPayload,
|
||||
normalizeMenuIds,
|
||||
parseMenuOptionList,
|
||||
parseUserGrantedColumnIds,
|
||||
type MenuOptionNode,
|
||||
@@ -13,18 +15,33 @@ import {
|
||||
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
|
||||
|
||||
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
|
||||
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
|
||||
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
||||
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
||||
return buildMenuOptionTree(parseMenuOptionList(data))
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
|
||||
])
|
||||
const nodes = [
|
||||
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
|
||||
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
|
||||
]
|
||||
return buildMenuOptionTree(nodes)
|
||||
}
|
||||
|
||||
/** 拉取某用户当前直接授权菜单 id 列表。 */
|
||||
/** 拉取某用户当前直接授权菜单 id 列表(admin + app 合并去重)。 */
|
||||
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
||||
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
})
|
||||
return parseUserGrantedColumnIds(data)
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
}),
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: APP_MENU_TYPE },
|
||||
}),
|
||||
])
|
||||
return normalizeMenuIds([
|
||||
...parseUserGrantedColumnIds(adminRes.data),
|
||||
...parseUserGrantedColumnIds(appRes.data),
|
||||
])
|
||||
}
|
||||
|
||||
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
|
||||
|
||||
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
||||
export const ADMIN_MENU_TYPE = 'admin'
|
||||
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
|
||||
export const APP_MENU_TYPE = 'app'
|
||||
|
||||
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
||||
export interface MenuOptionNode {
|
||||
@@ -12,6 +14,8 @@ export interface MenuOptionNode {
|
||||
name: string
|
||||
sort: number
|
||||
parentId: number | null
|
||||
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||
type: string
|
||||
children?: MenuOptionNode[]
|
||||
}
|
||||
|
||||
@@ -20,7 +24,7 @@ function numberOrNull(value: unknown): number | null {
|
||||
}
|
||||
|
||||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
|
||||
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
||||
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = numberOrNull(record.id)
|
||||
@@ -34,15 +38,16 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
||||
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
|
||||
sort: sortRaw === null ? 0 : sortRaw,
|
||||
parentId: parentId === null ? null : parentId,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
||||
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
|
||||
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parsePermissionMenuItem(raw))
|
||||
.map((raw) => parsePermissionMenuItem(raw, type))
|
||||
.filter((node): node is MenuOptionNode => node !== null)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
APP_MENU_TYPE,
|
||||
parseMenuOptionList,
|
||||
parsePermissionMenuItem,
|
||||
} from '../src/pages/account/user-menu-auth.ts'
|
||||
|
||||
// 验收反馈:用户创建/编辑的菜单权限配置要同时包含 后台(admin) 与 前端客户端(app) 两类菜单,
|
||||
// 并按类型分区落库(internal 不纳入)。
|
||||
|
||||
test('align_user_menu_auth_both_types_tree_nodes', () => {
|
||||
// 授权节点携带 type:admin/app 两类菜单都能进同一棵树。
|
||||
assert.equal(APP_MENU_TYPE, 'app')
|
||||
const admin = parsePermissionMenuItem({ id: 1, name: '账号权限', parent_id: null, sort_order: 1 }, ADMIN_MENU_TYPE)
|
||||
assert.equal(admin?.type, ADMIN_MENU_TYPE)
|
||||
const app = parseMenuOptionList(
|
||||
[{ id: 2, name: '视频', parent_id: null, sort_order: 1, column_key: 'wb' }],
|
||||
APP_MENU_TYPE,
|
||||
)
|
||||
assert.equal(app[0].type, APP_MENU_TYPE)
|
||||
})
|
||||
|
||||
test('align_user_menu_auth_both_types_fetch_and_submit_source', () => {
|
||||
const api = readSource('src/pages/account/user-menu-auth-api.ts')
|
||||
assert.match(api, /APP_MENU_TYPE/, '拉取/回显含客户端类型')
|
||||
assert.match(api, /Promise\.all/, '两类菜单并行拉取')
|
||||
assert.match(api, /normalizeMenuIds/, '两类已授权 id 合并去重')
|
||||
// 提交仍由后端把勾选 id 按类型分区写(user PUT),前端不再限定单类型。
|
||||
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java')
|
||||
assert.match(service, /resolveMenuTypes/, '后端按菜单 id 解析类型')
|
||||
assert.match(service, /MENU_TYPE_ADMIN/, 'admin 分区写')
|
||||
assert.match(service, /MENU_TYPE_APP/, 'app 分区写')
|
||||
const auth = readSource('src/pages/account/user-menu-auth.ts')
|
||||
assert.match(auth, /type: string/, '授权节点携带 type')
|
||||
})
|
||||
+29
-2
@@ -230,11 +230,38 @@ public class AdminUserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理授权树现在同时展示并勾选 后台(admin) + 前端客户端(app) 菜单:
|
||||
* 把勾选 id 按类型拆分后分区整树替换(internal 类型不在树里,出现即按无效处理)。
|
||||
*/
|
||||
private void replaceDirectPermissions(AdminUserEntity operator, Long userId, List<Long> columnIds) {
|
||||
List<Long> raw = columnIds == null ? List.of() : columnIds;
|
||||
Map<Long, String> typeById = permissionMenuService.resolveMenuTypes(raw);
|
||||
List<Long> adminIds = new ArrayList<>();
|
||||
List<Long> appIds = new ArrayList<>();
|
||||
for (Long id : raw) {
|
||||
if (id == null) {
|
||||
continue;
|
||||
}
|
||||
String type = typeById.getOrDefault(id, "");
|
||||
if (PermissionMenuService.MENU_TYPE_ADMIN.equals(type)) {
|
||||
adminIds.add(id);
|
||||
} else if (PermissionMenuService.MENU_TYPE_APP.equals(type)) {
|
||||
appIds.add(id);
|
||||
} else {
|
||||
throw new BusinessException("存在无效或不属于当前类型的菜单权限项");
|
||||
}
|
||||
}
|
||||
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
|
||||
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
|
||||
}
|
||||
|
||||
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
|
||||
String menuType) {
|
||||
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
|
||||
permissionRequest.setColumnIds(columnIds == null ? List.of() : columnIds);
|
||||
permissionRequest.setColumnIds(columnIds);
|
||||
permissionMenuService.updateUserColumnPermissions(
|
||||
operator, userId, permissionRequest, PermissionMenuService.MENU_TYPE_APP);
|
||||
operator, userId, permissionRequest, menuType);
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
|
||||
+19
@@ -61,6 +61,25 @@ public class PermissionMenuService {
|
||||
return toItemVos(menus, menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按菜单(column) id 解析所属类型;未知/无效 id 不在返回中。供用户创建/编辑把
|
||||
* 勾选的 后台(admin)+客户端(app) 菜单 id 拆分后分别落库。
|
||||
*/
|
||||
public Map<Long, String> resolveMenuTypes(List<Long> columnIds) {
|
||||
List<Long> ids = normalizeColumnIds(columnIds);
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<PermissionMenuEntity> rows = permissionMenuMapper.selectBatchIds(ids);
|
||||
Map<Long, String> typeById = new HashMap<>();
|
||||
for (PermissionMenuEntity row : rows) {
|
||||
if (row != null && row.getId() != null) {
|
||||
typeById.put(row.getId(), row.getMenuType() == null ? "" : row.getMenuType());
|
||||
}
|
||||
}
|
||||
return typeById;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PermissionMenuItemVo create(PermissionMenuCreateRequest request) {
|
||||
return create(null, request);
|
||||
|
||||
+21
-6
@@ -13,6 +13,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -41,6 +42,8 @@ class AdminUserServiceTest {
|
||||
|
||||
when(authSupport.currentRole(operator)).thenReturn("admin");
|
||||
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
|
||||
when(permissionService.resolveMenuTypes(List.of(11L, 12L)))
|
||||
.thenReturn(Map.of(11L, PermissionMenuService.MENU_TYPE_ADMIN, 12L, PermissionMenuService.MENU_TYPE_APP));
|
||||
when(passwordEncoder.hash("secret1")).thenReturn("hashed");
|
||||
when(userMapper.insert(any(AdminUserEntity.class))).thenAnswer(invocation -> {
|
||||
invocation.<AdminUserEntity>getArgument(0).setId(20L);
|
||||
@@ -50,11 +53,16 @@ class AdminUserServiceTest {
|
||||
Long createdId = service.createUser(operator, request);
|
||||
|
||||
assertThat(createdId).isEqualTo(20L);
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> permissions =
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> adminReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), permissions.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
assertThat(permissions.getValue().getColumnIds()).containsExactly(11L, 12L);
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
assertThat(adminReq.getValue().getColumnIds()).containsExactly(11L);
|
||||
assertThat(appReq.getValue().getColumnIds()).containsExactly(12L);
|
||||
assertThat(AdminUserService.class
|
||||
.getMethod("createUser", AdminUserEntity.class, AdminUserCreateRequest.class)
|
||||
.isAnnotationPresent(Transactional.class)).isTrue();
|
||||
@@ -75,16 +83,23 @@ class AdminUserServiceTest {
|
||||
|
||||
when(authSupport.currentRole(operator)).thenReturn("admin");
|
||||
when(permissionService.hasAnyAdminMenu(eq(operator), any())).thenReturn(true);
|
||||
when(permissionService.resolveMenuTypes(List.of(21L)))
|
||||
.thenReturn(Map.of(21L, PermissionMenuService.MENU_TYPE_APP));
|
||||
when(userMapper.selectById(20L)).thenReturn(target);
|
||||
|
||||
service.updateUser(operator, 20L, request);
|
||||
|
||||
verify(userMapper, never()).update(any(), any());
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> permissions =
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> adminReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), permissions.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
assertThat(permissions.getValue().getColumnIds()).containsExactly(21L);
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
assertThat(adminReq.getValue().getColumnIds()).isEmpty();
|
||||
assertThat(appReq.getValue().getColumnIds()).containsExactly(21L);
|
||||
assertThat(AdminUserService.class
|
||||
.getMethod("updateUser", AdminUserEntity.class, Long.class, AdminUserUpdateRequest.class)
|
||||
.isAnnotationPresent(Transactional.class)).isTrue();
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.nanri.aiimage.modules.permission.service;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.mapper.PermissionMenuMapper;
|
||||
import com.nanri.aiimage.modules.permission.mapper.UserColumnPermissionMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PermissionMenuResolveTypeTest {
|
||||
|
||||
@Test
|
||||
void resolveMenuTypesGroupsAdminAndAppByIdAndDropsUnknown() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(
|
||||
menuMapper,
|
||||
mock(UserColumnPermissionMapper.class),
|
||||
mock(AdminUserMapper.class));
|
||||
when(menuMapper.selectBatchIds(any())).thenReturn(List.of(
|
||||
menu(1L, PermissionMenuService.MENU_TYPE_ADMIN),
|
||||
menu(2L, PermissionMenuService.MENU_TYPE_APP)));
|
||||
|
||||
Map<Long, String> typeById = service.resolveMenuTypes(List.of(1L, 2L, 3L));
|
||||
|
||||
assertThat(typeById)
|
||||
.containsEntry(1L, PermissionMenuService.MENU_TYPE_ADMIN)
|
||||
.containsEntry(2L, PermissionMenuService.MENU_TYPE_APP)
|
||||
.doesNotContainKey(3L);
|
||||
}
|
||||
|
||||
private PermissionMenuEntity menu(Long id, String type) {
|
||||
PermissionMenuEntity entity = new PermissionMenuEntity();
|
||||
entity.setId(id);
|
||||
entity.setMenuType(type);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user