初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\command\install;
use app\command\BaseCommand;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* 下载前端代码命令
* 使用方法:
* - 下载所有前端代码:php webman madong-download:frontend
* - 下载指定代码:php webman madong-download:frontend --admin --web
* - 指定分支:php webman madong-download:frontend -b develop
* - 强制更新:php webman madong-download:frontend -f
*
* @author Mr.April
* @since 1.0.0
*/
#[AsCommand(
name: 'madong-download:frontend',
description: 'Download frontend code to the root directory',
aliases: ['madong-download:frontend'],
hidden: false
)]
class DownloadCommand extends BaseCommand
{
// 前端配置信息 - 支持灵活配置下载项目和重命名
// 所有前端代码统一下载到 frontend 目录下
private array $frontendConfigs = [
'admin' => [
'name' => '后台下载',
'git_url' => 'https://gitee.com/motion-code/madong-vue.git',
'dir_name' => 'frontend' . DIRECTORY_SEPARATOR . 'admin',
],
'web' => [
'name' => '前台下载',
'git_url' => 'https://gitee.com/motion-code/madong-nuxt.git',
'dir_name' => 'frontend' . DIRECTORY_SEPARATOR . 'web',
],
];
/**
* 配置命令
*/
protected function configure(): void
{
$this
->addOption('admin', 'a', InputOption::VALUE_NONE, 'Download admin frontend code')
->addOption('web', 'w', InputOption::VALUE_NONE, 'Download web frontend code')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force download (overwrite existing files)')
->addOption('branch', 'b', InputOption::VALUE_REQUIRED, 'Specify git branch to download', 'main');
// 为配置的每个项目自动添加选项
foreach (array_keys($this->frontendConfigs) as $project) {
if (!in_array($project, ['admin', 'web'])) { // 跳过已经手动添加的选项
$this->addOption($project, null, InputOption::VALUE_NONE, "Download {$project} frontend code");
}
}
}
/**
* 执行命令
*/
public function __invoke(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Frontend Code Downloader');
// 验证Git是否可用
if (!$this->isGitAvailable()) {
$io->error('Git is not available on your system. Please install Git first.');
$io->note('You can download Git from https://git-scm.com/downloads');
return $this->outputError($io, 'Git is not available');
}
// 获取参数
$forceDownload = $input->getOption('force');
$branch = $input->getOption('branch');
// 确定要下载的项目列表
$projectsToDownload = [];
$anyOptionSelected = false;
foreach (array_keys($this->frontendConfigs) as $project) {
if ($input->getOption($project)) {
$projectsToDownload[] = $project;
$anyOptionSelected = true;
}
}
// 如果没有指定具体项目,默认下载所有
if (!$anyOptionSelected) {
$projectsToDownload = array_keys($this->frontendConfigs);
}
// 获取当前项目根目录
$rootDir = dirname(base_path());
$io->info("Project root directory: {$rootDir}");
// 确保 frontend 目录存在
$frontendDir = $rootDir . DIRECTORY_SEPARATOR . 'frontend';
if (!is_dir($frontendDir)) {
if (!mkdir($frontendDir, 0755, true)) {
$io->error("Failed to create frontend directory");
return $this->outputError($io, 'Failed to create frontend directory');
}
$io->info("Created frontend directory");
}
$io->info("Using git mode for downloading code");
// 初始化统计信息
$successCount = 0;
$totalCount = count($projectsToDownload);
// 逐个下载前端项目
foreach ($projectsToDownload as $project) {
$frontendConfig = $this->frontendConfigs[$project];
$io->section("Downloading: {$frontendConfig['name']}");
// 定义前端代码保存的目录和git地址
$frontendGitUrl = $frontendConfig['git_url'];
$frontendDir = $frontendConfig['dir_name'];
$frontendFullPath = $rootDir . DIRECTORY_SEPARATOR . $frontendDir;
// 检查前端代码目录是否已存在
if (is_dir($frontendFullPath)) {
if (!$forceDownload) {
if (!$io->confirm("Directory '{$frontendDir}' already exists. Do you want to update it?", true)) {
$io->note("Skipping update for {$frontendDir} directory");
continue;
}
}
$io->info("Directory exists, updating {$frontendDir} code...");
// 执行git pull命令
$command = "cd \"{$frontendFullPath}\" && git pull origin {$branch}";
$io->text("Executing: {$command}");
$outputBuffer = [];
$returnValue = 0;
exec($command, $outputBuffer, $returnValue);
// 输出结果
foreach ($outputBuffer as $line) {
$io->text($line);
}
if ($returnValue !== 0) {
$io->error("Failed to update {$frontendDir}");
continue;
}
} else {
$io->info("Directory does not exist, cloning {$frontendDir} code...");
// 执行git clone命令 - 自动重命名为配置的目录名
$command = "git clone -b {$branch} {$frontendGitUrl} {$frontendDir}";
$io->text("Executing: {$command}");
$outputBuffer = [];
$returnValue = 0;
exec("cd \"{$rootDir}\" && " . $command, $outputBuffer, $returnValue);
// 输出结果
foreach ($outputBuffer as $line) {
$io->text($line);
}
if ($returnValue !== 0) {
$io->error("Failed to clone {$frontendDir}");
continue;
}
}
$io->success("Successfully downloaded {$frontendDir} frontend code");
$io->note("Saved to: {$frontendFullPath}");
$successCount++;
}
// 输出总结
$io->section('Download Summary');
if ($successCount === $totalCount) {
$io->success("Successfully downloaded all {$successCount} frontend code!");
return Command::SUCCESS;
} elseif ($successCount > 0) {
$io->warning("Downloaded {$successCount} of {$totalCount} frontend code successfully");
return Command::FAILURE;
} else {
$io->error("Failed to download any frontend code");
return Command::FAILURE;
}
}
/**
* 检查Git是否可用
*/
private function isGitAvailable(): bool
{
$output = [];
$returnValue = 0;
exec('git --version', $output, $returnValue);
return $returnValue === 0;
}
}
@@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\command\install;
use app\command\BaseCommand;
use app\service\core\install\InstallService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* 框架安装命令
*
* 支持 CLI 交互式安装和在线安装共享同一套服务逻辑
*
* 使用示例:
* php webman install:madong
* php webman install:madong --username=admin --password=123456
* php webman install:madong --db-host=localhost --db-port=3306 --db-name=madong --db-user=root --db-pass=123456 --admin-username=admin --admin-password=123456
*/
#[AsCommand(
name: 'install:madong',
description: '安装 MDAdmin 框架(交互式或参数化)',
aliases: ['install:madong'],
hidden: false
)]
class InstallCommand extends BaseCommand
{
private ?InstallService $installService = null;
public function __construct()
{
parent::__construct();
// 不在构造函数中实例化 InstallService,避免命令加载时触发依赖初始化
}
/**
* 获取 InstallService 实例(延迟加载)
*/
private function getInstallService(): InstallService
{
if ($this->installService === null) {
try {
$this->installService = new InstallService();
} catch (\Throwable $e) {
throw new \RuntimeException(
'Failed to initialize InstallService: ' . $e->getMessage(),
0,
$e
);
}
}
return $this->installService;
}
/**
* 配置命令参数和选项
*/
protected function configure(): void
{
$this
->setDescription('安装 MDAdmin 框架')
->setHelp('此命令用于安装 MDAdmin 框架,支持交互式和参数化两种方式')
// 数据库参数
->addOption('db-host', null, InputOption::VALUE_OPTIONAL, '数据库主机')
->addOption('db-port', null, InputOption::VALUE_OPTIONAL, '数据库端口', '3306')
->addOption('db-name', null, InputOption::VALUE_OPTIONAL, '数据库名称')
->addOption('db-user', null, InputOption::VALUE_OPTIONAL, '数据库用户名')
->addOption('db-pass', null, InputOption::VALUE_OPTIONAL, '数据库密码')
->addOption('db-prefix', null, InputOption::VALUE_OPTIONAL, '数据库表前缀', 'ma_')
// 管理员参数
->addOption('admin-username', null, InputOption::VALUE_OPTIONAL, '管理员用户名')
->addOption('admin-password', null, InputOption::VALUE_OPTIONAL, '管理员密码')
->addOption('admin-email', null, InputOption::VALUE_OPTIONAL, '管理员邮箱')
// 安装选项
->addOption('install-database', null, InputOption::VALUE_OPTIONAL, '是否安装数据库表(1=是,0=否)', '1')
->addOption('build-project', null, InputOption::VALUE_OPTIONAL, '是否构建前端项目(1=是,0=否)', '1')
->addOption('non-interactive', 'y', InputOption::VALUE_NONE, '非交互模式(使用所有默认值)')
->addOption('skip-env-check', null, InputOption::VALUE_NONE, '跳过环境检查');
}
/**
* 执行安装命令
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// 显示欢迎信息
$io->title('MDAdmin 框架安装程序');
$io->text([
'',
'欢迎使用 MDAdmin 框架安装程序!',
'此程序将引导您完成框架的安装配置。',
'',
'<comment>提示: 密码输入时不会显示字符(出于安全考虑),输入完成后会显示确认信息</comment>',
'',
]);
// 检查是否已安装
if ($this->getInstallService()->checkInstalled()) {
$io->error('系统已安装,请勿重复安装!');
$io->note('如需重新安装,请先删除 ' . base_path('install.lock') . ' 文件');
return Command::FAILURE;
}
// 交互式或非交互式
$isInteractive = !$input->getOption('non-interactive');
$skipEnvCheck = $input->getOption('skip-env-check');
// 1. 环境检测
if (!$skipEnvCheck) {
$io->section('1. 环境检测');
$this->handleEnvironmentCheck($io);
}
// 2. 数据库配置
$io->section('2. 数据库配置');
$installParams = $this->collectInstallParams($io, $input, $isInteractive);
// 3. 确认安装
$io->section('3. 安装确认');
if (!$this->confirmInstallation($io, $installParams, $isInteractive)) {
$io->note('安装已取消');
return Command::SUCCESS;
}
// 4. 执行安装
$io->section('4. 执行安装');
$io->text('开始安装...');
try {
// 调用安装服务(共享在线安装逻辑)
$generator = $this->getInstallService()->install($installParams);
// 处理 SSE 事件输出
foreach ($generator as $chunk) {
$result = $this->handleSseEvent($chunk, $io, null);
if ($result !== null) {
return $result;
}
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$this->outputError($io, '安装失败: ' . $e->getMessage(), $e);
return Command::FAILURE;
}
}
/**
* 处理环境检查
*/
private function handleEnvironmentCheck(SymfonyStyle $io): void
{
$environment = $this->getInstallService()->checkEnvironment();
// 显示系统信息
$io->text('系统信息:');
$io->definitionList(
['PHP 版本' => $environment['php_version']],
['服务器软件' => $environment['server_software']],
['操作系统' => $environment['operating_system']],
['内存限制' => $environment['memory_limit']],
['最大执行时间' => $environment['max_execution_time'] . '秒'],
);
$io->newLine();
// 检查系统要求
$allOk = $environment['all_requirements_met'];
if (!$allOk) {
$io->warning('环境检查未通过,请检查以下项目:');
// 显示失败的检查项
foreach ($environment['check_items'] as $item) {
if (!$item['status']) {
$io->error($item['message']);
}
}
foreach ($environment['directory_check_items'] as $item) {
if (!$item['status'] && $item['required']) {
$io->error($item['message']);
}
}
// 确认是否继续
if (!$this->confirm($io, '环境检查未通过,是否继续安装?', false)) {
throw new \Exception('环境检查未通过,安装已取消');
}
} else {
$io->success('环境检查通过!');
}
$io->newLine();
}
/**
* 收集安装参数
*/
private function collectInstallParams(SymfonyStyle $io, InputInterface $input, bool $isInteractive): array
{
$params = [];
// 数据库配置
$io->text('<info>数据库配置</info>');
$dbHost = $input->getOption('db-host');
if (!$dbHost && $isInteractive) {
$dbHost = $io->ask('请输入数据库主机', '127.0.0.1');
}
$params['db_host'] = $dbHost ?? '127.0.0.1';
$dbPort = $input->getOption('db-port');
if (!$dbPort && $isInteractive) {
$dbPort = $io->ask('请输入数据库端口', '3306');
}
$params['db_port'] = $dbPort ?? '3306';
$dbName = $input->getOption('db-name');
if (!$dbName && $isInteractive) {
$dbName = $io->ask('请输入数据库名称', 'md_admin');
}
$params['db_database'] = $dbName ?? 'md_admin';
$dbUser = $input->getOption('db-user');
if (!$dbUser && $isInteractive) {
$dbUser = $io->ask('请输入数据库用户名', 'root');
}
$params['db_username'] = $dbUser ?? 'root';
$dbPass = $input->getOption('db-pass');
if (!$dbPass && $isInteractive) {
$io->text('提示: 密码输入时显示掩码字符 *');
$dbPass = $this->askPassword($io, '请输入数据库密码: ');
if ($dbPass !== '') {
$io->text('✓ 数据库密码已设置');
}
}
$params['db_password'] = $dbPass ?? '';
$dbPrefix = $input->getOption('db-prefix');
if (!$dbPrefix && $isInteractive) {
$dbPrefix = $io->ask('请输入数据库表前缀', 'ma_');
}
$params['db_prefix'] = $dbPrefix ?? 'ma_';
// 管理员配置
$io->newLine();
$io->text('<info>管理员配置</info>');
$adminUsername = $input->getOption('admin-username');
if (!$adminUsername && $isInteractive) {
$adminUsername = $io->ask('请输入管理员用户名', 'admin');
}
$params['admin_username'] = $adminUsername ?? 'admin';
$adminPassword = $input->getOption('admin-password');
if (!$adminPassword && $isInteractive) {
$io->text('提示: 密码输入时显示掩码字符 *');
do {
$adminPassword = $this->askPassword($io, '请输入管理员密码(至少6位): ');
if (strlen($adminPassword) < 6) {
$io->error('密码长度不能少于6位!');
}
} while (strlen($adminPassword) < 6);
$io->text('✓ 管理员密码已设置');
}
$params['admin_password'] = $adminPassword ?? '123456';
$adminEmail = $input->getOption('admin-email');
if (!$adminEmail && $isInteractive) {
$adminEmail = $io->ask('请输入管理员邮箱', 'admin@example.com');
}
$params['admin_email'] = $adminEmail ?? 'admin@example.com';
// 安装选项
$io->newLine();
$io->text('<info>安装选项</info>');
$installDatabase = $input->getOption('install-database');
if ($installDatabase === null && $isInteractive) {
$installDatabase = $this->confirm($io, '是否安装数据库表?', true) ? '1' : '0';
}
$params['install_database'] = $installDatabase ?? '1';
$buildProject = $input->getOption('build-project');
if ($buildProject === null && $isInteractive) {
$buildProject = $this->confirm($io, '是否构建前端项目?', false) ? '1' : '0';
}
$params['build_project'] = $buildProject ?? '0';
return $params;
}
/**
* 确认安装信息
*/
private function confirmInstallation(SymfonyStyle $io, array $params, bool $isInteractive): bool
{
// 显示配置摘要
$io->text('安装配置摘要:');
$io->definitionList(
['数据库主机' => $params['db_host']],
['数据库端口' => $params['db_port']],
['数据库名称' => $params['db_database']],
['数据库用户' => $params['db_username']],
['表前缀' => $params['db_prefix']],
['管理员用户名' => $params['admin_username']],
['管理员邮箱' => $params['admin_email']],
['安装数据库' => $params['install_database'] == '1' ? '是' : '否'],
['构建前端' => $params['build_project'] == '1' ? '是' : '否'],
);
$io->newLine();
// 确认安装
if ($isInteractive) {
return $this->confirm($io, '确认开始安装?安装前请确保数据库配置正确!', false);
}
return true;
}
}
@@ -0,0 +1,798 @@
<?php
declare(strict_types=1);
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: http://www.madong.tech
*/
namespace app\command\install;
use app\command\BaseCommand;
use support\Db;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
//创建迁移(自动表名) madong-migrate make RoleBelongsMenu 生成小写蛇形文件名,提示表名
//创建迁移(手动表名) madong-migrate make RoleBelongsMenu --table=user_roles 使用指定表名,生成对应文件名
//创建种子 madong-migrate make-seed User 生成UserSeeder.php
//执行迁移 madong-migrate up 输出已迁移的文件名
//迁移+种子 madong-migrate up --seed 先迁移,后运行种子
//回滚迁移 madong-migrate rollback 输出回滚的文件名,清理日志
//运行种子 madong-migrate seed 输出已运行的种子类名
//查看状态 madong-migrate status 显示迁移状态
//批量回滚 madong-migrate rollback --batch=2 回滚到指定批次
//干运行模式 madong-migrate up --dry-run 预览将要执行的迁移(不实际执行)
//导出SQL(含数据) madong-migrate up --sql=install.sql 迁移+种子+导出SQL(含表结构和数据)
//导出SQL(无数据) madong-migrate up --sql=install.sql --no-data 只导出表结构,不包含数据
/**
* 数据迁移
*
* @author Mr.April
* @since 1.0
*/
class MigrateCommand extends BaseCommand
{
protected static string $defaultName = 'madong-migrate';
protected static string $defaultDescription = '数据迁移和种子数据 (迁移+种子+导出SQL)';
protected ?string $connection = null;
protected bool $dryRun = false;
protected bool $exportSql = false;
protected bool $exportWithData = true;
protected function configure(): void
{
$this->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("<error>❌ Invalid operation: {$operate}</error>");
return Command::INVALID;
}
if ($this->dryRun && !in_array($operate, ['up', 'rollback'])) {
$output->writeln("<error>❌ --dry-run is only supported for 'up' and 'rollback' operations</error>");
return Command::INVALID;
}
if ($this->exportSql && !in_array($operate, ['up', 'rollback'])) {
$output->writeln("<error>❌ --sql is only supported for 'up' and 'rollback' operations</error>");
return Command::INVALID;
}
if ($this->dryRun) {
$output->writeln("<comment>🔍 DRY RUN MODE - No changes will be made</comment>");
}
if ($this->exportSql) {
$output->writeln("<comment>📄 SQL EXPORT MODE - SQL will be saved to file</comment>");
}
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('<info>✅ Operation completed successfully!</info>');
}
return Command::SUCCESS;
}
/**
* 驼峰转下划线(小写)
*/
protected function camelToSnake(string $str): string
{
return strtolower(preg_replace('/(?<!^)([A-Z])/', '_$1', $str));
}
/**
* 下划线转驼峰(首字母大写)
*/
protected function snakeToCamel(string $str): string
{
return str_replace('_', '', ucwords($str, '_'));
}
/**
* 生成表名(驼峰转下划线 + 复数)
*/
protected function generateTableName(string $name): string
{
return $this->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("<info>ℹ️ Using specified table: {$tableName}</info>");
} else {
$output->writeln("<info>ℹ️ Generated table: {$tableName}</info>");
}
// 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("<error>❌ Migration file exists: {$fileName}</error>");
return;
}
// 5. 生成迁移模板(匿名类,文件名小写蛇形)
$template = $this->generateMigrationTemplate($tableName);
file_put_contents($filePath, $template);
$output->writeln("<info>📝 Migration created: {$fileName}</info>");
}
/**
* 生成迁移模板(保持不变)
*/
protected function generateMigrationTemplate(string $tableName): string
{
return <<<EOF
<?php
use Illuminate\Database\Schema\Builder;
use Illuminate\Database\Schema\Blueprint;
use support\Db;
return new class {
public function up(Builder \$schema): void
{
// 创建数据表(表名:{$tableName})
\$schema->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("<error>❌ Seeder exists: {$fileName}</error>");
return;
}
// 🔑 传入驼峰前缀生成模板(类名直接拼接前缀)
$template = $this->generateSeederTemplate($seederClassPrefix);
file_put_contents($filePath, $template);
// ✅ 明确提示生成的文件和类名
$output->writeln("<info>📝 Seeder created: {$fileName}</info>");
$output->writeln("<comment>ℹ️ Seeder class: {$seederClassPrefix}Seeder</comment>");
}
/**
* 生成种子模板
*
* @param string $name
*
* @return string
*/
protected function generateSeederTemplate(string $name): string
{
$className = ucfirst($name) . 'Seeder';
return <<<EOF
<?php
declare(strict_types=1);
use support\Db;
class {$className}
{
public function run(): void
{
// 示例:插入用户数据
/*
Db::table('users')->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("<comment>ℹ️ No seeders directory</comment>");
return;
}
$files = glob("{$seederDir}/*.php");
if (empty($files)) {
$output->writeln("<comment>ℹ️ No seeders available</comment>");
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("<info>✅ Seeded: {$fullClassName}</info>");
} catch (\Throwable $e) {
$output->writeln("<error>❌ Error seeding {$fullClassName}: {$e->getMessage()}</error>");
}
}
}
/**
* 查看迁移状态
*/
protected function runStatus(OutputInterface $output)
{
$logFile = $this->getMigrationLogFile();
$migrationDir = base_path('resource/database/migrations');
if (!is_dir($migrationDir)) {
$output->writeln("<comment>ℹ️ No migrations directory found</comment>");
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("<comment>ℹ️ No migration files found</comment>");
return;
}
// 按文件名排序
sort($allMigrations);
// 按批次分组显示
$batches = [];
foreach ($rows as $row) {
$batches[$row[0]][] = $row[1];
}
$output->writeln("<info>📊 Migration Status</info>");
$output->writeln(str_repeat('=', 50));
$totalMigrated = count($existingMigrations);
$totalPending = count($allMigrations) - $totalMigrated;
$output->writeln("<info>Migrated: {$totalMigrated} | Pending: {$totalPending} | Total: " . count($allMigrations) . "</info>");
$output->writeln(str_repeat('-', 50));
// 显示所有迁移及其状态
foreach ($allMigrations as $migration) {
if ($migration['status'] === '✅ Migrated') {
$output->writeln("<info>{$migration['status']}: {$migration['filename']}</info>");
} else {
$output->writeln("<comment>{$migration['status']}: {$migration['filename']}</comment>");
}
}
if (!empty($batches)) {
$output->writeln(str_repeat('-', 50));
$output->writeln("<info>📦 Migration Batches</info>");
krsort($batches);
foreach ($batches as $batch => $migrations) {
$output->writeln("<comment>Batch {$batch}:</comment> " . 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("<comment>ℹ️ No migrations directory found</comment>");
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("<comment>ℹ️ Migration already executed: {$targetName}</comment>");
} else {
$output->writeln("<error>❌ Migration file not found: {$targetName}</error>");
}
return;
}
if (empty($newMigrations)) {
$output->writeln("<comment>ℹ️ No pending migrations</comment>");
if ($runSeed) {
$this->runSeeders($output);
}
return;
}
sort($newMigrations);
$schema = $this->getSchemaBuilderWithInnoDB();
if ($this->dryRun) {
$output->writeln("<comment>🔍 Pending migrations to execute:</comment>");
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("<info>⬆️ Migrated: {$migration[1]}</info>");
} catch (\Throwable $e) {
$output->writeln("<error>❌ Error migrating {$migration[1]}: {$e->getMessage()}</error>");
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("<comment>🔄 Running seeders before SQL export...</comment>");
$this->runSeeders($output);
$output->writeln("<comment>✅ Seeders completed, now generating SQL...</comment>");
}
// 生成 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("<info>📄 SQL exported to: {$filepath}</info>");
}
/**
* 解析迁移文件生成 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("<comment>ℹ️ No migrations to rollback</comment>");
return;
}
if ($batchNum !== null) {
// 回滚到指定批次
$rollbackBatch = array_filter($rows, fn($row) => (int)$row[0] > $batchNum);
if (empty($rollbackBatch)) {
$output->writeln("<comment>ℹ️ No migrations to rollback for batch {$batchNum}</comment>");
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("<comment>🔍 Migrations to rollback:</comment>");
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("<info>⬇️ Rolled back: {$item[1]}</info>");
} catch (\Throwable $e) {
$output->writeln("<error>❌ Error rolling back {$item[1]}: {$e->getMessage()}</error>");
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("<info>📄 Rollback SQL exported to: {$filepath}</info>");
}
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;
}
}