Wujies 无界平台
机制

应用市场模块

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

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

wujies-platform/wujies-modules/wujies-app-market/ 是「应用外置」整套机制的落点: 目录数据、安装编排、插件运行时、包安全都在这一个模块里。

这一页只回答三个问题:

  1. 数据该怎么分 —— 哪些表的真相只有一个、哪些必须在每个部署各存一份;
  2. 动作由谁编排 —— 部署 / 安装 / 升级 / 停用 / 卸载分别落到哪个类;
  3. 代码该进哪个包 —— catalog/、plugin/、service/、spi/ 各自的边界在哪。

具体的坑与推导不在这里:改代码前先扫 wujies-platform/AGENTS.md 的 §2「必须遵守的不变量」 与 §3「应用市场架构」,那一份是权威约束,本页是它的导读;每个深水区在本章都有专页(见文末)。

wujies-modules/ 下没有 *-api 契约模块(2026-09-30 起数量为 0): 实体 / BO / VO / Mapper 接口与 Mapper XML / Service / controller 全在插件自己的 wujies/wujies-plugins/wujies-<app>/ 里。见 必须遵守的不变量。

一、三种数据,别混

类别表应处位置怎么认出来
目录app_info、app_version、app_category、app_plan(预留)中心一份,平台方维护不带 tenant_id,且已列入 security.tenant.excludes(wujies-platform/wujies-admin/src/main/resources/application.yml)
安装实例app_install、app_install_task每个部署各一份带 tenant_id,由多租户插件自动隔离
迁移记录app_schema_history每个部署各一份带 tenant_id(只记「谁最先应用」),但去重判据不带租户,见 数据库表

另有两处要单独交代:

  • app_version_menu —— 版本 → 菜单的归属映射,「菜单归属保护」与「彻底删除」都读它 (service/AppMenuOwnershipProvider)。它的行在安装期写入,但表本身没有 tenant_id、 也已在 security.tenant.excludes 里 —— 即「写入时机是安装,数据身份是平台级」。
  • app_dependency、app_subscription、app_audit_log、app_plan —— DDL 都在 script/sql/update/app_market.sql 里,但 domain/ 与 mapper/ 下没有任何对应 Java:属预留。

这三类分不清的代价是具体的:目录和安装实例混在一套表里,多部署一套系统就要同步一遍目录。

安装侧只允许通过 IAppCatalogService 读目录(catalog/IAppCatalogService.java,9 个方法), 不要直接注入 AppInfoMapper / AppVersionMapper。这样把目录搬到中心服务时只换实现 (LocalAppCatalogService → RemoteAppCatalogService),安装侧一行都不用改。

上架端 / 控制台是目录的所有者,它直接用 Mapper 是正常的。判断标准是一句话: 这段代码跑在别人的部署上吗? 跑在别人那里 → 必须走目录接口。

mapper/ 下正好 7 个 Mapper,对应 app_info、app_version、app_category、app_install、 app_install_task、app_version_menu、app_schema_history 这 7 张表;domain/ 下另有 AppManifest(app.json 的模型)、AppMenuPlan、AppMenuSyncResult 三个非表模型。 逐字段说明见 数据库表。

二、包地图

每个包只做一件事,改动前先确认自己进的是哪个包。

