必须遵守的不变量
本文是 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-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 对象」, 注入与类型判断全部失效。
插件仓库父 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 模块
旧规则是「契约放 *-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 与接口重复注册。
启动器读的是系统属性,读不到 yml(它在 Spring 之前执行)。所以只能这样:
-Dapp-market.plugin-runtime.enabled=false只在 yml 里改会导致插件完全不加载(启动器让路、运行时又没开)。
子上下文、组件扫描、卸载顺序等细节见插件运行时。
5. 宿主看不到插件(Spring 父子关系是单向的)
插件子上下文以宿主为 parent,所以插件能注入宿主,宿主不能注入插件。 宿主侧要拿插件服务只有两条路:
| 做法 | 位置 | 特点 |
|---|---|---|
PluginServiceProvider.getPluginBean(appCode, type) | wujies-common-core | SPI,宿主侧通用 |
XxxServiceLocator 的 available(...) / get(...) | wujies-common-plugin-sdk | 每次调用都反查,装卸 / 升级都跟得上;现有 MemberServiceLocator / PayServiceLocator / ImServiceLocator |
插件之间是「类可见、bean 不可见」:兄弟的 Mapper 接口能直接注入
(PluginMapperRegistrar 把它注册进了宿主的 bean factory),兄弟的 Service bean 不能 ——
必须走定位器,并先 available(...) 判断是否安装(可选依赖要优雅降级;强依赖就让定位器把
「请到应用市场安装 XX」抛出去,比 NPE 清楚)。范例见跟着 hello 读源码。
哪怕是「转发代理」也不行。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。
两处配套,缺一不可(都在代码里,别「顺手简化」掉):
PluginHandlerMapping.registerController在注册任何路由之前调attachHostInterceptorsOnce(), 把宿主 mapping 的getAdaptedInterceptors()复制过来 —— ⚠️setInterceptors(...)之后还要显式initInterceptors(),只 set 不 init 不生效 (本 bean 启动时已经afterPropertiesSet过);AllUrlHandler的登录清单要同时收「宿主 mapping + 插件 mapping」,且PluginRuntimeManager在 load / unload 后调refresh()—— SaInterceptor 的登录检查是「清单里没有的路径直接跳过」。
修完之后的实测行为:插件接口不带 token → {"code":401};带 token → 200;
插件里 @SaIgnore 的开放接口仍匿名可用。
那会把插件类同时注册到宿主 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:globals | scripts/check-remote-globals.mjs | 产物里的裸全局(只对「宿主自动导入宇宙」里的名字报错) |
npm run check:remote | scripts/check-remote-apps.mjs | 模板组件能否解析、组件白名单是否真在 SHARED_COMPONENTS 里、产物里残留的 process.env |
npm run check:runtime | scripts/check-runtime-shims.mjs | 运行时垫片的导出面 —— 少一个具名导出,浏览器在链接期整块报错 |
npm run audit:menus | scripts/audit-menus.mjs | C 菜单的 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 |
机制三句话(都在代码里):
- 平台装了租户行拦截器(
PlusTenantLineHandler.ignoreTable+MybatisPlusConfig.mybatisPlusInterceptor,且必须排在第一位): 未列入security.tenant.excludes的表,SQL 会被自动补上tenant_id = '000000'; /open/**会自动设置租户:OpenApiTenantInterceptor对未登录请求取?tenantId=/X-Tenant-Id,都没有就用默认值000000(open-api.tenant.path-patterns的默认值含/open/**);- 所以匿名接口同样会触发租户条件 —— 别以为「匿名就没事」。
两条路,按数据语义二选一(不是按省事):
| 这条路 | 判据 | 要做什么 |
|---|---|---|
| 业务表(默认设计) | 数据按租户各存一份 | 实体 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_id | TenantEntity | varchar(20) DEFAULT '000000' |
create_dept / create_by | BaseEntity(FieldFill.INSERT) | bigint DEFAULT NULL |
create_time | BaseEntity(FieldFill.INSERT) | datetime DEFAULT CURRENT_TIMESTAMP |
update_by / update_time | BaseEntity(FieldFill.INSERT_UPDATE) | bigint / datetime DEFAULT NULL |
del_flag | 实体自己声明 + @TableLogic | int 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、功能静默降级。宿主侧替插件兜底的代码要问一句「插件装了没」 | 后端工程 |