主题
框架设计
这一页回答**"分成几块、谁依赖谁、为什么这么分"**。它不列目录(那是 仓库目录 的事),也不列硬约束(那是 必须遵守的不变量 的事)。
全文的技术断言都给了落点(路径 / 类名 / 配置键 / 建表脚本)。结构变了请重读文件系统再改这一页, 不要凭记忆补。
一句话
把上游 RuoYi-Vue-Plus 的"一套系统服务一个客户"形态改造成 平台 + 应用:平台提供多租户底座 与应用市场机制,业务能力做成可后装、不重启、从市场拉下来直接跑的应用;宿主前端退化成一层壳, 业务页面由应用在运行期交付。
术语:平台 / 宿主 / 宿主前端 / 应用
| 说法 | 指什么 | 判据 |
|---|---|---|
| 平台(后端) | wujies-platform/,Java / Spring Boot + SQL 脚本 | 有 pom.xml;产物 wujies-admin.jar |
| 宿主 | 运行插件的那一个 JVM 进程 —— 就是平台后端 | 插件子上下文的 parent 就是它 |
| 宿主前端 | plus-ui/,Vue 3 / Vite | 有 vite.config.ts / src/;package.json 的 name 是 wujies-admin |
| 应用 | wujies/wujies-plugins/wujies-<app>/,一个应用一个目录 | 含 app.json、migrations/、src/main/java/cn/wujies/<app>/ |
"宿主"与"平台后端"是同一份进程的两个视角:讲类加载器与 Spring 父子上下文时叫宿主, 讲多租户底座与市场机制时叫平台。两者不是两个进程。
上游那套旧前缀不要照写
本项目源自上游 RuoYi-Vue-Plus,但自己的模块名、包名一律是 wujies-* / cn.wujies.*。 源码或旧文档里出现上游的旧前缀与旧包名,那是历史遗留 —— 改的时候换成 wujies-* / cn.wujies.*。
一、三个仓库
| 仓库 | 内容 | 默认分支 |
|---|---|---|
wujies-platform | 后端(Java / Spring Boot)+ 平台 SQL 脚本 | feature/app-market |
wujies/wujies-plugins | 插件本体 + 插件前端工程(pnpm workspace)+ 打包器 + docs/ | master |
plus-ui | 宿主前端(Vue 3 / Vite) | feature/app-market |
三者没有共同 git 根,各自分支、各自提交、各自推送 —— 加一个应用经常要跨仓库提交两到三次。 目录级细节见 仓库目录。
⚠️ wujies-admin 这一个名字指三处不同地方(历史上已经因此误导过两次排查):
| 写法 | 实际指 | 判据 |
|---|---|---|
wujies-platform/wujies-admin/ | 后端启动模块(Java) | 有 pom.xml / src/main / Dockerfile;产物 wujies-admin.jar |
工作区顶层的 wujies-admin/ | 前端产物落位目录 | 里面只有 app-market-dist/,没有 package.json、没有 src/ |
plus-ui/ | 宿主前端仓库 | 有 vite.config.ts / src/;package.json 的 name = wujies-admin |
记法:路径里出现 src/plugins/**、src/views/**、app-market-dist/** → 说的是 plus-ui/; 出现 src/main/resources/**、wujies-admin.jar、mvn -pl wujies-admin → 说的是 wujies-platform/wujies-admin/。
二、三者各负责什么
| 平台(宿主) | 应用(插件) | 宿主前端(plus-ui/) | |
|---|---|---|---|
| 用户与权限 | 用户 / 角色 / 部门 / 岗位 / 租户 / 菜单的授权 | 只声明自己的权限标识(app.json 的 menus[].perms) | 按登录返回的菜单树渲染侧边栏 |
| 数据 | 平台表(sys_* / app_* …),并保护它们不被误删 | 自己的业务表,建表语句放 migrations/ | — |
| 加载与隔离 | 类加载器、子 Spring 上下文、独立的 PluginHandlerMapping、宿主拦截器链 | 只写普通 Spring 代码 | — |
| 生命周期 | 部署 / 安装 / 升级 / 卸载 / 停用的编排与任务记录(app_install_task) | 无感 | 装完 / 升级 / 停用后调 refreshMenus() 刷新菜单树 |
| 前端页面 | 一层壳:布局、登录、工作台 | 自己的视图,产出独立 ESM | 远程应用装载器 + 静态路由兜底 |
| 包安全 | 产物白名单 + 签名校验(app-market.security.mode,当前 enforce) | 发布时签名 | — |
关键类是单向的,别记反:
- 插件能注入宿主,宿主不能注入插件(插件子上下文以宿主为 parent)。 宿主侧要拿插件服务只有两条路:
PluginServiceProvider.getPluginBean(appCode, type)(wujies-common-core的cn.wujies.common.core.plugin.PluginServiceProvider) 或应用自己的XxxServiceLocator.proxy(...)(每次调用都反查)。 细节见 定位器 与 必须遵守的不变量。 - 宿主前端在构建期不可能知道租户装了哪些应用。菜单里的
component = remote:<appCode>/<视图键>只是视图键,入口 JS 由运行期问后端得到:AppRuntimeController的GET /app/runtime/remote-apps。见 前端远程应用。
三、为什么前端必须先改造
后端把业务做成插件还不够 —— 起点约束在前端。改造前,页面是构建期用 glob 静态打进来的 (今天仍是这条,见 plus-ui/src/store/modules/permission.ts 第 17 行):
ts
const modules = import.meta.glob('./../../views/**/*.vue');于是 sys_menu.component 只能指向构建时已存在的 .vue 文件,第三方页面在物理上无法热加载。 所以"应用外置"必须先把前端运行时改出来:让菜单能指向一份运行期才知道在哪的独立 ESM。
宿主前端为此做了两件事:
plus-ui/src/plugins/appmarket/里的装载器(loader.ts/bootstrap.ts/runtime.ts/menuRefresh.ts/edition.ts/appAvailability.ts)在运行期问后端要入口并执行;- 不靠菜单驱动的页面(详情页、编辑页这类静态路由)也收藏到同一条路:
plus-ui/src/router/index.ts里大量使用createRemoteViewComponent('<视图键>', '<appCode>'), 否则菜单能点开、手打 URL 却打不开。
四、契约随插件走:平台里没有 *-api 模块
这是决定整个仓库结构的一条约定,也是最容易照着旧文档写错的一条。
现状:平台里 wujies-modules/*-api 数量为 0;平台对 cn.wujies.<app>.* 的 Java 引用为 0 (只允许出现在注释里)。每个应用自包含:
| 内容 | 位置 |
|---|---|
| 实体 / BO / VO / 枚举 | wujies/wujies-plugins/wujies-<app>/src/main/java/cn/wujies/<app>/domain/** |
| Mapper 接口 与 Mapper XML | 同上的 mapper/ 包,与 src/main/resources/mapper/<app>/*.xml |
| Service 接口与实现 | 同上的 service/、service/impl/ |
| controller / open / listener / task | 同上的 controller/、open/、listener/、task/ |
| 清单与建表脚本 | 插件根目录的 app.json、migrations/*.sql |
怎么自己核验(本工作区实测)
powershell
# ① 平台里有没有 *-api 契约模块?(期望:没有任何输出)
Get-ChildItem .\wujies-platform -Recurse -Directory -Filter '*-api' |
Where-Object { $_.FullName -notmatch '\\target\\' }
# ② 平台源码有没有真的 import 应用包?(期望:只命中 docs\removed\ 下的存档)
Get-ChildItem .\wujies-platform -Recurse -Include *.java |
Where-Object { $_.FullName -notmatch '\\target\\' } |
Select-String -Pattern '^\s*import\s+cn\.wujies\.(gallery|exam|im|pay|quote|social|mall|member|enterprise|hello|finance)\.'实测(2026-10 工作区):① 无输出;② 命中全部落在 wujies-platform/docs/removed/ (已移出反应堆的 wujies-portal / wujies-openapi 存档源码),活代码里一条都没有。
由它派生的四条硬事实
| 事实 | 说明 |
|---|---|
| 新增业务模块不用动平台一行 | 只写插件。这是这条改造的全部意义 |
| 宿主拿插件服务靠反查 | 不依赖插件构件,走 PluginServiceProvider 或 SDK 里的 MemberServiceLocator / PayServiceLocator / ImServiceLocator(在 wujies-common-plugin-sdk) |
| 跨插件调用靠"依赖兄弟插件构件" | 买到的是类型可见,不是 bean 可见:兄弟的 Mapper 接口能直接注入(注册在宿主 bean factory),兄弟的 Service bean 不能 —— 必须走定位器。范例见 跟着 hello 读源码 |
平台依赖一律 provided | 打进插件就会"同一个接口两个 Class 对象",注入与类型判断全部失效 |
Mapper XML 随插件 jar 走,由代码注册 —— 别指望宿主的扫描配置
宿主 application.yml 里那行 classpath*:mapper/**/*Mapper.xml 是拿宿主类加载器扫 classpath: 插件 jar 里的 XML 它扫不到,把宿主自己的映射文件再注册一遍又会撞 Result Maps collection already contains value,宿主直接起不来。
所以插件 XML 由 PluginMapperRegistrar (wujies-platform/wujies-modules/wujies-app-market/.../plugin/PluginMapperRegistrar.java) 在插件加载时只读传入的那个 jar 动态注册进宿主 SqlSessionFactory。
另外:定时任务也随应用走(包落在 cn.wujies.<app>.task),宿主只提供 wujies-common-job; 跨插件的事件类必须放在宿主类加载器可见的位置(cn.wujies.common.core.domain.event), 跟着发布方插件的 jar 走就会"订阅方永远收不到、且不报错"。
五、平台侧的模块地图
wujies-platform/ 38 个 pom.xml(2026-10 实测,不含 target/)
├── pom.xml 反应堆根:<modules> 只有 4 个聚合模块
├── wujies-admin/ 启动模块 → wujies-admin.jar;PluginClassPathLoader 在这里
├── wujies-common/ 27 个子模块(基础设施)
│ ├── wujies-common-core/ PluginServiceProvider / MenuOwnershipProvider
│ ├── wujies-common-web/ GlobalExceptionHandler(异常 → 前端的唯一出口)
│ ├── wujies-common-plugin-sdk/ 插件扩展点契约(Platform*Api / *ServiceLocator)
│ ├── wujies-common-mybatis/ MyBatis-Plus 集成(BaseMapperPlus)
│ ├── wujies-common-job/ 定时任务宿主侧支持(任务实现随应用走)
│ └── …(其余 22 个:tenant / satoken / security / redis / oss / mail / sms / rabbitmq / …)
├── wujies-extend/ 旁挂服务,独立进程
│ ├── wujies-monitor-admin/
│ └── wujies-snailjob-server/
├── wujies-modules/ 只剩 4 个
│ ├── wujies-app-market/ ★ 应用市场:目录 + 安装编排 + 插件运行时 + 包安全
│ ├── wujies-system/ 系统管理(用户 / 部门 / 角色 / 菜单 / 字典 / OSS / 消息)
│ ├── wujies-workflow/ 工作流
│ └── wujies-plugin-probe/ 插件运行时探针
└── plugins-dist/ 插件 jar 落位目录 + active-plugins.txt| 要找什么 | 去哪 |
|---|---|
| 宿主给插件用的能力契约 | wujies-common/wujies-common-plugin-sdk/:PlatformUserApi / PlatformDeptApi / PlatformMessageApi / PlatformOssApi / FollowRelationApi,加三个定位器。索引见 平台 SDK 契约 |
| 宿主反查插件与菜单归属的 SPI | wujies-common-core 的 cn.wujies.common.core.plugin.PluginServiceProvider 与 cn.wujies.common.core.menu.MenuOwnershipProvider |
| 应用市场实现 | wujies-modules/wujies-app-market/,分包见下一节 |
| 插件 jar 落在哪 | 配置键 app-market.deploy.plugin-dir(当前值 plugins-dist),必须与启动参数 -Dloader.path 一致 |
| 前端产物落在哪 | 配置键 app-market.deploy.frontend-dir,该物理目录必须就是宿主 /remote-apps/ 的位置 |
| 本机启用哪些插件 | plugins-dist/active-plugins.txt(两个加载时机都按它选版本;读不到则不过滤) |
已删 / 已归档的模块不要再当现存列
wujies-job、wujies-demo、wujies-generator 已删除;wujies-portal、wujies-openapi 归档在 wujies-platform/docs/removed/,已不在反应堆里。看到引用它们的旧文档,按归档理解,别去 mvn -pl。
平台内置模块的逐个说明在 平台内置模块;想自己核实模块数量, Get-ChildItem .\wujies-platform -Recurse -Filter pom.xml | Where-Object { $_.FullName -notmatch '\\target\\' } 数一下即可。
六、应用市场在平台里的分包
wujies-modules/wujies-app-market 是整套机制的核心(目录 + 运行时),按职责分包:
| 包 | 职责 | 代表类 |
|---|---|---|
catalog/ | 目录访问的唯一入口;本地 / 远程两种实现 | IAppCatalogService、LocalAppCatalogService、RemoteAppCatalogService、AppCatalogApiController |
edition/ | 版本形态(官方版 / 用户版)与上架端闸门 | MarketEditionGuardFilter、MarketEditionStartupCheck |
plugin/ | 插件类加载器、子上下文、独立 HandlerMapping、运行时管理、Mapper 与事件注册 | PluginClassLoader、PluginApplicationContext、PluginHandlerMapping、PluginRuntimeManager、PluginMapperRegistrar、PluginEventRelay |
security/ | 包摘要规范化、白名单 + 验签、离线签名工具、签名就绪度自检 | PackageDigest、PackageSecurityInspector、PackageSignerMain、PackageKeys、SignatureReadinessService |
service/ | 生命周期编排、落位、迁移、菜单计划与同步、归属、卸载清理、清单校验 | AppInstallServiceImpl、AppPackageDeployService、AppMigrationService、AppMenuPlanner、AppMenuOwnershipProvider、AppPurgeService、AppManifestValidator |
spi/ | 安装器 SPI(module / plugin / deploy) | AppInstaller 及其实现 |
state/ | 应用生命周期状态机 | — |
support/ | 任务进度落库与卡死清扫 | AppTaskRecorder、AppTaskTimeoutSweeper |
controller/ | 市场浏览端、上架端、版本端、安装包端、安装端、运行时答复、工作台 | AppMarketController、AppConsoleController、AppVersionController、AppPackageController、AppInstallController、AppRuntimeController、AppWorkbenchController |
domain/ mapper/ enums/ constant/ util/ | 实体、Mapper、枚举、常量与工具 | — |
分包与关键类的完整说明见 应用市场模块;目录集中化与两个版本形态见 目录中心化与版本形态。
七、一次安装把三者串起来
| 步 | 谁 | 做什么 |
|---|---|---|
| 1 | 宿主前端 | 用户在 plus-ui 的应用市场页点「安装」 |
| 2 | 平台 | AppInstallServiceImpl 编排:AppPackageDeployService 拉包 → 校验签名与校验和 → 落位(jar 进 plugin-dir 根下,前端产物进 frontend-dir/<appCode>/) |
| 3 | 平台 | AppMigrationService 按文件名顺序执行该应用的 migrations/*.sql,把账记进 app_schema_history |
| 4 | 平台 | PluginRuntimeManager 建子上下文加载插件;AppMenuPlanner 按 menu_key 幂等 upsert 菜单 |
| 5 | 宿主前端 | 菜单里是 component = remote:<code>/<视图键>,装载器问 GET /app/runtime/remote-apps 拿到入口 JS 并执行 |
| 6 | 应用 | 自己的接口:管理端走 @SaCheckPermission、开放端写 /open/<code>/**;路由与鉴权都挂在宿主的拦截器链上(这一步不做,接口就是裸奔的) |
顺序上刻意保证 落位 → 变更表结构 → 切换代码:安装与升级走的是 AppPackageDeployService.deploy(versionId, false)(只落位),加载推迟到迁移之后。
全过程的任务、步骤与日志落在 app_install_task,在「我的应用 → 应用任务」里看 —— 排查问题先看那里,比翻服务端日志快。见 任务中心、 应用生命周期。