包职责代表类
catalog/目录访问的唯一入口 + 服务间接口IAppCatalogService、LocalAppCatalogService、RemoteAppCatalogService、AppCatalogApiController、CatalogApiContract
edition/版本形态:官方版 / 用户版MarketEdition、MarketEditionGuardFilter、MarketEditionStartupCheck
plugin/插件运行时:类加载器、子上下文、独立路由、Mapper 与 Converter 的运行期注册PluginClassLoader、PluginApplicationContext、PluginHandlerMapping、PluginRuntimeManager、PluginMapperRegistrar、PluginConverterRegistrar、PluginEventRelay、PluginContextRegistry
security/包安全:摘要规范化、白名单 + 验签、离线签名工具、签名就绪度自检PackageDigest、PackageSecurityInspector、PackageSignerMain、PackageKeys、SignatureReadinessService
service/生命周期编排与各项落地服务IAppInstallService(实现 service/impl/AppInstallServiceImpl)、AppPackageDeployService、AppPurgeService、AppMigrationService、AppMenuPlanner、AppMenuSyncServiceImpl、AppMenuOwnershipProvider、AppMenuTreeExpander、AppManifestValidator、AppRouteHintResolver、AppMenuFilterImpl、AppMenuGrantServiceImpl、AppMenuCache、AppConsoleMenuResolver
spi/安装器扩展点与生命周期监听AppInstaller、AppInstallContext、AppLifecycleListener、AppExtensionPoint、AppHealth;实现见 spi/impl/ModuleAppInstaller、spi/impl/PluginAppInstaller
state/安装状态机(8 个状态、合法迁移、对外可见性)AppLifecycleStateMachine
support/任务进度落库与卡死任务清扫AppTaskRecorder、AppTaskTimeoutSweeper
controller/HTTP 入口,controller/ 下 9 个控制器AppMarketController(/app/market)、AppInstallController(/app/install)、AppConsoleController(/app/console)、AppVersionController(/app/version)、AppPackageController(/app/package)、AppCategoryController(/app/category)、AppRuntimeController(/app/runtime)、AppWorkbenchController(/app/workbench)、AppEditionController(/app/edition)
domain/ mapper/实体 + bo/ + vo/;mapper/ 是 MyBatis-Plus 的 Mapper 接口AppInfo、AppVersion、AppInstall、AppInstallTask、AppManifest 等
enums/安装状态、任务状态、任务类型AppInstallStatus(8 个取值)、AppTaskStatus(running / success / failed / canceled)、AppTaskType(install / upgrade / repair / enable / disable / uninstall / rollback)
constant/跨前后端与跨进程的常量AppMarketConstants:远程视图前缀 remote:、权限前缀 app:、应用表前缀 app_、清单文件名 app.json、OSS 前缀 app-market/package/ 与 app-market/asset/
util/清单引擎版本约束、HTML 清洗、运行时探针EngineConstraint、HtmlSanitizer、PluginRuntimeProbe
为什么进度落库不改 SPI 签名

AppInstaller 的方法签名里没有任务与日志参数 —— 进度由 AppTaskRecorder 用 ThreadLocal 传递(support/AppTaskRecorder.java)。所以新增一种应用类型 不需要给接口加参数,也不需要碰编排代码。

三、生命周期:谁编排

编排者是 IAppInstallService / AppInstallServiceImpl:它自己不做具体动作, 按状态机(state/AppLifecycleStateMachine)把工作分派给安装器 SPI。

动作后端行为
部署落位 + 热加载(卸载旧插件 → 用新 jar 建上下文 → 注册接口)
安装本机缺产物 → 从市场按 packageUrl 拉包 → 落位 → 加载
升级同上 + 菜单幂等 upsert;升级会强制重新落位(按「来源版本 ≠ 目标版本」判断)
停用 / 启用unload / 重新 load
卸载(保留数据,默认)unload + 回收本租户授权;菜单行与数据保留
卸载(彻底删除)上面这些 + 删菜单 / 删数据表 / 删迁移记录 / 删落位产物 / 删安装记录

这六个动作全部不需要重启,因为落位与加载是两件事(service/AppPackageDeployService.java):

方法行为谁用
deploy(versionId) → deploy(versionId, true)落位 + 立即加载控制台「部署」按钮(POST /app/version/{versionId}/deploy),它不跑迁移
deploy(versionId, false)只落位安装 / 升级

安装器传 false,加载交给排在迁移之后的 assertBackendLoaded —— 于是「落位 → 变更表结构 → 切换代码」这个顺序得到保证,插件在启动时机直接读新加的列是安全的。 deploy 里的安全检查(PackageSecurityInspector.inspectOrThrow)发生在落位之前。

「落位」落两样东西:

内容落到配置键
后端 jar<pluginDir> 根下(loader.path 只读根下、不递归子目录)app-market.deploy.plugin-dir,默认 plugins-dist
前端远程应用产物<frontendDir>/<appCode>/app-market.deploy.frontend-dir

部署完还会把「本应用当前生效哪个 jar」写进 <pluginDir>/active-plugins.txt (常量 AppPackageDeployService.ACTIVE_JARS_FILE)—— 运行期与启动期都按这份清单选版本。

应用依赖(dependencies):谁做判定、判定在哪一步

清单里的 dependencies[](字段形状见 应用清单)在平台侧由两处代码承接:

类职责
service/AppDependencyChecker判据。解析清单的 dependencies、逐个判 enabled / disabled / not_installed / optional_missing、拼那句中文报错。纯函数、不碰 Spring,14 项单测钉住
service/AppDependencyResolver把「目录里的声明」与「本租户安装台账」合起来。三个消费点:安装/升级前拦截、describe(...) 给前端、dependentsOf(...) 卸载反向提示

