数据库迁移
这一页解决一件事:给应用加表、加列、建索引、补数据时,脚本放哪、怎么打包、什么时候被执行、写错了怎么救。
一句话结论:脚本放插件的 migrations/,随应用包走,安装 / 升级时自动按文件名顺序执行;
平台不让你手工执行 SQL,也不让插件代码自己建表。
# 在插件仓库根目录运行(路径:wujies/wujies-plugins/)
# 本机已有 keys/private.pem 时会自动用它签名 —— 宿主是 enforce,不签名等于白打一个包
pwsh -File package.ps1 -Plugin wujies-hello不想动 app.json、只想额外塞一个修数据脚本时,用 -MigrationPath 指向它
(package.ps1 的 .EXAMPLE 里有原样可抄的命令,四种情况的表格见 打包与产物)。
服务器上唯一知道"这个应用有哪些表"的地方,就是这些脚本里的 CREATE TABLE ——
所以建表语句必须进包。
图床原本是宿主编译期模块,5 张表建在平台初始化 SQL 里。改成插件应用之后, 客户那台只导入平台 SQL 的部署装上它就是所有页面报表不存在; 而且"彻底删除"时平台也无从知道该删哪些表。
所以建表脚本有两个身份:① 安装时建表;② 卸载时被平台读出来当删表清单。
执行链路
| 环节 | 谁做 | 说明 |
|---|---|---|
| 打包 | package.ps1 | 默认取插件目录的 migrations/*.sql;-MigrationPath 可指定目录或单个文件 |
| 落位 | AppPackageDeployService | 解到 <pluginDir>/migrations/<appCode>/(落位前先清空该目录,见下) |
| 执行 | AppMigrationService.applyPending | 扫描该目录 → 按文件名排序 → 逐个执行 |
| 记账 | app_schema_history | 键 (app_code, script),含 checksum / success / execution_ms |
| 删表依据 | AppMigrationService.declaredTables | 从脚本里解析 CREATE TABLE 的表名,供"彻底删除"用 |
安装 / 升级的完整顺序(PluginAppInstaller):
产物落位 → 执行迁移脚本 → 加载新插件代码 → 同步菜单与授权这个顺序是刻意做出来的:AppPackageDeployService.deploy(versionId, false) 只落位,
加载交给排在迁移之后的 assertBackendLoaded。所以插件在启动时机直接读新加的列是安全的。
控制台「部署」按钮走的是单参版本(落位即加载),它不跑迁移脚本 —— 只改表结构、不改代码时才用它。
脚本放哪、怎么命名
wujies/wujies-plugins/wujies-<app>/migrations/
01-create-<app>-tables.sql 建表,纯 DDL(CREATE TABLE IF NOT EXISTS)
0N-create-<app>-seed-data.sql 示例 / 必需基础数据(INSERT IGNORE + 显式主键)
0N-create-<app>-dictionaries.sql 字典(MAX(dict_id)+N + WHERE NOT EXISTS)
0N-create-<app>-website-config.sql 站点配置(MAX(config_id)+N + WHERE NOT EXISTS)- 文件名排序即执行顺序,所以必须有
01-/02-这样的数字前缀; - 编号只表示顺序,取下一个空号,别为了整齐重排既有脚本;
.sql必须是该目录的顶层文件:打包只扫顶层,子目录里的脚本会被告警跳过;- 脚本以 UTF-8 读取(
AppMigrationService.read显式用StandardCharsets.UTF_8)。
范例应用 wujies-hello 就是这四个文件的最小样本:
01-create-hello-tables.sql(一张表:业务列 + tenant_id + BaseEntity 的公共列 + del_flag)、
02-create-hello-seed-data.sql(两条示例数据)、
03-create-hello-website-config.sql(sys_config,config_category = 'hello')。
字典与站点配置的取值策略、生成工具见 种子数据与站点配置。
本页只讲迁移,但新表往往要配实体与 Mapper。别去找 *-api 契约模块 ——
平台里没有这个模块(wujies-modules/*-api 数量为 0)。
实体 / BO / VO / Mapper 接口 与 Mapper XML / Service / controller 全在
wujies-plugins/wujies-<app>/src/ 下,例如
wujies-gallery/src/main/resources/mapper/gallery/GalleryImageMapper.xml。
XML 由 PluginMapperRegistrar 在插件加载时动态注册,不是靠 mapperLocations 扫出来的。
四条硬规矩 + 一条表结构约束
这五条都能在 AppMigrationService 的类注释里找到完整推导。
1. 只做 up,不回滚
app.json 的 migrations(含 down)是预留字段,当前不参与执行。
要回退表结构,请自己写一个"反向"的新迁移脚本,当作一次正常变更发布。
2. 脚本必须幂等
MySQL 的 DDL 是隐式提交的,AppMigrationService 刻意不声明事务 ——
包一个大事务只会让人误以为失败能整体回滚。重试时已成功的会跳过,
失败的那个必须能重跑。
CREATE TABLE IF NOT EXISTS `hello_item` (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
name varchar(64) NOT NULL COMMENT '名称',
remark varchar(255) DEFAULT NULL COMMENT '备注',
create_dept bigint DEFAULT NULL COMMENT '创建部门',
create_by bigint DEFAULT NULL COMMENT '创建者',
create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by bigint DEFAULT NULL COMMENT '更新者',
update_time datetime DEFAULT NULL COMMENT '更新时间',
tenant_id varchar(20) DEFAULT '000000' COMMENT '租户编号',
del_flag int DEFAULT 0 COMMENT '删除标志',
PRIMARY KEY (id),
-- `KEY` 这种行内索引写法 MySQL 与沙箱的 H2 (MODE=MySQL) 都认
KEY idx_hello_item_tenant (tenant_id)
);失败时会写一条 success='0' 的记录并中断安装;人工处理后重试,
已成功的脚本会被跳过、失败的那条(脚本多半已修正)复用同一行重跑。
加列前要用 information_schema 判断,再动态执行。
3. 已执行过的脚本内容不能改
账本判据是 (app_code, script),同一个 script 名再来时比对 checksum,
不一致直接报错拒绝执行:
迁移脚本 <name> 的内容已被修改(摘要与首次执行时不一致),
为避免同一脚本产生两种结果,拒绝执行。请改用新的脚本名。要变更就新增一个脚本。这是刻意的保护,不要绕过它:
01 让全新安装走到某个状态,02 让存量部署补齐到同一状态。
摘要按字节算,所以换行差异要单独容忍
工作区是 CRLF、Linux 上是 LF,语义相同但摘要不同。
AppMigrationService.checksumMatches 同时认 LF 归一化摘要与 CRLF 摘要
(记账统一存 LF 归一化那份),真正的改动照旧拒绝。
不要给 migrations/*.sql 加 eol 归一化 ——
本目录的换行本来就是混合的,"统一"反而会让所有存量记录失效一次。
4. 去重判据不带租户
表结构在所有租户间共享,带上租户会让第二个租户重复执行 DDL
(CREATE TABLE 直接报错)。读写账本一律走 TenantHelper.ignore(...);
tenant_id 只记「谁最先应用」,不是判据。
5. 账本唯一键必须正好是 (app_code, script)
表结构与代码必须一致。曾经 DDL 写的是 (tenant_id, app_code, version)
(少了 script、多了 version),后果是一个版本里只要有第二个迁移脚本,
第二次记账就撞唯一键:
Duplicate entry '000000-exam-1.0.1' for key 'app_schema_history.uk_schema_history'安装 / 升级直接失败。它藏得久是因为所有应用的 migrations/ 一直只有一个脚本;
而它不是升级专有的毛病 —— 全新部署安装一个多脚本应用同样会炸。
- 存量部署执行
wujies-platform/script/sql/update/app_market_schema_history_fix.sql(幂等:先去重、再删旧键、再建新键); - 平台建表脚本
wujies-platform/script/sql/update/app_market.sql已同步改正 (UNIQUE KEY uk_schema_history (app_code, script)),别改回去; - 回归测试
AppMigrationLedgerDdlTest会在 DDL 与代码不一致时直接失败。
每一列都要在:tenant_id 与 BaseEntity 的公共列
{"code":500,"msg":"Unknown column 'tenant_id' in 'where clause'","data":null}而且它只在这个表真的被查的接口上出现 —— 同一个插件的其它接口可以完全正常
(不查库的、或查的是平台本来就有 tenant_id 的表)。线上实测 hello 1.0.0:
| 线上接口 | 结果 | 为什么 |
|---|---|---|
GET /open/hello/config | ✅ 200 | 读平台的 sys_config(本来就有 tenant_id) |
GET /open/hello/greet | ✅ 200 | 不查库 |
GET /open/hello/item/list | ❌ 500 Unknown column 'tenant_id' in 'where clause' | 只有它真查了 hello_item |
机制(三句话,都在代码里):
- 平台装了租户行拦截器(
PlusTenantLineHandler.ignoreTable+MybatisPlusConfig.mybatisPlusInterceptor,且必须排在第一位): 未列入security.tenant.excludes的表,SQL 会被自动补上tenant_id = '000000'; /open/**会自动设置租户:OpenApiTenantInterceptor对未登录请求取?tenantId=/X-Tenant-Id,都没有就用默认值000000(宿主application.yml的open-api.tenant.path-patterns默认含/open/**);- 所以匿名接口同样会触发租户条件 —— 别以为「匿名就没事」。
两条路,按数据语义二选一(不是按省事):
| 这条路 | 判据 | 要做什么 |
|---|---|---|
| A. 业务表(推荐,平台默认设计) | 数据按租户各存一份 | 实体 extends TenantEntity;建表脚本里带 tenant_id + BaseEntity 的全部公共列 + del_flag |
| B. 全局表 | 数据是平台级、中心一份(像 app_info / app_version / app_version_route 那样,插件 jar 只有一份、与「谁装的」无关) | 把表名加进宿主 wujies-admin/src/main/resources/application.yml 的 security.tenant.excludes;实体用 BaseEntity 或不继承,读写处另套 TenantHelper.ignore(...) |
逐列对齐表(HelloItem extends TenantEntity extends BaseEntity,照它抄):
| 列 | 来自 | 类型 / 默认值 |
|---|---|---|
tenant_id | TenantEntity | varchar(20) DEFAULT '000000' |
create_dept / create_by | BaseEntity(FieldFill.INSERT) | bigint DEFAULT NULL |
create_time | BaseEntity(FieldFill.INSERT) | datetime DEFAULT CURRENT_TIMESTAMP |
update_by / update_time | BaseEntity(FieldFill.INSERT_UPDATE) | bigint / datetime DEFAULT NULL |
del_flag | 实体自己声明 + @TableLogic | int DEFAULT 0(删除 = 1,与 common-mybatis.yml 的 logicDeleteValue 配套) |
业务列(id / name / remark …) | 实体自己 | 按业务 |
BaseEntity里另有searchValue与params,都是@TableField(exist = false)⇒ 不落列。 审计字段的类型以基类为准(Date)—— 实体里不要再手写createTime/LocalDateTime, 同名列两个类型会造成隐性不一致。 列名拼错一个字母就是下一个Unknown column,务必逐列核对。
表在排除清单里、实体却继承了 TenantEntity 时,tenant_id 不会被自动填充(列会是空的)。
全局表请用 BaseEntity(或不继承基类),并套 TenantHelper.ignore(...) 读写。
范例:wujies/wujies-plugins/wujies-hello/ 的 migrations/01-create-hello-tables.sql 与
src/main/java/cn/wujies/hello/domain/HelloItem.java(1.0.1 起按 A 方案改正);
继承写法照 wujies-gallery 的 GalleryImageCategory,别自创。
账本长什么样
app_schema_history 每个部署各一份(不进 security.tenant.excludes):
| 字段 | 说明 |
|---|---|
app_code / script | 判据;script 是脚本文件名 |
version | 本次安装 / 升级的目标版本,只作追溯 |
checksum | 脚本内容的 SHA-256(统一存 LF 归一化摘要) |
success | 1 成功 / 0 失败(失败留痕,否则下次无法区分"没跑过"与"跑挂了") |
execution_ms | 耗时 |
tenant_id | 只记"谁最先应用" |
脚本执行后平台会自动清 SYS_DICT / SYS_DICT_TYPE / SYS_CONFIG 三个 Redis 缓存 ——
不清的话接口会继续返回迁移前的字典与参数值。
如果你跳过程序手工执行了 SQL,要自己调一次
/system/dict/type/refreshCache 或界面上的「刷新缓存」。
落位会先清空迁移目录
AppPackageDeployService 在释放新包内容之前会 deleteQuietly(migrationRoot),
把 <pluginDir>/migrations/<appCode>/ 整个清掉。
原因是 AppMigrationService 是按目录扫描执行的,而落位只释放新包内容、
不会删掉包里已经没有的旧文件 —— 于是"被删掉 / 改名过的旧脚本"会一直留在磁盘上,
并在下一次安装时被再次执行("幽灵脚本")。
| 影响 | 说明 |
|---|---|
| 审计能力 | 不受影响:历史与 checksum 都在 app_schema_history,脚本本身在每个版本的应用包里 |
declaredTables | 从此只反映当前包的脚本。若某张表由"后来被移除的脚本"创建,它不再出现在待删表名单里 —— 比原先按历史脚本并集删表更安全 |
2026-10-02 已改成先清空。判断依据以 AppPackageDeployService 里的注释与
deleteQuietly(migrationRoot) 那一行为准。
为什么是"目录驱动"而不是读清单
执行方式是扫描目录,而不是解析 app.json 的 migrations 字段。这样:
- 「清单声明了脚本却忘了打包」这类错误根本不会发生;
- 手工补一个修数据脚本,扔进目录即可,不必重新打包上架;
- 不需要在运行期重新解析一遍 zip(部署时已经解过了)。
app.json 里那个字段保留为文档与回滚预留。
打包规则的完整表格(不传 -MigrationPath、指向目录 / 文件、路径不存在、
子目录里有 .sql 分别怎么处理)见 打包与产物。
改过已执行脚本怎么办
改了 01-*.sql(例如把里面的示例数据拆出去)之后,存量部署会因摘要不一致被挡住。
这是刻意的保护,正确做法是:
- 先跑对应的修复脚本(删掉那几行账本记录)——
种子数据拆分用
wujies-platform/script/sql/update/app_market_app_seed_split_repair.sql; - 再升级(或重新部署)该应用 —— 迁移脚本只在 install / upgrade 里跑。
迁移只在 install / upgrade 触发。存量部署拿到新种子数据的唯一路径就是 「先跑修复脚本 → 再升级」。
自检清单
写完或改完迁移脚本,按这个顺序核一遍:
| # | 检查 | 怎么看 |
|---|---|---|
| 1 | 脚本在顶层、有数字前缀、幂等 | 目录列表 + 通读一遍 SQL |
| 2 | 包内真的打进去了 | 解包看 migrations/ 下有没有那些 .sql(漏打包只会表现为"升级成功但表没变") |
| 3 | 没改过已执行脚本 | 与已发布版本里的同名脚本比对字节 |
| 4 | 执行结果 | 任务中心(「我的应用 → 应用任务」)里找「执行版本迁移脚本」步骤,逐行看 已执行: / 已执行过,跳过: |
| 5 | 失败原因 | 同一步骤的失败文案;账本里 success='0' 那行。报错会带上根因链最里层的原始消息(不只是回放那条 SQL) |
| 6 | 表真的建了 | SHOW TABLES LIKE '<app>%',或直接调应用接口 |
任务中心的分步说明与日志偏移量验证见 任务中心, 常见报错对照表见 故障排查。