From 17de395f182f68f8bee8c9100e2b59a58d039137 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Thu, 30 Oct 2025 02:30:19 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(MemberPickServiceV2):=20=ED=94=BD?= =?UTF-8?q?=ED=94=BD=ED=94=BD=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/pick/PickRepository.java | 2 +- .../domain/repository/pick/PickSearchDto.java | 14 + .../pick/custom/PickRepositoryCustom.java | 4 + .../pick/custom/PickRepositoryImpl.java | 11 + .../repository/pick/mybatis/PickMapper.java | 14 + .../service/pick/GuestPickServiceV2.java | 17 +- .../service/pick/MemberPickServiceV2.java | 54 ++- .../domain/service/pick/PickServiceV2.java | 7 +- .../dto/response/pick/PickMainResponseV2.java | 13 +- .../pick/PickMainSearchResponseV2.java | 38 ++ src/main/resources/mapper/pick/Pick.xml | 75 ++++ .../service/pick/MemberPickServiceV2Test.java | 16 +- .../mysql/MemberPickServiceV2MySqlTest.java | 337 ++++++++++++++++++ .../GuestTechArticleServiceTest.java | 112 +++--- 14 files changed, 634 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSearchDto.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java create mode 100644 src/main/resources/mapper/pick/Pick.xml create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java index e8129d1a..317d2d67 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickRepository.java @@ -17,6 +17,6 @@ public interface PickRepository extends JpaRepository, PickRepositor List findTop1000ByContentStatusAndEmbeddingsIsNotNullOrderByCreatedAtDesc(ContentStatus contentStatus); Long countByMember(Member member); - + Long countByContentStatus(ContentStatus contentStatus); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSearchDto.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSearchDto.java new file mode 100644 index 00000000..1440d05a --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/PickSearchDto.java @@ -0,0 +1,14 @@ +package com.dreamypatisiel.devdevdev.domain.repository.pick; + +import lombok.Getter; + +@Getter +public class PickSearchDto { + private final Long pickId; + private final Double maxTotalScore; + + public PickSearchDto(Long pickId, Double maxTotalScore) { + this.pickId = pickId; + this.maxTotalScore = maxTotalScore; + } +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryCustom.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryCustom.java index c14c3d77..426a271c 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryCustom.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryCustom.java @@ -3,7 +3,9 @@ import com.dreamypatisiel.devdevdev.domain.entity.Member; import com.dreamypatisiel.devdevdev.domain.entity.Pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -15,4 +17,6 @@ public interface PickRepositoryCustom { Optional findPickWithPickOptionByPickId(Long pickId); Slice findPicksByMemberAndCursor(Pageable pageable, Member member, Long pickId); + + List findPicksWithPickOptionWithMemberByIdIn(Set ids); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryImpl.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryImpl.java index f415f74d..64f692b2 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryImpl.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/custom/PickRepositoryImpl.java @@ -16,6 +16,7 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -66,6 +67,16 @@ public Slice findPicksByMemberAndCursor(Pageable pageable, Member member, return new SliceImpl<>(contents, pageable, hasNextPage(contents, pageable.getPageSize())); } + @Override + public List findPicksWithPickOptionWithMemberByIdIn(Set ids) { + return query.selectFrom(pick) + .leftJoin(pick.pickOptions, pickOption) + .leftJoin(pick.member, member).fetchJoin() + .where(pick.id.in(ids) + .and(pick.contentStatus.eq(ContentStatus.APPROVAL))) + .fetch(); + } + @Override public Optional findPickWithPickOptionWithPickVoteWithMemberByPickId(Long pickId) { Pick findPick = query.selectFrom(pick) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java new file mode 100644 index 00000000..2cf2c7d5 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java @@ -0,0 +1,14 @@ +package com.dreamypatisiel.devdevdev.domain.repository.pick.mybatis; + +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSearchDto; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface PickMapper { + List findPickSearchDtoByKeywordAndCursor(@Param("cursorId") Long pickId, + @Param("keyword") String keyword, + @Param("cursorScore") Double maxTotalScore, + @Param("limit") int limit); +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index 71a6f8e5..bc039c5c 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -5,22 +5,25 @@ import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; -import com.dreamypatisiel.devdevdev.domain.repository.pick.*; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @Transactional(readOnly = true) public class GuestPickServiceV2 extends PickCommonService implements PickServiceV2 { @@ -44,7 +47,7 @@ public GuestPickServiceV2(PickRepository pickRepository, EmbeddingsService embed @Transactional @Override public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, - String anonymousMemberId, Authentication authentication) { + String anonymousMemberId, Authentication authentication) { // 익명 사용자 호출인지 확인 AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); @@ -72,4 +75,10 @@ public Slice findPicksMain(Pageable pageable, Long pickId, P public List findTop3SimilarPicksV2(Long pickId) { return super.findTop3SimilarPicksV2(pickId); } + + @Override + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, + String keyword, Authentication authentication) { + return null; + } } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java index eda4719d..cb061c9a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -8,37 +8,45 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSearchDto; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.repository.pick.mybatis.PickMapper; import com.dreamypatisiel.devdevdev.global.common.MemberProvider; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @Transactional(readOnly = true) public class MemberPickServiceV2 extends PickCommonService implements PickServiceV2 { private final MemberProvider memberProvider; + private final PickMapper pickMapper; public MemberPickServiceV2(EmbeddingsService embeddingsService, PickRepository pickRepository, MemberProvider memberProvider, PickCommentRepository pickCommentRepository, PickCommentRecommendRepository pickCommentRecommendRepository, PickPopularScorePolicy pickPopularScorePolicy, - PickBestCommentsPolicy pickBestCommentsPolicy, TimeProvider timeProvider) { + PickBestCommentsPolicy pickBestCommentsPolicy, TimeProvider timeProvider, PickMapper pickMapper) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, - pickCommentRepository, - pickCommentRecommendRepository); + pickCommentRepository, pickCommentRecommendRepository); this.memberProvider = memberProvider; + this.pickMapper = pickMapper; } /** @@ -46,7 +54,7 @@ public MemberPickServiceV2(EmbeddingsService embeddingsService, PickRepository p */ @Override public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, - String anonymousMemberId, Authentication authentication) { + String anonymousMemberId, Authentication authentication) { // 픽픽픽 조회 Slice picks = pickRepository.findPicksByCursor(pageable, pickId, pickSort); @@ -71,4 +79,38 @@ public Slice findPicksMain(Pageable pageable, Long pickId, P public List findTop3SimilarPicksV2(Long pickId) { return super.findTop3SimilarPicksV2(pickId); } + + /** + * @Author: 장세웅 + * @Note: 픽픽픽 검색 조회 + */ + @Override + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, + String keyword, Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 픽픽픽 검색 + List pickSearchDtos = pickMapper.findPickSearchDtoByKeywordAndCursor(pickId, + keyword, score, pageable.getPageSize()); + + Set pickIds = pickSearchDtos.stream() + .map(PickSearchDto::getPickId) + .collect(Collectors.toSet()); + + // 픽픽픽 조회 + Map findPicks = pickRepository.findPicksWithPickOptionWithMemberByIdIn(pickIds).stream() + .collect(Collectors.toMap(Pick::getId, Function.identity())); + + // 데이터 가공 + List pickMainSearchResponse = pickSearchDtos.stream() + .map(PickSearchDto::getPickId) + .map(findPicks::get) + .filter(Objects::nonNull) + .map(pick -> PickMainSearchResponseV2.of(pick, findMember, score)) + .toList(); + + return new SliceCustom<>(pickMainSearchResponse, pageable, (long) pickSearchDtos.size()); + } } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java index a9be0ccc..2a1844b2 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java @@ -2,16 +2,19 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; -import java.util.List; - public interface PickServiceV2 extends PickService { Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication); List findTop3SimilarPicksV2(Long pickId); + + Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, + String keyword, Authentication authentication); } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java index 1e7c7b8f..8225d8c2 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java @@ -8,10 +8,10 @@ import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; import java.util.List; import lombok.Builder; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -@Data +@Getter @NoArgsConstructor public class PickMainResponseV2 { private Long id; @@ -26,8 +26,8 @@ public class PickMainResponseV2 { @Builder public PickMainResponseV2(Long id, Title title, Count voteTotalCount, Count commentTotalCount, - Count viewTotalCount, Count popularScore, Boolean isVoted, Boolean isNew, - List pickOptions) { + Count viewTotalCount, Count popularScore, Boolean isVoted, Boolean isNew, + List pickOptions) { this.id = id; this.title = title.getTitle(); this.voteTotalCount = voteTotalCount.getCount(); @@ -69,16 +69,15 @@ public static PickMainResponseV2 of(Pick pick, AnonymousMember anonymousMember) .build(); } - private static List mapToPickOptionsResponse(Pick pick, Member member) { + protected static List mapToPickOptionsResponse(Pick pick, Member member) { return pick.getPickOptions().stream() .map(pickOption -> PickMainOptionResponseV2.of(pick, pickOption, member)) .toList(); } - private static List mapToPickOptionsResponse(Pick pick, AnonymousMember anonymousMember) { + protected static List mapToPickOptionsResponse(Pick pick, AnonymousMember anonymousMember) { return pick.getPickOptions().stream() .map(pickOption -> PickMainOptionResponseV2.of(pick, pickOption, anonymousMember)) .toList(); } - } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java new file mode 100644 index 00000000..db8b4374 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java @@ -0,0 +1,38 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class PickMainSearchResponseV2 extends PickMainResponseV2 { + private final Double score; + + @Builder(builderMethodName = "searchBuilder") + public PickMainSearchResponseV2(Long id, Title title, Count voteTotalCount, Count commentTotalCount, Count viewTotalCount, + Count popularScore, Boolean isVoted, Boolean isNew, + List pickOptions, Double score) { + super(id, title, voteTotalCount, commentTotalCount, viewTotalCount, popularScore, isVoted, isNew, pickOptions); + this.score = score; + } + + public static PickMainSearchResponseV2 of(Pick pick, Member member, Double score) { + return PickMainSearchResponseV2.searchBuilder() + .id(pick.getId()) + .title(pick.getTitle()) + .voteTotalCount(pick.getVoteTotalCount()) + .commentTotalCount(pick.getCommentTotalCount()) + .viewTotalCount(pick.getViewTotalCount()) + .popularScore(pick.getPopularScore()) + .pickOptions(mapToPickOptionsResponse(pick, member)) + .isVoted(PickResponseUtils.isVotedMember(pick, member)) + .isNew(PickResponseUtils.isNewPick(pick)) + .score(score) + .build(); + } +} diff --git a/src/main/resources/mapper/pick/Pick.xml b/src/main/resources/mapper/pick/Pick.xml new file mode 100644 index 00000000..37d4708c --- /dev/null +++ b/src/main/resources/mapper/pick/Pick.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java index ded5f271..d39a0f48 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java @@ -1,6 +1,14 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; -import com.dreamypatisiel.devdevdev.domain.entity.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.PickOption; +import com.dreamypatisiel.devdevdev.domain.entity.PickOptionImage; +import com.dreamypatisiel.devdevdev.domain.entity.PickVote; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; @@ -18,6 +26,7 @@ import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; import jakarta.persistence.EntityManager; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -28,11 +37,6 @@ import org.springframework.data.domain.Slice; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.tuple; - @SpringBootTest @Transactional class MemberPickServiceV2Test { diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java new file mode 100644 index 00000000..baedaf35 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java @@ -0,0 +1,337 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick.mysql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.PickOption; +import com.dreamypatisiel.devdevdev.domain.entity.PickOptionImage; +import com.dreamypatisiel.devdevdev.domain.entity.PickVote; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionImageRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; +import com.dreamypatisiel.devdevdev.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; +import jakarta.persistence.EntityManager; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Transactional +@Testcontainers +class MemberPickServiceV2MySqlTest { + + @Autowired + MemberPickServiceV2 memberPickServiceV2; + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + PickPopularScorePolicy pickPopularScorePolicy; + @Autowired + EntityManager em; + @MockBean + MemberProvider memberProvider; + @MockBean + EmbeddingsService embeddingsService; + @Autowired + private JdbcTemplate jdbcTemplate; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @Container + @ServiceConnection + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("devdevdev") + .withUsername("test") + .withPassword("test") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci", + "--ngram_token_size=2" + ); + + private static boolean indexesCreated = false; + + @BeforeTransaction + public void initIndexes() throws SQLException { + if (!indexesCreated) { + // 인덱스 생성 + createFulltextIndexesWithJDBC(); + indexesCreated = true; + + // 데이터 추가 + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + // 픽픽픽 생성 + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, + ContentStatus.APPROVAL); + pickRepository.save(pick); + + // 픽픽픽 옵션 생성 + PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), + new Count(1), PickOptionType.firstPickOption); + PickOption secondPickOption = createPickOption(pick, new Title("픽픽픽 옵션2"), new PickOptionContents("픽픽픽 옵션2 내용"), + new Count(0), PickOptionType.secondPickOption); + pickOptionRepository.saveAll(List.of(firstPickOption, secondPickOption)); + + // 픽픽픽 옵션 이미지 생성 + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + } + } + + /** + * JDBC를 사용하여 MySQL fulltext 인덱스를 생성 + */ + private void createFulltextIndexesWithJDBC() throws SQLException { + Connection connection = null; + try { + // 현재 테스트 클래스의 컨테이너에 직접 연결 + connection = DriverManager.getConnection( + mysql.getJdbcUrl(), + mysql.getUsername(), + mysql.getPassword() + ); + connection.setAutoCommit(false); // 트랜잭션 시작 + + try (Statement statement = connection.createStatement()) { + try { + // 기존 인덱스가 있다면 삭제 + statement.executeUpdate("DROP INDEX idx_pick_01 ON pick"); + statement.executeUpdate("DROP INDEX idx_pick_option_01 ON pick_option"); + statement.executeUpdate("DROP INDEX idx_pick_option_02 ON pick_option"); + } catch (Exception e) { + System.out.println("인덱스 없음 (정상): " + e.getMessage()); + } + + // fulltext 인덱스 생성 (개별 + 복합) + statement.executeUpdate("CREATE FULLTEXT INDEX idx_pick_01 ON pick (title) WITH PARSER ngram"); + statement.executeUpdate("CREATE FULLTEXT INDEX idx_pick_option_01 ON pick_option (title) WITH PARSER ngram"); + statement.executeUpdate( + "CREATE FULLTEXT INDEX idx_pick_option_02 ON pick_option (pick_option_contents) WITH PARSER ngram"); + connection.commit(); // 트랜잭션 커밋 + } + } finally { + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + } + + @Test + @DisplayName("회원이 픽픽픽 검색을 조회한다.") + void findPickMainSearch() { + // given + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Slice pickMainSearch = memberPickServiceV2.findPickMainSearch(pageable, null, null, "픽픽", + authentication); + + // then + assertThat(pickMainSearch).hasSize(1); + } + + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, + Count poplarScore, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .viewTotalCount(viewTotalCount) + .voteTotalCount(voteTotalCount) + .commentTotalCount(commentTotalCount) + .popularScore(poplarScore) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOption createPickOption(Title title, Count voteTotalCount, PickOptionType pickOptionType, Pick pick) { + PickOption pickOption = PickOption.builder() + .title(title) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private PickVote createPickVote(Member member, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .member(member) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private PickVote createPickVote(AnonymousMember anonymousMember, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .anonymousMember(anonymousMember) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private Pick createPick(Title title, Count pickVoteCount, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOptionImage createPickOptionImage(String name, String imageUrl, PickOption pickOption) { + PickOptionImage pickOptionImage = PickOptionImage.builder() + .name(name) + .imageUrl(imageUrl) + .imageKey("imageKey") + .build(); + + pickOptionImage.changePickOption(pickOption); + + return pickOptionImage; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, Count pickPopularScore, String thumbnailUrl, + String author, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .popularScore(pickPopularScore) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, String thumbnailUrl, String author, + List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Pick pick, Title title, PickOptionContents pickOptionContents, + Count voteTotalCount, PickOptionType pickOptionType) { + PickOption pickOption = PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechArticleServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechArticleServiceTest.java index 5abbb131..f4c4f0da 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechArticleServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/techArticle/GuestTechArticleServiceTest.java @@ -1,5 +1,12 @@ package com.dreamypatisiel.devdevdev.domain.service.techArticle; +import static com.dreamypatisiel.devdevdev.domain.exception.TechArticleExceptionMessage.NOT_FOUND_TECH_ARTICLE_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.GuestTechArticleService.INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; import com.dreamypatisiel.devdevdev.domain.entity.Company; import com.dreamypatisiel.devdevdev.domain.entity.TechArticle; @@ -19,15 +26,19 @@ import com.dreamypatisiel.devdevdev.exception.NotFoundException; import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleDetailResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleMainResponse; import com.dreamypatisiel.devdevdev.web.dto.response.techArticle.TechArticleRecommendResponse; import jakarta.persistence.EntityManager; -import org.springframework.test.context.transaction.BeforeTransaction; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import javax.sql.DataSource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -35,6 +46,7 @@ import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -45,23 +57,9 @@ import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.test.context.transaction.BeforeTransaction; import org.springframework.transaction.annotation.Transactional; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.sql.Statement; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -import static com.dreamypatisiel.devdevdev.domain.exception.TechArticleExceptionMessage.NOT_FOUND_TECH_ARTICLE_MESSAGE; -import static com.dreamypatisiel.devdevdev.domain.service.techArticle.techArticle.GuestTechArticleService.INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @Transactional @@ -93,9 +91,9 @@ class GuestTechArticleServiceTest { .withUsername("test") .withPassword("test") .withCommand( - "--character-set-server=utf8mb4", - "--collation-server=utf8mb4_general_ci", - "--ngram_token_size=1" + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci", + "--ngram_token_size=2" ); String email = "dreamy5patisiel@kakao.com"; @@ -115,8 +113,8 @@ public void initIndexes() throws SQLException { indexesCreated = true; // 데이터 추가 - testCompany = createCompany("꿈빛 파티시엘", "https://example.com/company.png", - "https://example.com", "https://example.com"); + testCompany = createCompany("꿈빛 파티시엘", "https://example.com/company.png", + "https://example.com", "https://example.com"); companyRepository.save(testCompany); testTechArticles = new ArrayList<>(); @@ -136,9 +134,9 @@ private void createFulltextIndexesWithJDBC() throws SQLException { try { // 현재 테스트 클래스의 컨테이너에 직접 연결 connection = DriverManager.getConnection( - mysql.getJdbcUrl(), - mysql.getUsername(), - mysql.getPassword() + mysql.getJdbcUrl(), + mysql.getUsername(), + mysql.getPassword() ); connection.setAutoCommit(false); // 트랜잭션 시작 @@ -155,7 +153,8 @@ private void createFulltextIndexesWithJDBC() throws SQLException { // fulltext 인덱스 생성 (개별 + 복합) statement.executeUpdate("CREATE FULLTEXT INDEX idx__ft__title ON tech_article (title) WITH PARSER ngram"); statement.executeUpdate("CREATE FULLTEXT INDEX idx__ft__contents ON tech_article (contents) WITH PARSER ngram"); - statement.executeUpdate("CREATE FULLTEXT INDEX idx__ft__title_contents ON tech_article (title, contents) WITH PARSER ngram"); + statement.executeUpdate( + "CREATE FULLTEXT INDEX idx__ft__title_contents ON tech_article (title, contents) WITH PARSER ngram"); connection.commit(); // 트랜잭션 커밋 } @@ -298,7 +297,8 @@ void getTechArticleWithRecommend() { em.clear(); // when - TechArticleDetailResponse techArticleDetailResponse = guestTechArticleService.getTechArticle(techArticleId, anonymousMemberId, + TechArticleDetailResponse techArticleDetailResponse = guestTechArticleService.getTechArticle(techArticleId, + anonymousMemberId, authentication); // then @@ -465,7 +465,8 @@ void createTechArticleRecommend() { Count recommendTotalCount = techArticle.getRecommendTotalCount(); // when - TechArticleRecommendResponse techArticleRecommendResponse = guestTechArticleService.updateRecommend(techArticleId, anonymousMemberId, authentication); + TechArticleRecommendResponse techArticleRecommendResponse = guestTechArticleService.updateRecommend(techArticleId, + anonymousMemberId, authentication); // then assertThat(techArticleRecommendResponse) @@ -484,7 +485,8 @@ void createTechArticleRecommend() { }); AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); - TechArticleRecommend techArticleRecommend = techArticleRecommendRepository.findByTechArticleAndAnonymousMember(techArticle, anonymousMember).get(); + TechArticleRecommend techArticleRecommend = techArticleRecommendRepository.findByTechArticleAndAnonymousMember( + techArticle, anonymousMember).get(); assertThat(techArticleRecommend) .satisfies(recommend -> { assertThat(recommend.getTechArticle().getId()).isEqualTo(techArticle.getId()); @@ -515,17 +517,18 @@ void cancelTechArticleRecommend() { AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); TechArticleRecommend techArticleRecommend = TechArticleRecommend.create(anonymousMember, techArticle); techArticleRecommendRepository.save(techArticleRecommend); - + // 추천 후 상태 저장 em.flush(); em.clear(); - + TechArticle updatedTechArticle = techArticleRepository.findById(techArticleId).get(); Count popularScore = updatedTechArticle.getPopularScore(); Count recommendTotalCount = updatedTechArticle.getRecommendTotalCount(); // when - TechArticleRecommendResponse techArticleRecommendResponse = guestTechArticleService.updateRecommend(techArticleId, anonymousMemberId, authentication); + TechArticleRecommendResponse techArticleRecommendResponse = guestTechArticleService.updateRecommend(techArticleId, + anonymousMemberId, authentication); // then em.flush(); @@ -546,7 +549,8 @@ void cancelTechArticleRecommend() { }); AnonymousMember findAnonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); - TechArticleRecommend findTechArticleRecommend = techArticleRecommendRepository.findByTechArticleAndAnonymousMember(techArticle, findAnonymousMember).get(); + TechArticleRecommend findTechArticleRecommend = techArticleRecommendRepository.findByTechArticleAndAnonymousMember( + techArticle, findAnonymousMember).get(); assertThat(findTechArticleRecommend) .satisfies(recommend -> { assertThat(recommend.getTechArticle().getId()).isEqualTo(techArticle.getId()); @@ -561,7 +565,7 @@ void cancelTechArticleRecommend() { void getTechArticlesWithDifferentSorts(TechArticleSort sort) { // given Pageable pageable = PageRequest.of(0, 10); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -572,9 +576,9 @@ void getTechArticlesWithDifferentSorts(TechArticleSort sort) { // then assertThat(techArticles).hasSize(pageable.getPageSize()); - + List articles = techArticles.getContent(); - + assertThat(articles).allSatisfy(article -> { assertThat(article.getId()).isNotNull(); assertThat(article.getTitle()).isNotNull().isNotEmpty(); @@ -595,7 +599,7 @@ void getTechArticlesWithDifferentSorts(TechArticleSort sort) { assertThat(article.getIsLogoImage()).isNotNull(); assertThat(article.getIsBookmarked()).isNotNull().isFalse(); }); - + // 정렬 검증 switch (sort) { case LATEST -> assertThat(articles) @@ -619,7 +623,7 @@ void getTechArticlesWithCursorOrderByLatest() { // given Pageable prevPageable = PageRequest.of(0, 1); Pageable pageable = PageRequest.of(0, 5); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -627,7 +631,7 @@ void getTechArticlesWithCursorOrderByLatest() { // 첫 번째 페이지 조회 Slice firstPage = guestTechArticleService.getTechArticles( prevPageable, null, TechArticleSort.LATEST, null, null, null, authentication); - + TechArticleMainResponse cursor = firstPage.getContent().get(0); // when @@ -668,7 +672,7 @@ void getTechArticlesWithCursorOrderByMostViewed() { // given Pageable prevPageable = PageRequest.of(0, 1); Pageable pageable = PageRequest.of(0, 5); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -676,7 +680,7 @@ void getTechArticlesWithCursorOrderByMostViewed() { // 첫 번째 페이지 조회 Slice firstPage = guestTechArticleService.getTechArticles( prevPageable, null, TechArticleSort.MOST_VIEWED, null, null, null, authentication); - + TechArticleMainResponse cursor = firstPage.getContent().get(0); // when @@ -717,7 +721,7 @@ void getTechArticlesWithCursorOrderByMostCommented() { // given Pageable prevPageable = PageRequest.of(0, 1); Pageable pageable = PageRequest.of(0, 5); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -725,7 +729,7 @@ void getTechArticlesWithCursorOrderByMostCommented() { // 첫 번째 페이지 조회 Slice firstPage = guestTechArticleService.getTechArticles( prevPageable, null, TechArticleSort.MOST_COMMENTED, null, null, null, authentication); - + TechArticleMainResponse cursor = firstPage.getContent().get(0); // when @@ -766,7 +770,7 @@ void getTechArticlesWithCursorOrderByPopular() { // given Pageable prevPageable = PageRequest.of(0, 1); Pageable pageable = PageRequest.of(0, 5); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -774,7 +778,7 @@ void getTechArticlesWithCursorOrderByPopular() { // 첫 번째 페이지 조회 Slice firstPage = guestTechArticleService.getTechArticles( prevPageable, null, TechArticleSort.POPULAR, null, null, null, authentication); - + TechArticleMainResponse cursor = firstPage.getContent().get(0); // when @@ -815,7 +819,7 @@ void getTechArticlesWithKeyword() { // given Pageable pageable = PageRequest.of(0, 10); String keyword = "내용"; - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -847,7 +851,7 @@ void getTechArticlesWithKeyword() { assertThat(article.getIsLogoImage()).isNotNull(); assertThat(article.getIsBookmarked()).isNotNull().isFalse(); boolean containsKeyword = article.getTitle().contains(keyword) || - article.getContents().contains(keyword); + article.getContents().contains(keyword); assertThat(containsKeyword).isTrue(); }); } @@ -857,7 +861,7 @@ void getTechArticlesWithKeyword() { void getTechArticlesFilterByCompany() { // given Pageable pageable = PageRequest.of(0, 10); - + when(authentication.getPrincipal()).thenReturn("anonymousUser"); when(securityContext.getAuthentication()).thenReturn(authentication); SecurityContextHolder.setContext(securityContext); @@ -932,7 +936,7 @@ private static TechArticle createTechArticle(int i, Company company) { .commentTotalCount(new Count(i)) .recommendTotalCount(new Count(i)) .viewTotalCount(new Count(i)) - .popularScore(new Count(10L *i)) + .popularScore(new Count(10L * i)) .build(); } } \ No newline at end of file From a036cf199dc4e91006c2468d3217612a5c55c75c Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sat, 1 Nov 2025 22:58:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(MemberPickServiceV2):=20=ED=94=BD?= =?UTF-8?q?=ED=94=BD=ED=94=BD=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/pick/MemberPickServiceV2.java | 10 ++++---- .../mysql/MemberPickServiceV2MySqlTest.java | 25 ++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java index cb061c9a..3390fc05 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -20,7 +20,7 @@ import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -105,10 +105,10 @@ public Slice findPickMainSearch(Pageable pageable, Lon // 데이터 가공 List pickMainSearchResponse = pickSearchDtos.stream() - .map(PickSearchDto::getPickId) - .map(findPicks::get) - .filter(Objects::nonNull) - .map(pick -> PickMainSearchResponseV2.of(pick, findMember, score)) + .flatMap(pickSearchDto -> Optional.ofNullable(findPicks.get(pickSearchDto.getPickId())) + .map(pick -> PickMainSearchResponseV2.of(pick, findMember, pickSearchDto.getMaxTotalScore())) + .stream() + ) .toList(); return new SliceCustom<>(pickMainSearchResponse, pageable, (long) pickSearchDtos.size()); diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java index baedaf35..39a62a2f 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java @@ -1,6 +1,7 @@ package com.dreamypatisiel.devdevdev.domain.service.pick.mysql; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; import com.dreamypatisiel.devdevdev.domain.entity.Member; @@ -99,6 +100,7 @@ class MemberPickServiceV2MySqlTest { ); private static boolean indexesCreated = false; + private Long pickId; @BeforeTransaction public void initIndexes() throws SQLException { @@ -117,6 +119,7 @@ public void initIndexes() throws SQLException { Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, ContentStatus.APPROVAL); pickRepository.save(pick); + pickId = pick.getId(); // 픽픽픽 옵션 생성 PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), @@ -191,7 +194,27 @@ void findPickMainSearch() { authentication); // then - assertThat(pickMainSearch).hasSize(1); + Pick findPick = pickRepository.findById(pickId).get(); + assertThat(pickMainSearch).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew", "score") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true, + 600000.0) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(pickMainSearch.getContent().get(0).getPickOptions()).hasSize(2) + .extracting("id", "title", "percent", "isPicked", "content", "thumbnailImageUrl") + .containsExactly( + tuple(pickOptions.get(0).getId(), pickOptions.get(0).getTitle().getTitle(), 100, + false, "픽픽픽 옵션1 내용", "http://iamge1.png"), + tuple(pickOptions.get(1).getId(), pickOptions.get(1).getTitle().getTitle(), 0, + false, "픽픽픽 옵션2 내용", "http://iamge2.png") + ); } private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, From e50023da149c26fd30494bce9d8f591cacd45ed9 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sun, 2 Nov 2025 00:14:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(MemberPickServiceV2):=20=ED=94=BD?= =?UTF-8?q?=ED=94=BD=ED=94=BD=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커서 조건 추가 --- .../devdevdev/domain/entity/Pick.java | 3 +- .../repository/pick/mybatis/PickMapper.java | 3 +- .../service/pick/GuestPickServiceV2.java | 2 +- .../service/pick/MemberPickServiceV2.java | 7 +- .../domain/service/pick/PickServiceV2.java | 2 +- .../global/config/SwaggerConfig.java | 8 +- .../web/controller/pick/PickControllerV2.java | 35 +++++- .../pick/PickMainSearchResponseV2.java | 10 +- src/main/resources/mapper/pick/Pick.xml | 111 ++++++++++-------- .../mysql/MemberPickServiceV2MySqlTest.java | 2 +- 10 files changed, 109 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Pick.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Pick.java index 85e69df7..4f38eef3 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Pick.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/entity/Pick.java @@ -35,7 +35,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(indexes = { @Index(name = "idx__content_status", columnList = "contentStatus"), - @Index(name = "idx__member", columnList = "member_id") + @Index(name = "idx__member", columnList = "member_id"), + @Index(name = "idx_pick_01", columnList = "title") }) public class Pick extends BasicTime { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java index 2cf2c7d5..428a0e45 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java @@ -9,6 +9,7 @@ public interface PickMapper { List findPickSearchDtoByKeywordAndCursor(@Param("cursorId") Long pickId, @Param("keyword") String keyword, - @Param("cursorScore") Double maxTotalScore, + @Param("cursorSearchScore") Double searchScore, + @Param("cursorPopularScore") Double popularScore, @Param("limit") int limit); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index bc039c5c..b8ca45b2 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -77,7 +77,7 @@ public List findTop3SimilarPicksV2(Long pickId) { } @Override - public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, Double popularScore, String keyword, Authentication authentication) { return null; } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java index 3390fc05..86b424c3 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -85,15 +85,16 @@ public List findTop3SimilarPicksV2(Long pickId) { * @Note: 픽픽픽 검색 조회 */ @Override - public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, - String keyword, Authentication authentication) { + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, + Double popularScore, String keyword, + Authentication authentication) { // 회원 조회 Member findMember = memberProvider.getMemberByAuthentication(authentication); // 픽픽픽 검색 List pickSearchDtos = pickMapper.findPickSearchDtoByKeywordAndCursor(pickId, - keyword, score, pageable.getPageSize()); + keyword, searchScore, popularScore, pageable.getPageSize()); Set pickIds = pickSearchDtos.stream() .map(PickSearchDto::getPickId) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java index 2a1844b2..9a663971 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java @@ -15,6 +15,6 @@ Slice findPicksMain(Pageable pageable, Long pickId, PickSort List findTop3SimilarPicksV2(Long pickId); - Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, + Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, Double popularScore, String keyword, Authentication authentication); } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/config/SwaggerConfig.java b/src/main/java/com/dreamypatisiel/devdevdev/global/config/SwaggerConfig.java index df55d302..33b1ed60 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/config/SwaggerConfig.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/config/SwaggerConfig.java @@ -2,16 +2,13 @@ import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; - import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; - import java.util.Collections; - import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,12 +19,11 @@ public class SwaggerConfig { @Bean - public OpenAPI openAPI(){ + public OpenAPI openAPI() { SecurityScheme accessToken = new SecurityScheme() .type(SecurityScheme.Type.HTTP).scheme(SecurityConstant.BEARER_PREFIX.trim()).bearerFormat("JWT") .in(SecurityScheme.In.HEADER).name(SecurityConstant.AUTHORIZATION_HEADER); - SecurityRequirement securityRequirement = new SecurityRequirement() .addList("accessToken"); @@ -50,7 +46,7 @@ public GroupedOpenApi getAllApi() { return GroupedOpenApi .builder() .group("all") - .pathsToMatch("/devdevdev/api/v1/**") + .pathsToMatch("/devdevdev/api/**") .build(); } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java index d374e216..a5a8405b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java @@ -1,5 +1,7 @@ package com.dreamypatisiel.devdevdev.web.controller.pick; +import static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; + import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; @@ -8,9 +10,11 @@ import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -18,13 +22,13 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; - -import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -import static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; - -@Tag(name = "픽픽픽 API V2", description = "") +@Tag(name = "픽픽픽 API V2", description = "픽픽픽 메인, 픽픽픽 검색, 나도 고민했는데 픽픽픽") @RestController @RequiredArgsConstructor @RequestMapping("/devdevdev/api/v2") @@ -58,4 +62,23 @@ public ResponseEntity> getSimilarPicks(@Pat return ResponseEntity.ok(BasicResponse.success(response)); } + + @Operation(summary = "픽픽픽 검색 V2", description = "픽픽픽 메인에서 검색한 결과를 커서방식으로 조회합니다.") + @GetMapping("/picks/search") + public ResponseEntity>> searchPicksMain( + @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable, + @RequestParam(required = false) Long pickId, + @RequestParam(required = false) Double searchScore, + @RequestParam(required = false) Double popularScore, + @RequestParam String keyword) { + + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + + PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); + Slice response = pickService.findPickMainSearch(pageable, pickId, searchScore, popularScore, + keyword, + authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java index db8b4374..ab9a9270 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java @@ -11,17 +11,17 @@ @Getter public class PickMainSearchResponseV2 extends PickMainResponseV2 { - private final Double score; + private final Double searchScore; @Builder(builderMethodName = "searchBuilder") public PickMainSearchResponseV2(Long id, Title title, Count voteTotalCount, Count commentTotalCount, Count viewTotalCount, Count popularScore, Boolean isVoted, Boolean isNew, - List pickOptions, Double score) { + List pickOptions, Double searchScore) { super(id, title, voteTotalCount, commentTotalCount, viewTotalCount, popularScore, isVoted, isNew, pickOptions); - this.score = score; + this.searchScore = searchScore; } - public static PickMainSearchResponseV2 of(Pick pick, Member member, Double score) { + public static PickMainSearchResponseV2 of(Pick pick, Member member, Double searchScore) { return PickMainSearchResponseV2.searchBuilder() .id(pick.getId()) .title(pick.getTitle()) @@ -32,7 +32,7 @@ public static PickMainSearchResponseV2 of(Pick pick, Member member, Double score .pickOptions(mapToPickOptionsResponse(pick, member)) .isVoted(PickResponseUtils.isVotedMember(pick, member)) .isNew(PickResponseUtils.isNewPick(pick)) - .score(score) + .searchScore(searchScore) .build(); } } diff --git a/src/main/resources/mapper/pick/Pick.xml b/src/main/resources/mapper/pick/Pick.xml index 37d4708c..301f1e54 100644 --- a/src/main/resources/mapper/pick/Pick.xml +++ b/src/main/resources/mapper/pick/Pick.xml @@ -7,62 +7,75 @@ + + + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java index 39a62a2f..edd9373e 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java @@ -190,7 +190,7 @@ void findPickMainSearch() { Pageable pageable = PageRequest.of(0, 10); // when - Slice pickMainSearch = memberPickServiceV2.findPickMainSearch(pageable, null, null, "픽픽", + Slice pickMainSearch = memberPickServiceV2.findPickMainSearch(pageable, null, null, null, "픽픽", authentication); // then From 6fd9a10be11d7de8e72e6c70fc429de17ebe7b0f Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sun, 2 Nov 2025 14:31:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(PickControllerV2):=20=ED=94=BD?= =?UTF-8?q?=ED=94=BD=ED=94=BD=20=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/pick/{ => v1}/pick-delete.adoc | 0 .../api/pick/{ => v1}/pick-detail.adoc | 0 .../asciidoc/api/pick/{ => v1}/pick-main.adoc | 0 .../api/pick/{ => v1}/pick-modify.adoc | 0 .../{ => v1}/pick-option-delete-image.adoc | 0 .../{ => v1}/pick-option-register-image.adoc | 0 .../api/pick/{ => v1}/pick-register.adoc | 0 .../api/pick/{ => v1}/pick-similarity.adoc | 0 .../asciidoc/api/pick/{ => v1}/pick-vote.adoc | 0 src/docs/asciidoc/api/pick/{ => v1}/pick.adoc | 6 +- .../api/pick/{ => v2}/pick-main-v2.adoc | 0 .../asciidoc/api/pick/v2/pick-search-v2.adoc | 17 + .../api/pick/{ => v2}/pick-similarity-v2.adoc | 0 src/docs/asciidoc/api/pick/v2/pick.adoc | 5 + src/docs/asciidoc/index.adoc | 3 +- .../repository/pick/mybatis/PickMapper.java | 1 - .../service/pick/GuestPickServiceV2.java | 45 ++- .../service/pick/MemberPickServiceV2.java | 7 +- .../domain/service/pick/PickServiceV2.java | 4 +- .../web/controller/pick/PickControllerV2.java | 10 +- .../pick/PickMainSearchResponseV2.java | 16 + src/main/resources/mapper/pick/Pick.xml | 25 +- .../mysql/GuestPickServiceV2MySqlTest.java | 345 ++++++++++++++++++ .../mysql/MemberPickServiceV2MySqlTest.java | 7 +- .../controller/pick/PickControllerV2Test.java | 122 ++++++- .../web/docs/PickControllerV2DocsTest.java | 191 ++++++++-- 26 files changed, 728 insertions(+), 76 deletions(-) rename src/docs/asciidoc/api/pick/{ => v1}/pick-delete.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-detail.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-main.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-modify.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-option-delete-image.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-option-register-image.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-register.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-similarity.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick-vote.adoc (100%) rename src/docs/asciidoc/api/pick/{ => v1}/pick.adoc (70%) rename src/docs/asciidoc/api/pick/{ => v2}/pick-main-v2.adoc (100%) create mode 100644 src/docs/asciidoc/api/pick/v2/pick-search-v2.adoc rename src/docs/asciidoc/api/pick/{ => v2}/pick-similarity-v2.adoc (100%) create mode 100644 src/docs/asciidoc/api/pick/v2/pick.adoc create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/GuestPickServiceV2MySqlTest.java diff --git a/src/docs/asciidoc/api/pick/pick-delete.adoc b/src/docs/asciidoc/api/pick/v1/pick-delete.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-delete.adoc rename to src/docs/asciidoc/api/pick/v1/pick-delete.adoc diff --git a/src/docs/asciidoc/api/pick/pick-detail.adoc b/src/docs/asciidoc/api/pick/v1/pick-detail.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-detail.adoc rename to src/docs/asciidoc/api/pick/v1/pick-detail.adoc diff --git a/src/docs/asciidoc/api/pick/pick-main.adoc b/src/docs/asciidoc/api/pick/v1/pick-main.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-main.adoc rename to src/docs/asciidoc/api/pick/v1/pick-main.adoc diff --git a/src/docs/asciidoc/api/pick/pick-modify.adoc b/src/docs/asciidoc/api/pick/v1/pick-modify.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-modify.adoc rename to src/docs/asciidoc/api/pick/v1/pick-modify.adoc diff --git a/src/docs/asciidoc/api/pick/pick-option-delete-image.adoc b/src/docs/asciidoc/api/pick/v1/pick-option-delete-image.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-option-delete-image.adoc rename to src/docs/asciidoc/api/pick/v1/pick-option-delete-image.adoc diff --git a/src/docs/asciidoc/api/pick/pick-option-register-image.adoc b/src/docs/asciidoc/api/pick/v1/pick-option-register-image.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-option-register-image.adoc rename to src/docs/asciidoc/api/pick/v1/pick-option-register-image.adoc diff --git a/src/docs/asciidoc/api/pick/pick-register.adoc b/src/docs/asciidoc/api/pick/v1/pick-register.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-register.adoc rename to src/docs/asciidoc/api/pick/v1/pick-register.adoc diff --git a/src/docs/asciidoc/api/pick/pick-similarity.adoc b/src/docs/asciidoc/api/pick/v1/pick-similarity.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-similarity.adoc rename to src/docs/asciidoc/api/pick/v1/pick-similarity.adoc diff --git a/src/docs/asciidoc/api/pick/pick-vote.adoc b/src/docs/asciidoc/api/pick/v1/pick-vote.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-vote.adoc rename to src/docs/asciidoc/api/pick/v1/pick-vote.adoc diff --git a/src/docs/asciidoc/api/pick/pick.adoc b/src/docs/asciidoc/api/pick/v1/pick.adoc similarity index 70% rename from src/docs/asciidoc/api/pick/pick.adoc rename to src/docs/asciidoc/api/pick/v1/pick.adoc index 3bfcca27..7820c8d0 100644 --- a/src/docs/asciidoc/api/pick/pick.adoc +++ b/src/docs/asciidoc/api/pick/v1/pick.adoc @@ -1,7 +1,6 @@ -= 픽픽픽 += 픽픽픽 V1 include::pick-main.adoc[] -include::pick-main-v2.adoc[] include::pick-detail.adoc[] include::pick-register.adoc[] include::pick-modify.adoc[] @@ -9,5 +8,4 @@ include::pick-delete.adoc[] include::pick-vote.adoc[] include::pick-option-register-image.adoc[] include::pick-option-delete-image.adoc[] -include::pick-similarity.adoc[] -include::pick-similarity-v2.adoc[] \ No newline at end of file +include::pick-similarity.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/api/pick/pick-main-v2.adoc b/src/docs/asciidoc/api/pick/v2/pick-main-v2.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-main-v2.adoc rename to src/docs/asciidoc/api/pick/v2/pick-main-v2.adoc diff --git a/src/docs/asciidoc/api/pick/v2/pick-search-v2.adoc b/src/docs/asciidoc/api/pick/v2/pick-search-v2.adoc new file mode 100644 index 00000000..84a0d8ae --- /dev/null +++ b/src/docs/asciidoc/api/pick/v2/pick-search-v2.adoc @@ -0,0 +1,17 @@ +[[pick-search-v2]] +== 픽픽픽 메인 검색 API(GET: /devdevdev/api/v2/picks/search) +* 픽픽픽 메인에서 검색 결과를 반환한다. +* 정확도순 정렬만 가능하다. + +=== 정상 요청/응답 +==== HTTP Request +include::{snippets}/pick-search-v2/http-request.adoc[] +==== HTTP Request Header Fields +include::{snippets}/pick-search-v2/request-headers.adoc[] +==== HTTP Request Query Parameters Fields +include::{snippets}/pick-search-v2/query-parameters.adoc[] + +==== HTTP Response +include::{snippets}/pick-search-v2/http-response.adoc[] +==== HTTP Response Fields +include::{snippets}/pick-search-v2/response-fields.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc b/src/docs/asciidoc/api/pick/v2/pick-similarity-v2.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-similarity-v2.adoc rename to src/docs/asciidoc/api/pick/v2/pick-similarity-v2.adoc diff --git a/src/docs/asciidoc/api/pick/v2/pick.adoc b/src/docs/asciidoc/api/pick/v2/pick.adoc new file mode 100644 index 00000000..a8925e6a --- /dev/null +++ b/src/docs/asciidoc/api/pick/v2/pick.adoc @@ -0,0 +1,5 @@ += 픽픽픽 V2 + +include::pick-main-v2.adoc[] +include::pick-search-v2.adoc[] +include::pick-similarity-v2.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index d0caf71f..213fa132 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -14,7 +14,8 @@ include::api/token/token.adoc[] include::api/member/member.adoc[] include::api/mypage/mypage.adoc[] include::api/common/common.adoc[] -include::api/pick/pick.adoc[] +include::api/pick/v1/pick.adoc[] +include::api/pick/v2/pick.adoc[] include::api/pick-commnet/pick-comment.adoc[] include::api/tech-article/tech-article.adoc[] include::api/tech-article-comment/tech-article-comment.adoc[] diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java index 428a0e45..a0d07d52 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/repository/pick/mybatis/PickMapper.java @@ -10,6 +10,5 @@ public interface PickMapper { List findPickSearchDtoByKeywordAndCursor(@Param("cursorId") Long pickId, @Param("keyword") String keyword, @Param("cursorSearchScore") Double searchScore, - @Param("cursorPopularScore") Double popularScore, @Param("limit") int limit); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index b8ca45b2..dc51a04e 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -8,7 +8,9 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSearchDto; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.repository.pick.mybatis.PickMapper; import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; @@ -18,6 +20,11 @@ import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -29,16 +36,18 @@ public class GuestPickServiceV2 extends PickCommonService implements PickServiceV2 { private final AnonymousMemberService anonymousMemberService; + private final PickMapper pickMapper; public GuestPickServiceV2(PickRepository pickRepository, EmbeddingsService embeddingsService, PickBestCommentsPolicy pickBestCommentsPolicy, PickCommentRepository pickCommentRepository, PickCommentRecommendRepository pickCommentRecommendRepository, PickPopularScorePolicy pickPopularScorePolicy, - TimeProvider timeProvider, AnonymousMemberService anonymousMemberService) { + TimeProvider timeProvider, AnonymousMemberService anonymousMemberService, PickMapper pickMapper) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, pickCommentRepository, pickCommentRecommendRepository); this.anonymousMemberService = anonymousMemberService; + this.pickMapper = pickMapper; } /** @@ -77,8 +86,36 @@ public List findTop3SimilarPicksV2(Long pickId) { } @Override - public Slice findPickMainSearch(Pageable pageable, Long pickId, Double score, Double popularScore, - String keyword, Authentication authentication) { - return null; + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, + String keyword, String anonymousMemberId, + Authentication authentication) { + + // 익명 사용자 호출인지 확인 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); + + // anonymousMemberId 검증 + AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); + + // 픽픽픽 검색 + List pickSearchDtos = pickMapper.findPickSearchDtoByKeywordAndCursor(pickId, + keyword, searchScore, pageable.getPageSize()); + + Set pickIds = pickSearchDtos.stream() + .map(PickSearchDto::getPickId) + .collect(Collectors.toSet()); + + // 픽픽픽 조회 + Map findPicks = pickRepository.findPicksWithPickOptionWithMemberByIdIn(pickIds).stream() + .collect(Collectors.toMap(Pick::getId, Function.identity())); + + // 데이터 가공 + List pickMainSearchResponse = pickSearchDtos.stream() + .flatMap(pickSearchDto -> Optional.ofNullable(findPicks.get(pickSearchDto.getPickId())) + .map(pick -> PickMainSearchResponseV2.of(pick, anonymousMember, pickSearchDto.getMaxTotalScore())) + .stream() + ) + .toList(); + + return new SliceCustom<>(pickMainSearchResponse, pageable, (long) pickSearchDtos.size()); } } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java index 86b424c3..bab6fc90 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -85,16 +85,15 @@ public List findTop3SimilarPicksV2(Long pickId) { * @Note: 픽픽픽 검색 조회 */ @Override - public Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, - Double popularScore, String keyword, - Authentication authentication) { + public Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, String keyword, + String anonymousMemberId, Authentication authentication) { // 회원 조회 Member findMember = memberProvider.getMemberByAuthentication(authentication); // 픽픽픽 검색 List pickSearchDtos = pickMapper.findPickSearchDtoByKeywordAndCursor(pickId, - keyword, searchScore, popularScore, pageable.getPageSize()); + keyword, searchScore, pageable.getPageSize()); Set pickIds = pickSearchDtos.stream() .map(PickSearchDto::getPickId) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java index 9a663971..49964a23 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java @@ -15,6 +15,6 @@ Slice findPicksMain(Pageable pageable, Long pickId, PickSort List findTop3SimilarPicksV2(Long pickId); - Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, Double popularScore, - String keyword, Authentication authentication); + Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, String keyword, + String anonymousMemberId, Authentication authentication); } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java index a5a8405b..e646878a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java @@ -24,6 +24,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -69,15 +70,14 @@ public ResponseEntity>> searchPick @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable, @RequestParam(required = false) Long pickId, @RequestParam(required = false) Double searchScore, - @RequestParam(required = false) Double popularScore, - @RequestParam String keyword) { + @RequestParam String keyword, + @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { Authentication authentication = AuthenticationMemberUtils.getAuthentication(); PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - Slice response = pickService.findPickMainSearch(pageable, pickId, searchScore, popularScore, - keyword, - authentication); + Slice response = pickService.findPickMainSearch(pageable, pickId, searchScore, + keyword, anonymousMemberId, authentication); return ResponseEntity.ok(BasicResponse.success(response)); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java index ab9a9270..a2967dd2 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSearchResponseV2.java @@ -1,5 +1,6 @@ package com.dreamypatisiel.devdevdev.web.dto.response.pick; +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; import com.dreamypatisiel.devdevdev.domain.entity.Member; import com.dreamypatisiel.devdevdev.domain.entity.Pick; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; @@ -35,4 +36,19 @@ public static PickMainSearchResponseV2 of(Pick pick, Member member, Double searc .searchScore(searchScore) .build(); } + + public static PickMainSearchResponseV2 of(Pick pick, AnonymousMember anonymousMember, Double searchScore) { + return PickMainSearchResponseV2.searchBuilder() + .id(pick.getId()) + .title(pick.getTitle()) + .voteTotalCount(pick.getVoteTotalCount()) + .commentTotalCount(pick.getCommentTotalCount()) + .viewTotalCount(pick.getViewTotalCount()) + .popularScore(pick.getPopularScore()) + .pickOptions(mapToPickOptionsResponse(pick, anonymousMember)) + .isVoted(PickResponseUtils.isVotedAnonymousMember(pick, anonymousMember)) + .isNew(PickResponseUtils.isNewPick(pick)) + .searchScore(searchScore) + .build(); + } } diff --git a/src/main/resources/mapper/pick/Pick.xml b/src/main/resources/mapper/pick/Pick.xml index 301f1e54..4a29d286 100644 --- a/src/main/resources/mapper/pick/Pick.xml +++ b/src/main/resources/mapper/pick/Pick.xml @@ -12,8 +12,7 @@ diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/GuestPickServiceV2MySqlTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/GuestPickServiceV2MySqlTest.java new file mode 100644 index 00000000..9f9f633d --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/GuestPickServiceV2MySqlTest.java @@ -0,0 +1,345 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick.mysql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.PickOption; +import com.dreamypatisiel.devdevdev.domain.entity.PickOptionImage; +import com.dreamypatisiel.devdevdev.domain.entity.PickVote; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; +import com.dreamypatisiel.devdevdev.domain.entity.enums.Role; +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.domain.repository.member.AnonymousMemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.member.MemberRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionImageRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.transaction.BeforeTransaction; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Transactional +@Testcontainers +class GuestPickServiceV2MySqlTest { + + @Autowired + GuestPickServiceV2 guestPickServiceV2; + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + AnonymousMemberRepository anonymousMemberRepository; + + String userId = "dreamy5patisiel"; + String name = "꿈빛파티시엘"; + String nickname = "행복한 꿈빛파티시엘"; + String email = "dreamy5patisiel@kakao.com"; + String password = "password"; + String socialType = SocialType.KAKAO.name(); + String role = Role.ROLE_USER.name(); + + @Container + @ServiceConnection + static MySQLContainer mysql = new MySQLContainer<>("mysql:8.0") + .withDatabaseName("devdevdev") + .withUsername("test") + .withPassword("test") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci", + "--ngram_token_size=2" + ); + + private static boolean indexesCreated = false; + private Long pickId; + + @BeforeTransaction + public void initIndexes() throws SQLException { + if (!indexesCreated) { + // 인덱스 생성 + createFulltextIndexesWithJDBC(); + indexesCreated = true; + + // 데이터 추가 + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + // 픽픽픽 생성 + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, + ContentStatus.APPROVAL); + pickRepository.save(pick); + pickId = pick.getId(); + + // 픽픽픽 옵션 생성 + PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), + new Count(1), PickOptionType.firstPickOption); + PickOption secondPickOption = createPickOption(pick, new Title("픽픽픽 옵션2"), new PickOptionContents("픽픽픽 옵션2 내용"), + new Count(0), PickOptionType.secondPickOption); + pickOptionRepository.saveAll(List.of(firstPickOption, secondPickOption)); + + // 픽픽픽 옵션 이미지 생성 + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + } + } + + /** + * JDBC를 사용하여 MySQL fulltext 인덱스를 생성 + */ + private void createFulltextIndexesWithJDBC() throws SQLException { + Connection connection = null; + try { + // 현재 테스트 클래스의 컨테이너에 직접 연결 + connection = DriverManager.getConnection( + mysql.getJdbcUrl(), + mysql.getUsername(), + mysql.getPassword() + ); + connection.setAutoCommit(false); // 트랜잭션 시작 + + try (Statement statement = connection.createStatement()) { + try { + // 기존 인덱스가 있다면 삭제 + statement.executeUpdate("DROP INDEX idx_pick_01 ON pick"); + statement.executeUpdate("DROP INDEX idx_pick_option_01 ON pick_option"); + statement.executeUpdate("DROP INDEX idx_pick_option_02 ON pick_option"); + } catch (Exception e) { + System.out.println("인덱스 없음 (정상): " + e.getMessage()); + } + + // fulltext 인덱스 생성 (개별 + 복합) + statement.executeUpdate("CREATE FULLTEXT INDEX idx_pick_01 ON pick (title) WITH PARSER ngram"); + statement.executeUpdate("CREATE FULLTEXT INDEX idx_pick_option_01 ON pick_option (title) WITH PARSER ngram"); + statement.executeUpdate( + "CREATE FULLTEXT INDEX idx_pick_option_02 ON pick_option (pick_option_contents) WITH PARSER ngram"); + connection.commit(); // 트랜잭션 커밋 + } + } finally { + if (connection != null && !connection.isClosed()) { + connection.close(); + } + } + } + + @Test + @DisplayName("익명회원이 픽픽픽 검색을 조회한다.") + void findPickMainSearch() { + // given + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + + Pageable pageable = PageRequest.of(0, 10); + + String anonymousMemberId = "GA1.1.276672604.1715872960"; + AnonymousMember anonymousMember = AnonymousMember.builder() + .anonymousMemberId(anonymousMemberId) + .build(); + anonymousMemberRepository.save(anonymousMember); + + // when + Slice pickMainSearch = guestPickServiceV2.findPickMainSearch(pageable, null, null, "픽픽", + anonymousMemberId, authentication); + + // then + Pick findPick = pickRepository.findById(pickId).get(); + assertThat(pickMainSearch).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew", "searchScore") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true, + 600000.0) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(pickMainSearch.getContent().get(0).getPickOptions()).hasSize(2) + .extracting("id", "title", "percent", "isPicked", "content", "thumbnailImageUrl") + .containsExactly( + tuple(pickOptions.get(0).getId(), pickOptions.get(0).getTitle().getTitle(), 100, + false, "픽픽픽 옵션1 내용", "http://iamge1.png"), + tuple(pickOptions.get(1).getId(), pickOptions.get(1).getTitle().getTitle(), 0, + false, "픽픽픽 옵션2 내용", "http://iamge2.png") + ); + } + + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, + Count poplarScore, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .viewTotalCount(viewTotalCount) + .voteTotalCount(voteTotalCount) + .commentTotalCount(commentTotalCount) + .popularScore(poplarScore) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOption createPickOption(Title title, Count voteTotalCount, PickOptionType pickOptionType, Pick pick) { + PickOption pickOption = PickOption.builder() + .title(title) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } + + private SocialMemberDto createSocialDto(String userId, String name, String nickName, String password, String email, + String socialType, String role) { + return SocialMemberDto.builder() + .userId(userId) + .name(name) + .nickname(nickName) + .password(password) + .email(email) + .socialType(SocialType.valueOf(socialType)) + .role(Role.valueOf(role)) + .build(); + } + + private PickVote createPickVote(Member member, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .member(member) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private PickVote createPickVote(AnonymousMember anonymousMember, PickOption pickOption, Pick pick) { + PickVote pickVote = PickVote.builder() + .anonymousMember(anonymousMember) + .pickOption(pickOption) + .pick(pick) + .build(); + + pickVote.changePick(pick); + + return pickVote; + } + + private Pick createPick(Title title, Count pickVoteCount, Member member, ContentStatus contentStatus) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .member(member) + .contentStatus(contentStatus) + .build(); + } + + private PickOptionImage createPickOptionImage(String name, String imageUrl, PickOption pickOption) { + PickOptionImage pickOptionImage = PickOptionImage.builder() + .name(name) + .imageUrl(imageUrl) + .imageKey("imageKey") + .build(); + + pickOptionImage.changePickOption(pickOption); + + return pickOptionImage; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, Count pickPopularScore, String thumbnailUrl, + String author, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .popularScore(pickPopularScore) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickcommentTotalCount, String thumbnailUrl, String author, + List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickcommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(ContentStatus.APPROVAL) + .build(); + + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Pick pick, Title title, PickOptionContents pickOptionContents, + Count voteTotalCount, PickOptionType pickOptionType) { + PickOption pickOption = PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + + pickOption.changePick(pick); + + return pickOption; + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java index edd9373e..3164928c 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/mysql/MemberPickServiceV2MySqlTest.java @@ -190,13 +190,13 @@ void findPickMainSearch() { Pageable pageable = PageRequest.of(0, 10); // when - Slice pickMainSearch = memberPickServiceV2.findPickMainSearch(pageable, null, null, null, "픽픽", + Slice pickMainSearch = memberPickServiceV2.findPickMainSearch(pageable, null, null, "픽픽", null, authentication); // then Pick findPick = pickRepository.findById(pickId).get(); assertThat(pickMainSearch).hasSize(1) - .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew", "score") + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew", "searchScore") .containsExactly( tuple(findPick.getId(), findPick.getTitle().getTitle(), @@ -324,8 +324,7 @@ private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Co private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, Count pickcommentTotalCount, String thumbnailUrl, String author, - List pickVotes - ) { + List pickVotes) { Pick pick = Pick.builder() .member(member) diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java index 50e37eb8..a46972b7 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java @@ -1,5 +1,14 @@ package com.dreamypatisiel.devdevdev.web.controller.pick; +import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; @@ -10,7 +19,11 @@ import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainOptionResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -20,17 +33,6 @@ import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; -import java.nio.charset.StandardCharsets; -import java.util.List; - -import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - class PickControllerV2Test extends SupportControllerTest { @MockBean @@ -118,6 +120,7 @@ void getPicksMainByMember() throws Exception { .andExpect(jsonPath("$.data.pageable.offset").isNumber()) .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.totalElements").isNumber()) .andExpect(jsonPath("$.data.first").isBoolean()) .andExpect(jsonPath("$.data.last").isBoolean()) .andExpect(jsonPath("$.data.size").isNumber()) @@ -195,5 +198,102 @@ void getSimilarPicks() throws Exception { .andExpect(jsonPath("$.datas.[2].similarity").isNumber()) .andExpect(jsonPath("$.datas.[2].isNew").isBoolean()); } + + @Test + @DisplayName("회원이 픽픽픽 검색을 조회한다.") + void searchPicksMain() throws Exception { + // given + PickMainOptionResponseV2 pickMainOptionResponse1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("필요하지!")) + .percent(new BigDecimal("50.0")) + .isPicked(false) + .content("검색 좋아") + .thumbnailImageUrl("https://example.com/image1.png") + .build(); + + PickMainOptionResponseV2 pickMainOptionResponse2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("굳이?")) + .percent(new BigDecimal("50.0")) + .isPicked(true) + .content("검색할 일이 있을까?") + .thumbnailImageUrl("https://example.com/image2.png") + .build(); + + PickMainSearchResponseV2 pickMainSearchResponseV2 = PickMainSearchResponseV2.searchBuilder() + .id(1L) + .title(new Title("검색기능 필요해?")) + .voteTotalCount(new Count(100_000L)) + .commentTotalCount(new Count(99_109L)) + .viewTotalCount(new Count(81_229L)) + .popularScore(new Count(1000)) + .pickOptions(List.of(pickMainOptionResponse1, pickMainOptionResponse2)) + .isVoted(true) + .isNew(true) + .searchScore(60.0) + .build(); + + Pageable pageable = PageRequest.of(0, 10); + + SliceCustom response = new SliceCustom<>(List.of(pickMainSearchResponseV2), + pageable, 1L); + + // when + when(memberPickServiceV2.findPickMainSearch(any(), any(), any(), any(), any(), any())).thenReturn(response); + + // then + mockMvc.perform(get("/devdevdev/api/v2/picks/search") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("searchScore", "10") + .queryParam("keyword", "검색") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(SUCCESS.name())) + .andExpect(jsonPath("$.data").isNotEmpty()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.[0].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].title").isString()) + .andExpect(jsonPath("$.data.content.[0].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].viewTotalCount").isNumber()) + .andExpect(jsonPath("$.data.content.[0].popularScore").isNumber()) + .andExpect(jsonPath("$.data.content.[0].isVoted").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].searchScore").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions").isArray()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].title").isString()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].percent").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[0].isPicked").isBoolean()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].id").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].title").isString()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].percent").isNumber()) + .andExpect(jsonPath("$.data.content.[0].pickOptions.[1].isPicked").isBoolean()) + .andExpect(jsonPath("$.data.pageable").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.pageNumber").isNumber()) + .andExpect(jsonPath("$.data.pageable.pageSize").isNumber()) + .andExpect(jsonPath("$.data.pageable.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.pageable.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.pageable.offset").isNumber()) + .andExpect(jsonPath("$.data.pageable.paged").isBoolean()) + .andExpect(jsonPath("$.data.pageable.unpaged").isBoolean()) + .andExpect(jsonPath("$.data.totalElements").isNumber()) + .andExpect(jsonPath("$.data.first").isBoolean()) + .andExpect(jsonPath("$.data.last").isBoolean()) + .andExpect(jsonPath("$.data.size").isNumber()) + .andExpect(jsonPath("$.data.number").isNumber()) + .andExpect(jsonPath("$.data.sort").isNotEmpty()) + .andExpect(jsonPath("$.data.sort.empty").isBoolean()) + .andExpect(jsonPath("$.data.sort.sorted").isBoolean()) + .andExpect(jsonPath("$.data.sort.unsorted").isBoolean()) + .andExpect(jsonPath("$.data.numberOfElements").isNumber()) + .andExpect(jsonPath("$.data.empty").isBoolean()); + } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java index 1f616eaf..8c4adfb9 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -1,5 +1,31 @@ package com.dreamypatisiel.devdevdev.web.docs; +import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.authenticationType; +import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.pickSortType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.OBJECT; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; @@ -9,10 +35,13 @@ import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainOptionResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainSearchResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -20,26 +49,7 @@ import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.test.web.servlet.ResultActions; - -import java.nio.charset.StandardCharsets; -import java.util.List; - -import static com.dreamypatisiel.devdevdev.global.constant.SecurityConstant.AUTHORIZATION_HEADER; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.authenticationType; -import static com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.pickSortType; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.JsonFieldType.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; class PickControllerV2DocsTest extends SupportControllerDocsTest { @@ -134,14 +144,16 @@ void getPicksMainByMember() throws Exception { fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), - fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING).description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING) + .description("픽픽픽 썸네일 이미지 (NEW)"), fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), fieldWithPath("data.content[].pickOptions[].id").type(NUMBER).description("픽픽픽 옵션 아이디"), fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), - fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING).description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING) + .description("픽픽픽 썸네일 이미지 (NEW)"), fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), @@ -242,5 +254,138 @@ void getSimilarPicks() throws Exception { ) )); } + + @Test + @DisplayName("회원이 픽픽픽 검색을 조회한다.") + void searchPicksMain() throws Exception { + // given + PickMainOptionResponseV2 pickMainOptionResponse1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("필요하지!")) + .percent(new BigDecimal("49.0")) + .isPicked(false) + .content("검색 좋아") + .thumbnailImageUrl("https://example.com/image1.png") + .build(); + + PickMainOptionResponseV2 pickMainOptionResponse2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("굳이?")) + .percent(new BigDecimal("51.0")) + .isPicked(true) + .content("검색할 일이 있을까?") + .thumbnailImageUrl("https://example.com/image2.png") + .build(); + + PickMainSearchResponseV2 pickMainSearchResponseV2 = PickMainSearchResponseV2.searchBuilder() + .id(1L) + .title(new Title("검색기능 필요해?")) + .voteTotalCount(new Count(100_000L)) + .commentTotalCount(new Count(99_109L)) + .viewTotalCount(new Count(81_229L)) + .popularScore(new Count(1000)) + .pickOptions(List.of(pickMainOptionResponse1, pickMainOptionResponse2)) + .isVoted(true) + .isNew(true) + .searchScore(60.0) + .build(); + + Pageable pageable = PageRequest.of(0, 10); + + SliceCustom response = new SliceCustom<>(List.of(pickMainSearchResponseV2), + pageable, 1L); + + // when + when(memberPickServiceV2.findPickMainSearch(any(), any(), any(), any(), any(), any())).thenReturn(response); + + // then + ResultActions actions = mockMvc.perform(MockMvcRequestBuilders.get("/devdevdev/api/v2/picks/search") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("searchScore", "10") + .queryParam("keyword", "검색") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-search-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), + queryParameters( + parameterWithName("pickId").optional().description("픽픽픽 아이디"), + parameterWithName("keyword").optional().description("픽픽픽 검색어"), + parameterWithName("searchScore").optional().description("게시글 검색 점수"), + parameterWithName("size").optional().description("조회되는 데이터 수") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + + fieldWithPath("data.content").type(ARRAY).description("픽픽픽 메인 배열"), + fieldWithPath("data.content[].id").type(NUMBER).description("픽픽픽 아이디"), + fieldWithPath("data.content[].title").type(STRING).description("픽픽픽 제목"), + fieldWithPath("data.content[].voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수"), + fieldWithPath("data.content[].commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수"), + fieldWithPath("data.content[].viewTotalCount").type(NUMBER).description("픽픽픽 조회 수"), + fieldWithPath("data.content[].popularScore").type(NUMBER).description("픽픽픽 인기점수"), + fieldWithPath("data.content[].isVoted").attributes(authenticationType()).type(BOOLEAN) + .description("픽픽픽 투표 여부(익명 사용자는 필드가 없다.)"), + fieldWithPath("data.content[].isNew").attributes(authenticationType()).type(BOOLEAN) + .description("일주일 이내 게시글 여부 (NEW)"), + fieldWithPath("data.content[].searchScore").type(NUMBER).description("검색 결과 점수"), + + fieldWithPath("data.content[].pickOptions").type(ARRAY).description("픽픽픽 옵션 배열"), + fieldWithPath("data.content[].pickOptions[].id").type(NUMBER).description("픽픽픽 옵션 아이디"), + fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), + fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), + fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING) + .description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( + BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), + fieldWithPath("data.content[].pickOptions[].id").type(NUMBER).description("픽픽픽 옵션 아이디"), + fieldWithPath("data.content[].pickOptions[].title").type(STRING).description("픽픽픽 옵션 제목"), + fieldWithPath("data.content[].pickOptions[].percent").type(NUMBER).description("픽픽픽 옵션 투표율(%)"), + fieldWithPath("data.content[].pickOptions[].content").type(STRING).description("픽픽픽 옵션 내용 (NEW)"), + fieldWithPath("data.content[].pickOptions[].thumbnailImageUrl").type(STRING) + .description("픽픽픽 썸네일 이미지 (NEW)"), + fieldWithPath("data.content[].pickOptions[].isPicked").attributes(authenticationType()).type( + BOOLEAN).description("픽픽픽 옵션 투표 여부(익명 사용자는 필드가 없다.)"), + + fieldWithPath("data.pageable").type(OBJECT).description("픽픽픽 메인 페이지네이션 정보"), + fieldWithPath("data.pageable.pageNumber").type(NUMBER).description("페이지 번호"), + fieldWithPath("data.pageable.pageSize").type(NUMBER).description("페이지 사이즈"), + + fieldWithPath("data.pageable.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.pageable.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.pageable.sort.sorted").type(BOOLEAN).description("정렬 여부"), + fieldWithPath("data.pageable.sort.unsorted").type(BOOLEAN).description("비정렬 여부"), + + fieldWithPath("data.pageable.offset").type(NUMBER).description("페이지 오프셋 (페이지 크기 * 페이지 번호)"), + fieldWithPath("data.pageable.paged").type(BOOLEAN).description("페이지 정보 포함 여부"), + fieldWithPath("data.pageable.unpaged").type(BOOLEAN).description("페이지 정보 비포함 여부"), + + fieldWithPath("data.first").type(BOOLEAN).description("현재 페이지가 첫 페이지 여부"), + fieldWithPath("data.last").type(BOOLEAN).description("현재 페이지가 마지막 페이지 여부"), + fieldWithPath("data.size").type(NUMBER).description("페이지 크기"), + fieldWithPath("data.number").type(NUMBER).description("현재 페이지"), + + fieldWithPath("data.sort").type(OBJECT).description("정렬 정보"), + fieldWithPath("data.sort.empty").type(BOOLEAN).description("정렬 정보가 비어있는지 여부"), + fieldWithPath("data.sort.sorted").type(BOOLEAN).description("정렬 상태 여부"), + fieldWithPath("data.sort.unsorted").type(BOOLEAN).description("비정렬 상태 여부"), + fieldWithPath("data.numberOfElements").type(NUMBER).description("현재 페이지 데이터 수"), + fieldWithPath("data.totalElements").type(NUMBER).description("전체 픽픽픽 데이터 수 (NEW)"), + fieldWithPath("data.empty").type(BOOLEAN).description("현재 빈 페이지 여부") + ) + )); + } } From 8ec439ef4cfc509317b5a326a6a3037079100832 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sun, 2 Nov 2025 17:52:55 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(GuestPickServiceV2):=20findPickMainSea?= =?UTF-8?q?rch()=20@Transactional=20=EB=88=84=EB=9D=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devdevdev/domain/service/pick/GuestPickServiceV2.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index dc51a04e..ab240a5b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -85,6 +85,7 @@ public List findTop3SimilarPicksV2(Long pickId) { return super.findTop3SimilarPicksV2(pickId); } + @Transactional @Override public Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, String keyword, String anonymousMemberId, From 0fec0fcffe150559595106e796e27bcbf4eb65f4 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sun, 2 Nov 2025 17:57:37 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(SecurityConstant):=20pick=20v2=20endpo?= =?UTF-8?q?int=20whitelist=20url=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devdevdev/global/constant/SecurityConstant.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java index 798895ae..f381bcd4 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/constant/SecurityConstant.java @@ -32,6 +32,7 @@ public class SecurityConstant { "/devdevdev/api/v1/test/**", "/devdevdev/api/v1/token/**", "/devdevdev/api/v1/picks/**", + "/devdevdev/api/v2/picks/**", "/devdevdev/api/v1/articles/**", "/devdevdev/api/v1/keywords/**", "/devdevdev/api/v1/subscriptions/**", @@ -70,6 +71,7 @@ public class SecurityConstant { "/devdevdev/api/v1/login/oauth2/code/kakao", "/devdevdev/api/v1/token/**", "/devdevdev/api/v1/picks/**", + "/devdevdev/api/v2/picks/**", "/devdevdev/api/v1/articles/**", "/devdevdev/api/v1/keywords/**", "/devdevdev/api/v1/subscriptions/**", From f124d2d5539ac4061927832f78df42797122b825 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Wed, 5 Nov 2025 22:54:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/mapper/pick/Pick.xml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/resources/mapper/pick/Pick.xml b/src/main/resources/mapper/pick/Pick.xml index 4a29d286..481198a8 100644 --- a/src/main/resources/mapper/pick/Pick.xml +++ b/src/main/resources/mapper/pick/Pick.xml @@ -30,6 +30,7 @@ ) as max_total_score from devdevdev.pick p where match(p.title) against(#{keyword} in natural language mode) + and p.content_status = 'APPROVAL' union @@ -40,8 +41,9 @@ coalesce(max(least(match(po.pick_option_contents) against(#{keyword} in natural language mode), 100000)), 0) ) as max_total_score from devdevdev.pick p - inner join devdevdev.pick_option po on p.id = po.pick_id + inner join devdevdev.pick_option po on p.id = po.pick_id where match(po.title) against(#{keyword} in natural language mode) + and p.content_status = 'APPROVAL' group by p.id union @@ -53,8 +55,9 @@ coalesce(max(least(match(po.pick_option_contents) against(#{keyword} in natural language mode), 100000)), 0) ) as max_total_score from devdevdev.pick p - inner join devdevdev.pick_option po on p.id = po.pick_id + inner join devdevdev.pick_option po on p.id = po.pick_id where match(po.pick_option_contents) against(#{keyword} in natural language mode) + and p.content_status = 'APPROVAL' group by p.id ) as pick_search_results