diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java b/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java index 37fda27..e1c6640 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java @@ -26,7 +26,6 @@ import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumRepository; import org.zipzip.zipzipserver.domain.photo.entity.Photo; -import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup; import org.zipzip.zipzipserver.domain.storage.ObjectStorageService; @@ -290,10 +289,7 @@ private List toThumbnails(UUID albumId) { sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId( albumId, PageRequest.of(0, THUMBNAIL_COUNT)); return oldestPhotos.stream() - .filter( - photo -> - photo.getThumbnailStatus() == PhotoThumbnailStatus.READY - && photo.getThumbnailObjectKey() != null) + .filter(Photo::hasUsableThumbnail) .map( photo -> { PresignedDownload thumbnail = diff --git a/src/main/java/org/zipzip/zipzipserver/domain/photo/entity/Photo.java b/src/main/java/org/zipzip/zipzipserver/domain/photo/entity/Photo.java index 4964791..53eb9e8 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/photo/entity/Photo.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/photo/entity/Photo.java @@ -99,10 +99,19 @@ public void applyLocation( } public void markThumbnailReady(String thumbnailObjectKey) { + if (thumbnailObjectKey == null || thumbnailObjectKey.isBlank()) { + throw new IllegalArgumentException("썸네일 객체 키는 비어 있을 수 없습니다."); + } this.thumbnailObjectKey = thumbnailObjectKey; this.thumbnailStatus = PhotoThumbnailStatus.READY; } + public boolean hasUsableThumbnail() { + return thumbnailStatus == PhotoThumbnailStatus.READY + && thumbnailObjectKey != null + && !thumbnailObjectKey.isBlank(); + } + public void markThumbnailFailed() { this.thumbnailStatus = PhotoThumbnailStatus.FAILED; } diff --git a/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoService.java b/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoService.java index 42cac50..5338e30 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/photo/service/PhotoService.java @@ -25,7 +25,6 @@ import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoListResponse; import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoMetadataUpdateResponse; import org.zipzip.zipzipserver.domain.photo.entity.Photo; -import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole; import org.zipzip.zipzipserver.domain.sharedgroup.repository.SharedGroupMembershipRepository; @@ -319,8 +318,7 @@ private PhotoListResponse.PhotoListItem toListItem( Photo photo, SharedAlbum sharedAlbum, UUID appUserId) { String thumbnailUrl = null; Instant thumbnailUrlExpiresAt = null; - if (photo.getThumbnailStatus() == PhotoThumbnailStatus.READY - && photo.getThumbnailObjectKey() != null) { + if (photo.hasUsableThumbnail()) { PresignedDownload thumbnail = objectStorageService.issueDownloadUrl( photo.getThumbnailObjectKey(), DOWNLOAD_URL_TTL); diff --git a/src/main/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionService.java b/src/main/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionService.java index cb8295a..e5e8e74 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionService.java @@ -14,7 +14,6 @@ import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; import org.zipzip.zipzipserver.domain.photo.code.PhotoErrorCode; import org.zipzip.zipzipserver.domain.photo.entity.Photo; -import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.photo.service.PhotoAccessGuard; import org.zipzip.zipzipserver.domain.reaction.code.ReactionErrorCode; @@ -197,8 +196,7 @@ private Photo requireActivePhoto(UUID photoId) { } private PresignedDownload issueThumbnailDownload(Photo photo) { - if (photo.getThumbnailStatus() != PhotoThumbnailStatus.READY - || photo.getThumbnailObjectKey() == null) { + if (!photo.hasUsableThumbnail()) { return null; } return objectStorageService.issueDownloadUrl( diff --git a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupInviteService.java b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupInviteService.java index 960756d..9edb6a2 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupInviteService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupInviteService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; import org.zipzip.zipzipserver.domain.photo.entity.Photo; -import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.sharedgroup.code.SharedGroupErrorCode; import org.zipzip.zipzipserver.domain.sharedgroup.dto.request.SharedGroupJoinRequest; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.InviteCodeResponse; @@ -173,8 +172,7 @@ private PresignedDownload findRepresentativeImage(UUID sharedGroupId) { private PresignedDownload issueRepresentativeImageDownload(Photo photo) { String objectKey = - photo.getThumbnailStatus() == PhotoThumbnailStatus.READY - && photo.getThumbnailObjectKey() != null + photo.hasUsableThumbnail() ? photo.getThumbnailObjectKey() : photo.getOriginalObjectKey(); return objectStorageService.issueDownloadUrl(objectKey, REPRESENTATIVE_IMAGE_URL_TTL); diff --git a/src/main/resources/db/migration/V20260716103000__enforce_thumbnail_ready_object_key_integrity.sql b/src/main/resources/db/migration/V20260716103000__enforce_thumbnail_ready_object_key_integrity.sql new file mode 100644 index 0000000..13f49a9 --- /dev/null +++ b/src/main/resources/db/migration/V20260716103000__enforce_thumbnail_ready_object_key_integrity.sql @@ -0,0 +1,19 @@ +-- 기존의 잘못된 READY 행은 썸네일 재시도 대상인 FAILED로 정리한 뒤, +-- READY 상태가 비어 있지 않은 객체 키를 반드시 갖도록 보장한다. +update photo +set thumbnail_status = 'FAILED' +where thumbnail_status = 'READY' + and ( + thumbnail_object_key is null + or thumbnail_object_key ~ '^[[:space:]]*$' + ); + +alter table photo + add constraint chk_photo__thumbnail_ready_object_key + check ( + thumbnail_status <> 'READY' + or ( + thumbnail_object_key is not null + and thumbnail_object_key !~ '^[[:space:]]*$' + ) + ); diff --git a/src/test/java/org/zipzip/zipzipserver/domain/DomainEntityMappingTests.java b/src/test/java/org/zipzip/zipzipserver/domain/DomainEntityMappingTests.java index 1998350..da35c6c 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/DomainEntityMappingTests.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/DomainEntityMappingTests.java @@ -1,6 +1,7 @@ package org.zipzip.zipzipserver.domain; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import jakarta.persistence.Table; import java.lang.reflect.Field; @@ -99,15 +100,32 @@ void photoThumbnailStatusTransitions() { assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.PENDING); assertThat(photo.getThumbnailObjectKey()).isNull(); + assertThat(photo.hasUsableThumbnail()).isFalse(); photo.markThumbnailReady("photos/385ff765/thumbnail.jpg"); assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.READY); assertThat(photo.getThumbnailObjectKey()).isEqualTo("photos/385ff765/thumbnail.jpg"); + assertThat(photo.hasUsableThumbnail()).isTrue(); photo.markThumbnailFailed(); assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.FAILED); + assertThat(photo.hasUsableThumbnail()).isFalse(); + } + + @Test + void photo는_비어있는_썸네일_객체_키로_READY_상태가_될_수_없다() { + AppUser user = AppUser.create("apple-subject", "사용자"); + Photo photo = Photo.create(user, null, "photos/385ff765/original.jpg", null, null, null); + + assertThatThrownBy(() -> photo.markThumbnailReady(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> photo.markThumbnailReady(" \t\n")) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.PENDING); + assertThat(photo.getThumbnailObjectKey()).isNull(); } @Test diff --git a/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java index 1eb54c0..aa865e5 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import org.zipzip.zipzipserver.domain.album.code.SharedAlbumErrorCode; import org.zipzip.zipzipserver.domain.album.dto.request.SharedAlbumIdsRequest; import org.zipzip.zipzipserver.domain.album.dto.request.SharedAlbumNameRequest; @@ -29,6 +30,7 @@ import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumRepository; import org.zipzip.zipzipserver.domain.photo.entity.Photo; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup; @@ -376,6 +378,32 @@ albumId, requesterId, new SharedAlbumNameRequest(" "))) .containsExactly("https://cdn/ready"); } + @Test + void 공백_키를_가진_기존_READY_썸네일은_목록에서_제외된다() { + AppUser creator = anAppUser(); + SharedGroup group = aSharedGroup(creator); + SharedAlbum album = anAlbum(creator, group); + Photo malformed = aPhoto(creator); + ReflectionTestUtils.setField(malformed, "thumbnailStatus", PhotoThumbnailStatus.READY); + ReflectionTestUtils.setField(malformed, "thumbnailObjectKey", " \t"); + + when(sharedAlbumAccessGuard.requireActiveSharedGroup(group.getId(), creator.getId())) + .thenReturn(group); + when(sharedAlbumRepository.findPageByActiveSharedGroupId( + eq(group.getId()), any(), any(), any())) + .thenReturn(List.of(album)); + when(sharedAlbumPhotoRepository.countBySharedAlbumIdAndPhotoDeletedAtIsNull(album.getId())) + .thenReturn(1L); + when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(eq(album.getId()), any())) + .thenReturn(List.of(malformed)); + + SharedAlbumListResponse response = + sharedAlbumService.listAlbums(group.getId(), creator.getId(), null, null); + + assertThat(response.items().getFirst().thumbnails()).isEmpty(); + verify(objectStorageService, never()).issueDownloadUrl(eq(" \t"), any()); + } + @Test void 사진이_없는_공유집은_빈_썸네일_목록을_반환한다() { AppUser creator = anAppUser(); diff --git a/src/test/java/org/zipzip/zipzipserver/domain/photo/repository/PhotoThumbnailIntegrityMigrationTest.java b/src/test/java/org/zipzip/zipzipserver/domain/photo/repository/PhotoThumbnailIntegrityMigrationTest.java new file mode 100644 index 0000000..b03f896 --- /dev/null +++ b/src/test/java/org/zipzip/zipzipserver/domain/photo/repository/PhotoThumbnailIntegrityMigrationTest.java @@ -0,0 +1,116 @@ +package org.zipzip.zipzipserver.domain.photo.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +class PhotoThumbnailIntegrityMigrationTest { + + private static final String PREVIOUS_MIGRATION_VERSION = "20260710140929"; + + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:18"); + + @Test + void 썸네일_READY_상태는_비어있지_않은_객체_키를_가져야_한다() throws SQLException { + migrateUpToPreviousVersion(); + UUID userId = insertUser(); + UUID malformedPhotoId = insertPhoto(userId, "READY", " \t\n"); + + migrateLatest(); + + assertThat(findThumbnailStatus(malformedPhotoId)).isEqualTo("FAILED"); + assertThatThrownBy(() -> insertPhoto(userId, "READY", null)) + .isInstanceOf(SQLException.class); + assertThatThrownBy(() -> insertPhoto(userId, "READY", " ")) + .isInstanceOf(SQLException.class); + assertThat(insertPhoto(userId, "PENDING", null)).isNotNull(); + assertThat(insertPhoto(userId, "READY", "photos/thumbnail.jpg")).isNotNull(); + } + + private void migrateUpToPreviousVersion() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(PREVIOUS_MIGRATION_VERSION) + .load() + .migrate(); + } + + private void migrateLatest() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + private UUID insertUser() throws SQLException { + UUID userId = UUID.randomUUID(); + try (Connection connection = connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + insert into app_user (id, apple_subject, display_name) + values (?, ?, '사용자') + """)) { + statement.setObject(1, userId); + statement.setString(2, "apple-subject-" + userId); + statement.executeUpdate(); + } + return userId; + } + + private UUID insertPhoto(UUID userId, String thumbnailStatus, String thumbnailObjectKey) + throws SQLException { + UUID photoId = UUID.randomUUID(); + try (Connection connection = connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + insert into photo ( + id, uploaded_by_app_user_id, original_object_key, thumbnail_object_key, + thumbnail_status, is_inferred + ) + values (?, ?, ?, ?, ?, false) + """)) { + statement.setObject(1, photoId); + statement.setObject(2, userId); + statement.setString(3, "photos/" + photoId + "/original.jpg"); + statement.setString(4, thumbnailObjectKey); + statement.setString(5, thumbnailStatus); + statement.executeUpdate(); + } + return photoId; + } + + private String findThumbnailStatus(UUID photoId) throws SQLException { + try (Connection connection = connection(); + PreparedStatement statement = + connection.prepareStatement( + "select thumbnail_status from photo where id = ?")) { + statement.setObject(1, photoId); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getString("thumbnail_status"); + } + } + } + + private Connection connection() throws SQLException { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + } +} diff --git a/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoServiceTest.java index 2565b8a..7cd75fc 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/photo/service/PhotoServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum; import org.zipzip.zipzipserver.domain.album.entity.SharedAlbumPhoto; import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; @@ -27,13 +28,16 @@ import org.zipzip.zipzipserver.domain.photo.dto.request.PhotoIdsRequest; import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoAttachResponse; import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoDetachResponse; +import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoListResponse; import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoMetadataUpdateResponse; import org.zipzip.zipzipserver.domain.photo.entity.Photo; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup; import org.zipzip.zipzipserver.domain.sharedgroup.repository.SharedGroupMembershipRepository; import org.zipzip.zipzipserver.domain.storage.ObjectStorageService; +import org.zipzip.zipzipserver.domain.storage.PresignedDownload; import org.zipzip.zipzipserver.domain.user.entity.AppUser; import org.zipzip.zipzipserver.global.exception.BusinessException; import org.zipzip.zipzipserver.global.idempotency.ApiIdempotencyRecord; @@ -85,6 +89,27 @@ void setUp() { .isEqualTo(PhotoErrorCode.NOT_PHOTO_UPLOADER)); } + @Test + void 공백_키를_가진_기존_READY_썸네일에는_URL을_발급하지_않는다() { + Photo photo = aPhoto(uploader); + ReflectionTestUtils.setField(photo, "thumbnailStatus", PhotoThumbnailStatus.READY); + ReflectionTestUtils.setField(photo, "thumbnailObjectKey", " \t"); + when(photoAccessGuard.requireActiveSharedAlbum(sharedAlbum.getId(), uploader.getId())) + .thenReturn(sharedAlbum); + when(sharedAlbumPhotoRepository.findPageByActiveSharedAlbumId( + eq(sharedAlbum.getId()), any(), any(), any())) + .thenReturn(List.of(photo)); + when(objectStorageService.issueDownloadUrl(eq(photo.getOriginalObjectKey()), any())) + .thenReturn( + new PresignedDownload("https://storage.example/original", Instant.now())); + + PhotoListResponse response = + photoService.listPhotos(sharedAlbum.getId(), uploader.getId(), null, null); + + assertThat(response.items().getFirst().thumbnailUrl()).isNull(); + verify(objectStorageService, never()).issueDownloadUrl(eq(" \t"), any()); + } + @Test void 메타데이터_수정_takenAt_형식이_잘못되면_예외() { Photo photo = aPhoto(uploader); diff --git a/src/test/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionServiceTest.java index e6c7bda..231701c 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/reaction/service/ReactionServiceTest.java @@ -27,6 +27,7 @@ import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository; import org.zipzip.zipzipserver.domain.photo.code.PhotoErrorCode; import org.zipzip.zipzipserver.domain.photo.entity.Photo; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository; import org.zipzip.zipzipserver.domain.photo.service.PhotoAccessGuard; import org.zipzip.zipzipserver.domain.reaction.code.ReactionErrorCode; @@ -106,6 +107,27 @@ void setUp() { assertThat(response.isLikedByMe()).isTrue(); } + @Test + void 공백_키를_가진_기존_READY_썸네일에는_URL을_발급하지_않는다() { + ReflectionTestUtils.setField(photo, "thumbnailStatus", PhotoThumbnailStatus.READY); + ReflectionTestUtils.setField(photo, "thumbnailObjectKey", " \t"); + givenAccessiblePhoto(); + when(sharedAlbumPhotoRepository.findByPhotoId(photo.getId())) + .thenReturn(List.of(SharedAlbumPhoto.create(sharedAlbum, photo))); + when(objectStorageService.issueDownloadUrl(eq("photos/original.jpg"), any())) + .thenReturn( + new PresignedDownload( + "https://storage.example/original", + Instant.parse("2026-07-10T01:10:00Z"))); + + PhotoDetailResponse response = + reactionService.getPhotoDetail(photo.getId(), requester.getId()); + + assertThat(response.thumbnailUrl()).isNull(); + verify(objectStorageService, org.mockito.Mockito.never()) + .issueDownloadUrl(eq(" \t"), any()); + } + @Test void 좋아요_설정은_insert_on_conflict_방식으로_멱등_처리한다() { givenAccessiblePhoto();