Wujies 无界平台
平台功能(自研)插件开发进阶

从宿主模块抽成应用(SOP)

这一页解决一个问题:已经在宿主里写好的业务模块,怎么搬成插件应用。

比从零写一个应用多两件事:改宿主消费方(宿主不能再编译期引用它)与存量迁移 SQL(已经装了这套系统的客户,菜单和数据要继续可用)。

范围先说清楚:抽模块不等于拆契约。当前架构下应用是自包含工程,不存在"先拆一个契约模块"这一步。看到"契约放 *-api""XML 必须放 *-api"的说法都是历史写法(2026-09-30 前的结构),见必须遵守的不变量第 2 条。

先读范例再动手

wujies/wujies-plugins/wujies-hello/ 是可运行的范例应用(后端 + frontend/hello-app/),四个演示点——调用宿主系统服务、跨插件调用、从插件开放 /open 接口、两种定时任务——都写成了能跑的代码。照着读一遍比看十页文档快,见跟着 hello 读源码。

现状:八个模块已完成

模块应用编码版本备注
报价管理quote1.0.1第一个走完全程的模块,步骤可直接复用
企业数据enterprise1.0.11.0.0 漏搬前端,1.0.1 才补回
会员管理member1.0.1菜单 68 行,必须生成而不是手写
支付管理pay1.0.0唯一带第三方 SDK 的模块
考试系统exam1.0.1
即时通讯im1.0.0同一 perms 在多行菜单重复出现,key 要父键限定
社交互动social1.0.1菜单要整棵树取,中间目录没有应用前缀
商城管理mall1.0.1原本 6 个 Maven 子模块,合并成一个 jar

另有纯前端应用 finance(财务管理):清单只写 assets.frontend,包里没有 jar,读的是 mall / member 现成的接口。

动手前的三点共识

  • 一个应用 = 一个自包含工程:实体 / BO / VO / Mapper 接口与 Mapper XML / Service 接口与实现 / controller / listener / task / app.json / migrations/ 全在 wujies/wujies-plugins/wujies-<code>/ 下。
  • 平台对 cn.wujies.<app>.* 的 Java 引用为 0。宿主消费方要拿插件服务,只能走定位器或事件,不能 import。
  • 平台依赖一律 provided:由宿主类加载器在运行期提供。父 POM wujies-plugins-parent 已在 dependencyManagement 里统一成 provided,插件自己的 pom.xml 里通常不必再写(wujies-hello/pom.xml 就是这种写法)。

十个步骤

#步骤关键点
1契约直接写进插件实体 / BO / VO / Mapper 接口 / Service 接口 / Mapper XML 全在 wujies-plugins/wujies-<code>/src/ 下。没有"先拆 *-api 进 reactor"这一步
2实现在同一个模块controller + service.impl + listener + task 与契约同模块;实测 jar 里只有实现类
3改宿主消费方删掉根 pom dependencyManagement、wujies-modules、wujies-admin 里对该模块构件的引用;调用点改成定位器 / 事件(见下节)
4写清单 app.json菜单树含 F 按钮,perms 与库里既有行逐条对齐(别自己发明);C 菜单 component 写 remote:<code>/<视图键>。字段含义见应用清单 app.json
5建表脚本 migrations/01-*.sql从库里 SHOW CREATE TABLE 导出 → 改成 CREATE TABLE IF NOT EXISTS。它同时是安装建表与彻底删除时删表的唯一依据
6前端 wujies-plugins/frontend/<code>-app调 frontend/build/vite.ts 的 defineRemoteApp({...});src/index.ts 返回 {manifest, views}。见前端远程应用
7打包签名在插件仓库根:.\package.ps1 -Plugin wujies-<code>(本机有 keys/private.pem 时自动签名)。见打包构建
8上架POST /app/package/parse(multipart,落 OSS 并回填校验和)→ POST /app/version → POST /app/version/{id}/publish
9存量迁移 SQLwujies-platform/script/sql/update/app_market_<m>_migrate.sql:应用登记改 plugin + 上架、菜单归属 seed、存量租户回填 enabled 安装实例。见下节
10验证见下节

三条边界:搬实现时哪些必须留在宿主侧

