应用市场模块
wujies-platform/wujies-modules/wujies-app-market/ 是「应用外置」整套机制的落点:
目录数据、安装编排、插件运行时、包安全都在这一个模块里。
这一页只回答三个问题:
- 数据该怎么分 —— 哪些表的真相只有一个、哪些必须在每个部署各存一份;
- 动作由谁编排 —— 部署 / 安装 / 升级 / 停用 / 卸载分别落到哪个类;
- 代码该进哪个包 ——
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 |
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 的应用,
它此前发布过的所有版本都等于没有依赖声明。
如果要让平台"自动把依赖一起装上"(当前刻意没做,一次点击装一个应用): 改动会集中在三处,建议按这个顺序:
AppInstallServiceImpl.install里把assertSatisfied换成"取缺失清单 → 递归install依赖 → 再装自己", 并处理环(A 依赖 B、B 依赖 A)与深度上限;- 任务日志要能表达"这是为 X 自动装的 Y"(
AppTaskRecorder已经能写任意行, 但要给依赖装出来的子任务一个父任务关系,否则用户看到一串莫名其妙的安装记录); - 卸载侧要从"提示"升级成"是否级联卸载"的显式选择(现在只提示,见
dependentsOf)。
先想清楚这三件事再动手 —— 自动安装的本质是代替用户做不可逆决定, 而现在这道闸只是"把缺谁告诉你"。
安装器 SPI
AppInstaller 用 type() 区分应用种类,supports(manifest) 默认按 type 与清单的 type 匹配。
spi/impl/ 下只有两个实现:
| 安装器 | type() | 做什么 |
|---|---|---|
ModuleAppInstaller | module | 平台内置模块:注册菜单 + 授权给租户 + 执行迁移,不加载任何外部代码 |
PluginAppInstaller(extends ModuleAppInstaller) | plugin | 复用上面全部,额外确认插件声明的后端产物真的被当前 JVM 加载了,否则拒绝安装 |
| —— | deploy | AppInstaller 的类注释里声明了这一类(云主机应用,调 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)
| 组件 | 位置 | 作用 |
|---|---|---|
IAppCatalogService | catalog/ | 安装侧唯一允许依赖的目录入口 |
LocalAppCatalogService | catalog/ | 直连本库;mode 缺省或 local 时装配 |
RemoteAppCatalogService | catalog/ | HTTP 实现;mode: remote 时装配 |
AppCatalogApiController | catalog/ | 服务间接口 /app/catalog/**(已列入 security.excludes,用共享令牌兜底) |
CatalogApiContract | catalog/ | 路径 / 请求头常量,两端共用 |
两者用 @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 引用。用法见 定位器。