Wujies 无界平台
参考

数据库表

本文是 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 模式的 事实,不是架构约束,详见 应用市场 与 目录与版本形态。
执行建表脚本后必须同步改两处
  1. 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 不要加进去。
  2. 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_idbigint NOT NULL主键,应用 ID
app_codevarchar(64) NOT NULL唯一,应用编码;包内 app.json、落位目录名、app_schema_history.app_code 都用它
app_namevarchar(64) NOT NULL应用名称
app_typevarchar(16) NOT NULL DEFAULT 'module'形态:module 内置 / plugin 插件 / deploy 部署型
install_modevarchar(16) NOT NULL DEFAULT 'optional'system 平台自带(不可卸载、不进市场)/ optional 可选应用。由 app_market_app_model.sql 补列
audiencevarchar(64) NOT NULL DEFAULT 'tenant'可见范围:platform / tenant / member,逗号分隔。由 app_market_app_model.sql 补列
home_pathvarchar(200) DEFAULT NULL入口路由(如 /gallery)。系统应用必填;已安装应用留空则从菜单树推导。由 app_market_home_path.sql 补列
category_idbigint DEFAULT NULL分类 ID → app_category.category_id
official_flagchar(1) NOT NULL DEFAULT '0'是否官方(0 否 1 是)
latest_versionvarchar(32) DEFAULT NULL最新版本号(冗余展示字段)
install_countint NOT NULL DEFAULT 0安装量
ratingdecimal(3,2) NOT NULL DEFAULT 0.00评分
statuschar(1) NOT NULL DEFAULT '0'状态:0 草稿 / 1 已上架 / 2 已下架
del_flagchar(1) NOT NULL DEFAULT '0'逻辑删除:0 存在 / 1 删除
sort_numint NOT NULL DEFAULT 0排序
create_time / update_timedatetime 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_idbigint NOT NULL主键,版本 ID
app_idbigint NOT NULL应用 ID
versionvarchar(32) NOT NULL语义化版本
manifest_jsonlongtext NOT NULL应用清单(app.json 原文)
package_urlvarchar(500) DEFAULT NULL应用包地址(OSS)
package_sizebigint DEFAULT NULL包大小(byte)
package_md5varchar(64) DEFAULT NULL包 MD5,下载后校验
package_sha256varchar(128) DEFAULT NULL包 SHA256,下载后校验
signaturevarchar(512) DEFAULT NULL数字签名(第三方应用必填)。实际验签依据是包根 signature.txt,此列是登记信息
engine_rangevarchar(64) DEFAULT NULL平台版本约束,如 >=5.6.2 <6.0.0
release_timedatetime DEFAULT NULL发布时间
statuschar(1) NOT NULL DEFAULT '0'0 待发布 / 1 已发布 / 2 已弃用 / 3 已下架
del_flagchar(1) NOT NULL DEFAULT '0'逻辑删除
create_time / update_timedatetime DEFAULT NULL创建 / 更新时间

另含 changelog(text)。

PRIMARY KEY (`version_id`),
UNIQUE KEY `uk_app_version` (`app_id`, `version`),
KEY `idx_version_status` (`status`)
uk_app_version 已被替换