判定点与顺序(AppInstallServiceImpl):

  • 安装:requireOnShelfApp → requireLatestVersion → parseManifest → assertEngineCompatible → assertSatisfied, 全部排在"建安装记录 + 起任务"之前。所以依赖不满足时用户立刻拿到报错, 而且不会留下一条 installing 的垃圾安装记录;
  • 升级:同样在最前面(排在状态机与任务之前)。升级会换一份新清单,它可能新增了依赖。

⚠️ 只拦安装与升级,绝不拦已装应用的加载 —— 后者是运行期容错(应用还在、只是某个能力不可用), 拦它会变成"依赖一被停用,依赖方自己都起不来"。

依赖信息从哪来:目录的 app_version.manifest_json,不新增表。 这点与路由清单(app_version_route,见 插件运行时)刻意不同 —— 路由清单必须落表是因为它要从包内 jar 的字节码里扫出来;而 dependencies 是 app.json 的顶层字段,manifest_json 里本来就有,所以不需要任何迁移脚本。

⚠️ 但"清单里本来就有"只对"声明了 dependencies 之后打的包"成立(2026-10-04 线上实测纠正, 本节原文在这里写错过一次):manifest_json 是上传包时从包内 app.json 抄下来的 (AppPackageController#parse → extractManifest → parseAndValidateManifest → 前端把返回的 manifestJson 原样存进 /app/version)。所以在 dependencies 字段被加进 某个应用的 app.json 之前发布的那些版本,它们的 manifest_json 里根本没有这个键 —— 于是 AppDependencyChecker.parse 走"没有这个字段 ⇒ 这个应用没有依赖"这条分支, 闸门会静默放行,前端拿到的也是 allowed=true,按钮照旧可点。

判定链路上没有第二处兜底:parseManifest 只映射成 AppManifest(有 dependencies 字段), 没有的话就是空列表 ⇒ assertSatisfied 立即 return。现象与"这个应用真的没依赖"完全一样, 只能靠"去看那个版本的包内 app.json"区分。

Live 例子(mall):源码 app.json 在 d494bdf(2026-10-03 12:25)才有 dependencies (member / pay / im),而线上目录里的 mall 1.0.3(2026-10-02 打的)与本地 dist-packages/mall-1.0.4.zip(2026-10-03 10:45 打的)包内 app.json 都没有这个键, 两者只差 version 一个字符串 ⇒ 装 mall 时闸门全程放行,直到第 4 步加载插件才报 Lookup method resolution failed。

⇒ 责任在发布侧,不在检查代码:要让闸门生效,必须重新打包该应用并发布一个更高版本 (版本号不往上提则发布不上去/覆盖不了)。新增了 dependencies 的应用, 它此前发布过的所有版本都等于没有依赖声明。

如果要让平台"自动把依赖一起装上"(当前刻意没做,一次点击装一个应用): 改动会集中在三处,建议按这个顺序:

  1. AppInstallServiceImpl.install 里把 assertSatisfied 换成"取缺失清单 → 递归 install 依赖 → 再装自己", 并处理环(A 依赖 B、B 依赖 A)与深度上限;
  2. 任务日志要能表达"这是为 X 自动装的 Y"(AppTaskRecorder 已经能写任意行, 但要给依赖装出来的子任务一个父任务关系,否则用户看到一串莫名其妙的安装记录);
  3. 卸载侧要从"提示"升级成"是否级联卸载"的显式选择(现在只提示,见 dependentsOf)。

先想清楚这三件事再动手 —— 自动安装的本质是代替用户做不可逆决定, 而现在这道闸只是"把缺谁告诉你"。

安装器 SPI

AppInstaller 用 type() 区分应用种类,supports(manifest) 默认按 type 与清单的 type 匹配。 spi/impl/ 下只有两个实现:

安装器type()做什么
ModuleAppInstallermodule平台内置模块:注册菜单 + 授权给租户 + 执行迁移,不加载任何外部代码
PluginAppInstaller(extends ModuleAppInstaller)plugin复用上面全部,额外确认插件声明的后端产物真的被当前 JVM 加载了,否则拒绝安装
——deployAppInstaller 的类注释里声明了这一类(云主机应用,调 PVE / 云 API),但 spi/impl/ 下没有实现类:目前是预留

找不到匹配的安装器时,AppInstallServiceImpl 抛 没有可处理该应用的安装器,app_type=…(service/impl/AppInstallServiceImpl.java)。

任务记在哪

安装 / 升级 / 卸载都产生 app_install_task 记录,步骤与日志边跑边落库 (support/AppTaskRecorder);长时间没有进度的任务由 support/AppTaskTimeoutSweeper 判定失败 (阈值键 app-market.task.timeout-minutes,默认 10 分钟)。

前端页面是 plus-ui/src/views/appstore/tasks/index.vue。排查问题先从这里看, 比翻服务端日志快 —— 用法见 任务中心与日志。

四、目录中心化与版本形态

这两个开关解决的是同一个问题的两面:一套平台代码,怎么服务多种部署拓扑。 完整推导、实测记录与前端半边见 目录中心化与版本形态。

目录中心化(app-market.catalog.mode)

组件位置作用
IAppCatalogServicecatalog/安装侧唯一允许依赖的目录入口
LocalAppCatalogServicecatalog/直连本库;mode 缺省或 local 时装配
RemoteAppCatalogServicecatalog/HTTP 实现;mode: remote 时装配
AppCatalogApiControllercatalog/服务间接口 /app/catalog/**(已列入 security.excludes,用共享令牌兜底)
CatalogApiContractcatalog/路径 / 请求头常量,两端共用

两者用 @ConditionalOnProperty 二选一,刻意不用 @ConditionalOnMissingBean(后者依赖 bean 定义顺序)。

app-market:
  catalog:
    mode: local                          # local(默认)/ remote
    base-url: https://market.example.com # mode=remote 时必填,缺失启动即失败
    token: <两端一致的共享令牌>

配置键的完整清单(含超时与容器环境变量覆盖)见 配置项索引。

版本形态(app-market.edition)

同一套代码扮演两个角色,刻意不做 fork:

official(默认)client
目录在自己库里(catalog.mode=local)从官方 API 读(必须 mode=remote)
上架端有(应用 / 版本 / 分类 / 安装包上传)没有
/app/catalog/**对外提供不提供
应用市场页读本地目录读远端目录
安装 / 升级 / 任务中心 / 菜单保护 / 签名校验都有都有

三处配套,读代码时会遇到:

  • MarketEditionGuardFilter 按路径前缀挡上架端:/app/console、/app/version、/app/package, 以及 /app/category 的写操作(分类的读接口保留,市场页要用它做筛选); 唯一例外是 GET /app/console/owned-menus(菜单管理页要用它打归属标签)。拦截结果是 403 + 明确文案,不是 404、也不是静默失效。
  • MarketEditionStartupCheck 在 client + catalog.mode=local 时直接让启动失败并写明该改哪一行 —— 这个组合「能起来但什么都装不了」,根因在报错里看不出来。
  • GET /app/edition 返回 {edition, consoleVisible}(AppEditionController):静态路由服务端管不到, 前端必须问一次服务端才能把上架端入口摘干净(plus-ui/src/plugins/appmarket/edition.ts)。

五、模块外的相关代码

位置内容
wujies-platform/wujies-admin/PluginClassPathLoader —— 兼容模式的启动加载器,在 Spring 之前执行,同样按 active-plugins.txt 过滤要挂载哪些 jar
wujies-common/wujies-common-core/PluginServiceProvider、PluginServiceLocator、PluginClassLoaderProvider、MenuOwnershipProvider、AppRouteHintProvider
wujies-common/wujies-common-web/GlobalExceptionHandler(异常 → 前端的唯一出口)
wujies-common/wujies-common-security/AllUrlHandler(登录清单,要同时收宿主与插件的路由)
wujies-common/wujies-common-plugin-sdk/插件扩展点契约:FollowRelationApi、PlatformUserApi / PlatformDeptApi / PlatformOssApi / PlatformMessageApi / PlatformMemberApi、MemberServiceLocator / PayServiceLocator / ImServiceLocator
wujies-modules/wujies-plugin-probe/插件运行时探针:用最小 jar 验证外置 jar 能否被正常装配
plus-ui/src/plugins/appmarket/前端运行时:远程应用装载、菜单刷新、形态自检、可用性探测(bootstrap.ts / loader.ts / menuRefresh.ts / edition.ts / appAvailability.ts)
plus-ui/src/views/appstore/前端页面:market/(应用市场)、installed/(我的应用)、tasks/(应用任务)、console/(上架端)

宿主要用插件的服务时,走平台 SDK 的契约 + 定位器(PluginServiceProvider / PluginServiceLocator), 不能直接依赖插件构件 —— 那会让平台对 cn.wujies.<app>.* 产生 Java 引用。用法见 定位器。

相关阅读

目录