diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java index b1bdb233..a390de0a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.community.application; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityCollectionsResponse; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse; @@ -22,6 +23,8 @@ @RequiredArgsConstructor public class CommunityQueryService { + private static final int BIRD_DETAIL_RELATED_COLLECTION_LIMIT = 5; + private final CommunityRepository communityRepository; private final CommunityDataAssembler dataAssembler; private final CommunityWebMapper communityWebMapper; @@ -52,6 +55,14 @@ public GetCommunityCollectionsResponse getRecentCollections(Long userId, Communi return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId)); } + public List getRecentPublicCollectionsByBirdId(Long birdId, Long userId) { + List collections = communityRepository.findRecentPublicCollectionsByBirdId( + birdId, + BIRD_DETAIL_RELATED_COLLECTION_LIMIT + ); + return dataAssembler.toCollectionInfos(collections, userId); + } + public GetCommunityCollectionsResponse getPopularCollections(Long userId, CommunityQueryCommand command) { List collections = communityRepository.findPopularCollections(command); return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId)); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java index 41b05195..94d8e975 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java @@ -32,6 +32,22 @@ public List findRecentPublicCollections(CommunityQueryComman return query.getResultList(); } + /** 특정 조류가 지정된 공개 컬렉션을 최신순으로 조회한다. */ + public List findRecentPublicCollectionsByBirdId(Long birdId, int limit) { + return em.createQuery(""" + SELECT c FROM UserBirdCollection c + JOIN FETCH c.user u + JOIN FETCH c.bird b + WHERE c.accessLevel = :public + AND b.id = :birdId + ORDER BY c.createdAt DESC, c.id DESC + """, UserBirdCollection.class) + .setParameter("public", AccessLevelType.PUBLIC) + .setParameter("birdId", birdId) + .setMaxResults(limit) + .getResultList(); + } + // 인기 있는 컬렉션들을 조회 (좋아요 수가 minLikes 이상인 것들 최신순) public List findPopularCollections(CommunityQueryCommand command) { Query query = em.createQuery(""" diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java index 01463246..c12e6108 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/BirdController.java @@ -11,9 +11,11 @@ import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.*; import org.devkor.apu.saerok_server.domain.dex.bird.application.BirdQueryService; import org.devkor.apu.saerok_server.domain.dex.bird.application.dto.BirdSearchCommand; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; import org.devkor.apu.saerok_server.global.shared.exception.ErrorResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.time.OffsetDateTime; @@ -147,8 +149,11 @@ public ResponseEntity getBirds( } ) public ResponseEntity getBirdDetail( - @Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId) { - BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId); + @Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId, + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal != null ? userPrincipal.getId() : null; + BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId); return ResponseEntity.ok(response); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java index 1a0503c5..6709755e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -37,6 +38,9 @@ public class BirdDetailResponse { @Schema(description = "국내 관찰 가능 계절 목록") public List seasonsWithRarity; + @Schema(description = "이 조류가 지정된 공개 컬렉션 목록 (최신순, 최대 5개)") + public List relatedCollections; + @Schema(description = "분류학적 정보") public static class BirdTaxonomy { public String phylumEng; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java index d4a85f34..fae7e882 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryService.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.application; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdAutocompleteResponse; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdChangesResponse; import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse; @@ -44,6 +45,7 @@ public class BirdQueryService { private final SizeCategoryRulesMapper sizeCategoryRulesMapper; private final SizeCategoryService sizeCategoryService; private final SizeCategoryRulesConfig sizeCategoryRulesConfig; + private final CommunityQueryService communityQueryService; public BirdFullSyncResponse getBirdFullSyncResponse() { List birdProfileViews = birdProfileViewRepository.findAll(); @@ -60,11 +62,12 @@ public BirdFullSyncResponse getBirdFullSyncResponse() { return response; } - public BirdDetailResponse getBirdDetailResponse(Long birdId) { + public BirdDetailResponse getBirdDetailResponse(Long birdId, Long userId) { BirdProfileView birdProfileView = birdProfileViewRepository.findById(birdId) .orElseThrow(() -> new NotFoundException("해당 id의 birdProfileView를 찾지 못했어요")); BirdDetailResponse response = birdProfileViewMapper.toBirdDetailResponse(birdProfileView); response.sizeCategory = sizeCategoryService.getSizeCategory(birdProfileView).getLabel(); + response.relatedCollections = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId); return response; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java index f748e7cc..afaaa97d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java @@ -45,6 +45,7 @@ protected void fillS3Urls( @Mapping(source = "description.description", target = "description") @Mapping(target = "imageUrls", ignore = true) @Mapping(target = "sizeCategory", ignore = true) + @Mapping(target = "relatedCollections", ignore = true) public abstract BirdDetailResponse toBirdDetailResponse(BirdProfileView view); @AfterMapping diff --git a/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql b/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql new file mode 100644 index 00000000..a543810b --- /dev/null +++ b/src/main/resources/db/migration/V97__add_public_bird_collection_recent_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_user_bird_collection_public_bird_created_at + ON user_bird_collection (bird_id, created_at DESC, id DESC) + WHERE access_level = 'PUBLIC'; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index 8f7564be..7f55521a 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -223,6 +223,33 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() { assertThat(secondItem.note()).isEqualTo("까치를 발견했어요!"); } + @Test + @DisplayName("도감 상세용 관련 컬렉션은 최대 5개 조회 결과를 기존 카드 형식으로 조립한다") + void getRecentPublicCollectionsByBirdId_assemblesRelatedCollections() { + // Given + Long birdId = 100L; + Long userId = 1L; + User owner = user(2L, "테스트유저"); + Bird bird = bird(birdId, "까치"); + List collections = List.of(collection(10L, owner, bird, "까치를 발견했어요!")); + CommunityCollectionInfo collectionInfo = collectionInfo( + 10L, "https://example.com/image.jpg", "https://example.com/thumbnail.webp", "까치를 발견했어요!", + 1L, 2L, false, null, false, + new CommunityCollectionInfo.BirdInfo(birdId, "까치"), + new CommunityCollectionInfo.UserInfo(2L, "테스트유저", null, null) + ); + given(communityRepository.findRecentPublicCollectionsByBirdId(birdId, 5)).willReturn(collections); + given(dataAssembler.toCollectionInfos(collections, userId)).willReturn(List.of(collectionInfo)); + + // When + List result = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId); + + // Then + assertThat(result).containsExactly(collectionInfo); + then(communityRepository).should().findRecentPublicCollectionsByBirdId(birdId, 5); + then(dataAssembler).should().toCollectionInfos(collections, userId); + } + @Test @DisplayName("커뮤니티 메인 조회 시 자유게시판 최신 글 5건이 포함된다") void getCommunityMain_returnsRecentFreeBoardPosts() { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java index cace7917..9e6a7540 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepositoryTest.java @@ -167,6 +167,39 @@ void findRecent_returnsPublicOnly() { assertEquals(publicOld.getId(), result.get(1).getId(), "이전 컬렉션이 다음"); } + @Test + @DisplayName("특정 조류의 공개 컬렉션만 최신순으로 최대 개수만큼 조회") + void findRecentPublicCollectionsByBirdId_returnsLatestPublicCollectionsWithinLimit() { + // given + User user = newUser("user"); + Bird targetBird = newBird("참새"); + Bird otherBird = newBird("까마귀"); + OffsetDateTime now = OffsetDateTime.now(); + + UserBirdCollection oldest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(6)); + UserBirdCollection fifth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(5)); + UserBirdCollection fourth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(4)); + UserBirdCollection third = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(3)); + UserBirdCollection second = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(2)); + UserBirdCollection latest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(1)); + newCollection(user, targetBird, AccessLevelType.PRIVATE, now); + newCollection(user, otherBird, AccessLevelType.PUBLIC, now); + + em.flush(); + em.clear(); + + // when + List result = communityRepository.findRecentPublicCollectionsByBirdId(targetBird.getId(), 5); + + // then + assertEquals(5, result.size()); + assertEquals( + List.of(latest.getId(), second.getId(), third.getId(), fourth.getId(), fifth.getId()), + result.stream().map(UserBirdCollection::getId).toList() + ); + assertFalse(result.stream().anyMatch(collection -> collection.getId().equals(oldest.getId()))); + } + @Test @DisplayName("PopularCollection 스냅샷에 등록된 PUBLIC 컬렉션만 조회하고 좋아요 수와 무관") void findPopular_returnsOnlySnapshotEntries() { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java new file mode 100644 index 00000000..1eccd40a --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/application/BirdQueryServiceTest.java @@ -0,0 +1,81 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.application; + +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; +import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService; +import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse; +import org.devkor.apu.saerok_server.domain.dex.bird.core.dto.SizeCategoryDto; +import org.devkor.apu.saerok_server.domain.dex.bird.core.mapper.BirdMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.core.service.SizeCategoryService; +import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.BirdProfileViewMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.SizeCategoryRulesMapper; +import org.devkor.apu.saerok_server.domain.dex.bird.query.repository.BirdProfileViewRepository; +import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; +import org.devkor.apu.saerok_server.global.core.config.feature.SizeCategoryRulesConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class BirdQueryServiceTest { + + private BirdQueryService birdQueryService; + + @Mock private BirdRepository birdRepository; + @Mock private BirdMapper birdMapper; + @Mock private BirdProfileViewRepository birdProfileViewRepository; + @Mock private BirdProfileViewMapper birdProfileViewMapper; + @Mock private SizeCategoryRulesMapper sizeCategoryRulesMapper; + @Mock private SizeCategoryService sizeCategoryService; + @Mock private SizeCategoryRulesConfig sizeCategoryRulesConfig; + @Mock private CommunityQueryService communityQueryService; + + @BeforeEach + void setUp() { + birdQueryService = new BirdQueryService( + birdRepository, + birdMapper, + birdProfileViewRepository, + birdProfileViewMapper, + sizeCategoryRulesMapper, + sizeCategoryService, + sizeCategoryRulesConfig, + communityQueryService + ); + } + + @Test + @DisplayName("도감 상세 응답에 관련 공개 컬렉션을 포함한다") + void getBirdDetailResponse_includesRelatedCollections() { + // Given + Long birdId = 100L; + Long userId = 1L; + BirdProfileView birdProfileView = org.mockito.Mockito.mock(BirdProfileView.class); + BirdDetailResponse birdDetailResponse = new BirdDetailResponse(); + List relatedCollections = List.of(); + + given(birdProfileViewRepository.findById(birdId)).willReturn(Optional.of(birdProfileView)); + given(birdProfileViewMapper.toBirdDetailResponse(birdProfileView)).willReturn(birdDetailResponse); + given(sizeCategoryService.getSizeCategory(birdProfileView)).willReturn(new SizeCategoryDto("small", "참새 크기")); + given(communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId)) + .willReturn(relatedCollections); + + // When + BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId); + + // Then + assertThat(response.sizeCategory).isEqualTo("참새 크기"); + assertThat(response.relatedCollections).isSameAs(relatedCollections); + then(communityQueryService).should().getRecentPublicCollectionsByBirdId(birdId, userId); + } +}