Files

973 lines
36 KiB
PHP
Raw Permalink 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>
// +----------------------------------------------------------------------
/**
* 插件健康检查命令(合并版)
*
* 融合原 addon:health(人工排查 + --fix 自动修复)与 addon:health-check
* (系统环境 / 缓存系统检查 + JSON/HTML 输出 + 告警 + 报告存档)的能力。
*
* php think addon:health # 系统级 + 全部插件健康检查
* php think addon:health <name> # 单独检查某个插件(含详情)
* php think addon:health --detail # 显示详细信息
* php think addon:health --fix # 尝试自动修复问题(清缓存/修权限)
* php think addon:health --output=json # 输出 JSON(机器消费)
* php think addon:health --output=html # 输出 HTML 报告
* php think addon:health --send-alert # 异常时发送告警
* php think addon:health --webhook=URL # 告警 Webhook 地址
* php think addon:health --email=ADDR # 告警邮箱
* php think addon:health --save-report # 存档检查报告(runtime 保留 30 天)
*/
declare(strict_types=1);
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\Log;
use think\facade\Config;
use think\facade\Cache;
use ywxapp\service\AddonPerformanceMonitor;
/**
* AddonHealth 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class AddonHealth extends Command
{
/**
* 配置命令
*/
protected function configure()
{
$this->setName('addon:health')
->addArgument('addon', Argument::OPTIONAL, '插件名称(留空则检查全部插件 + 系统环境)')
->addOption('detail', 'd', Option::VALUE_NONE, '显示详细信息')
->addOption('fix', 'f', Option::VALUE_NONE, '尝试自动修复问题(清缓存 / 修权限)')
->addOption('output', 'o', Option::VALUE_OPTIONAL, '输出格式 (text, json, html)', 'text')
->addOption('send-alert', 'a', Option::VALUE_NONE, '异常时发送告警通知')
->addOption('webhook', 'w', Option::VALUE_OPTIONAL, '告警 Webhook URL')
->addOption('email', 'e', Option::VALUE_OPTIONAL, '告警邮箱')
->addOption('save-report', 's', Option::VALUE_NONE, '保存检查报告')
->setDescription('插件健康检查(含系统环境 / 缓存 / 文件 / 性能 / 依赖 / 告警)');
}
/**
* 执行命令
*
* @param Input $input 输入
* @param Output $output 输出
* @return int 退出码(0 健康 / 1 异常)
*/
protected function execute(Input $input, Output $output): int
{
$addon = $input->getArgument('addon');
$detail = $input->getOption('detail');
$fix = $input->getOption('fix');
$outputFormat = $input->getOption('output') ?: 'text';
$sendAlert = $input->getOption('send-alert');
$webhookUrl = $input->getOption('webhook');
$email = $input->getOption('email');
$saveReport = $input->getOption('save-report');
// 仅文本模式打印标题横幅,避免污染 JSON/HTML 输出(JSON/HTML 模式下保持纯输出)
if ($outputFormat === 'text') {
$output->writeln('<info>=== 插件健康检查 ===</info>');
$output->writeln('');
}
// 构建检查报告(始终含系统环境 + 缓存系统检查)
$report = $this->buildReport($addon, $detail);
// 按格式输出
switch ($outputFormat) {
case 'json':
$this->outputJson($report, $output);
break;
case 'html':
$this->outputHtml($report, $output);
break;
default:
$this->outputText($report, $output);
// 单插件 + --detail 时,补充版本 / 作者 / 依赖等详细信息
if ($addon && $detail) {
$this->outputSingleDetail($addon, $report['addon'][$addon] ?? [], $output);
}
break;
}
// 自动修复
if ($fix) {
$this->runAutoFix($report, $output);
}
// 发送告警
if ($sendAlert && !$report['healthy']) {
$this->sendAlert($report, $webhookUrl, $email, $output);
}
// 保存报告
if ($saveReport) {
$this->saveReport($report, $output);
}
return $report['healthy'] ? 0 : 1;
}
/**
* 构建健康检查报告
*/
private function buildReport(?string $addon, bool $detail): array
{
$report = [
'healthy' => true,
'timestamp' => time(),
'checks' => [],
'issues' => [],
'warnings' => [],
'addon' => []
];
// 1/2. 系统环境 + 缓存系统(始终检查,属全局健康)
$report['checks']['system'] = $this->checkSystemEnvironment();
$report['checks']['cache'] = $this->checkCacheSystem();
if ($addon) {
// 单插件深检
$report['addon'][$addon] = $this->getAddonHealth($addon);
// --detail 时把性能采样并入报告,使 JSON/HTML 输出也包含详情
// version/author/dependencies 等已在 getAddonHealth 返回中,此处补性能)
if ($detail && !empty($report['addon'][$addon]['enabled'])) {
$stats = AddonPerformanceMonitor::getPerformanceStats($addon);
if ($stats['total_calls'] > 0) {
$report['addon'][$addon]['performance'] = $stats;
}
}
} else {
// 全量:补充文件 / 性能 / 依赖 / 状态聚合检查
$report['checks']['files'] = $this->checkAddonFiles();
$report['checks']['performance'] = $this->checkAddonPerformance();
$report['checks']['dependencies'] = $this->checkDependencies();
$report['checks']['status'] = $this->checkaddontatus();
$addon = Config::get('addon', []);
foreach ($addon as $a) {
if (!isset($a['name'])) {
continue;
}
$report['addon'][$a['name']] = $this->getAddonHealth($a['name']);
}
}
// 聚合 checks 中的问题 / 警告
foreach ($report['checks'] as $checkResult) {
if (!$checkResult['healthy']) {
$report['healthy'] = false;
$report['issues'] = array_merge($report['issues'], $checkResult['issues']);
}
if (!empty($checkResult['warnings'])) {
$report['warnings'] = array_merge($report['warnings'], $checkResult['warnings']);
}
}
// 插件个体健康状态影响整体
foreach ($report['addon'] as $health) {
if (!$health['healthy']) {
$report['healthy'] = false;
}
}
return $report;
}
/**
* 获取单个插件健康状态(丰富信息:目录 / 文件 / info / 依赖 / 扩展 / 主类 / 性能)
*/
private function getAddonHealth(string $addon): array
{
$health = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'enabled' => false,
'install_time' => null
];
$addonPath = ADDON_PATH . $addon;
// 检查插件目录
if (!is_dir($addonPath)) {
$health['healthy'] = false;
$health['issues'][] = "插件目录不存在: {$addonPath}";
return $health;
}
// 检查必需文件
$requiredFiles = ['info.php', 'Addon.php'];
foreach ($requiredFiles as $file) {
if (!is_file($addonPath . DIRECTORY_SEPARATOR . $file)) {
$health['healthy'] = false;
$health['issues'][] = "缺少必需文件: {$file}";
}
}
// 检查 info.php
$infoFile = $addonPath . DIRECTORY_SEPARATOR . 'info.php';
if (is_file($infoFile)) {
$info = include $infoFile;
$health['enabled'] = ($info['state'] ?? false) == 1;
$health['install_time'] = $info['install_time'] ?? null;
$health['version'] = $info['version'] ?? null;
$health['author'] = $info['author'] ?? null;
$health['description'] = $info['intro'] ?? null;
// 检查依赖插件
if (isset($info['dependencies']) && !empty($info['dependencies'])) {
$health['dependencies'] = $info['dependencies'];
foreach ($info['dependencies'] as $dep) {
if (!$this->isAddonInstalled($dep)) {
$health['healthy'] = false;
$health['issues'][] = "依赖插件 {$dep} 未安装";
} elseif (!$this->isAddonEnabled($dep)) {
$health['healthy'] = false;
$health['issues'][] = "依赖插件 {$dep} 未启用";
}
}
}
// 检查 PHP 扩展
if (isset($info['extensions']) && !empty($info['extensions'])) {
$health['extensions'] = $info['extensions'];
foreach ($info['extensions'] as $ext) {
if (!extension_loaded($ext)) {
$health['healthy'] = false;
$health['issues'][] = "缺少PHP扩展: {$ext}";
}
}
}
}
// 检查插件主类与必需方法
$addonClass = '\\addon\\' . $addon . '\\Addon';
if (class_exists($addonClass)) {
try {
$instance = app()->make($addonClass);
foreach (['install', 'uninstall'] as $method) {
if (!method_exists($instance, $method)) {
$health['healthy'] = false;
$health['issues'][] = "缺少必需方法: {$method}()";
}
}
} catch (\Exception $e) {
$health['healthy'] = false;
$health['issues'][] = "插件类初始化失败: " . $e->getMessage();
}
}
// 性能检查
if ($health['enabled']) {
$stats = AddonPerformanceMonitor::getPerformanceStats($addon);
if ($stats['total_calls'] > 10) {
if ($stats['avg_execution_time'] > AddonPerformanceMonitor::PERFORMANCE_THRESHOLD) {
$health['warnings'][] = "执行时间过长: " . number_format($stats['avg_execution_time'], 2) . "秒";
}
if ($stats['avg_memory_usage'] > AddonPerformanceMonitor::MEMORY_THRESHOLD) {
$health['warnings'][] = "内存使用过多: " . number_format($stats['avg_memory_usage'] / 1024 / 1024, 2) . "MB";
}
if ($stats['success_rate'] < 90) {
$health['healthy'] = false;
$health['issues'][] = "成功率过低: " . number_format($stats['success_rate'], 2) . "%";
}
}
}
return $health;
}
/**
* 检查系统环境(PHP 版本 / 必需扩展 / runtime 可写 / 磁盘空间)
*/
private function checkSystemEnvironment(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
// PHP 版本
$phpVersion = PHP_VERSION;
$result['details']['php_version'] = $phpVersion;
if (version_compare($phpVersion, '8.0.0', '<')) {
$result['healthy'] = false;
$result['issues'][] = "PHP版本过低 ({$phpVersion}),要求 >= 8.0.0";
}
// 必需扩展
foreach (['json', 'mbstring', 'curl', 'zip'] as $ext) {
$loaded = extension_loaded($ext);
$result['details'][$ext] = $loaded;
if (!$loaded) {
$result['healthy'] = false;
$result['issues'][] = "缺少PHP扩展: {$ext}";
}
}
// runtime 目录可写
$runtimePath = root_path() . 'runtime';
if (!is_writable($runtimePath)) {
$result['healthy'] = false;
$result['issues'][] = "runtime目录不可写: {$runtimePath}";
}
// 磁盘空间
$freeSpace = disk_free_space(root_path());
$result['details']['disk_free_space'] = $this->formatBytes((int) $freeSpace);
if ($freeSpace < 104857600) { // 小于 100MB
$result['warnings'][] = "磁盘空间不足,剩余: " . $this->formatBytes((int) $freeSpace);
}
return $result;
}
/**
* 检查缓存系统
*/
private function checkCacheSystem(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
try {
$testKey = 'health_check_test_' . time();
$testValue = ['test' => true, 'timestamp' => time()];
Cache::set($testKey, $testValue, 60);
$retrieved = Cache::get($testKey);
if ($retrieved === false) {
$result['healthy'] = false;
$result['issues'][] = "缓存写入/读取失败";
} else {
$result['details']['cache_test'] = 'passed';
}
Cache::delete($testKey);
// 插件缓存命中情况(仅警告,不影响健康)
$addon = Config::get('addon', []);
foreach ($addon as $addon) {
if (!isset($addon['name']) || !($addon['state'] ?? false)) {
continue;
}
if (Cache::get('addon_config_' . $addon['name']) === false) {
$result['warnings'][] = "插件 {$addon['name']} 缓存未命中,可能影响性能";
}
}
} catch (\Exception $e) {
$result['healthy'] = false;
$result['issues'][] = "缓存系统异常: " . $e->getMessage();
}
return $result;
}
/**
* 检查插件文件完整性(全量)
*/
private function checkAddonFiles(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonPath = ADDON_PATH . $addon['name'];
$result['details'][$addon['name']] = [];
if (!is_dir($addonPath)) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addon['name']} 目录不存在";
continue;
}
foreach (['info.php', 'Addon.php'] as $file) {
$filePath = $addonPath . DIRECTORY_SEPARATOR . $file;
$exists = is_file($filePath);
$result['details'][$addon['name']][$file] = $exists;
if (!$exists) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addon['name']} 缺少必需文件: {$file}";
}
}
// 文件权限(不可读即警告)
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($addonPath),
\RecursiveIteratorIterator::SELF_FIRST
);
$permissionIssues = [];
foreach ($iterator as $item) {
if ($item->isFile() && ($item->getPerms() & 0x0004) === 0) {
$permissionIssues[] = $item->getPathname();
}
}
if (!empty($permissionIssues)) {
$result['warnings'][] = "插件 {$addon['name']} 有文件权限问题";
$result['details'][$addon['name']]['permission_issues'] = count($permissionIssues);
}
}
return $result;
}
/**
* 检查插件性能(全量)
*/
private function checkAddonPerformance(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$problematicaddon = AddonPerformanceMonitor::getProblematicaddon();
if (!empty($problematicaddon)) {
$result['healthy'] = false;
foreach ($problematicaddon as $addon => $info) {
$result['issues'][] = "插件 {$addon} 性能异常: " . implode(', ', $info['issues']);
$result['details'][$addon] = $info['issues'];
}
}
$overview = AddonPerformanceMonitor::getAllPerformanceOverview();
$totalCalls = $totalTime = $totalSuccess = 0;
foreach ($overview as $stats) {
$totalCalls += $stats['total_calls'];
$totalTime += $stats['avg_execution_time'] * $stats['total_calls'];
$totalSuccess += ($stats['success_rate'] / 100) * $stats['total_calls'];
}
if ($totalCalls > 0) {
$avgTime = $totalTime / $totalCalls;
$successRate = ($totalSuccess / $totalCalls) * 100;
$result['details']['total_calls'] = $totalCalls;
$result['details']['avg_execution_time'] = $avgTime;
$result['details']['overall_success_rate'] = $successRate;
if ($avgTime > 1.0) {
$result['warnings'][] = "总体平均执行时间过长: " . number_format($avgTime * 1000, 2) . " ms";
}
if ($successRate < 95) {
$result['healthy'] = false;
$result['issues'][] = "总体成功率过低: " . number_format($successRate, 2) . "%";
}
}
return $result;
}
/**
* 检查依赖关系(全量)
*/
private function checkDependencies(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
$installedaddon = [];
foreach ($addon as $addon) {
if (isset($addon['name'])) {
$installedaddon[$addon['name']] = $addon['state'] ?? false;
}
}
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonName = $addon['name'];
$result['details'][$addonName] = [];
$addonClass = '\\addon\\' . $addonName . '\\Addon';
if (!class_exists($addonClass)) {
continue;
}
try {
$instance = app()->make($addonClass);
if (method_exists($instance, 'getDependencies')) {
$dependencies = $instance->getDependencies();
$result['details'][$addonName]['dependencies'] = $dependencies;
foreach ($dependencies as $dep) {
if (!isset($installedaddon[$dep])) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 依赖插件 {$dep} 未安装";
} elseif (!$installedaddon[$dep]) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 依赖插件 {$dep} 未启用";
}
}
}
if (method_exists($instance, 'getExtensions')) {
$extensions = $instance->getExtensions();
$result['details'][$addonName]['extensions'] = $extensions;
foreach ($extensions as $ext) {
if (!extension_loaded($ext)) {
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 需要PHP扩展: {$ext}";
}
}
}
} catch (\Exception $e) {
$result['warnings'][] = "无法检查插件 {$addonName} 的依赖: " . $e->getMessage();
}
}
return $result;
}
/**
* 检查插件状态(全量)
*/
private function checkaddontatus(): array
{
$result = [
'healthy' => true,
'issues' => [],
'warnings' => [],
'details' => []
];
$addon = Config::get('addon', []);
$enabledCount = $disabledCount = $errorCount = 0;
foreach ($addon as $addon) {
if (!isset($addon['name'])) {
continue;
}
$addonName = $addon['name'];
$isEnabled = ($addon['state'] ?? false) == 1;
if ($isEnabled) {
$enabledCount++;
$addonClass = '\\addon\\' . $addonName . '\\Addon';
if (class_exists($addonClass)) {
try {
app()->make($addonClass);
$result['details'][$addonName] = 'healthy';
} catch (\Exception $e) {
$errorCount++;
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 加载失败: " . $e->getMessage();
$result['details'][$addonName] = 'error';
}
} else {
$errorCount++;
$result['healthy'] = false;
$result['issues'][] = "插件 {$addonName} 主类不存在";
$result['details'][$addonName] = 'missing';
}
} else {
$disabledCount++;
$result['details'][$addonName] = 'disabled';
}
}
$result['details']['enabled_count'] = $enabledCount;
$result['details']['disabled_count'] = $disabledCount;
$result['details']['error_count'] = $errorCount;
return $result;
}
/**
* 输出文本格式
*/
private function outputText(array $report, Output $output): void
{
$output->writeln('检查时间: ' . date('Y-m-d H:i:s', $report['timestamp']));
$output->writeln('整体状态: ' . ($report['healthy'] ? '<info>健康</info>' : '<error>异常</error>'));
$output->writeln('');
// 检查项
foreach ($report['checks'] as $checkName => $checkResult) {
$status = $checkResult['healthy'] ? '<info>✓</info>' : '<error>✗</error>';
$output->writeln("{$status} {$this->getCheckName($checkName)}");
foreach ($checkResult['issues'] ?? [] as $issue) {
$output->writeln(" <error>✗</error> {$issue}");
}
foreach ($checkResult['warnings'] ?? [] as $warning) {
$output->writeln(" <comment>⚠</comment> {$warning}");
}
}
// 插件状态
if (!empty($report['addon'])) {
$output->writeln('');
$output->writeln('<comment>插件状态:</comment>');
foreach ($report['addon'] as $addon => $health) {
$status = $health['healthy'] ? '<info>✓</info>' : '<error>✗</error>';
$output->writeln("{$status} {$addon}" .
($health['enabled'] ? ' <comment>(已启用)</comment>' : ' <comment>(已禁用)</comment>'));
foreach ($health['issues'] ?? [] as $issue) {
$output->writeln(" <error>✗</error> {$issue}");
}
foreach ($health['warnings'] ?? [] as $warning) {
$output->writeln(" <comment>⚠</comment> {$warning}");
}
}
}
// 汇总
$output->writeln('');
$output->writeln('<info>=== 检查汇总 ===</info>');
$output->writeln('总问题数: ' . count($report['issues']));
$output->writeln('总警告数: ' . count($report['warnings']));
$output->writeln('插件总数: ' . count($report['addon']));
$output->writeln('健康插件: ' . count(array_filter($report['addon'], fn($a) => $a['healthy'])));
}
/**
* 单插件详细信息(--detail 时补充)
*/
private function outputSingleDetail(string $addon, array $health, Output $output): void
{
if (empty($health)) {
return;
}
$output->writeln('');
$output->writeln('<info>详细信息:</info>');
$output->writeln("版本: " . ($health['version'] ?? '未知'));
$output->writeln("作者: " . ($health['author'] ?? '未知'));
$output->writeln("描述: " . ($health['description'] ?? '无'));
$output->writeln("启用状态: " . ($health['enabled'] ? '已启用' : '已禁用'));
$output->writeln("安装时间: " . (isset($health['install_time']) ? date('Y-m-d H:i:s', $health['install_time']) : '未知'));
if (isset($health['dependencies'])) {
$output->writeln("依赖插件: " . implode(', ', $health['dependencies']));
}
if (isset($health['extensions'])) {
$output->writeln("PHP扩展: " . implode(', ', $health['extensions']));
}
if ($health['enabled']) {
$stats = $health['performance'] ?? AddonPerformanceMonitor::getPerformanceStats($addon);
if (!empty($stats) && $stats['total_calls'] > 0) {
$output->writeln('');
$output->writeln('<info>性能状态:</info>');
$output->writeln(" 总调用次数: {$stats['total_calls']}");
$output->writeln(" 平均执行时间: " . number_format($stats['avg_execution_time'] * 1000, 2) . " ms");
$output->writeln(" 成功率: " . number_format($stats['success_rate'], 2) . "%");
if ($stats['avg_execution_time'] > AddonPerformanceMonitor::PERFORMANCE_THRESHOLD) {
$output->writeln(' <error>⚠ 执行时间过长</error>');
}
if ($stats['success_rate'] < 90) {
$output->writeln(' <error>⚠ 成功率过低</error>');
}
}
}
}
/**
* 输出 JSON 格式
*/
private function outputJson(array $report, Output $output): void
{
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* 输出 HTML 格式
*/
private function outputHtml(array $report, Output $output): void
{
$html = '<!DOCTYPE html><html><head><meta charset="utf-8"><title>插件健康检查报告</title>';
$html .= '<style>body{font-family:Arial,sans-serif;margin:20px;}';
$html .= '.healthy{color:#5fb878;}.error{color:#ff5722;}.warning{color:#ffb800;}';
$html .= '.section{margin:20px 0;padding:15px;background:#f9f9f9;border-radius:4px;}';
$html .= '.check{margin:10px 0;padding:10px;background:#fff;border-radius:4px;}';
$html .= '</style></head><body>';
$html .= '<h1>插件健康检查报告</h1>';
$html .= '<p>检查时间: ' . date('Y-m-d H:i:s', $report['timestamp']) . '</p>';
$html .= '<p>整体状态: <span class="' . ($report['healthy'] ? 'healthy' : 'error') . '">' . ($report['healthy'] ? '健康' : '异常') . '</span></p>';
foreach ($report['checks'] as $checkName => $checkResult) {
$html .= '<div class="section">';
$html .= '<h2>' . $this->getCheckName($checkName) . '</h2>';
foreach ($checkResult['issues'] ?? [] as $issue) {
$html .= '<div class="check error">✗ ' . htmlspecialchars($issue) . '</div>';
}
foreach ($checkResult['warnings'] ?? [] as $warning) {
$html .= '<div class="check warning">⚠ ' . htmlspecialchars($warning) . '</div>';
}
$html .= '</div>';
}
$html .= '</body></html>';
echo $html;
}
/**
* 获取检查项中文名
*/
private function getCheckName(string $checkKey): string
{
$names = [
'system' => '系统环境检查',
'cache' => '缓存系统检查',
'files' => '插件文件检查',
'performance' => '插件性能检查',
'dependencies' => '依赖关系检查',
'status' => '插件状态检查'
];
return $names[$checkKey] ?? $checkKey;
}
/**
* 尝试自动修复所有问题(清缓存 / 修权限)
*/
private function runAutoFix(array $report, Output $output): void
{
// 遍历插件问题(缓存测试键已由 checkCacheSystem 自身清理,无需此处重复)
foreach ($report['addon'] as $addon => $health) {
$this->tryAutoFix($addon, $health['issues'] ?? [], $output);
}
}
/**
* 尝试自动修复单个插件问题
*/
private function tryAutoFix(string $addon, array $issues, Output $output): void
{
$fixedCount = 0;
foreach ($issues as $issue) {
// 缓存类问题
if (strpos($issue, '缓存') !== false) {
try {
Cache::delete('addon_config_' . $addon);
Cache::delete('addon_config_version_' . $addon);
$output->writeln("<info> ✓ 已清除插件 {$addon} 缓存</info>");
$fixedCount++;
} catch (\Exception $e) {
$output->writeln("<comment> ⚠ 清除缓存失败: {$e->getMessage()}</comment>");
}
}
// 权限类问题(含 runtime 不可写)
if (strpos($issue, '权限') !== false || strpos($issue, 'runtime') !== false) {
try {
if (strpos($issue, 'runtime') !== false) {
$this->fixPermissions(root_path() . 'runtime');
}
$this->fixPermissions(ADDON_PATH . $addon);
$output->writeln("<info> ✓ 已修复文件权限 ({$addon})</info>");
$fixedCount++;
} catch (\Exception $e) {
$output->writeln("<comment> ⚠ 修复权限失败: {$e->getMessage()}</comment>");
}
}
}
if ($fixedCount > 0) {
$output->writeln("<info>已自动修复 {$fixedCount} 个问题({$addon}</info>");
} else {
$output->writeln("<comment>插件 {$addon} 无可通过 --fix 自动修复的问题</comment>");
}
}
/**
* 修复文件权限(递归 chmod)
*/
private function fixPermissions(string $path): void
{
if (!is_dir($path)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
chmod($item->getPathname(), $item->isDir() ? 0755 : 0644);
}
chmod($path, 0755);
}
/**
* 发送告警
*/
private function sendAlert(array $report, ?string $webhookUrl, ?string $email, Output $output): void
{
$message = "插件系统健康检查发现异常!\n\n";
$message .= "检查时间: " . date('Y-m-d H:i:s', $report['timestamp']) . "\n";
$message .= "总问题数: " . count($report['issues']) . "\n";
$message .= "总警告数: " . count($report['warnings']) . "\n\n";
if (!empty($report['issues'])) {
$message .= "问题列表:\n";
foreach ($report['issues'] as $issue) {
$message .= "- {$issue}\n";
}
$message .= "\n";
}
Log::error('插件健康检查异常', $report);
if ($webhookUrl) {
try {
$this->sendWebhook($webhookUrl, $report);
$output->writeln('<info>已发送Webhook告警</info>');
} catch (\Exception $e) {
$output->writeln('<error>Webhook发送失败: ' . $e->getMessage() . '</error>');
}
}
if ($email) {
try {
$this->sendEmail($email, '插件系统健康检查告警', $message);
$output->writeln('<info>已发送邮件告警</info>');
} catch (\Exception $e) {
$output->writeln('<error>邮件发送失败: ' . $e->getMessage() . '</error>');
}
}
$output->writeln('<error>告警消息: ' . str_replace("\n", " ", $message) . '</error>');
}
/**
* 发送 Webhook
*/
private function sendWebhook(string $url, array $data): void
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
throw new \Exception("Webhook返回错误: HTTP {$httpCode}");
}
}
/**
* 发送邮件
*/
private function sendEmail(string $to, string $subject, string $message): void
{
$headers = "From: noreply@yourdomain.com\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
if (!mail($to, $subject, $message, $headers)) {
throw new \Exception("邮件发送失败");
}
}
/**
* 保存检查报告(runtime/addon_health_reports,保留 30 天)
*/
private function saveReport(array $report, Output $output): void
{
$reportDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon_health_reports';
if (!is_dir($reportDir)) {
mkdir($reportDir, 0755, true);
}
$reportFile = $reportDir . DIRECTORY_SEPARATOR . 'health_report_' . date('YmdHis') . '.json';
file_put_contents($reportFile, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$output->writeln("<info>检查报告已保存: {$reportFile}</info>");
$this->cleanupOldReports($reportDir, 30);
}
/**
* 清理旧报告
*/
private function cleanupOldReports(string $dir, int $days): void
{
$files = glob($dir . DIRECTORY_SEPARATOR . 'health_report_*.json');
$cutoffTime = time() - ($days * 86400);
foreach ($files as $file) {
if (filemtime($file) < $cutoffTime) {
unlink($file);
}
}
}
/**
* 格式化字节
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
/**
* 检查插件是否已安装
*/
private function isAddonInstalled(string $addon): bool
{
return is_dir(ADDON_PATH . $addon) && is_file(ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php');
}
/**
* 检查插件是否已启用
*/
private function isAddonEnabled(string $addon): bool
{
$infoFile = ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php';
if (!is_file($infoFile)) {
return false;
}
$info = include $infoFile;
return isset($info['state']) && $info['state'] == 1;
}
}