应用生命周期
这一页讲七个生命周期动作(部署 / 安装 / 升级 / 停用 / 启用 / 卸载 / 彻底删除)在后端各自做了什么、按什么顺序做、失败停在哪一步,以及「彻底删除」为什么会被一道共享闸拦下一半。它面向要改安装或卸载流程的人。
界面上的操作入口与排查速查见 在部署里使用应用,任务日志怎么读见 任务中心与日志。
实现全在 wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/;下面每个结论都标了类名或配置键,可以逐条核对。
编排的收口:一个入口、一张状态机
| 环节 | 类 | 文件 |
|---|---|---|
| 生命周期入口与状态推进 | AppInstallServiceImpl(实现 IAppInstallService) | service/impl/AppInstallServiceImpl.java |
| 合法迁移表 | AppLifecycleStateMachine | state/AppLifecycleStateMachine.java |
| 状态编码与中文名 | AppInstallStatus | enums/AppInstallStatus.java |
| 执行器选择 | AppInstaller.supports() | spi/AppInstaller.java |
| 插件型执行器 | PluginAppInstaller extends ModuleAppInstaller | spi/impl/PluginAppInstaller.java |
| 模块型执行器 | ModuleAppInstaller | spi/impl/ModuleAppInstaller.java |
| 进度边跑边落库 | AppTaskRecorder(ThreadLocal) | support/AppTaskRecorder.java |
| 任务表实体 | AppInstallTask、AppTaskType、AppTaskStatus | domain/、enums/ |
执行器按清单里的 type 选:AppInstallServiceImpl.requireInstaller 用 installer.supports(manifest) 过滤,默认实现就是比 manifest.type 与 installer.type()。AppInstaller 的注释里预留了 module / plugin / deploy 三种,但 spi/impl/ 下只有前两种有实现类 —— 清单写 type: deploy 会报「没有可处理该应用的安装器」。
合法迁移表
起点是 app_install.status 的当前值(AppInstallStatus,无记录表示"未安装"):
| 起始状态 | 允许到达 |
|---|---|
| 无记录(未安装) | installing |
installing | installed、failed |
installed | enabled、uninstalling、failed |
enabled | disabled、upgrading、uninstalling、failed |
disabled | enabled、upgrading、uninstalling、failed |
upgrading | enabled、disabled、failed |
failed | installing、upgrading、uninstalling、uninstalled |
uninstalling | uninstalled、failed |
uninstalled | installing |
四条读表时容易忽略的:
- 只有
enabled算"对本租户可见"(AppLifecycleStateMachine.isVisible);installed不可见。 installed只是路径中间态。安装的成功路径是installing → installed → enabled(装完即启用),而successPathOf的终态才落库 —— 所以实际停靠的状态里不会出现installed。- 升级保留原启停状态:路径写成
upgrading → disabled(原本停用)或upgrading → enabled。 - 没有任何状态能直达
uninstalled,必须先过uninstalling。历史上有一次把卸载路径写成[uninstalled],于是disabled → uninstalled直接抛「非法的应用状态迁移」—— 现在所有路径集中在successPathOf一处,就是为了不再各写一份。
runTask 的三条实现约束
- 整条路径先校验、后执行:
AppInstallServiceImpl.runTask在插入app_install_task之前把成功路径逐跳喂给AppLifecycleStateMachine.transit。非法就抛「非法的应用状态迁移: …」,而且不产生任务行。 - "已处于目标状态"只对三类动作生效(
goalStatusOf):启用 / 停用 / 卸载。安装与升级刻意不做这个判断 —— 升级一个已停用的应用时路径终态恰好等于当前状态(upgrading → disabled),按终态判重会把"已停用的应用升不了级"做成一个死结。 - 失败必须落库,连
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 |
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,那时按清单过滤,读不到清单(文件缺失或为空)则不过滤 |
两个加载点合起来看:同一个 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),成立就跳过"文件已存在就用本机产物"这条捷径。沿用旧产物会同时踩两个静默坑:
- 前端产物按定义是旧版,而"后端 jar 在磁盘上、前端入口也存在"已经满足提前返回的条件 → 前端永远不会被更新;
- 包内的
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,顺序固定):
- 下载(单包上限 200MB,
download); verifyChecksum:比对app_version的package_md5/package_sha256。两个都没记时只 WARN 跳过(手工填packageUrl的版本就是这种);PackageSecurityInspector.inspectOrThrow:产物白名单 + 验签。app-market.security.mode=enforce下直接抛异常,包连解都不会解、插件目录里不会落任何文件;- 解包落位(含 zip-slip 防护
safeResolve、条目数与单条目大小上限),写active-plugins.txt。
见 包安全与签名。
③ 迁移:扫 <plugin-dir>/migrations/<appCode>/,按文件名排序逐个执行,记账到 app_schema_history(键 (app_code, script)),执行后清字典与参数的 Redis 缓存。失败不吞异常 —— 表结构没变更成功却继续装菜单,会留下"装上了但用不了"的坏状态。四条硬规矩见 数据库迁移。
④ assertBackendLoaded 依次问三处:
PluginRuntimeManager.loadedJar(appCode, jarName)—— 插件运行时里加载的是不是这个 jar;顺序不能反,插件运行时模式下 classpath 探针必然查不到,先查探针会把"已加载"判成"没加载";PluginRuntimeProbe.findLoadedJar(jarName)—— classpath / TCCL 上有没有(兼容-Dloader.path那条路);- 都没有但磁盘上有 → 直接
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)默认选中「保留数据」;选「彻底删除」必须把应用名完整敲一遍才亮确认按钮。
| 步骤 | 谁做 | 保留数据 | 彻底删除 |
|---|---|---|---|
撤本租户菜单授权 + unload | ModuleAppInstaller.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 不存在: …」。要动安装记录就走升级。