Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/apidoc/06-shared-album.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
}
```

`thumbnails`는 그 공유집(앨범) **가장 먼저 저장된 사진 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. 썸네일이 아직 준비되지 않은(`thumbnailStatus`가 `READY`가 아닌) 사진은 건너뛰므로 0~3개 사이일 수 있고, 채워서 3개를 맞추지 않는다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고).
`thumbnails`는 그 공유집(앨범)에서 **썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. `thumbnailStatus`가 `READY`가 아니거나 썸네일 오브젝트 키가 없거나 공백인 사진, soft-delete된 사진은 건너뛰고 뒤의 사용 가능한 사진으로 최대 3장을 채운다. 준비된 사진 자체가 3장보다 적으면 0~2개일 수 있다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고).

### Fail Response

Expand Down
2 changes: 1 addition & 1 deletion docs/apidoc/shared-album-photo-ios-integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ iOS 구현 규칙: **하나의 논리적 요청(사용자의 한 번의 액션)
1. `sharedGroupId`가 존재하고 요청자가 그 그룹의 활성 멤버인지 확인(`SharedAlbumAccessGuard`) — 아니면 `404 SHARED_GROUP_NOT_FOUND`.
2. `createdAt` 내림차순 + 동점 시 앨범 ID 내림차순으로 커서 페이지네이션.
3. 앨범마다 `photoCount`를 그 시점에 실시간 count(비정규화 컬럼 없음 — 저장된 카운터가 아니라 매번 `shared_album_photo` 조인 집계).
4. 앨범마다 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준으로 가장 먼저 추가된 순 최대 3장을 뽑아 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. 썸네일이 아직 `READY`가 아닌 사진은 건너뛰므로(뒤 순번으로 채워 넣지 않음) 0~3개 사이일 수 있다.
4. 앨범마다 썸네일이 `READY`이고 유효한 썸네일 오브젝트 키가 있는 활성 사진을 대상으로 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준 가장 먼저 추가된 순 최대 3장을 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. `READY`가 아니거나 키가 없거나 공백인 사진, soft-delete된 사진은 건너뛰고 뒤의 사용 가능한 사진으로 최대 3장을 채우며, 준비된 사진 자체가 부족하면 0~2개일 수 있다.

**응답 필드**: `items[].{id, name, photoCount, thumbnails[].{url, urlExpiresAt}, createdBy{userId, displayName}, isCreator, createdAt, updatedAt}`, `nextCursor`, `hasNext`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public record Item(
@Schema(description = "활성 매핑·활성 사진 기준 실시간 사진 수", example = "42") long photoCount,
@Schema(
description =
"가장 먼저 저장된 사진 순으로 최대 3장의 썸네일. 썸네일이 아직 준비되지 않은 사진은"
+ " 건너뛰므로 개수가 3장보다 적을 수 있습니다.")
"READY 상태이고 유효한 썸네일 오브젝트 키가 있는 사진 중 가장 먼저 저장된 순으로 최대 3장. 사용할 수"
+ " 없는 사진은 건너뛰고 뒤의 준비된 사진으로 채우며, 후보가 부족하면 3장보다 적을 수 있습니다.")
List<Thumbnail> thumbnails,
@Schema(description = "생성자 요약") SharedAlbumResponse.Creator createdBy,
@Schema(description = "요청자 본인이 생성자인지 여부") boolean isCreator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.data.repository.query.Param;
import org.zipzip.zipzipserver.domain.album.entity.SharedAlbumPhoto;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;

public interface SharedAlbumPhotoRepository extends JpaRepository<SharedAlbumPhoto, UUID> {

Expand Down Expand Up @@ -91,8 +92,13 @@ List<Photo> findPageByActiveSharedAlbumId(
join sap.photo photo
where sap.sharedAlbum.id = :sharedAlbumId
and photo.deletedAt is null
and photo.thumbnailStatus = :thumbnailStatus
and photo.thumbnailObjectKey is not null
and trim(photo.thumbnailObjectKey) <> ''
order by sap.createdAt asc, sap.id asc
""")
List<Photo> findOldestPhotosBySharedAlbumId(
@Param("sharedAlbumId") UUID sharedAlbumId, Pageable pageable);
List<Photo> findOldestPhotosBySharedAlbumIdAndThumbnailStatus(
@Param("sharedAlbumId") UUID sharedAlbumId,
@Param("thumbnailStatus") PhotoThumbnailStatus thumbnailStatus,
Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -287,13 +287,9 @@ private SharedAlbumListResponse.Item toListItem(SharedAlbum album, UUID appUserI

private List<SharedAlbumListResponse.Thumbnail> toThumbnails(UUID albumId) {
List<Photo> oldestPhotos =
sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(
albumId, PageRequest.of(0, THUMBNAIL_COUNT));
sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumIdAndThumbnailStatus(
albumId, PhotoThumbnailStatus.READY, PageRequest.of(0, THUMBNAIL_COUNT));
return oldestPhotos.stream()
.filter(
photo ->
photo.getThumbnailStatus() == PhotoThumbnailStatus.READY
&& photo.getThumbnailObjectKey() != null)
.map(
photo -> {
PresignedDownload thumbnail =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand Down Expand Up @@ -144,6 +145,177 @@ void setUp() {
.value("https://cdn/third-attached"));
}

@Test
void 먼저_추가된_사진의_썸네일이_준비되지_않으면_뒤의_준비된_사진으로_최대_3장을_채운다() throws Exception {
SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId());
AppUser managedCreator = entityManager.find(AppUser.class, creator.getId());
SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범"));

Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z"));
Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z"));
failed.markThumbnailFailed();
Photo anotherFailed = aPhoto(managedCreator, Instant.parse("2026-01-03T00:00:00Z"));
anotherFailed.markThumbnailFailed();
Photo readyFirst =
aReadyPhoto(
managedCreator,
Instant.parse("2026-01-04T00:00:00Z"),
"thumb/ready-first.jpg");
Photo readySecond =
aReadyPhoto(
managedCreator,
Instant.parse("2026-01-05T00:00:00Z"),
"thumb/ready-second.jpg");
Photo readyThird =
aReadyPhoto(
managedCreator,
Instant.parse("2026-01-06T00:00:00Z"),
"thumb/ready-third.jpg");
persist(pending);
persist(failed);
persist(anotherFailed);
persist(readyFirst);
persist(readySecond);
persist(readyThird);
entityManager.flush();

persist(SharedAlbumPhoto.create(album, pending));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, failed));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, anotherFailed));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, readyFirst));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, readySecond));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, readyThird));
entityManager.flush();
entityManager.clear();

when(objectStorageService.issueDownloadUrl(eq("thumb/ready-first.jpg"), any()))
.thenReturn(new PresignedDownload("https://cdn/ready-first", Instant.now()));
when(objectStorageService.issueDownloadUrl(eq("thumb/ready-second.jpg"), any()))
.thenReturn(new PresignedDownload("https://cdn/ready-second", Instant.now()));
when(objectStorageService.issueDownloadUrl(eq("thumb/ready-third.jpg"), any()))
.thenReturn(new PresignedDownload("https://cdn/ready-third", Instant.now()));

mockMvc.perform(
get(
"/api/v1/shared-groups/{sharedGroupId}/shared-albums",
sharedGroup.getId())
.header("Authorization", bearerToken(creator)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(3))
.andExpect(
jsonPath("$.data.items[0].thumbnails[0].url")
.value("https://cdn/ready-first"))
.andExpect(
jsonPath("$.data.items[0].thumbnails[1].url")
.value("https://cdn/ready-second"))
.andExpect(
jsonPath("$.data.items[0].thumbnails[2].url")
.value("https://cdn/ready-third"));
}

@Test
void 사용할_수_없는_썸네일만_있으면_사진이_있어도_빈_배열을_반환한다() throws Exception {
SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId());
AppUser managedCreator = entityManager.find(AppUser.class, creator.getId());
SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범"));

Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z"));
Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z"));
failed.markThumbnailFailed();
Photo readyWithoutKey =
aReadyPhoto(managedCreator, Instant.parse("2026-01-03T00:00:00Z"), null);
Photo readyWithBlankKey =
aReadyPhoto(managedCreator, Instant.parse("2026-01-04T00:00:00Z"), " ");
Photo deletedReady =
aReadyPhoto(
managedCreator, Instant.parse("2026-01-05T00:00:00Z"), "thumb/deleted.jpg");
deletedReady.delete(Instant.parse("2026-02-01T00:00:00Z"));
persist(pending);
persist(failed);
persist(readyWithoutKey);
persist(readyWithBlankKey);
persist(deletedReady);
entityManager.flush();

persist(SharedAlbumPhoto.create(album, pending));
persist(SharedAlbumPhoto.create(album, failed));
persist(SharedAlbumPhoto.create(album, readyWithoutKey));
persist(SharedAlbumPhoto.create(album, readyWithBlankKey));
persist(SharedAlbumPhoto.create(album, deletedReady));
entityManager.flush();
entityManager.clear();

mockMvc.perform(
get(
"/api/v1/shared-groups/{sharedGroupId}/shared-albums",
sharedGroup.getId())
.header("Authorization", bearerToken(creator)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].photoCount").value(4))
.andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(0));
verifyNoInteractions(objectStorageService);
}

@Test
void 준비된_썸네일이_3장보다_적으면_사용할_수_있는_사진만_추가순으로_반환한다() throws Exception {
SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId());
AppUser managedCreator = entityManager.find(AppUser.class, creator.getId());
SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범"));

Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z"));
Photo readyFirst =
aReadyPhoto(
managedCreator,
Instant.parse("2026-03-01T00:00:00Z"),
"thumb/ready-first.jpg");
Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z"));
failed.markThumbnailFailed();
Photo readySecond =
aReadyPhoto(
managedCreator,
Instant.parse("2026-02-01T00:00:00Z"),
"thumb/ready-second.jpg");
persist(pending);
persist(readyFirst);
persist(failed);
persist(readySecond);
entityManager.flush();

persist(SharedAlbumPhoto.create(album, pending));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, readyFirst));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, failed));
entityManager.flush();
persist(SharedAlbumPhoto.create(album, readySecond));
entityManager.flush();
entityManager.clear();

when(objectStorageService.issueDownloadUrl(eq("thumb/ready-first.jpg"), any()))
.thenReturn(new PresignedDownload("https://cdn/ready-first", Instant.now()));
when(objectStorageService.issueDownloadUrl(eq("thumb/ready-second.jpg"), any()))
.thenReturn(new PresignedDownload("https://cdn/ready-second", Instant.now()));

mockMvc.perform(
get(
"/api/v1/shared-groups/{sharedGroupId}/shared-albums",
sharedGroup.getId())
.header("Authorization", bearerToken(creator)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(2))
.andExpect(
jsonPath("$.data.items[0].thumbnails[0].url")
.value("https://cdn/ready-first"))
.andExpect(
jsonPath("$.data.items[0].thumbnails[1].url")
.value("https://cdn/ready-second"));
}

@Test
void 사진이_없는_앨범은_빈_썸네일_배열을_반환한다() throws Exception {
SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId());
Expand All @@ -162,18 +334,21 @@ void setUp() {
}

private Photo aReadyPhoto(AppUser uploader, Instant takenAt, String thumbnailObjectKey) {
Photo photo =
Photo.create(
uploader,
"iPhone 15",
"photos/" + UUID.randomUUID() + "/original.jpg",
takenAt,
4032,
3024);
Photo photo = aPhoto(uploader, takenAt);
photo.markThumbnailReady(thumbnailObjectKey);
return photo;
}

private Photo aPhoto(AppUser uploader, Instant takenAt) {
return Photo.create(
uploader,
"iPhone 15",
"photos/" + UUID.randomUUID() + "/original.jpg",
takenAt,
4032,
3024);
}

private String bearerToken(AppUser appUser) {
return "Bearer " + jwtTokenProvider.generateAccessToken(appUser.getId());
}
Expand Down
Loading
Loading