Wujies 无界平台
开始使用

核心概念

这一页解决的是**"东西该放哪、谁看得见谁"**的问题。平台里绝大多数"看不懂"和"改出 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)。

不要指望 classpath*:mapper//*Mapper.xml 能扫到插件的 XML

那行配置写在宿主 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宿主业务代码调用插件服务
不要在宿主上下文里注册与插件实现同类型的 bean

哪怕是一个"转发代理"也不行。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,菜单只能通过应用清单声明。

详见 菜单与权限。

相关阅读

目录