应用生命周期
五个动作,全部不需要重启
| 动作 | 后端行为 |
|---|---|
| 部署 | 落位 + 热加载(卸载旧插件 → 用新 jar 建上下文 → 注册接口) |
| 安装 | 本机缺产物 → 从市场按 packageUrl 拉包 → 落位 → 加载 |
| 升级 | 同上 + 菜单幂等 upsert |
| 卸载(保留数据,默认) | unload(关子上下文 + 类加载器 + 注销接口)+ 回收本租户授权;菜单行与数据保留 |
| 卸载(彻底删除) | 上面这些 + 删菜单 / 删数据表 / 删迁移记录 / 删落位产物 / 删安装记录 |
| 停用 / 启用 | unload / 重新 load |
落位与加载是两件事
这是整套顺序保证的关键。
| 方法 | 行为 | 谁用 |
|---|---|---|
deploy(versionId) | 落位 + 立即加载 | 控制台「部署」按钮(它不跑迁移) |
deploy(versionId, false) | 只落位 | 安装 / 升级 |
安装器传 false,加载交给排在迁移之后的 assertBackendLoaded —— 它本来就会"磁盘上有就装上"。
落位 → 变更表结构 → 切换代码。 所以插件在启动时机直接读新加的列是安全的。
实测:任务日志里迁移脚本那行在偏移 221,后端产物那行在 269 —— 迁移确实在前。
「落位」落的是两样东西:
| 清单字段 | 落位到 | 默认值 |
|---|---|---|
assets.backend | app-market.deploy.plugin-dir 根下 | plugins-dist(必须与 -Dloader.path 一致) |
assets.frontend 所在目录整棵树 | app-market.deploy.frontend-dir/<appCode>/ | ../plus-ui/app-market-dist/remote-apps |
菜单里的 component = remote:exam/exam/settings 只是视图键, 真正执行的是那份独立 ESM。
升级会强制重新落位
「文件存在就跳过」只在同版本重装时成立。升级时若沿用旧产物会同时踩两个静默坑: 前端还是旧版、迁移脚本根本不落位。
状态机
UNINSTALLED → INSTALLING → INSTALLED → ENABLED ⇄ DISABLED
↓ ↓ ↓
FAILED UNINSTALLING ←┘
↓ ↓
(可重试) UNINSTALLED
ENABLED / DISABLED → UPGRADING → (回到原启停状态)几个细节:
- 应用对外可见的判据只有
ENABLED; - 升级保留原启停状态 —— 已停用的应用升级完仍然是停用;
- 「已处于目标状态」对启用/停用/卸载给出可读提示,而不是让状态机抛错;
FAILED可以重试(重新 install / upgrade / uninstall)。
存量部署上「安装」走不通
迁移脚本回填的安装实例状态是 enabled,而状态机里 enabled → installing 非法, 升级到同一版本也会被拒(「当前已是该版本」)。
所以把已有模块落地成插件只有两条路:
- 在上架端点**「部署」**(落位 + 热加载);
- 发布一个更高版本后升级。
给客户交付时建议直接发布 1.0.1,让"升级"成为自然路径。
安装的完整顺序
① 生成 app_install_task 记录
② 检查本机有没有产物
├─ 已有 → 就地使用(同一版本重装)
└─ 没有 / 正在升级 → 从市场按 packageUrl 下载
├─ 校验 MD5 / SHA256
├─ 安全检查:产物白名单 + 签名校验
├─ 解包落位
└─ 记录 active-plugins.txt
③ 执行包内 migrations/*.sql(按文件名排序,幂等,只做 up)
④ 加载后端插件(先卸载旧的 → 建新上下文 → 注册接口) ← 必须在 ③ 之后
⑤ 同步菜单与授权(按清单幂等 upsert)
⑥ 任务置为成功最后一道断言:后端真的加载了吗
assertBackendLoaded 处理的是一件事:「磁盘上有一个 jar」与「JVM 把它挂到 classpath 上」 是两件完全不同的事。
它依次问:
- 插件运行时:这个 appCode 当前加载的是哪个 jar?
- classpath 探针:这个 jar 真的在当前 JVM 里吗?
- 都没有但磁盘上有 → 直接 load;
- 磁盘上也没有 → 拒绝,并把失败原因分成两条互不相同的处置建议: "还没部署 → 去点部署"vs"启动方式不对 → 用
-Dloader.path启动"。
停用与启用
| 动作 | 行为 | 顺序为什么是这样 |
|---|---|---|
| 停用 | 撤本租户菜单授权 + unload | 先撤授权,用户立刻看不到入口 |
| 启用 | 先 load 再放出菜单 | 反过来用户会先看到入口却打不开 |
加载失败必须让启用失败,不能"看起来启用了"。
卸载的两种模式
前端弹窗只给两个选项,默认落在可恢复的那条路。
| 步骤 | 谁做 | 说明 |
|---|---|---|
| 撤本租户授权 + unload | 安装器 | 两种模式都做 |
| 删菜单(含后代)+ 所有角色授权 + 归属映射 | 清理服务 | 菜单是全局行,要先过"别人还在用吗"这道闸 |
| DROP 数据表 | 清理服务 | 表名来自迁移脚本里的 CREATE TABLE |
| 删迁移记录 | 清理服务 | 必须删,否则重装时建表脚本被"已执行过"跳过,表再也建不回来 |
删产物(jar / 前端目录 / active-plugins.txt 记录) | 落位服务 | 尽力而为:Windows 上被 JVM 占用的 jar 删不掉会如实报出来 |
| 删安装记录 | 清理服务 | 排在任务状态落库之后,否则终态无处可写 |
两道安全闸
闸一:别人还在用就不动全局的东西。sys_menu、数据表、本机产物都是一个部署内全局共享的。只要还有别的租户装着, 就跳过菜单 / 表 / 迁移记录 / 产物的删除,只在任务日志里说清楚。
判据必须排除自己那条记录 —— 此时它的状态还没被改成 uninstalled, 不排除的话每次卸载都会"发现还有一个租户在用",功能直接失效。
闸二:平台表前缀受保护。 只删迁移脚本声明过的表,且名字命中 sys_ / app_ / gen_ / flow_ / act_ / qrtz_ / snailjob_ / undo_log 前缀时一律跳过并告警 —— 防止一个写错的脚本(CREATE TABLE IF NOT EXISTS app_install)把平台表带下水。
刻意不做
- 任务记录(
app_install_task)保留 —— 它是审计线索。代价是历史行会显示「(应用已删除)」。 - 对象存储里的文件不删 —— 平台不知道它们属于谁,也不该替用户删文件。
卸载里三个踩过的坑
删菜单必须连后代一起删
归属映射里只有清单声明过的菜单,而 sys_menu 里还有按钮(menu_type='F')—— 它们历史上来自平台初始化 SQL,从来不在映射表里。
只删映射到的行,按钮就变成孤儿(父菜单没了、自己还挂在 parent_id 上), 菜单管理页里一堆没有父级的按钮。实测踩到过(23 个 gallery 按钮)。
清归属映射要按三条线索并行
menu_id ∈ 本次删掉的菜单
∪ menu_key ∈ 清单声明的键
∪ version_id ∈ 该应用已知版本少一条就会留下"死映射":某条映射挂在软删除的旧版本上(version_id 已不在目录里), 前两条都找不到它。而菜单同步是按 appId 跨版本解析 key → menu_id 的 —— 下次安装/升级复用它指向的已删除菜单 ID,updateById 影响 0 行、映射照样写回, 结果是**「安装成功、状态 enabled,但一个菜单都没有」**。
配套地,菜单同步现在会自愈:同步前把"映射指向已不存在的菜单行"的项丢掉、 按新菜单重建,并 WARN 出被丢的键。
表名解析要先剥注释
只认 CREATE TABLE:ALTER / INSERT / DROP 都不构成"拥有"某张表。 并且必须先剥注释 —— 脚本头部常写「请用 CREATE TABLE IF NOT EXISTS」这类说明, 不剥就会把注释里的名字当成表名。
回滚能力
| 动作 | 支持吗 |
|---|---|
| 升级失败后重试 | ✅ 已成功的迁移会跳过 |
| 退回旧版本 | ✅ 从市场选旧版本安装(菜单按旧版清单同步) |
| 应用卸载 | ✅ 两种模式 |
| 自动回滚表结构 | ❌ 不支持 —— 迁移只做 up |