From 6e2e4bf8e71f299790056cd261fad2028a6d433f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 6 Sep 2026 01:57:17 +0800 Subject: [PATCH] =?UTF-8?q?task-275(=E9=AA=8C=E6=94=B6=E5=8F=8D=E9=A6=88):?= =?UTF-8?q?=20=E8=8F=9C=E5=8D=95=E6=9D=83=E9=99=90=E6=A0=87=E8=AF=86/?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=90=8E=E5=8F=B0=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90(menu=5Fauto=5F/auto/=20=E5=89=8D=E7=BC=80),=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E7=A7=BB=E9=99=A4=E4=B8=A4=E9=A1=B9=E8=BE=93=E5=85=A5?= =?UTF-8?q?;=E7=BC=96=E8=BE=91=E7=BC=BA=E7=9C=81=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E5=8E=9F=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/account/MenusPage.vue | 6 --- .../src/pages/account/menu-manage-model.ts | 14 +++-- .../tests/align-menu-auto-key.test.ts | 40 ++++++++++++++ admin-frontend-vue/tests/task-252.test.ts | 10 ++-- admin-frontend-vue/tests/task-53.test.ts | 10 ++-- admin-frontend-vue/tests/task-56.test.ts | 4 +- .../dto/PermissionMenuCreateRequest.java | 4 +- .../dto/PermissionMenuUpdateRequest.java | 4 +- .../service/PermissionMenuService.java | 42 +++++++++++++-- .../service/PermissionMenuServiceTest.java | 52 +++++++++++++++++++ 10 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 admin-frontend-vue/tests/align-menu-auto-key.test.ts diff --git a/admin-frontend-vue/src/pages/account/MenusPage.vue b/admin-frontend-vue/src/pages/account/MenusPage.vue index 8b8f8270..eb6676ab 100644 --- a/admin-frontend-vue/src/pages/account/MenusPage.vue +++ b/admin-frontend-vue/src/pages/account/MenusPage.vue @@ -225,9 +225,6 @@ onMounted(loadMenus) - - - @@ -243,9 +240,6 @@ onMounted(loadMenus) /> - - - diff --git a/admin-frontend-vue/src/pages/account/menu-manage-model.ts b/admin-frontend-vue/src/pages/account/menu-manage-model.ts index eedd6321..abc7095f 100644 --- a/admin-frontend-vue/src/pages/account/menu-manage-model.ts +++ b/admin-frontend-vue/src/pages/account/menu-manage-model.ts @@ -38,13 +38,14 @@ export interface MenuManageForm { sortOrder: number } -/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。 */ +/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。 + * 权限标识/路由由后台自动生成,不随表单提交,故为可选字段。 */ export interface MenuManagePayload { name: string - columnKey: string + columnKey?: string parentId: number | null menuType: string - routePath: string + routePath?: string sortOrder: number } @@ -258,23 +259,20 @@ export function emptyMenuManageForm(): MenuManageForm { return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 } } -/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。 */ +/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。权限标识/路由由后台自动生成,不参与校验。 */ export function validateMenuManageForm(form: MenuManageForm): { valid: boolean; errors: MenuManageFormErrors } { const errors: MenuManageFormErrors = {} if (!text(form.name)) errors.name = '菜单名称不能为空' - if (!text(form.columnKey)) errors.columnKey = '菜单标识不能为空' if (!text(form.menuType)) errors.menuType = '菜单类型不能为空' - if (!text(form.routePath)) errors.routePath = '菜单路由不能为空' return { valid: Object.keys(errors).length === 0, errors } } function toMenuManagePayload(form: MenuManageForm): MenuManagePayload { + // 权限标识(column_key)/路由(route_path) 不随表单提交:由后台自动生成(创建)或保持原值(编辑)。 return { name: text(form.name), - columnKey: text(form.columnKey), parentId: typeof form.parentId === 'number' ? form.parentId : null, menuType: text(form.menuType), - routePath: text(form.routePath), sortOrder: normalizeMenuSort(form.sortOrder), } } diff --git a/admin-frontend-vue/tests/align-menu-auto-key.test.ts b/admin-frontend-vue/tests/align-menu-auto-key.test.ts new file mode 100644 index 00000000..4d0d61a2 --- /dev/null +++ b/admin-frontend-vue/tests/align-menu-auto-key.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { emptyMenuManageForm, toMenuCreateRequest, toMenuUpdateRequest, validateMenuManageForm } from '../src/pages/account/menu-manage-model.ts' + +// 验收反馈:权限标识/路由由后台自动生成,不再出现在菜单表单里。 +// 前端契约:表单不采集、校验不要求、请求体不携带;后端创建时自动生成、编辑时保持原值。 + +test('align_menu_auto_key_normal_primary_path', () => { + const page = readSource('src/pages/account/MenusPage.vue') + assert.ok(!page.includes('权限标识'), '表单不应再有“权限标识”项') + assert.ok(!page.includes('label="路由"'), '表单不应再有“路由”项') +}) + +test('align_menu_auto_key_normal_variant_input', () => { + const createReq = toMenuCreateRequest({ ...emptyMenuManageForm(), name: '新菜单' }) + const updateReq = toMenuUpdateRequest({ ...emptyMenuManageForm(), name: '改名' }) + assert.ok(!('columnKey' in createReq), '创建请求不携带权限标识') + assert.ok(!('routePath' in createReq), '创建请求不携带路由') + assert.ok(!('columnKey' in updateReq), '更新请求不携带权限标识') + assert.ok(!('routePath' in updateReq), '更新请求不携带路由') +}) + +test('align_menu_auto_key_boundary_empty_input', () => { + const result = validateMenuManageForm({ ...emptyMenuManageForm(), name: '有名称' }) + assert.equal(result.valid, true, '未填权限标识/路由也应通过表单校验') +}) + +test('align_menu_auto_key_repeated_operation_is_idempotent', () => { + const model = readSource('src/pages/account/menu-manage-model.ts') + assert.match(model, /后台自动生成/, 'model 需注释说明自动生成语义') +}) + +test('align_menu_auto_key_dependency_failure_returns_actionable_message', () => { + // 后端契约:创建缺省自动生成 menu_auto_<类型>_<时间戳> 与 auto/<标识>,编辑缺省保持原值。 + const java = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java') + assert.match(java, /generateColumnKey|menu_auto_/, '后端含权限标识自动生成逻辑') + assert.match(java, /generateRoutePath|auto\/|"auto\/" \+ columnKey/, '后端含路由自动生成逻辑') + assert.match(java, /entity\.getColumnKey\(\)/, '编辑缺省保持原权限标识') +}) diff --git a/admin-frontend-vue/tests/task-252.test.ts b/admin-frontend-vue/tests/task-252.test.ts index 18a3b8dc..88820bca 100644 --- a/admin-frontend-vue/tests/task-252.test.ts +++ b/admin-frontend-vue/tests/task-252.test.ts @@ -13,12 +13,12 @@ test('test_task_252_menus_list_normal_primary_path', () => { }) test('test_task_252_menus_list_normal_variant_input', () => { - // 正常变体:权限标识/路由不作为列表列展示(运维从对话框维护),列宽自适应;删除确认含菜单名。 + // 正常变体:权限标识/路由不作为列表列展示,也不再出现在表单内(后台自动生成);列宽自适应;删除确认含菜单名。 const page = readSource('src/pages/account/MenusPage.vue') assert.equal(/]*prop="columnKey"/.test(page), false, '权限标识不应作为列表列') assert.equal(/]*prop="routePath"/.test(page), false, '路由不应作为列表列') - assert.match(page, /menuForm\.columnKey/, '权限标识字段仍在编辑表单内') - assert.match(page, /menuForm\.routePath/, '路由字段仍在编辑表单内') + assert.ok(!page.includes('menuForm.columnKey'), '权限标识输入项已从表单移除') + assert.ok(!page.includes('menuForm.routePath'), '路由输入项已从表单移除') assert.match(page, /确定删除菜单/, '删除确认需含“确定删除菜单”') assert.match(page, /menuDeleteReason/, '删除需过前置校验') }) @@ -46,9 +46,9 @@ test('test_task_252_menus_list_boundary_single_item', () => { }) test('test_task_252_menus_list_boundary_limit_or_missing_field', () => { - // 边界上限:创建/编辑表单含名称/标识/父菜单/路由/排序。 + // 边界上限:创建/编辑表单含名称/父菜单/排序(权限标识/路由后台自动生成,不在表单)。 const page = readSource('src/pages/account/MenusPage.vue') - for (const field of ['menuForm.name', 'menuForm.columnKey', 'menuForm.parentId', 'menuForm.routePath', 'menuForm.sortOrder']) { + for (const field of ['menuForm.name', 'menuForm.parentId', 'menuForm.sortOrder']) { assert.ok(page.includes(field), `表单需含 ${field}`) } }) diff --git a/admin-frontend-vue/tests/task-53.test.ts b/admin-frontend-vue/tests/task-53.test.ts index 3202e263..0d2e3939 100644 --- a/admin-frontend-vue/tests/task-53.test.ts +++ b/admin-frontend-vue/tests/task-53.test.ts @@ -65,12 +65,12 @@ test('test_task_053_menu_manage_dto_boundary_single_item', () => { }) test('test_task_053_menu_manage_dto_boundary_limit_or_missing_field', () => { - // 边界上限/缺字段:请求体字段与 Java 对齐(必填含 menuType/routePath);排序取整。 + // 边界上限/缺字段:请求体字段与 Java 对齐(必填含 menuType);权限标识/路由由后台自动生成,不随表单提交;排序取整。 const req = toMenuUpdateRequest({ ...emptyMenuManageForm(), name: 'n', columnKey: 'c', routePath: '/r', sortOrder: 2.7 }) assert.equal(req.sortOrder, 2) assert.ok('menuType' in req) - assert.ok('routePath' in req) - assert.equal(req.columnKey, 'c') + assert.ok(!('routePath' in req), '路由由后台自动生成,不提交') + assert.ok(!('columnKey' in req), '权限标识由后台自动生成,不提交') }) test('test_task_053_menu_manage_dto_invalid_input_rejected', () => { @@ -78,8 +78,8 @@ test('test_task_053_menu_manage_dto_invalid_input_rejected', () => { const r = validateMenuManageForm(emptyMenuManageForm()) assert.equal(r.valid, false) assert.ok(r.errors.name) - assert.ok(r.errors.columnKey) - assert.ok(r.errors.routePath) + assert.ok(!('columnKey' in r.errors), '权限标识不参与表单校验(后台自动生成)') + assert.ok(!('routePath' in r.errors), '路由不参与表单校验(后台自动生成)') assert.equal(parseMenuManageItem('garbage'), null) }) diff --git a/admin-frontend-vue/tests/task-56.test.ts b/admin-frontend-vue/tests/task-56.test.ts index 3371b456..cbdb7c1e 100644 --- a/admin-frontend-vue/tests/task-56.test.ts +++ b/admin-frontend-vue/tests/task-56.test.ts @@ -23,11 +23,11 @@ test('test_task_056_menu_edit_form_normal_primary_path', () => { }) test('test_task_056_menu_edit_form_normal_variant_input', () => { - // 正常变体:根节点回填 parentId=null;序列化到 update 请求体字段对齐。 + // 正常变体:根节点回填 parentId=null;权限标识/路由由后台自动生成,不随 update 请求体提交。 const node = parseMenuManageList([{ id: 1, name: '账号', column_key: 'account', parent_id: null, sort_order: 0 }])[0] const form = menuFormFromNode(node) assert.equal(form.parentId, null) - assert.deepEqual(toMenuUpdateRequest(form).columnKey, 'account') + assert.ok(!('columnKey' in toMenuUpdateRequest(form)), 'update 请求体不携带权限标识') }) test('test_task_056_menu_edit_form_normal_repeated_operation_is_idempotent', () => { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java index 10f86271..bf9110cd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuCreateRequest.java @@ -10,7 +10,7 @@ public class PermissionMenuCreateRequest { @NotBlank(message = "菜单名称不能为空") private String name; - @NotBlank(message = "菜单标识不能为空") + /** 菜单标识;不传由后台自动生成(menu_auto_<类型>_<时间戳>)。 */ @JsonAlias("column_key") private String columnKey; @@ -22,7 +22,7 @@ public class PermissionMenuCreateRequest { @JsonAlias("menu_type") private String menuType; - @NotBlank(message = "菜单路由不能为空") + /** 页面路由;不传由后台自动生成(auto/<菜单标识>)。 */ @JsonAlias("route_path") private String routePath; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java index 192a57c7..5d4e677e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/dto/PermissionMenuUpdateRequest.java @@ -10,7 +10,7 @@ public class PermissionMenuUpdateRequest { @NotBlank(message = "菜单名称不能为空") private String name; - @NotBlank(message = "菜单标识不能为空") + /** 菜单标识;不传保持原值(表单不采集)。 */ @JsonAlias("column_key") private String columnKey; @@ -22,7 +22,7 @@ public class PermissionMenuUpdateRequest { @JsonAlias("menu_type") private String menuType; - @NotBlank(message = "菜单路由不能为空") + /** 页面路由;不传保持原值(表单不采集)。 */ @JsonAlias("route_path") private String routePath; 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 28e25f80..63417993 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 @@ -74,9 +74,16 @@ public class PermissionMenuService { throw new BusinessException("菜单参数不能为空"); } String name = normalizeRequired(request.getName(), "菜单名称不能为空"); - String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空"); String menuType = normalizeMenuTypeRequired(request.getMenuType()); - String routePath = normalizeRequired(request.getRoutePath(), "页面路径不能为空"); + // 权限标识/路由由后台自动生成:表单不采集,缺省时生成稳定唯一值。 + String columnKey = normalizeOptional(request.getColumnKey()); + if (columnKey == null) { + columnKey = generateColumnKey(menuType); + } + String routePath = normalizeOptional(request.getRoutePath()); + if (routePath == null) { + routePath = generateRoutePath(columnKey); + } Long parentId = normalizeParentId(request.getParentId()); ensureParentValid(parentId, null, menuType); ensureUniqueColumnKey(columnKey, null); @@ -107,9 +114,16 @@ public class PermissionMenuService { throw new BusinessException("菜单参数不能为空"); } String name = normalizeRequired(request.getName(), "菜单名称不能为空"); - String columnKey = normalizeRequired(request.getColumnKey(), "菜单标识不能为空"); String menuType = normalizeMenuTypeRequired(request.getMenuType()); - String routePath = normalizeRequired(request.getRoutePath(), "页面路径不能为空"); + // 编辑时未传权限标识/路由则保持原值(表单不采集,避免覆盖为自动值)。 + String columnKey = normalizeOptional(request.getColumnKey()); + if (columnKey == null) { + columnKey = entity.getColumnKey(); + } + String routePath = normalizeOptional(request.getRoutePath()); + if (routePath == null) { + routePath = entity.getRoutePath(); + } Long parentId = normalizeParentId(request.getParentId()); ensureParentValid(parentId, id, menuType); ensureMenuTypeChangeAllowed(entity, menuType); @@ -824,6 +838,26 @@ public class PermissionMenuService { return normalized; } + /** trim 后为空的返回 null(用于“后台自动生成/保持原值”的可选字段)。 */ + private String normalizeOptional(String value) { + String normalized = value == null ? "" : value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + /** 未传菜单标识时后台自动生成(menu_auto_<类型>_<毫秒时间戳>,唯一性由 ensureUniqueColumnKey 兜底)。 */ + private String generateColumnKey(String menuType) { + String generated = "menu_auto_" + menuType + "_" + System.currentTimeMillis(); + log.info("[menu-auto-key] 未传菜单标识,后台自动生成 columnKey={}", generated); + return generated; + } + + /** 未传页面路径时后台自动生成(auto/<菜单标识>,不与真实页面路由冲突)。 */ + private String generateRoutePath(String columnKey) { + String generated = "auto/" + columnKey; + log.info("[menu-auto-key] 未传页面路径,后台自动生成 routePath={}", generated); + return generated; + } + private Long normalizeParentId(Long value) { return value == null || value <= 0 ? null : value; } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index 96d40806..bd33a418 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -672,6 +672,58 @@ class PermissionMenuServiceTest { assertThat(inserted.getValue().getMenuType()).isEqualTo("admin"); } + @Test + void createWithoutColumnKeyAndRoutePathAutoGenerates() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity adminTail = menu(9L, null, "admin", 134); + adminTail.setName("admin tail"); + when(menuMapper.selectOne(any())).thenReturn(null, null, adminTail); + when(menuMapper.selectById(any())).thenReturn(adminTail); + + PermissionMenuCreateRequest request = new PermissionMenuCreateRequest(); + request.setName("新菜单"); + request.setMenuType("admin"); + // 不传 columnKey / routePath:由后台自动生成。 + + service.create(request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(PermissionMenuEntity.class); + verify(menuMapper).insert(inserted.capture()); + assertThat(inserted.getValue().getColumnKey()).startsWith("menu_auto_admin_"); + assertThat(inserted.getValue().getRoutePath()).startsWith("auto/").contains(inserted.getValue().getColumnKey()); + } + + @Test + void updateWithoutColumnKeyAndRoutePathKeepsOriginal() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + + PermissionMenuEntity current = menu(1L, null, "admin", 1); + current.setName("原名"); + when(menuMapper.selectById(1L)).thenReturn(current); + when(menuMapper.selectOne(any())).thenReturn(null, null, current); + when(menuMapper.selectById(any())).thenReturn(current); + + PermissionMenuUpdateRequest request = new PermissionMenuUpdateRequest(); + request.setName("改名"); + request.setMenuType("admin"); + // 不传 columnKey / routePath:保持原值。 + + service.update(1L, request); + + ArgumentCaptor updated = ArgumentCaptor.forClass(PermissionMenuEntity.class); + verify(menuMapper).updateById(updated.capture()); + assertThat(updated.getValue().getColumnKey()).isEqualTo("menu-1"); + assertThat(updated.getValue().getRoutePath()).isEqualTo("route-1"); + assertThat(updated.getValue().getName()).isEqualTo("改名"); + } + private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) { PermissionMenuCreateRequest request = new PermissionMenuCreateRequest(); request.setName("child");