Zsens Admin 插件数据迁移:建表、升级、卸载时别踩的坑——从 `install.sql` 到 `uninstall.php` 的全生命周期脏数据清理与版本断层修复

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 76 浏览 0 回复

写插件最烦的不是功能实现,是数据迁移。你本地跑得好好的,一上线用户从 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。

评论0
回复 · 0
还没有回复
微信客服 微信客服