MenuProviderExtension

MenuProviderExtension 是插件向平台导航栏动态添加菜单项的标准契约。与 menus.json 静态声明不同,该 SPI 在每次菜单构建请求时触发,并接收实时的用户和租户上下文——从而支持基于角色的导航门控、功能开关驱动的菜单树,以及租户级菜单自定义,无需改动插件清单。

当菜单项的可见性取决于当前用户身份、活跃租户或运行时配置时,应使用此 SPI。若菜单项对所有人始终可见且不会变化,menus.json 更简洁;若可见性依赖上下文,请使用此 SPI。

概念

平台的菜单构建流程从两个来源汇聚贡献:插件清单中声明的静态 menus.json 条目,以及注册为 MenuProviderExtension 实现的动态提供者。提供者在插件加载时通过 pf4j 发现,并在静态条目解析完成后被调用。平台首先调用 isActive(context)——若返回 false,则不再调用 getMenuItems(context),该提供者本次不贡献任何项。

MenuContext 携带当前请求的完整身份信息:tenantIduserIduserRolesuserPermissions、发起方 pluginId、其 namespace,以及存放运行时标志的 settings 映射。两个便捷方法——hasRole(String)hasPermission(String)——让提供者可以内联门控菜单项,无需手动遍历列表。

MenuItem 是贡献的最小单元。条目通过 children 嵌套,自带 requiredRolesrequiredPermissions 声明(由平台渲染器强制执行),并支持通过 target 设置外部链接。metadata 映射会转发给前端渲染器,供扩展特定的装饰使用。

接口签名

package com.auraboot.framework.plugin.extension;

public interface MenuProviderExtension extends ExtensionPoint {

    /**
     * 本提供者贡献到的菜单组。
     * 常用组名:"main-sidebar"、"header-menu"、"user-menu"、"settings-menu"
     */
    String getMenuGroup();

    /**
     * 返回给定上下文下需要添加的菜单项。
     */
    List<MenuItem> getMenuItems(MenuContext context);

    /**
     * 守卫方法。返回 false 则本次不贡献任何项。
     * 默认:true(始终激活)。
     */
    default boolean isActive(MenuContext context) {
        return true;
    }

    /**
     * 在菜单组内的排序。值越小越靠前。
     * 默认:100。
     */
    default int getOrder() {
        return 100;
    }

    record MenuContext(
            Long tenantId,
            String pluginId,
            String namespace,
            Long userId,
            List<String> userRoles,
            List<String> userPermissions,
            Map<String, Object> settings
    ) {
        public boolean hasRole(String role) { ... }
        public boolean hasPermission(String permission) { ... }
    }

    record MenuItem(
            String key,
            String label,
            String icon,
            String path,
            String target,          // 默认 "_self"
            int order,              // 默认 100
            List<MenuItem> children,
            List<String> requiredRoles,
            List<String> requiredPermissions,
            Map<String, Object> metadata
    ) {}
}

实现一个提供者

@Extension
public class BillingMenuProvider implements MenuProviderExtension {

    @Override
    public String getMenuGroup() {
        return "main-sidebar";
    }

    @Override
    public boolean isActive(MenuContext context) {
        // 仅当当前用户持有 billing.invoice.view 权限时显示
        return context.hasPermission("billing.invoice.view");
    }

    @Override
    public List<MenuItem> getMenuItems(MenuContext context) {
        return List.of(
            MenuItem.builder()
                .key("billing-invoices")
                .label("Invoices")
                .icon("receipt")
                .path("/billing/invoices")
                .order(100)
                .requiredPermissions(List.of("billing.invoice.view"))
                .build(),
            MenuItem.builder()
                .key("billing-payments")
                .label("Payments")
                .icon("credit-card")
                .path("/billing/payments")
                .order(110)
                .requiredPermissions(List.of("billing.payment.view"))
                .build()
        );
    }

    @Override
    public int getOrder() {
        return 50; // 排在默认提供者之前
    }
}

三点说明:

  • @Extension 是必须的。 缺少该注解,pf4j 不会发现实现类,菜单项将静默缺失。
  • isActive 是守卫,而非过滤器。 用它提前跳过整个提供者,将单个条目的门控留给 MenuItem 上的 requiredPermissions
  • label 应为 i18n key 或经 LocalizedText 解析的字符串。 硬编码展示文本违反平台的 i18n 约束(参见插件清单)。

使用场景与规则

场景推荐做法
仅对特定权限用户可见的菜单isActive 调用 context.hasPermission(...)
在已有分组条目下嵌套子菜单在父 MenuItem 上设置 children
在新标签页打开的外部链接在条目上设置 target = "_blank"
menus.json 静态条目的排序关系使用 getOrder()——静态与动态条目统一按值排序
按租户差异化展示菜单(如不同授权模块)读取 context.tenantId()context.settings() 中的租户级标志
特定命名空间下禁用提供者isActive 中判断 context.namespace()

错误语义

getMenuItemsisActive 在请求范围的菜单构建过程中同步调用。平台用 catch 块包裹每次提供者调用——提供者抛出未处理异常时,该提供者本次贡献被抑制并记录警告,不会导致页面加载失败。这意味着若异常被吞没,可能出现静默失败。

不要从 getMenuItems 抛出受检异常。若必要依赖不可用,返回空列表并记录原因。不要在 getMenuItems 内访问数据库或发起远程调用——该方法必须快速且无副作用。

审计与关联

菜单提供者调用不单独记录到平台审计日志,因为不产生副作用。若提供者通过 context.settings() 读取了由平台服务支撑的功能开关,该服务自身的状态读取会记录在其对应的审计链中。

如需追踪哪个提供者贡献了哪条菜单项(例如排查菜单缺失),可在 MenuItemmetadata 中设置一个稳定的提供者标识键。平台会将 metadata 转发到前端渲染器,它将出现在 React DevTools 组件树中。

常见陷阱

模式为什么会出问题
遗漏 @Extensionpf4j 不发现该类;菜单项静默缺失
getMenuItems 内执行 I/O阻塞菜单构建请求路径;应在 isActive 中提前跳过,在插件启动时缓存上游数据
label 中硬编码展示文本违反 i18n 约束;在非默认语言环境下显示为原始字符串
getMenuItems 返回 null平台期望非 null 的 List;请返回 List.of()
menus.json 中已有的 key 重复导致重复菜单条目;请使用命名空间化的 key,如 billing-invoices 而非 invoices
仅在 getMenuItems 内检查权限而不在 isActive 中检查略有低效;整体守卫应放在 isActive,避免构建后被丢弃的条目列表

与其他 SPI 的关系

  • 插件清单menus.json 静态贡献 vs 通过此 SPI 动态贡献;无条件的条目优先使用静态声明。
  • 权限 — 菜单可见性在两层进行权限门控:提供者层的 isActive / requiredPermissions,以及平台渲染器中的权限强制执行。两者必须与已注册的权限码保持一致。
  • CommandHandlerExtension — 菜单项通常触发命令;条目上的 path 一般指向展示命令面板的页面。

企业版说明 — 基于订阅层级的租户级菜单权益门控(仅向特定订阅层的租户开放菜单组)、由组织层级驱动的角色动态菜单树,以及带队列分配的菜单 A/B 测试,均为商业版专属能力,构建于此 SPI 之上。社区版与企业版的 MenuProviderExtension 接口完全相同。

下一步

  • 插件清单 — 声明静态菜单条目,了解何时应选择动态提供者
  • 权限 — 注册 requiredPermissions 声明所依赖的权限码
  • CommandHandlerExtension — 实现菜单项导航到的命令