diff --git a/app/Console/Commands/RecalculateTenantStorageCommand.php b/app/Console/Commands/RecalculateTenantStorageCommand.php new file mode 100644 index 00000000..16570043 --- /dev/null +++ b/app/Console/Commands/RecalculateTenantStorageCommand.php @@ -0,0 +1,163 @@ +option('tenant'); + $dryRun = $this->option('dry-run'); + + $this->info(''); + $this->info('==========================================='); + $this->info(' 테넌트 저장소 사용량 재계산'); + $this->info('==========================================='); + + if ($dryRun) { + $this->warn(' [DRY-RUN 모드] 실제 업데이트가 수행되지 않습니다.'); + } + + $this->info(''); + + // 테넌트별 실제 파일 사용량 계산 + $query = File::query() + ->select('tenant_id', DB::raw('SUM(file_size) as total_size'), DB::raw('COUNT(*) as file_count')) + ->whereNotNull('tenant_id') + ->groupBy('tenant_id'); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $actualUsage = $query->get()->keyBy('tenant_id'); + + // 모든 테넌트 조회 + $tenantsQuery = Tenant::query(); + if ($tenantId) { + $tenantsQuery->where('id', $tenantId); + } + $tenants = $tenantsQuery->get(); + + if ($tenants->isEmpty()) { + $this->warn('처리할 테넌트가 없습니다.'); + + return Command::SUCCESS; + } + + $results = []; + $updated = 0; + $errors = 0; + + foreach ($tenants as $tenant) { + $currentStored = $tenant->storage_used ?? 0; + $actualData = $actualUsage->get($tenant->id); + $actualSize = $actualData?->total_size ?? 0; + $fileCount = $actualData?->file_count ?? 0; + $difference = $actualSize - $currentStored; + + $status = '✅ 정상'; + if ($difference !== 0) { + $status = $difference > 0 ? '⚠️ 부족' : '⚠️ 초과'; + } + + $results[] = [ + $tenant->id, + $tenant->company_name ?? 'N/A', + $fileCount, + $this->formatBytes($currentStored), + $this->formatBytes($actualSize), + $this->formatDifference($difference), + $status, + ]; + + // 업데이트 필요 시 + if ($difference !== 0 && ! $dryRun) { + try { + $tenant->storage_used = $actualSize; + $tenant->save(); + $updated++; + } catch (\Exception $e) { + $this->error("테넌트 {$tenant->id} 업데이트 실패: {$e->getMessage()}"); + $errors++; + } + } + } + + // 결과 테이블 출력 + $this->table( + ['ID', '회사명', '파일수', '저장된 값', '실제 값', '차이', '상태'], + $results + ); + + $this->info(''); + $this->info('==========================================='); + $this->info(" 총 테넌트: {$tenants->count()}개"); + + if ($dryRun) { + $needsUpdate = collect($results)->filter(fn ($r) => $r[6] !== '✅ 정상')->count(); + $this->warn(" 업데이트 필요: {$needsUpdate}개"); + $this->info(' [DRY-RUN] 실제 업데이트를 수행하려면 --dry-run 옵션을 제거하세요.'); + } else { + $this->info(" 업데이트됨: {$updated}개"); + if ($errors > 0) { + $this->error(" 실패: {$errors}개"); + } + } + $this->info('==========================================='); + $this->info(''); + + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } + + /** + * 바이트를 읽기 쉬운 형식으로 변환 + */ + private function formatBytes(int $bytes): string + { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2).' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2).' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2).' KB'; + } + + return number_format($bytes).' B'; + } + + /** + * 차이값 포맷팅 (양수/음수 표시) + */ + private function formatDifference(int $diff): string + { + if ($diff === 0) { + return '0'; + } + + $prefix = $diff > 0 ? '+' : ''; + $absBytes = abs($diff); + + return $prefix.$this->formatBytes($absBytes); + } +} diff --git a/app/Observers/FileObserver.php b/app/Observers/FileObserver.php new file mode 100644 index 00000000..bf600200 --- /dev/null +++ b/app/Observers/FileObserver.php @@ -0,0 +1,76 @@ +updateTenantStorage($file->tenant_id, $file->file_size); + } + + /** + * 파일 소프트 삭제 시 - 테넌트 storage_used 감소 + */ + public function deleted(File $file): void + { + $this->updateTenantStorage($file->tenant_id, -$file->file_size); + } + + /** + * 파일 복원 시 - 테넌트 storage_used 증가 + */ + public function restored(File $file): void + { + $this->updateTenantStorage($file->tenant_id, $file->file_size); + } + + /** + * 파일 영구 삭제 시 - 이미 deleted에서 처리되므로 추가 작업 불필요 + * forceDelete는 deleted 이벤트를 트리거하지 않으므로 별도 처리 + */ + public function forceDeleting(File $file): void + { + // 소프트 삭제되지 않은 상태에서 바로 forceDelete하는 경우만 처리 + if (! $file->trashed()) { + $this->updateTenantStorage($file->tenant_id, -$file->file_size); + } + } + + /** + * 테넌트 저장소 사용량 업데이트 + */ + private function updateTenantStorage(?int $tenantId, int $sizeDelta): void + { + if (! $tenantId || $sizeDelta === 0) { + return; + } + + try { + $tenant = Tenant::find($tenantId); + if ($tenant) { + // 음수가 되지 않도록 보호 + $newSize = max(0, ($tenant->storage_used ?? 0) + $sizeDelta); + $tenant->storage_used = $newSize; + $tenant->save(); + + Log::debug("Tenant {$tenantId} storage updated: {$sizeDelta} bytes (total: {$newSize})"); + } + } catch (\Exception $e) { + Log::error("Failed to update tenant storage: {$e->getMessage()}", [ + 'tenant_id' => $tenantId, + 'size_delta' => $sizeDelta, + ]); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ff8e9220..a46a4728 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Models\Boards\File; use App\Models\Boards\Post; use App\Models\Tenants\Department; use App\Models\User; +use App\Observers\FileObserver; use App\Services\SidebarMenuService; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\View; @@ -26,6 +28,9 @@ public function register(): void */ public function boot(): void { + // File Observer: 파일 생성/삭제 시 테넌트 저장소 사용량 자동 업데이트 + File::observe(FileObserver::class); + // Morph Map: Polymorphic 관계 모델 등록 Relation::enforceMorphMap([ 'user' => User::class,