feat(iam): 菜单子页面分组与类型不可变校验

支持 subpage_group 父子约束与统一处理;更新菜单时禁止修改 menuType,并补充错误与枚举 i18n。
This commit is contained in:
DaxPay Dev
2026-07-11 12:24:14 +08:00
parent ddbc3e959d
commit 13390cc99d
9 changed files with 169 additions and 29 deletions

View File

@@ -12,7 +12,7 @@
Target Server Version : 160014 (160014)
File Encoding : 65001
Date: 11/07/2026 10:27:03
Date: 11/07/2026 11:04:59
*/
@@ -76,7 +76,7 @@ COMMENT ON COLUMN "public"."iam_perm_menu"."creator" IS '创建者ID';
COMMENT ON COLUMN "public"."iam_perm_menu"."last_modifier" IS '最后修改ID';
COMMENT ON COLUMN "public"."iam_perm_menu"."version" IS '版本号';
COMMENT ON COLUMN "public"."iam_perm_menu"."deleted" IS '删除标志';
COMMENT ON COLUMN "public"."iam_perm_menu"."menu_type" IS '菜单类型: catalog-目录, menu-菜单, embedded-内嵌, link-外链';
COMMENT ON COLUMN "public"."iam_perm_menu"."menu_type" IS '菜单类型: catalog-目录, menu-菜单, subpage-子页面, subpage_group-子页面分组, embedded-内嵌, link-外链';
COMMENT ON COLUMN "public"."iam_perm_menu"."active_icon" IS '激活状态图标';
COMMENT ON COLUMN "public"."iam_perm_menu"."badge" IS '徽章显示文本';
COMMENT ON COLUMN "public"."iam_perm_menu"."badge_type" IS '徽章类型: dot-圆点, normal-文本';
@@ -160,6 +160,7 @@ INSERT INTO "public"."iam_perm_menu" VALUES (803, 8, 'develop:auth', 'admin', 'C
INSERT INTO "public"."iam_perm_menu" VALUES (310, 308, 'system:notify:wechat-config', 'admin', 'WechatNotify', '微信消息通知', 'Wechat Message Notification', 'menu.system.notify.wechatConfig', 'lucide:message-circle', 'f', 'f', '/system/notify/wechat/index', '/system/notify/wechat', NULL, 30, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-09 00:00:00+00', '2026-07-09 00:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (40504, 304, 'system:config:mobile_app', 'admin', 'MobileAppConfig', '移动端应用管理', 'Mobile App Management', 'menu.system.config.mobileApp', 'lucide:smartphone', 'f', 'f', '/system/config/mobileApp/MobileAppConfig', '/system/config/mobile-app', NULL, 6, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 00:00:00+00', '2026-07-10 08:54:32.213917+00');
INSERT INTO "public"."iam_perm_menu" VALUES (40505, 40504, 'system:config:mobile_app_detail', 'admin', 'MobileAppDetail', '移动端应用详情', 'Mobile App Detail', 'menu.system.config.mobileAppDetail', NULL, 't', 'f', '/system/config/mobileApp/detail/MobileAppDetail', '/system/config/mobile-app/detail/:appType', NULL, 1, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 00:00:00+00', '2026-07-10 08:54:32.222476+00');
INSERT INTO "public"."iam_perm_menu" VALUES (4040121, 40401, 'merchant:gateway-aggregate', 'admin', 'AggregateScanConfig', '聚合扫码', 'Aggregate QR Pay', 'menu.payment.merchant.aggregateScan', NULL, 't', 'f', '/payment/merchant/aggregate/AggregateScanConfig', '/payment/merchant/aggregate', NULL, 3, 'f', 't', 'f', 1, 1, 1, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-11 03:00:13.374496+00', '2026-07-11 03:00:13.374496+00');
-- ----------------------------
-- Primary Key structure for table iam_perm_menu

View File

@@ -57,13 +57,82 @@ WHERE "id" = 9 AND "menu_code" = 'device';
-- ----------------------------
-- 聚合扫码配置菜单(应用级子页面)
-- 对接后端 GatewayAggregateConfigAdminController, 三级配置深度(AUTO/METHOD/DIRECT)
-- 注意: id 不能用 4040120, 该 ID 已被"微信域名验证"(merchant:wx_verify)占用
-- ----------------------------
INSERT INTO "public"."iam_perm_menu"
SELECT 4040120, 40401, 'merchant:gateway-aggregate', 'admin', 'AggregateScanConfig', '聚合扫码', 'Aggregate QR Pay',
'menu.payment.merchant.aggregateScan', NULL, 't', 'f',
'/payment/merchant/aggregate/AggregateScanConfig', '/payment/merchant/aggregate', NULL, 3, 'f', 't', 'f', 1, 1, 1, 'f', 'subpage',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM "public"."iam_perm_menu" WHERE "id" = 4040120 OR "menu_code" = 'merchant:gateway-aggregate'
INSERT INTO "public"."iam_perm_menu" VALUES (
4040121, 40401, 'merchant:gateway-aggregate', 'admin', 'AggregateScanConfig',
'聚合扫码', 'Aggregate QR Pay', 'menu.payment.merchant.aggregateScan',
NULL, 't', 'f',
'/payment/merchant/aggregate/AggregateScanConfig', '/payment/merchant/aggregate',
NULL, 3, 'f', 't', 'f', 1, 1, 1, 'f', 'subpage',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
-- ----------------------------
-- 2026-07-11 菜单子页面分组(subpage_group)升级
-- 新增 subpage_group 类型用于组织 menu 下大量平铺的 subpage, 不在侧边栏显示
-- 详见方案: catalog > menu > subpage_group(隐藏) > subpage, 面包屑显示完整四级
-- ----------------------------
-- 更新 menu_type 字段注释
COMMENT ON COLUMN "public"."iam_perm_menu"."menu_type" IS '菜单类型: catalog-目录, menu-菜单, subpage-子页面, subpage_group-子页面分组, embedded-内嵌, link-外链';
-- 新建 4 个子页面分组节点(纯容器: 无 path/component/menu_code, 强制隐藏)
-- 40401 商户列表下 3 个分组
INSERT INTO "public"."iam_perm_menu"
(id, pid, menu_code, client_code, name, title_cn, title_en, i18n_key, icon,
hidden, hide_children_menu, component, path, redirect, sort_no, root,
keep_alive, affix_tab, creator, last_modifier, version, deleted, menu_type,
active_icon, badge, badge_type, badge_variants, iframe_src, link,
create_time, last_modified_time)
VALUES
(4040130, 40401, NULL, 'admin', 'MchManageGroup', '商户管理', 'Merchant Management',
'menu.payment.merchant.group.manage', 'lucide:settings-2',
true, false, NULL, NULL, NULL, 1, false,
false, false, 1, 1, 0, false, 'subpage_group',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(4040131, 40401, NULL, 'admin', 'ChannelMerchantGroup', '通道商户', 'Channel Merchant',
'menu.payment.merchant.group.channelMerchant', 'lucide:repeat',
true, false, NULL, NULL, NULL, 2, false,
false, false, 1, 1, 0, false, 'subpage_group',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(4040132, 40401, NULL, 'admin', 'ChannelAppGroup', '渠道应用', 'Channel App',
'menu.payment.merchant.group.channelApp', 'lucide:layout-grid',
true, false, NULL, NULL, NULL, 3, false,
false, false, 1, 1, 0, false, 'subpage_group',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
-- 40105 支付产品管理下 1 个分组
(40508, 40105, NULL, 'admin', 'ChannelIsvConfigGroup', '渠道服务商配置', 'Channel ISV Config',
'menu.payment.config.group.channelIsv', 'lucide:server',
true, false, NULL, NULL, NULL, 1, false,
false, false, 1, 1, 0, false, 'subpage_group',
NULL, NULL, NULL, NULL, NULL, NULL,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- 将子页面 pid 迁移到对应分组
-- 4040130 商户管理组(9个)
UPDATE "public"."iam_perm_menu"
SET "pid" = 4040130, "last_modified_time" = CURRENT_TIMESTAMP
WHERE "id" IN (4040101, 4040103, 4040102, 4040110, 4040111, 4040108, 4040117, 4040121, 4040120);
-- 4040131 通道商户组(3个)
UPDATE "public"."iam_perm_menu"
SET "pid" = 4040131, "last_modified_time" = CURRENT_TIMESTAMP
WHERE "id" IN (4040106, 4040109, 4040112);
-- 4040132 渠道应用组(4个)
UPDATE "public"."iam_perm_menu"
SET "pid" = 4040132, "last_modified_time" = CURRENT_TIMESTAMP
WHERE "id" IN (4040113, 4040114, 4040115, 4040119);
-- 40508 渠道服务商配置组(3个)
UPDATE "public"."iam_perm_menu"
SET "pid" = 40508, "last_modified_time" = CURRENT_TIMESTAMP
WHERE "id" IN (40502, 40503, 40506);
-- 注: 40501(产品配置详情)、40505(移动端应用详情) 仅1个子页面, 保留直挂 menu 不强制分组

View File

@@ -2,6 +2,7 @@
"catalog": "Catalog",
"menu": "Menu",
"subpage": "Subpage",
"subpage_group": "Subpage Group",
"embedded": "Embedded Page",
"link": "External Link"
}

View File

@@ -4,9 +4,14 @@
"subpageNoChildren": "Subpage cannot have child menus",
"hasChildrenCannotDelete": "Cannot delete menu with child menus",
"invalidType": "Invalid menu type",
"typeImmutable": "Menu type cannot be changed after creation",
"subpageNeedParent": "Subpage must select a parent menu",
"parentNotExist": "Parent menu not found",
"subpageParentMustBeMenu": "Subpage parent must be a menu type",
"subpageParentInvalid": "Subpage parent must be a menu or subpage group",
"subpageGroupNeedParent": "Subpage group must select a parent menu",
"subpageGroupParentMustBeMenu": "Subpage group parent must be a menu type",
"subpageGroupChildMustBeSubpage": "Subpage group can only contain subpages",
"needParentCatalog": "Menu, embedded page, or external link must select a parent directory",
"parentMustBeCatalog": "Parent of menu, embedded page, or external link must be a directory type",
"notExist": "Menu not found"

View File

@@ -2,6 +2,7 @@
"catalog": "目录",
"menu": "菜单",
"subpage": "子页面",
"subpage_group": "子页面分组",
"embedded": "内嵌页面",
"link": "外链"
}

View File

@@ -4,9 +4,14 @@
"subpageNoChildren": "子页面不能有下级菜单",
"hasChildrenCannotDelete": "有子菜单不可以删除",
"invalidType": "无效的菜单类型",
"typeImmutable": "菜单类型创建后不可修改",
"subpageNeedParent": "子页面必须选择上级菜单",
"parentNotExist": "上级菜单不存在",
"subpageParentMustBeMenu": "子页面的上级菜单必须是菜单类型",
"subpageParentInvalid": "子页面的上级必须是菜单或子页面分组",
"subpageGroupNeedParent": "子页面分组必须选择上级菜单",
"subpageGroupParentMustBeMenu": "子页面分组的上级必须是菜单类型",
"subpageGroupChildMustBeSubpage": "子页面分组下只能包含子页面",
"needParentCatalog": "菜单、内嵌页面、外链必须选择上级目录",
"parentMustBeCatalog": "菜单、内嵌页面、外链的上级菜单必须是目录类型",
"notExist": "菜单不存在"

View File

@@ -16,6 +16,8 @@ public enum MenuTypeEnum implements I18nSupport {
MENU("menu"),
/// 子页面
SUBPAGE("subpage"),
/// 子页面分组
SUBPAGE_GROUP("subpage_group"),
/// 内嵌页面
EMBEDDED("embedded"),
/// 外链
@@ -46,7 +48,7 @@ public enum MenuTypeEnum implements I18nSupport {
}
public boolean canAddChild() {
return this == CATALOG || this == MENU;
return this == CATALOG || this == MENU || this == SUBPAGE_GROUP;
}
public boolean needParent() {

View File

@@ -61,7 +61,7 @@ public class PermMenuResult {
@Schema(description = "是否固定标签页")
private boolean affixTab;
@Schema(description = "菜单类型: catalog-目录, menu-菜单, embedded-内嵌, link-外链")
@Schema(description = "菜单类型: catalog-目录, menu-菜单, subpage-子页面, subpage_group-子页面分组, embedded-内嵌, link-外链")
private String menuType;
@Schema(description = "徽章显示文本")

View File

@@ -47,9 +47,8 @@ public class PermMenuService {
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.codeExists");
}
}
if (MenuTypeEnum.SUBPAGE.equalsCode(param.getMenuType())) {
param.setHidden(true);
}
// 子页面类菜单(subpage/subpage_group)统一处理
applySubpageLikeRules(param, false);
PermMenu permMenu = PermMenu.init(param);
permMenuManager.save(permMenu);
}
@@ -61,6 +60,10 @@ public class PermMenuService {
// 权限: 菜单不存在
.orElseThrow(() -> new DataNotExistException("error.iam.menu.notExist"));
// 菜单类型创建后不可修改
if (!Objects.equals(permMenu.getMenuType(), param.getMenuType())) {
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.typeImmutable");
}
// 校验父级菜单类型
validateParentType(param.getPid(), param.getMenuType());
// 检查上级菜单是否出现了循环依赖
@@ -79,9 +82,8 @@ public class PermMenuService {
// 权限: 子页面不能有下级菜单
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageNoChildren");
}
if (MenuTypeEnum.SUBPAGE.equalsCode(param.getMenuType())) {
param.setHidden(true);
}
// 子页面类菜单统一处理(含分组下级类型校验)
applySubpageLikeRules(param, true);
PermMenuConvert.CONVERT.copy(param, permMenu);
permMenuManager.updateById(permMenu);
}
@@ -168,7 +170,8 @@ public class PermMenuService {
/// 校验父级菜单类型
/// - 目录类型可以选择目录作为父级,也可以不选(作为根目录)
/// - 菜单、内嵌页面、外链的上级必须是目录类型
/// - 子页面的上级必须是菜单类型
/// - 子页面的上级必须是菜单或子页面分组
/// - 子页面分组的上级必须是菜单(一级限制,不可嵌套)
///
/// @param pid 父级菜单ID
/// @param menuType 当前菜单类型
@@ -182,18 +185,21 @@ public class PermMenuService {
return;
}
if (MenuTypeEnum.SUBPAGE.equals(typeEnum)) {
if (pid == null) {
// 权限: 子页面必须选择上级菜单
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageNeedParent");
}
PermMenu parent = permMenuManager.findById(pid).orElse(null);
if (parent == null) {
// 权限: 上级菜单不存在
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.parentNotExist");
PermMenu parent = validateAndGetParent(pid, "error.iam.menu.subpageNeedParent");
String parentType = parent.getMenuType();
// 子页面的上级可以是菜单或子页面分组
if (!MenuTypeEnum.MENU.equalsCode(parentType) && !MenuTypeEnum.SUBPAGE_GROUP.equalsCode(parentType)) {
// 权限: 子页面的上级必须是菜单或子页面分组
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageParentInvalid");
}
return;
}
if (MenuTypeEnum.SUBPAGE_GROUP.equals(typeEnum)) {
PermMenu parent = validateAndGetParent(pid, "error.iam.menu.subpageGroupNeedParent");
// 子页面分组的上级必须是菜单(不可嵌套分组)
if (!MenuTypeEnum.MENU.equalsCode(parent.getMenuType())) {
// 权限: 子页面的上级菜单必须是菜单类型
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageParentMustBeMenu");
// 权限: 子页面分组的上级必须是菜单类型
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageGroupParentMustBeMenu");
}
return;
}
@@ -212,6 +218,56 @@ public class PermMenuService {
}
}
/// 校验并获取父级菜单(子页面/子页面分组共用)
///
/// @param pid 父级菜单ID
/// @param needParentKey pid 为空时抛出的错误消息 key
/// @return 父级菜单实体
private PermMenu validateAndGetParent(Long pid, String needParentKey) {
if (pid == null) {
throw new BizException(CommonCode.FAIL_CODE, needParentKey);
}
PermMenu parent = permMenuManager.findById(pid).orElse(null);
if (parent == null) {
// 权限: 上级菜单不存在
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.parentNotExist");
}
return parent;
}
/// 子页面类菜单(subpage/subpage_group)的统一处理
/// - 强制隐藏(不在侧边栏显示)
/// - 子页面分组额外校验下级只能是子页面,并清空路由/组件/编码(纯容器)
///
/// @param param 菜单参数
/// @param checkChildren 是否校验下级类型(编辑时为 true
private void applySubpageLikeRules(PermMenuParam param, boolean checkChildren) {
String menuType = param.getMenuType();
boolean isSubpage = MenuTypeEnum.SUBPAGE.equalsCode(menuType);
boolean isGroup = MenuTypeEnum.SUBPAGE_GROUP.equalsCode(menuType);
if (!isSubpage && !isGroup) {
return;
}
// 强制隐藏
param.setHidden(true);
if (isGroup) {
// 子页面分组: 编辑时校验下级只能是子页面
if (checkChildren && param.getId() != null) {
List<PermMenu> children = permMenuManager.findAllByPid(param.getId());
for (PermMenu child : children) {
if (!MenuTypeEnum.SUBPAGE.equalsCode(child.getMenuType())) {
// 权限: 子页面分组下只能包含子页面
throw new BizException(CommonCode.FAIL_CODE, "error.iam.menu.subpageGroupChildMustBeSubpage");
}
}
}
// 清空路由/组件/编码(纯容器节点)
param.setMenuCode(null);
param.setComponent(null);
param.setPath(null);
}
}
/// 检查菜单编码是否存在(同一终端下)
/// @param menuCode 菜单编码
/// @param clientCode 终端编码