Wujies 无界平台
插件开发advanced

数据库迁移

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

这一页解决一件事:给应用加表、加列、建索引、补数据时,脚本放哪、怎么打包、什么时候被执行、写错了怎么救。

一句话结论:脚本放插件的 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 XML 也在插件仓库里

本页只讲迁移,但新表往往要配实体与 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' 的记录并中断安装;人工处理后重试, 已成功的脚本会被跳过、失败的那条(脚本多半已修正)复用同一行重跑。

MySQL 8 没有 ADD COLUMN IF NOT EXISTS

加列前要用 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

机制(三句话,都在代码里):

  1. 平台装了租户行拦截器(PlusTenantLineHandler.ignoreTable + MybatisPlusConfig.mybatisPlusInterceptor,且必须排在第一位): 未列入 security.tenant.excludes 的表,SQL 会被自动补上 tenant_id = '000000';
  2. /open/** 会自动设置租户:OpenApiTenantInterceptor 对未登录请求取 ?tenantId= / X-Tenant-Id,都没有就用默认值 000000 (宿主 application.yml 的 open-api.tenant.path-patterns 默认含 /open/**);
  3. 所以匿名接口同样会触发租户条件 —— 别以为「匿名就没事」。

两条路,按数据语义二选一(不是按省事):

这条路判据要做什么
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_idTenantEntityvarchar(20) DEFAULT '000000'
create_dept / create_byBaseEntity(FieldFill.INSERT)bigint DEFAULT NULL
create_timeBaseEntity(FieldFill.INSERT)datetime DEFAULT CURRENT_TIMESTAMP
update_by / update_timeBaseEntity(FieldFill.INSERT_UPDATE)bigint / datetime DEFAULT NULL
del_flag实体自己声明 + @TableLogicint 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 归一化摘要)
success1 成功 / 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(例如把里面的示例数据拆出去)之后,存量部署会因摘要不一致被挡住。 这是刻意的保护,正确做法是:

  1. 先跑对应的修复脚本(删掉那几行账本记录)—— 种子数据拆分用 wujies-platform/script/sql/update/app_market_app_seed_split_repair.sql;
  2. 再升级(或重新部署)该应用 —— 迁移脚本只在 install / upgrade 里跑。
光把包传上去不会有任何变化

迁移只在 install / upgrade 触发。存量部署拿到新种子数据的唯一路径就是 「先跑修复脚本 → 再升级」。

自检清单

写完或改完迁移脚本,按这个顺序核一遍:

#检查怎么看
1脚本在顶层、有数字前缀、幂等目录列表 + 通读一遍 SQL
2包内真的打进去了解包看 migrations/ 下有没有那些 .sql(漏打包只会表现为"升级成功但表没变")
3没改过已执行脚本与已发布版本里的同名脚本比对字节
4执行结果任务中心(「我的应用 → 应用任务」)里找「执行版本迁移脚本」步骤,逐行看 已执行: / 已执行过,跳过:
5失败原因同一步骤的失败文案;账本里 success='0' 那行。报错会带上根因链最里层的原始消息(不只是回放那条 SQL)
6表真的建了SHOW TABLES LIKE '<app>%',或直接调应用接口

任务中心的分步说明与日志偏移量验证见 任务中心, 常见报错对照表见 故障排查。

相关阅读

目录