Skip to content

Commit e7ebc64

Browse files
authored
Merge pull request #219 from JECT-Study/develop
release: develop → main
2 parents 9a630c5 + c36e8c7 commit e7ebc64

7 files changed

Lines changed: 210 additions & 2 deletions

File tree

src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.ject.vs.config.AnonymousId;
44
import com.ject.vs.vote.adapter.web.dto.ImmersiveFeedResponse;
55
import com.ject.vs.vote.adapter.web.dto.ImmersiveLiveResponse;
6+
import com.ject.vs.vote.adapter.web.dto.ImmersiveNextRequest;
7+
import com.ject.vs.vote.adapter.web.dto.ImmersiveNextResponse;
68
import com.ject.vs.vote.adapter.web.dto.ImmersiveParticipateResponse;
79
import com.ject.vs.vote.adapter.web.dto.ParticipateRequest;
810
import com.ject.vs.vote.adapter.web.dto.ShareLinkResponse;
@@ -63,4 +65,15 @@ public ImmersiveLiveResponse getLive(@PathVariable Long voteId) {
6365
public ShareLinkResponse getShareLink(@PathVariable Long voteId) {
6466
return ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId));
6567
}
68+
69+
@Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환)")
70+
@PostMapping("/next")
71+
public ImmersiveNextResponse getNextRandom(
72+
@RequestBody @Valid ImmersiveNextRequest request,
73+
@AuthenticationPrincipal Long userId,
74+
@Parameter(hidden = true) @AnonymousId String anonymousId) {
75+
return ImmersiveNextResponse.from(
76+
immersiveVoteQueryUseCase.getNextRandom(request.excludeIds(), request.size(), userId, anonymousId)
77+
);
78+
}
6679
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.ject.vs.vote.adapter.web.dto;
2+
3+
import jakarta.validation.constraints.Max;
4+
import jakarta.validation.constraints.Min;
5+
6+
import java.util.List;
7+
8+
public record ImmersiveNextRequest(
9+
List<Long> excludeIds,
10+
11+
@Min(1)
12+
@Max(50)
13+
Integer size
14+
) {
15+
public ImmersiveNextRequest {
16+
if (size == null) {
17+
size = 10;
18+
}
19+
}
20+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.ject.vs.vote.adapter.web.dto;
2+
3+
import com.ject.vs.vote.domain.VoteEmoji;
4+
import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveNextResult;
5+
6+
import java.time.Instant;
7+
import java.time.OffsetDateTime;
8+
import java.time.ZoneOffset;
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
public record ImmersiveNextResponse(List<VoteItem> items) {
13+
14+
public record VoteItem(
15+
Long voteId,
16+
String title,
17+
String content,
18+
String imageUrl,
19+
OffsetDateTime endAt,
20+
List<OptionItem> options,
21+
MyVote myVote,
22+
EmojiSummary emojiSummary,
23+
String myEmoji,
24+
int commentCount,
25+
int currentViewerCount
26+
) {
27+
}
28+
29+
public record OptionItem(Long optionId, String label, Long voteCount, Integer ratio) {
30+
}
31+
32+
public record MyVote(boolean voted, Long selectedOptionId) {
33+
}
34+
35+
public record EmojiSummary(long LIKE, long SAD, long ANGRY, long WOW, long total) {
36+
public static EmojiSummary from(Map<VoteEmoji, Long> map, long total) {
37+
return new EmojiSummary(
38+
map.getOrDefault(VoteEmoji.LIKE, 0L),
39+
map.getOrDefault(VoteEmoji.SAD, 0L),
40+
map.getOrDefault(VoteEmoji.ANGRY, 0L),
41+
map.getOrDefault(VoteEmoji.WOW, 0L),
42+
total
43+
);
44+
}
45+
}
46+
47+
private static OffsetDateTime toKst(Instant instant) {
48+
return instant.atOffset(ZoneOffset.ofHours(9));
49+
}
50+
51+
public static ImmersiveNextResponse from(ImmersiveNextResult result) {
52+
List<VoteItem> items = result.items().stream()
53+
.map(i -> {
54+
List<OptionItem> options = i.options().stream()
55+
.map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio()))
56+
.toList();
57+
MyVote myVote = new MyVote(i.voted(), i.mySelectedOptionId());
58+
EmojiSummary emojiSummary = EmojiSummary.from(i.emojiSummary(), i.emojiTotal());
59+
String myEmoji = i.myEmoji() != null ? i.myEmoji().name() : null;
60+
61+
return new VoteItem(
62+
i.voteId(),
63+
i.title(),
64+
i.content(),
65+
i.imageUrl(),
66+
toKst(i.endAt()),
67+
options,
68+
myVote,
69+
emojiSummary,
70+
myEmoji,
71+
i.commentCount(),
72+
i.currentViewerCount()
73+
);
74+
})
75+
.toList();
76+
return new ImmersiveNextResponse(items);
77+
}
78+
}

