feat(tenant): 테넌트 저장소 사용량 자동 추적 기능 추가

- FileObserver: 파일 생성/삭제 시 tenant.storage_used 자동 업데이트
- RecalculateTenantStorageCommand: 기존 데이터 재계산 명령어
  - php artisan tenant:recalculate-storage [--tenant=ID] [--dry-run]
- 음수 storage_used 방지 로직 포함

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 02:20:08 +09:00
parent 504eb697e4
commit d77dc37068
3 changed files with 244 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Console\Commands;
use App\Models\Boards\File;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 테넌트 저장소 사용량 재계산 명령어
*
* files 테이블의 실제 file_size 합계로 tenants.storage_used를 업데이트합니다.
*
* @example php artisan tenant:recalculate-storage
* @example php artisan tenant:recalculate-storage --tenant=1
* @example php artisan tenant:recalculate-storage --dry-run
*/
class RecalculateTenantStorageCommand extends Command
{
protected $signature = 'tenant:recalculate-storage
{--tenant= : 특정 테넌트 ID만 처리}
{--dry-run : 실제 업데이트 없이 결과만 확인}';
protected $description = '테넌트별 저장소 사용량을 files 테이블 기준으로 재계산합니다';
public function handle(): int
{
$tenantId = $this->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);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Observers;
use App\Models\Boards\File;
use App\Models\Tenants\Tenant;
use Illuminate\Support\Facades\Log;
/**
* 파일 생성/삭제 시 테넌트 저장소 사용량 자동 업데이트
*/
class FileObserver
{
/**
* 파일 생성 시 - 테넌트 storage_used 증가
*/
public function created(File $file): void
{
$this->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,
]);
}
}
}

View File

@@ -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,