From af7990374a3bfeb2875069e493e8cf6fc4bf26d4 Mon Sep 17 00:00:00 2001 From: jucheonsu Date: Fri, 17 Apr 2026 12:44:25 +0900 Subject: [PATCH] =?UTF-8?q?[Refactor]=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=20URL=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BattleOptionRepository.java | 9 + .../battle/repository/BattleRepository.java | 9 + .../battle/service/BattleServiceImpl.java | 40 ++- .../service/PerspectiveCommentService.java | 12 +- .../service/PerspectiveService.java | 12 +- .../domain/user/service/MypageService.java | 15 +- .../service/LocalDraftFileStorageService.java | 126 +++++++++- .../s3/controller/FileUploadController.java | 22 +- .../infra/s3/service/S3UploadServiceImpl.java | 236 ++++++++++++++---- .../user/service/MypageServiceTest.java | 9 +- 10 files changed, 407 insertions(+), 83 deletions(-) diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java index 2260ed8e..0fe65122 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java @@ -23,4 +23,13 @@ public interface BattleOptionRepository extends JpaRepository findByBattleIn(@Param("battles") List battles); + + @Query("SELECT COUNT(bo) FROM BattleOption bo " + + "WHERE bo.battle.deletedAt IS NULL " + + "AND bo.imageUrl = :imageUrl " + + "AND (:excludeOptionId IS NULL OR bo.id <> :excludeOptionId)") + long countActiveImageReferences( + @Param("imageUrl") String imageUrl, + @Param("excludeOptionId") Long excludeOptionId + ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index 6bd79776..7f4663a6 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -64,6 +64,15 @@ public interface BattleRepository extends JpaRepository { // 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회 List findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status); + @Query("SELECT COUNT(b) FROM Battle b " + + "WHERE b.deletedAt IS NULL " + + "AND b.thumbnailUrl = :thumbnailUrl " + + "AND (:excludeBattleId IS NULL OR b.id <> :excludeBattleId)") + long countActiveThumbnailReferences( + @Param("thumbnailUrl") String thumbnailUrl, + @Param("excludeBattleId") Long excludeBattleId + ); + // 탐색 탭: 전체 배틀 검색 @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchAll(Pageable pageable); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index f985e871..85279e1c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -344,6 +344,8 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Collectors.mapping(BattleOptionTag::getTag, Collectors.toList()) )); + cleanupUnreferencedDraftAssets(request.thumbnailUrl(), request.options()); + return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), savedOptions, optionTagsMap); } @@ -373,7 +375,7 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe String existingThumbnailKey = normalizeStoredImageReference(battle.getThumbnailUrl(), FileCategory.BATTLE); String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); if (existingThumbnailKey != null && !existingThumbnailKey.equals(resolvedThumbnailKey)) { - deleteStoredAsset(existingThumbnailKey); + deleteStoredAsset(existingThumbnailKey, battle.getId(), null); } battle.update( @@ -421,7 +423,7 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe } else { String existingOptionImageKey = normalizeStoredImageReference(option.getImageUrl(), FileCategory.PHILOSOPHER); if (existingOptionImageKey != null && !existingOptionImageKey.equals(resolvedOptionImageKey)) { - deleteStoredAsset(existingOptionImageKey); + deleteStoredAsset(existingOptionImageKey, null, option.getId()); } option.update(optionRequest.title(), optionRequest.stance(), optionRequest.representative(), resolvedOptionImageKey); @@ -435,7 +437,7 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe .toList(); for (BattleOption removedOption : removedOptions) { - deleteStoredAsset(removedOption.getImageUrl()); + deleteStoredAsset(removedOption.getImageUrl(), null, removedOption.getId()); List optionTags = battleOptionTagRepository.findByBattleOption(removedOption); if (!optionTags.isEmpty()) { battleOptionTagRepository.deleteAll(optionTags); @@ -447,6 +449,8 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe } } + cleanupUnreferencedDraftAssets(request.thumbnailUrl(), request.options()); + List updatedOptions = battleOptionRepository.findByBattle(battle); Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) .stream() @@ -585,12 +589,16 @@ private String extractPath(String value) { return value; } - private void deleteStoredAsset(String rawReference) { + private void deleteStoredAsset(String rawReference, Long excludeBattleId, Long excludeOptionId) { String normalized = normalizeStoredImageReference(rawReference, null); if (normalized == null) { return; } + if (hasOtherActiveReferences(normalized, excludeBattleId, excludeOptionId)) { + return; + } + if (localDraftFileStorageService.isLocalDraftReference(normalized)) { localDraftFileStorageService.deleteIfLocalReference(normalized); return; @@ -599,6 +607,30 @@ private void deleteStoredAsset(String rawReference) { s3UploadService.deleteFile(normalized); } + private boolean hasOtherActiveReferences(String normalizedReference, Long excludeBattleId, Long excludeOptionId) { + long thumbnailReferences = battleRepository.countActiveThumbnailReferences(normalizedReference, excludeBattleId); + long optionImageReferences = battleOptionRepository.countActiveImageReferences(normalizedReference, excludeOptionId); + return (thumbnailReferences + optionImageReferences) > 0; + } + + private void cleanupUnreferencedDraftAssets(String thumbnailUrl, List options) { + Set candidates = new LinkedHashSet<>(); + if (thumbnailUrl != null && !thumbnailUrl.isBlank()) { + candidates.add(thumbnailUrl); + } + if (options != null) { + options.stream() + .map(AdminBattleOptionRequest::imageUrl) + .filter(Objects::nonNull) + .filter(imageUrl -> !imageUrl.isBlank()) + .forEach(candidates::add); + } + + for (String rawReference : candidates) { + deleteStoredAsset(rawReference, null, null); + } + } + private BattleStatus parseBattleStatus(String status) { if (status == null || status.isBlank() || "ALL".equalsIgnoreCase(status)) { return null; diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java index c7808893..03eaf902 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java @@ -20,7 +20,8 @@ import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; -import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -43,7 +44,7 @@ public class PerspectiveCommentService { private final UserService userQueryService; private final BattleVoteService BattleVoteService; private final BattleService battleService; - private final S3PresignedUrlService s3PresignedUrlService; + private final ResourceUrlProvider resourceUrlProvider; @Transactional public CreateCommentResponse createComment(Long perspectiveId, Long userId, CreateCommentRequest request) { @@ -207,6 +208,9 @@ private String resolveCharacterImageUrl(String characterType) { if (characterType == null || characterType.isBlank()) { return null; } - return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); + return resourceUrlProvider.getImageUrl( + FileCategory.CHARACTER, + CharacterType.resolveImageKey(characterType) + ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index ed8d596c..55747210 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -24,7 +24,8 @@ import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; -import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -48,7 +49,7 @@ public class PerspectiveService { private final UserService userQueryService; private final UserRepository userRepository; private final GptModerationService gptModerationService; - private final S3PresignedUrlService s3PresignedUrlService; + private final ResourceUrlProvider resourceUrlProvider; public PerspectiveDetailResponse getPerspectiveDetail(Long perspectiveId, Long userId) { Perspective perspective = findPerspectiveById(perspectiveId); @@ -215,6 +216,9 @@ private String resolveCharacterImageUrl(String characterType) { if (characterType == null || characterType.isBlank()) { return null; } - return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); + return resourceUrlProvider.getImageUrl( + FileCategory.CHARACTER, + CharacterType.resolveImageKey(characterType) + ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java index 585b1ea6..108e62e0 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java @@ -26,7 +26,8 @@ import com.swyp.picke.domain.vote.service.VoteQueryService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; -import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,7 +49,7 @@ public class MypageService { private final VoteQueryService voteQueryService; private final BattleQueryService battleQueryService; private final PerspectiveQueryService perspectiveQueryService; - private final S3PresignedUrlService s3PresignedUrlService; + private final ResourceUrlProvider resourceUrlProvider; @Transactional public MypageResponse getMypage() { @@ -74,7 +75,7 @@ public MypageResponse getMypage() { philosopherType.getLabel(), philosopherType.getTypeName(), philosopherType.getDescription(), - s3PresignedUrlService.generatePresignedUrl( + resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(philosopherType.getLabel()) )) : null; @@ -355,7 +356,7 @@ private RecapResponse.PhilosopherCard toPhilosopherCard(PhilosopherType type) { type.getTypeName(), type.getDescription(), type.getKeywordTags(), - s3PresignedUrlService.generatePresignedUrl( + resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(type.getLabel()) ) ); @@ -379,7 +380,9 @@ private NotificationSettingsResponse toNotificationSettingsResponse(UserSettings private String resolveCharacterImageUrl(CharacterType characterType) { String imageKey = CharacterType.resolveImageKey(characterType); - return imageKey != null ? s3PresignedUrlService.generatePresignedUrl(imageKey) : null; + return imageKey != null + ? resourceUrlProvider.getImageUrl(FileCategory.CHARACTER, imageKey) + : null; } private String resolveCharacterImageUrl(String characterType) { @@ -387,7 +390,7 @@ private String resolveCharacterImageUrl(String characterType) { return null; } String imageKey = CharacterType.resolveImageKey(characterType); - return s3PresignedUrlService.generatePresignedUrl(imageKey); + return resourceUrlProvider.getImageUrl(FileCategory.CHARACTER, imageKey); } } diff --git a/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java b/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java index c7b037b1..12bf3b2a 100644 --- a/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java +++ b/src/main/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageService.java @@ -11,13 +11,16 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Optional; -import java.util.UUID; +import java.util.stream.Stream; @Service public class LocalDraftFileStorageService { @@ -38,10 +41,20 @@ public String saveDraftFile(MultipartFile multipartFile) throws IOException { String originalName = Optional.ofNullable(multipartFile.getOriginalFilename()).orElse("draft.bin"); String sanitizedName = sanitizeFileName(originalName); - String fileName = UUID.randomUUID() + "_" + sanitizedName; - String localKey = LOCAL_DRAFT_PREFIX + fileName; + if (sanitizedName.isBlank()) { + sanitizedName = "draft.bin"; + } + + byte[] fileBytes = multipartFile.getBytes(); + String incomingHash = calculateSha256(fileBytes); + String localKey = resolveAvailableLocalKey(sanitizedName, incomingHash); Path targetPath = resolvePath(localKey); + if (Files.exists(targetPath)) { + // Same content already exists with this key. Reuse without writing again. + return localKey; + } + Files.createDirectories(targetPath.getParent()); Files.copy(multipartFile.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); @@ -104,7 +117,6 @@ public String promoteLocalDraftToS3(String rawReference, FileCategory category, String fileName = extractFileName(normalized); String s3Key = category.getPath() + "/" + fileName; s3UploadService.uploadFile(s3Key, localPath.toFile()); - deleteIfLocalReference(normalized); return s3Key; } @@ -180,10 +192,112 @@ private String extractFileName(String localKey) { } private String sanitizeFileName(String fileName) { - return fileName + String sanitized = fileName + .trim() .replace("\\", "_") .replace("/", "_") .replace("..", "_") - .replaceAll("[^a-zA-Z0-9._-]", "_"); + .replaceAll("\\s+", "_") + .replaceAll("[^\\p{L}\\p{N}._-]", "_"); + return sanitized.isBlank() ? "draft.bin" : sanitized; + } + + private String resolveAvailableLocalKey(String sanitizedName, String incomingHash) throws IOException { + String existingByHash = findExistingLocalKeyByHash(incomingHash); + if (existingByHash != null) { + return existingByHash; + } + + String[] split = splitNameAndExtension(sanitizedName); + String baseName = split[0]; + String extension = split[1]; + + int sequence = 0; + while (true) { + String candidateName = sequence == 0 + ? sanitizedName + : String.format("%s-%d%s", baseName, sequence + 1, extension); + String candidateKey = LOCAL_DRAFT_PREFIX + candidateName; + Path candidatePath = resolvePath(candidateKey); + + if (!Files.exists(candidatePath)) { + return candidateKey; + } + + String existingHash = calculateSha256(candidatePath); + if (incomingHash.equals(existingHash)) { + return candidateKey; + } + sequence++; + } + } + + private String findExistingLocalKeyByHash(String incomingHash) throws IOException { + Path draftsDir = resolvePath(LOCAL_DRAFT_PREFIX); + if (!Files.exists(draftsDir) || !Files.isDirectory(draftsDir)) { + return null; + } + + try (Stream pathStream = Files.walk(draftsDir)) { + Optional matchedPath = pathStream + .filter(Files::isRegularFile) + .filter(path -> { + try { + return incomingHash.equals(calculateSha256(path)); + } catch (IOException ignored) { + return false; + } + }) + .findFirst(); + + if (matchedPath.isEmpty()) { + return null; + } + + String relative = draftsDir.relativize(matchedPath.get()).toString().replace("\\", "/"); + return LOCAL_DRAFT_PREFIX + relative; + } + } + + private String[] splitNameAndExtension(String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex == fileName.length() - 1) { + return new String[]{fileName, ""}; + } + return new String[]{fileName.substring(0, dotIndex), fileName.substring(dotIndex)}; + } + + private String calculateSha256(byte[] content) { + MessageDigest digest = newSha256Digest(); + digest.update(content); + return toHex(digest.digest()); + } + + private String calculateSha256(Path filePath) throws IOException { + MessageDigest digest = newSha256Digest(); + try (InputStream inputStream = Files.newInputStream(filePath)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + return toHex(digest.digest()); + } + + private MessageDigest newSha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available", e); + } + } + + private String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); } } diff --git a/src/main/java/com/swyp/picke/global/infra/s3/controller/FileUploadController.java b/src/main/java/com/swyp/picke/global/infra/s3/controller/FileUploadController.java index f96b656b..510335de 100644 --- a/src/main/java/com/swyp/picke/global/infra/s3/controller/FileUploadController.java +++ b/src/main/java/com/swyp/picke/global/infra/s3/controller/FileUploadController.java @@ -46,7 +46,8 @@ public ApiResponse uploadFile( File tempFile = convertMultiPartToFile(multipartFile); try { - String fileName = category.getPath() + "/" + UUID.randomUUID() + "_" + multipartFile.getOriginalFilename(); + String safeName = sanitizeFileName(multipartFile.getOriginalFilename()); + String fileName = category.getPath() + "/" + safeName; String s3Key = s3UploadService.uploadFile(fileName, tempFile); String presignedUrl = s3PresignedUrlService.generatePresignedUrl(s3Key); return ApiResponse.onSuccess(new FileUploadResponse(s3Key, presignedUrl)); @@ -69,9 +70,7 @@ public ApiResponse uploadLocalDraftFile( } private File convertMultiPartToFile(MultipartFile file) throws IOException { - String safeName = (file.getOriginalFilename() == null || file.getOriginalFilename().isBlank()) - ? "upload.bin" - : file.getOriginalFilename().replaceAll("[\\\\/:*?\"<>|]", "_"); + String safeName = sanitizeFileName(file.getOriginalFilename()); File convFile = new File(System.getProperty("java.io.tmpdir") + "/" + UUID.randomUUID() + "_" + safeName); try (FileOutputStream fos = new FileOutputStream(convFile)) { @@ -79,4 +78,19 @@ private File convertMultiPartToFile(MultipartFile file) throws IOException { } return convFile; } + + private String sanitizeFileName(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + return "upload.bin"; + } + + String sanitized = originalFilename + .trim() + .replace("\\", "_") + .replace("/", "_") + .replace("..", "_") + .replaceAll("\\s+", "_") + .replaceAll("[^\\p{L}\\p{N}._-]", "_"); + return sanitized.isBlank() ? "upload.bin" : sanitized; + } } diff --git a/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java index e22c4e0b..d162b06c 100644 --- a/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java @@ -10,14 +10,24 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.S3Object; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Map; @Slf4j @Primary @@ -26,15 +36,11 @@ public class S3UploadServiceImpl implements S3UploadService { private final S3Client s3Client; - private final S3Presigner s3Presigner; // 보안 URL 생성을 위한 프레시그너 추가 + private final S3Presigner s3Presigner; @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; - /** - * S3 파일 업로드 - * @return 저장된 파일의 'Key(경로)' 또는 필요 시 전체 URL - */ @Override public String uploadFile(String key, File file) { if (file == null || !file.exists()) { @@ -42,34 +48,47 @@ public String uploadFile(String key, File file) { } try { - log.info("[AWS S3] 업로드 시작 - 버킷: {}, 키: {}", bucketName, key); + String normalizedKey = extractKey(key); + log.info("[AWS S3] Upload start - bucket: {}, key: {}", bucketName, normalizedKey); + + // Reuse immediately when key already exists (same file name/path). + if (objectExists(normalizedKey)) { + log.info("[AWS S3] Reusing existing object by key: {}", normalizedKey); + return normalizedKey; + } - // Content-Type 자동 감지 (오디오 등 확장자 대응) String contentType = Files.probeContentType(file.toPath()); if (contentType == null) { - contentType = determineContentType(key); + contentType = determineContentType(normalizedKey); + } + + // Reuse existing key when same content hash already exists in this prefix. + String sha256 = calculateSha256(file.toPath()); + String md5 = calculateMd5(file.toPath()); + String prefix = extractPrefix(normalizedKey); + String existingSameContentKey = findExistingKeyByContentDigest(prefix, sha256, md5); + if (existingSameContentKey != null) { + log.info("[AWS S3] Reusing existing object by content hash: {}", existingSameContentKey); + return existingSameContentKey; } - // S3 업로드 요청 생성 PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucketName) - .key(key) + .key(normalizedKey) .contentType(contentType) + .metadata(Map.of("sha256", sha256)) .build(); s3Client.putObject(putObjectRequest, RequestBody.fromFile(file)); - log.info("[AWS S3] 업로드 완료! 키: {}, Content-Type: {}", key, contentType); - return key; + log.info("[AWS S3] Upload complete - key: {}, Content-Type: {}", normalizedKey, contentType); + return normalizedKey; } catch (Exception e) { - log.error("[AWS S3] 업로드 실패 - 키: {}", key, e); + log.error("[AWS S3] Upload failed - key: {}", key, e); throw new RuntimeException(ErrorCode.FILE_UPLOAD_FAILED.getMessage()); } } - /** - * S3에서 파일을 다운로드하여 로컬 임시 파일로 반환 - */ @Override public File downloadFile(String fileUrl) { if (fileUrl == null || fileUrl.isBlank()) { @@ -77,40 +96,30 @@ public File downloadFile(String fileUrl) { } try { - // URL에서 순수 Key만 추출 - String pureKey = fileUrl.contains(".com/") ? fileUrl.split(".com/")[1] : fileUrl; + String pureKey = extractKey(fileUrl); - // 다운로드 받을 로컬 임시 파일 생성 File tempFile = File.createTempFile("s3_download_", ".mp3"); Path tempFilePath = tempFile.toPath(); - // S3 다운로드 요청 GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucketName) .key(pureKey) .build(); - // S3에서 파일을 읽어서 로컬 임시 파일에 쓰기 s3Client.getObject(getObjectRequest, tempFilePath); - - return tempFile; // FFmpeg 병합 완료 후 cleanUpFiles에서 알아서 지워짐 + return tempFile; } catch (Exception e) { - log.error("[AWS S3] 파일 다운로드 실패 - URL: {}", fileUrl, e); + log.error("[AWS S3] Download failed - URL: {}", fileUrl, e); throw new RuntimeException("S3 오디오 조각 다운로드 실패", e); } } - /** - * 관리자 전용: 특정 시점에만 유효한 임시 보안 URL 생성 (Presigned URL) - * @param key S3에 저장된 파일의 경로 (예: images/battles/uuid.png) - * @param durationUrl 유효 시간 (예: Duration.ofMinutes(10)) - */ + @Override public String getPresignedUrl(String key, Duration durationUrl) { if (key == null || key.isEmpty()) return null; - // URL에서 도메인을 제외한 순수 'Key'만 추출 (만약 전체 URL이 들어올 경우를 대비) - String pureKey = key.contains(".com/") ? key.split(".com/")[1] : key; + String pureKey = extractKey(key); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(durationUrl) @@ -127,18 +136,6 @@ private String determineContentType(String key) { return "application/octet-stream"; } - private void deleteLocalFile(File file) { - if (file != null && file.exists()) { - if (!file.delete()) { - log.warn("[로컬 파일 삭제 실패]: {}", file.getAbsolutePath()); - } - } - } - - /** - * S3 파일 삭제 - * @param fileUrl 삭제할 파일의 전체 URL 또는 Key - */ @Override public void deleteFile(String fileUrl) { if (fileUrl == null || fileUrl.isBlank()) { @@ -146,22 +143,159 @@ public void deleteFile(String fileUrl) { } try { - // 1. 전체 URL에서 순수 Key 추출 (기존 getPresignedUrl에 있던 방식과 동일하게 처리) - String pureKey = fileUrl.contains(".com/") ? fileUrl.split(".com/")[1] : fileUrl; + String pureKey = extractKey(fileUrl); - // 2. AWS SDK v2 전용 삭제 요청 객체 생성 DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() .bucket(bucketName) .key(pureKey) .build(); - // 3. S3에서 파일 삭제 실행 s3Client.deleteObject(deleteObjectRequest); - log.info("[AWS S3] 파일 삭제 성공: {}", pureKey); + log.info("[AWS S3] Delete success: {}", pureKey); } catch (Exception e) { - // 파일 삭제 실패가 전체 서비스(수정 로직 등)의 예외(Rollback)로 번지지 않도록 로그만 남깁니다. - log.error("[AWS S3] 파일 삭제 실패 - URL: {}", fileUrl, e); + log.error("[AWS S3] Delete failed - URL: {}", fileUrl, e); + } + } + + private boolean objectExists(String key) { + try { + HeadObjectResponse response = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build() + ); + return response != null; + } catch (S3Exception e) { + if (isNotFound(e)) { + return false; + } + throw e; + } + } + + private String findExistingKeyByContentDigest(String prefix, String sha256, String md5) { + String continuationToken = null; + + do { + ListObjectsV2Request.Builder requestBuilder = ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(prefix) + .maxKeys(1000); + + if (continuationToken != null) { + requestBuilder.continuationToken(continuationToken); + } + + ListObjectsV2Response response = s3Client.listObjectsV2(requestBuilder.build()); + if (response == null || response.contents() == null || response.contents().isEmpty()) { + return null; + } + + for (S3Object object : response.contents()) { + if (object == null || object.key() == null || object.key().endsWith("/")) { + continue; + } + + // Fast path: ETag usually equals MD5 for single-part uploads. + String normalizedETag = normalizeEtag(object.eTag()); + if (normalizedETag != null && normalizedETag.equalsIgnoreCase(md5)) { + return object.key(); + } + + String existingHash = fetchSha256Metadata(object.key()); + if (sha256.equals(existingHash)) { + return object.key(); + } + } + + continuationToken = Boolean.TRUE.equals(response.isTruncated()) + ? response.nextContinuationToken() + : null; + + } while (continuationToken != null && !continuationToken.isBlank()); + + return null; + } + + private String fetchSha256Metadata(String key) { + try { + HeadObjectResponse response = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build() + ); + if (response == null || response.metadata() == null || response.metadata().isEmpty()) { + return null; + } + return response.metadata().get("sha256"); + } catch (S3Exception e) { + if (isNotFound(e)) { + return null; + } + throw e; + } + } + + private String calculateSha256(Path filePath) throws IOException { + return calculateDigest(filePath, "SHA-256"); + } + + private String calculateMd5(Path filePath) throws IOException { + return calculateDigest(filePath, "MD5"); + } + + private String calculateDigest(Path filePath, String algorithm) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance(algorithm); + byte[] bytes = Files.readAllBytes(filePath); + byte[] hashed = digest.digest(bytes); + StringBuilder sb = new StringBuilder(hashed.length * 2); + for (byte b : hashed) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(algorithm + " algorithm is not available", e); } } -} \ No newline at end of file + + private String extractPrefix(String key) { + if (key == null || key.isBlank()) { + return ""; + } + int idx = key.lastIndexOf('/'); + if (idx < 0) { + return ""; + } + return key.substring(0, idx + 1); + } + + private String extractKey(String keyOrUrl) { + if (keyOrUrl == null) { + return null; + } + return keyOrUrl.contains(".com/") ? keyOrUrl.split(".com/")[1] : keyOrUrl; + } + + private boolean isNotFound(S3Exception e) { + if (e == null) { + return false; + } + if (e.statusCode() == 404) { + return true; + } + return e.awsErrorDetails() != null + && "NotFound".equalsIgnoreCase(e.awsErrorDetails().errorCode()); + } + + private String normalizeEtag(String etag) { + if (etag == null || etag.isBlank()) { + return null; + } + return etag.replace("\"", "").trim(); + } +} + diff --git a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java index e73f73a2..2e5558f2 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -31,7 +31,7 @@ import com.swyp.picke.domain.user.enums.VoteSide; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.service.VoteQueryService; -import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,6 +50,7 @@ import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -69,7 +70,7 @@ class MypageServiceTest { @Mock private PerspectiveQueryService perspectiveQueryService; @Mock - private S3PresignedUrlService s3PresignedUrlService; + private ResourceUrlProvider resourceUrlProvider; @InjectMocks private MypageService mypageService; @@ -90,7 +91,7 @@ void getMypage_returns_profile_philosopher_tier() { when(userService.findCurrentUser()).thenReturn(user); when(userService.findUserProfile(1L)).thenReturn(profile); when(creditService.getTotalPoints(1L)).thenReturn(0); - when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); + when(resourceUrlProvider.getImageUrl(any(), anyString())).thenReturn("http://localhost:8080/api/v1/resources/images/CHARACTER/owl.png"); MypageResponse response = mypageService.getMypage(); @@ -114,7 +115,7 @@ void getRecap_returns_cards_scores_report() { when(userService.findCurrentUser()).thenReturn(user); when(userService.findUserProfile(1L)).thenReturn(profile); - when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); + when(resourceUrlProvider.getImageUrl(any(), anyString())).thenReturn("http://localhost:8080/api/v1/resources/images/PHILOSOPHER/kant.png"); when(voteQueryService.countTotalParticipation(1L)).thenReturn(15L); when(voteQueryService.countOpinionChanges(1L)).thenReturn(3L); when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(70);