- getPhotoUrls(): 공개 URL → GoogleCloudStorageService.getSignedUrl() 사용 - basic-info 탭: 동일하게 Signed URL로 변경 - URL 유효기간 120분
341 lines
11 KiB
PHP
341 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Boards\File;
|
|
use App\Models\Equipment\Equipment;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class EquipmentPhotoService
|
|
{
|
|
private const MAX_PHOTOS = 10;
|
|
|
|
private const MAX_FILE_SIZE = 1048576; // 1MB
|
|
|
|
private const MAX_DIMENSION = 2048;
|
|
|
|
private const INITIAL_QUALITY = 85;
|
|
|
|
private const MIN_QUALITY = 40;
|
|
|
|
private const QUALITY_STEP = 5;
|
|
|
|
public function __construct(
|
|
private readonly GoogleCloudService $googleCloudService,
|
|
private readonly GoogleCloudStorageService $googleCloudStorageService,
|
|
) {}
|
|
|
|
/**
|
|
* 설비 사진 업로드 (멀티)
|
|
*
|
|
* @param array<UploadedFile> $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';
|
|
|
|
$storedName = basename($objectName);
|
|
|
|
File::create([
|
|
'tenant_id' => $tenantId,
|
|
'document_id' => $equipment->id,
|
|
'document_type' => 'equipment',
|
|
'file_path' => $objectName,
|
|
'stored_name' => $storedName,
|
|
'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];
|
|
}
|
|
|
|
/**
|
|
* 로컬 파일 경로에서 설비 사진 업로드 (엑셀 Import용)
|
|
*/
|
|
public function uploadPhotoFromPath(
|
|
Equipment $equipment,
|
|
string $localPath,
|
|
string $originalName,
|
|
string $extension
|
|
): bool {
|
|
$tenantId = session('selected_tenant_id');
|
|
$currentCount = $equipment->photos()->count();
|
|
|
|
if ($currentCount >= self::MAX_PHOTOS) {
|
|
return false;
|
|
}
|
|
|
|
$compressedPath = null;
|
|
|
|
try {
|
|
[$compressedPath, $finalExtension] = $this->compressImage($localPath, $extension);
|
|
|
|
$timestamp = now()->format('Ymd_His');
|
|
$uniqueId = substr(md5(uniqid()), 0, 8);
|
|
$objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$uniqueId}.{$finalExtension}";
|
|
|
|
$result = $this->googleCloudService->uploadToStorage($compressedPath, $objectName);
|
|
|
|
if (! $result) {
|
|
Log::error('EquipmentPhoto: GCS 업로드 실패 (Import)', [
|
|
'equipment_id' => $equipment->id,
|
|
'file' => $originalName,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$mimeType = $finalExtension === 'png' ? 'image/png' : 'image/jpeg';
|
|
|
|
$storedName = basename($objectName);
|
|
|
|
File::create([
|
|
'tenant_id' => $tenantId,
|
|
'document_id' => $equipment->id,
|
|
'document_type' => 'equipment',
|
|
'file_path' => $objectName,
|
|
'stored_name' => $storedName,
|
|
'original_name' => $originalName,
|
|
'display_name' => $originalName,
|
|
'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,
|
|
]);
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
Log::error('EquipmentPhoto: Import 사진 처리 실패', [
|
|
'equipment_id' => $equipment->id,
|
|
'file' => $originalName,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
} finally {
|
|
if ($compressedPath && file_exists($compressedPath) && $compressedPath !== $localPath) {
|
|
@unlink($compressedPath);
|
|
}
|
|
if (file_exists($localPath)) {
|
|
@unlink($localPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설비 사진 삭제
|
|
*/
|
|
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();
|
|
|
|
return $photos->map(function ($photo) {
|
|
$url = null;
|
|
if ($photo->gcs_object_name) {
|
|
$url = $this->googleCloudStorageService->getSignedUrl($photo->gcs_object_name, 120);
|
|
}
|
|
|
|
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} [압축 파일 경로, 최종 확장자]
|
|
*/
|
|
public 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 이미지 리소스 생성
|
|
*/
|
|
public 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이미지에 투명 픽셀이 있는지 확인
|
|
*/
|
|
public 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;
|
|
}
|
|
}
|