$files * @return array ['uploaded' => int, 'errors' => array] */ public function uploadPhotos(Equipment $equipment, array $files): array { $tenantId = session('selected_tenant_id'); $currentCount = $equipment->photos()->count(); $uploaded = 0; $errors = []; foreach ($files as $index => $file) { if ($currentCount + $uploaded >= self::MAX_PHOTOS) { $errors[] = '최대 '.self::MAX_PHOTOS.'장까지 업로드 가능합니다.'; break; } $extension = strtolower($file->getClientOriginalExtension()); $compressedPath = null; try { [$compressedPath, $finalExtension] = $this->compressImage($file->getRealPath(), $extension); $timestamp = now()->format('Ymd_His'); $objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$finalExtension}"; $result = $this->googleCloudService->uploadToStorage($compressedPath, $objectName); if (! $result) { $errors[] = "파일 '{$file->getClientOriginalName()}' 업로드 실패"; Log::error('EquipmentPhoto: GCS 업로드 실패', [ 'equipment_id' => $equipment->id, 'file' => $file->getClientOriginalName(), ]); continue; } $mimeType = $finalExtension === 'png' ? 'image/png' : 'image/jpeg'; File::create([ 'tenant_id' => $tenantId, 'document_id' => $equipment->id, 'document_type' => 'equipment', 'file_path' => $objectName, 'original_name' => $file->getClientOriginalName(), 'display_name' => $file->getClientOriginalName(), 'file_size' => $result['size'], 'mime_type' => $mimeType, 'file_type' => 'image', 'gcs_object_name' => $objectName, 'gcs_uri' => $result['uri'], 'uploaded_by' => Auth::id(), 'is_temp' => false, ]); $uploaded++; } catch (\Throwable $e) { $errors[] = "파일 '{$file->getClientOriginalName()}' 압축 실패: {$e->getMessage()}"; Log::error('EquipmentPhoto: 이미지 압축 실패', [ 'equipment_id' => $equipment->id, 'file' => $file->getClientOriginalName(), 'error' => $e->getMessage(), ]); } finally { if ($compressedPath && file_exists($compressedPath) && $compressedPath !== $file->getRealPath()) { @unlink($compressedPath); } } } return ['uploaded' => $uploaded, 'errors' => $errors]; } /** * 설비 사진 삭제 */ public function deletePhoto(Equipment $equipment, int $fileId): bool { $file = $equipment->photos()->where('id', $fileId)->first(); if (! $file) { return false; } if ($file->gcs_object_name) { $this->googleCloudService->deleteFromStorage($file->gcs_object_name); } $file->forceDelete(); return true; } /** * 설비 사진 목록 (Signed URL 포함) */ public function getPhotoUrls(Equipment $equipment): array { $photos = $equipment->photos()->get(); $bucket = config('services.google.storage_bucket'); return $photos->map(function ($photo) use ($bucket) { $url = null; if ($photo->gcs_object_name && $bucket) { $url = "https://storage.googleapis.com/{$bucket}/{$photo->gcs_object_name}"; } return [ 'id' => $photo->id, 'original_name' => $photo->original_name, 'file_size' => $photo->file_size, 'mime_type' => $photo->mime_type, 'url' => $url, 'created_at' => $photo->created_at?->format('Y-m-d H:i'), ]; })->toArray(); } /** * 설비의 모든 사진 삭제 (설비 삭제 시) */ public function deleteAllPhotos(Equipment $equipment): void { foreach ($equipment->photos as $photo) { if ($photo->gcs_object_name) { $this->googleCloudService->deleteFromStorage($photo->gcs_object_name); } $photo->forceDelete(); } } /** * 이미지 압축 (1MB 이하로) * * 1단계: 장축 2048px 초과 시 리사이즈 * 2단계: JPEG 품질 85→40 점진적 감소 * * @return array{0: string, 1: string} [압축 파일 경로, 최종 확장자] */ private function compressImage(string $sourcePath, string $extension): array { $image = $this->createImageFromFile($sourcePath, $extension); if (! $image) { return [$sourcePath, $extension]; } // 1단계: 리사이즈 (장축 2048px 초과 시) $width = imagesx($image); $height = imagesy($image); if ($width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) { $ratio = min(self::MAX_DIMENSION / $width, self::MAX_DIMENSION / $height); $newWidth = (int) round($width * $ratio); $newHeight = (int) round($height * $ratio); $resized = imagecreatetruecolor($newWidth, $newHeight); imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); imagedestroy($image); $image = $resized; } // PNG 투명도 확인 → 투명 있으면 PNG 유지, 없으면 JPEG 변환 $outputAsPng = ($extension === 'png') && $this->hasTransparency($image); $finalExtension = $outputAsPng ? 'png' : 'jpg'; $tempPath = tempnam(sys_get_temp_dir(), 'eq_compress_'); if ($outputAsPng) { // PNG: 최대 압축 레벨(9)로 저장 imagepng($image, $tempPath, 9); imagedestroy($image); return [$tempPath, $finalExtension]; } // 2단계: JPEG 품질 점진적 감소 $quality = self::INITIAL_QUALITY; while ($quality >= self::MIN_QUALITY) { imagejpeg($image, $tempPath, $quality); if (filesize($tempPath) <= self::MAX_FILE_SIZE) { break; } $quality -= self::QUALITY_STEP; } imagedestroy($image); return [$tempPath, $finalExtension]; } /** * 확장자에 따라 GD 이미지 리소스 생성 */ private function createImageFromFile(string $path, string $extension): ?\GdImage { return match ($extension) { 'jpg', 'jpeg' => @imagecreatefromjpeg($path), 'png' => @imagecreatefrompng($path), 'gif' => @imagecreatefromgif($path), 'bmp' => @imagecreatefrombmp($path), default => null, }; } /** * 이미지에 투명 픽셀이 있는지 확인 */ private function hasTransparency(\GdImage $image): bool { $width = imagesx($image); $height = imagesy($image); // 전체 픽셀 검사는 비효율적이므로 샘플링 (최대 1000개) $step = max(1, (int) sqrt($width * $height / 1000)); for ($x = 0; $x < $width; $x += $step) { for ($y = 0; $y < $height; $y += $step) { $rgba = imagecolorat($image, $x, $y); $alpha = ($rgba >> 24) & 0x7F; if ($alpha > 0) { return true; } } } return false; } }