Zsens Admin 插件数据迁移:install.sql 之外,那些让回滚崩溃的隐式依赖与版本断层
在 Zsens Admin 插件开发中,数据迁移往往被简化为"写好 install.sql,框架自动执行"。但生产环境里,建表时的字符集陷阱、升级时的字段语义漂移、卸载时的外键级联黑洞,才是真正让插件从"可用"滑向"灾难"的暗礁。本文基于 ThinkPHP 8 + Think ORM 的迁移链路,拆解三个阶段的实操盲区。
一、建表阶段:ORM 隐式行为与显式声明的错位
Think ORM 的 $table->create() 在 Zsens Admin 环境下会继承框架默认的 utf8mb4_0900_ai_ci 排序规则,但插件若未在 install.sql 头部显式声明,可能遭遇两类异常:
1. 索引长度溢出:当字段为 varchar(255) 且建立联合索引时,utf8mb4 单字符 4 字节会导致 1071 Specified key was too long。解决方案不是缩字段,而是在 install.sql 中前置指定:
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
2. JSON 字段的默认值盲区:MySQL 8.0.13 前不允许 JSON 列设默认值,而 Think ORM 的 json('config')->default('[]') 在低版本会静默失败。Zsens Admin 要求 PHP 8.2+,但目标数据库版本不可控,建议用迁移类兜底:
public function up()
{
$sql = "CREATE TABLE IF NOT EXISTS `{$this->prefix}my_plugin_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`config` json DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
// 显式捕获而非依赖框架自动执行
try {
Db::execute($sql);
} catch (\PDOException $e) {
// 低版本兼容:降级为 TEXT + 应用层校验
if (str_contains($e->getMessage(), 'JSON')) {
$sql = str_replace('`config` json DEFAULT NULL', '`config` text', $sql);
Db::execute($sql);
// 写入插件元数据,标记降级状态供业务层读取
Cache::set('my_plugin:json_fallback', true);
}
}
}
二、升级阶段:版本断层的"幽灵字段"与数据回填
Zsens Admin 插件的升级脚本通常按版本号顺序执行,但存在一个被忽视的边界:用户可能从 v1.0.1 直接跳到 v1.2.0,跳过中间版本。若升级脚本假设"上一版本必然存在某字段",则直接 ALTER TABLE ... ADD COLUMN 会触发 Duplicate column name;若用 IF NOT EXISTS 绕过,又可能漏掉该版本应有的数据转换逻辑。
推荐采用幂等性版本快照模式,而非线性增量脚本:
// upgrade.php 不再写死 ALTER 语句,而是描述"目标态"
return [
'target_version' => '1.2.0',
'schema_checksum' => 'a3f7e2...', // 目标表结构的哈希
'migrations' => [
// 每个操作自带前置检测
[
'check' => "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$prefix}my_plugin_goods'
AND COLUMN_NAME = 'sku_rule'",
'if_missing' => "ALTER TABLE `{$prefix}my_plugin_goods`
ADD COLUMN `sku_rule` json NULL COMMENT '规格生成规则' AFTER `category_id`",
'data_backfill' => function() {
// 仅当字段新建后执行,避免重复回填
$rows = Db::name('my_plugin_goods')
->whereNull('sku_rule')
->column('id, spec_template');
foreach ($rows as $row) {
$parsed = $this->legacyParser($row['spec_template']); // 旧格式解析
Db::name('my_plugin_goods')
->where('id', $row['id'])
->update(['sku_rule' => json_encode($parsed)]);
}
}
]
]
];
关键设计:将"结构变更"与"数据迁移"解耦,且数据回填逻辑仅在检测到字段确实由本次操作新建时才触发。这避免了重复执行导致的数据覆盖。
三、卸载阶段:外键级联与插件间隐性契约的清理顺序
Zsens Admin 支持插件间依赖声明,但卸载时的清理顺序若处理不当,会引发跨插件数据污染。典型场景:插件 A 的表含外键指向插件 B 的表,当 B 先卸载时,A 的残留数据将成为孤儿记录,甚至阻塞 B 的重新安装(外键指向不存在的表)。
更隐蔽的问题是触发器与事件调度器。install.sql 中若创建了:
CREATE TRIGGER `trg_after_insert_goods`
AFTER INSERT ON `zsens_my_plugin_goods`
FOR EACH ROW
INSERT IGNORE INTO `zsens_other_plugin_stock` ...
卸载时仅 DROP TABLE 不会自动清理触发器,且该触发器在 other_plugin 卸载后会成为"悬空对象",导致任意写入报错。必须在卸载脚本中显式遍历:
public function uninstall()
{
$prefix = config('database.connections.mysql.prefix');
// 1. 先解除外键约束(获取信息_schema中的约束名,非硬编码)
$fks = Db::query("SELECT CONSTRAINT_NAME
FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$prefix}my_plugin_goods'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'");
foreach ($fks as $fk) {
Db::execute("ALTER TABLE `{$prefix}my_plugin_goods`
DROP FOREIGN KEY `{$fk['CONSTRAINT_NAME']}`");
}
// 2. 清理触发器(需扫描本表关联的所有触发器,而非仅当前插件创建的)
$triggers = Db::query("SELECT TRIGGER_NAME
FROM information_schema.TRIGGERS
WHERE EVENT_OBJECT_TABLE = '{$prefix}my_plugin_goods'");
foreach ($triggers as $tg) {
Db::execute("DROP TRIGGER IF EXISTS `{$tg['TRIGGER_NAME']}`");
}
// 3. 最后删表,此时无级联风险
Db::execute("DROP TABLE IF EXISTS `{$prefix}my_plugin_goods`");
// 4. 清理其他插件可能缓存的本插件元数据
Cache::tag('plugin_meta')->clear();
}
四、一个未文档化的边界:Think ORM 的迁移事务与 DDL 隐式提交
MySQL 中 DDL 语句(CREATE/ALTER/DROP)会隐式提交当前事务,这意味着 Zsens Admin 插件若在迁移方法中包裹 Db::transaction(),一旦 DDL 失败,此前的 DML 操作无法回滚。建议将结构变更与数据操作拆分为独立事务,或在应用层实现"预检-执行-校验"的三阶段补偿:
// 错误示范:DDL 导致事务断裂
Db::transaction(function() {
Db::name('temp')->insert(['status' => 'migrating']); // 可能已提交
Db::execute('ALTER TABLE ...'); // 隐式提交,此后 insert 无法回滚
Db::name('temp')->insert(['status' => 'done']); // 若此处失败,前面已固化
});
// 正确做法:结构变更前置,数据操作后置且独立校验
$schemaOk = $this->applySchemaChange(); // 无事务包裹,单语句执行
if (!$schemaOk) {
$this->rollbackSchema(); // 手动准备逆向脚本
return false;
}
Db::transaction(function() {
// 仅包含 DML,可安全回滚
$this->migrateData();
$this->verifyChecksum(); // 校验目标态与预期一致
});
结语
数据迁移的健壮性不在于 SQL 写得多么精巧,而在于对"失败时世界处于何种状态"的预判。Zsens Admin 插件的 install/upgrade/uninstall 三个钩子,本质上是与不确定的运行环境签订契约——字符集、数据库版本、插件加载顺序、其他开发者的扩展行为,都是契约中的变量。显

