addArgument( 'operate', InputArgument::OPTIONAL, 'Operation: make/make-seed/up/rollback/seed/status', 'up', ['make', 'make-seed', 'up', 'rollback', 'seed', 'status'] ); $this->addArgument('name', InputArgument::OPTIONAL, 'Migration/Seeder name (camelCase)'); $this->addOption('connection', null, InputOption::VALUE_OPTIONAL, 'DB connection [default: "default"]'); $this->addOption('seed', null, InputOption::VALUE_NONE, 'Run seeders after migration'); $this->addOption('table', 't', InputOption::VALUE_OPTIONAL, 'Specify table name for migration (auto-generate if not provided)'); $this->addOption('batch', 'b', InputOption::VALUE_OPTIONAL, 'Batch number to rollback to (for rollback operation)'); $this->addOption('dry-run', 'd', InputOption::VALUE_NONE, 'Preview migrations without executing (for up operation)'); $this->addOption('sql', 's', InputOption::VALUE_OPTIONAL, 'Export SQL to file (包含表结构和种子数据), e.g: --sql=install.sql'); $this->addOption('no-data', null, InputOption::VALUE_NONE, 'Export SQL without data (only table structure)'); } protected function execute(InputInterface $input, OutputInterface $output): int { $operate = strtolower($input->getArgument('operate')); $name = $input->getArgument('name'); $this->connection = $input->getOption('connection'); $runSeed = $input->getOption('seed'); $this->dryRun = $input->getOption('dry-run'); $sqlOption = $input->getOption('sql'); $this->exportWithData = !$input->getOption('no-data'); // 检测是否使用 --sql 选项(必须指定文件名) $this->exportSql = is_string($sqlOption) && strlen(trim($sqlOption)) > 0; $validOperations = ['make', 'make-seed', 'up', 'rollback', 'seed', 'status']; if (!in_array($operate, $validOperations)) { $output->writeln("❌ Invalid operation: {$operate}"); return Command::INVALID; } if ($this->dryRun && !in_array($operate, ['up', 'rollback'])) { $output->writeln("❌ --dry-run is only supported for 'up' and 'rollback' operations"); return Command::INVALID; } if ($this->exportSql && !in_array($operate, ['up', 'rollback'])) { $output->writeln("❌ --sql is only supported for 'up' and 'rollback' operations"); return Command::INVALID; } if ($this->dryRun) { $output->writeln("🔍 DRY RUN MODE - No changes will be made"); } if ($this->exportSql) { $output->writeln("📄 SQL EXPORT MODE - SQL will be saved to file"); } match ($operate) { 'make' => $this->runMakeMigration($name, $input, $output), 'make-seed' => $this->runMakeSeeder($name, $output), 'up' => $this->runMigrations($output, $runSeed, $sqlOption, $name), 'rollback' => $this->runRollback($output, $input->getOption('batch'), $sqlOption), 'seed' => $this->runSeeders($output), 'status' => $this->runStatus($output), }; if (!$this->dryRun) { $output->writeln('✅ Operation completed successfully!'); } return Command::SUCCESS; } /** * 驼峰转下划线(小写) */ protected function camelToSnake(string $str): string { return strtolower(preg_replace('/(?camelToSnake($name); } /** * 创建迁移文件(修复多余下划线问题) */ protected function runMakeMigration(string $name, InputInterface $input, OutputInterface $output) { $migrationDir = base_path('resource/database/migrations'); if (!is_dir($migrationDir)) { mkdir($migrationDir, 0777, true); } // 1. 获取表名(手动指定或自动生成) $specifiedTableName = $input->getOption('table'); $tableName = $specifiedTableName ?? $this->generateTableName($name); // 提示表名来源 if ($specifiedTableName) { $output->writeln("ℹ️ Using specified table: {$tableName}"); } else { $output->writeln("ℹ️ Generated table: {$tableName}"); } // 2. 生成「文件名前缀」(纯驼峰格式:create + 表名驼峰) $tableCamel = $this->snakeToCamel($tableName); // 如 role_belongs_menus → RoleBelongsMenus $filenamePrefix = 'create' . $tableCamel; // 如 createRoleBelongsMenus(关键修正:去掉下划线) // 3. 转换为小写蛇形(最终文件名核心:create_role_belongs_menus) $snakeFilenamePrefix = $this->camelToSnake($filenamePrefix); // 4. 生成完整文件名(时间戳 + 小写蛇形前缀 + .php) $fileName = date('Y_m_d_His_') . $snakeFilenamePrefix . '.php'; $filePath = "{$migrationDir}/{$fileName}"; if (file_exists($filePath)) { $output->writeln("❌ Migration file exists: {$fileName}"); return; } // 5. 生成迁移模板(匿名类,文件名小写蛇形) $template = $this->generateMigrationTemplate($tableName); file_put_contents($filePath, $template); $output->writeln("📝 Migration created: {$fileName}"); } /** * 生成迁移模板(保持不变) */ protected function generateMigrationTemplate(string $tableName): string { return <<create('{$tableName}', function(Blueprint \$table) { \$table->bigInteger('id')->primary()->comment('主键'); \$table->timestamps(); }); } public function down(Builder \$schema): void { // 回滚:删除表(表名:{$tableName}) \$schema->dropIfExists('{$tableName}'); } }; EOF; } /** * 创建种子 * * @param string $name * @param \Symfony\Component\Console\Output\OutputInterface $output */ protected function runMakeSeeder(string $name, OutputInterface $output) { $seederClassPrefix = $this->snakeToCamel($name); $seederDir = base_path('resource/database/seeds'); if (!is_dir($seederDir)) { mkdir($seederDir, 0777, true); } // 🔑 文件名用驼峰前缀 + Seeder.php(确保与类名一致) $fileName = "{$seederClassPrefix}Seeder.php"; $filePath = "{$seederDir}/{$fileName}"; if (file_exists($filePath)) { $output->writeln("❌ Seeder exists: {$fileName}"); return; } // 🔑 传入驼峰前缀生成模板(类名直接拼接前缀) $template = $this->generateSeederTemplate($seederClassPrefix); file_put_contents($filePath, $template); // ✅ 明确提示生成的文件和类名 $output->writeln("📝 Seeder created: {$fileName}"); $output->writeln("ℹ️ Seeder class: {$seederClassPrefix}Seeder"); } /** * 生成种子模板 * * @param string $name * * @return string */ protected function generateSeederTemplate(string $name): string { $className = ucfirst($name) . 'Seeder'; return <<insert([ 'username' => 'seed_user', 'email' => 'seed@example.com', 'password' => password_hash('secure', PASSWORD_DEFAULT), ]); */ } } EOF; } protected function runSeeders(OutputInterface $output) { $seederDir = base_path('resource/database/seeds'); if (!is_dir($seederDir)) { $output->writeln("ℹ️ No seeders directory"); return; } $files = glob("{$seederDir}/*.php"); if (empty($files)) { $output->writeln("ℹ️ No seeders available"); return; } foreach ($files as $file) { // 拼接完整命名空间(resource\database\seeds\文件名) $fileNameWithoutExt = pathinfo($file, PATHINFO_FILENAME); $fullClassName = 'resource\\database\\seeds\\' . $fileNameWithoutExt; // 完整类名:resource\database\seeds\AdminSeeder try { // 1. 加载种子文件(注册类到自动加载) require_once $file; // 用require_once避免重复加载 // 2. 检查类是否已正确定义(用完整类名) if (!class_exists($fullClassName)) { throw new \InvalidArgumentException("Seeder class '{$fullClassName}' not found in file '{$file}'"); } // 3. 实例化种子类(用完整类名) $seederInstance = new $fullClassName(); // 4. 执行run方法 $seederInstance->run(); $output->writeln("✅ Seeded: {$fullClassName}"); } catch (\Throwable $e) { $output->writeln("❌ Error seeding {$fullClassName}: {$e->getMessage()}"); } } } /** * 查看迁移状态 */ protected function runStatus(OutputInterface $output) { $logFile = $this->getMigrationLogFile(); $migrationDir = base_path('resource/database/migrations'); if (!is_dir($migrationDir)) { $output->writeln("ℹ️ No migrations directory found"); return; } $rows = $this->fetchMigrationRows($logFile); $existingMigrations = array_column($rows, 1); // 获取所有迁移文件 $files = glob("{$migrationDir}/*.php"); $allMigrations = []; foreach ($files as $file) { $basename = basename($file, '.php'); $allMigrations[] = [ 'filename' => $basename, 'status' => in_array($basename, $existingMigrations) ? '✅ Migrated' : '⏳ Pending' ]; } if (empty($allMigrations)) { $output->writeln("ℹ️ No migration files found"); return; } // 按文件名排序 sort($allMigrations); // 按批次分组显示 $batches = []; foreach ($rows as $row) { $batches[$row[0]][] = $row[1]; } $output->writeln("📊 Migration Status"); $output->writeln(str_repeat('=', 50)); $totalMigrated = count($existingMigrations); $totalPending = count($allMigrations) - $totalMigrated; $output->writeln("Migrated: {$totalMigrated} | Pending: {$totalPending} | Total: " . count($allMigrations) . ""); $output->writeln(str_repeat('-', 50)); // 显示所有迁移及其状态 foreach ($allMigrations as $migration) { if ($migration['status'] === '✅ Migrated') { $output->writeln("{$migration['status']}: {$migration['filename']}"); } else { $output->writeln("{$migration['status']}: {$migration['filename']}"); } } if (!empty($batches)) { $output->writeln(str_repeat('-', 50)); $output->writeln("📦 Migration Batches"); krsort($batches); foreach ($batches as $batch => $migrations) { $output->writeln("Batch {$batch}: " . implode(', ', $migrations)); } } } /** * 迁移 * * @param \Symfony\Component\Console\Output\OutputInterface $output * @param bool $runSeed * @param string|bool $sqlOption */ protected function runMigrations(OutputInterface $output, bool $runSeed = false, $sqlOption = false, ?string $migrationName = null) { $logFile = $this->getMigrationLogFile(); $rows = $this->fetchMigrationRows($logFile); $latestBatch = empty($rows) ? 0 : (int)end($rows)[0]; $existingMigrations = array_column($rows, 1); $newMigrations = []; $migrationDir = base_path('resource/database/migrations'); if (!is_dir($migrationDir)) { $output->writeln("ℹ️ No migrations directory found"); return; } // 使用 glob 替代 DirectoryIterator 提高性能 $files = glob("{$migrationDir}/*.php"); foreach ($files as $file) { $basename = basename($file, '.php'); if ($migrationName !== null && $migrationName !== '' && $basename !== pathinfo($migrationName, PATHINFO_FILENAME)) { continue; } if (!in_array($basename, $existingMigrations)) { $newMigrations[] = [$file, $basename]; } } if ($migrationName !== null && $migrationName !== '' && empty($newMigrations)) { $targetName = pathinfo($migrationName, PATHINFO_FILENAME); if (in_array($targetName, $existingMigrations)) { $output->writeln("ℹ️ Migration already executed: {$targetName}"); } else { $output->writeln("❌ Migration file not found: {$targetName}"); } return; } if (empty($newMigrations)) { $output->writeln("ℹ️ No pending migrations"); if ($runSeed) { $this->runSeeders($output); } return; } sort($newMigrations); $schema = $this->getSchemaBuilderWithInnoDB(); if ($this->dryRun) { $output->writeln("🔍 Pending migrations to execute:"); foreach ($newMigrations as $migration) { $output->writeln(" - {$migration[1]}"); } return; } // 如果是导出 SQL 模式 if ($this->exportSql) { $this->exportMigrationsToSql($output, $newMigrations, $sqlOption); return; } $batchNum = $latestBatch + 1; $capturedSql = []; // 启用查询监听以捕获 SQL Db::connection($this->connection)->listen(function ($query) use (&$capturedSql) { $capturedSql[] = $query->sql; }); // DDL(如 CREATE TABLE)在 MySQL 中会隐式提交事务,因此不包裹外层事务执行 foreach ($newMigrations as $migration) { try { $migrationClass = require $migration[0]; $migrationClass->up($schema); $output->writeln("⬆️ Migrated: {$migration[1]}"); } catch (\Throwable $e) { $output->writeln("❌ Error migrating {$migration[1]}: {$e->getMessage()}"); throw $e; } } // 记录日志(在事务外,确保成功后才记录) $logLines = array_map( fn($item) => "{$batchNum}," . $item[1], $newMigrations ); file_put_contents($logFile, PHP_EOL . implode(PHP_EOL, $logLines), FILE_APPEND | LOCK_EX); if ($runSeed) { $this->runSeeders($output); } } /** * 导出迁移 SQL 到文件 */ protected function exportMigrationsToSql(OutputInterface $output, array $newMigrations, $sqlOption): void { $connection = Db::connection($this->connection); $prefix = config('database.connections.mysql.prefix', 'ma_'); // 如果需要导出数据且不是干运行模式 if ($this->exportWithData && !$this->dryRun) { // 先运行 seeders(插入初始数据) $output->writeln("🔄 Running seeders before SQL export..."); $this->runSeeders($output); $output->writeln("✅ Seeders completed, now generating SQL..."); } // 生成 SQL 文件 $sqlContent = "-- Migration SQL Export\n"; $sqlContent .= "-- Generated at: " . date('Y-m-d H:i:s') . "\n"; $sqlContent .= "-- Connection: {$this->connection}\n"; $sqlContent .= "-- Include Data: " . ($this->exportWithData ? 'YES' : 'NO') . "\n\n"; $sqlContent .= "SET FOREIGN_KEY_CHECKS = 0;\n\n"; // 从迁移文件解析并生成 SQL(通过分析迁移文件内容) $sqlStatements = $this->parseMigrationsToSql($connection, $newMigrations, $prefix, $this->exportWithData); $sqlContent .= $sqlStatements; $sqlContent .= "SET FOREIGN_KEY_CHECKS = 1;\n"; // 保存文件 $filename = $sqlOption === true ? 'migration_' . date('YmdHis') . '.sql' : $sqlOption; $filepath = base_path($filename); file_put_contents($filepath, $sqlContent); $output->writeln("📄 SQL exported to: {$filepath}"); } /** * 解析迁移文件生成 SQL */ protected function parseMigrationsToSql($connection, array $migrations, string $prefix, bool $exportData = true): string { $sqlContent = ''; foreach ($migrations as $migration) { $migrationName = $migration[1]; $tableName = $this->extractTableNameFromMigration($migrationName); $fullTableName = $prefix . $tableName; $sqlContent .= "-- Migration: {$migrationName}\n"; $sqlContent .= "-- Table: {$fullTableName}\n\n"; // 尝试从数据库获取表结构 $createSql = $this->generateCreateTableSql($connection, $fullTableName); if ($createSql) { // 表已存在,使用现有结构 $sqlContent .= "DROP TABLE IF EXISTS `{$fullTableName}`;\n"; $sqlContent .= $createSql . ";\n\n"; // 导出表中的数据(如果启用) if ($exportData) { $insertSql = $this->generateInsertDataSql($connection, $fullTableName); if ($insertSql) { $sqlContent .= "-- Data from {$fullTableName}\n"; $sqlContent .= $insertSql . "\n\n"; } } } else { // 表不存在,生成基本的 CREATE TABLE 语句 $sqlContent .= "-- Table {$fullTableName} does not exist, please run migration first\n"; $sqlContent .= "-- Or manually create the table based on: {$migrationName}\n\n"; } } return $sqlContent; } /** * 生成 INSERT 数据 SQL */ protected function generateInsertDataSql($connection, string $tableName): ?string { try { $rows = $connection->select("SELECT * FROM `{$tableName}`"); if (empty($rows)) { return null; } $insertStatements = []; foreach ($rows as $row) { $row = (array)$row; $columns = array_keys($row); $values = array_values($row); // 处理值 $escapedValues = array_map(function ($value) use ($connection) { if ($value === null) { return 'NULL'; } return "'" . $connection->getPdo()->quote($value) . "'"; }, $values); $insertStatements[] = sprintf( "INSERT INTO `{$tableName}` (`%s`) VALUES (%s);", implode('`, `', $columns), implode(', ', $escapedValues) ); } return implode("\n", $insertStatements); } catch (\Throwable) { // 如果查询失败(如视图),返回 null return null; } } /** * 从迁移文件名提取表名 */ protected function extractTableNameFromMigration(string $migrationName): string { // 例如: 2025_10_10_162850_create_cache -> cache // 例如: 2025_10_10_162851_create_sys_admin -> sys_admin if (preg_match('/create_(\w+)$/', $migrationName, $matches)) { return $matches[1]; } // 对于修改表的迁移,尝试提取表名 if (preg_match('/_(add|alter|drop|optimize)_(.+)$/', $migrationName, $matches)) { return $matches[2]; } return $migrationName; } /** * 生成 CREATE TABLE SQL */ protected function generateCreateTableSql($connection, string $tableName): ?string { try { $result = $connection->select("SHOW CREATE TABLE `{$tableName}`"); if (!empty($result)) { $row = (array)$result[0]; return $row['Create Table'] ?? $row['Create View'] ?? null; } } catch (\Throwable) { // 表不存在,忽略 } return null; } /** * 回滚 * * @param \Symfony\Component\Console\Output\OutputInterface $output * @param int|null $batchNum * @param string|bool $sqlOption */ protected function runRollback(OutputInterface $output, ?int $batchNum = null, $sqlOption = false) { $logFile = $this->getMigrationLogFile(); $rows = $this->fetchMigrationRows($logFile); if (empty($rows)) { $output->writeln("ℹ️ No migrations to rollback"); return; } if ($batchNum !== null) { // 回滚到指定批次 $rollbackBatch = array_filter($rows, fn($row) => (int)$row[0] > $batchNum); if (empty($rollbackBatch)) { $output->writeln("ℹ️ No migrations to rollback for batch {$batchNum}"); return; } } else { // 回滚最新批次(默认行为) $latestBatch = (int)end($rows)[0]; $rollbackBatch = array_filter($rows, fn($row) => (int)$row[0] === $latestBatch); } $rollbackBatch = array_reverse($rollbackBatch); if ($this->dryRun) { $output->writeln("🔍 Migrations to rollback:"); foreach ($rollbackBatch as $item) { $output->writeln(" - {$item[1]}"); } return; } // 如果是导出 SQL 模式 if ($this->exportSql) { $this->exportRollbackToSql($output, $rollbackBatch, $sqlOption); return; } $schema = $this->getSchemaBuilderWithInnoDB(); // 启用事务 Db::connection($this->connection)->transaction(function () use ($output, $rollbackBatch, $schema) { foreach ($rollbackBatch as $item) { try { $migrationClass = require base_path("resource/database/migrations/{$item[1]}.php"); $migrationClass->down($schema); $output->writeln("⬇️ Rolled back: {$item[1]}"); } catch (\Throwable $e) { $output->writeln("❌ Error rolling back {$item[1]}: {$e->getMessage()}"); throw $e; } } }); // 清理日志 $latestBatch = (int)end($rows)[0]; $remainingLogs = array_filter($rows, fn($row) => $batchNum !== null ? (int)$row[0] <= $batchNum : (int)$row[0] < $latestBatch); file_put_contents($logFile, implode(PHP_EOL, array_map(fn($row) => implode(',', $row), $remainingLogs))); } /** * 导出回滚 SQL 到文件 */ protected function exportRollbackToSql(OutputInterface $output, array $rollbackBatch, $sqlOption): void { $prefix = config('database.connections.mysql.prefix', 'ma_'); // 生成 SQL 文件 $sqlContent = "-- Rollback SQL Export\n"; $sqlContent .= "-- Generated at: " . date('Y-m-d H:i:s') . "\n"; $sqlContent .= "-- Connection: {$this->connection}\n\n"; $sqlContent .= "SET FOREIGN_KEY_CHECKS = 0;\n\n"; // 为每个要回滚的迁移生成 DROP TABLE 语句 foreach ($rollbackBatch as $item) { $tableName = $this->extractTableNameFromMigration($item[1]); $fullTableName = $prefix . $tableName; // 尝试获取表结构用于生成 DROP 语句 $sqlContent .= "-- Rollback: {$item[1]}\n"; $sqlContent .= "-- Table: {$fullTableName}\n"; // 添加 DROP TABLE 语句 $sqlContent .= "DROP TABLE IF EXISTS `{$fullTableName}`;\n\n"; } $sqlContent .= "SET FOREIGN_KEY_CHECKS = 1;\n"; // 保存文件 $filename = $sqlOption === true ? 'rollback_' . date('YmdHis') . '.sql' : $sqlOption; $filepath = base_path($filename); file_put_contents($filepath, $sqlContent); $output->writeln("📄 Rollback SQL exported to: {$filepath}"); } protected function getMigrationLogFile(): string { return runtime_path('migrations/' . ($this->connection ?? 'default') . '-migrations.log'); } protected function fetchMigrationRows(string $logFile): array { if (!file_exists($logFile)) { touch($logFile); } $content = trim(file_get_contents($logFile)); return empty($content) ? [] : array_map(fn($row) => explode(',', $row), explode(PHP_EOL, $content)); } protected function getSchemaBuilder(): \Illuminate\Database\Schema\Builder { $schema = Db::connection($this->connection)->getSchemaBuilder(); // 设置默认使用 InnoDB 引擎 $schema->blueprintResolver(function ($table, $callback) { return new \Illuminate\Database\Schema\Blueprint($table, $callback); }); return $schema; } /** * 获取带引擎配置的表创建回调 */ protected function getSchemaBuilderWithInnoDB(): \Illuminate\Database\Schema\Builder { $schema = $this->getSchemaBuilder(); // 确保使用 InnoDB 引擎 Db::connection($this->connection)->statement("SET default_storage_engine=INNODB"); return $schema; } }