// +----------------------------------------------------------------------
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;
}
}