这是本页最容易做错的部分。"留宿主"的判据只有一条:这份东西的消费方可能是另一个插件或宿主本身,而它们看不见插件。

东西放哪为什么
实体 / BO / VO / Mapper 接口 与 Mapper XML / Service 接口与实现插件自己目录里应用自包含;平台对 cn.wujies.<app>.* 的引用为 0。XML 不再需要放宿主 —— 由 PluginMapperRegistrar 在插件加载时注册进宿主 SqlSessionFactory
平台 SPI 的实现宿主侧(wujies-common-*)消费方可能是另一个插件。插件的 parent 是宿主,看不见兄弟插件。样板:契约 MenuOwnershipProvider 在 wujies-common-core,实现 AppMenuOwnershipProvider 在 wujies-app-market
跨插件事件类宿主侧(wujies-common-core 的 cn.wujies.common.core.domain.event)订阅方按自己类加载器里那个 Class 对象匹配。事件类跟着发布方插件走 → 第二个 Class → 永远不匹配,且不报任何错
端点按归属拆:应用专属的进插件,平台泛化的留宿主见下

边界一:XML 随插件 jar 走,别绕回 mapperLocations

PluginMapperRegistrar 只读传入的那个 jar,刻意不用 classpath*:mapper/**/*Mapper.xml —— 那个写法是拿宿主类加载器扫 classpath,会把宿主的映射文件也捞一遍,再注册就撞 Result Maps collection already contains value,宿主直接起不来。

卸载时还要把 MyBatis StrictMap 里的短名一起摘掉,否则 MappedStatement 持有接口 Class、Class 持有类加载器,每装一次插件泄漏一个类加载器。这两条有回归测试 PluginMapperRegistrarTest 盯着。

边界二:跨插件事件类放 wujies-common-core

  • 插件 publishEvent 会冒泡到宿主的监听器,这是 Spring 自带行为;
  • 但反向不成立:宿主或兄弟插件发的事件不会自动进入某个插件子上下文。商城抽成插件时,MallPaySuccessListener 会静默失效(订单付完停在"待支付")——是 PluginEventRelay 才把宿主事件转发进各子上下文;
  • 事件类必须由宿主类加载器加载,现在的位置是 wujies-common-core 的 cn.wujies.common.core.domain.event(PaySuccessEvent / PayRefundSuccessEvent / UserRegisteredEvent / ProcessEvent 都在这里);
  • 回归测试 PluginEventClassLoaderTest 用两个真正独立的类加载器钉住这条:事件类一旦被挪回插件模块,测试就失败;
  • ⚠️ 事件里不要塞继承 TenantEntity 的实体(wujies-common-core 不能依赖 wujies-common-tenant,成环)。正确形态是扁平 POJO 只带标量,需要实体的一方自己按 ID / 单号重查。

边界三:端点的归属

宿主的 wujies-portal / wujies-openapi 已经被移除(归档在 wujies-platform/docs/removed/),端点现在是按归属分开放的:

类型放哪当前实例(/portal/** 与 /open/** 的全部归属)
单个应用专属的端点搬进插件,插件内直接注入自己的服务(少一跳定位器);插件没装时这些路由根本不存在,不会留下"能路由、一调就报没装"的空壳商城 PortalMallController(/portal/mall)、会员 PortalMemberController(/portal/member) 与 PortalMemberAddressController(/portal/member/address)、报价 PortalQuoteController(/portal/quote)、社交 PortalFollowController(/portal/follow)、企业 PortalEnterpriseCompanyController(/portal/enterprise/company)、考试 OpenExamController(/open/exam)、图床三个 Gallery*OpenController(/open/gallery/*)、商城两个 OpenMall*Controller(/open/mall*)
平台泛化的端点留在宿主(读的是平台表、没有任何应用业务逻辑,搬进 N 个插件就是把同一段代码抄 N 遍)OpenWebsiteConfigController(/open/{code}/config)、PortalUserProfileController(/portal/user)、OpenMessageController(/open/system/message),都在 wujies-modules/wujies-system
应用自己的公开配置模板插件提供 sys_config 行(config_category = 应用编码),端点仍是上面那个泛化模板新应用接进来不用改平台代码,见种子数据与站点配置

搬走的规矩:

  • 被搬的 DTO / 模块内服务要一起进插件(报价那轮搬了 4 个 DTO、MemberInfoVo、FollowUserVo、MobileMemberService 等),否则会逼出"插件反向依赖宿主业务包",那是最坏的方向;
  • 端点路径一个字节都不变,前端与第三方零改动;
  • 搬完必须确认 /open/** 的租户拦截器仍生效 —— 插件控制器挂在独立的 PluginHandlerMapping 上,只有挂上宿主拦截器链之后才拦得到;@SaIgnore(开放接口)与 @SaCheckLogin 也要在挂上链之后复核一遍;
  • 凡是宿主侧替插件兜底 / 代理的代码,都要先问一句"插件装了没"。样板:社交被彻底删除会 DROP social_follow,此时再去查表就报"表不存在",表现为图床报 500。做法是先问 PluginServiceProvider.isPluginLoaded("<code>"),没装就返回空集。把"没装"当成一种正常状态,而不是异常。
  • ⚠️ 平台 SPI 的实现仍然必须在宿主侧(本页"三条边界"第二行)。当前仓库里有一个反例:FollowRelationApi 的契约在 wujies-common-plugin-sdk,而实现 FollowRelationApiAdapter 却在 wujies-social 插件里,消费方是图床(GalleryImageServiceImpl 用 ObjectProvider<FollowRelationApi>)。按"插件只看得见父上下文"的不变量,图床在宿主里找不到这个 bean,拿到 null 后静默降级。搬模块时别照抄它:SPI 实现放宿主侧才是与不变量一致的做法。PluginServiceProvider 在 wujies-common-core(wujies-common-core/.../core/plugin/),isPluginLoaded(appCode) 与 getPluginBean(appCode, type) 都在那里。

第 3 步的细节:改宿主消费方

先删 *-api 引用,再谈调用点

当前架构里宿主的 dependencyManagement、wujies-modules、wujies-admin 本来就不该有 wujies-<m>-api 这一行。如果你在改造中途看到它,说明这份代码还是旧结构,直接删。

宿主拿插件服务的三种场景

场景做法
宿主代码要调插件 Servicecn.wujies.common.core.plugin.PluginServiceLocator 的 proxy(...)(返回每次调用都反查的代理),或 PluginServiceProvider.getPluginBean(appCode, type)
宿主只想"通知",不想编译期持有插件类型走事件:宿主发事件,PluginEventRelay 转发进插件子上下文。会员注册就是这么改的(UserRegisteredEvent)
插件内部(定时任务、controller 搬进来之后)直接构造注入,不必再绕定位器。定位器存在的理由是"宿主注入不到插件 bean",插件里没这个问题

⚠️ 绝对不要在宿主上下文里注册与插件实现同类型的 bean(哪怕是"转发代理"):Spring 按类型注入会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报 expected single matching bean but found 2。

Mapper 不用改,Service 才要

Mapper 接口与 XML 都在插件里,但接口是注册进宿主 bean factory 的,所以插件之间可以直接注入兄弟的 Mapper(HelloDemoCrossPluginController 就注入了 MemberMapper)。只有 Service 接口要走定位器。

⚠️ 代价要清楚:直接用兄弟的 Mapper 等于隐式依赖对方的数据表,表结构变了你先坏。只在确实要联合查询时才这么干。

排查注入点用对正则

查注入点用 I?Xxx\w*Service,别用 \w+ —— IMemberService 中间没有词,IMember\w+Service 会把它整个漏掉。漏掉的表现是运行期"找不到 bean",编译期不报。

定位器别抄

PluginServiceLocator 在 wujies-common-core;跨插件的定位器(MemberServiceLocator / PayServiceLocator / ImServiceLocator)在 wujies-common-plugin-sdk,插件不需要额外依赖就能用。各应用只写二十来行的薄包装。

第 4 / 5 步:清单与迁移脚本

  • 菜单多的时候要"生成"而不是手写(会员 68 行、商城 176 行)。app.json 与 seed SQL 的 menu_key 必须由同一份数据生成,手写两份必然漂移,而漂移的后果是升级时建出一套重复菜单。写法见菜单与权限。
  • menu_key 只能是小写 kebab:perms 里的 camelCase(member:member:adjustPoints)直接转 key 会在上传时被拒。做法是驼峰前插 - 再整体小写。
  • 同一个 perms 可能在多行菜单重复出现(im 那轮 19 个 perms 重复 2~3 次)。F 行 key 要用父键限定(im-workbench-<尾> / im-conversation-<尾>),并断言同版本内 key 唯一。
  • 菜单要"从根整棵取",别按 perms LIKE '<code>:%' + path 过滤:社交在根目录和页面之间有两个中间目录,既没有应用前缀的 perms、path 里也不含 social,过滤会把它们连同下面 11 个页面一起丢掉。用递归 CTE 从根往下取。
  • 建表脚本只做 up、必须幂等、执行过就不能改(改了会被 checksum 拒绝执行),四条硬规矩与文件命名见数据库迁移;字典 / 站点配置随包走的写法见种子数据与站点配置。
  • 库里存在、但代码里根本没有的菜单不要收进清单 —— 收了等于让全新安装也建出一堆点不开的菜单。
  • C 菜单可以指向宿主页面(component 写 dashboard/overview/index 而不是 remote:<code>/...)。这不是漏洞,是刻意的:那个页面本来就住在宿主。见应用清单 app.json。

第 9 步的细节:存量迁移 SQL

脚本模板:wujies-platform/script/sql/update/app_market_<m>_migrate.sql(enterprise / exam / gallery / im / mall / member / pay / quote / social 各一份,含回滚段与校验查询)。四件事:

  1. 应用登记改 plugin + 上架:app_type 必须是 plugin。module 类型的安装器只管注册菜单、不会加载 jar,那样"装上了但接口全是 404"。
  2. 菜单归属 seed:把清单里的 menu_key 映射到库里既有的 menu_id。不先种进去,同步会给这些菜单再建一套重复的。
  3. 存量租户回填 enabled 安装实例。
  4. 校验查询:脚本末尾的校验段要留,尤其"有映射但没有已启用安装实例的租户应为空"这一条。
第 2 步与第 3 步必须在同一个脚本里

菜单可见性的判据是「有归属映射,但没有已启用的安装实例 → 隐藏」。只写映射不写安装实例,菜单会立刻消失。

存量部署上「安装」走不通

迁移脚本回填的状态是 enabled,而状态机里 enabled → installing 非法(实测报「非法的应用状态迁移」);升级到同一版本也会被拒(「当前已是该版本」)。所以落地只有两条路:

  1. 在上架端点**「部署」**(落位 + 热加载);
  2. 发布一个更高版本后升级。

给客户交付时建议直接发布 1.0.1,让"升级"成为自然路径。

回填「已安装」不等于本机有产物

菜单来自数据库,路由来自 jar。本机 plugin-dir 里没有那个 jar 时,前端点进去就是整片 404(实测:plugins-dist 里只有 gallery,/member/member/detail/user/1 报"请求地址不存在",而 /gallery/image/list 正常)。

补产物走「部署」;对已经在运行的实例它是热加载,不用重启。

菜单同步只在 install / upgrade 里跑

回填的部署从没跑过菜单同步,于是抽离时改过的 component(宿主路径 → remote:<code>/<视图键>)会永远停在库里,前端表现为 [Vue Router warn]: Record with path "..." is either missing a "component(s)"。

补法:跑一条幂等的修正 SQL,或让该应用升级一次。

授权不在清单能表达的范围内

安装只把菜单授给安装者所属角色。原本"谁有底层数据权限谁就能看到页面"的设计要靠补一条授权 SQL 还原(样板:script/sql/update/finance_menu_grants.sql,幂等,按 menu_key 定位菜单)。

第 10 步:验证什么

  • 部署报告里 hotLoaded=true,hotLoadNote 里有组件与接口数(实测形如「插件 quote 已加载:组件 4 个,接口 16 个」)
  • 插件自己的接口能返回 200 且有真实数据
  • 宿主消费方也要打一次 —— 找那个走定位器代理的 portal / 业务接口直接调,拿到真实数据说明注入点真的通了;若报「应用『xxx』未安装或未启用」,说明定位器或插件加载有问题
  • sys_menu 里这个应用的菜单行数不变(没有重复建)。升级路径的菜单同步是幂等的:新版本映射与旧版本应逐条同 menu_id
  • 插件接口不带 token 要 401,带 token 才是 200(证明拦截器链挂上了,见必须遵守的不变量第 7 条)
  • 被搬进插件的 /open/** 端点匿名仍是 200,租户拦截器日志照旧打印
  • 前端页面能打开、样式正常、K* 组件不是"只剩文字"
  • 在宿主前端仓库跑 npm run audit:menus,没有新增死菜单
  • 改过 .vue 的话跑 npm run check:globals(自动导入宇宙的守门脚本)
  • 只验证到编译通过时,要说清"只验证了编译"

六个真实踩过的坑

1. 前端要么跟着搬,要么把菜单一起删掉

留一条指向"本分支根本没有的页面"的菜单,是最难发现的一类坏数据 —— 它由应用清单声明,所以全新安装也会照建。

实测:enterprise 被当成"纯后端应用"(清单里没有 assets.frontend,菜单里那行 C 的 component 还是宿主路径),那个菜单永远点不开,控制台报「菜单的 component 解析不出来,已跳过该路由」。1.0.1 才补回前端并走升级修正。

纯前端应用是合法形态:finance 清单只写 assets.frontend、不写 assets.backend,package.ps1 会跳过后端构建与 jar 组装(不需要 JDK / Maven,但签名仍要 JDK,签名工具是 Java 写的)。

2. "多子模块模块"必须合并成一个 jar

一个应用 = 一个插件 jar = 一个子上下文。商城原本是 6 个 Maven 子模块,合并成一个 wujies-plugins/wujies-mall 单 jar;子模块之间的调用在插件内就是普通注入。原来那 5 个 META-INF/spring.factories 随之删掉 —— 插件运行时是按注解扫 jar 条目注册 bean,不看 spring.factories。

3. 插件的 RabbitMQ 队列声明要写在宿主

子上下文里声明的 Queue / Exchange / Binding,宿主的 RabbitAdmin 看不见、不会声明到 broker,表现是"插件加载成功、消息发不出去 / 消费者起不来"。图床、社交、商城的队列声明当初就是这么搬进宿主的 wujies-common-rabbitmq(.../rabbitmq/config/RabbitMQConfig.java),插件侧只留 @RabbitListener。判定标准:队列 / 交换机 / 绑定关系写宿主,消费逻辑写插件。

反过来,插件的 @RabbitListener / @Scheduled 本身没问题:运行时会给子上下文补上必要的基础设施(注册的是 BeanPostProcessor 本身,所以插件里不需要写 @EnableScheduling)。wujies-hello 的 HelloDemoScheduledTask 是可直接对照的最小例子。

4. 同一个应用别留下两个版本的 jar

留下的旧版本删不掉时,下次重启会因重复 mapper XML 直接起不来。本机已有旧版本的(例如库里 1.0.5、本机 1.0.3)要么先停宿主再部署,要么部署后删旧 jar 再重启。wujies-platform/script/prune/start-host.ps1 会先跑同目录的清理脚本,并用 active-plugins.txt 记录运行期真正加载的那一个。

5. 控制台「部署」不改安装记录的 version_id

「部署」只做落位 + 热加载。落位新版本时会清掉旧版本的 jar,于是「部署 1.0.1 → 停用 → 启用」会报「插件 jar 不存在: …-1.0.0.jar」(安装记录还指向 1.0.0)。要动安装记录就走升级:POST /app/install/{installId}/upgrade?versionId=…。

6. 改后端代码后必须先重建宿主 jar 再验证

只跑 mvn compile(哪怕成功)不会更新 wujies-admin.jar,你测的是旧代码,很容易得出"功能没生效"的错误结论然后去改本来是对的实现。另外不要在别人的 JVM 活着时跑 mvn install(会清空输出目录,正在运行的 JVM 抛 NoClassDefFoundError);新增测试必须带 @Tag,否则根 pom 的 surefire <groups> 会让它根本不执行,症状是看着全绿、总数不变。

相关阅读

目录