Wujies 无界平台
平台功能(自研)应用市场

应用生命周期

这一页讲七个生命周期动作(部署 / 安装 / 升级 / 停用 / 启用 / 卸载 / 彻底删除)在后端各自做了什么、按什么顺序做、失败停在哪一步,以及「彻底删除」为什么会被一道共享闸拦下一半。它面向要改安装或卸载流程的人。

界面上的操作入口与排查速查见 在部署里使用应用,任务日志怎么读见 任务中心与日志。

实现全在 wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/;下面每个结论都标了类名或配置键,可以逐条核对。

编排的收口:一个入口、一张状态机

环节类文件
生命周期入口与状态推进AppInstallServiceImpl(实现 IAppInstallService)service/impl/AppInstallServiceImpl.java
合法迁移表AppLifecycleStateMachinestate/AppLifecycleStateMachine.java
状态编码与中文名AppInstallStatusenums/AppInstallStatus.java
执行器选择AppInstaller.supports()spi/AppInstaller.java
插件型执行器PluginAppInstaller extends ModuleAppInstallerspi/impl/PluginAppInstaller.java
模块型执行器ModuleAppInstallerspi/impl/ModuleAppInstaller.java
进度边跑边落库AppTaskRecorder(ThreadLocal)support/AppTaskRecorder.java
任务表实体AppInstallTask、AppTaskType、AppTaskStatusdomain/、enums/

执行器按清单里的 type 选:AppInstallServiceImpl.requireInstaller 用 installer.supports(manifest) 过滤,默认实现就是比 manifest.type 与 installer.type()。AppInstaller 的注释里预留了 module / plugin / deploy 三种,但 spi/impl/ 下只有前两种有实现类 —— 清单写 type: deploy 会报「没有可处理该应用的安装器」。

合法迁移表

起点是 app_install.status 的当前值(AppInstallStatus,无记录表示"未安装"):

起始状态允许到达
无记录(未安装)installing
installinginstalled、failed
installedenabled、uninstalling、failed
enableddisabled、upgrading、uninstalling、failed
disabledenabled、upgrading、uninstalling、failed
upgradingenabled、disabled、failed
failedinstalling、upgrading、uninstalling、uninstalled
uninstallinguninstalled、failed
uninstalledinstalling

四条读表时容易忽略的:

  • 只有 enabled 算"对本租户可见"(AppLifecycleStateMachine.isVisible);installed 不可见。
  • installed 只是路径中间态。安装的成功路径是 installing → installed → enabled(装完即启用),而 successPathOf 的终态才落库 —— 所以实际停靠的状态里不会出现 installed。
  • 升级保留原启停状态:路径写成 upgrading → disabled(原本停用)或 upgrading → enabled。
  • 没有任何状态能直达 uninstalled,必须先过 uninstalling。历史上有一次把卸载路径写成 [uninstalled],于是 disabled → uninstalled 直接抛「非法的应用状态迁移」—— 现在所有路径集中在 successPathOf 一处,就是为了不再各写一份。

runTask 的三条实现约束

  1. 整条路径先校验、后执行:AppInstallServiceImpl.runTask 在插入 app_install_task 之前把成功路径逐跳喂给 AppLifecycleStateMachine.transit。非法就抛「非法的应用状态迁移: …」,而且不产生任务行。
  2. "已处于目标状态"只对三类动作生效(goalStatusOf):启用 / 停用 / 卸载。安装与升级刻意不做这个判断 —— 升级一个已停用的应用时路径终态恰好等于当前状态(upgrading → disabled),按终态判重会把"已停用的应用升不了级"做成一个死结。
  3. 失败必须落库,连 Error 一起捕:markFailed 把 app_install_task.status 置 failed 并写 error_msg,同时把 app_install.status 也置 failed。只捕 RuntimeException 是不够的 —— NoClassDefFoundError、OOM 会让任务永远停在 running(前端看到"一直在跑",其实早就死了),这是实测踩到过的。

任务类型枚举 AppTaskType 里有 repair(修复)与 rollback(回滚),但 successPathOf 对这两个直接抛 UnsupportedOperationException("尚未实现的生命周期动作") —— 它们目前只是预留。