版本删除是逻辑删除(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_idbigint NOT NULL主键,分类 ID
parent_idbigint NOT NULL DEFAULT 0父分类 ID,0 为根
category_namevarchar(64) NOT NULL分类名称
order_numint NOT NULL DEFAULT 0显示顺序
statuschar(1) NOT NULL DEFAULT '0'状态:0 正常 / 1 停用
del_flagchar(1) NOT NULL DEFAULT '0'逻辑删除
iconvarchar(200) DEFAULT NULL图标
create_time / update_timedatetime 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_idbigint NOT NULL主键,套餐 ID
app_idbigint NOT NULL应用 ID
plan_namevarchar(64) NOT NULL套餐名称
plan_typevarchar(16) NOT NULLfree / trial / paid
pricedecimal(10,2) NOT NULL DEFAULT 0.00价格
duration_daysint DEFAULT NULL有效天数(NULL = 永久)
quota_jsonvarchar(1000) DEFAULT NULL配额 JSON(用户数 / 存储 / 调用量)
goods_idbigint DEFAULT NULL关联商城商品 ID(wujies-mall)
statuschar(1) NOT NULL DEFAULT '0'状态:0 正常 / 1 停用
del_flagchar(1) NOT NULL DEFAULT '0'逻辑删除
create_time / update_timedatetime DEFAULT NULL创建 / 更新时间
PRIMARY KEY (`plan_id`),
KEY `idx_plan_app` (`app_id`)

生命周期:未找到写入方。它被列在 tenant.excludes 里,属于平台级目录数据 (同一份目录服务上还是本地)。建表脚本的回滚段会把这张表一起 DROP。


5. app_dependency —— 应用依赖表

用途:应用之间的依赖声明(哪个应用需要哪个应用、版本范围是多少)。 当前未见实现:没有实体类、没有 Mapper、没有引用点;字段含义按 DDL 注释整理。

字段类型说明
idbigint NOT NULL主键
app_idbigint NOT NULL应用 ID
dep_app_codevarchar(64) NOT NULL依赖的应用编码
version_rangevarchar(64) NOT NULL版本约束,如 >=1.0.0
optional_flagchar(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 节。

字段类型说明
idbigint NOT NULL主键
version_idbigint NOT NULL应用版本 ID → app_version.version_id
menu_idbigint NOT NULLsys_menu.menu_id
menu_keyvarchar(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_idbigint NOT NULL主键,安装实例 ID
tenant_idvarchar(20) NOT NULL租户字段,多租户插件自动隔离
app_idbigint NOT NULL应用 ID
version_idbigint NOT NULL当前版本 ID
statusvarchar(16) NOT NULL DEFAULT 'enabled'状态:installing / installed / enabled / disabled / upgrading / failed / uninstalling / uninstalled;对外可见的判据只有 enabled
config_jsonlongtext应用配置(按 manifest.config 渲染存储)
enabled_time / disabled_timedatetime DEFAULT NULL启用 / 停用时间
expire_timedatetime DEFAULT NULL到期时间(NULL = 永久)
sourcevarchar(16) DEFAULT NULL来源:admin 自行 / market 购买 / trial 试用
health_statusvarchar(16) DEFAULT NULL健康检查结果
last_health_timedatetime DEFAULT NULL最近健康检查时间
del_flagchar(1) NOT NULL DEFAULT '0'逻辑删除
create_time / update_timedatetime 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_idbigint NOT NULL主键,任务 ID
install_idbigint NOT NULL安装实例 ID
tenant_idvarchar(20) DEFAULT NULL租户字段(DDL 里可空)
task_typevarchar(16) NOT NULLinstall / upgrade / repair / enable / disable / uninstall / rollback
from_version / to_versionvarchar(32) DEFAULT NULL起始 / 目标版本
current_stepvarchar(64) DEFAULT NULL当前步骤
progressint NOT NULL DEFAULT 0进度 0–100
log_textlongtext执行日志,AppTaskRecorder 边跑边落库
statusvarchar(16) NOT NULL DEFAULT 'running'状态:running / success / failed / canceled
error_msgtext失败原因
start_time / end_timedatetime DEFAULT NULL开始 / 结束时间
create_time / update_timedatetime 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_idbigint NOT NULL主键,订阅 ID
tenant_idvarchar(20) NOT NULL租户字段
app_idbigint NOT NULL应用 ID
plan_idbigint NOT NULL套餐 ID → app_plan.plan_id
order_idbigint DEFAULT NULL商城订单 ID
start_time / end_timedatetime DEFAULT NULL生效 / 到期时间
statusvarchar(16) NOT NULL状态:active / expired / canceled / refunded(DDL 未给默认值)
create_time / update_timedatetime 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_idbigint NOT NULL主键,日志 ID
tenant_idvarchar(20) DEFAULT NULL租户字段(DDL 里可空)
app_idbigint DEFAULT NULL应用 ID
actionvarchar(32) NOT NULLinstall / enable / disable / upgrade / uninstall / config
operatorvarchar(64) DEFAULT NULL操作人
detailtext详情
ipvarchar(64) DEFAULT NULL操作 IP
create_timedatetime 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 节。

字段类型说明
idbigint NOT NULL主键
app_codevarchar(64) NOT NULL应用编码,去重判据的一半
scriptvarchar(255) DEFAULT NULL脚本相对路径(如 01-create-exam-tables.sql),去重判据的另一半
versionvarchar(32) NOT NULL目标版本。语义是「这个脚本最早由哪个版本引入」,只作追溯
tenant_idvarchar(20) NOT NULL首次应用该脚本的租户,只作追溯;判据不带它
checksumvarchar(64) DEFAULT NULL脚本内容摘要,用于篡改检测
successchar(1) NOT NULL DEFAULT '1'状态:是否执行成功(0 否 1 是)
execution_msbigint DEFAULT NULL执行耗时(ms)
create_timedatetime DEFAULT NULL执行时间
update_timedatetime DEFAULT NULL更新时间
PRIMARY KEY (`id`),
UNIQUE KEY `uk_schema_history` (`app_code`, `script`)
唯一键必须是 (app_code, script)

不要改回 (tenant_id, app_code, version)。 理由与后果见下文专节。

生命周期:AppMigrationService 在每个脚本执行后写一行(读写都走 TenantHelper.ignore(...))。 checksum 是按脚本比对的,所以幂等的最小单位是「脚本」而不是「版本」。 「彻底删除」时必须删掉该应用的全部账本行,否则重装时建表脚本会被「已执行过」跳过,表再也建不回来。

这张表的实体不走 BaseEntity

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

菜单同步时已经写下了这份映射,它就是权威记录。 再加一列会带来三个具体代价:

  1. 要动框架的实体 / VO / Mapper XML / 菜单管理页面(sys_menu 是平台表,改它等于改框架);
  2. 会形成两份真相 —— sys_menu.app_code 与 app_version_menu 迟早不一致, 而两者的读者(菜单保护、彻底删除、工作台入口)对不一致的容忍度是零;
  3. 归属必须是「版本 → 菜单」的关系,而不是「菜单 → 应用」:同一个应用的不同版本 可以有不同菜单树,按版本记录才能在升级时回答「这个菜单是这一版新加的,还是上一版就有的」。
归属要连后代一起算

映射表里只有清单声明过的菜单,而 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 直接失败)
不含 versionversion 列保留,语义只是「这个脚本最早由哪个版本引入」,是追溯信息

曾经写错成什么样,后果是什么

表定义原本是:

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,幂等、可重复执行、不需要重启:

  1. 先按 (app_code, script) 去重,同一组合只留 id 最小的那一行 (唯一键建不起来就白改了,所以先清干净);
  2. 用 information_schema.STATISTICS 判断后删掉旧键(MySQL 没有 DROP INDEX IF EXISTS);
  3. 建新键 UNIQUE KEY uk_schema_history (app_code, script),已有 script 列则跳过;
  4. 打印 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 表:表不存在, 它只是关于路由墓碑未来可选落库的一句设计说明。

相关阅读

目录