chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,972 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user