七个动作各自做了什么

动作接口(权限)后端真实行为状态
部署POST /app/version/{versionId}/deploy(app:console:edit)拉包校验 → 落位 → 热加载。不跑迁移脚本,也不改安装记录不动 app_install
安装POST /app/install(app:install:add)落位 → 迁移 → 断言后端已加载 → 按清单幂等 upsert 菜单 + 授权给本租户管理员角色installing → installed → enabled
升级POST /app/install/{installId}/upgrade?versionId=(app:install:edit)同安装,但强制重新落位;成功后把 app_install.version_id 落成目标版本enabled/disabled → upgrading → 原状态
启用POST /app/install/{installId}/enable先 load 把后端装回运行时,再补授权installed/disabled → enabled
停用POST /app/install/{installId}/disable只 unload;菜单行与授权都保留enabled → disabled
卸载(保留数据,默认)POST /app/install/{installId}/uninstall(purge 缺省 false)撤本租户菜单授权 + unload;菜单行、数据表、迁移记录、本机产物全留→ uninstalling → uninstalled
彻底删除同一条接口 + ?purge=true上面那些 + AppPurgeService.purge()(见下文),最后物理删 app_install 那一行同上

接口清单与权限标识见 HTTP 接口索引。

  • 安装请求体只有 appCode 与 tenantId(AppInstallBo),版本由目录的 requireLatestVersion(appId) 决定 —— 安装装的一定是最新的已发布版本,要装别的版本只能走升级并显式传 versionId。
  • 安装前有两道前置校验:应用必须上架(IAppCatalogService.requireOnShelf),清单 engine 必须与 AppMarketConstants.PLATFORM_VERSION(当前 5.6.2)兼容(EngineConstraint.describeMismatch)。见 应用清单 app.json。
  • 每次状态跃迁后 updateInstallStatus 都会调 AppMenuCache.evictTenant(...) —— 这是安装/升级/启用/停用/卸载唯一的菜单缓存失效点(成功与失败都会走到)。
  • 停用与卸载之后,应用还留着的接口会收到业务码 4610(AppRouteHintProvider.CODE_APP_UNAVAILABLE),措辞区分"已停用 / 未安装或已卸载 / 已启用但产物没加载"。

落位与加载是两件事

方法行为谁用
AppPackageDeployService.deploy(versionId)落位 + 立即热加载控制台「部署」按钮(plus-ui 的 deployAppVersion),它不跑迁移
AppPackageDeployService.deploy(versionId, false)只落位安装 / 升级;加载交给迁移之后的 assertBackendLoaded

顺序保证:落位 → 变更表结构 → 切换代码。 这样插件在启动时机读新加的列才是安全的。安装器传 false 的理由写在双参版本的类注释里:如果落位时顺手把新插件加载起来,就变成"新代码先上线、表结构后变更"。

「落位」落三样东西

