Zsens Admin 插件数据迁移:我用"迁移契约"把 install/uninstall/upgrade 拧成一根绳,终于告别了版本号对不上就炸库的噩梦
之前我的迁移逻辑是典型的"三段式灾难":install.sql 管建表、upgrade.php 管改字段、uninstall.php 管删库——三个文件各写各的,版本号对不上就直接白屏。最惨一次是从 v1.2.3 升到 v1.4.0,中间跳了两个小版本,upgrade.php 里写的是 if ($oldVersion === '1.2.3'),结果用户是从 1.2.1 打上来的,字段直接漏加。
现在我把迁移拆成了"契约驱动"的四件套,核心是个 MigrationContract 接口:
interface MigrationContract
{
public function version(): string; // 目标版本
public function depends(): array; // 前置版本依赖
public function up(Schema $schema): void;
public function down(Schema $schema): void; // 回滚用
}
每个版本一个类,比如 V120_AddNotifyTable、V130_AddNotifyIndex,升级时按拓扑排序跑,卸载时倒序执行 down()。关键是 depends() 显式声明依赖,跳版本自动补链。
但这里有个大坑:down() 不是简单的 DROP TABLE。我早期写卸载逻辑时,down() 里直接 DROP TABLE zsens_notify,结果用户数据全丢,差评爆炸。现在的准则是——
卸载分三级策略:
1. --soft:只删配置,保留业务表(适合重装排查问题)
2. --purge:down() 执行到 v0.0.0,但先导出 CSV 到 storage/backup/
3. --nuke:真·删库,需二次确认 + 管理员密码
另一个暗雷是字段回滚的不可逆操作。比如 v1.3.0 把 status 从 TINYINT 改成 ENUM('pending','done','failed'),down() 时 ENUM 能无损转回 TINYINT 吗?如果用户已经存了 'failed',而 1.2.x 只认 0/1,回滚就炸。我的解法是给每个破坏性变更配"桥接映射":
public function down(Schema $schema): void
{
// 先打标记,把新状态映射回旧状态
$this->bridgeMap('status', [
'pending' => 0,
'done' => 1,
'failed' => 1, // 合并到 1,日志里记一笔
]);
$schema->modifyColumn('status', 'TINYINT(1)');
}
还有个点容易忽略:插件卸载时系统钩子可能已经被别的插件依赖。我遇到过 A 插件卸载后,B 插件的 hook.zsens.notify.send 直接 fatal error,因为 B 没做存在性检查。现在我的 uninstall 流程里加了"钩子占用检测":
$consumers = HookRegistry::whoListens('zsens.notify.send');
if (!empty($consumers)) {
throw new UninstallBlockedException(
'以下插件仍依赖本插件钩子:' . implode(', ', $consumers)
);
}
最后说个血泪教训:别在 upgrade 里用 ORM 查不存在的字段。v1.2→v1.3 加字段时,我顺手在 upgrade 脚本里用 Model 做数据填充,结果 Model 的 $fillable 已经包含了新字段,但表结构还没改完,查询直接报错。upgrade 脚本里只许用原生 SQL 或 Schema Builder,ORM 那是升级完成后的特权。
你们 uninstall 时遇到过"删不干净残留配置"或者"回滚后别的插件报错"的情况吗?我这套契约驱动目前跑了 17 个版本迭代,还没炸过,但总觉得还有死角。

