数据库表
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
本页整理应用市场相关的全部数据表。表结构以 DDL 为准,字段含义参考
wujies-app-market 的实体类;两者不一致处以 DDL 为准(已知的不一致在第 12 张表和
app_version 的索引上,各自小节里都写明了)。
建表脚本来源
| 脚本 | 作用 |
|---|---|
script/sql/update/app_market.sql | 应用市场 M1 增量建表脚本,11 张表全在这里 |
script/sql/update/app_market_app_model.sql | 给 app_info 补 install_mode / audience |
script/sql/update/app_market_home_path.sql | 给 app_info 补 home_path |
script/sql/update/fix_app_version_unique_index.sql | 重建 app_version 的唯一索引 |
script/sql/update/app_market_schema_history_fix.sql | 修正 app_schema_history 的唯一键,见下文 |
script/sql/update/app_market_task_center.sql | 「应用任务」菜单,不建表 |
script/sql/update/app_market_menu_restructure.sql 等菜单类脚本 | 不建表,只整理 sys_menu |
script/sql/update/platform_align_collation.sql | 把上述表统一到 utf8mb4_0900_ai_ci,不改结构 |
全部 CREATE TABLE 都是 IF NOT EXISTS,重复执行安全,也不删任何已有表与数据。
三种数据,别混
| 表名 | 类别 | 是否按租户隔离 | 用途 |
|---|---|---|---|
app_info | 目录 | 否(tenant.excludes) | 应用主表:编码、名称、类型、可见范围、上架状态 |
app_version | 目录 | 否 | 应用版本:清单原文、安装包地址与校验和、签名 |
app_category | 目录 | 否 | 应用分类树 |
app_plan | 目录 | 否 | 应用套餐与价格(M3 计费,当前未见实现) |
app_dependency | 目录 | 否 | 应用间依赖声明 |
app_version_menu | 目录 | 否 | 版本 ↔ sys_menu 归属映射(菜单归属的权威记录) |
app_install | 安装实例 | 是 | 本租户对某应用的安装实例与状态 → 每部署各一份 |
app_install_task | 安装实例 | 是 | 安装/升级/卸载任务的进度与日志 |
app_subscription | 安装实例 | 是 | 应用订阅(M3 计费,当前未见实现) |
app_audit_log | 安装实例 | 是 | 应用操作审计(当前未见实现) |
app_schema_history | 迁移记录 | 是(写入 tenant_id,但判据不带租户) | 迁移脚本执行账本 → 每部署各一份 |
合计 11 张表:目录 6、安装实例 4、迁移记录 1。
两点需要说清楚:
- 「按租户隔离」不等于「表里有
tenant_id列」。app_schema_history有tenant_id且是NOT NULL,但它不在tenant.excludes里,读写一律走TenantHelper.ignore(...)—— 因为表结构在所有租户间共享,判据带上租户会让第二个租户 重复执行 DDL。而app_version_menu没有tenant_id,属于平台级目录数据。 - 目录与安装实例混在同一套表里,但可以搬到中心服务:
app-market.catalog.mode=remote之后,安装侧只通过IAppCatalogService读目录。所以「目录表在本地库」只是local模式的 事实,不是架构约束,详见 应用市场 与 目录与版本形态。
wujies-platform/wujies-admin/src/main/resources/application.yml的tenant.excludes里要包含app_info/app_version/app_category/app_version_menu/app_dependency/app_plan这六张全局表;带tenant_id的app_install/app_install_task/app_subscription/app_audit_log/app_schema_history不要加进去。app_schema_history的唯一键必须以(app_code, script)为准,存量部署跑app_market_schema_history_fix.sql(见下文第 13 节)。
所有表的公共约定:主键是 bigint 雪花 ID(MyBatis-Plus idType=ASSIGN_ID,应用侧赋值,
不是自增);审计列与 RuoYi 规范一致(create_dept / create_by / create_time /
update_by / update_time);引擎 InnoDB,字符集 utf8mb4。
1. app_info —— 应用主表
用途:应用市场的目录主表,一行一个应用;平台方维护,全局一份。
| 字段 | 类型 | 说明 |
|---|---|---|
app_id | bigint NOT NULL | 主键,应用 ID |
app_code | varchar(64) NOT NULL | 唯一,应用编码;包内 app.json、落位目录名、app_schema_history.app_code 都用它 |
app_name | varchar(64) NOT NULL | 应用名称 |
app_type | varchar(16) NOT NULL DEFAULT 'module' | 形态:module 内置 / plugin 插件 / deploy 部署型 |
install_mode | varchar(16) NOT NULL DEFAULT 'optional' | system 平台自带(不可卸载、不进市场)/ optional 可选应用。由 app_market_app_model.sql 补列 |
audience | varchar(64) NOT NULL DEFAULT 'tenant' | 可见范围:platform / tenant / member,逗号分隔。由 app_market_app_model.sql 补列 |
home_path | varchar(200) DEFAULT NULL | 入口路由(如 /gallery)。系统应用必填;已安装应用留空则从菜单树推导。由 app_market_home_path.sql 补列 |
category_id | bigint DEFAULT NULL | 分类 ID → app_category.category_id |
official_flag | char(1) NOT NULL DEFAULT '0' | 是否官方(0 否 1 是) |
latest_version | varchar(32) DEFAULT NULL | 最新版本号(冗余展示字段) |
install_count | int NOT NULL DEFAULT 0 | 安装量 |
rating | decimal(3,2) NOT NULL DEFAULT 0.00 | 评分 |
status | char(1) NOT NULL DEFAULT '0' | 状态:0 草稿 / 1 已上架 / 2 已下架 |
del_flag | char(1) NOT NULL DEFAULT '0' | 逻辑删除:0 存在 / 1 删除 |
sort_num | int NOT NULL DEFAULT 0 | 排序 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
其余展示类字段:summary、description(Markdown 详情)、icon、cover、screenshots
(JSON 数组)、author、author_url、license、tags(逗号分隔)、remark。
PRIMARY KEY (`app_id`),
UNIQUE KEY `uk_app_code` (`app_code`),
KEY `idx_app_category` (`category_id`),
KEY `idx_app_status` (`status`)生命周期:控制台(上架端)在应用创建/编辑时写入,status 由「上架/下架」动作切换。
安装侧只读,且必须走 IAppCatalogService,不许直接注入 AppInfoMapper。应用被删除时是
逻辑删除(del_flag='1')。卸载应用不删这张表的行 —— 目录与安装实例是两件事。
2. app_version —— 应用版本表
用途:一个应用可以有多个版本,每个版本承载一份完整的 app.json(manifest_json);
菜单模板、依赖、迁移脚本、配置表单都从这份清单解析。
| 字段 | 类型 | 说明 |
|---|---|---|
version_id | bigint NOT NULL | 主键,版本 ID |
app_id | bigint NOT NULL | 应用 ID |
version | varchar(32) NOT NULL | 语义化版本 |
manifest_json | longtext NOT NULL | 应用清单(app.json 原文) |
package_url | varchar(500) DEFAULT NULL | 应用包地址(OSS) |
package_size | bigint DEFAULT NULL | 包大小(byte) |
package_md5 | varchar(64) DEFAULT NULL | 包 MD5,下载后校验 |
package_sha256 | varchar(128) DEFAULT NULL | 包 SHA256,下载后校验 |
signature | varchar(512) DEFAULT NULL | 数字签名(第三方应用必填)。实际验签依据是包根 signature.txt,此列是登记信息 |
engine_range | varchar(64) DEFAULT NULL | 平台版本约束,如 >=5.6.2 <6.0.0 |
release_time | datetime DEFAULT NULL | 发布时间 |
status | char(1) NOT NULL DEFAULT '0' | 0 待发布 / 1 已发布 / 2 已弃用 / 3 已下架 |
del_flag | char(1) NOT NULL DEFAULT '0' | 逻辑删除 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
另含 changelog(text)。
PRIMARY KEY (`version_id`),
UNIQUE KEY `uk_app_version` (`app_id`, `version`),
KEY `idx_version_status` (`status`)版本删除是逻辑删除(del_flag='1' 且保留行),而 (app_id, version) 不含 del_flag ——
删掉 1.0.0 之后再新增 1.0.0 会在数据库层撞唯一索引,报 409,而应用层
assertVersionUnique 查的是 del_flag='0',会放行,报错信息看不出真正原因。
fix_app_version_unique_index.sql 的做法是:删掉 uk_app_version,加一列虚拟生成列
version_active(未删除时等于 version,删除后为 NULL),再建
UNIQUE KEY uk_app_version_active (app_id, version_active) —— 利用「MySQL 唯一索引中
NULL 不参与唯一性判断」,让唯一性只作用于存活的行。回滚语句在该脚本头部。
生命周期:控制台在版本创建 / 上传安装包 / 发布时写入,status 由发布、弃用、下架动作切换。
安装与升级按 version_id 读取 package_url 拉包(远程目录模式下同样经 IAppCatalogService)。
3. app_category —— 应用分类表
用途:应用市场的分类树,纯展示与筛选用。
| 字段 | 类型 | 说明 |
|---|---|---|
category_id | bigint NOT NULL | 主键,分类 ID |
parent_id | bigint NOT NULL DEFAULT 0 | 父分类 ID,0 为根 |
category_name | varchar(64) NOT NULL | 分类名称 |
order_num | int NOT NULL DEFAULT 0 | 显示顺序 |
status | char(1) NOT NULL DEFAULT '0' | 状态:0 正常 / 1 停用 |
del_flag | char(1) NOT NULL DEFAULT '0' | 逻辑删除 |
icon | varchar(200) DEFAULT NULL | 图标 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
PRIMARY KEY (`category_id`),
KEY `idx_category_parent` (`parent_id`)生命周期:控制台维护。用户版(edition=client)里分类的读接口保留、写接口被
MarketEditionGuardFilter 挡成 403 —— 市场页要用它做筛选。卸载应用不影响这张表。
4. app_plan —— 应用套餐表
用途:应用套餐与价格定义(M3 计费)。当前未见实现:DDL 里有这张表,但
wujies-app-market 的 domain/ 下没有对应实体类,mapper/ 下没有对应 Mapper,
也没有任何服务或控制器引用它。字段含义按 DDL 注释整理,未在代码中验证。
| 字段 | 类型 | 说明 |
|---|---|---|
plan_id | bigint NOT NULL | 主键,套餐 ID |
app_id | bigint NOT NULL | 应用 ID |
plan_name | varchar(64) NOT NULL | 套餐名称 |
plan_type | varchar(16) NOT NULL | free / trial / paid |
price | decimal(10,2) NOT NULL DEFAULT 0.00 | 价格 |
duration_days | int DEFAULT NULL | 有效天数(NULL = 永久) |
quota_json | varchar(1000) DEFAULT NULL | 配额 JSON(用户数 / 存储 / 调用量) |
goods_id | bigint DEFAULT NULL | 关联商城商品 ID(wujies-mall) |
status | char(1) NOT NULL DEFAULT '0' | 状态:0 正常 / 1 停用 |
del_flag | char(1) NOT NULL DEFAULT '0' | 逻辑删除 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
PRIMARY KEY (`plan_id`),
KEY `idx_plan_app` (`app_id`)生命周期:未找到写入方。它被列在 tenant.excludes 里,属于平台级目录数据
(同一份目录服务上还是本地)。建表脚本的回滚段会把这张表一起 DROP。
5. app_dependency —— 应用依赖表
用途:应用之间的依赖声明(哪个应用需要哪个应用、版本范围是多少)。 当前未见实现:没有实体类、没有 Mapper、没有引用点;字段含义按 DDL 注释整理。
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint NOT NULL | 主键 |
app_id | bigint NOT NULL | 应用 ID |
dep_app_code | varchar(64) NOT NULL | 依赖的应用编码 |
version_range | varchar(64) NOT NULL | 版本约束,如 >=1.0.0 |
optional_flag | char(1) NOT NULL DEFAULT '0' | 是否可选依赖(0 否 1 是) |
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_dep` (`app_id`, `dep_app_code`)生命周期:未找到写入方。与 app_info 同类,列在 tenant.excludes 里,属平台级数据。
注意这张表没有审计列,也不继承任何实体基类 —— 与 app_version_menu 一样是纯关联表。
6. app_version_menu —— 应用版本菜单关联表
用途:菜单归属的权威记录。应用上架时把清单里的菜单幂等 upsert 进 sys_menu,
并在此登记 menu_key → menu_id 的映射;租户开通应用时,就是把这些 menu_id 并入该租户的
套餐 / 角色授权。详细推导见下文第 12 节。
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint NOT NULL | 主键 |
version_id | bigint NOT NULL | 应用版本 ID → app_version.version_id |
menu_id | bigint NOT NULL | sys_menu.menu_id |
menu_key | varchar(128) NOT NULL | 应用内菜单唯一键,幂等 upsert 的依据;只允许小写 kebab(小写字母开头、小写字母/数字/连字符、2–63 位) |
PRIMARY KEY (`id`),
UNIQUE KEY `uk_version_menu` (`version_id`, `menu_id`),
UNIQUE KEY `uk_version_menu_key` (`version_id`, `menu_key`),
KEY `idx_version_menu_menu` (`menu_id`)生命周期:由菜单同步(AppMenuSyncServiceImpl)在安装 / 升级 / 手动「重新同步菜单」时写入,
按 menu_key 幂等 upsert,租户已分配的权限不能失效。读取方是菜单归属反查
(AppMenuOwnershipProvider:app_install → app_info → app_version → 本表)与「彻底删除」。
「彻底删除」时这张表的映射按三条线索并行清理(menu_id ∈ 本次删掉的菜单 ∪
menu_key ∈ 清单声明的键 ∪ version_id ∈ 该应用已知版本),少一条就会留下「死映射」——
下次安装复用指向已删除菜单的 ID,表现为「安装成功、状态 enabled,但一个菜单都没有」。
没有 tenant_id(平台级数据),也没有审计列 —— 因此实体 AppVersionMenu 不继承
BaseEntity,直接实现 Serializable。
7. app_install —— 租户应用安装实例表
用途:多租户核心表。一个租户对某个应用至多一条记录,状态迁移由状态机约束。
| 字段 | 类型 | 说明 |
|---|---|---|
install_id | bigint NOT NULL | 主键,安装实例 ID |
tenant_id | varchar(20) NOT NULL | 租户字段,多租户插件自动隔离 |
app_id | bigint NOT NULL | 应用 ID |
version_id | bigint NOT NULL | 当前版本 ID |
status | varchar(16) NOT NULL DEFAULT 'enabled' | 状态:installing / installed / enabled / disabled / upgrading / failed / uninstalling / uninstalled;对外可见的判据只有 enabled |
config_json | longtext | 应用配置(按 manifest.config 渲染存储) |
enabled_time / disabled_time | datetime DEFAULT NULL | 启用 / 停用时间 |
expire_time | datetime DEFAULT NULL | 到期时间(NULL = 永久) |
source | varchar(16) DEFAULT NULL | 来源:admin 自行 / market 购买 / trial 试用 |
health_status | varchar(16) DEFAULT NULL | 健康检查结果 |
last_health_time | datetime DEFAULT NULL | 最近健康检查时间 |
del_flag | char(1) NOT NULL DEFAULT '0' | 逻辑删除 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
PRIMARY KEY (`install_id`),
UNIQUE KEY `uk_tenant_app` (`tenant_id`, `app_id`, `del_flag`),
KEY `idx_install_tenant_status` (`tenant_id`, `status`)uk_tenant_app 把 del_flag 也算进了唯一键:这是「同一租户同一应用的历史安装记录」与
app_version 不同的处理方式 —— 一个应用在同一租户下卸载过再装,靠的是复用同一条记录
(状态回到 enabled),而不是插新行。
生命周期:AppInstallServiceImpl 编排安装 / 升级 / 启用 / 停用 / 卸载,每一步都写
status。「保留数据卸载」不删这一行,只把状态改成 uninstalled 并回收本租户授权;
「彻底删除」在这张表里做物理删除,且排在任务状态落库之后,否则终态无处可写。
app_install_task 与它的关系是「一次操作」对「一个实例」:任务记录在彻底删除时刻意保留
(审计线索),代价是历史行会显示「(应用已删除)」。
8. app_install_task —— 应用安装任务表
用途:安装 / 升级 / 卸载是会产生副作用的操作,用户需要看到「现在到哪一步、花了多久、
失败原因是什么、完整日志」。接口立即返回 taskId,前端订阅进度。
| 字段 | 类型 | 说明 |
|---|---|---|
task_id | bigint NOT NULL | 主键,任务 ID |
install_id | bigint NOT NULL | 安装实例 ID |
tenant_id | varchar(20) DEFAULT NULL | 租户字段(DDL 里可空) |
task_type | varchar(16) NOT NULL | install / upgrade / repair / enable / disable / uninstall / rollback |
from_version / to_version | varchar(32) DEFAULT NULL | 起始 / 目标版本 |
current_step | varchar(64) DEFAULT NULL | 当前步骤 |
progress | int NOT NULL DEFAULT 0 | 进度 0–100 |
log_text | longtext | 执行日志,AppTaskRecorder 边跑边落库 |
status | varchar(16) NOT NULL DEFAULT 'running' | 状态:running / success / failed / canceled |
error_msg | text | 失败原因 |
start_time / end_time | datetime DEFAULT NULL | 开始 / 结束时间 |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
PRIMARY KEY (`task_id`),
KEY `idx_task_install` (`install_id`),
KEY `idx_task_status` (`status`)生命周期:任务开始时插入一行,AppTaskRecorder 不断追加 current_step / progress /
log_text,结束时落 status 与 error_msg。它是排查问题的第一入口,比翻服务端日志快,
见任务中心。
彻底删除应用时刻意不删:这张表是「谁在什么时候卸载了什么」的审计线索,代价是安装记录删掉后
任务列表里查不到应用名(前端 appMissing 字段就是为此)。
9. app_subscription —— 应用订阅表
用途:租户对应用套餐的订阅记录(M3 计费)。当前未见实现:DDL 里有这张表,
但 wujies-app-market 下没有对应实体类与 Mapper,也没有引用点;字段含义按 DDL 注释整理。
| 字段 | 类型 | 说明 |
|---|---|---|
sub_id | bigint NOT NULL | 主键,订阅 ID |
tenant_id | varchar(20) NOT NULL | 租户字段 |
app_id | bigint NOT NULL | 应用 ID |
plan_id | bigint NOT NULL | 套餐 ID → app_plan.plan_id |
order_id | bigint DEFAULT NULL | 商城订单 ID |
start_time / end_time | datetime DEFAULT NULL | 生效 / 到期时间 |
status | varchar(16) NOT NULL | 状态:active / expired / canceled / refunded(DDL 未给默认值) |
create_time / update_time | datetime DEFAULT NULL | 创建 / 更新时间 |
PRIMARY KEY (`sub_id`),
KEY `idx_sub_tenant_app` (`tenant_id`, `app_id`),
KEY `idx_sub_status` (`status`)生命周期:未找到写入方。它不在 tenant.excludes 里(带 tenant_id,交给多租户插件隔离),
所以「按租户隔离」这一列的判断依据是配置而非列名。
10. app_audit_log —— 应用操作审计表
用途:应用操作的审计流水。当前未见实现:DDL 里有这张表,但没有对应实体类与
Mapper,也没有引用点;字段含义按 DDL 注释整理。当前事实上的操作记录在 app_install_task
的日志里。
| 字段 | 类型 | 说明 |
|---|---|---|
log_id | bigint NOT NULL | 主键,日志 ID |
tenant_id | varchar(20) DEFAULT NULL | 租户字段(DDL 里可空) |
app_id | bigint DEFAULT NULL | 应用 ID |
action | varchar(32) NOT NULL | install / enable / disable / upgrade / uninstall / config |
operator | varchar(64) DEFAULT NULL | 操作人 |
detail | text | 详情 |
ip | varchar(64) DEFAULT NULL | 操作 IP |
create_time | datetime DEFAULT NULL | 操作时间(DDL 注释如此,语义上等同创建时间) |
PRIMARY KEY (`log_id`),
KEY `idx_audit_app_time` (`app_id`, `create_time`),
KEY `idx_audit_tenant` (`tenant_id`)生命周期:未找到写入方。同样不在 tenant.excludes 里。
11. app_schema_history —— 应用迁移执行历史表
用途:版本化迁移的账本。迁移引擎靠它判断「这个脚本跑过没有、内容有没有变」, 是最容易改错、也最值得看清楚的一张表 —— 详细解析见下文第 13 节。
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint NOT NULL | 主键 |
app_code | varchar(64) NOT NULL | 应用编码,去重判据的一半 |
script | varchar(255) DEFAULT NULL | 脚本相对路径(如 01-create-exam-tables.sql),去重判据的另一半 |
version | varchar(32) NOT NULL | 目标版本。语义是「这个脚本最早由哪个版本引入」,只作追溯 |
tenant_id | varchar(20) NOT NULL | 首次应用该脚本的租户,只作追溯;判据不带它 |
checksum | varchar(64) DEFAULT NULL | 脚本内容摘要,用于篡改检测 |
success | char(1) NOT NULL DEFAULT '1' | 状态:是否执行成功(0 否 1 是) |
execution_ms | bigint DEFAULT NULL | 执行耗时(ms) |
create_time | datetime DEFAULT NULL | 执行时间 |
update_time | datetime DEFAULT NULL | 更新时间 |
PRIMARY KEY (`id`),
UNIQUE KEY `uk_schema_history` (`app_code`, `script`)不要改回 (tenant_id, app_code, version)。 理由与后果见下文专节。
生命周期:AppMigrationService 在每个脚本执行后写一行(读写都走 TenantHelper.ignore(...))。
checksum 是按脚本比对的,所以幂等的最小单位是「脚本」而不是「版本」。
「彻底删除」时必须删掉该应用的全部账本行,否则重装时建表脚本会被「已执行过」跳过,表再也建不回来。
AppSchemaHistory 不继承 TenantEntity,直接实现 Serializable:它记录的是表结构变更,
而表结构在整个部署里全局共享(多租户共用一套表,靠 tenant_id 列区分数据,不是分库分表)。
字段里也没有 create_by / update_by / create_dept。
12. app_version_menu 与菜单归属
这一节单独讲 app_version_menu,因为它是「哪些菜单属于这个应用」的唯一权威记录,
也是菜单保护与彻底删除的共同依据。
记录了什么
version_id → 哪个应用版本
menu_id → sys_menu 里那一行
menu_key → 应用清单里的稳定标识(幂等 upsert 依据)三条线索各有用处:
- 上线(菜单同步)用
menu_key:同一个应用升级时清单里menu_key不变, 于是复用既有的menu_id而不是插新菜单 —— 这是「租户已分配的权限不能失效」的实现方式; - 反查归属用
menu_id:AppMenuOwnershipProvider由app_install→app_info→app_version→ 本表,得到「这个menu_id属于哪个应用」,菜单管理页据此打标签、禁用删除; - 彻底删除用三者并行:见第 6 张表的生命周期说明。
为什么不用给 sys_menu 加 app_code
菜单同步时已经写下了这份映射,它就是权威记录。 再加一列会带来三个具体代价:
- 要动框架的实体 / VO / Mapper XML / 菜单管理页面(
sys_menu是平台表,改它等于改框架); - 会形成两份真相 ——
sys_menu.app_code与app_version_menu迟早不一致, 而两者的读者(菜单保护、彻底删除、工作台入口)对不一致的容忍度是零; - 归属必须是「版本 → 菜单」的关系,而不是「菜单 → 应用」:同一个应用的不同版本 可以有不同菜单树,按版本记录才能在升级时回答「这个菜单是这一版新加的,还是上一版就有的」。
映射表里只有清单声明过的菜单,而 sys_menu 里还有按钮(menu_type='F',如
gallery:image:query)—— 它们历史上来自平台初始化 SQL,从来不在映射表里。
所以归属计算与删除都必须用 AppMenuTreeExpander.withDescendants(...) 把后代一并收进来,
否则按钮会变成孤儿(父菜单没了、自己还挂在 parent_id 上)。
配套要求:清单里必须写按钮(type: "F" + menu_key),否则全新部署装完没有按钮,
前端 v-hasPermi 过不了;彻底删除也删不掉它们。
13. app_schema_history 的唯一键
这一节单独讲账本表,因为它的错误形态不会在开发环境暴露,只会在生产安装时炸。
判据为什么是 (app_code, script)
| 判据 | 为什么 |
|---|---|
含 app_code | 账本是按应用分账的,一个部署里装着多个应用 |
含 script | 幂等的最小单位是「脚本」而不是「版本」 —— checksum 也是按脚本比对的;一个版本可以有多个脚本 |
不含 tenant_id | 表结构在所有租户间共享,带上租户会让第二个租户以为没执行过、重复跑 DDL(CREATE TABLE 直接失败) |
不含 version | version 列保留,语义只是「这个脚本最早由哪个版本引入」,是追溯信息 |
曾经写错成什么样,后果是什么
表定义原本是:
UNIQUE KEY `uk_schema_history` (`tenant_id`, `app_code`, `version`) -- 错误:少了 script、多了 version而代码里的记账判据是 (app_code, script)。两者不一致的后果非常具体:
一个版本里只要有第二个迁移脚本,第二次记账就撞唯一键,整个安装 / 升级直接失败:
Duplicate entry '000000-exam-1.0.1' for key 'app_schema_history.uk_schema_history'实测场景是 exam 1.0.1(migrations/01-create-exam-tables.sql +
02-create-exam-dictionaries.sql)升级报上面那句错。
这个洞藏得久,是因为所有应用的 migrations/ 一直只有一个脚本。
但只要某个应用加第二个脚本,全新部署的安装同样会失败 —— 报错信息里只有
tenant_id-app_code-version 拼出来的那段,看不出「少了一列」这件事。
修复脚本
script/sql/update/app_market_schema_history_fix.sql,幂等、可重复执行、不需要重启:
- 先按
(app_code, script)去重,同一组合只留id最小的那一行 (唯一键建不起来就白改了,所以先清干净); - 用
information_schema.STATISTICS判断后删掉旧键(MySQL 没有DROP INDEX IF EXISTS); - 建新键
UNIQUE KEY uk_schema_history (app_code, script),已有script列则跳过; - 打印
information_schema.STATISTICS自检 —— 应当是正好两列。
平台建表脚本 app_market.sql 也已同步改正。回归测试
AppMigrationLedgerDdlTest 会在 DDL 与代码不一致时直接失败。
把唯一键改回 (tenant_id, app_code, version) 会让「一个版本里只要有第二个迁移脚本就装不上」
这个故障重新出现。这是实测踩到的真问题,修复脚本的文件名就是
app_market_schema_history_fix.sql。
记账之后的连带动作
- 脚本内容不能改:同一个
script再出现时比对checksum,不一致直接报错拒绝执行 —— 否则同名脚本会跑出两种结果,无法回溯。要变更请新增脚本。 ⚠️ 工作区是 CRLF、Linux 上是 LF,语义相同摘要不同,所以checksumMatches同时认两种摘要 (记账统一存 LF 归一化摘要),真正的改动照旧拒绝。 - 改过已执行脚本的做法是「先修账本、再升级」:把 5 个应用的
01-*-tables.sql里的示例数据 拆到新脚本后,存量部署必须先跑script/sql/update/app_market_app_seed_split_repair.sql删掉那几行账本记录, 否则升级会被「脚本已被修改」挡住。 - 迁移只做
up,不回滚:MySQL 的 DDL 隐式提交,没法整体回滚;重试时已成功的会跳过。 - 脚本里补的字典 / 站点配置要失效缓存:执行后
AppMigrationService自动清SYS_DICT/SYS_DICT_TYPE/SYS_CONFIG,否则接口继续返回迁移前的标签。
14. 卸载时会删哪些表
「彻底删除」(purge=true)会 DROP 应用自己的数据表,依据是应用包内
migrations/*.sql 里的 CREATE TABLE,而不是任何一份手工维护的表清单。
AppMigrationService.declaredTables(appCode)扫描落位目录<pluginDir>/migrations/<appCode>/,按文件名排序逐个解析;parseDeclaredTables是纯函数(有单测AppPurgeTableParsingTest):必须先剥注释 (--/#行注释与块注释,字符串字面量里的同形字符不动),否则脚本头部 「请用 CREATE TABLE IF NOT EXISTS」这类说明会被当成表名;- 只认
CREATE TABLE:ALTER/INSERT/DROP都不构成「拥有」某张表; - 表名可以带反引号 / 双引号 / 方括号,也可以带库名前缀(
db.tbl,取最后一段)。
这也解释了为什么建表语句必须放进应用包:包里没有脚本,平台就无从知道该删什么, 任务日志里会明确警告「该应用没有声明任何数据表」。
两道闸
闸一:别的租户还在用就不动全局的东西。
sys_menu、数据表、迁移记录、本机产物都是一个部署内全局共享的。只要
app_install 里还有状态非 uninstalled 的其它记录,就跳过菜单 / 表 / 迁移记录 / 产物的删除,
只在任务日志里说清楚。判据必须排除自己那条记录 —— 此时它的状态还没被改成 uninstalled
(状态是任务成功后才落的),不排除的话每次卸载都会「发现还有一个租户在用」,功能直接失效。
闸二:平台表前缀受保护。 只删迁移脚本声明过的表,且名字命中以下前缀时一律跳过并告警:
sys_ app_ gen_ flow_ act_ qrtz_ snailjob_ undo_log判据刻意是前缀而不是精确表名:平台表会持续增加(每个版本都可能加),精确名单一定会漏,
而漏的后果是删掉平台数据。防的场景很具体 —— 一个写错的脚本
(CREATE TABLE IF NOT EXISTS app_install)把平台表带下水。
删表前还会用 [A-Za-z0-9_$]+ 再校验一次表名,确保拼进 DDL 的字符串不可能带出注入。
一次彻底删除的完整清单
| 对象 | 依据 |
|---|---|
| 应用数据表 | migrations/*.sql 的 CREATE TABLE(剥注释后解析),受前缀保护 |
| 菜单(含后代) + 所有角色授权 | app_version_menu 的映射 + AppMenuTreeExpander |
app_version_menu 映射 | menu_id / menu_key / version_id 三条线索并行 |
app_schema_history 账本行 | app_code,必须删,否则重装建不出表 |
| 应用自带的字典与站点配置 | dict_type LIKE '<appCode>_%'、config_category = <appCode> 或 config_key LIKE '<appCode>.%' |
本机产物(jar / 前端目录 / active-plugins.txt 记录) | 落位目录,尽力而为(Windows 上被 JVM 占用的 jar 删不掉会如实报出来) |
app_install 安装记录 | 物理删除,排在任务状态落库之后 |
刻意不做:app_install_task 保留(审计线索);对象存储里的图片等外部文件不删
(平台不知道它们属于谁,也不该替用户删文件)。
「保留数据卸载」(purge=false,默认)只做两件事:撤本租户授权 + unload(关子上下文、
类加载器、注销接口)。菜单行、数据表、账本、产物、安装记录全部保留。
两种模式的对比见应用生命周期。
未找到的信息
app_plan/app_subscription/app_audit_log/app_dependency的写入方: 四张表在 DDL 里齐全,但代码里没有实体类、没有 Mapper、没有引用点。app_install_task.tenant_id与app_audit_log.tenant_id为何在 DDL 里可空、 而实体继承的TenantEntity语义上要求非空:未找到说明。- 与订阅 / 计费相关的服务、接口、菜单:未找到。
app_info.install_count与rating的写入方(是否有统计任务):未找到。- 历史注释提到过、但当前代码中不存在的
app_route_index表:表不存在, 它只是关于路由墓碑未来可选落库的一句设计说明。