Zsens Admin 插件数据迁移:建表、升级、卸载时别踩的坑——从 `install.sql` 到 `uninstall.php` 的全生命周期脏数据清理与版本断层修复
写插件最烦的不是功能实现,是数据迁移。你本地跑得好好的,一上线用户从 v1.0.2 升到 v1.3.0 直接白屏,或者卸载完插件发现库里一堆僵尸表、配置项还在 redis 里飘着。这篇把我踩过的坑按阶段拆开说,代码都是能直接抄的。
一、建表阶段:别信 "IF NOT EXISTS" 能救你
新手爱这么写 install.sql:
CREATE TABLE IF NOT EXISTS `__PREFIX__zsens_log` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`action` varchar(50) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
问题在哪?IF NOT EXISTS 只校验表名,不校验结构。用户之前手动改过字段、或者从其他渠道装过同名表,你的 SQL 直接跳过,后续代码查新字段就炸。
我现在改用 PHP 兜底建表,结构不一致时主动重建或告警:
public function install()
{
$table = $this->getTable('zsens_log');
// 先暴力检测:表存在但结构不对 = 脏环境
if ($this->schema->hasTable('zsens_log')) {
$columns = $this->schema->getColumnListing('zsens_log');
$required = ['id', 'action', 'created_at', 'trace_id']; // v1.2 新增
$missing = array_diff($required, $columns);
if (!empty($missing)) {
// 别自动删表!记日志让运维决定
trace("表结构断层: " . implode(',', $missing), 'error');
// 或者走安全升级路径
$this->safeUpgrade($missing);
}
}
// 真正建表用 schema builder,别裸写 SQL
$this->schema->create('zsens_log', function ($table) {
$table->id();
$table->string('action', 50)->default('');
$table->string('trace_id', 32)->nullable()->index(); // v1.2 新增,记得索引
$table->timestamp('created_at')->useCurrent();
});
}
二、升级阶段:版本号别存在文件里
早期我把当前版本写死在一个 version.php 里,升级脚本读这个文件判断执行到哪。结果用户手动覆盖文件、或者 git pull 冲突,版本号直接错乱,升级脚本重复执行或者跳过。
现在版本号存数据库,升级走事务化迁移脚本:
// 迁移脚本命名规范:upgrade_1_0_2_1_1_0.php
// 语义:从 1.0.2 升到 1.1.0
public function upgrade($fromVersion, $toVersion)
{
$migrations = $this->discoverMigrations($fromVersion, $toVersion);
foreach ($migrations as $file) {
$class = $this->resolveClass($file);
$instance = new $class();
try {
Db::startTrans();
$instance->up(); // 执行具体迁移
$this->recordMigration(basename($file)); // 记执行日志
Db::commit();
} catch (\Exception $e) {
Db::rollback();
// 关键:回滚后版本号不能变,否则下次进来直接跳过
throw new \RuntimeException("迁移失败 [{$file}]: " . $e->getMessage());
}
}
}
有个血泪教训:v1.1.0 我给某个字段加了唯一索引,但用户数据里已经有重复值。迁移事务回滚了,但用户看到报错手动刷新页面,因为版本号没更新,他认为"没升级成功"又点了一次,重复报错循环。后来加了迁移幂等校验:
public function up()
{
// 先清理脏数据,再加约束
$duplicates = Db::table('zsens_log')
->selectRaw('action, COUNT(*) as c')
->group('action')
->having('c', '>', 1)
->column('action');
if (!empty($duplicates)) {
// 自动合并或抛异常让用户处理,别静默丢数据
throw new \RuntimeException("存在重复 action: " . implode(', ', $duplicates));
}
Db::execute("ALTER TABLE `__PREFIX__zsens_log` ADD UNIQUE INDEX `idx_action` (`action`)");
}
三、卸载阶段:你以为删了表就干净了?
最坑的是卸载。很多插件 uninstall 只删表,但:
- 配置项还在
system_config里,key 带插件前缀但没人管 - redis 缓存键没清,用户重装插件读到旧缓存
- 上传目录里的文件变成孤儿,日积月累占空间
- 定时任务、事件监听没注销,后台日志疯狂报 Class not found
我现在卸载走 checklist:
public function uninstall()
{
// 1. 停掉相关定时任务,先停再删
\think\facade\Cron::remove('zsens_log_cleanup');
// 2. 清事件监听,防止卸载后触发报错
Event::off('user_login', 'zsens\listener\LogUserLogin');
// 3. 删表(可选给用户留数据,加配置开关)
if (!$this->getConfig('keep_data_on_uninstall')) {
$this->schema->dropIfExists('zsens_log');
$this->schema->dropIfExists('zsens_log_archive'); // 别忘了归档表!
}
// 4. 清配置,要扫前缀匹配
Db::table('system_config')->whereLike('name', 'zsens_%')->delete();
// 5. 清缓存,必须带前缀匹配
$keys = Cache::tag('zsens')->keys(); // 如果缓存驱动支持 tag
foreach ($keys as $key) {
Cache::delete($key);
}
// 不支持 tag 的驱动,用前缀扫描兜底
$this->clearCacheByPrefix('zsens_');
// 6. 清上传目录,但别 rm -rf,移到回收站
$uploadPath = root_path('public/uploads/zsens');
if (is_dir($uploadPath)) {
$trash = runtime_path('plugin_trash/zsens_' . date('YmdHis'));
rename($uploadPath, $trash); // 保留7天自动清理
}
}
四、一个真实惨案:升级时字段类型变更
v1.2.0 我把 status 从 tinyint(1) 改成 enum('pending','done','failed')。直接 ALTER TABLE MODIFY,MySQL 8 里 0/1 自动映射成 'pending'/'done',但 MariaDB 10.3 直接报错。更坑的是有些用户 status 存了 2,enum 里没有对应值。
现在类型变更必须走中间态:
public function up()
{
// 步骤1:加新字段,不删旧的
Db::execute("ALTER TABLE `__PREFIX__zsens_log` ADD COLUMN `status_new` ENUM('pending','done','failed') NOT NULL DEFAULT 'pending'");
// 步骤2:数据迁移,脏值兜底
Db::execute("
UPDATE `__PREFIX__zsens_log`
SET `status_new` = CASE
WHEN `status` = 1 THEN 'done'
WHEN `status` = 2 THEN 'failed'
ELSE 'pending'
END
");
// 步骤3:删旧字段,重命名新字段
Db::execute("ALTER TABLE `__PREFIX__zsens_log` DROP COLUMN `status`");
Db::execute("ALTER TABLE `__PREFIX__zsens_log` CHANGE `status_new` `status` ENUM('pending','done','failed') NOT NULL DEFAULT 'pending'");
}
五、最后
数据迁移没有"跑通一次"就完事的,要测:全新安装、跨版本升级、降级回滚、卸载重装。我现在的 CI 里加了四个 job 专门跑这个。你们有什么奇葩迁移事故?评论区说说,我看看能不能补进 checklist。

