Wujies 无界平台
插件开发basics

必须遵守的不变量

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

这一页解决一个问题:改代码之前,哪些约定不能碰。 下面每一条都是踩过坑换来的硬约束, 而违反之后的表现几乎都是构建通过、上传通过、运行期静默失效 —— 所以不能靠编译器兜住。

用法:

你在做什么先读
起一个新插件、写依赖第 1、2 条
装 / 升级 / 热加载出问题第 4、6、7 条
远程前端装载失败第 8 条
把宿主模块抽成应用第 2、3、9、10 条,以及抽取 SOP

约束的权威版本是 wujies-platform/AGENTS.md §2(本页是它的站点版,并补上了对应源码位置); 两边不一致时以那份为准。

先认清三个目录

目录是什么判据
wujies-platform/后端(Java / Spring Boot)+ SQL 脚本有 pom.xml、wujies-admin.jar
wujies/wujies-plugins/插件本体 + 插件前端工程(pnpm workspace)+ 打包器 package.ps1一个应用一个 wujies-<app>/
plus-ui/宿主前端(Vue 3 / Vite)有 vite.config.ts / src/
wujies-admin 这个名字在三处含义不同
  • wujies-platform/wujies-admin/ —— 后端启动模块(有 pom.xml / src/main,产物 wujies-admin.jar);
  • 工作区顶层的 wujies-admin/ —— 里面只有 app-market-dist/,是前端产物的落位目录;
  • plus-ui/package.json 的 name 也是 wujies-admin —— 这就是混淆的根源,别被名字骗了。

判据:指向 src/plugins/**、src/views/**、app-market-dist/** 的都是前端 = plus-ui/。

先按症状查

症状多半违反了详细排查
ClassCastException: X cannot be cast to X、instanceof 莫名返回 false、No qualifying bean第 1、2、5 条后端工程
自定义 SQL 报 Invalid bound statement (not found)第 2 条故障排查
服务起不来,报 Result Maps collection already contains value第 2、6 条故障排查
插件接口不带 token 也能拿到真实数据第 7 条插件运行时
菜单点开是「远程视图不可用」,控制台 ReferenceError: xxx is not defined第 8 条前端远程应用
装完没有按钮,或「彻底删除」后按钮变成孤儿第 3 条菜单归属与保护
升级被「迁移脚本内容已被修改(摘要不一致)」挡住第 10 条数据库迁移
Unknown column 'tenant_id' in 'where clause',且只在本插件"真查那张表"的接口上出现第 10.1 条数据库迁移
安装记录是 enabled,接口却整片 404第 4、6 条故障排查

1. 平台依赖一律 provided

插件 jar 里的所有平台依赖 —— Spring / MyBatis-Plus / wujies-common-* / wujies-common-plugin-sdk, 以及兄弟插件构件 —— 一律 provided:

<dependency>
    <groupId>cn.wujies</groupId>
    <artifactId>wujies-common-core</artifactId>
    <scope>provided</scope>
</dependency>

理由:这些类由宿主的类加载器在运行期提供。打进插件就会出现「同一个 FQCN 两个 Class 对象」, 注入与类型判断全部失效。

子模块里通常不用逐个写 scope

插件仓库父 pom cn.wujies.plugins:wujies-plugins-parent 的 dependencyManagement 已经统一设成 provided,所以 wujies/wujies-plugins/wujies-hello/pom.xml 里只有兄弟插件构件显式写了 provided。

但你得知道它必须是 provided —— 一旦有人把它改成 compile,症状是「注入报找不到 bean / instanceof 莫名返回 false」,很难查。

别去动

插件 pom 里的这个属性保留但当前无人在用(它原本用于从平台已废弃的 *-api 解包契约), 默认值是哨兵 none,靠「属性 != none」激活 —— 空值会让 Maven 解包全部依赖, 实测把 BaseMapperPlus 这类平台类打进了插件 jar(正好违反本条)。

2. 契约随插件走,平台里没有 *-api 模块

这一条在 2026-09-30 被整个推翻

旧规则是「契约放 *-api、实现放 plugins/*」。现在不成立,别再照着走回头路。

现在是:

  • 平台里 wujies-modules/*-api 数量为 0;平台对 cn.wujies.<app>.* 的 Java 引用为 0 (只允许出现在注释里);
  • 每个应用是自包含的:实体 / BO / VO / Mapper 接口 / Mapper XML / Service 接口与实现 / controller / open / listener / task / app.json / migrations/ 全在 wujies/wujies-plugins/wujies-<app>/ 下;
  • 插件之间互相要对方的契约 → 依赖兄弟插件构件(provided)。 例:wujies-hello/pom.xml 依赖 wujies-member-plugin;
  • 宿主要用插件的服务 → 不依赖插件构件,走 SDK 契约 + 定位器(见第 5 条);
  • 新增业务模块不需要动平台一行:只写插件。这是这次改造的全部意义。

⚠️ 依赖兄弟构件买到的是类型可见,不是 bean 可见。 HelloDemoCrossPluginController 的类注释把这两层拆得很清楚 —— 见跟着 hello 读源码。

Mapper XML 随插件 jar 走,由 PluginMapperRegistrar 在插件加载时动态注册进宿主 SqlSessionFactory:

  • 它只读传入的那个 jar,刻意不用 classpath*:mapper/**/*Mapper.xml —— 那个写法是拿宿主类加载器扫 classpath,会把宿主自己的映射文件也捞一遍, 再注册就撞 Result Maps collection already contains value,宿主直接起不来;
  • 注销时必须连 MyBatis StrictMap 里的短名一起摘,否则 MappedStatement 仍持有接口 Class、Class 持有类加载器 → 每装一次插件泄漏一个类加载器;
  • 注册前还会按包名前缀扫除、并强制重新解析 —— 卸载侧那套「差集记账」不可靠, 实测出现过「注销 292 条、下一轮只注册出 4 条」。 推导见工作区根目录的 docs/开发问题与解决方案.md §2.1(该文件不在本站内)。

