From ee2e36393a36b3b174208fd8d97b5ece7d615e0c Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 19 Oct 2025 16:37:55 +0900 Subject: [PATCH 01/22] =?UTF-8?q?fix(pick):=20PickService=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/pick/GuestPickService.java | 2 +- .../service/pick/GuestPickServiceV2.java | 51 +++++++++++++ .../service/pick/MemberPickService.java | 2 +- .../service/pick/MemberPickServiceV2.java | 52 +++++++++++++ .../service/pick/PickFacadeService.java | 6 +- .../domain/service/pick/PickService.java | 39 +--------- .../service/pick/PickServiceStrategy.java | 15 ++-- .../domain/service/pick/PickServiceV1.java | 39 ++++++++++ .../domain/service/pick/PickServiceV2.java | 20 +++++ .../devdevdev/web/controller/ApiVersion.java | 6 ++ .../web/controller/pick/PickController.java | 21 ++--- .../web/controller/pick/PickControllerV2.java | 76 +++++++++++++++++++ 12 files changed, 274 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV1.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/controller/ApiVersion.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java index afc2f82d..d6d7f469 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickService.java @@ -51,7 +51,7 @@ @Service @Transactional(readOnly = true) -public class GuestPickService extends PickCommonService implements PickService { +public class GuestPickService extends PickCommonService implements PickServiceV1 { public static final String INVALID_ANONYMOUS_CAN_NOT_USE_THIS_FUNCTION_MESSAGE = "비회원은 현재 해당 기능을 이용할 수 없습니다."; 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 new file mode 100644 index 00000000..e24f1682 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java @@ -0,0 +1,51 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; +import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; +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.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +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 { + public GuestPickServiceV2(EmbeddingsService embeddingsService, + PickBestCommentsPolicy pickBestCommentsPolicy, + PickPopularScorePolicy pickPopularScorePolicy, + TimeProvider timeProvider, + PickRepository pickRepository, + PickCommentRepository pickCommentRepository, + PickCommentRecommendRepository pickCommentRecommendRepository) { + super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, + pickCommentRepository, pickCommentRecommendRepository); + } + + @Override + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { + return null; + } + + @Override + public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { + return null; + } + + @Override + public List findTop3SimilarPicks(Long pickId) { + return null; + } + +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickService.java index d907a0fb..9202c4f0 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickService.java @@ -72,7 +72,7 @@ @Service @Transactional(readOnly = true) -public class MemberPickService extends PickCommonService implements PickService { +public class MemberPickService extends PickCommonService implements PickServiceV1 { public static final String FIRST_PICK_OPTION_IMAGE = "firstPickOptionImage"; public static final String SECOND_PICK_OPTION_IMAGE = "secondPickOptionImage"; 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 new file mode 100644 index 00000000..5102de41 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2.java @@ -0,0 +1,52 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; +import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; +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.global.common.TimeProvider; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +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 { + + public MemberPickServiceV2(EmbeddingsService embeddingsService, + PickBestCommentsPolicy pickBestCommentsPolicy, + PickPopularScorePolicy pickPopularScorePolicy, + TimeProvider timeProvider, + PickRepository pickRepository, + PickCommentRepository pickCommentRepository, + PickCommentRecommendRepository pickCommentRecommendRepository) { + super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, + pickCommentRepository, pickCommentRecommendRepository); + } + + @Override + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { + return null; + } + + @Override + public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { + return null; + } + + @Override + public List findTop3SimilarPicks(Long pickId) { + return null; + } + +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java index 6ea030fc..416cd6e7 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickFacadeService.java @@ -18,7 +18,7 @@ @RequiredArgsConstructor public class PickFacadeService { - private PickService pickService; + private PickServiceV1 pickService; private final EmbeddingsService embeddingsService; @Transactional @@ -54,12 +54,12 @@ public PickModifyResponse modifyPickAndSaveEmbedding(Long pickId, return response; } - public void injectPickService(PickService pickService) { + public void injectPickService(PickServiceV1 pickService) { validatePickService(pickService); this.pickService = pickService; } - private void validatePickService(PickService pickService) { + private void validatePickService(PickServiceV1 pickService) { if (ObjectUtils.isEmpty(pickService)) { throw new IllegalStateException("Pick service cannot be empty"); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickService.java index 77455529..d93220dc 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickService.java @@ -1,39 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.dto.VotePickOptionDto; -import com.dreamypatisiel.devdevdev.web.dto.request.pick.ModifyPickRequest; -import com.dreamypatisiel.devdevdev.web.dto.request.pick.RegisterPickRequest; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickModifyResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickRegisterResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickUploadImageResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickResponse; -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.web.multipart.MultipartFile; - +/** + * PickService의 공통 인터페이스 + * 각 버전별 인터페이스에서 상속받아 사용 + */ public interface PickService { - Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, - Authentication authentication); - - PickUploadImageResponse uploadImages(String name, List images); - - void deleteImage(Long pickOptionImageId); - - PickRegisterResponse registerPick(RegisterPickRequest registerPickRequest, Authentication authentication); - - PickModifyResponse modifyPick(Long pickId, ModifyPickRequest modifyPickRequest, Authentication authentication); - - PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); - - VotePickResponse votePickOption(VotePickOptionDto votePickOptionDto, Authentication authentication); - - void deletePick(Long pickId, Authentication authentication); - - List findTop3SimilarPicks(Long pickId); } \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategy.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategy.java index 17b4d73f..542a55c9 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategy.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategy.java @@ -1,6 +1,7 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; @@ -11,11 +12,15 @@ public class PickServiceStrategy { private final ApplicationContext applicationContext; - public PickService getPickService() { - if (AuthenticationMemberUtils.isAnonymous()) { - return applicationContext.getBean(GuestPickService.class); - } - return applicationContext.getBean(MemberPickService.class); + public PickService getPickService(ApiVersion apiVersion) { + return switch (apiVersion) { + case V1 -> AuthenticationMemberUtils.isAnonymous() + ? applicationContext.getBean(GuestPickService.class) + : applicationContext.getBean(MemberPickService.class); + case V2 -> AuthenticationMemberUtils.isAnonymous() + ? applicationContext.getBean(GuestPickServiceV2.class) + : applicationContext.getBean(MemberPickServiceV2.class); + }; } public PickCommentService pickCommentService() { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV1.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV1.java new file mode 100644 index 00000000..da150dc8 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV1.java @@ -0,0 +1,39 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.dto.VotePickOptionDto; +import com.dreamypatisiel.devdevdev.web.dto.request.pick.ModifyPickRequest; +import com.dreamypatisiel.devdevdev.web.dto.request.pick.RegisterPickRequest; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickModifyResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickRegisterResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickUploadImageResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.VotePickResponse; +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.web.multipart.MultipartFile; + +public interface PickServiceV1 extends PickService { + Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, + Authentication authentication); + + PickUploadImageResponse uploadImages(String name, List images); + + void deleteImage(Long pickOptionImageId); + + PickRegisterResponse registerPick(RegisterPickRequest registerPickRequest, Authentication authentication); + + PickModifyResponse modifyPick(Long pickId, ModifyPickRequest modifyPickRequest, Authentication authentication); + + PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); + + VotePickResponse votePickOption(VotePickOptionDto votePickOptionDto, Authentication authentication); + + void deletePick(Long pickId, Authentication authentication); + + List findTop3SimilarPicks(Long pickId); +} 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 new file mode 100644 index 00000000..1d0b2818 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceV2.java @@ -0,0 +1,20 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +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); + + PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); + + List findTop3SimilarPicks(Long pickId); +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/ApiVersion.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/ApiVersion.java new file mode 100644 index 00000000..132d46f4 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/ApiVersion.java @@ -0,0 +1,6 @@ +package com.dreamypatisiel.devdevdev.web.controller; + +public enum ApiVersion { + V1, + V2 +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java index ecef1fd7..6450d49c 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickController.java @@ -4,8 +4,8 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.domain.service.pick.PickFacadeService; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickService; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV1; import com.dreamypatisiel.devdevdev.domain.service.pick.dto.VotePickOptionDto; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.global.utils.HttpRequestUtils; @@ -13,6 +13,7 @@ import com.dreamypatisiel.devdevdev.openai.data.response.Embedding; import com.dreamypatisiel.devdevdev.openai.data.response.OpenAIResponse; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; +import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.dto.request.pick.ModifyPickRequest; import com.dreamypatisiel.devdevdev.web.dto.request.pick.RegisterPickRequest; import com.dreamypatisiel.devdevdev.web.dto.request.pick.VotePickOptionRequest; @@ -69,7 +70,7 @@ public ResponseEntity>> getPicksMain( Authentication authentication = AuthenticationMemberUtils.getAuthentication(); String anonymousMemberId = HttpRequestUtils.getHeaderValue(HEADER_ANONYMOUS_MEMBER_ID); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, authentication); @@ -83,7 +84,7 @@ public ResponseEntity> uploadPickOptionIm @RequestParam String name, @RequestPart List pickOptionImages) { - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); PickUploadImageResponse pickUploadImageResponse = pickService.uploadImages(name, pickOptionImages); return ResponseEntity.ok(BasicResponse.success(pickUploadImageResponse)); @@ -93,7 +94,7 @@ public ResponseEntity> uploadPickOptionIm @DeleteMapping("/picks/image/{pickOptionImageId}") public ResponseEntity> deletePickImage(@PathVariable Long pickOptionImageId) { - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); pickService.deleteImage(pickOptionImageId); return ResponseEntity.ok(BasicResponse.success()); @@ -110,7 +111,7 @@ public ResponseEntity> registerPick( OpenAIResponse embeddingOpenAIResponse = embeddingRequestHandler.postEmbeddings( EmbeddingRequest.createTextEmbedding3Small(registerPickRequest.getPickTitle())); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); pickFacadeService.injectPickService(pickService); @@ -133,7 +134,7 @@ public ResponseEntity> modifyPick( OpenAIResponse embeddingOpenAIResponse = embeddingRequestHandler.postEmbeddings( EmbeddingRequest.createTextEmbedding3Small(modifyPickRequest.getPickTitle())); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); pickFacadeService.injectPickService(pickService); @@ -150,7 +151,7 @@ public ResponseEntity> getPickDetail(@PathVari @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { Authentication authentication = AuthenticationMemberUtils.getAuthentication(); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); PickDetailResponse response = pickService.findPickDetail(pickId, anonymousMemberId, authentication); return ResponseEntity.ok(BasicResponse.success(response)); @@ -164,7 +165,7 @@ public ResponseEntity> votePickOption( Authentication authentication = AuthenticationMemberUtils.getAuthentication(); String anonymousMemberId = HttpRequestUtils.getHeaderValue(HEADER_ANONYMOUS_MEMBER_ID); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); VotePickOptionDto votePickOptionDto = VotePickOptionDto.of(votePickOptionRequest, anonymousMemberId); VotePickResponse response = pickService.votePickOption(votePickOptionDto, authentication); @@ -177,7 +178,7 @@ public ResponseEntity> votePickOption( public ResponseEntity> deletePick(@PathVariable Long pickId) { Authentication authentication = AuthenticationMemberUtils.getAuthentication(); - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); pickService.deletePick(pickId, authentication); return ResponseEntity.ok(BasicResponse.success()); @@ -187,7 +188,7 @@ public ResponseEntity> deletePick(@PathVariable Long pickId) @GetMapping("picks/{pickId}/similarties") public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { - PickService pickService = pickServiceStrategy.getPickService(); + PickServiceV1 pickService = (PickServiceV1) pickServiceStrategy.getPickService(ApiVersion.V1); List response = pickService.findTop3SimilarPicks(pickId); return ResponseEntity.ok(BasicResponse.success(response)); 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 new file mode 100644 index 00000000..ef08db98 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2.java @@ -0,0 +1,76 @@ +package com.dreamypatisiel.devdevdev.web.controller.pick; + +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickFacadeService; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.global.utils.HttpRequestUtils; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; +import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; +import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort.Direction; +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 static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; + +@Tag(name = "픽픽픽 API", description = "") +@RestController +@RequiredArgsConstructor +@RequestMapping("/devdevdev/api/v2") +public class PickControllerV2 { + + private final PickServiceStrategy pickServiceStrategy; + + @Operation(summary = "픽픽픽 메인 조회", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") + @GetMapping("/picks") + public ResponseEntity>> getPicksMain( + @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable, + @RequestParam(required = false) Long pickId, + @RequestParam(required = false) PickSort pickSort) { + + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + String anonymousMemberId = HttpRequestUtils.getHeaderValue(HEADER_ANONYMOUS_MEMBER_ID); + + PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); + Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, + authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "픽픽픽 상세 조회", description = "픽픽픽 상세 페이지를 조회합니다.") + @GetMapping("/picks/{pickId}") + public ResponseEntity> getPickDetail(@PathVariable Long pickId, + @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + + PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); + PickDetailResponse response = pickService.findPickDetail(pickId, anonymousMemberId, authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } + + @Operation(summary = "나도 고민했는데 픽픽픽", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") + @GetMapping("picks/{pickId}/similarties") + public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { + + PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); + List response = pickService.findTop3SimilarPicks(pickId); + + return ResponseEntity.ok(BasicResponse.success(response)); + } +} From d8835246d674d8c275fe71f69322dcf347036c77 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 19 Oct 2025 16:42:40 +0900 Subject: [PATCH 02/22] =?UTF-8?q?test:=20PickServiceStrategyTest=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/pick/PickServiceStrategyTest.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategyTest.java diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategyTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategyTest.java new file mode 100644 index 00000000..1858d907 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickServiceStrategyTest.java @@ -0,0 +1,98 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.dreamypatisiel.devdevdev.domain.entity.enums.SocialType; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.UserPrincipal; +import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class PickServiceStrategyTest { + + @Autowired + PickServiceStrategy pickServiceStrategy; + + @Mock + Authentication authentication; + + @Mock + SecurityContext securityContext; + + @Test + @DisplayName("V1이면서 익명 사용자이면 GuestPickService를 반환한다.") + void getPickService_V1_Anonymous() { + // given + when(authentication.getPrincipal()).thenReturn("anonymousUser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + // when + PickService result = pickServiceStrategy.getPickService(ApiVersion.V1); + + // then + assertThat(result).isInstanceOf(GuestPickService.class); + } + + @Test + @DisplayName("V1이면서 로그인 사용자이면 MemberPickService를 반환한다.") + void getPickService_V1_Member() { + // given + UserPrincipal userPrincipal = UserPrincipal.createByEmailAndRoleAndSocialType( + "email@test.com", "ROLE_USER", SocialType.KAKAO.name()); + + when(authentication.getPrincipal()).thenReturn(userPrincipal); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + // when + PickService result = pickServiceStrategy.getPickService(ApiVersion.V1); + + // then + assertThat(result).isInstanceOf(MemberPickService.class); + } + + @Test + @DisplayName("V2이면서 익명 사용자이면 GuestPickServiceV2를 반환한다.") + void getPickService_V2_Anonymous() { + // given + when(authentication.getPrincipal()).thenReturn("anonymousUser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + // when + PickService result = pickServiceStrategy.getPickService(ApiVersion.V2); + + // then + assertThat(result).isInstanceOf(GuestPickServiceV2.class); + } + + @Test + @DisplayName("V2이면서 로그인 사용자이면 MemberPickServiceV2를 반환한다.") + void getPickService_V2_Member() { + // given + UserPrincipal userPrincipal = UserPrincipal.createByEmailAndRoleAndSocialType( + "email@test.com", "ROLE_USER", SocialType.KAKAO.name()); + + when(authentication.getPrincipal()).thenReturn(userPrincipal); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + // when + PickService result = pickServiceStrategy.getPickService(ApiVersion.V2); + + // then + assertThat(result).isInstanceOf(MemberPickServiceV2.class); + } +} From f82613c65658001eb783f2d03b9072929daa2494 Mon Sep 17 00:00:00 2001 From: soyoung Date: Wed, 22 Oct 2025 23:51:16 +0900 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A3=BC=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=82=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20util=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devdevdev/global/utils/TimeUtils.java | 20 ++++++ .../devdevdev/global/utils/TimeUtilsTest.java | 61 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java new file mode 100644 index 00000000..20ca6767 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java @@ -0,0 +1,20 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import java.time.LocalDateTime; + +/** + * 시간 관련 유틸리티 클래스 + */ +public class TimeUtils { + + /** + * 주어진 시간이 현재로부터 일주일 이내인지 확인합니다. + * + * @param createdAt 확인할 시간 + * @return 일주일 이내라면 true, 그렇지 않다면 false + */ + public static boolean isWithinOneWeek(LocalDateTime createdAt) { + LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); + return createdAt.isAfter(oneWeekAgo); + } +} diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java new file mode 100644 index 00000000..0ff6c2a1 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java @@ -0,0 +1,61 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TimeUtils 테스트") +class TimeUtilsTest { + + @ParameterizedTest + @CsvSource({ + "6, DAYS", // 6일 전 + "1, HOURS", // 1시간 전 + "1, MINUTES", // 1분 전 + "6, DAYS", // 6일 23시간 전 + }) + @DisplayName("일주일 이내의 시간은 true를 반환한다") + void isWithinOneWeek_true(int amount, String unit) { + // given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime testTime = createTestTime(now, amount, unit); + + // when // then + assertThat(TimeUtils.isWithinOneWeek(testTime)) + .isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "1, WEEKS", // 일주일 전 + "8, DAYS", // 8일 전 + "2, WEEKS", // 2주일 전 + "1, MONTHS" // 1개월 전 + }) + @DisplayName("일주일을 초과한 시간은 false를 반환한다") + void isWithinOneWeek_false(int amount, String unit) { + // given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime testTime = createTestTime(now, amount, unit); + + // when // then + assertThat(TimeUtils.isWithinOneWeek(testTime)) + .isFalse(); + } + + private LocalDateTime createTestTime(LocalDateTime baseTime, int amount, String unit) { + return switch (unit) { + case "DAYS" -> baseTime.minusDays(amount); + case "HOURS" -> baseTime.minusHours(amount); + case "MINUTES" -> baseTime.minusMinutes(amount); + case "WEEKS" -> baseTime.minusWeeks(amount); + case "MONTHS" -> baseTime.minusMonths(amount); + default -> baseTime; + }; + } +} From f0cf8087c9475db568803f6674a0bb7ce2a55c84 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 15:35:15 +0900 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20=ED=94=BD=ED=94=BD=ED=94=BD=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20API,=20=EB=82=98=EA=B3=A0=ED=94=BD=20API?= =?UTF-8?q?=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/pick/PickRepository.java | 2 + .../service/pick/GuestPickServiceV2.java | 68 ++++++++++----- .../service/pick/MemberPickServiceV2.java | 62 +++++++++----- .../service/pick/PickCommonService.java | 29 +++++++ .../domain/service/pick/PickServiceV2.java | 13 ++- .../web/controller/pick/PickControllerV2.java | 35 +++----- .../pick/PickMainOptionResponseV2.java | 78 +++++++++++++++++ .../dto/response/pick/PickMainResponseV2.java | 84 +++++++++++++++++++ .../pick/PickMainSliceResponseV2.java | 19 +++++ .../response/pick/SimilarPickResponseV2.java | 42 ++++++++++ .../web/dto/util/PickResponseUtils.java | 5 ++ 11 files changed, 362 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.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 1ff6838b..e8129d1a 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,4 +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/service/pick/GuestPickServiceV2.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2.java index e24f1682..71a6f8e5 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 @@ -1,16 +1,18 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; +import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +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.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.repository.pick.*; +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.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -22,30 +24,52 @@ @Service @Transactional(readOnly = true) public class GuestPickServiceV2 extends PickCommonService implements PickServiceV2 { - public GuestPickServiceV2(EmbeddingsService embeddingsService, + + private final AnonymousMemberService anonymousMemberService; + + public GuestPickServiceV2(PickRepository pickRepository, EmbeddingsService embeddingsService, PickBestCommentsPolicy pickBestCommentsPolicy, - PickPopularScorePolicy pickPopularScorePolicy, - TimeProvider timeProvider, - PickRepository pickRepository, PickCommentRepository pickCommentRepository, - PickCommentRecommendRepository pickCommentRecommendRepository) { + PickCommentRecommendRepository pickCommentRecommendRepository, + PickPopularScorePolicy pickPopularScorePolicy, + TimeProvider timeProvider, AnonymousMemberService anonymousMemberService) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, pickCommentRepository, pickCommentRecommendRepository); + this.anonymousMemberService = anonymousMemberService; } + /** + * 픽픽픽 메인 조회 V2 + */ + @Transactional @Override - public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { - return null; - } + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, + String anonymousMemberId, Authentication authentication) { + // 익명 사용자 호출인지 확인 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); - @Override - public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { - return null; + // anonymousMemberId 검증 + AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); + + // 픽픽픽 조회 + Slice picks = pickRepository.findPicksByCursor(pageable, pickId, pickSort); + + // 데이터 가공 + List pickMainResponse = picks.stream() + .map(pick -> PickMainResponseV2.of(pick, anonymousMember)) + .toList(); + + // 전체 픽픽픽 개수 조회 + long totalElements = pickRepository.countByContentStatus(ContentStatus.APPROVAL); + + return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 나도 고민했는데 픽픽픽 V2 + */ @Override - public List findTop3SimilarPicks(Long pickId) { - return null; + public List findTop3SimilarPicksV2(Long pickId) { + return super.findTop3SimilarPicksV2(pickId); } - -} +} \ 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 5102de41..eda4719d 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 @@ -1,16 +1,20 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; +import com.dreamypatisiel.devdevdev.domain.entity.Member; +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +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.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.global.common.MemberProvider; import com.dreamypatisiel.devdevdev.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -23,30 +27,48 @@ @Transactional(readOnly = true) public class MemberPickServiceV2 extends PickCommonService implements PickServiceV2 { - public MemberPickServiceV2(EmbeddingsService embeddingsService, - PickBestCommentsPolicy pickBestCommentsPolicy, - PickPopularScorePolicy pickPopularScorePolicy, - TimeProvider timeProvider, - PickRepository pickRepository, + private final MemberProvider memberProvider; + + public MemberPickServiceV2(EmbeddingsService embeddingsService, PickRepository pickRepository, + MemberProvider memberProvider, PickCommentRepository pickCommentRepository, - PickCommentRecommendRepository pickCommentRecommendRepository) { + PickCommentRecommendRepository pickCommentRecommendRepository, + PickPopularScorePolicy pickPopularScorePolicy, + PickBestCommentsPolicy pickBestCommentsPolicy, TimeProvider timeProvider) { super(embeddingsService, pickBestCommentsPolicy, pickPopularScorePolicy, timeProvider, pickRepository, - pickCommentRepository, pickCommentRecommendRepository); + pickCommentRepository, + pickCommentRecommendRepository); + this.memberProvider = memberProvider; } + /** + * 픽픽픽 메인 조회 V2 + */ @Override - public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication) { - return null; - } + public Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, + String anonymousMemberId, Authentication authentication) { + // 픽픽픽 조회 + Slice picks = pickRepository.findPicksByCursor(pageable, pickId, pickSort); - @Override - public PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { - return null; + // 회원 조회 + Member member = memberProvider.getMemberByAuthentication(authentication); + + // 데이터 가공 + List pickMainResponse = picks.stream() + .map(pick -> PickMainResponseV2.of(pick, member)) + .toList(); + + // 전체 픽픽픽 개수 조회 + long totalElements = pickRepository.countByContentStatus(ContentStatus.APPROVAL); + + return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 나도 고민했는데 픽픽픽 V2 + */ @Override - public List findTop3SimilarPicks(Long pickId) { - return null; + public List findTop3SimilarPicksV2(Long pickId) { + return super.findTop3SimilarPicksV2(pickId); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java index 2ee55318..8fcc1d0b 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonService.java @@ -36,6 +36,8 @@ import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nullable; + +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -86,6 +88,33 @@ public List findTop3SimilarPicks(Long pickId) { .toList(); } + public List findTop3SimilarPicksV2(Long pickId) { + + // 픽픽픽 조회 + Pick findPick = pickRepository.findById(pickId) + .orElseThrow(() -> new NotFoundException(INVALID_NOT_FOUND_PICK_MESSAGE)); + + // 픽픽픽 게시글의 승인 상태가 아니면 + if (!findPick.isTrueContentStatus(ContentStatus.APPROVAL)) { + throw new IllegalArgumentException(INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE); + } + + // 임베딩 값이 없으면 500 예외 발생 + if (ObjectUtils.isEmpty(findPick.getEmbeddings())) { + throw new InternalServerException(); + } + + // 유사도를 계산한 픽픽픽 조회 + List pickWithSimilarityDto = embeddingsService.getPicksWithSimilarityDtoExcludeTargetPick( + findPick); + + return pickWithSimilarityDto.stream() + .map(SimilarPickResponseV2::from) + .sorted(Comparator.comparingDouble(SimilarPickResponseV2::getSimilarity).reversed()) // 내림차순 + .limit(SIMILARITY_PICK_MAX_COUNT) + .toList(); + } + // 픽픽픽 게시글의 승인 상태 검증 public static void validateIsApprovalPickContentStatus(Pick pick, String message, @Nullable String messageArgs) { 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 1d0b2818..a9be0ccc 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 @@ -1,9 +1,8 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.core.Authentication; @@ -11,10 +10,8 @@ import java.util.List; public interface PickServiceV2 extends PickService { - Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, - Authentication authentication); + Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, + Authentication authentication); - PickDetailResponse findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); - - List findTop3SimilarPicks(Long pickId); + List findTop3SimilarPicksV2(Long pickId); } \ 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 ef08db98..d374e216 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,17 +1,14 @@ package com.dreamypatisiel.devdevdev.web.controller.pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickFacadeService; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.global.utils.HttpRequestUtils; -import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingRequestHandler; import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +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 lombok.RequiredArgsConstructor; @@ -27,7 +24,7 @@ import static com.dreamypatisiel.devdevdev.web.WebConstant.HEADER_ANONYMOUS_MEMBER_ID; -@Tag(name = "픽픽픽 API", description = "") +@Tag(name = "픽픽픽 API V2", description = "") @RestController @RequiredArgsConstructor @RequestMapping("/devdevdev/api/v2") @@ -35,9 +32,9 @@ public class PickControllerV2 { private final PickServiceStrategy pickServiceStrategy; - @Operation(summary = "픽픽픽 메인 조회", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") + @Operation(summary = "픽픽픽 메인 조회 V2", description = "픽픽픽 메인 페이지에 필요한 데이터를 커서 방식으로 조회합니다.") @GetMapping("/picks") - public ResponseEntity>> getPicksMain( + public ResponseEntity>> getPicksMain( @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable, @RequestParam(required = false) Long pickId, @RequestParam(required = false) PickSort pickSort) { @@ -46,30 +43,18 @@ public ResponseEntity>> getPicksMain( String anonymousMemberId = HttpRequestUtils.getHeaderValue(HEADER_ANONYMOUS_MEMBER_ID); PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, + Slice response = pickService.findPicksMain(pageable, pickId, pickSort, anonymousMemberId, authentication); return ResponseEntity.ok(BasicResponse.success(response)); } - @Operation(summary = "픽픽픽 상세 조회", description = "픽픽픽 상세 페이지를 조회합니다.") - @GetMapping("/picks/{pickId}") - public ResponseEntity> getPickDetail(@PathVariable Long pickId, - @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { - Authentication authentication = AuthenticationMemberUtils.getAuthentication(); - - PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - PickDetailResponse response = pickService.findPickDetail(pickId, anonymousMemberId, authentication); - - return ResponseEntity.ok(BasicResponse.success(response)); - } - - @Operation(summary = "나도 고민했는데 픽픽픽", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") + @Operation(summary = "나도 고민했는데 픽픽픽 V2", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") @GetMapping("picks/{pickId}/similarties") - public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { + public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); - List response = pickService.findTop3SimilarPicks(pickId); + List response = pickService.findTop3SimilarPicksV2(pickId); return ResponseEntity.ok(BasicResponse.success(response)); } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java new file mode 100644 index 00000000..0fefc3f9 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java @@ -0,0 +1,78 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Collectors; + +@Data +public class PickMainOptionResponseV2 { + private final Long id; + private final String title; + private final int percent; + private final Boolean isPicked; + private final String content; + private final String thumbnailImageUrl; + + @Builder + public PickMainOptionResponseV2(Long id, Title title, BigDecimal percent, Boolean isPicked, + String content, String thumbnailImageUrl) { + this.id = id; + this.title = title.getTitle(); + this.percent = percent.intValueExact(); + this.isPicked = isPicked; + this.content = content; + this.thumbnailImageUrl = thumbnailImageUrl; + } + + // 회원 전용 + public static PickMainOptionResponseV2 of(Pick pick, PickOption pickOption, Member member) { + return PickMainOptionResponseV2.builder() + .id(pickOption.getId()) + .title(pickOption.getTitle()) + .percent(PickOption.calculatePercentBy(pick, pickOption)) + .isPicked(PickResponseUtils.isPickedPickOptionByMember(pick, pickOption, member)) + .content(getContentWithLengthLimit(pickOption)) + .thumbnailImageUrl(getThumbnailImageUrl(pickOption)) + .build(); + } + + // 익명 회원 전용 + public static PickMainOptionResponseV2 of(Pick pick, PickOption pickOption, AnonymousMember anonymousMember) { + return PickMainOptionResponseV2.builder() + .id(pickOption.getId()) + .title(pickOption.getTitle()) + .percent(PickOption.calculatePercentBy(pick, pickOption)) + .isPicked(PickResponseUtils.isPickedPickOptionByAnonymousMember(pick, pickOption, anonymousMember)) + .content(getContentWithLengthLimit(pickOption)) + .thumbnailImageUrl(getThumbnailImageUrl(pickOption)) + .build(); + } + + public static String getThumbnailImageUrl(PickOption pickOption) { + List imageUrls = pickOption.getPickOptionImages().stream() + .map(PickOptionImage::getImageUrl) + .toList(); + + return imageUrls.isEmpty() ? null : imageUrls.getFirst(); + } + + public static String getContentWithLengthLimit(PickOption pickOption) { + String content = pickOption.getContents().getPickOptionContents(); + if (content == null) { + return null; + } + + // 300자 제한 + if (content.length() > 300) { + return content.substring(0, 300); + } + return content; + } +} + 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 new file mode 100644 index 00000000..1e7c7b8f --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainResponseV2.java @@ -0,0 +1,84 @@ +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; +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.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class PickMainResponseV2 { + private Long id; + private String title; + private long voteTotalCount; + private long commentTotalCount; + private long viewTotalCount; + private long popularScore; + private Boolean isVoted; + private Boolean isNew; + private List pickOptions; + + @Builder + public PickMainResponseV2(Long id, Title title, Count voteTotalCount, Count commentTotalCount, + Count viewTotalCount, Count popularScore, Boolean isVoted, Boolean isNew, + List pickOptions) { + this.id = id; + this.title = title.getTitle(); + this.voteTotalCount = voteTotalCount.getCount(); + this.commentTotalCount = commentTotalCount.getCount(); + this.viewTotalCount = viewTotalCount.getCount(); + this.popularScore = popularScore.getCount(); + this.isVoted = isVoted; + this.isNew = isNew; + this.pickOptions = pickOptions; + } + + // 회원 전용 + public static PickMainResponseV2 of(Pick pick, Member member) { + return PickMainResponseV2.builder() + .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)) + .build(); + } + + // 익명 회원 전용 + public static PickMainResponseV2 of(Pick pick, AnonymousMember anonymousMember) { + return PickMainResponseV2.builder() + .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)) + .build(); + } + + private 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) { + 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/PickMainSliceResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java new file mode 100644 index 00000000..056511a2 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainSliceResponseV2.java @@ -0,0 +1,19 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import lombok.Builder; +import lombok.Data; +import org.springframework.data.domain.Slice; + +@Data +@Builder +public class PickMainSliceResponseV2 { + private final Slice picks; + private final long totalElements; + + public static PickMainSliceResponseV2 of(Slice picks, long totalElements) { + return PickMainSliceResponseV2.builder() + .picks(picks) + .totalElements(totalElements) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java new file mode 100644 index 00000000..7eb49f2e --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/SimilarPickResponseV2.java @@ -0,0 +1,42 @@ +package com.dreamypatisiel.devdevdev.web.dto.response.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.openai.data.response.PickWithSimilarityDto; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import lombok.Builder; +import lombok.Data; + +@Data +public class SimilarPickResponseV2 { + private final Long id; + private final String title; + private final long voteTotalCount; + private final long commentTotalCount; + private final double similarity; + private final Boolean isNew; + + @Builder + public SimilarPickResponseV2(Long id, String title, long voteTotalCount, long commentTotalCount, + double similarity, Boolean isNew) { + this.id = id; + this.title = title; + this.voteTotalCount = voteTotalCount; + this.commentTotalCount = commentTotalCount; + this.similarity = similarity; + this.isNew = isNew; + } + + public static SimilarPickResponseV2 from(PickWithSimilarityDto pickWithSimilarityDto) { + Pick pick = pickWithSimilarityDto.getPick(); + + return SimilarPickResponseV2.builder() + .id(pick.getId()) + .title(pick.getTitle().getTitle()) + .voteTotalCount(pick.getVoteTotalCount().getCount()) + .commentTotalCount(pick.getCommentTotalCount().getCount()) + .similarity(pickWithSimilarityDto.getSimilarity()) + .isNew(PickResponseUtils.isNewPick(pick)) + .build(); + } + +} diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java index b2c290aa..adbdcf3a 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java @@ -4,6 +4,7 @@ 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.global.utils.TimeUtils; public class PickResponseUtils { @@ -41,4 +42,8 @@ public static boolean isPickedPickOptionByAnonymousMember(Pick pick, PickOption .anyMatch(pickVote -> !pickVote.isDeleted() && pickVote.getAnonymousMember().isEqualAnonymousMemberId(anonymousMember.getId())); } + + public static boolean isNewPick(Pick pick) { + return TimeUtils.isWithinOneWeek(pick.getCreatedAt()); + } } From 6094a53a8c4c7fb4606bee1b57e41ed995a15108 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 15:35:31 +0900 Subject: [PATCH 05/22] =?UTF-8?q?test:=20=ED=94=BD=ED=94=BD=ED=94=BD=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20API,=20=EB=82=98=EA=B3=A0=ED=94=BD=20API?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/pick/GuestPickServiceV2Test.java | 275 ++++++++++++++++ .../service/pick/MemberPickServiceV2Test.java | 261 +++++++++++++++ .../service/pick/PickCommonServiceTest.java | 41 +++ .../controller/pick/PickControllerV2Test.java | 256 +++++++++++++++ .../web/docs/PickControllerV2DocsTest.java | 308 ++++++++++++++++++ 5 files changed, 1141 insertions(+) create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java new file mode 100644 index 00000000..c4c208ae --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java @@ -0,0 +1,275 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +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.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.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.firstPickOption; +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.service.pick.PickTestUtils.createPickOptionImage; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@Transactional +class GuestPickServiceV2Test { + + @Autowired + GuestPickServiceV2 guestPickServiceV2; + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + MemberRepository memberRepository; + @MockBean + AnonymousMemberService anonymousMemberService; + @MockBean + EmbeddingsService embeddingsService; + + 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(); + + @Test + @DisplayName("익명 회원이 픽픽픽 메인을 조회한다.") + void findPicksMain() { + // given + String anonymousMemberId = "GA1.1.276672604.1715872960"; + AnonymousMember anonymousMember = AnonymousMember.builder() + .anonymousMemberId(anonymousMemberId) + .build(); + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, 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)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Slice picksMain = guestPickServiceV2.findPicksMain(pageable, null, null, anonymousMemberId, authentication); + + // then + Pick findPick = pickRepository.findById(pick.getId()).get(); + assertThat(picksMain).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(picksMain.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/MemberPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java new file mode 100644 index 00000000..ded5f271 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/MemberPickServiceV2Test.java @@ -0,0 +1,261 @@ +package com.dreamypatisiel.devdevdev.domain.service.pick; + +import com.dreamypatisiel.devdevdev.domain.entity.*; +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.global.common.MemberProvider; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import jakarta.persistence.EntityManager; +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.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +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 { + + @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; + + 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(); + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void findPicksMain() { + // given + 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)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Slice picksMain = memberPickServiceV2.findPicksMain(pageable, null, null, null, null); + + // then + Pick findPick = pickRepository.findById(pick.getId()).get(); + assertThat(picksMain).hasSize(1) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "isNew") + .containsExactly( + tuple(findPick.getId(), + findPick.getTitle().getTitle(), + findPick.getVoteTotalCount().getCount(), + findPick.getCommentTotalCount().getCount(), + true) + ); + + List pickOptions = findPick.getPickOptions(); + assertThat(picksMain.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/PickCommonServiceTest.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java index a1ce577c..75cc24a9 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/PickCommonServiceTest.java @@ -20,6 +20,8 @@ import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponse; import java.util.List; + +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -88,6 +90,45 @@ void findTop3SimilarPicks() { ); } + @Test + @DisplayName("타겟 픽픽픽을 기준으로 다른 픽픽픽과 유사도를 계산하여 타겟 픽픽픽을 제외한 승인상태인 상위 3개의 픽픽픽을 조회한다.") + void findTop3SimilarPicksV2() { + // given + // 픽픽픽 작성자 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, + List.of(1.0, 1.0, 1.0)); + Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, + List.of(0.1, 0.2, 0.3)); + Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, + List.of(0.2, 0.3, 0.4)); + Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, + List.of(0.3, 0.4, 0.5)); + Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, + List.of(0.4, 0.5, 0.6)); + Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, + List.of(0.4, 0.5, 0.6)); + pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + + // when + List top3SimilarPicks = pickCommonService.findTop3SimilarPicksV2(targetPick.getId()); + + // then + assertThat(top3SimilarPicks).hasSize(3) + .extracting("id", "title", "voteTotalCount", "commentTotalCount", "similarity", "isNew") + .containsExactly( + tuple(pick3.getId(), pick3.getTitle().getTitle(), pick3.getVoteTotalCount().getCount(), + pick3.getCommentTotalCount().getCount(), 0.9797958971132711, true), + tuple(pick2.getId(), pick2.getTitle().getTitle(), pick2.getVoteTotalCount().getCount(), + pick2.getCommentTotalCount().getCount(), 0.9649012813540153, true), + tuple(pick1.getId(), pick1.getTitle().getTitle(), pick1.getVoteTotalCount().getCount(), + pick1.getCommentTotalCount().getCount(), 0.9258200997725515, true) + ); + } + @Test @DisplayName("타겟 픽픽픽을 기준으로 다른 픽픽픽과 유사도를 계산하여 타겟 픽픽픽을 제외한 상위 3개의 픽픽픽을 조회할 때 타겟 픽픽픽이 없으면 예외가 발생한다.") void findTop3SimilarPicks_INVALID_NOT_FOUND_PICK_MESSAGE() { 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 new file mode 100644 index 00000000..67cf629b --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/controller/pick/PickControllerV2Test.java @@ -0,0 +1,256 @@ +package com.dreamypatisiel.devdevdev.web.controller.pick; + +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.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.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.PickOptionRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; +import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; +import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.SimilarPickResponseV2; +import jakarta.persistence.EntityManager; +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; +import org.springframework.data.domain.SliceImpl; +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.domain.entity.enums.PickOptionType.firstPickOption; +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; +import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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 { + + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + PickPopularScorePolicy pickPopularScorePolicy; + @Autowired + EntityManager em; + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void getPicksMainByMember() throws Exception { + // given + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + PickOption pickOption1 = createPickOption(new Title("픽옵션1"), new PickOptionContents("픽콘텐츠1"), new Count(1), + firstPickOption); + PickOption pickOption2 = createPickOption(new Title("픽옵션2"), new PickOptionContents("픽콘텐츠2"), new Count(1), + secondPickOption); + + Title title = new Title("픽1타이틀"); + Count count = new Count(2); + String thumbnailUrl = "https://devdevdev.co.kr/devdevdev/api/v1/pick/image/1"; + String author = "운영자"; + Pick pick = createPick(member, title, count, count, count, thumbnailUrl, author, ContentStatus.APPROVAL, + List.of(pickOption1, pickOption2), List.of()); + pick.changePopularScore(pickPopularScorePolicy); + + pickRepository.save(pick); + pickOptionRepository.saveAll(List.of(pickOption1, pickOption2)); + + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + + Pageable pageable = PageRequest.of(0, 10); + + // when // then + mockMvc.perform(get("/devdevdev/api/v2/picks") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("pickSort", PickSort.LATEST.name()) + .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].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.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()); + } + + @Test + @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") + void getSimilarPicks() throws Exception { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, + List.of(1.0, 1.0, 1.0)); + Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, + List.of(0.1, 0.2, 0.3)); + Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, + List.of(0.2, 0.3, 0.4)); + Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, + List.of(0.3, 0.4, 0.5)); + Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, + List.of(0.4, 0.5, 0.6)); + Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, + List.of(0.4, 0.5, 0.6)); + pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + + // when // then + mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPick.getId()) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.datas").isArray()) + .andExpect(jsonPath("$.datas.[0].id").isNumber()) + .andExpect(jsonPath("$.datas.[0].title").isString()) + .andExpect(jsonPath("$.datas.[0].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[0].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[0].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[0].isNew").isBoolean()) + .andExpect(jsonPath("$.datas.[1].id").isNumber()) + .andExpect(jsonPath("$.datas.[1].title").isString()) + .andExpect(jsonPath("$.datas.[1].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[1].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[1].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[1].isNew").isBoolean()) + .andExpect(jsonPath("$.datas.[2].id").isNumber()) + .andExpect(jsonPath("$.datas.[2].title").isString()) + .andExpect(jsonPath("$.datas.[2].voteTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[2].commentTotalCount").isNumber()) + .andExpect(jsonPath("$.datas.[2].similarity").isNumber()) + .andExpect(jsonPath("$.datas.[2].isNew").isBoolean()); + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickCommentTotalCount, String thumbnailUrl, String author, + ContentStatus contentStatus, + List pickOptions, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickCommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(contentStatus) + .build(); + + pick.changePickOptions(pickOptions); + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Title title, PickOptionContents pickOptionContents, Count voteTotalCount, + com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType pickOptionType) { + return PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + } + + 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 Pick createPick(Title title, Count pickVoteCount, Count commentTotalCount, Member member, + ContentStatus contentStatus, List embeddings) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .commentTotalCount(commentTotalCount) + .member(member) + .contentStatus(contentStatus) + .embeddings(embeddings) + .build(); + } +} + diff --git a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java new file mode 100644 index 00000000..4fdbd3be --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -0,0 +1,308 @@ +package com.dreamypatisiel.devdevdev.web.docs; + +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.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.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.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; +import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.firstPickOption; +import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; +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.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; + +class PickControllerV2DocsTest extends SupportControllerDocsTest { + + @Autowired + PickRepository pickRepository; + @Autowired + PickOptionRepository pickOptionRepository; + @Autowired + PickOptionImageRepository pickOptionImageRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + PickPopularScorePolicy pickPopularScorePolicy; + @Autowired + PickServiceStrategy pickServiceStrategy; + + @Test + @DisplayName("회원이 픽픽픽 메인을 조회한다.") + void getPicksMainByMember() throws Exception { + // given + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + PickOption pickOption1 = createPickOption(new Title("픽옵션1"), new PickOptionContents("픽콘텐츠1"), new Count(1), + firstPickOption); + PickOption pickOption2 = createPickOption(new Title("픽옵션2"), new PickOptionContents("픽콘텐츠2"), new Count(1), + secondPickOption); + + // 픽픽픽 옵션 이미지 생성 + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", pickOption1); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", pickOption2); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + + + Title title = new Title("픽1타이틀"); + Count count = new Count(2); + String thumbnailUrl = "https://devdevdev.co.kr/devdevdev/api/v1/pick/image/1"; + String author = "운영자"; + Pick pick = createPick(member, title, count, count, count, thumbnailUrl, author, ContentStatus.APPROVAL, + List.of(pickOption1, pickOption2), List.of()); + pick.changePopularScore(pickPopularScorePolicy); + + pickRepository.save(pick); + pickOptionRepository.saveAll(List.of(pickOption1, pickOption2)); + + member.updateRefreshToken(refreshToken); + memberRepository.save(member); + + Pageable pageable = PageRequest.of(0, 10); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks") + .queryParam("size", String.valueOf(pageable.getPageSize())) + .queryParam("pickId", String.valueOf(Long.MAX_VALUE)) + .queryParam("pickSort", PickSort.LATEST.name()) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(StandardCharsets.UTF_8) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-main-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), + queryParameters( + parameterWithName("pickId").optional().description("픽픽픽 아이디"), + parameterWithName("pickSort").optional().description("픽픽픽 정렬 조건").attributes(pickSortType()), + 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[].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("현재 빈 페이지 여부") + ) + )); + } + + @Test + @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") + void getSimilarPicks() throws Exception { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", + "꿈빛파티시엘", "1234", email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, + List.of(1.0, 1.0, 1.0)); + Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, + List.of(0.1, 0.2, 0.3)); + Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, + List.of(0.2, 0.3, 0.4)); + Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, + List.of(0.3, 0.4, 0.5)); + Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, + List.of(0.4, 0.5, 0.6)); + Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, + List.of(0.4, 0.5, 0.6)); + pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPick.getId()) + .contentType(MediaType.APPLICATION_JSON) + .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-similar-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("pickId").description("픽픽픽 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("datas").type(ARRAY).description("나도 고민했는데 픽픽픽 배열"), + fieldWithPath("datas[].id").type(NUMBER).description("픽픽픽 아이디"), + fieldWithPath("datas[].title").type(STRING).description("픽픽픽 제목"), + fieldWithPath("datas[].voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수"), + fieldWithPath("datas[].commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수"), + fieldWithPath("datas[].similarity").type(NUMBER).description("픽픽픽 유사도"), + fieldWithPath("datas[].isNew").type(BOOLEAN).description("신규 픽픽픽 여부") + ) + )); + } + + private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, + Count pickCommentTotalCount, String thumbnailUrl, String author, + ContentStatus contentStatus, + List pickOptions, List pickVotes + ) { + + Pick pick = Pick.builder() + .member(member) + .title(title) + .voteTotalCount(pickVoteTotalCount) + .viewTotalCount(pickViewTotalCount) + .commentTotalCount(pickCommentTotalCount) + .thumbnailUrl(thumbnailUrl) + .author(author) + .contentStatus(contentStatus) + .build(); + + pick.changePickOptions(pickOptions); + pick.changePickVote(pickVotes); + + return pick; + } + + private PickOption createPickOption(Title title, PickOptionContents pickOptionContents, Count voteTotalCount, + com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType pickOptionType) { + return PickOption.builder() + .title(title) + .contents(pickOptionContents) + .voteTotalCount(voteTotalCount) + .pickOptionType(pickOptionType) + .build(); + } + + 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 Pick createPick(Title title, Count pickVoteCount, Count commentTotalCount, Member member, + ContentStatus contentStatus, List embeddings) { + return Pick.builder() + .title(title) + .voteTotalCount(pickVoteCount) + .commentTotalCount(commentTotalCount) + .member(member) + .contentStatus(contentStatus) + .embeddings(embeddings) + .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; + } +} + From 479d15e231095c596bbb3731add6d5680a29a3f3 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 15:53:26 +0900 Subject: [PATCH 06/22] =?UTF-8?q?fix:=20=ED=94=BD=ED=94=BD=ED=94=BD=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=A7=88=ED=81=AC=EB=8B=A4=EC=9A=B4=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../devdevdev/global/utils/MarkdownUtils.java | 27 ++++++++++ .../pick/PickMainOptionResponseV2.java | 18 +++++-- .../global/utils/MarkdownUtilsTest.java | 54 +++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java create mode 100644 src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java diff --git a/build.gradle b/build.gradle index 40baffc0..06110c0c 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,9 @@ dependencies { // mybatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4' + // markdown parser + implementation 'org.commonmark:commonmark:0.21.0' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java new file mode 100644 index 00000000..3c9b7427 --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtils.java @@ -0,0 +1,27 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.text.TextContentRenderer; + +/** + * 마크다운 처리를 위한 유틸리티 클래스 + */ +public abstract class MarkdownUtils { + + private static final Parser PARSER = Parser.builder().build(); + private static final TextContentRenderer TEXT_RENDERER = TextContentRenderer.builder().build(); + + /** + * 마크다운 텍스트를 순수 텍스트로 변환 + */ + public static String convertMarkdownToText(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return markdown; + } + + Node document = PARSER.parse(markdown); + return TEXT_RENDERER.render(document).trim(); + } +} + diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java index 0fefc3f9..698549ed 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickMainOptionResponseV2.java @@ -2,13 +2,14 @@ import com.dreamypatisiel.devdevdev.domain.entity.*; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.global.utils.MarkdownUtils; import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; import lombok.Builder; import lombok.Data; +import org.springframework.util.ObjectUtils; import java.math.BigDecimal; import java.util.List; -import java.util.stream.Collectors; @Data public class PickMainOptionResponseV2 { @@ -63,16 +64,23 @@ public static String getThumbnailImageUrl(PickOption pickOption) { } public static String getContentWithLengthLimit(PickOption pickOption) { + if (pickOption == null || pickOption.getContents() == null) { + return null; + } + String content = pickOption.getContents().getPickOptionContents(); - if (content == null) { + if (ObjectUtils.isEmpty(content)) { return null; } + // 마크다운 문법 제거 및 300자 제한 + String text = MarkdownUtils.convertMarkdownToText(content); + // 300자 제한 - if (content.length() > 300) { - return content.substring(0, 300); + if (text.length() > 300) { + return text.substring(0, 300); } - return content; + return text; } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java new file mode 100644 index 00000000..d27598a3 --- /dev/null +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/MarkdownUtilsTest.java @@ -0,0 +1,54 @@ +package com.dreamypatisiel.devdevdev.global.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MarkdownUtils 테스트") +class MarkdownUtilsTest { + + @Test + @DisplayName("복합 마크다운을 텍스트로 변환한다") + void convertMarkdownToText() { + // given + String markdown = """ + # 제목 + + **굵은 글씨**와 *이탤릭* 그리고 [링크](https://example.com) + + - 항목1 + - 항목2 + + `코드` + """; + + // when + String result = MarkdownUtils.convertMarkdownToText(markdown); + + // then + assertThat(result).contains("제목"); + assertThat(result).contains("굵은 글씨와 이탤릭 그리고"); + assertThat(result).contains("링크"); + assertThat(result).contains("항목1"); + assertThat(result).contains("항목2"); + assertThat(result).contains("코드"); + assertThat(result).doesNotContain("#"); + assertThat(result).doesNotContain("**"); + assertThat(result).doesNotContain("["); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("null 또는 빈 문자열은 그대로 반환한다") + void convertMarkdownToText_nullOrEmpty(String input) { + // when + String result = MarkdownUtils.convertMarkdownToText(input); + + // then + assertThat(result).isEqualTo(input); + } +} + From e3009b2ff622ab8dadb077336f1fa21cd3ffb5ed Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 16:07:13 +0900 Subject: [PATCH 07/22] =?UTF-8?q?docs:=20=ED=94=BD=ED=94=BD=ED=94=BD=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8,=20=EB=82=98=EA=B3=A0=ED=94=BD=20V2=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/api/pick/pick-main-v2.adoc | 17 +++++++++ .../asciidoc/api/pick/pick-similarity-v2.adoc | 38 +++++++++++++++++++ src/docs/asciidoc/api/pick/pick.adoc | 4 +- .../web/docs/PickControllerV2DocsTest.java | 8 +++- 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 src/docs/asciidoc/api/pick/pick-main-v2.adoc create mode 100644 src/docs/asciidoc/api/pick/pick-similarity-v2.adoc diff --git a/src/docs/asciidoc/api/pick/pick-main-v2.adoc b/src/docs/asciidoc/api/pick/pick-main-v2.adoc new file mode 100644 index 00000000..a26f54b4 --- /dev/null +++ b/src/docs/asciidoc/api/pick/pick-main-v2.adoc @@ -0,0 +1,17 @@ +[[Pick-Main-V2]] +== 픽픽픽 메인 API(GET: /devdevdev/api/v2/picks) +* 픽픽픽 메인 화면을 조회할 수 있다. +* 회원/익명 사용자에 따라 API 응답값이 상이하다. + +=== 정상 요청/응답 +==== HTTP Request +include::{snippets}/pick-main-v2/http-request.adoc[] +==== HTTP Request Header Fields +include::{snippets}/pick-main-v2/request-headers.adoc[] +==== HTTP Request Query Parameters Fields +include::{snippets}/pick-main-v2/query-parameters.adoc[] + +==== HTTP Response +include::{snippets}/pick-main-v2/http-response.adoc[] +==== HTTP Response Fields +include::{snippets}/pick-main-v2/response-fields.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc b/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc new file mode 100644 index 00000000..f83b17c8 --- /dev/null +++ b/src/docs/asciidoc/api/pick/pick-similarity-v2.adoc @@ -0,0 +1,38 @@ +[[Pick-Similarties-V2]] +== 나도 고민 했는데 픽픽픽 API(GET: /devdevdev/api/v2/picks/{pickId}/similarties) + +* 픽픽픽 상세와 유사도가 가장 높은 픽픽픽 3개를 조회한다. +* 유사도가 가장 높은 픽픽픽이 존재하지 않으면 빈 배열로 응답된다. +* 유사도 계산에 사용되는 embeddings 값이 없을 경우 503 예외를 발생한다. +** 외부 API(Open AI API) 를 사용하기 때문에 embeddings 값을 저장하지 못할 수 있다. +* 회원/익명 사용자 모두 조회할 수 있다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/pick-similarity-v2/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/pick-similarity-v2/request-headers.adoc[] + +==== HTTP Request Path Parameters Fields + +include::{snippets}/pick-similarity-v2/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/pick-similarity-v2/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/pick-similarity-v2/response-fields.adoc[] + +=== 예외 + +==== HTTP Response + +include::{snippets}/pick-similarity-not-found/response-body.adoc[] +include::{snippets}/pick-similarity-bad-request/response-body.adoc[] +include::{snippets}/pick-similarity-internal-server-exception/response-body.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick.adoc b/src/docs/asciidoc/api/pick/pick.adoc index f28862e0..3bfcca27 100644 --- a/src/docs/asciidoc/api/pick/pick.adoc +++ b/src/docs/asciidoc/api/pick/pick.adoc @@ -1,6 +1,7 @@ = 픽픽픽 include::pick-main.adoc[] +include::pick-main-v2.adoc[] include::pick-detail.adoc[] include::pick-register.adoc[] include::pick-modify.adoc[] @@ -8,4 +9,5 @@ include::pick-delete.adoc[] include::pick-vote.adoc[] include::pick-option-register-image.adoc[] include::pick-option-delete-image.adoc[] -include::pick-similarity.adoc[] \ No newline at end of file +include::pick-similarity.adoc[] +include::pick-similarity-v2.adoc[] \ No newline at end of file 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 4fdbd3be..bbf7dc36 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -216,12 +216,16 @@ void getSimilarPicks() throws Exception { .andExpect(status().isOk()); // docs - actions.andDo(document("pick-similar-v2", + actions.andDo(document("pick-similarity-v2", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("pickId").description("픽픽픽 아이디") ), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), responseFields( fieldWithPath("resultType").type(STRING).description("응답 결과"), fieldWithPath("datas").type(ARRAY).description("나도 고민했는데 픽픽픽 배열"), @@ -230,7 +234,7 @@ void getSimilarPicks() throws Exception { fieldWithPath("datas[].voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수"), fieldWithPath("datas[].commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수"), fieldWithPath("datas[].similarity").type(NUMBER).description("픽픽픽 유사도"), - fieldWithPath("datas[].isNew").type(BOOLEAN).description("신규 픽픽픽 여부") + fieldWithPath("datas[].isNew").type(BOOLEAN).description("일주일 이내 게시글 여부 (NEW)") ) )); } From b4079c36e6917e1120e713e3531c27fa36a0bec0 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 17:45:20 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devdevdev/global/utils/TimeUtils.java | 2 +- .../web/dto/util/PickResponseUtils.java | 2 +- .../controller/pick/PickControllerV2Test.java | 207 ++++++---------- .../web/docs/PickControllerV2DocsTest.java | 224 ++++++------------ 4 files changed, 156 insertions(+), 279 deletions(-) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java index 20ca6767..4fdd5721 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java @@ -5,7 +5,7 @@ /** * 시간 관련 유틸리티 클래스 */ -public class TimeUtils { +public abstract class TimeUtils { /** * 주어진 시간이 현재로부터 일주일 이내인지 확인합니다. diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java index adbdcf3a..020a4996 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java @@ -6,7 +6,7 @@ import com.dreamypatisiel.devdevdev.domain.entity.PickOption; import com.dreamypatisiel.devdevdev.global.utils.TimeUtils; -public class PickResponseUtils { +public abstract class PickResponseUtils { public static boolean isVotedMember(Pick pick, Member member) { return pick.getPickVotes().stream() 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 67cf629b..50e37eb8 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,47 +1,30 @@ package com.dreamypatisiel.devdevdev.web.controller.pick; -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.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.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.PickOptionRepository; -import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; -import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; -import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.controller.SupportControllerTest; -import com.dreamypatisiel.devdevdev.web.dto.response.ResultType; +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.SimilarPickResponseV2; -import jakarta.persistence.EntityManager; 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; -import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Slice; 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.domain.entity.enums.PickOptionType.firstPickOption; -import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; import static com.dreamypatisiel.devdevdev.web.dto.response.ResultType.SUCCESS; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +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; @@ -50,46 +33,51 @@ class PickControllerV2Test extends SupportControllerTest { - @Autowired - PickRepository pickRepository; - @Autowired - PickOptionRepository pickOptionRepository; - @Autowired - MemberRepository memberRepository; - @Autowired - PickPopularScorePolicy pickPopularScorePolicy; - @Autowired - EntityManager em; + @MockBean + GuestPickServiceV2 guestPickServiceV2; + @MockBean + MemberPickServiceV2 memberPickServiceV2; @Test @DisplayName("회원이 픽픽픽 메인을 조회한다.") void getPicksMainByMember() throws Exception { // given - SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", - "꿈빛파티시엘", "1234", email, socialType, role); - Member member = Member.createMemberBy(socialMemberDto); - memberRepository.save(member); - - PickOption pickOption1 = createPickOption(new Title("픽옵션1"), new PickOptionContents("픽콘텐츠1"), new Count(1), - firstPickOption); - PickOption pickOption2 = createPickOption(new Title("픽옵션2"), new PickOptionContents("픽콘텐츠2"), new Count(1), - secondPickOption); + Pageable pageable = PageRequest.of(0, 10); - Title title = new Title("픽1타이틀"); - Count count = new Count(2); - String thumbnailUrl = "https://devdevdev.co.kr/devdevdev/api/v1/pick/image/1"; - String author = "운영자"; - Pick pick = createPick(member, title, count, count, count, thumbnailUrl, author, ContentStatus.APPROVAL, - List.of(pickOption1, pickOption2), List.of()); - pick.changePopularScore(pickPopularScorePolicy); + // Mock 데이터 생성 + PickMainOptionResponseV2 option1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("픽옵션1")) + .percent(new java.math.BigDecimal("50")) + .isPicked(true) + .content("픽콘텐츠1") + .thumbnailImageUrl("http://image1.png") + .build(); - pickRepository.save(pick); - pickOptionRepository.saveAll(List.of(pickOption1, pickOption2)); + PickMainOptionResponseV2 option2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("픽옵션2")) + .percent(new java.math.BigDecimal("50")) + .isPicked(false) + .content("픽콘텐츠2") + .thumbnailImageUrl("http://image2.png") + .build(); - member.updateRefreshToken(refreshToken); - memberRepository.save(member); + PickMainResponseV2 pickMainResponse = PickMainResponseV2.builder() + .id(1L) + .title(new Title("픽1타이틀")) + .voteTotalCount(new Count(2)) + .commentTotalCount(new Count(2)) + .viewTotalCount(new Count(2)) + .popularScore(new Count(100)) + .isVoted(true) + .isNew(true) + .pickOptions(List.of(option1, option2)) + .build(); - Pageable pageable = PageRequest.of(0, 10); + Slice result = new SliceCustom<>(List.of(pickMainResponse), pageable, false, 1L); + when(memberPickServiceV2.findPicksMain(any(Pageable.class), any(), any(), + any(), any(Authentication.class))).thenReturn(result); // when // then mockMvc.perform(get("/devdevdev/api/v2/picks") @@ -146,34 +134,47 @@ void getPicksMainByMember() throws Exception { @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") void getSimilarPicks() throws Exception { // given - // 회원 생성 - SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", - "꿈빛파티시엘", "1234", email, socialType, role); - Member member = Member.createMemberBy(socialMemberDto); - memberRepository.save(member); + Long targetPickId = 1L; + + // Mock 데이터 생성 + SimilarPickResponseV2 similarPick1 = SimilarPickResponseV2.builder() + .id(2L) + .title("유쏘영") + .voteTotalCount(2L) + .commentTotalCount(5L) + .similarity(0.95) + .isNew(true) + .build(); - Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, - List.of(1.0, 1.0, 1.0)); - Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, - List.of(0.1, 0.2, 0.3)); - Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, - List.of(0.2, 0.3, 0.4)); - Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, - List.of(0.3, 0.4, 0.5)); - Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, - List.of(0.4, 0.5, 0.6)); - Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, - List.of(0.4, 0.5, 0.6)); - pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + SimilarPickResponseV2 similarPick2 = SimilarPickResponseV2.builder() + .id(3L) + .title("소영쏘") + .voteTotalCount(3L) + .commentTotalCount(4L) + .similarity(0.85) + .isNew(false) + .build(); + + SimilarPickResponseV2 similarPick3 = SimilarPickResponseV2.builder() + .id(4L) + .title("쏘영쏘") + .voteTotalCount(4L) + .commentTotalCount(3L) + .similarity(0.75) + .isNew(false) + .build(); + + List result = List.of(similarPick1, similarPick2, similarPick3); + when(memberPickServiceV2.findTop3SimilarPicksV2(anyLong())).thenReturn(result); // when // then - mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPick.getId()) + mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPickId) .contentType(MediaType.APPLICATION_JSON) .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) .characterEncoding(StandardCharsets.UTF_8)) .andDo(print()) .andExpect(status().isOk()) - .andExpect(jsonPath("$.resultType").value(ResultType.SUCCESS.name())) + .andExpect(jsonPath("$.resultType").value(SUCCESS.name())) .andExpect(jsonPath("$.datas").isArray()) .andExpect(jsonPath("$.datas.[0].id").isNumber()) .andExpect(jsonPath("$.datas.[0].title").isString()) @@ -194,63 +195,5 @@ void getSimilarPicks() throws Exception { .andExpect(jsonPath("$.datas.[2].similarity").isNumber()) .andExpect(jsonPath("$.datas.[2].isNew").isBoolean()); } - - private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, - Count pickCommentTotalCount, String thumbnailUrl, String author, - ContentStatus contentStatus, - List pickOptions, List pickVotes - ) { - - Pick pick = Pick.builder() - .member(member) - .title(title) - .voteTotalCount(pickVoteTotalCount) - .viewTotalCount(pickViewTotalCount) - .commentTotalCount(pickCommentTotalCount) - .thumbnailUrl(thumbnailUrl) - .author(author) - .contentStatus(contentStatus) - .build(); - - pick.changePickOptions(pickOptions); - pick.changePickVote(pickVotes); - - return pick; - } - - private PickOption createPickOption(Title title, PickOptionContents pickOptionContents, Count voteTotalCount, - com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType pickOptionType) { - return PickOption.builder() - .title(title) - .contents(pickOptionContents) - .voteTotalCount(voteTotalCount) - .pickOptionType(pickOptionType) - .build(); - } - - 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 Pick createPick(Title title, Count pickVoteCount, Count commentTotalCount, Member member, - ContentStatus contentStatus, List embeddings) { - return Pick.builder() - .title(title) - .voteTotalCount(pickVoteCount) - .commentTotalCount(commentTotalCount) - .member(member) - .contentStatus(contentStatus) - .embeddings(embeddings) - .build(); - } } 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 bbf7dc36..1f616eaf 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -1,40 +1,34 @@ package com.dreamypatisiel.devdevdev.web.docs; -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.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.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.repository.pick.PickSort; -import com.dreamypatisiel.devdevdev.domain.service.pick.PickServiceStrategy; +import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; +import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; -import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; +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.SimilarPickResponseV2; 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; +import org.springframework.data.domain.Slice; 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.domain.entity.enums.PickOptionType.firstPickOption; -import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; 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; @@ -49,54 +43,51 @@ class PickControllerV2DocsTest extends SupportControllerDocsTest { - @Autowired - PickRepository pickRepository; - @Autowired - PickOptionRepository pickOptionRepository; - @Autowired - PickOptionImageRepository pickOptionImageRepository; - @Autowired - MemberRepository memberRepository; - @Autowired - PickPopularScorePolicy pickPopularScorePolicy; - @Autowired - PickServiceStrategy pickServiceStrategy; + @MockBean + GuestPickServiceV2 guestPickServiceV2; + @MockBean + MemberPickServiceV2 memberPickServiceV2; @Test @DisplayName("회원이 픽픽픽 메인을 조회한다.") void getPicksMainByMember() throws Exception { // given - SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", - "꿈빛파티시엘", "1234", email, socialType, role); - Member member = Member.createMemberBy(socialMemberDto); - memberRepository.save(member); - - PickOption pickOption1 = createPickOption(new Title("픽옵션1"), new PickOptionContents("픽콘텐츠1"), new Count(1), - firstPickOption); - PickOption pickOption2 = createPickOption(new Title("픽옵션2"), new PickOptionContents("픽콘텐츠2"), new Count(1), - secondPickOption); - - // 픽픽픽 옵션 이미지 생성 - PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", pickOption1); - PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", pickOption2); - pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); - + Pageable pageable = PageRequest.of(0, 10); - Title title = new Title("픽1타이틀"); - Count count = new Count(2); - String thumbnailUrl = "https://devdevdev.co.kr/devdevdev/api/v1/pick/image/1"; - String author = "운영자"; - Pick pick = createPick(member, title, count, count, count, thumbnailUrl, author, ContentStatus.APPROVAL, - List.of(pickOption1, pickOption2), List.of()); - pick.changePopularScore(pickPopularScorePolicy); + // Mock 데이터 생성 + PickMainOptionResponseV2 option1 = PickMainOptionResponseV2.builder() + .id(1L) + .title(new Title("픽옵션1")) + .percent(new java.math.BigDecimal("50")) + .isPicked(true) + .content("픽콘텐츠1") + .thumbnailImageUrl("http://image1.png") + .build(); - pickRepository.save(pick); - pickOptionRepository.saveAll(List.of(pickOption1, pickOption2)); + PickMainOptionResponseV2 option2 = PickMainOptionResponseV2.builder() + .id(2L) + .title(new Title("픽옵션2")) + .percent(new java.math.BigDecimal("50")) + .isPicked(false) + .content("픽콘텐츠2") + .thumbnailImageUrl("http://image2.png") + .build(); - member.updateRefreshToken(refreshToken); - memberRepository.save(member); + PickMainResponseV2 pickMainResponse = PickMainResponseV2.builder() + .id(1L) + .title(new Title("픽1타이틀")) + .voteTotalCount(new Count(2)) + .commentTotalCount(new Count(2)) + .viewTotalCount(new Count(2)) + .popularScore(new Count(100)) + .isVoted(true) + .isNew(true) + .pickOptions(List.of(option1, option2)) + .build(); - Pageable pageable = PageRequest.of(0, 10); + Slice result = new SliceCustom<>(List.of(pickMainResponse), pageable, false, 1L); + when(memberPickServiceV2.findPicksMain(any(Pageable.class), any(), any(), + any(), any(Authentication.class))).thenReturn(result); // when // then ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks") @@ -187,28 +178,41 @@ void getPicksMainByMember() throws Exception { @DisplayName("나도 고민했는데 픽픽픽을 조회한다.") void getSimilarPicks() throws Exception { // given - // 회원 생성 - SocialMemberDto socialMemberDto = createSocialDto("dreamy5patisiel", "꿈빛파티시엘", - "꿈빛파티시엘", "1234", email, socialType, role); - Member member = Member.createMemberBy(socialMemberDto); - memberRepository.save(member); + Long targetPickId = 1L; + + // Mock 데이터 생성 + SimilarPickResponseV2 similarPick1 = SimilarPickResponseV2.builder() + .id(2L) + .title("유쏘영") + .voteTotalCount(2L) + .commentTotalCount(5L) + .similarity(0.95) + .isNew(true) + .build(); - Pick targetPick = createPick(new Title("유소영"), new Count(1), new Count(1), member, ContentStatus.APPROVAL, - List.of(1.0, 1.0, 1.0)); - Pick pick1 = createPick(new Title("유쏘영"), new Count(2), new Count(5), member, ContentStatus.APPROVAL, - List.of(0.1, 0.2, 0.3)); - Pick pick2 = createPick(new Title("소영쏘"), new Count(3), new Count(4), member, ContentStatus.APPROVAL, - List.of(0.2, 0.3, 0.4)); - Pick pick3 = createPick(new Title("쏘영쏘"), new Count(4), new Count(3), member, ContentStatus.APPROVAL, - List.of(0.3, 0.4, 0.5)); - Pick pick4 = createPick(new Title("쏘주쏘"), new Count(5), new Count(2), member, ContentStatus.READY, - List.of(0.4, 0.5, 0.6)); - Pick pick5 = createPick(new Title("쏘주"), new Count(6), new Count(1), member, ContentStatus.REJECT, - List.of(0.4, 0.5, 0.6)); - pickRepository.saveAll(List.of(targetPick, pick1, pick2, pick3, pick4, pick5)); + SimilarPickResponseV2 similarPick2 = SimilarPickResponseV2.builder() + .id(3L) + .title("소영쏘") + .voteTotalCount(3L) + .commentTotalCount(4L) + .similarity(0.85) + .isNew(false) + .build(); + + SimilarPickResponseV2 similarPick3 = SimilarPickResponseV2.builder() + .id(4L) + .title("쏘영쏘") + .voteTotalCount(4L) + .commentTotalCount(3L) + .similarity(0.75) + .isNew(false) + .build(); + + List result = List.of(similarPick1, similarPick2, similarPick3); + when(memberPickServiceV2.findTop3SimilarPicksV2(anyLong())).thenReturn(result); // when // then - ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPick.getId()) + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}/similarties", targetPickId) .contentType(MediaType.APPLICATION_JSON) .header(SecurityConstant.AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) .characterEncoding(StandardCharsets.UTF_8)) @@ -238,75 +242,5 @@ void getSimilarPicks() throws Exception { ) )); } - - private Pick createPick(Member member, Title title, Count pickVoteTotalCount, Count pickViewTotalCount, - Count pickCommentTotalCount, String thumbnailUrl, String author, - ContentStatus contentStatus, - List pickOptions, List pickVotes - ) { - - Pick pick = Pick.builder() - .member(member) - .title(title) - .voteTotalCount(pickVoteTotalCount) - .viewTotalCount(pickViewTotalCount) - .commentTotalCount(pickCommentTotalCount) - .thumbnailUrl(thumbnailUrl) - .author(author) - .contentStatus(contentStatus) - .build(); - - pick.changePickOptions(pickOptions); - pick.changePickVote(pickVotes); - - return pick; - } - - private PickOption createPickOption(Title title, PickOptionContents pickOptionContents, Count voteTotalCount, - com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType pickOptionType) { - return PickOption.builder() - .title(title) - .contents(pickOptionContents) - .voteTotalCount(voteTotalCount) - .pickOptionType(pickOptionType) - .build(); - } - - 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 Pick createPick(Title title, Count pickVoteCount, Count commentTotalCount, Member member, - ContentStatus contentStatus, List embeddings) { - return Pick.builder() - .title(title) - .voteTotalCount(pickVoteCount) - .commentTotalCount(commentTotalCount) - .member(member) - .contentStatus(contentStatus) - .embeddings(embeddings) - .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; - } } From 850c311d5de69b0a3ab68efc2714ef65ee9fbba0 Mon Sep 17 00:00:00 2001 From: soyoung Date: Sun, 26 Oct 2025 17:59:29 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A3=BC=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=82=B4=20=EA=B8=B0=EC=A4=80=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=EC=97=90=EC=84=9C=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devdevdev/global/utils/TimeUtils.java | 12 ++-- .../web/dto/util/PickResponseUtils.java | 5 +- .../devdevdev/global/utils/TimeUtilsTest.java | 65 ++++++++----------- 3 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java index 4fdd5721..003a9c51 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtils.java @@ -1,5 +1,6 @@ package com.dreamypatisiel.devdevdev.global.utils; +import java.time.LocalDate; import java.time.LocalDateTime; /** @@ -8,13 +9,14 @@ public abstract class TimeUtils { /** - * 주어진 시간이 현재로부터 일주일 이내인지 확인합니다. + * 주어진 날짜가 현재로부터 일주일 이내인지 확인합니다. * * @param createdAt 확인할 시간 - * @return 일주일 이내라면 true, 그렇지 않다면 false + * @return 날짜 기준 일주일 이내라면 true, 그렇지 않다면 false */ - public static boolean isWithinOneWeek(LocalDateTime createdAt) { - LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); - return createdAt.isAfter(oneWeekAgo); + public static boolean isWithinOneWeek(LocalDateTime createdAt, LocalDateTime now) { + LocalDate createdDate = createdAt.toLocalDate(); + LocalDate oneWeekAgo = now.toLocalDate().minusWeeks(1); + return !createdDate.isBefore(oneWeekAgo); } } diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java index 020a4996..634b36b7 100644 --- a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/util/PickResponseUtils.java @@ -4,8 +4,11 @@ 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.global.common.TimeProvider; import com.dreamypatisiel.devdevdev.global.utils.TimeUtils; +import java.time.LocalDateTime; + public abstract class PickResponseUtils { public static boolean isVotedMember(Pick pick, Member member) { @@ -44,6 +47,6 @@ public static boolean isPickedPickOptionByAnonymousMember(Pick pick, PickOption } public static boolean isNewPick(Pick pick) { - return TimeUtils.isWithinOneWeek(pick.getCreatedAt()); + return TimeUtils.isWithinOneWeek(pick.getCreatedAt(), LocalDateTime.now()); } } diff --git a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java index 0ff6c2a1..2144e778 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/global/utils/TimeUtilsTest.java @@ -1,61 +1,52 @@ package com.dreamypatisiel.devdevdev.global.utils; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import java.time.LocalDateTime; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("TimeUtils 테스트") class TimeUtilsTest { - @ParameterizedTest - @CsvSource({ - "6, DAYS", // 6일 전 - "1, HOURS", // 1시간 전 - "1, MINUTES", // 1분 전 - "6, DAYS", // 6일 23시간 전 - }) - @DisplayName("일주일 이내의 시간은 true를 반환한다") - void isWithinOneWeek_true(int amount, String unit) { - // given - LocalDateTime now = LocalDateTime.now(); - LocalDateTime testTime = createTestTime(now, amount, unit); + // 기준 시간: 2025-10-26 10:00:00 + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2025, 10, 26, 10, 0, 0); + @ParameterizedTest + @MethodSource("provideWithinOneWeekDates") + @DisplayName("날짜 기준 7일 이내는 true를 반환한다") + void isWithinOneWeek_true(LocalDateTime testTime) { // when // then - assertThat(TimeUtils.isWithinOneWeek(testTime)) + assertThat(TimeUtils.isWithinOneWeek(testTime, BASE_TIME)) .isTrue(); } @ParameterizedTest - @CsvSource({ - "1, WEEKS", // 일주일 전 - "8, DAYS", // 8일 전 - "2, WEEKS", // 2주일 전 - "1, MONTHS" // 1개월 전 - }) - @DisplayName("일주일을 초과한 시간은 false를 반환한다") - void isWithinOneWeek_false(int amount, String unit) { - // given - LocalDateTime now = LocalDateTime.now(); - LocalDateTime testTime = createTestTime(now, amount, unit); - + @MethodSource("provideOverOneWeekDates") + @DisplayName("날짜 기준 7일 초과는 false를 반환한다") + void isWithinOneWeek_false(LocalDateTime testTime) { // when // then - assertThat(TimeUtils.isWithinOneWeek(testTime)) + assertThat(TimeUtils.isWithinOneWeek(testTime, BASE_TIME)) .isFalse(); } - private LocalDateTime createTestTime(LocalDateTime baseTime, int amount, String unit) { - return switch (unit) { - case "DAYS" -> baseTime.minusDays(amount); - case "HOURS" -> baseTime.minusHours(amount); - case "MINUTES" -> baseTime.minusMinutes(amount); - case "WEEKS" -> baseTime.minusWeeks(amount); - case "MONTHS" -> baseTime.minusMonths(amount); - default -> baseTime; - }; + private static Stream provideWithinOneWeekDates() { + return Stream.of( + LocalDateTime.of(2025, 10, 26, 10, 0, 0), // 오늘 (2025-10-26) + LocalDateTime.of(2025, 10, 25, 15, 30, 0), // 1일 전 (2025-10-25) + LocalDateTime.of(2025, 10, 20, 8, 0, 0), // 6일 전 (2025-10-20) + LocalDateTime.of(2025, 10, 19, 23, 59, 59) // 7일 전 (2025-10-19) + ); + } + + private static Stream provideOverOneWeekDates() { + return Stream.of( + LocalDateTime.of(2025, 10, 18, 10, 0, 0), // 8일 전 (2025-10-18) + LocalDateTime.of(2025, 10, 12, 10, 0, 0), // 14일 전 (2025-10-12) + LocalDateTime.of(2025, 9, 26, 10, 0, 0) // 30일 전 (2025-09-26) + ); } } From 17de395f182f68f8bee8c9100e2b59a58d039137 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Thu, 30 Oct 2025 02:30:19 +0900 Subject: [PATCH 10/22] =?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 11/22] =?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 12/22] =?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 13/22] =?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 14/22] =?UTF-8?q?feat(GuestPickServiceV2):=20findPickMainS?= =?UTF-8?q?earch()=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 15/22] =?UTF-8?q?feat(SecurityConstant):=20pick=20v2=20end?= =?UTF-8?q?point=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 16/22] =?UTF-8?q?fix(=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= 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 From 817185e9306c24851e21c545dde4ad377d1efcc8 Mon Sep 17 00:00:00 2001 From: soyoung Date: Wed, 5 Nov 2025 22:53:57 +0900 Subject: [PATCH 17/22] =?UTF-8?q?feat(pick):=20=ED=94=BD=ED=94=BD=ED=94=BD?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/pick/GuestPickServiceV2.java | 61 +++++-- .../service/pick/MemberPickServiceV2.java | 39 +++++ .../domain/service/pick/PickServiceV2.java | 3 + .../web/controller/pick/PickControllerV2.java | 13 ++ .../response/pick/PickDetailResponseV2.java | 79 +++++++++ .../web/docs/PickControllerV2DocsTest.java | 161 ++++++++++++++++++ 6 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickDetailResponseV2.java 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 ab240a5b..2e79324e 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 @@ -2,34 +2,35 @@ import com.dreamypatisiel.devdevdev.domain.entity.AnonymousMember; import com.dreamypatisiel.devdevdev.domain.entity.Pick; +import com.dreamypatisiel.devdevdev.domain.entity.PickOption; import com.dreamypatisiel.devdevdev.domain.entity.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; -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.*; import com.dreamypatisiel.devdevdev.domain.repository.pick.mybatis.PickMapper; import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; 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 com.dreamypatisiel.devdevdev.web.dto.response.pick.*; +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; 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; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; + +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; @Service @Transactional(readOnly = true) @@ -77,6 +78,40 @@ public Slice findPicksMain(Pageable pageable, Long pickId, P return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 픽픽픽 상세 조회 V2 + */ + @Transactional + @Override + public PickDetailResponseV2 findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { + + // 익명 사용자 호출인지 확인 + AuthenticationMemberUtils.validateAnonymousMethodCall(authentication); + + // 익명 회원 조회 또는 생성 + AnonymousMember anonymousMember = anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId); + + // 픽픽픽 상세 조회(pickOption 페치조인) + Pick findPick = pickRepository.findPickWithPickOptionByPickId(pickId) + .orElseThrow(() -> new NotFoundException(INVALID_NOT_FOUND_PICK_MESSAGE)); + + // 픽픽픽 게시글의 승인 상태가 아니면 + if (!findPick.isTrueContentStatus(ContentStatus.APPROVAL)) { + throw new IllegalArgumentException(INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE); + } + + findPick.plusOneViewTotalCount(); // 조회수 증가 + findPick.changePopularScore(pickPopularScorePolicy); // 인기점수 계산 + + // 픽픽픽 옵션 가공 + Map pickDetailOptions = findPick.getPickOptions().stream() + .collect(Collectors.toMap(PickOption::getPickOptionType, + pickOption -> PickDetailOptionResponse.of(pickOption, findPick, anonymousMember))); + + // 픽픽픽 상세 + return PickDetailResponseV2.of(findPick, findPick.getMember(), anonymousMember, pickDetailOptions); + } + /** * 나도 고민했는데 픽픽픽 V2 */ 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 bab6fc90..18cf80b9 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 @@ -2,7 +2,9 @@ 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.enums.ContentStatus; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.policy.PickBestCommentsPolicy; import com.dreamypatisiel.devdevdev.domain.policy.PickPopularScorePolicy; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickCommentRecommendRepository; @@ -10,11 +12,14 @@ 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.exception.NotFoundException; 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.PickDetailOptionResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; 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; @@ -30,6 +35,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; + @Service @Transactional(readOnly = true) public class MemberPickServiceV2 extends PickCommonService implements PickServiceV2 { @@ -72,6 +80,37 @@ public Slice findPicksMain(Pageable pageable, Long pickId, P return new SliceCustom<>(pickMainResponse, pageable, picks.hasNext(), totalElements); } + /** + * 픽픽픽 상세 조회 V2 + */ + @Transactional + @Override + public PickDetailResponseV2 findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication) { + + // 회원 조회 + Member findMember = memberProvider.getMemberByAuthentication(authentication); + + // 픽픽픽 상세 조회(pickOption 페치조인) + Pick findPick = pickRepository.findPickWithPickOptionByPickId(pickId) + .orElseThrow(() -> new NotFoundException(INVALID_NOT_FOUND_PICK_MESSAGE)); + + // 픽픽픽 게시글의 승인 상태가 아니면 + if (!findPick.isTrueContentStatus(ContentStatus.APPROVAL)) { + throw new IllegalArgumentException(INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE); + } + + findPick.plusOneViewTotalCount(); // 조회수 증가 + findPick.changePopularScore(pickPopularScorePolicy); // 인기점수 계산 + + // 픽픽픽 옵션 가공 + Map pickDetailOptions = findPick.getPickOptions().stream() + .collect(Collectors.toMap(PickOption::getPickOptionType, + pickOption -> PickDetailOptionResponse.of(pickOption, findPick, findMember))); + + // 픽픽픽 상세 + return PickDetailResponseV2.of(findPick, findPick.getMember(), findMember, pickDetailOptions); + } + /** * 나도 고민했는데 픽픽픽 V2 */ 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 49964a23..aa531fe8 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 @@ -1,6 +1,7 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; 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; @@ -13,6 +14,8 @@ public interface PickServiceV2 extends PickService { Slice findPicksMain(Pageable pageable, Long pickId, PickSort pickSort, String anonymousMemberId, Authentication authentication); + PickDetailResponseV2 findPickDetail(Long pickId, String anonymousMemberId, Authentication authentication); + List findTop3SimilarPicksV2(Long pickId); Slice findPickMainSearch(Pageable pageable, Long pickId, Double searchScore, String keyword, 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 e646878a..961a9fd9 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 @@ -9,6 +9,7 @@ import com.dreamypatisiel.devdevdev.global.utils.HttpRequestUtils; import com.dreamypatisiel.devdevdev.web.controller.ApiVersion; import com.dreamypatisiel.devdevdev.web.dto.response.BasicResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; 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; @@ -54,6 +55,18 @@ public ResponseEntity>> getPicksMain( return ResponseEntity.ok(BasicResponse.success(response)); } + @Operation(summary = "픽픽픽 상세 조회 V2", description = "픽픽픽 상세 페이지를 조회합니다.") + @GetMapping("/picks/{pickId}") + public ResponseEntity> getPickDetail(@PathVariable Long pickId, + @RequestHeader(value = HEADER_ANONYMOUS_MEMBER_ID, required = false) String anonymousMemberId) { + Authentication authentication = AuthenticationMemberUtils.getAuthentication(); + + PickServiceV2 pickService = (PickServiceV2) pickServiceStrategy.getPickService(ApiVersion.V2); + PickDetailResponseV2 response = pickService.findPickDetail(pickId, anonymousMemberId, authentication); + + return ResponseEntity.ok(BasicResponse.success(response)); + } + @Operation(summary = "나도 고민했는데 픽픽픽 V2", description = "픽픽픽 상세와 유사한 성격의 픽픽픽 3개를 추천합니다.") @GetMapping("picks/{pickId}/similarties") public ResponseEntity> getSimilarPicks(@PathVariable Long pickId) { diff --git a/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickDetailResponseV2.java b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickDetailResponseV2.java new file mode 100644 index 00000000..dc072dab --- /dev/null +++ b/src/main/java/com/dreamypatisiel/devdevdev/web/dto/response/pick/PickDetailResponseV2.java @@ -0,0 +1,79 @@ +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.enums.PickOptionType; +import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; +import com.dreamypatisiel.devdevdev.web.dto.util.PickResponseUtils; +import com.dreamypatisiel.devdevdev.global.common.TimeProvider; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonFormat.Shape; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.Builder; +import lombok.Data; + +@Data +public class PickDetailResponseV2 { + private final String userId; + private final String nickname; + + @JsonFormat(shape = Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = TimeProvider.DEFAULT_ZONE_ID) + private final LocalDateTime pickCreatedAt; + + private final String pickTitle; + private final long voteTotalCount; + private final long commentTotalCount; + private final Boolean isAuthor; + private final Boolean isVoted; + private final Map pickOptions; + + @Builder + public PickDetailResponseV2(String userId, String nickname, LocalDateTime pickCreatedAt, String pickTitle, + long voteTotalCount, long commentTotalCount, boolean isAuthor, boolean isVoted, + Map pickOptions) { + this.userId = userId; + this.nickname = nickname; + this.pickCreatedAt = pickCreatedAt; + this.pickTitle = pickTitle; + this.voteTotalCount = voteTotalCount; + this.commentTotalCount = commentTotalCount; + this.isAuthor = isAuthor; + this.isVoted = isVoted; + this.pickOptions = pickOptions; + } + + // 회원 전용 + public static PickDetailResponseV2 of(Pick pick, Member pickMember, Member member, + Map pickDetailOptions) { + return PickDetailResponseV2.builder() + .userId(CommonResponseUtil.sliceAndMaskEmail(pickMember.getEmail().getEmail())) + .nickname(pickMember.getNickname().getNickname()) + .pickCreatedAt(pick.getCreatedAt()) + .pickTitle(pick.getTitle().getTitle()) + .voteTotalCount(pick.getVoteTotalCount().getCount()) + .commentTotalCount(pick.getCommentTotalCount().getCount()) + .isAuthor(pick.isEqualMember(member)) + .isVoted(PickResponseUtils.isVotedMember(pick, member)) + .pickOptions(pickDetailOptions) + .build(); + } + + // 익명 회원 전용 + public static PickDetailResponseV2 of(Pick pick, Member pickMember, AnonymousMember anonymousMember, + Map pickDetailOptions) { + return PickDetailResponseV2.builder() + .isAuthor(false) + .pickCreatedAt(pick.getCreatedAt()) + .nickname(pickMember.getNickname().getNickname()) + .userId(CommonResponseUtil.sliceAndMaskEmail(pickMember.getEmail().getEmail())) + .pickTitle(pick.getTitle().getTitle()) + .voteTotalCount(pick.getVoteTotalCount().getCount()) + .commentTotalCount(pick.getCommentTotalCount().getCount()) + .pickOptions(pickDetailOptions) + .isVoted(PickResponseUtils.isVotedAnonymousMember(pick, anonymousMember)) + .build(); + } +} + 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 8c4adfb9..18afebc3 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/web/docs/PickControllerV2DocsTest.java @@ -28,11 +28,13 @@ import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; +import com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.domain.service.pick.GuestPickServiceV2; import com.dreamypatisiel.devdevdev.domain.service.pick.MemberPickServiceV2; import com.dreamypatisiel.devdevdev.global.constant.SecurityConstant; import com.dreamypatisiel.devdevdev.web.dto.SliceCustom; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.*; 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; @@ -49,6 +51,30 @@ import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.test.web.servlet.ResultActions; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +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 com.dreamypatisiel.devdevdev.web.docs.format.ApiDocsFormatGenerator.yearMonthDateTimeType; +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 { @@ -255,6 +281,141 @@ void getSimilarPicks() throws Exception { )); } + @Test + @DisplayName("회원이 픽픽픽 상세를 조회한다. V2") + void getPickDetail() throws Exception { + // given + Long pickId = 1L; + + // Mock 데이터 생성 + PickDetailOptionImageResponse image1 = PickDetailOptionImageResponse.builder() + .id(1L) + .imageUrl("http://image1.png") + .build(); + + PickDetailOptionImageResponse image2 = PickDetailOptionImageResponse.builder() + .id(2L) + .imageUrl("http://image2.png") + .build(); + + PickDetailOptionResponse firstOption = PickDetailOptionResponse.builder() + .id(1L) + .title("픽옵션1") + .isPicked(true) + .percent(new BigDecimal("60")) + .content("픽콘텐츠1") + .voteTotalCount(3L) + .pickDetailOptionImagesResponse(List.of(image1)) + .build(); + + PickDetailOptionResponse secondOption = PickDetailOptionResponse.builder() + .id(2L) + .title("픽옵션2") + .isPicked(false) + .percent(new BigDecimal("40")) + .content("픽콘텐츠2") + .voteTotalCount(2L) + .pickDetailOptionImagesResponse(List.of(image2)) + .build(); + + Map pickOptions = Map.of( + PickOptionType.firstPickOption, firstOption, + PickOptionType.secondPickOption, secondOption + ); + + PickDetailResponseV2 response = PickDetailResponseV2.builder() + .userId("dre***@gmail.com") + .nickname("꿈빛파티시엘") + .pickCreatedAt(LocalDateTime.of(2024, 1, 1, 12, 0, 0)) + .pickTitle("픽픽픽 제목") + .voteTotalCount(5L) + .commentTotalCount(10L) + .isAuthor(true) + .isVoted(true) + .pickOptions(pickOptions) + .build(); + + when(memberPickServiceV2.findPickDetail(anyLong(), any(), any(Authentication.class))) + .thenReturn(response); + + // when // then + ResultActions actions = mockMvc.perform(get("/devdevdev/api/v2/picks/{pickId}", pickId) + .contentType(MediaType.APPLICATION_JSON) + .header(AUTHORIZATION_HEADER, SecurityConstant.BEARER_PREFIX + accessToken) + .characterEncoding(StandardCharsets.UTF_8)) + .andDo(print()) + .andExpect(status().isOk()); + + // docs + actions.andDo(document("pick-detail-v2", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AUTHORIZATION_HEADER).optional().description("Bearer 엑세스 토큰"), + headerWithName("Anonymous-Member-Id").optional().description("익명 회원 아이디") + ), + pathParameters( + parameterWithName("pickId").description("픽픽픽 아이디") + ), + responseFields( + fieldWithPath("resultType").type(STRING).description("응답 결과"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + + fieldWithPath("data.nickname").type(STRING).description("픽픽픽 작성자 닉네임"), + fieldWithPath("data.userId").type(STRING).description("픽픽픽 작성자 아이디"), + fieldWithPath("data.pickCreatedAt").type(STRING).description("픽픽픽 생성 일시") + .attributes(yearMonthDateTimeType()), + fieldWithPath("data.pickTitle").type(STRING).description("픽픽픽 제목"), + fieldWithPath("data.voteTotalCount").type(NUMBER).description("픽픽픽 전체 투표 수 (NEW)"), + fieldWithPath("data.commentTotalCount").type(NUMBER).description("픽픽픽 전체 댓글 수 (NEW)"), + fieldWithPath("data.isAuthor").type(BOOLEAN).description("현재 로그인한 회원이 픽픽픽 작성자 여부"), + fieldWithPath("data.isVoted").type(BOOLEAN).description("픽픽픽 투표 여부"), + + fieldWithPath("data.pickOptions").type(OBJECT).description("픽픽픽 옵션 객체"), + + fieldWithPath("data.pickOptions.firstPickOption").type(OBJECT).description("픽픽픽 첫번째 옵션 객체"), + fieldWithPath("data.pickOptions.firstPickOption.id").type(NUMBER) + .description("첫 번째 픽픽픽 옵션 아이디"), + fieldWithPath("data.pickOptions.firstPickOption.title").type(STRING) + .description("첫 번째 픽픽픽 옵션 제목"), + fieldWithPath("data.pickOptions.firstPickOption.isPicked").type(BOOLEAN) + .description("첫 번째 픽픽픽 옵션 투표 여부"), + fieldWithPath("data.pickOptions.firstPickOption.percent").type(NUMBER) + .description("첫 번째 픽픽픽 옵션 득표율(%)"), + fieldWithPath("data.pickOptions.firstPickOption.content").type(STRING) + .description("첫 번째 픽픽픽 옵션 내용"), + fieldWithPath("data.pickOptions.firstPickOption.voteTotalCount").type(NUMBER) + .description("첫 번째 픽픽픽 옵션 득표수"), + fieldWithPath("data.pickOptions.firstPickOption.pickDetailOptionImages").type(ARRAY) + .description("첫 번째 픽픽픽 옵션 이미지 배열"), + fieldWithPath("data.pickOptions.firstPickOption.pickDetailOptionImages.[].id").type(NUMBER) + .description("첫 번째 픽픽픽 옵션 이미지 아이디"), + fieldWithPath("data.pickOptions.firstPickOption.pickDetailOptionImages.[].imageUrl").type( + STRING).description("첫 번째 픽픽픽 옵션 이미지 url"), + + fieldWithPath("data.pickOptions.secondPickOption").type(OBJECT).description("픽픽픽 두번째 옵션 객체"), + fieldWithPath("data.pickOptions.secondPickOption.id").type(NUMBER) + .description("두 번째 픽픽픽 옵션 아이디"), + fieldWithPath("data.pickOptions.secondPickOption.title").type(STRING) + .description("두 번째 픽픽픽 옵션 제목"), + fieldWithPath("data.pickOptions.secondPickOption.isPicked").type(BOOLEAN) + .description("두 번째 픽픽픽 옵션 투표 여부"), + fieldWithPath("data.pickOptions.secondPickOption.percent").type(NUMBER) + .description("두 번째 픽픽픽 옵션 득표율(%)"), + fieldWithPath("data.pickOptions.secondPickOption.content").type(STRING) + .description("두 번째 픽픽픽 옵션 내용"), + fieldWithPath("data.pickOptions.secondPickOption.voteTotalCount").type(NUMBER) + .description("두 번째 픽픽픽 옵션 득표수"), + fieldWithPath("data.pickOptions.secondPickOption.pickDetailOptionImages").type(ARRAY) + .description("두 번째 픽픽픽 옵션 이미지 배열"), + fieldWithPath("data.pickOptions.secondPickOption.pickDetailOptionImages.[].id").type(NUMBER) + .description("두 번째 픽픽픽 옵션 이미지 아이디"), + fieldWithPath("data.pickOptions.secondPickOption.pickDetailOptionImages.[].imageUrl").type( + STRING).description("두 번째 픽픽픽 옵션 이미지 url") + ) + )); + } + @Test @DisplayName("회원이 픽픽픽 검색을 조회한다.") void searchPicksMain() throws Exception { From 051de8af431d479cb7a2470b0fa16f0f07befd50 Mon Sep 17 00:00:00 2001 From: soyoung Date: Wed, 5 Nov 2025 23:07:26 +0900 Subject: [PATCH 18/22] =?UTF-8?q?feat(pick):=20=ED=94=BD=ED=94=BD=ED=94=BD?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20V2=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PickDetailResponseV2 DTO 추가 (voteTotalCount, commentTotalCount 포함) - MemberPickServiceV2, GuestPickServiceV2에 findPickDetail 구현 - PickControllerV2에 상세 조회 엔드포인트 추가 - Service 및 Controller 테스트 추가 - API 문서 추가 --- .../asciidoc/api/pick/pick-detail-v2.adoc | 34 +++++ src/docs/asciidoc/api/pick/v1/pick.adoc | 1 + .../service/pick/GuestPickServiceV2Test.java | 110 ++++++++++++++++ .../service/pick/MemberPickServiceV2Test.java | 117 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 src/docs/asciidoc/api/pick/pick-detail-v2.adoc diff --git a/src/docs/asciidoc/api/pick/pick-detail-v2.adoc b/src/docs/asciidoc/api/pick/pick-detail-v2.adoc new file mode 100644 index 00000000..b6a68da6 --- /dev/null +++ b/src/docs/asciidoc/api/pick/pick-detail-v2.adoc @@ -0,0 +1,34 @@ +[[Pick-Detail-V2]] +== 픽픽픽 상세 API V2(GET: /devdevdev/api/v2/picks/{pickId}) + +* 픽픽픽 아이디로 상세 화면을 조회할 수 있다. +* 픽픽픽 게시글의 상태가 승인 상태(`APPROVAL`)가 아니면 조회할 수 없다. +* V1과 비교하여 응답에 **투표수(voteTotalCount)**와 **댓글수(commentTotalCount)** 필드가 추가되었다. + +=== 정상 요청/응답 + +==== HTTP Request + +include::{snippets}/pick-detail-v2/http-request.adoc[] + +==== HTTP Request Header Fields + +include::{snippets}/pick-detail-v2/request-headers.adoc[] + +==== HTTP Request Path Parameters Fields + +include::{snippets}/pick-detail-v2/path-parameters.adoc[] + +==== HTTP Response + +include::{snippets}/pick-detail-v2/http-response.adoc[] + +==== HTTP Response Fields + +include::{snippets}/pick-detail-v2/response-fields.adoc[] + +=== V1과의 차이점 + +* `data.voteTotalCount`: 픽픽픽 전체 투표 수 (NEW) +* `data.commentTotalCount`: 픽픽픽 전체 댓글 수 (NEW) + diff --git a/src/docs/asciidoc/api/pick/v1/pick.adoc b/src/docs/asciidoc/api/pick/v1/pick.adoc index 7820c8d0..ccc3e9e3 100644 --- a/src/docs/asciidoc/api/pick/v1/pick.adoc +++ b/src/docs/asciidoc/api/pick/v1/pick.adoc @@ -2,6 +2,7 @@ include::pick-main.adoc[] include::pick-detail.adoc[] +include::pick-detail-v2.adoc[] include::pick-register.adoc[] include::pick-modify.adoc[] include::pick-delete.adoc[] diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java index c4c208ae..e3608f56 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java @@ -13,12 +13,18 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickOptionRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickSort; +import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailOptionResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -32,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.firstPickOption; import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; @@ -39,6 +46,7 @@ import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; import static com.dreamypatisiel.devdevdev.domain.service.pick.PickTestUtils.createPickOptionImage; import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -57,7 +65,11 @@ class GuestPickServiceV2Test { @Autowired PickOptionImageRepository pickOptionImageRepository; @Autowired + PickVoteRepository pickVoteRepository; + @Autowired MemberRepository memberRepository; + @Autowired + EntityManager em; @MockBean AnonymousMemberService anonymousMemberService; @MockBean @@ -132,6 +144,104 @@ void findPicksMain() { ); } + @Test + @DisplayName("익명 회원이 픽픽픽 상세를 조회한다. V2") + void findPickDetail() { + // given + String anonymousMemberId = "GA1.1.276672604.1715872960"; + AnonymousMember anonymousMember = AnonymousMember.builder() + .anonymousMemberId(anonymousMemberId) + .build(); + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + when(anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId)).thenReturn(anonymousMember); + + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + // 픽픽픽 생성 (투표수 2, 댓글수 7) + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(7), new Count(2), new Count(0), member, + ContentStatus.APPROVAL); + pickRepository.save(pick); + + // 픽픽픽 옵션 생성 + PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), + new Count(2), 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://image1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://image2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + + // 픽픽픽 옵션 투표 여부 + PickVote pickVote = createPickVote(anonymousMember, firstPickOption, pick); + pickVoteRepository.save(pickVote); + + em.flush(); + em.clear(); + + // when + PickDetailResponseV2 pickDetail = guestPickServiceV2.findPickDetail(pick.getId(), anonymousMemberId, authentication); + + // then + assertThat(pickDetail).isNotNull(); + assertAll( + () -> assertThat(pickDetail.getUserId()).isEqualTo( + CommonResponseUtil.sliceAndMaskEmail(member.getEmail().getEmail())), + () -> assertThat(pickDetail.getNickname()).isEqualTo(member.getNickname().getNickname()), + () -> assertThat(pickDetail.getPickTitle()).isEqualTo("픽픽픽 제목"), + () -> assertThat(pickDetail.getVoteTotalCount()).isEqualTo(2L), + () -> assertThat(pickDetail.getCommentTotalCount()).isEqualTo(7L), + () -> assertThat(pickDetail.getIsAuthor()).isEqualTo(false), + () -> assertThat(pickDetail.getIsVoted()).isEqualTo(true) + ); + + Map pickOptions = pickDetail.getPickOptions(); + PickDetailOptionResponse findFirstPickOptionResponse = pickOptions.get(PickOptionType.firstPickOption); + PickDetailOptionResponse findSecondPickOptionResponse = pickOptions.get(PickOptionType.secondPickOption); + + assertThat(findFirstPickOptionResponse).isNotNull(); + assertAll( + () -> assertThat(findFirstPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션1"), + () -> assertThat(findFirstPickOptionResponse.getIsPicked()).isEqualTo(true), + () -> assertThat(findFirstPickOptionResponse.getPercent()).isEqualTo(100), + () -> assertThat(findFirstPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션1 내용"), + () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(2L) + ); + + assertThat(findSecondPickOptionResponse).isNotNull(); + assertAll( + () -> assertThat(findSecondPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션2"), + () -> assertThat(findSecondPickOptionResponse.getIsPicked()).isEqualTo(false), + () -> assertThat(findSecondPickOptionResponse.getPercent()).isEqualTo(0), + () -> assertThat(findSecondPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션2 내용"), + () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("익명 회원이 픽픽픽 상세 조회할 때 픽픽픽이 없으면 예외가 발생한다. V2") + void findPickDetailNotFoundPickDetail() { + // given + String anonymousMemberId = "GA1.1.276672604.1715872960"; + AnonymousMember anonymousMember = AnonymousMember.builder() + .anonymousMemberId(anonymousMemberId) + .build(); + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + when(anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId)).thenReturn(anonymousMember); + + // when // then + assertThatThrownBy(() -> guestPickServiceV2.findPickDetail(0L, anonymousMemberId, authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(INVALID_NOT_FOUND_PICK_MESSAGE); + } + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, Count poplarScore, Member member, ContentStatus contentStatus) { return Pick.builder() 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 d39a0f48..4690faac 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 @@ -21,10 +21,16 @@ 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.repository.pick.PickVoteRepository; +import com.dreamypatisiel.devdevdev.exception.NotFoundException; 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.PickDetailOptionResponse; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; +import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; import jakarta.persistence.EntityManager; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -35,8 +41,19 @@ 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.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + +import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; + @SpringBootTest @Transactional class MemberPickServiceV2Test { @@ -52,6 +69,8 @@ class MemberPickServiceV2Test { @Autowired PickOptionImageRepository pickOptionImageRepository; @Autowired + PickVoteRepository pickVoteRepository; + @Autowired PickPopularScorePolicy pickPopularScorePolicy; @Autowired EntityManager em; @@ -122,6 +141,104 @@ void findPicksMain() { ); } + @Test + @DisplayName("회원이 픽픽픽 상세를 조회한다. V2") + void findPickDetail() { + // given + // 회원 생성 + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 픽픽픽 생성 (투표수 1, 댓글수 5) + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(5), 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://image1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://image2.png", secondPickOption); + pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); + + // 픽픽픽 옵션 투표 여부 + PickVote pickVote = createPickVote(member, firstPickOption, pick); + pickVoteRepository.save(pickVote); + + em.flush(); + em.clear(); + + // when + PickDetailResponseV2 pickDetail = memberPickServiceV2.findPickDetail(pick.getId(), null, authentication); + + // then + assertThat(pickDetail).isNotNull(); + assertAll( + () -> assertThat(pickDetail.getUserId()).isEqualTo( + CommonResponseUtil.sliceAndMaskEmail(member.getEmail().getEmail())), + () -> assertThat(pickDetail.getNickname()).isEqualTo(member.getNickname().getNickname()), + () -> assertThat(pickDetail.getPickTitle()).isEqualTo("픽픽픽 제목"), + () -> assertThat(pickDetail.getVoteTotalCount()).isEqualTo(1L), + () -> assertThat(pickDetail.getCommentTotalCount()).isEqualTo(5L), + () -> assertThat(pickDetail.getIsAuthor()).isEqualTo(true), + () -> assertThat(pickDetail.getIsVoted()).isEqualTo(true) + ); + + Map pickOptions = pickDetail.getPickOptions(); + PickDetailOptionResponse findFirstPickOptionResponse = pickOptions.get(PickOptionType.firstPickOption); + PickDetailOptionResponse findSecondPickOptionResponse = pickOptions.get(PickOptionType.secondPickOption); + + assertThat(findFirstPickOptionResponse).isNotNull(); + assertAll( + () -> assertThat(findFirstPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션1"), + () -> assertThat(findFirstPickOptionResponse.getIsPicked()).isEqualTo(true), + () -> assertThat(findFirstPickOptionResponse.getPercent()).isEqualTo(100), + () -> assertThat(findFirstPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션1 내용"), + () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(1L) + ); + + assertThat(findSecondPickOptionResponse).isNotNull(); + assertAll( + () -> assertThat(findSecondPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션2"), + () -> assertThat(findSecondPickOptionResponse.getIsPicked()).isEqualTo(false), + () -> assertThat(findSecondPickOptionResponse.getPercent()).isEqualTo(0), + () -> assertThat(findSecondPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션2 내용"), + () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0L) + ); + } + + @Test + @DisplayName("회원이 픽픽픽 상세 조회할 때 픽픽픽이 없으면 예외가 발생한다. V2") + void findPickDetailNotFoundPickDetail() { + // given + SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); + Member member = Member.createMemberBy(socialMemberDto); + memberRepository.save(member); + + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // when // then + assertThatThrownBy(() -> memberPickServiceV2.findPickDetail(0L, null, authentication)) + .isInstanceOf(NotFoundException.class) + .hasMessage(INVALID_NOT_FOUND_PICK_MESSAGE); + } + private Pick createPick(Title title, Count viewTotalCount, Count commentTotalCount, Count voteTotalCount, Count poplarScore, Member member, ContentStatus contentStatus) { return Pick.builder() From d1c497efc2c8b11d0e2bff3cf24a72625faf931a Mon Sep 17 00:00:00 2001 From: ssoyoung Date: Wed, 5 Nov 2025 23:32:54 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix(dockerfile):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile-dev | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile-dev b/Dockerfile-dev index 2cede7fc..580b03a0 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,4 +1,4 @@ -FROM openjdk:21-jdk +FROM eclipse-temurin:21-jdk # JAR 파일 메인 디렉토리에 복사 COPY build/libs/*.jar app.jar @@ -6,4 +6,4 @@ COPY build/libs/*.jar app.jar ENV TZ Asia/Seoul # 시스템 진입점 정의 -CMD java -jar -Dspring.profiles.active=dev /app.jar \ No newline at end of file +CMD java -jar -Dspring.profiles.active=dev /app.jar From 58d41c34f485ca279c85e1a4df89c7f6e9015955 Mon Sep 17 00:00:00 2001 From: soyoung Date: Thu, 6 Nov 2025 00:07:08 +0900 Subject: [PATCH 20/22] =?UTF-8?q?test(pick):=20=ED=94=BD=ED=94=BD=ED=94=BD?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20V2=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/pick/GuestPickServiceV2Test.java | 74 +++++++++++-------- .../service/pick/MemberPickServiceV2Test.java | 36 ++++----- 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java index e3608f56..1d209fca 100644 --- a/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java +++ b/src/test/java/com/dreamypatisiel/devdevdev/domain/service/pick/GuestPickServiceV2Test.java @@ -8,20 +8,19 @@ 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.repository.pick.PickSort; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; -import com.dreamypatisiel.devdevdev.domain.service.member.AnonymousMemberService; import com.dreamypatisiel.devdevdev.exception.NotFoundException; import com.dreamypatisiel.devdevdev.global.security.oauth2.model.SocialMemberDto; import com.dreamypatisiel.devdevdev.global.utils.AuthenticationMemberUtils; import com.dreamypatisiel.devdevdev.openai.embeddings.EmbeddingsService; +import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailOptionImageResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailOptionResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; -import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; import jakarta.persistence.EntityManager; @@ -33,22 +32,15 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; import org.springframework.security.core.Authentication; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; -import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.firstPickOption; -import static com.dreamypatisiel.devdevdev.domain.entity.enums.PickOptionType.secondPickOption; -import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_APPROVAL_STATUS_PICK_MESSAGE; import static com.dreamypatisiel.devdevdev.domain.exception.PickExceptionMessage.INVALID_NOT_FOUND_PICK_MESSAGE; -import static com.dreamypatisiel.devdevdev.domain.service.pick.PickTestUtils.createPickOptionImage; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -69,10 +61,10 @@ class GuestPickServiceV2Test { @Autowired MemberRepository memberRepository; @Autowired + AnonymousMemberRepository anonymousMemberRepository; + @Autowired EntityManager em; @MockBean - AnonymousMemberService anonymousMemberService; - @MockBean EmbeddingsService embeddingsService; String userId = "dreamy5patisiel"; @@ -145,37 +137,39 @@ void findPicksMain() { } @Test - @DisplayName("익명 회원이 픽픽픽 상세를 조회한다. V2") + @DisplayName("익명 회원이 픽픽픽 상세를 조회한다.") void findPickDetail() { // given + // 익명 회원 생성 + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); + String anonymousMemberId = "GA1.1.276672604.1715872960"; AnonymousMember anonymousMember = AnonymousMember.builder() .anonymousMemberId(anonymousMemberId) .build(); - Authentication authentication = mock(Authentication.class); - when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - when(anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId)).thenReturn(anonymousMember); + anonymousMemberRepository.save(anonymousMember); - // 회원 생성 + // 픽픽픽 작성 회원 생성 SocialMemberDto socialMemberDto = createSocialDto(userId, name, nickname, password, email, socialType, role); Member member = Member.createMemberBy(socialMemberDto); memberRepository.save(member); - // 픽픽픽 생성 (투표수 2, 댓글수 7) - Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(7), new Count(2), new Count(0), member, + // 픽픽픽 생성 + Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(5), new Count(1), new Count(0), member, ContentStatus.APPROVAL); pickRepository.save(pick); // 픽픽픽 옵션 생성 PickOption firstPickOption = createPickOption(pick, new Title("픽픽픽 옵션1"), new PickOptionContents("픽픽픽 옵션1 내용"), - new Count(2), PickOptionType.firstPickOption); + 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://image1.png", firstPickOption); - PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://image2.png", secondPickOption); + PickOptionImage firstPickOptionImage = createPickOptionImage("이미지1", "http://iamge1.png", firstPickOption); + PickOptionImage secondPickOptionImage = createPickOptionImage("이미지2", "http://iamge2.png", secondPickOption); pickOptionImageRepository.saveAll(List.of(firstPickOptionImage, secondPickOptionImage)); // 픽픽픽 옵션 투표 여부 @@ -186,7 +180,8 @@ void findPickDetail() { em.clear(); // when - PickDetailResponseV2 pickDetail = guestPickServiceV2.findPickDetail(pick.getId(), anonymousMemberId, authentication); + PickDetailResponseV2 pickDetail = guestPickServiceV2.findPickDetail(pick.getId(), anonymousMemberId, + authentication); // then assertThat(pickDetail).isNotNull(); @@ -195,35 +190,55 @@ void findPickDetail() { CommonResponseUtil.sliceAndMaskEmail(member.getEmail().getEmail())), () -> assertThat(pickDetail.getNickname()).isEqualTo(member.getNickname().getNickname()), () -> assertThat(pickDetail.getPickTitle()).isEqualTo("픽픽픽 제목"), - () -> assertThat(pickDetail.getVoteTotalCount()).isEqualTo(2L), - () -> assertThat(pickDetail.getCommentTotalCount()).isEqualTo(7L), - () -> assertThat(pickDetail.getIsAuthor()).isEqualTo(false), - () -> assertThat(pickDetail.getIsVoted()).isEqualTo(true) + () -> assertThat(pickDetail.getCommentTotalCount()).isEqualTo(5), + () -> assertThat(pickDetail.getVoteTotalCount()).isEqualTo(1), + () -> assertThat(pickDetail.getIsAuthor()).isEqualTo(false) ); Map pickOptions = pickDetail.getPickOptions(); PickDetailOptionResponse findFirstPickOptionResponse = pickOptions.get(PickOptionType.firstPickOption); PickDetailOptionResponse findSecondPickOptionResponse = pickOptions.get(PickOptionType.secondPickOption); + PickOption findFirstPickOption = pickOptionRepository.findById(findFirstPickOptionResponse.getId()).get(); assertThat(findFirstPickOptionResponse).isNotNull(); assertAll( + () -> assertThat(findFirstPickOptionResponse.getId()).isEqualTo(findFirstPickOption.getId()), () -> assertThat(findFirstPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션1"), () -> assertThat(findFirstPickOptionResponse.getIsPicked()).isEqualTo(true), () -> assertThat(findFirstPickOptionResponse.getPercent()).isEqualTo(100), () -> assertThat(findFirstPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션1 내용"), - () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(2L) + () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(1) ); + List findFirstPickOptionPickOptionImagesResponse = findFirstPickOptionResponse.getPickDetailOptionImages(); + PickOptionImage findFirstPickOptionImage = findFirstPickOption.getPickOptionImages().get(0); + assertThat(findFirstPickOptionPickOptionImagesResponse).hasSize(1) + .extracting("id", "imageUrl") + .containsExactly( + tuple(findFirstPickOptionImage.getId(), "http://iamge1.png") + ); + + PickOption findfirstPickOption = pickOptionRepository.findById(findSecondPickOptionResponse.getId()).get(); assertThat(findSecondPickOptionResponse).isNotNull(); assertAll( + () -> assertThat(findSecondPickOptionResponse.getId()).isEqualTo(findfirstPickOption.getId()), () -> assertThat(findSecondPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션2"), () -> assertThat(findSecondPickOptionResponse.getIsPicked()).isEqualTo(false), () -> assertThat(findSecondPickOptionResponse.getPercent()).isEqualTo(0), () -> assertThat(findSecondPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션2 내용"), - () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0L) + () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0) ); + + List findfirstPickOptionPickOptionImagesResponse = findSecondPickOptionResponse.getPickDetailOptionImages(); + PickOptionImage findsecondPickOptionImage = secondPickOption.getPickOptionImages().get(0); + assertThat(findfirstPickOptionPickOptionImagesResponse).hasSize(1) + .extracting("id", "imageUrl") + .containsExactly( + tuple(findsecondPickOptionImage.getId(), "http://iamge2.png") + ); } + @Test @DisplayName("익명 회원이 픽픽픽 상세 조회할 때 픽픽픽이 없으면 예외가 발생한다. V2") void findPickDetailNotFoundPickDetail() { @@ -234,7 +249,6 @@ void findPickDetailNotFoundPickDetail() { .build(); Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(AuthenticationMemberUtils.ANONYMOUS_USER); - when(anonymousMemberService.findOrCreateAnonymousMember(anonymousMemberId)).thenReturn(anonymousMember); // when // then assertThatThrownBy(() -> guestPickServiceV2.findPickDetail(0L, anonymousMemberId, authentication)) 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 4690faac..86624eba 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,14 +1,6 @@ package com.dreamypatisiel.devdevdev.domain.service.pick; -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.*; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Count; import com.dreamypatisiel.devdevdev.domain.entity.embedded.PickOptionContents; import com.dreamypatisiel.devdevdev.domain.entity.embedded.Title; @@ -23,21 +15,17 @@ import com.dreamypatisiel.devdevdev.domain.repository.pick.PickRepository; import com.dreamypatisiel.devdevdev.domain.repository.pick.PickVoteRepository; import com.dreamypatisiel.devdevdev.exception.NotFoundException; -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.PickDetailOptionResponse; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickDetailResponseV2; import com.dreamypatisiel.devdevdev.web.dto.response.pick.PickMainResponseV2; import com.dreamypatisiel.devdevdev.web.dto.util.CommonResponseUtil; 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; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -74,10 +62,6 @@ class MemberPickServiceV2Test { PickPopularScorePolicy pickPopularScorePolicy; @Autowired EntityManager em; - @MockBean - MemberProvider memberProvider; - @MockBean - EmbeddingsService embeddingsService; String userId = "dreamy5patisiel"; String name = "꿈빛파티시엘"; @@ -96,6 +80,12 @@ void findPicksMain() { Member member = Member.createMemberBy(socialMemberDto); memberRepository.save(member); + UserPrincipal userPrincipal = UserPrincipal.createByMember(member); + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(new OAuth2AuthenticationToken(userPrincipal, userPrincipal.getAuthorities(), + userPrincipal.getSocialType().name())); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + // 픽픽픽 생성 Pick pick = createPick(new Title("픽픽픽 제목"), new Count(0), new Count(0), new Count(1), new Count(0), member, ContentStatus.APPROVAL); @@ -116,7 +106,8 @@ void findPicksMain() { Pageable pageable = PageRequest.of(0, 10); // when - Slice picksMain = memberPickServiceV2.findPicksMain(pageable, null, null, null, null); + Slice picksMain = memberPickServiceV2.findPicksMain(pageable, null, null, + null, authentication); // then Pick findPick = pickRepository.findById(pick.getId()).get(); @@ -178,7 +169,6 @@ void findPickDetail() { pickVoteRepository.save(pickVote); em.flush(); - em.clear(); // when PickDetailResponseV2 pickDetail = memberPickServiceV2.findPickDetail(pick.getId(), null, authentication); @@ -200,22 +190,26 @@ void findPickDetail() { PickDetailOptionResponse findFirstPickOptionResponse = pickOptions.get(PickOptionType.firstPickOption); PickDetailOptionResponse findSecondPickOptionResponse = pickOptions.get(PickOptionType.secondPickOption); + PickOption findFirstPickOption = pickOptionRepository.findById(findFirstPickOptionResponse.getId()).get(); assertThat(findFirstPickOptionResponse).isNotNull(); assertAll( () -> assertThat(findFirstPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션1"), () -> assertThat(findFirstPickOptionResponse.getIsPicked()).isEqualTo(true), () -> assertThat(findFirstPickOptionResponse.getPercent()).isEqualTo(100), () -> assertThat(findFirstPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션1 내용"), - () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(1L) + () -> assertThat(findFirstPickOptionResponse.getVoteTotalCount()).isEqualTo(1L), + () -> assertThat(findFirstPickOption.getPickOptionImages()).hasSize(1) ); + PickOption findSecondPickOption = pickOptionRepository.findById(findSecondPickOptionResponse.getId()).get(); assertThat(findSecondPickOptionResponse).isNotNull(); assertAll( () -> assertThat(findSecondPickOptionResponse.getTitle()).isEqualTo("픽픽픽 옵션2"), () -> assertThat(findSecondPickOptionResponse.getIsPicked()).isEqualTo(false), () -> assertThat(findSecondPickOptionResponse.getPercent()).isEqualTo(0), () -> assertThat(findSecondPickOptionResponse.getContent()).isEqualTo("픽픽픽 옵션2 내용"), - () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0L) + () -> assertThat(findSecondPickOptionResponse.getVoteTotalCount()).isEqualTo(0L), + () -> assertThat(findSecondPickOption.getPickOptionImages()).hasSize(1) ); } From 568d19274d5be83014a070541d1ab60d0eabc5f9 Mon Sep 17 00:00:00 2001 From: daisy Date: Thu, 6 Nov 2025 00:12:22 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix(pick):=20=EC=83=81=EC=84=B8=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20V2=20=EB=AC=B8=EC=84=9C=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/api/pick/v1/pick.adoc | 1 - src/docs/asciidoc/api/pick/{ => v2}/pick-detail-v2.adoc | 0 src/docs/asciidoc/api/pick/v2/pick.adoc | 1 + 3 files changed, 1 insertion(+), 1 deletion(-) rename src/docs/asciidoc/api/pick/{ => v2}/pick-detail-v2.adoc (100%) diff --git a/src/docs/asciidoc/api/pick/v1/pick.adoc b/src/docs/asciidoc/api/pick/v1/pick.adoc index ccc3e9e3..7820c8d0 100644 --- a/src/docs/asciidoc/api/pick/v1/pick.adoc +++ b/src/docs/asciidoc/api/pick/v1/pick.adoc @@ -2,7 +2,6 @@ include::pick-main.adoc[] include::pick-detail.adoc[] -include::pick-detail-v2.adoc[] include::pick-register.adoc[] include::pick-modify.adoc[] include::pick-delete.adoc[] diff --git a/src/docs/asciidoc/api/pick/pick-detail-v2.adoc b/src/docs/asciidoc/api/pick/v2/pick-detail-v2.adoc similarity index 100% rename from src/docs/asciidoc/api/pick/pick-detail-v2.adoc rename to src/docs/asciidoc/api/pick/v2/pick-detail-v2.adoc diff --git a/src/docs/asciidoc/api/pick/v2/pick.adoc b/src/docs/asciidoc/api/pick/v2/pick.adoc index a8925e6a..cbacae15 100644 --- a/src/docs/asciidoc/api/pick/v2/pick.adoc +++ b/src/docs/asciidoc/api/pick/v2/pick.adoc @@ -1,5 +1,6 @@ = 픽픽픽 V2 include::pick-main-v2.adoc[] +include::pick-detail-v2.adoc[] include::pick-search-v2.adoc[] include::pick-similarity-v2.adoc[] \ No newline at end of file From 9b273e0d4f151fef1f9cc88306aa1034ce9750e7 Mon Sep 17 00:00:00 2001 From: howisitgoing Date: Sun, 16 Nov 2025 17:16:04 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix():=20=ED=94=BD=ED=94=BD=ED=94=BD=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=A4=91=EB=B3=B5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B0=9C=EC=83=9D=20=EC=9D=B4=EC=8A=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿼리 수정 --- src/main/resources/mapper/pick/Pick.xml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/resources/mapper/pick/Pick.xml b/src/main/resources/mapper/pick/Pick.xml index 481198a8..e129e519 100644 --- a/src/main/resources/mapper/pick/Pick.xml +++ b/src/main/resources/mapper/pick/Pick.xml @@ -12,24 +12,26 @@