// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\backend\library; use think\facade\Db; use ywxapp\library\AdminLogger; use ywxapp\library\DbBackup; use ywxapp\model\CommonAttachment; /** * 系统信息采集与缓存清理 * * 从 System 控制器里抽出来,供「系统信息」页与「控制台」首页共用一份数据, * 避免同一套巡检逻辑在两个控制器里各写一遍(改一处、忘一处)。 * * 只读采集为主;唯一的写操作 cleanRuntime() 只删 runtime/cache 与 runtime/temp, * 绝不碰 runtime/log(审计日志要留着)与业务文件。 * * @author ywxapp */ class SystemInfo { /** * 采集环境 / 目录 / 磁盘 / 统计信息 * * 任何一项失败都不抛异常(巡检页不能因为一张表缺失就 500)。 * * @return array{env:array,dirs:array,disk:array,stat:array} */ public static function collect(): array { $root = app()->getRootPath(); /* ---------------- 运行环境 ---------------- */ $mysql = '-'; try { $ver = Db::query('SELECT VERSION() AS v'); $mysql = $ver[0]['v'] ?? '-'; } catch (\Throwable $e) { $mysql = '读取失败'; } $disable = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions')))); $env = [ ['YwxApp 版本', (string) config('ywxapp.version', '-')], ['ThinkPHP 版本', app()->version()], ['PHP 版本', PHP_VERSION . '(' . PHP_SAPI . ')'], ['MySQL 版本', $mysql], ['操作系统', PHP_OS . ' ' . php_uname('r')], ['服务器时间', date('Y-m-d H:i:s') . '(' . (string) config('app.default_timezone', date_default_timezone_get()) . ')'], ['上传限制', ini_get('upload_max_filesize') . ' / POST ' . ini_get('post_max_size')], ['内存限制', ini_get('memory_limit')], ['脚本超时', ini_get('max_execution_time') . ' 秒'], ['禁用函数', $disable ? implode(', ', array_slice($disable, 0, 6)) . (count($disable) > 6 ? ' …' : '') : '无'], ]; /* ---------------- 目录可写 ---------------- */ $dirs = [ 'runtime' => $root . 'runtime', 'public/storage' => public_path() . 'storage', 'config' => $root . 'config', 'addon' => $root . 'addon', ]; $dirList = []; foreach ($dirs as $label => $path) { $dirList[] = [ 'name' => $label, 'exists' => is_dir($path), 'writable' => is_dir($path) && is_writable($path), 'path' => $path, ]; } /* ---------------- 磁盘 ---------------- */ $diskTotal = @disk_total_space($root) ?: 0; $diskFree = @disk_free_space($root) ?: 0; $disk = [ 'total' => $diskTotal, 'free' => $diskFree, 'used' => $diskTotal - $diskFree, 'total_text' => self::formatBytes($diskTotal), 'free_text' => self::formatBytes($diskFree), 'used_text' => self::formatBytes($diskTotal - $diskFree), 'percent' => $diskTotal > 0 ? round(($diskTotal - $diskFree) / $diskTotal * 100, 1) : 0, ]; /* ---------------- 运行统计 ---------------- */ $dbSize = 0; try { foreach ((new DbBackup())->tables() as $t) { $dbSize += (int) ($t['size'] ?? 0); } } catch (\Throwable $e) { // 统计失败不阻断 } $attachSize = 0; try { $attachSize = (int) CommonAttachment::where('delete_at', 0)->sum('size'); } catch (\Throwable $e) { $attachSize = 0; } $stat = [ 'admin' => self::safeCount('backend_admin'), 'attachment' => self::safeCount('common_attachment', function ($q) { $q->where('delete_at', 0); }), 'attach_size' => $attachSize, 'attach_size_text' => self::formatBytes($attachSize), 'log_week' => self::safeCount('backend_log', function ($q) { $q->where('create_at', '>', time() - 7 * 86400); }), 'login_fail' => self::safeCount('backend_log', function ($q) { $q->where('action', '登录失败')->where('create_at', '>', time() - 7 * 86400); }), 'db_size' => $dbSize, 'db_size_text' => self::formatBytes($dbSize), 'addon' => count(self::addonDirs()), ]; return [ 'env' => $env, 'dirs' => $dirList, 'disk' => $disk, 'stat' => $stat, ]; } /** * 清理 runtime 缓存:cache / temp / all(不动 log) * * @param string $type * @return int 删除项数 * @throws \InvalidArgumentException 未知类型 */ public static function cleanRuntime(string $type): int { $targets = [ 'cache' => ['cache'], // 数据缓存 'temp' => ['temp'], // 模板编译缓存 'all' => ['cache', 'temp'], ]; if (! isset($targets[$type])) { throw new \InvalidArgumentException('未知清理类型:' . $type); } $root = runtime_path(); $files = 0; foreach ($targets[$type] as $dir) { $files += self::clearDir($root . $dir); } // 路由缓存文件在 runtime 根目录,清 all 时一并删掉(下次请求自动重建) if ($type === 'all') { $routeCache = $root . 'route_list.php'; if (is_file($routeCache)) { @unlink($routeCache); $files++; } } AdminLogger::write('console/clean', '清理类型:' . $type . ',删除 ' . $files . ' 项'); return $files; } /** * 安全计数:表缺失 / 查询异常时返回 0,不阻断巡检页 * * @param string $table * @param callable|null $where * @return int */ public static function safeCount(string $table, callable $where = null): int { try { $q = Db::name($table); if ($where) { $where($q); } return (int) $q->count(); } catch (\Throwable $e) { return 0; } } /** * 插件目录清单(一级目录即插件名) * @return array */ public static function addonDirs(): array { $base = app()->getRootPath() . 'addon'; if (! is_dir($base)) { return []; } $dirs = glob($base . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: []; return array_map('basename', $dirs); } /** * 递归删除目录下的内容(保留目录本身) * * @param string $dir * @return int 删除项数 */ protected static function clearDir(string $dir): int { if (! is_dir($dir)) { return 0; } $count = 0; $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } $count++; } return $count; } /** * 字节数格式化 * * 注意:disk_total_space() 返回 float,参数必须收 float, * 否则严格类型下直接 TypeError(页面 500)。 * * @param float $bytes * @return string */ public static function formatBytes(float $bytes): string { if ($bytes <= 0) { return '0 B'; } $units = ['B', 'KB', 'MB', 'GB', 'TB']; $i = (int) min(count($units) - 1, floor(log($bytes, 1024))); return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i]; } }