实现位置:wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/plugin/PluginMapperRegistrar.java。 XML 放 src/main/resources/mapper/<app>/*.xml,写法见后端工程。

3. 菜单只能通过清单 + AppMenuPlanner 注册

不要手写 INSERT INTO sys_menu。菜单走 app.json 的 menus → 安装 / 升级时 AppMenuPlanner 幂等 upsert,按 menu_key 复用既有 menu_id(租户已分配的权限不能失效)。

清单里的字段叫 key,落到 app_version_menu 的列叫 menu_key。三条容易漏的:

要求漏了的后果
type: "F" 的按钮也要写进清单(key 唯一)全新部署装完没有按钮(v-hasPermi 过不了);「彻底删除」也删不掉它们
C 菜单的 component 写 remote:<appCode>/<视图键>悬空菜单 —— 由清单声明,所以全新安装也会照建
key 只能是小写 kebab(2–63 位)上传时就被校验器拒绝(perms 里的 camelCase 要转成 kebab)

菜单行落库之后由平台保护:改 path / component / perms / menuType / parentId 与删除会被 SysMenuServiceImpl 拒绝,改名称 / 图标 / 排序 / 显隐放行 —— 见菜单归属与保护。

存量部署的菜单漂移(例如抽离时改过的 component 停在库里、页面报 Record with path ... is either missing a "component(s)")只有两条路: POST /app/install/{installId}/sync-menus(权限 app:install:edit),或让该应用升级一次 —— 菜单同步只在 install / upgrade 里跑。细节见菜单与权限。

4. 两套插件加载机制不能同时启用

机制实现触发类加载器
启动期加载器(兼容)PluginClassPathLoader(在 wujies-admin)JVM 启动瞬间扫 plugins-dist一个共享的
插件运行时(默认)PluginRuntimeManager(在 wujies-app-market)启动期 + 运行期每插件独立

开关是 app-market.plugin-runtime.enabled,代码默认 true。 两者同时生效 → 插件类被加载两次 → bean 与接口重复注册。

必须用 JVM 参数切换

启动器读的是系统属性,读不到 yml(它在 Spring 之前执行)。所以只能这样:

-Dapp-market.plugin-runtime.enabled=false

只在 yml 里改会导致插件完全不加载(启动器让路、运行时又没开)。

子上下文、组件扫描、卸载顺序等细节见插件运行时。

5. 宿主看不到插件(Spring 父子关系是单向的)

插件子上下文以宿主为 parent,所以插件能注入宿主,宿主不能注入插件。 宿主侧要拿插件服务只有两条路:

做法位置特点
PluginServiceProvider.getPluginBean(appCode, type)wujies-common-coreSPI,宿主侧通用
XxxServiceLocator 的 available(...) / get(...)wujies-common-plugin-sdk每次调用都反查,装卸 / 升级都跟得上;现有 MemberServiceLocator / PayServiceLocator / ImServiceLocator

插件之间是「类可见、bean 不可见」:兄弟的 Mapper 接口能直接注入 (PluginMapperRegistrar 把它注册进了宿主的 bean factory),兄弟的 Service bean 不能 —— 必须走定位器,并先 available(...) 判断是否安装(可选依赖要优雅降级;强依赖就让定位器把 「请到应用市场安装 XX」抛出去,比 NPE 清楚)。范例见跟着 hello 读源码。

绝对不要在宿主上下文里注册与插件实现同类型的 bean

哪怕是「转发代理」也不行。Spring 按类型注入走 beanNamesForTypeIncludingAncestors, 会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报 expected single matching bean but found 2。

6. 同一个 appCode 名下只留一个版本的 jar

两份同样的 Mapper XML → MyBatis 抛 Result Maps collection already contains value → 服务直接起不来(已实测复现)。

两个加载时机现在都按 active-plugins.txt 选版本:

时机加载者选版本?
运行期(部署 / 热加载 / 启动时的插件运行时)PluginRuntimeManager选:只加载清单登记的那个
JVM 启动瞬间PluginClassPathLoader选:同样按清单过滤;读不到清单时退回「全挂」

启动期那道过滤是 2026-10-02 才补上的(见 PluginClassPathLoader.listJars 的类注释): 此前是「目录下所有 jar 全挂」,残留 jar 会被启动期加载器永久占住文件句柄 —— 表现为「彻底删除删不掉、重装覆盖到被打开的文件上读到坏字节」。

别把「过滤」当成可以不清理

清单缺失、或你手工往插件目录丢过 jar 时,「全挂」行为就会回来 —— 两个版本一起挂上, 就是上面那个起不来的错。启动脚本 script/prune/start-host.ps1 会先跑同目录的 prune-plugins.ps1 清掉非生效版本,用它启动。

插件目录只扫根下的 jar、不递归子目录 —— 所以迁移脚本放的 migrations/ 子目录 不会被当插件加载(见第 10 条)。

7. 插件路由必须挂上宿主的拦截器链

插件控制器挂在独立的 PluginHandlerMapping(order=10)上,而宿主的拦截器是通过 WebMvcConfigurer#addInterceptors 注册的 —— 那批只挂到框架自己的 mapping 上; 父类只会在上下文里存在 MappedInterceptor bean 时自动挂,而宿主没有那种 bean。

不管这件事,插件接口就是完全裸奔的:修复前实测,不带 token 调 GET /mall/product/list 直接返回真实商品数据,而同样带 @SaCheckPermission 的宿主接口返回 401。

两处配套,缺一不可(都在代码里,别「顺手简化」掉):

  1. PluginHandlerMapping.registerController 在注册任何路由之前调 attachHostInterceptorsOnce(), 把宿主 mapping 的 getAdaptedInterceptors() 复制过来 —— ⚠️ setInterceptors(...) 之后还要显式 initInterceptors(),只 set 不 init 不生效 (本 bean 启动时已经 afterPropertiesSet 过);
  2. AllUrlHandler 的登录清单要同时收「宿主 mapping + 插件 mapping」,且 PluginRuntimeManager 在 load / unload 后调 refresh() —— SaInterceptor 的登录检查是「清单里没有的路径直接跳过」。

修完之后的实测行为:插件接口不带 token → {"code":401};带 token → 200; 插件里 @SaIgnore 的开放接口仍匿名可用。

验证这类改动时不要再叠 -Dloader.path

那会把插件类同时注册到宿主 mapping 上,卸载摘不掉它们,表现为「停用了接口还能用」。

开放接口(/open/<app>/**)走的是同一条链:类上加 @SaIgnore,前缀本身也在宿主白名单里, 租户由 open-api.tenant.pathPatterns 的 /open/** 默认值设进上下文。 范例见 hello 的 HelloDemoOpenController(跟着 hello 读源码)。

8. 远程应用的自动导入清单必须与宿主逐项一致

远程产物里混着宿主源码(@/store/**、@/utils/**、@/api/system/** 按别名原样打进产物), 而宿主开了 unplugin-auto-import(plus-ui/vite/plugins/auto-import.ts: vue / vue-router / pinia / @vueuse/core + Element Plus resolver), 所以宿主源码里 ref(...) / useStorage(...) 这些没有 import 语句。

插件工程少列一个 preset,它们在产物里就是裸全局 —— 构建期不报错(Rollup 当它是全局变量), 一装载就 ReferenceError。实测:im 的 ImageUpload 用 @/utils/propTypes、 @/store/modules/user 又带进宿主 src/utils/auth.ts(用的是 @vueuse/core 的 useStorage), im 工程没列 @vueuse/core → ReferenceError: useStorage is not defined → 整个应用装载失败,菜单点开是「远程视图不可用」。

守门脚本都在宿主前端仓库 plus-ui/ 里跑:

命令脚本拦什么
npm run check:globalsscripts/check-remote-globals.mjs产物里的裸全局(只对「宿主自动导入宇宙」里的名字报错)
npm run check:remotescripts/check-remote-apps.mjs模板组件能否解析、组件白名单是否真在 SHARED_COMPONENTS 里、产物里残留的 process.env
npm run check:runtimescripts/check-runtime-shims.mjs运行时垫片的导出面 —— 少一个具名导出,浏览器在链接期整块报错
npm run audit:menusscripts/audit-menus.mjsC 菜单的 component 在产物或宿主里能不能解析出来

三条同源的坑:

  • 别在插件里写「薄垫片」绕 SDK:@/utils/request 直接指向 @dromara/request (已具名导出 globalHeaders / download / isRelogin),并且必须用正则精确匹配 —— 字符串 alias 是前缀匹配,会把 @/utils/requestCache 一起吃掉;
  • 每个前端工程的 vite.config.ts 都要有 define: { 'process.env.NODE_ENV': JSON.stringify('production') }: 浏览器没有 process,而依赖在模块顶层读它 → 一装载就 ReferenceError: process is not defined;
  • el-* 要解析成裸 element-plus,不要用 ElementPlusResolver —— 它生成子路径导入、不在 external 列表里 → 会把第二份 Element Plus 打进产物。

这些逻辑现在只有一份:插件前端工程调共享工厂 wujies/wujies-plugins/frontend/build/vite.ts 的 defineRemoteApp({...}),各应用只声明真正不同的东西(appCode、组件白名单、额外别名)。 完整清单见前端远程应用。

9. 搬模块时前端要跟着走

留一条指向「本分支根本没有的页面」的菜单,是最难发现的一类坏数据 —— 它由应用清单声明,所以全新安装也会照建。enterprise 与商城的「财务管理」都这么踩过。

  • npm run audit:menus -- <菜单导出文件> 一次查全悬空的 component;
  • 宿主 src/router/index.ts 里的静态路由(编辑页、详情页)要改成 createRemoteViewComponent('<视图键>', '<appCode>'),与菜单驱动的加载走同一条路;
  • api/ 包装层不是页面,别跟着视图一起搬走 —— 搬之前先 grep 一遍宿主内的引用。

完整步骤见从宿主模块抽成应用。

10. 已执行过的迁移脚本不能改

账本判据是 (app_code, script):同名脚本再来时比对 checksum,不一致直接报错拒绝执行。 要变更就新增脚本(02-xxx.sql,幂等),让全新安装走 01、已有部署走 02。

其余几条同样硬:

规矩说明
只做 up,不回滚app.json 的 migrations(含 down)是预留字段,当前不参与执行
脚本必须幂等MySQL 的 DDL 隐式提交,没法整体回滚;用 CREATE TABLE IF NOT EXISTS、加列前先判断
去重判据不带租户表结构在所有租户间共享;读写账本走 TenantHelper.ignore(...)
账本唯一键必须正好是 (app_code, script)写成 (tenant_id, app_code, version) 时,一个版本里只要有第二个脚本就会撞唯一键、安装 / 升级直接失败(存量库修法:script/sql/update/app_market_schema_history_fix.sql)

两条只有做过才知道的:

  • 执行是「目录驱动」的(扫描落位目录,不解析 app.json),所以落位前会先清空该应用的 迁移目录 —— 删掉 / 改名过的旧脚本不会变成「幽灵脚本」在下次安装时又跑一遍;
  • 摘要按字节算,checksumMatches 同时认 LF / CRLF 两种摘要(记账统一存 LF 归一化摘要): 换行差异容忍,真正的改动照旧拒绝。别为此去「统一」迁移目录的换行。

建表语句必须随包走:它是安装建表的依据,也是「彻底删除」时删哪些表的唯一依据 (表名从脚本里的 CREATE TABLE 解析:先剥注释、只认 CREATE TABLE, 且命中 sys_ / app_ / flow_ / snailjob_ 等平台前缀一律跳过)。

⚠️ 改过已执行脚本的存量部署,拿到新数据的唯一路径是「先跑修复脚本 (如 script/sql/update/app_market_app_seed_split_repair.sql)→ 再升级 / 重新部署」—— 迁移脚本只在 install / upgrade 里跑,光把包传上去不会有任何变化。

完整推导见数据库迁移与种子数据与站点配置。

10.1 插件自有表:带 tenant_id,或明确声明为全局表

症状(原样记下来,便于以后搜)
{"code":500,"msg":"Unknown column 'tenant_id' in 'where clause'","data":null}

它最坑的地方是只在真的查那张表的接口上出现 —— 同一个插件的其它接口可以完全正常 (不查库的、或查的是平台本来就有 tenant_id 的表),所以第一反应总是「接口写错了」, 而真相是「表结构与基类不对齐」。线上实测(hello 1.0.0):

线上接口结果为什么
GET /open/hello/config✅ 200读平台的 sys_config(本来就有 tenant_id)
GET /open/hello/greet✅ 200不查库
GET /open/hello/item/list❌ 500 Unknown column 'tenant_id' in 'where clause'只有它真查了 hello_item

机制三句话(都在代码里):

  1. 平台装了租户行拦截器(PlusTenantLineHandler.ignoreTable + MybatisPlusConfig.mybatisPlusInterceptor,且必须排在第一位): 未列入 security.tenant.excludes 的表,SQL 会被自动补上 tenant_id = '000000';
  2. /open/** 会自动设置租户:OpenApiTenantInterceptor 对未登录请求取 ?tenantId= / X-Tenant-Id,都没有就用默认值 000000 (open-api.tenant.path-patterns 的默认值含 /open/**);
  3. 所以匿名接口同样会触发租户条件 —— 别以为「匿名就没事」。

两条路,按数据语义二选一(不是按省事):

这条路判据要做什么
业务表(默认设计)数据按租户各存一份实体 extends TenantEntity;建表脚本里带 tenant_id + BaseEntity 的全部公共列 + del_flag
全局表数据是平台级、中心一份(像 app_info / app_version / app_version_route 那样,插件 jar 只有一份、与「谁装的」无关)把表名加进宿主 wujies-admin/src/main/resources/application.yml 的 security.tenant.excludes;实体用 BaseEntity 或不继承,读写处另套 TenantHelper.ignore(...)

逐列对齐表(HelloItem extends TenantEntity extends BaseEntity,照它抄):

列来自类型 / 默认值
tenant_idTenantEntityvarchar(20) DEFAULT '000000'
create_dept / create_byBaseEntity(FieldFill.INSERT)bigint DEFAULT NULL
create_timeBaseEntity(FieldFill.INSERT)datetime DEFAULT CURRENT_TIMESTAMP
update_by / update_timeBaseEntity(FieldFill.INSERT_UPDATE)bigint / datetime DEFAULT NULL
del_flag实体自己声明 + @TableLogicint DEFAULT 0(删除 = 1)

BaseEntity 里另有 searchValue 与 params,都是 @TableField(exist = false) ⇒ 不落列。 审计字段的类型以基类为准(Date),实体里不要再手写 createTime / LocalDateTime。

反向的坑同样存在

表在排除清单里、实体却继承了 TenantEntity 时,tenant_id 不会被自动填充(列会是空的)。 全局表请用 BaseEntity(或不继承基类),并套 TenantHelper.ignore(...) 读写。

范例:wujies/wujies-plugins/wujies-hello/ 的 domain/HelloItem.java 与 migrations/01-create-hello-tables.sql(1.0.1 起按这条路改正); 继承写法照 wujies-gallery 的 GalleryImageCategory,别自创。

11. 不要在正在运行的 JVM 上跑 mvn install

maven-compiler-plugin 会先清空输出目录再全编,正在运行、按需懒解析类的 JVM 会抛 NoClassDefFoundError。真实发生过。

两条配套的验证纪律:

  • 改了后端代码,必须重建 wujies-admin.jar 再验证 —— 只跑 mvn compile(哪怕成功) 不会更新那个 fat jar,你会测到旧代码,然后去改本来是对的实现;
  • 改了注解 / 常量 / 静态初始化这类东西,验证前先 -pl <模块> clean install —— 增量编译按时间戳判断是否需要重编,源码与上一次的 class 落在同一秒内会被判定「已是最新」 (实测:@NotBlank(groups = ...) 改了分组,install 打出 BUILD SUCCESS,jar 里还是旧注解)。

12. 新增测试必须打标签

根 pom 的 surefire 用 profiles.active 作为 groups 的值。没打 @Tag("dev") / @Tag("local") / @Tag("prod") 的测试不会被执行 —— 看着全绿,其实没跑。

照抄现有测试的三个标签,并且新增后核对一下 Tests run 总数。

另外几条同样会静默出错的

这几条不在 AGENTS §2 里,但违反之后同样是「构建通过、运行期才发现」:

主题硬约束细节
跨插件事件类必须由宿主类加载器加载(cn.wujies.common.core.domain.event)。跟着发布方插件的 jar 走,就会出现两个 Class 对象,订阅方永远收不到、且不报任何错。事件里别塞继承 TenantEntity 的实体(common-core 不能依赖 common-tenant,成环),用只带标量的扁平 POJO后端工程
事件方向插件 publishEvent 会冒泡到宿主;反向不成立 —— 宿主 / 兄弟插件发的事件由 PluginEventRelay 转发进各插件子上下文插件运行时
定时任务随应用走(cn.wujies.<app>.task),宿主只提供 wujies-common-job。@Scheduled 不用自己写 @EnableScheduling;SnailJob 的 @JobExecutor 光有注解不会跑,还要在调度中心建任务(执行器名必须与注解完全一致),且日志要用 SnailJobLog.REMOTE 才能在控制台看到跟着 hello 读源码
RabbitMQ 队列声明Queue / Exchange / Binding 要写在宿主(wujies-common-rabbitmq)—— 子上下文里声明的,宿主的 RabbitAdmin 看不见、不会声明到 broker。插件的 @RabbitListener 本身没问题插件运行时
端点归属单个应用专属的端点搬进插件(没装时这些路由根本不存在);跨应用聚合、带共享基类的配置端点留在宿主。搬完路径一个字节都不变从宿主模块抽成应用
平台 SPI 的实现消费方可能是另一个插件时,实现必须放宿主侧(wujies-common-*)—— 插件看不见兄弟插件,放进插件里对方拿到 null、功能静默降级。宿主侧替插件兜底的代码要问一句「插件装了没」后端工程

相关阅读

目录