Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> excludeIds,

@Min(1)
@Max(50)
Integer size
) {
public ImmersiveNextRequest {
if (size == null) {
size = 10;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<VoteItem> items) {

public record VoteItem(
Long voteId,
String title,
String content,
String imageUrl,
OffsetDateTime endAt,
List<OptionItem> 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<VoteEmoji, Long> 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<VoteItem> items = result.items().stream()
.map(i -> {
List<OptionItem> 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);
}
}
38 changes: 37 additions & 1 deletion src/main/java/com/ject/vs/vote/domain/VoteRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Slice<Vote> 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
""")
Expand All @@ -159,4 +159,40 @@ Slice<Vote> 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<Vote> findRandomExcluding(
@Param("now") Instant now,
@Param("excludeIds") List<Long> excludeIds,
Pageable pageable
);

/**
* 진행 중인 투표 랜덤 조회 (excludeIds 없는 첫 조회용)
*/
@Query("""
SELECT v FROM Vote v
WHERE v.endAt > :now
ORDER BY FUNCTION('RANDOM')
""")
Slice<Vote> findRandom(
@Param("now") Instant now,
Pageable pageable
);

/**
* 진행 중인 투표 총 개수 조회 (무한 순환 판단용)
*/
@Query("SELECT COUNT(v) FROM Vote v WHERE v.endAt > :now")
long countOngoing(@Param("now") Instant now);
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ public ImmersiveLiveResult getLive(Long voteId) {
return new ImmersiveLiveResult(liveOptions, 0, (int) total);
}

@Override
public ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId) {
Instant now = Instant.now(clock);
PageRequest pageable = PageRequest.of(0, size);

Slice<Vote> slice;
if (excludeIds == null || excludeIds.isEmpty()) {
slice = voteRepository.findRandom(now, pageable);
} else {
slice = voteRepository.findRandomExcluding(now, excludeIds, pageable);
}

List<ImmersiveFeedItem> 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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public interface ImmersiveVoteQueryUseCase {

ImmersiveLiveResult getLive(Long voteId);

/**
* 랜덤 다음 투표 조회 (excludeIds 제외, 무한 순환)
*/
ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId);

record ImmersiveFeedResult(List<ImmersiveFeedItem> items, Long nextCursor, boolean hasNext) {
}

Expand Down Expand Up @@ -44,4 +49,10 @@ record ImmersiveLiveResult(

record LiveOptionItem(Long optionId, long voteCount, int ratio) {
}

/**
* 랜덤 다음 투표 조회 결과 (무한 순환용)
*/
record ImmersiveNextResult(List<ImmersiveFeedItem> items) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading