From 6c6a4cd93869e46284366514973b967e5151c058 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 3 Jun 2026 22:14:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20VoteRepository=EC=97=90=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=A4=20=ED=88=AC=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findRandomExcluding: excludeIds 제외하고 랜덤 조회 - findRandom: 첫 조회용 랜덤 쿼리 - countOngoing: 진행 중 투표 개수 조회 --- .../ject/vs/vote/domain/VoteRepository.java | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java index c0f7b60..83b2c04 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java @@ -149,7 +149,7 @@ Slice findFirstPageForHomeByEndingSoon( @Query(""" SELECT v FROM Vote v WHERE v.endAt > :now - AND (v.endAt > :lastEndAt + AND (v.endAt > :lastEndAt OR (v.endAt = :lastEndAt AND v.id > :lastId)) ORDER BY v.endAt ASC, v.id ASC """) @@ -159,4 +159,40 @@ Slice findForHomeByEndingSoonWithKeyset( @Param("now") Instant now, Pageable pageable ); + + // ===== 몰입형 투표 랜덤 조회 ===== + + /** + * 진행 중인 투표 중 excludeIds를 제외하고 랜덤으로 조회 + */ + @Query(""" + SELECT v FROM Vote v + WHERE v.endAt > :now + AND v.id NOT IN :excludeIds + ORDER BY FUNCTION('RANDOM') + """) + Slice findRandomExcluding( + @Param("now") Instant now, + @Param("excludeIds") List excludeIds, + Pageable pageable + ); + + /** + * 진행 중인 투표 랜덤 조회 (excludeIds 없는 첫 조회용) + */ + @Query(""" + SELECT v FROM Vote v + WHERE v.endAt > :now + ORDER BY FUNCTION('RANDOM') + """) + Slice findRandom( + @Param("now") Instant now, + Pageable pageable + ); + + /** + * 진행 중인 투표 총 개수 조회 (무한 순환 판단용) + */ + @Query("SELECT COUNT(v) FROM Vote v WHERE v.endAt > :now") + long countOngoing(@Param("now") Instant now); } From e6f0176ae74ef0adcd9ed9fbfcc22b55fca046ed Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 3 Jun 2026 22:14:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20ImmersiveVoteQueryUseCase=EC=97=90?= =?UTF-8?q?=20getNextRandom=20=EB=A9=94=EC=84=9C=EB=93=9C=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 - getNextRandom 메서드 시그니처 정의 - ImmersiveNextResult record 추가 --- .../vs/vote/port/in/ImmersiveVoteQueryUseCase.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java index 40ff6d2..0f31b61 100644 --- a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java @@ -12,6 +12,11 @@ public interface ImmersiveVoteQueryUseCase { ImmersiveLiveResult getLive(Long voteId); + /** + * 랜덤 다음 투표 조회 (excludeIds 제외, 무한 순환) + */ + ImmersiveNextResult getNextRandom(List excludeIds, int size, Long userId, String anonymousId); + record ImmersiveFeedResult(List items, Long nextCursor, boolean hasNext) { } @@ -44,4 +49,10 @@ record ImmersiveLiveResult( record LiveOptionItem(Long optionId, long voteCount, int ratio) { } + + /** + * 랜덤 다음 투표 조회 결과 (무한 순환용) + */ + record ImmersiveNextResult(List items) { + } } From 1cf224409736757a739a44664b64b9afcc4a83e7 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 3 Jun 2026 22:15:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20ImmersiveVoteQueryService=EC=97=90?= =?UTF-8?q?=20getNextRandom=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - excludeIds 제외하고 랜덤 투표 조회 - 기존 toFeedItem 메서드 재사용 - imageUrl null 시 thumbnailUrl로 폴백 처리 --- .../vote/port/ImmersiveVoteQueryService.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java index bfc9aad..53fc114 100644 --- a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java @@ -89,6 +89,25 @@ public ImmersiveLiveResult getLive(Long voteId) { return new ImmersiveLiveResult(liveOptions, 0, (int) total); } + @Override + public ImmersiveNextResult getNextRandom(List excludeIds, int size, Long userId, String anonymousId) { + Instant now = Instant.now(clock); + PageRequest pageable = PageRequest.of(0, size); + + Slice slice; + if (excludeIds == null || excludeIds.isEmpty()) { + slice = voteRepository.findRandom(now, pageable); + } else { + slice = voteRepository.findRandomExcluding(now, excludeIds, pageable); + } + + List items = slice.getContent().stream() + .map(v -> toFeedItem(v, userId, anonymousId)) + .toList(); + + return new ImmersiveNextResult(items); + } + private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) { Long voteId = vote.getId(); long total = voteParticipationRepository.countByVoteId(voteId); @@ -140,11 +159,14 @@ private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) int commentCount = (int) chatMessageRepository.countByVoteId(voteId); + // imageFile 없이 생성된 투표는 imageUrl이 null이므로 thumbnailUrl로 폴백한다. + String imageUrl = vote.getImageUrl() != null ? vote.getImageUrl() : vote.getThumbnailUrl(); + return new ImmersiveFeedItem( voteId, vote.getTitle(), vote.getContent(), - vote.getImageUrl(), + imageUrl, vote.getEndAt(), options, voted, From 8340a8545b9e4148ad92760dded7cd47876802b2 Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 3 Jun 2026 22:15:16 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20POST=20/api/immersive-votes/next=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=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 - ImmersiveNextRequest: excludeIds, size 파라미터 - ImmersiveNextResponse: 기존 피드 응답 구조와 동일 - 무한 순환: 모든 투표 소진 시 빈 배열 반환 --- .../adapter/web/ImmersiveVoteController.java | 13 ++++ .../adapter/web/dto/ImmersiveNextRequest.java | 20 +++++ .../web/dto/ImmersiveNextResponse.java | 78 +++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java create mode 100644 src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java diff --git a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java index c575926..c485fc4 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java @@ -3,6 +3,8 @@ import com.ject.vs.config.AnonymousId; import com.ject.vs.vote.adapter.web.dto.ImmersiveFeedResponse; import com.ject.vs.vote.adapter.web.dto.ImmersiveLiveResponse; +import com.ject.vs.vote.adapter.web.dto.ImmersiveNextRequest; +import com.ject.vs.vote.adapter.web.dto.ImmersiveNextResponse; import com.ject.vs.vote.adapter.web.dto.ImmersiveParticipateResponse; import com.ject.vs.vote.adapter.web.dto.ParticipateRequest; import com.ject.vs.vote.adapter.web.dto.ShareLinkResponse; @@ -63,4 +65,15 @@ public ImmersiveLiveResponse getLive(@PathVariable Long voteId) { public ShareLinkResponse getShareLink(@PathVariable Long voteId) { return ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId)); } + + @Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환)") + @PostMapping("/next") + public ImmersiveNextResponse getNextRandom( + @RequestBody @Valid ImmersiveNextRequest request, + @AuthenticationPrincipal Long userId, + @Parameter(hidden = true) @AnonymousId String anonymousId) { + return ImmersiveNextResponse.from( + immersiveVoteQueryUseCase.getNextRandom(request.excludeIds(), request.size(), userId, anonymousId) + ); + } } diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java new file mode 100644 index 0000000..dc67b6d --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java @@ -0,0 +1,20 @@ +package com.ject.vs.vote.adapter.web.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.util.List; + +public record ImmersiveNextRequest( + List excludeIds, + + @Min(1) + @Max(50) + Integer size +) { + public ImmersiveNextRequest { + if (size == null) { + size = 10; + } + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java new file mode 100644 index 0000000..bbf0f10 --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java @@ -0,0 +1,78 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveNextResult; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; + +public record ImmersiveNextResponse(List items) { + + public record VoteItem( + Long voteId, + String title, + String content, + String imageUrl, + OffsetDateTime endAt, + List options, + MyVote myVote, + EmojiSummary emojiSummary, + String myEmoji, + int commentCount, + int currentViewerCount + ) { + } + + public record OptionItem(Long optionId, String label, Long voteCount, Integer ratio) { + } + + public record MyVote(boolean voted, Long selectedOptionId) { + } + + public record EmojiSummary(long LIKE, long SAD, long ANGRY, long WOW, long total) { + public static EmojiSummary from(Map map, long total) { + return new EmojiSummary( + map.getOrDefault(VoteEmoji.LIKE, 0L), + map.getOrDefault(VoteEmoji.SAD, 0L), + map.getOrDefault(VoteEmoji.ANGRY, 0L), + map.getOrDefault(VoteEmoji.WOW, 0L), + total + ); + } + } + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static ImmersiveNextResponse from(ImmersiveNextResult result) { + List items = result.items().stream() + .map(i -> { + List options = i.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + MyVote myVote = new MyVote(i.voted(), i.mySelectedOptionId()); + EmojiSummary emojiSummary = EmojiSummary.from(i.emojiSummary(), i.emojiTotal()); + String myEmoji = i.myEmoji() != null ? i.myEmoji().name() : null; + + return new VoteItem( + i.voteId(), + i.title(), + i.content(), + i.imageUrl(), + toKst(i.endAt()), + options, + myVote, + emojiSummary, + myEmoji, + i.commentCount(), + i.currentViewerCount() + ); + }) + .toList(); + return new ImmersiveNextResponse(items); + } +} From 117d77f6f45cab77d81b675b47662884df8b71db Mon Sep 17 00:00:00 2001 From: junhyukkkk Date: Wed, 3 Jun 2026 22:15:39 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20imageUrl=20=ED=8F=B4=EB=B0=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - imageUrl 있으면 그대로 반환 - imageUrl null이면 thumbnailUrl로 폴백 --- .../port/ImmersiveVoteQueryServiceTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java b/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java index b33184c..f810bdf 100644 --- a/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java +++ b/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java @@ -141,6 +141,34 @@ void setUp() { assertThat(result.items().get(0).mySelectedOptionId()).isEqualTo(77L); } + @Test + void imageUrl_있으면_그대로_노출() { + Vote vote = makeVote(Duration.ofHours(24)); // thumbnailUrl="t", imageUrl="img.png" + given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of()); + given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null); + + assertThat(result.items().get(0).imageUrl()).isEqualTo("img.png"); + } + + @Test + void imageUrl_null이면_thumbnailUrl로_폴백() { + Vote vote = Vote.create("몰입", null, "thumb.png", null, Duration.ofHours(24), FIXED_CLOCK); + given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of()); + given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null); + + assertThat(result.items().get(0).imageUrl()).isEqualTo("thumb.png"); + } + @Test void 미참여시_mySelectedOptionId_null() { Vote vote = makeVote(Duration.ofHours(24));