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); + } +} 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); } 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, 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) { + } } 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));