src/main/java/com/ject/vs/vote/domain/VoteRepository.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Slice<Vote> findFirstPageForHomeByEndingSoon(
149149
@Query("""
150150
SELECT v FROM Vote v
151151
WHERE v.endAt > :now
152-
AND (v.endAt > :lastEndAt
152+
AND (v.endAt > :lastEndAt
153153
OR (v.endAt = :lastEndAt AND v.id > :lastId))
154154
ORDER BY v.endAt ASC, v.id ASC
155155
""")
@@ -159,4 +159,40 @@ Slice<Vote> findForHomeByEndingSoonWithKeyset(
159159
@Param("now") Instant now,
160160
Pageable pageable
161161
);
162+
163+
// ===== 몰입형 투표 랜덤 조회 =====
164+
165+
/**
166+
* 진행 중인 투표 중 excludeIds를 제외하고 랜덤으로 조회
167+
*/
168+
@Query("""
169+
SELECT v FROM Vote v
170+
WHERE v.endAt > :now
171+
AND v.id NOT IN :excludeIds
172+
ORDER BY FUNCTION('RANDOM')
173+
""")
174+
Slice<Vote> findRandomExcluding(
175+
@Param("now") Instant now,
176+
@Param("excludeIds") List<Long> excludeIds,
177+
Pageable pageable
178+
);
179+
180+
/**
181+
* 진행 중인 투표 랜덤 조회 (excludeIds 없는 첫 조회용)
182+
*/
183+
@Query("""
184+
SELECT v FROM Vote v
185+
WHERE v.endAt > :now
186+
ORDER BY FUNCTION('RANDOM')
187+
""")
188+
Slice<Vote> findRandom(
189+
@Param("now") Instant now,
190+
Pageable pageable
191+
);
192+
193+
/**
194+
* 진행 중인 투표 총 개수 조회 (무한 순환 판단용)
195+
*/
196+
@Query("SELECT COUNT(v) FROM Vote v WHERE v.endAt > :now")
197+
long countOngoing(@Param("now") Instant now);
162198
}

src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,25 @@ public ImmersiveLiveResult getLive(Long voteId) {
8989
return new ImmersiveLiveResult(liveOptions, 0, (int) total);
9090
}
9191

92+
@Override
93+
public ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId) {
94+
Instant now = Instant.now(clock);
95+
PageRequest pageable = PageRequest.of(0, size);
96+
97+
Slice<Vote> slice;
98+
if (excludeIds == null || excludeIds.isEmpty()) {
99+
slice = voteRepository.findRandom(now, pageable);
100+
} else {
101+
slice = voteRepository.findRandomExcluding(now, excludeIds, pageable);
102+
}
103+
104+
List<ImmersiveFeedItem> items = slice.getContent().stream()
105+
.map(v -> toFeedItem(v, userId, anonymousId))
106+
.toList();
107+
108+
return new ImmersiveNextResult(items);
109+
}
110+
92111
private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) {
93112
Long voteId = vote.getId();
94113
long total = voteParticipationRepository.countByVoteId(voteId);
@@ -140,11 +159,14 @@ private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId)
140159

141160
int commentCount = (int) chatMessageRepository.countByVoteId(voteId);
142161

162+
// imageFile 없이 생성된 투표는 imageUrl이 null이므로 thumbnailUrl로 폴백한다.
163+
String imageUrl = vote.getImageUrl() != null ? vote.getImageUrl() : vote.getThumbnailUrl();
164+
143165
return new ImmersiveFeedItem(
144166
voteId,
145167
vote.getTitle(),
146168
vote.getContent(),
147-
vote.getImageUrl(),
169+
imageUrl,
148170
vote.getEndAt(),
149171
options,
150172
voted,

src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ public interface ImmersiveVoteQueryUseCase {
1212

1313
ImmersiveLiveResult getLive(Long voteId);
1414

15+
/**
16+
* 랜덤 다음 투표 조회 (excludeIds 제외, 무한 순환)
17+
*/
18+
ImmersiveNextResult getNextRandom(List<Long> excludeIds, int size, Long userId, String anonymousId);
19+
1520
record ImmersiveFeedResult(List<ImmersiveFeedItem> items, Long nextCursor, boolean hasNext) {
1621
}
1722

@@ -44,4 +49,10 @@ record ImmersiveLiveResult(
4449

4550
record LiveOptionItem(Long optionId, long voteCount, int ratio) {
4651
}
52+
53+
/**
54+
* 랜덤 다음 투표 조회 결과 (무한 순환용)
55+
*/
56+
record ImmersiveNextResult(List<ImmersiveFeedItem> items) {
57+
}
4758
}

src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,34 @@ void setUp() {
141141
assertThat(result.items().get(0).mySelectedOptionId()).isEqualTo(77L);
142142
}
143143

144+
@Test
145+
void imageUrl_있으면_그대로_노출() {
146+
Vote vote = makeVote(Duration.ofHours(24)); // thumbnailUrl="t", imageUrl="img.png"
147+
given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any()))
148+
.willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false));
149+
given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of());
150+
given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of());
151+
given(voteParticipationRepository.countByVoteId(any())).willReturn(0L);
152+
153+
ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null);
154+
155+
assertThat(result.items().get(0).imageUrl()).isEqualTo("img.png");
156+
}
157+
158+
@Test
159+
void imageUrl_null이면_thumbnailUrl로_폴백() {
160+
Vote vote = Vote.create("몰입", null, "thumb.png", null, Duration.ofHours(24), FIXED_CLOCK);
161+
given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any()))
162+
.willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false));
163+
given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of());
164+
given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of());
165+
given(voteParticipationRepository.countByVoteId(any())).willReturn(0L);
166+
167+
ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null);
168+
169+
assertThat(result.items().get(0).imageUrl()).isEqualTo("thumb.png");
170+
}
171+
144172
@Test
145173
void 미참여시_mySelectedOptionId_null() {
146174
Vote vote = makeVote(Duration.ofHours(24));

0 commit comments

Comments
 (0)