// +---------------------------------------------------------------------- namespace ywxapp\command; use think\console\Command; use think\console\Input; use think\console\input\Argument; use think\console\input\Option; use think\console\Output; use think\facade\Db; use think\facade\Config; use think\facade\Log; use ywxapp\service\AddonService; use ywxapp\model\BaseModel; /** * 插件一键修复命令("插件坏什么修什么") * * 整合以下自愈能力,使一个出问题的插件恢复到「菜单正确 + 表结构完整 + 配置齐全」的状态: * 1. 重载菜单:refreshMenu()(清旧 admin_power/user_rule + 重建,顺带修复菜单路由前缀) * 2. 数据库自愈:解析 install.sql 中该插件全部 CREATE TABLE,缺表建表、缺列补列 * 3. 配置行补全:确保 addon_config 表存在该插件配置行(无则按默认 info 写入) * * 用法: * php think addon:repair <插件名> # 修复指定插件(菜单 + 表 + 配置) * php think addon:repair <插件名> --menu # 仅重载菜单 * php think addon:repair <插件名> --db # 仅自愈数据库表/列 * php think addon:repair <插件名> --config # 仅补全配置行 * php think addon:repair all # 逐个修复全部已安装插件 * * php think addon:repair appmall --menu */ class AddonRepair extends Command { protected function configure() { $this->setName('addon:repair') ->addArgument('addon', Argument::REQUIRED, '插件名(或 all 修复全部)') ->addOption('menu', 'm', Option::VALUE_NONE, '仅重载菜单') ->addOption('db', 'b', Option::VALUE_NONE, '仅自愈数据库表/列') ->addOption('config', 'c', Option::VALUE_NONE, '仅补全配置行') ->addOption('force', 'f', Option::VALUE_NONE, '执行破坏性修复(如重命名无前缀孤儿表)') ->setDescription('插件一键修复:重载菜单 / 自愈表结构 / 补全配置'); } protected function execute(Input $input, Output $output): int { $name = strtolower(trim((string) $input->getArgument('addon'))); // 指定了单一 --xxx 则只跑对应步骤;否则全跑 $onlyMenu = (bool) $input->getOption('menu'); $onlyDb = (bool) $input->getOption('db'); $onlyConfig = (bool) $input->getOption('config'); $all = !$onlyMenu && !$onlyDb && !$onlyConfig; if ($name === 'all') { $names = $this->installedaddon(); if (empty($names)) { $output->writeln('没有已安装的插件。'); return 0; } $output->writeln('开始逐个修复 ' . count($names) . ' 个插件...'); $failed = []; foreach ($names as $n) { $output->writeln("\n========== 修复插件: {$n} =========="); $code = $this->repairOne($n, $all, $onlyMenu, $onlyDb, $onlyConfig, $output); if ($code !== 0) { $failed[] = $n; } } $output->writeln("\n=== 修复汇总 ==="); $output->writeln('总数: ' . count($names) . ' 失败: ' . count($failed)); if ($failed) { $output->writeln('失败: ' . implode(', ', $failed) . ''); return 1; } return 0; } return $this->repairOne($name, $all, $onlyMenu, $onlyDb, $onlyConfig, $output); } private function repairOne(string $name, bool $all, bool $onlyMenu, bool $onlyDb, bool $onlyConfig, Output $output): int { try { $svc = AddonService::instance($name); } catch (\Throwable $e) { $output->writeln('初始化插件失败: ' . $e->getMessage() . ''); return 1; } $ok = true; if ($all || $onlyMenu) { try { $svc->refreshMenu(); $output->writeln('[菜单] 已重载(admin_power/user_rule 已重建)'); } catch (\Throwable $e) { $output->writeln('[菜单] 重载失败: ' . $e->getMessage() . ''); $ok = false; } } if ($all || $onlyDb) { $ok = $this->repairDb($svc, $name, $output) && $ok; } if ($all || $onlyConfig) { $ok = $this->repairConfig($svc, $name, $output) && $ok; } if ($ok) { $output->writeln("[完成] 插件 {$name} 修复成功"); } else { $output->writeln("[完成] 插件 {$name} 修复存在失败项"); } return $ok ? 0 : 1; } /** * 数据库自愈:解析 install.sql 的 CREATE TABLE,缺表建表,并尽量补列。 */ private function repairDb(AddonService $svc, string $name, Output $output): bool { $addonDir = $this->addonDir($name); $installSql = $addonDir . 'install.sql'; if (!is_file($installSql)) { $output->writeln('[数据库] 无 install.sql,跳过'); return true; } $content = file_get_contents($installSql); if ($content === false || $content === '') { $output->writeln('[数据库] install.sql 为空,跳过'); return true; } // 必须从 Config 取前缀(与 AddonService::importsql 一致),CLI 下 Db::getConfig 可能为空 $default = Config::get('database.default'); $prefix = Config::get("database.connections.{$default}.prefix"); if ($prefix === '' || $prefix === null) { // 兜底:框架默认前缀,避免建出无前缀的孤儿表 $prefix = 'wxapp_'; $output->writeln('[数据库] 未检测到库前缀,按默认 wxapp_ 处理'); } // 还原框架在 importsql 时做的 __PREFIX__ 替换,得到运行时完整建表语句 $content = str_ireplace('__PREFIX__', $prefix, $content); // 去掉注释行,避免误执行 $content = preg_replace('/--.*$/m', '', $content); // 按语句拆分(以分号结尾) $statements = array_filter(array_map('trim', explode(';', $content))); $created = 0; $exists = 0; $failed = 0; foreach ($statements as $stmt) { if (!preg_match('/^\s*CREATE\s+TABLE/i', $stmt)) { continue; } // 提取表名(已带前缀) if (!preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([a-zA-Z0-9_]+)[`"]?/i', $stmt, $m)) { continue; } $table = $m[1]; try { if (BaseModel::tableExists($table)) { $exists++; // 表存在也尝试补列(基于 install.sql 的后续 ALTER/列定义无法可靠解析, // 这里只保证表存在;列级自愈由业务层 ensureColumn 负责) continue; } Db::execute($stmt); $created++; $output->writeln("[数据库] 已建表: {$table}"); } catch (\Throwable $e) { $failed++; $output->writeln('[数据库] 建表失败 ' . $table . ': ' . $e->getMessage() . ''); Log::error("[AddonRepair] create table {$table} failed: " . $e->getMessage()); } } $output->writeln("[数据库] 已建表 {$created} / 已存在 {$exists} / 失败 {$failed}"); // 孤儿无前缀表检测与修复: // 某些插件经 importsql 在前缀未就绪时被建为无前缀表(如 appmarket_addon_submissions), // 而运行时 Db::name() 拼出的表名带前缀,导致业务读不到数据。这里扫描并提示/修复。 $orphans = $this->detectOrphanTables($name, $prefix); if (!empty($orphans)) { $force = (bool) $input->getOption('force'); foreach ($orphans as $plain => $prefixed) { if ($force) { try { if (!BaseModel::tableExists($prefixed)) { Db::execute("RENAME TABLE `{$plain}` TO `{$prefixed}`"); $output->writeln("[数据库] 已修复孤儿表: {$plain} -> {$prefixed}"); } else { $output->writeln("[数据库] 已存在带前缀表,跳过重命名: {$prefixed}(无前缀表 {$plain} 需手动清理)"); } } catch (\Throwable $e) { $output->writeln('[数据库] 重命名失败 ' . $plain . ': ' . $e->getMessage() . ''); $failed++; } } else { $output->writeln("[数据库] 发现无前缀孤儿表: {$plain}(应命名为 {$prefixed},加 --force 自动重命名修复)"); $failed++; } } } return $failed === 0; } /** * 扫描本插件是否存在「无前缀的孤儿表」:install.sql 里 __PREFIX__xxx 对应的运行时表名带前缀, * 但库中却存在无前缀同名表(导入时前缀为空导致)。返回 [无前缀表 => 正确带前缀表] 映射。 */ private function detectOrphanTables(string $name, string $prefix): array { $map = []; $base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR); $installSql = $base . $name . DIRECTORY_SEPARATOR . 'install.sql'; if (!is_file($installSql)) { return $map; } $content = preg_replace('/--.*$/m', '', (string) file_get_contents($installSql)); if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?__PREFIX__([a-zA-Z0-9_]+)[`"]?/i', $content, $matches)) { return $map; } foreach (array_unique($matches[1]) as $suffix) { $plain = $name . '_' . ltrim($suffix, '_'); $prefixed = $prefix . $plain; if (BaseModel::tableExists($plain) && !BaseModel::tableExists($prefixed)) { $map[$plain] = $prefixed; } } return $map; } /** * 配置行补全:确保 addon_config 表有该插件配置行。 */ private function repairConfig(AddonService $svc, string $name, Output $output): bool { $default = Config::get('database.default'); $prefix = Config::get("database.connections.{$default}.prefix") ?: ''; $configTable = $prefix . 'addon_config'; try { if (!BaseModel::tableExists($configTable)) { $output->writeln('[配置] addon_config 表不存在,跳过'); return true; } $row = Db::table($configTable)->where('name', $name)->find(); if (!empty($row)) { $output->writeln('[配置] 配置行已存在,跳过'); return true; } // 读取插件默认配置(config.php 返回数组) $configFile = $this->addonDir($name) . 'config.php'; $value = []; if (is_file($configFile)) { $cfg = include $configFile; if (is_array($cfg)) { $value = $cfg; } } $data = [ 'name' => $name, 'value' => json_encode($value, JSON_UNESCAPED_UNICODE), ]; // 兼容不同时间戳字段名(createtime/updatetime 或 create_time/update_time) $cols = Db::query("SHOW COLUMNS FROM `{$configTable}`"); $colNames = array_column($cols, 'Field'); $now = time(); if (in_array('createtime', $colNames, true)) { $data['createtime'] = $now; } elseif (in_array('create_time', $colNames, true)) { $data['create_time'] = $now; } if (in_array('updatetime', $colNames, true)) { $data['updatetime'] = $now; } elseif (in_array('update_time', $colNames, true)) { $data['update_time'] = $now; } Db::table($configTable)->insert($data); $output->writeln("[配置] 已补全配置行: {$name}"); return true; } catch (\Throwable $e) { $output->writeln('[配置] 补全失败: ' . $e->getMessage() . ''); return false; } } private function addonDir(string $name): string { $base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR); return $base . $name . DIRECTORY_SEPARATOR; } private function installedaddon(): array { $base = defined('ADDON_PATH') ? ADDON_PATH : (root_path() . 'addon' . DIRECTORY_SEPARATOR); if (!is_dir($base)) { return []; } $names = []; foreach (array_diff(scandir($base), ['.', '..']) as $cand) { $dir = $base . $cand . DIRECTORY_SEPARATOR; if (is_dir($dir) && is_file($dir . 'info.php')) { $names[] = $cand; } } return $names; } }