包内落到生效配置键(wujies-platform/wujies-admin/src/main/resources/application.yml)
assets.backend 声明的那个 jar<plugin-dir> 根下(loader.path 只加载根下的 jar,不递归子目录)app-market.deploy.plugin-dir = plugins-dist
assets.frontend 所在目录整棵树(剥掉该前缀)<frontend-dir>/<appCode>/(必须就是宿主 /remote-apps/<appCode>/ 的物理位置)app-market.deploy.frontend-dir = ../wujies-admin/app-market-dist/remote-apps
migrations/*.sql(不计入 released 计数)<plugin-dir>/migrations/<appCode>/AppMigrationService.MIGRATION_DIR_NAME = migrations
frontend-dir 有两份默认值

AppPackageDeployService 上 @Value 的兜底是 ../plus-ui/app-market-dist/remote-apps,而 application.yml 里写的是 ../wujies-admin/app-market-dist/remote-apps —— 生效的是 yml 那份(工作区顶层 wujies-admin/ 只有 app-market-dist/,没有 package.json)。这个差异的来龙去脉见 配置项。

落位前会清空两个目录(deleteQuietly):

  • 前端目录:不清的话上一版本的 chunk 会残留,而 index.js 已更新,可能加载到过期分片;
  • 迁移目录(2026-10-02 起):包里已经删掉或改名的旧脚本会一直留在磁盘上,并在下一次安装时被再次执行("幽灵脚本")。副作用是刻意的:declaredTables(彻底删除时据以删表)只反映当前包的脚本。

active-plugins.txt:谁在选版本

AppPackageDeployService.recordActiveJar 往 <plugin-dir>/active-plugins.txt 写一行 appCode=jarFileName;彻底删除时 forgetActiveJar 擦掉这一行。两个加载点都读它,但严格程度不同 —— wujies-platform/AGENTS.md §2.6 把两者都概述成"只加载清单里的那个",以源码为准的实际行为是:

加载点选版本规则
运行期 PluginRuntimeManager.loadAll()(监听 ApplicationReadyEvent)清单里的 jar 按 appCode → jar 加载;不在清单里的走"手工放置的插件"兜底 —— toLoad.putIfAbsent(resolveAppCode(jar), jar),resolveAppCode 按文件名约定 wujies-<code>-plugin-<version>.jar → <code> 推导。该 appCode 已有生效版本时,putIfAbsent 会挡住旧版本、不覆盖生效的那个
启动期 PluginClassPathLoader.install()(Spring 之前执行)插件运行时开启(默认 true)时它直接让路,一个 jar 都不挂;只有 -Dapp-market.plugin-runtime.enabled=false 时才执行 listJars,那时按清单过滤,读不到清单(文件缺失或为空)则不过滤
残留 jar 什么时候仍会被打开

两个加载点合起来看:同一个 appCode 已有生效版本时,旧版本 jar 会被 putIfAbsent 挡住、不再挂载 —— 所以"带着残留重启不会再同时挂上两个版本"在补装/升级场景成立。但已彻底删除的应用留下的孤儿 jar(清单里无记录、同 appCode 也没有生效版本)会被当成手工放置的插件继续加载,于是它重启后又被 JVM 占住,deleteArtifacts 再删依旧失败。

Windows 上被运行中 JVM 打开的 jar 删不掉,部署报告里的 staleJars 会如实列出,任务日志里也会 WARN。清理按情况选:wujies-platform/script/prune/prune-plugins.ps1(Linux 用同目录 prune-plugins.sh)只删同一 appCode 名下、非生效版本的 jar,不碰孤儿 jar —— 对孤儿 jar 要么停服后手工删,要么重新部署该应用把 active-plugins.txt 记录写回后再跑一次脚本。script/prune/start-host.ps1 启动前会先跑一遍它。工作区根的 docs/开发问题与解决方案.md §5.2 / §5.3 对这条修复也标着"尚未重启验证"。

升级为什么强制重新落位

PluginAppInstaller.ensureArtifactsInstalled 先判 fromVersion != toVersion(versionChanged),成立就跳过"文件已存在就用本机产物"这条捷径。沿用旧产物会同时踩两个静默坑:

  1. 前端产物按定义是旧版,而"后端 jar 在磁盘上、前端入口也存在"已经满足提前返回的条件 → 前端永远不会被更新;
  2. 包内的 migrations/*.sql 根本不落位 → 迁移静默不执行,用户以为升级成功,表结构其实没变。

同版本重装("文件已存在就跳过"那条捷径)才走:后端已由运行时加载、前端入口文件在(frontendEntryExists 只看 <frontend-dir>/<appCode>/index.js)就直接返回,只在任务日志里记一句「前后端产物均已在磁盘上,跳过拉包(将在迁移脚本之后加载)」。

纯前端应用是合法形态:清单只写 assets.frontend 不写 assets.backend 时,deploy 对空 backend 判空、assertBackendLoaded 直接放行。例子是插件仓库的 wujies/wujies-plugins/wujies-finance/(目录下没有 migrations/)。

安装与升级的完整顺序(可以照着任务日志核对)

runTask            「执行安装」/「执行升级」        进度 10
 ② ensureArtifacts 「从应用市场拉取安装包」          进度 20   ← 本机已有该版本产物时整步跳过
 ③ applyMigrations「执行版本迁移脚本」              进度 30
 ④ assertBackend   「校验后端产物:<jar>」          进度 40   ← 必须在 ③ 之后
 ⑤ super.install   「同步菜单与授权」               进度 60
 ⑥ 任务成功,进度 100,current_step = 完成

顺带一个读日志的技巧:进度可能从 10 直接跳到 30,那不是丢了步骤,而是第 ② 步整步被跳过了。

② 落位的四件事(都在 AppPackageDeployService.deploy,顺序固定):

  1. 下载(单包上限 200MB,download);
  2. verifyChecksum:比对 app_version 的 package_md5 / package_sha256。两个都没记时只 WARN 跳过(手工填 packageUrl 的版本就是这种);
  3. PackageSecurityInspector.inspectOrThrow:产物白名单 + 验签。app-market.security.mode=enforce 下直接抛异常,包连解都不会解、插件目录里不会落任何文件;
  4. 解包落位(含 zip-slip 防护 safeResolve、条目数与单条目大小上限),写 active-plugins.txt。

见 包安全与签名。

③ 迁移:扫 <plugin-dir>/migrations/<appCode>/,按文件名排序逐个执行,记账到 app_schema_history(键 (app_code, script)),执行后清字典与参数的 Redis 缓存。失败不吞异常 —— 表结构没变更成功却继续装菜单,会留下"装上了但用不了"的坏状态。四条硬规矩见 数据库迁移。

④ assertBackendLoaded 依次问三处:

  1. PluginRuntimeManager.loadedJar(appCode, jarName) —— 插件运行时里加载的是不是这个 jar;顺序不能反,插件运行时模式下 classpath 探针必然查不到,先查探针会把"已加载"判成"没加载";
  2. PluginRuntimeProbe.findLoadedJar(jarName) —— classpath / TCCL 上有没有(兼容 -Dloader.path 那条路);
  3. 都没有但磁盘上有 → 直接 load,不拒绝用户(卸载过、部署时热加载失败、用旧方式启动都会落到这一支)。

三处都拿不到才抛错,并且把失败原因拆成两条互不相同的处置建议(jar 不存在 → "还没落位,去上架端点部署";jar 在但没在 classpath 上 → "启动方式不对,-Dloader.path 只被打包后的 PropertiesLauncher 识别,从 IDE 直接跑主类会被静默忽略")。

停用与启用

动作后端顺序为什么是这个顺序
停用ModuleAppInstaller.disable(刻意留空)→ PluginAppInstaller.unloadRuntime菜单入口隐藏由 AppMenuFilter 按安装状态完成,不需要撤授权;不撤授权,重新启用才能立刻恢复,也不破坏管理员此前的手工分配
启用loadRuntime 先把 jar 装回运行时 → ModuleAppInstaller.enable 补授权反过来的话用户会先看到入口却打不开
  • loadRuntime 只在插件运行时开启时生效(app-market.plugin-runtime.enabled,@Value 默认 true):已经加载过就直接返回,纯前端应用(无 assets.backend)也直接返回。
  • 加载失败必须让启用失败:异常被包成 IllegalStateException("启用失败:插件后端无法加载 —— …"),任务落 failed、app_install.status 也变 failed。放任一个后端起不来的应用处于"已启用",就是"装上了但用不了"。
  • 停用只改 app_install.status:菜单行与 sys_role_menu 授权都不动。见 装完以后:菜单与权限。

卸载的两种模式(AppPurgeService)

两种模式走同一个接口,由 purge 决定。前端弹窗(plus-ui/src/views/appstore/installed/index.vue)默认选中「保留数据」;选「彻底删除」必须把应用名完整敲一遍才亮确认按钮。

步骤谁做保留数据彻底删除
撤本租户菜单授权 + unloadModuleAppInstaller.uninstall + PluginAppInstaller.unloadRuntime做做
删菜单行(含后代)+ 所有角色授权 + 归属映射AppPurgeService.purge全留删
DROP TABLE 应用声明过的表同上全留删
删 app_schema_history同上留删
删应用自带的字典与站点配置同上留删
删本机产物(jar / 前端目录 / active-plugins.txt 那一行)AppPackageDeployService.deleteArtifacts留尽力而为地删
物理删 app_install 那一行AppPurgeService.deleteInstallRecord不删(状态置 uninstalled)删

为什么 app_schema_history 必须删:不删的话重装时建表脚本会被当成"已执行过"跳过,表再也建不回来。

几个字段级的实现细节

  • 删除排在状态落库之后:deleteInstallRecord 在 runTask 返回后调用 —— 先删记录就没地方写终态了。也因此 purge 不是事务:中途失败会停在半途,app_install 那一行保留、状态 failed,可以再点一次卸载(failed → uninstalling 合法)。
  • 删菜单连后代一起删:AppMenuTreeExpander.withDescendants。app_version_menu 里只有清单声明过的菜单,而 sys_menu 里还有 menu_type='F' 的按钮 —— 只删映射到的行,按钮就成了"父菜单没了、自己还挂在 parent_id 上"的孤儿(实测踩到过 23 个 gallery 按钮)。
  • 清归属映射按三条线索并行:menu_id ∈ 本次删掉的菜单、menu_key ∈ 清单声明的键、version_id ∈ 该应用已知版本。versionIds 来自 IAppCatalogService.releasedVersions(appId) 加上本次安装的 versionId(兜底:应用已从市场撤下时目录里查不到它),走目录接口而不是直接查 app_version —— 用户版(catalog.mode=remote)本机没有目录数据。少一条就会留下"死映射"(常见形态是挂在软删除的旧版本上),下次安装/升级复用它指向的已删除菜单 ID,表现是「安装成功、状态 enabled,但一个菜单都没有」。
  • 删字典与站点配置的判据:dict_type LIKE '<appCode>_%';config_category = <appCode> 或 config_key LIKE '<appCode>.%'。删完调 resetDictCache() / resetConfigCache()。留着的后果不是"多几行垃圾",而是重装的脚本用 WHERE NOT EXISTS / INSERT IGNORE 会沿用上一次的旧值("装成功了、标签还是旧的标签")。见 种子数据。
  • 删产物是尽力而为:删不掉的收进 ArtifactCleanup.kept(),任务日志 WARN「以下产物未能删除,可停服务后手工清理」。active-plugins.txt 里那一行最后擦 —— 先擦记录、jar 又删失败,那个 jar 就成了没人认领的孤儿。

共享闸:别人还在用就不动全局的东西

判据在 AppPurgeService.countOtherTenantInstalls(appId, installId):

app_install 里 appId 相同、del_flag='0'、status != 'uninstalled'、install_id != 本次这条
    → 计数 > 0 即 shared = true

查询用 TenantHelper.ignore(...) 包着,所以数到的是所有租户的安装实例(不只"别人的租户",也包括本租户的另一条记录)。

情形shared = true 时的行为
菜单行与角色授权、归属映射、数据表、迁移记录、字典与站点配置、本机产物全部跳过,逐条写进任务日志("检测到还有 N 个租户装着该应用:菜单与数据表是全局共享的,已跳过删除;只清理了本租户的安装记录。要彻底删除请先让它们也卸载。")
卸载那一步的本租户授权回收照做(它在 purge 之前)
purge=true 时本租户安装记录的物理删除照做 —— 这一条是"本租户的",不共享
判据必须排除自己那条记录

执行 purge 时,本次这条 app_install 的状态还没被改成 uninstalled(状态是任务成功后才落的)。不排除自己的话,每次卸载都会"发现还有一个租户在用",功能直接失效 —— 实测踩到过。

为什么这些算"全局的":sys_menu 行不分租户、表结构不分租户、本机产物一个部署只有一份。替别人删掉就是替别人做了一个不可恢复的决定。字典与站点配置行虽然按租户各一份,但同样归这道闸管(应用只装一次,删掉会影响别人正在用的标签)。

第一版漏的正是产物

共享场景下它仍然删了前端产物,而产物是部署内共享的 —— 别的租户还装着,删掉他们的页面就立刻坏。现在 purge 里按 shared 判空,不调 deleteArtifacts。

第二道闸:平台表前缀受保护。 AppPurgeService.PROTECTED_PREFIXES = sys_ / app_ / gen_ / flow_ / act_ / qrtz_ / snailjob_ / undo_log;表名还要先过 SAFE_TABLE_NAME([A-Za-z0-9_$]+)才会被拼进 DROP TABLE IF EXISTS。命中的表跳过并告警 —— 防的是一个写错的脚本(例如 CREATE TABLE IF NOT EXISTS app_install)把平台表带下水。

待删表从哪来:AppMigrationService.declaredTables(appCode) 读 <plugin-dir>/migrations/<appCode>/ 下的脚本,用 parseDeclaredTables(纯函数、有单测)解析。两个坑:

  • 必须先剥注释 —— 脚本头部常写"请用 CREATE TABLE IF NOT EXISTS"这类说明,不剥就会把注释里的名字当成表名(wujies/wujies-plugins/wujies-gallery/migrations/01-create-gallery-tables.sql 的注释里就留着这样一段当样本);
  • 只认 CREATE TABLE:ALTER / INSERT / DROP 都不构成"拥有"某张表。

表名一个都没解析出来时,会 WARN「该应用没有声明任何数据表(迁移脚本里没有 CREATE TABLE),因此没有删表」—— 这正是"表结构必须随应用包走"的原因,见 数据库迁移。

刻意不做

  • 任务记录 app_install_task 保留,它是"谁在什么时候卸载了什么"的审计线索。代价是彻底删除后那些历史行的应用名查不到,前端用 AppInstallTaskVo.appMissing 标记、列表里显示「(应用已删除)」(plus-ui/src/views/appstore/tasks/index.vue)。见 任务中心与日志。
  • 对象存储里的图片等外部文件不删 —— 平台不知道它们属于谁,也不该替用户删文件。
  • PurgeReport 不对接口暴露:它逐项记录"删了什么、跳过什么"(sharedWithOtherTenants、menusDeleted、tablesDropped、schemaHistoryDeleted、artifactsKept …),但卸载接口只返回 taskId,要看明细就读任务日志。

失败、重试与回滚能力

情形支持吗
安装 / 升级失败后重试✅ 任务落 failed,failed → installing / upgrading 合法;已成功的迁移脚本记在账本里,重试时跳过
停用后重新启用✅ disabled → enabled
卸载(保留数据)后重装✅ 菜单行与数据都在,uninstalled → installing 合法
彻底删除后重装✅ 表与菜单会重新建出来 —— 前提是迁移记录被删干净,PurgeReport.schemaHistoryDeleted 就是这一项
退回旧版本⚠️ 接口支持:POST /app/install/{installId}/upgrade?versionId=<旧版本ID>(只校验"属于同一应用"且"不与当前同版本")。但宿主前端没有版本选择入口(upgradeApp(installId) 不传 versionId,市场详情的「版本」页签只读),所以退回旧版本要直接调接口
升级到同一版本当作"重装"❌ 直接拒绝「当前已是该版本,无需升级」;要重装走控制台「部署」
自动回滚表结构❌ 迁移只做 up,app.json 里的 down 字段当前不参与执行
存量部署上「安装」走不通

迁移脚本给存量租户回填的安装实例状态是 enabled,而状态机里 enabled → installing 非法,升级到同一版本也会被拒。所以把已有模块落地成插件只有两条路:控制台点「部署」(落位 + 热加载),或发布一个更高版本后升级。给客户交付时建议直接发布 1.0.1,让"升级"成为自然路径。

「部署」不改安装记录,会让停用/启用对不上

「部署」只落位 + 热加载,不动 app_install.version_id;而落位新版本时会清掉旧版本的 jar。于是「部署 1.0.1 → 停用 → 启用」会失败在 loadRuntime:安装记录还指向 1.0.0,找不到那个 jar,报错形如「启用失败:插件后端无法加载 —— 插件 jar 不存在: …」。要动安装记录就走升级。

相关阅读

目录