核心概念
这一页解决的是**"东西该放哪、谁看得见谁"**的问题。平台里绝大多数"看不懂"和"改出 bug", 根源都是把下面几组概念混在了一起。
用法:每一节先给结论,再给可核验的落点(真实路径、类名或命令)。
本工作区没有验证过的内容一律标注"未验证",不做推测;口径有分歧时以
wujies-platform/AGENTS.md 为准(它是约束与已知坑的权威清单)。
一、三个仓库,先认清目录名
| 仓库 | 内容 | 工作区中的路径 |
|---|---|---|
| 平台后端 | Java / Spring Boot + SQL 脚本 | wujies-platform/ |
| 插件仓库 | 插件本体 + 插件前端工程(pnpm workspace)+ 打包器 + docs/ | wujies/wujies-plugins/ |
| 宿主前端 | Vue 3 / Vite 宿主壳 | plus-ui/ |
三个仓库没有共同的 git 根,各自分支、各自提交。
wujies-admin 这一个名字指三处不同地方
| 写法 | 实际指什么 | 判据 |
|---|---|---|
wujies-platform/wujies-admin/ | 后端启动模块(Java) | 有 pom.xml / src/main;产物是 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、-pl wujies-admin → 说的是
wujies-platform/wujies-admin/。
平台自己的类前缀是 cn.wujies.*,插件仓库父 POM 是 cn.wujies.plugins:wujies-plugins-parent
(见 wujies/wujies-plugins/pom.xml)。本项目源自上游 RuoYi-Vue-Plus,
但本项目自己的模块名、包名、文档名都不用上游那套旧名 —— 在源码或文档里看到旧前缀,那是历史遗留,不要照着写。
二、一个应用 = 一个自包含工程
结论
平台里没有为应用单独拆出的契约模块,平台源码也不引用应用的类。每个应用是一个自包含的工程:
| 内容 | 放在哪 |
|---|---|
| 实体、BO、VO | 插件自己的 src/main/java/cn/wujies/<app>/domain/** |
| Mapper 接口 与 Mapper XML | 插件自己的 mapper/ 包与 src/main/resources/mapper/<app>/ |
| Service 接口与实现 | 插件自己的 service/ 与 service/impl/ |
| controller / listener / 定时任务 | 插件自己的 controller/、listener/、task/ |
| 清单与建表脚本 | 插件根目录的 app.json、migrations/ |
由此得到三条硬事实:
wujies-platform/wujies-modules/下的模块全是平台自己的,没有应用契约模块;- 平台对
cn.wujies.<app>.*的 Java 引用为 0,只允许出现在注释里; - 新增业务模块不需要改平台一行,只写插件。
怎么自己核验(本工作区实测)
第一条命令列出平台模块清单(不应出现任何应用自己的模块),
第二条检查平台源码有没有真的 import 应用包:
Get-ChildItem .\wujies-platform\wujies-modules -Directory | Select-Object -ExpandProperty Name
Get-ChildItem .\wujies-platform -Recurse -Include *.java |
Select-String -Pattern 'cn\.wujies\.(gallery|exam|im|pay|quote|social|mall|member|enterprise|hello|finance)\.'实测:第一条命令的输出是 wujies-app-market、wujies-plugin-probe、wujies-system、wujies-workflow
四个模块,没有任何应用自己的模块。第二条的命中全部落在注释、日志文案与测试断言字符串里;
唯一带真实 import 的命中在 wujies-platform/docs/removed/ 下 —— 那是已移出 reactor 的存档源码。
一个真实插件的目录(wujies-quote,本工作区实测)
wujies/wujies-plugins/wujies-quote/
├── app.json 应用清单(菜单树、产物路径、依赖)
├── pom.xml parent = cn.wujies.plugins:wujies-plugins-parent
├── migrations/01-create-quote-tables.sql 建表脚本,安装/升级时执行
└── src/main/
├── java/cn/wujies/quote/
│ ├── controller/ QuoteController、QuotePriceController
│ ├── domain/ 实体;bo/ 与 vo/ 在其下
│ ├── mapper/ QuoteMapper、QuoteItemMapper、QuotePriceMapper
│ ├── service/、service/impl/ 接口与实现
│ ├── fallback/QuoteServiceLocator 给宿主用的定位器
│ └── portal/ 门户端(C 端)接口
└── resources/mapper/quote/ QuoteMapper.xml 等 3 个映射文件两条命名约定:
- 包名 =
cn.wujies.<app>.*,应用编码就是包名的第二段; - 插件模块的
artifactId是wujies-<app>-plugin(如wujies-quote-plugin),jar 也按这个名字产出。
定时任务同样随应用走(包落在 cn.wujies.<app>.task 下),例如
wujies/wujies-plugins/wujies-member/src/main/java/cn/wujies/member/task/MemberPointsExpireTask.java。
三、Mapper XML:随插件 jar 走,由代码注册
这一条最容易被教反,单独讲。先看结论:
| 问题 | 答案 |
|---|---|
| XML 放哪 | 插件自己的 src/main/resources/mapper/<appCode>/XxxMapper.xml |
| 包内路径 | 必须以 mapper/ 开头(如 mapper/quote/QuoteMapper.xml) |
<mapper namespace> | 接口全限定名,如 cn.wujies.quote.mapper.QuoteMapper |
| 谁注册 | PluginMapperRegistrar,插件加载时动态注册进宿主 SqlSessionFactory |
| 靠宿主配置扫吗 | 不靠。宿主 yml 里的 mapperLocations 与此无关 |
代码位置:
wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/plugin/PluginMapperRegistrar.java;
同包的 PluginRuntimeManager 负责把 Mapper bean 注册进宿主(registerMapperBeans)。
那行配置写在宿主 wujies-platform/wujies-admin/src/main/resources/application.yml 的 mybatis-plus 段
(同段还有 mapperPackage: cn.wujies.**.mapper、typeAliasesPackage: cn.wujies.**.domain)。
它是宿主类加载器在扫 classpath:
- 插件 jar 里的 XML 它扫不到;
- 把宿主自己的映射文件再注册一遍会撞
Result Maps collection already contains value,宿主直接起不来。
PluginMapperRegistrar 因此刻意只读传入的那个 jar:枚举 jar 条目,只取 mapper/ 开头且 .xml
结尾的,再用 XMLMapperBuilder 解析。注册是代码做的,不是配置做的。
接口怎么被认出来
| 判据 | 说明 |
|---|---|
类上有 @Mapper | 首选写法 |
包名里含 .mapper | 兜底判据 —— 所以 Mapper 接口的包路径必须含 .mapper(如 cn.wujies.quote.mapper) |
Mapper bean 注册在宿主 beanFactory,而不是插件自己的子上下文 —— 因为兄弟插件也要按类型注入它
(例如 mall 注入 member 的 MemberMapper)。只在宿主注册一份,插件子上下文里不再注册。
排错对照
| 症状 | 多半是 | 去哪看 |
|---|---|---|
Invalid bound statement (not found),但 Mapper bean 注入得到 | XML 没被解析/注册:包内路径不是 mapper/ 开头;或同名语句没清干净导致解析中断 | PluginMapperRegistrar 的注册日志(形如"插件 X 的 Mapper 已注册:接口 N 个,XML M 个,语句 K 条") |
启动期 Result Maps collection already contains value | 同一个 appCode 下挂着两个版本的 jar;或有人用 classpath*: 全量扫描 | active-plugins.txt 与启动前的清理脚本 wujies-platform/script/prune/prune-plugins.ps1 |
| 卸载重装后语句"消失"、类加载器只增不减 | 注销时没连 MyBatis StrictMap 里的短名一起摘 | 同一文件的注销路径 |
这些细节与实测记录见 wujies-platform/AGENTS.md §2.2、docs/开发问题与解决方案.md §2。
四、两种上下文:宿主 / 插件
插件子上下文以宿主为 parent,所以 插件能注入宿主,宿主不能注入插件。 这条 Spring 的父子关系是单向的,也是很多"为什么注入不进来"的答案。
宿主侧要拿插件服务,只有两条路:
| 方式 | 位置 | 适用 |
|---|---|---|
PluginServiceProvider.getPluginBean(appCode, type) | wujies-common-core 的 SPI | 通用,推荐 |
XxxServiceLocator.proxy(...) | 应用自己的 fallback/ 包(如 cn.wujies.quote.fallback.QuoteServiceLocator);平台 SDK 里另有 MemberServiceLocator / PayServiceLocator / ImServiceLocator | 宿主业务代码调用插件服务 |
哪怕是一个"转发代理"也不行。Spring 按类型注入走 beanNamesForTypeIncludingAncestors,
会把父上下文的同类型 bean 一起算作候选,插件的控制器启动时就会报
expected single matching bean but found 2。
插件之间怎么互相调用(依赖兄弟插件构件、需要共享的事件类必须放平台等), 见 必须遵守的不变量。
五、三种数据:目录 / 安装实例 / 迁移记录
| 类别 | 表 | 应该在哪 | 谁维护 |
|---|---|---|---|
| 目录(catalog) | app_info、app_version、app_category、app_plan | 平台级,全局一份 | 平台方(上架端) |
| 安装实例 | app_install、app_install_task、app_version_menu | 每个部署各一份 | 各部署自己 |
| 迁移记录 | app_schema_history | 每个部署各一份 | 应用升级时自动写 |
关键点:目录表已经配置成"不按租户隔离"(见
wujies-platform/wujies-admin/src/main/resources/application.yml 的 security.tenant.excludes),
而安装实例表带 tenant_id、由多租户插件自动隔离。所以:
- 想把目录集中到一台服务器、其它部署远程读 → 可行,而且不需要传租户参数;
- 想集中安装实例 → 不要做,那是每个部署自己的运行状态。
安装侧只允许通过 IAppCatalogService 读目录,不要直接注入 AppInfoMapper / AppVersionMapper。
一旦往安装侧塞了目录表的 Mapper,就等于把"目录可以搬到别处"这条路挖断了。
判断标准是:这段代码会跑在别人的部署上吗? 会 → 必须走目录接口。 上架端/控制台是目录的所有者,它直接用 Mapper 是正常的。
app_version_menu(菜单归属映射)在本节按 wujies-platform/AGENTS.md §3.1 归入安装实例;
数据模型 把它列在目录类。以 AGENTS.md 为准。
远程读目录还有一个静默坑:空串参数不是"没传"。拼 ?audience= 时服务端拿到的是空字符串而不是
null,而可见范围判断是 audience != null → 会去匹配 FIND_IN_SET('', audience) → 一条都查不出来。
跨进程传参时,可选参数要整条丢掉,不要拼空值。
六、另外三组容易混的
6.1 官方版 / 用户版(app-market.edition)
同一套代码扮演两个角色,刻意不做 fork:
official(默认) | client | |
|---|---|---|
| 目录 | 在自己库里(catalog.mode=local) | 从官方 API 读(必须 mode=remote) |
| 上架端 | 有 | 没有(接口按路径前缀 403) |
| 应用市场页 | 读本地目录 | 读远端目录 |
| 安装/升级/任务中心/菜单保护/签名校验 | 都有 | 都有 |
详见 目录中心化与版本形态。
6.2 落位 / 加载
"安装应用"其实是两件事,本项目刻意把它们拆开:
| 动作 | 做什么 | 谁用 |
|---|---|---|
| 落位 | 下载 → 校验签名与校验和 → 解包 → jar 进插件目录、前端产物进 /remote-apps/<appCode>/ | 都要 |
| 加载 | 卸载旧插件 → 建新子上下文与类加载器 → 注册接口 | 控制台"部署"按钮;安装/升级时延后到迁移之后 |
顺序上保证 落位 → 变更表结构 → 切换代码。反过来的话,新代码在启动时机读一个还没建的列就会炸。 详见 应用生命周期。
6.3 菜单 key / menu_id
菜单以 menu_key 为身份,menu_id 只是数据库主键。
- 安装/升级时按
menu_key幂等 upsert,同名 key 复用既有 menu_id —— 否则租户已经分配好的权限会在升级后全部失效; - 新版本里被移除的菜单标记为 obsolete,调用方应"停用"而不是"删除";
- 因此永远不要手写
INSERT INTO sys_menu,菜单只能通过应用清单声明。
详见 菜单与权限。