diff --git a/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts b/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts index 5ec67f20..c86cd2b7 100644 --- a/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts +++ b/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts @@ -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 { - const { data } = await http.get(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }) - return buildMenuOptionTree(parseMenuOptionList(data)) + const [adminRes, appRes] = await Promise.all([ + http.get(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }), + http.get(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 { - const { data } = await http.get(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, { - params: { menuType: ADMIN_MENU_TYPE }, - }) - return parseUserGrantedColumnIds(data) + const [adminRes, appRes] = await Promise.all([ + http.get(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, { + params: { menuType: ADMIN_MENU_TYPE }, + }), + http.get(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, { + params: { menuType: APP_MENU_TYPE }, + }), + ]) + return normalizeMenuIds([ + ...parseUserGrantedColumnIds(adminRes.data), + ...parseUserGrantedColumnIds(appRes.data), + ]) } /** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */ diff --git a/admin-frontend-vue/src/pages/account/user-menu-auth.ts b/admin-frontend-vue/src/pages/account/user-menu-auth.ts index 131e12be..49762779 100644 --- a/admin-frontend-vue/src/pages/account/user-menu-auth.ts +++ b/admin-frontend-vue/src/pages/account/user-menu-auth.ts @@ -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 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(payload) if (!Array.isArray(data)) return [] return data - .map((raw) => parsePermissionMenuItem(raw)) + .map((raw) => parsePermissionMenuItem(raw, type)) .filter((node): node is MenuOptionNode => node !== null) } diff --git a/admin-frontend-vue/tests/align-user-menu-auth-app.test.ts b/admin-frontend-vue/tests/align-user-menu-auth-app.test.ts new file mode 100644 index 00000000..c5548009 --- /dev/null +++ b/admin-frontend-vue/tests/align-user-menu-auth-app.test.ts @@ -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') +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java index 91a1c509..9188f94b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java @@ -230,11 +230,38 @@ public class AdminUserService { } } + /** + * 用户管理授权树现在同时展示并勾选 后台(admin) + 前端客户端(app) 菜单: + * 把勾选 id 按类型拆分后分区整树替换(internal 类型不在树里,出现即按无效处理)。 + */ private void replaceDirectPermissions(AdminUserEntity operator, Long userId, List columnIds) { + List raw = columnIds == null ? List.of() : columnIds; + Map typeById = permissionMenuService.resolveMenuTypes(raw); + List adminIds = new ArrayList<>(); + List 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 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) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 63417993..55fac59e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -61,6 +61,25 @@ public class PermissionMenuService { return toItemVos(menus, menus); } + /** + * 按菜单(column) id 解析所属类型;未知/无效 id 不在返回中。供用户创建/编辑把 + * 勾选的 后台(admin)+客户端(app) 菜单 id 拆分后分别落库。 + */ + public Map resolveMenuTypes(List columnIds) { + List ids = normalizeColumnIds(columnIds); + if (ids.isEmpty()) { + return Map.of(); + } + List rows = permissionMenuMapper.selectBatchIds(ids); + Map 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); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java index 59494fa7..3117d5b7 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java @@ -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.getArgument(0).setId(20L); @@ -50,11 +53,16 @@ class AdminUserServiceTest { Long createdId = service.createUser(operator, request); assertThat(createdId).isEqualTo(20L); - ArgumentCaptor permissions = + ArgumentCaptor adminReq = + ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class); + ArgumentCaptor 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 permissions = + ArgumentCaptor adminReq = + ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class); + ArgumentCaptor 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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuResolveTypeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuResolveTypeTest.java new file mode 100644 index 00000000..e19f3d70 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuResolveTypeTest.java @@ -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 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; + } +}