Files
YwxAppThink/ywxapp/command/AddonRepair.php
T

323 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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('<comment>没有已安装的插件。</comment>');
return 0;
}
$output->writeln('<info>开始逐个修复 ' . count($names) . ' 个插件...</info>');
$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('<error>失败: ' . implode(', ', $failed) . '</error>');
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('<error>初始化插件失败: ' . $e->getMessage() . '</error>');
return 1;
}
$ok = true;
if ($all || $onlyMenu) {
try {
$svc->refreshMenu();
$output->writeln('<info>[菜单] 已重载(admin_power/user_rule 已重建)</info>');
} catch (\Throwable $e) {
$output->writeln('<error>[菜单] 重载失败: ' . $e->getMessage() . '</error>');
$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("<info>[完成] 插件 {$name} 修复成功</info>");
} else {
$output->writeln("<error>[完成] 插件 {$name} 修复存在失败项</error>");
}
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('<comment>[数据库] 无 install.sql,跳过</comment>');
return true;
}
$content = file_get_contents($installSql);
if ($content === false || $content === '') {
$output->writeln('<comment>[数据库] install.sql 为空,跳过</comment>');
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('<comment>[数据库] 未检测到库前缀,按默认 wxapp_ 处理</comment>');
}
// 还原框架在 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("<info>[数据库] 已建表: {$table}</info>");
} catch (\Throwable $e) {
$failed++;
$output->writeln('<error>[数据库] 建表失败 ' . $table . ': ' . $e->getMessage() . '</error>');
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("<info>[数据库] 已修复孤儿表: {$plain} -> {$prefixed}</info>");
} else {
$output->writeln("<comment>[数据库] 已存在带前缀表,跳过重命名: {$prefixed}(无前缀表 {$plain} 需手动清理)</comment>");
}
} catch (\Throwable $e) {
$output->writeln('<error>[数据库] 重命名失败 ' . $plain . ': ' . $e->getMessage() . '</error>');
$failed++;
}
} else {
$output->writeln("<error>[数据库] 发现无前缀孤儿表: {$plain}(应命名为 {$prefixed},加 --force 自动重命名修复)</error>");
$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('<comment>[配置] addon_config 表不存在,跳过</comment>');
return true;
}
$row = Db::table($configTable)->where('name', $name)->find();
if (!empty($row)) {
$output->writeln('<comment>[配置] 配置行已存在,跳过</comment>');
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("<info>[配置] 已补全配置行: {$name}</info>");
return true;
} catch (\Throwable $e) {
$output->writeln('<error>[配置] 补全失败: ' . $e->getMessage() . '</error>');
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;
}
}