Files
Gnuboard7/app/Services/DashboardService.php
2026-07-01 10:30:32 +09:00

601 lines
20 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Contracts\Repositories\NotificationLogRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Extension\HookManager;
use App\Helpers\TimezoneHelper;
use App\Models\ActivityLog;
use App\Models\NotificationLog;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 대시보드 서비스
*
* 관리자 대시보드에 표시되는 통계, 리소스, 활동 등의 데이터를 제공합니다.
*/
class DashboardService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private ActivityLogRepositoryInterface $activityLogRepository,
private ModuleManagerInterface $moduleManager,
private PluginManagerInterface $pluginManager,
private NotificationLogRepositoryInterface $notificationLogRepository,
private TemplateManagerInterface $templateManager,
private LanguagePackRepositoryInterface $languagePackRepository,
private LanguagePackService $languagePackService
) {}
/**
* 대시보드 통계를 조회합니다.
*
* @return array 통계 데이터 배열
*/
public function getStats(): array
{
// 훅: 대시보드 통계 조회 전 (IDV 정책 가드 지점)
HookManager::doAction('core.dashboard.before_stats');
$stats = [
'total_users' => $this->getUserStats(),
'installed_modules' => $this->getModuleStats(),
'active_plugins' => $this->getPluginStats(),
'installed_templates' => $this->getTemplateStats(),
'language_packs' => $this->getLanguagePackStats(),
'system_status' => $this->getSystemStatus(),
];
// 훅을 통한 통계 확장 지원
return HookManager::applyFilters('core.dashboard.stats', $stats);
}
/**
* 사용자 통계를 조회합니다.
*
* @return array 사용자 통계 배열
*/
private function getUserStats(): array
{
$statistics = $this->userRepository->getStatistics();
$totalUsers = $statistics['total_users'] ?? 0;
$usersThisMonth = $statistics['users_this_month'] ?? 0;
// 전월 사용자 수 추정 (전체 - 이번달 가입자)
$previousMonthUsers = max(0, $totalUsers - $usersThisMonth);
// 변화율 계산
$changePercent = 0;
if ($previousMonthUsers > 0) {
$changePercent = round(($usersThisMonth / $previousMonthUsers) * 100, 1);
}
$trend = $usersThisMonth > 0 ? 'up' : ($usersThisMonth < 0 ? 'down' : 'up');
$changeDisplay = "+{$usersThisMonth}";
return [
'count' => $totalUsers,
'change_percent' => abs($changePercent),
'change_display' => $changeDisplay,
'trend' => $trend,
];
}
/**
* 모듈 통계를 조회합니다.
*
* @return array 모듈 통계 배열
*/
private function getModuleStats(): array
{
$allModules = $this->moduleManager->getAllModules();
$activeModules = $this->moduleManager->getActiveModules();
return [
'total' => count($allModules),
'active' => count($activeModules),
];
}
/**
* 플러그인 통계를 조회합니다.
*
* @return array 플러그인 통계 배열
*/
private function getPluginStats(): array
{
$allPlugins = $this->pluginManager->getAllPlugins();
$activePlugins = $this->pluginManager->getActivePlugins();
return [
'total' => count($allPlugins),
'active' => count($activePlugins),
];
}
/**
* 템플릿 통계를 조회합니다.
*
* 활성 템플릿은 타입(admin/user)별로 1개씩 지정되므로, 활성 수는
* 활성 템플릿이 지정된 타입 수와 같습니다.
*
* @return array{total: int, active: int} 템플릿 통계 배열
*/
private function getTemplateStats(): array
{
$allTemplates = $this->templateManager->getAllTemplates();
$activeCount = 0;
foreach (['admin', 'user'] as $type) {
if ($this->templateManager->getActiveTemplate($type) !== null) {
$activeCount++;
}
}
return [
'total' => count($allTemplates),
'active' => $activeCount,
];
}
/**
* 언어팩 통계를 조회합니다.
*
* 활성 = 현재 활성화된 언어팩 레코드 수,
* 전체 = 활성 + 미설치 번들 팩(설치 가능 후보) 수.
*
* @return array{total: int, active: int} 언어팩 통계 배열
*/
private function getLanguagePackStats(): array
{
$activeCount = $this->languagePackRepository->getActivePacks()->count();
$uninstalledBundledCount = $this->languagePackService->getUninstalledBundledPacks()->count();
return [
'total' => $activeCount + $uninstalledBundledCount,
'active' => $activeCount,
];
}
/**
* 시스템 상태를 조회합니다.
*
* @return array 시스템 상태 배열
*/
private function getSystemStatus(): array
{
// 기본 시스템 상태 확인
$allServicesRunning = $this->checkAllServicesRunning();
$status = $allServicesRunning ? 'normal' : 'warning';
$label = $allServicesRunning
? __('dashboard.stats.status_normal')
: __('dashboard.stats.status_warning');
return [
'status' => $status,
'label' => $label,
'all_services_running' => $allServicesRunning,
];
}
/**
* 모든 서비스가 정상 동작 중인지 확인합니다.
*
* @return bool 모든 서비스 정상 여부
*/
private function checkAllServicesRunning(): bool
{
// TODO: 실제 서비스 상태 체크 로직 구현
// 현재는 기본적으로 true 반환
return true;
}
/**
* 시스템 리소스 사용량을 조회합니다.
*
* @return array 리소스 사용량 배열
*/
public function getSystemResources(): array
{
// IDV 정책 가드 지점 (민감 관리자 조회 훅 커버리지)
HookManager::doAction('core.dashboard.before_resources');
// 각 probe 를 개별 격리한다 (공개#40).
// 일부 호스팅(open_basedir 제한, disable_functions, /proc 미접근)에서 한 probe 의
// 실패(warning → ErrorException)가 리소스 조회 전체를 500 으로 떨어뜨리지 않도록,
// probe 별 가드(레이어 a) 위에 safeProbe 격리(레이어 b)를 추가한다.
$resources = [
'cpu' => $this->safeProbe(fn () => $this->getCpuUsage(), $this->fallbackCpu()),
'memory' => $this->safeProbe(fn () => $this->getMemoryUsage(), $this->fallbackMemory()),
'disk' => $this->safeProbe(fn () => $this->getDiskUsage(), $this->fallbackDisk()),
];
// 훅을 통한 리소스 정보 확장 지원
return HookManager::applyFilters('core.dashboard.resources', $resources);
}
/**
* 시스템 리소스 probe 를 안전하게 실행합니다.
*
* probe 가 예외(ErrorException·Error 포함)를 던지면 전파하지 않고 폴백값을 반환합니다.
* 시스템 리소스 수집 실패가 결코 대시보드 리소스 조회 전체를 500 으로 만들지 않게 합니다.
*
* @param callable $cb 실행할 probe 콜백
* @param array $fallback 실패 시 반환할 폴백 구조
* @return array probe 결과 또는 폴백
*/
protected function safeProbe(callable $cb, array $fallback): array
{
try {
return $cb();
} catch (\Throwable $e) {
Log::warning('대시보드 시스템 리소스 수집 실패', ['error' => $e->getMessage()]);
return $fallback;
}
}
/**
* CPU 수집 불가 시 폴백 구조를 반환합니다.
*
* @return array CPU 폴백 (percentage 0, color gray)
*/
protected function fallbackCpu(): array
{
return ['percentage' => 0, 'color' => 'gray'];
}
/**
* 메모리 수집 불가 시 폴백 구조를 반환합니다.
*
* @return array 메모리 폴백 (percentage 0, used/total "알 수 없음", color gray)
*/
protected function fallbackMemory(): array
{
return [
'percentage' => 0,
'used' => __('common.unknown'),
'total' => __('common.unknown'),
'color' => 'gray',
];
}
/**
* 디스크 수집 불가 시 폴백 구조를 반환합니다.
*
* @return array 디스크 폴백 (percentage 0, used/total "알 수 없음", color gray)
*/
protected function fallbackDisk(): array
{
return [
'percentage' => 0,
'used' => __('common.unknown'),
'total' => __('common.unknown'),
'color' => 'gray',
];
}
/**
* CPU 사용률을 조회합니다.
*
* @return array CPU 사용률 정보
*/
protected function getCpuUsage(): array
{
$percentage = 0;
if (PHP_OS_FAMILY === 'Windows') {
$percentage = $this->getWindowsCpuUsage();
} else {
// Linux/Unix: /proc/stat에서 CPU 사용률 계산
if (function_exists('sys_getloadavg')) {
$load = sys_getloadavg();
// 1분 평균 로드를 CPU 코어 수로 나눠서 백분율로 변환.
// disable_functions 에 shell_exec 가 있으면 호출 자체가 warning → 가드 (공개#40).
$cores = function_exists('shell_exec') ? ((int) @shell_exec('nproc 2>/dev/null') ?: 1) : 1;
$percentage = min(100, round(($load[0] / $cores) * 100));
}
}
return [
'percentage' => $percentage,
'color' => $this->getResourceColor($percentage),
];
}
/**
* Windows에서 CPU 사용률을 조회합니다.
*
* PowerShell Get-CimInstance를 우선 사용하고, 실패 시 wmic을 fallback으로 사용합니다.
*
* @return int CPU 사용률 (0-100)
*/
private function getWindowsCpuUsage(): int
{
// PowerShell Get-CimInstance 시도 (Windows 10/11 권장)
$output = shell_exec('powershell -NoProfile -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage" 2>&1');
if ($output !== null) {
$value = (int) trim($output);
if ($value >= 0 && $value <= 100) {
return $value;
}
}
// wmic fallback (이전 Windows 버전)
$output = shell_exec('wmic cpu get loadpercentage 2>&1');
if ($output) {
$lines = explode("\n", trim($output));
if (isset($lines[1])) {
return (int) trim($lines[1]);
}
}
return 0;
}
/**
* 메모리 사용량을 조회합니다.
*
* @return array 메모리 사용량 정보
*/
protected function getMemoryUsage(): array
{
$used = 0;
$total = 0;
$percentage = 0;
if (PHP_OS_FAMILY === 'Windows') {
[$total, $used] = $this->getWindowsMemoryUsage();
} else {
// Linux: /proc/meminfo에서 메모리 정보 조회.
// open_basedir 제한 환경에서 file_get_contents 가 warning → ErrorException 이 되지
// 않도록 is_readable 가드 + @ 억제 + false 체크 (공개#40, SettingsService::getMemoryUsage 동형).
if (PHP_OS_FAMILY === 'Linux' && is_readable('/proc/meminfo')) {
$memInfo = @file_get_contents('/proc/meminfo');
if ($memInfo !== false) {
if (preg_match('/MemTotal:\s+(\d+)/', $memInfo, $matches)) {
$total = $matches[1] * 1024;
}
if (preg_match('/MemAvailable:\s+(\d+)/', $memInfo, $matches)) {
$available = $matches[1] * 1024;
$used = $total - $available;
}
}
}
}
if ($total > 0) {
$percentage = round(($used / $total) * 100);
}
return [
'percentage' => $percentage,
'used' => $this->formatBytes($used),
'total' => $this->formatBytes($total),
'color' => $this->getResourceColor($percentage),
];
}
/**
* Windows에서 메모리 사용량을 조회합니다.
*
* PowerShell Get-CimInstance를 우선 사용하고, 실패 시 wmic을 fallback으로 사용합니다.
*
* @return array [total, used] 바이트 단위
*/
private function getWindowsMemoryUsage(): array
{
$total = 0;
$used = 0;
// PowerShell Get-CimInstance 시도 (Windows 10/11 권장)
$totalOutput = shell_exec('powershell -NoProfile -Command "(Get-CimInstance -ClassName Win32_ComputerSystem).TotalPhysicalMemory" 2>&1');
$freeOutput = shell_exec('powershell -NoProfile -Command "(Get-CimInstance -ClassName Win32_OperatingSystem).FreePhysicalMemory" 2>&1');
if ($totalOutput !== null && $freeOutput !== null) {
$totalValue = (int) trim($totalOutput);
$freeKb = (int) trim($freeOutput);
if ($totalValue > 0) {
$total = $totalValue;
$free = $freeKb * 1024;
$used = $total - $free;
return [$total, $used];
}
}
// wmic fallback (이전 Windows 버전)
$totalOutput = shell_exec('wmic computersystem get totalphysicalmemory 2>&1');
$freeOutput = shell_exec('wmic os get freephysicalmemory 2>&1');
if ($totalOutput && $freeOutput) {
$lines = explode("\n", trim($totalOutput));
if (isset($lines[1])) {
$total = (int) trim($lines[1]);
}
$lines = explode("\n", trim($freeOutput));
if (isset($lines[1])) {
$freeKb = (int) trim($lines[1]);
$free = $freeKb * 1024;
$used = $total - $free;
}
}
return [$total, $used];
}
/**
* 디스크 사용량을 조회합니다.
*
* @return array 디스크 사용량 정보
*/
protected function getDiskUsage(): array
{
$path = PHP_OS_FAMILY === 'Windows' ? 'C:' : '/';
// open_basedir 로 '/' 가 제한된 환경에서 disk_*_space 가 warning → ErrorException 이
// 되지 않도록 @ 억제 + numeric 체크 (공개#40). ?: 0 폴백은 예외 승격 후엔 무력하므로 @ 필수.
$totalRaw = @disk_total_space($path);
$freeRaw = @disk_free_space($path);
$total = is_numeric($totalRaw) ? (float) $totalRaw : 0;
$free = is_numeric($freeRaw) ? (float) $freeRaw : 0;
$used = $total - $free;
$percentage = $total > 0 ? round(($used / $total) * 100) : 0;
return [
'percentage' => $percentage,
'used' => $this->formatBytes($used),
'total' => $this->formatBytes($total),
'color' => $this->getResourceColor($percentage),
];
}
/**
* 리소스 사용률에 따른 색상을 반환합니다.
*
* @param int $percentage 사용률 (0-100)
* @return string 색상명
*/
private function getResourceColor(int $percentage): string
{
if ($percentage >= 90) {
return 'red';
} elseif ($percentage >= 70) {
return 'yellow';
} elseif ($percentage >= 50) {
return 'blue';
}
return 'green';
}
/**
* 바이트 단위를 읽기 쉬운 형태로 변환합니다.
*
* @param int|float $bytes 변환할 바이트 수
* @return string 형식화된 문자열
*/
private function formatBytes(int|float $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 1).' '.$units[$i];
}
/**
* 최근 활동 내역을 조회합니다.
*
* ActivityLog 테이블에서 최근 활동을 조회하여
* 대시보드 표시 형식으로 변환합니다.
*
* @param int $limit 조회할 활동 수
* @return array 활동 내역 배열
*/
public function getRecentActivities(int $limit = 5): array
{
$logs = $this->activityLogRepository->getRecent('core.dashboard.activities', $limit);
$activities = $logs->map(function (ActivityLog $log) {
return [
'type' => $log->log_type->value,
'icon' => $this->getActivityIcon($log->action),
'icon_color' => $log->log_type->variant(),
'title' => $log->localized_description,
'description' => $log->actor_name,
'time' => TimezoneHelper::toUserCarbon($log->created_at)?->diffForHumans(),
'timestamp' => TimezoneHelper::toUserTimezone($log->created_at),
];
})->toArray();
// 훅을 통한 활동 내역 확장 지원
$activities = HookManager::applyFilters('core.dashboard.activities', $activities, $limit);
return array_slice($activities, 0, $limit);
}
/**
* 활동 액션에 따른 아이콘을 반환합니다.
*
* @param string|null $action 액션 문자열
* @return string 아이콘명
*/
private function getActivityIcon(?string $action): string
{
if ($action === null) {
return 'circle-info';
}
$lastSegment = last(explode('.', $action));
return match ($lastSegment) {
'create' => 'plus',
'update' => 'pen-to-square',
'delete' => 'trash',
'login' => 'right-to-bracket',
'logout' => 'right-from-bracket',
'activate' => 'toggle-on',
'deactivate' => 'toggle-off',
'install' => 'download',
'uninstall' => 'trash-can',
default => 'circle-info',
};
}
/**
* 시스템 알림을 조회합니다.
*
* @return array 시스템 알림 배열
*/
public function getSystemAlerts(): array
{
return HookManager::applyFilters('core.dashboard.alerts', []);
}
/**
* 최근 발송된 알림 이력을 조회합니다.
*
* notification_logs 테이블에서 발송 시각 최신순으로 조회하여
* 대시보드 "최근 알림" 카드 표시 형식으로 변환합니다.
*
* @param int $limit 조회할 알림 수
* @return array 최근 알림 배열
*/
public function getRecentNotificationLogs(int $limit = 5): array
{
$logs = $this->notificationLogRepository->getRecent($limit);
return $logs->map(function (NotificationLog $log) {
return [
'id' => $log->id,
'type' => $log->notification_type,
'channel' => $log->channel,
'recipient' => $log->recipientUser?->name ?? $log->recipient_name ?? $log->recipient_identifier,
'subject' => Str::limit((string) $log->subject, 50),
'status' => $log->status->value,
'time' => TimezoneHelper::toUserCarbon($log->sent_at ?? $log->created_at)?->diffForHumans(),
'timestamp' => TimezoneHelper::toUserTimezone($log->sent_at ?? $log->created_at),
];
})->toArray();
}
}