diff --git a/docs/api-specs/perspectives-api.md b/docs/api-specs/perspectives-api.md index ec976cd7..e01aeaa6 100644 --- a/docs/api-specs/perspectives-api.md +++ b/docs/api-specs/perspectives-api.md @@ -147,6 +147,39 @@ } ``` +--- +## 주간 베스트 관점 조회 API +### `GET /api/v1/perspectives/weekly-best` + +- 직전 완료된 한 주(월요일 00:00 ~ 일요일 24:00, KST) 동안 눌린 좋아요 수 기준으로 최대 25개를 반환합니다. 화면의 5개씩 페이지 처리는 앱에서 합니다. +- 정렬: 해당 주 좋아요 수 내림차순 → 최신 관점 → id 내림차순 +- 대상: `PUBLISHED` 관점 중 공개 배틀의 일반 관점. 클래스 관점은 제외합니다. +- 토큰은 선택입니다. 토큰이 없으면 `participated` 는 모두 `false` 이고, 만료된 토큰을 보내면 401 입니다. +- `participated` 는 사후투표 완료 여부입니다. `true` 면 해당 관점으로, `false` 면 배틀 진입점으로 이동합니다. + +#### 성공 응답 `200 OK` + +```json +{ + "statusCode": 200, + "data": { + "weekStart": "2026-11-02", + "weekEnd": "2026-11-08", + "items": [ + { + "perspectiveId": 1, + "battleId": 10, + "battleTitle": "배틀 제목", + "content": "관점 본문", + "likeCount": 50, + "participated": true + } + ] + }, + "error": null +} +``` + --- ## 내 PENDING 관점 조회 API ### `GET /api/v1/battles/{battle_id}/perspectives/me/pending` diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index b71973cf..0ed0fff6 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -84,6 +84,14 @@ public JwtFilter(JwtProvider jwtProvider, UserDailyActivityRepository userDailyA "/error" ); + /** + * 토큰이 없어도 통과하지만, 토큰이 있으면 검증해 사용자를 식별하는 경로. + * WHITELIST는 필터 자체를 건너뛰어 로그인 사용자도 식별되지 않으므로 분리한다. + */ + private static final Set OPTIONAL_AUTH_PATHS = Set.of( + "/api/v1/perspectives/weekly-best" + ); + @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String requestUri = request.getRequestURI(); @@ -127,7 +135,7 @@ protected void doFilterInternal(HttpServletRequest request, SecurityContextHolder.getContext().setAuthentication(authentication); recordActivity(userId); - } else { + } else if (!OPTIONAL_AUTH_PATHS.contains(requestUri)) { // shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단 log.warn("[JwtFilter] Token missing for secured URI: {}", requestUri); setErrorResponse(response, ErrorCode.AUTH_UNAUTHORIZED); diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 842cc265..1ade2e1f 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -7,7 +7,9 @@ import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; import com.swyp.picke.domain.perspective.dto.response.PerspectiveListResponse; import com.swyp.picke.domain.perspective.dto.response.UpdatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse; import com.swyp.picke.domain.perspective.service.PerspectiveService; +import com.swyp.picke.domain.perspective.service.WeeklyBestPerspectiveService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -33,6 +35,14 @@ public class PerspectiveController { private final PerspectiveService perspectiveService; + private final WeeklyBestPerspectiveService weeklyBestPerspectiveService; + + @Operation(summary = "주간 베스트 관점 조회", description = "직전 한 주 동안 좋아요를 많이 받은 관점을 최대 25개 조회합니다. 토큰 없이 호출하면 participated 는 false 입니다.") + @GetMapping("/perspectives/weekly-best") + public ApiResponse getWeeklyBestPerspectives( + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(weeklyBestPerspectiveService.getWeeklyBest(userId)); + } @Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.") @GetMapping("/perspectives/{perspectiveId}") diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java b/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java new file mode 100644 index 00000000..256520d0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/response/WeeklyBestPerspectiveResponse.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.perspective.dto.response; + +import java.time.LocalDate; +import java.util.List; + +public record WeeklyBestPerspectiveResponse( + LocalDate weekStart, + LocalDate weekEnd, + List items +) { + public record Item( + Long perspectiveId, + Long battleId, + String battleTitle, + String content, + long likeCount, + boolean participated + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java index 9764b4a0..d608e15c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveLikeRepository.java @@ -1,13 +1,17 @@ package com.swyp.picke.domain.perspective.repository; +import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -33,5 +37,25 @@ select count(l) from PerspectiveLike l long countReceivedByUserInClass(@org.springframework.data.repository.query.Param("classId") Long classId, @org.springframework.data.repository.query.Param("userId") Long userId); + // 주간 베스트 관점: 기간 안에 눌린 좋아요 수 기준. 취소된 좋아요는 행이 삭제되어 자동으로 빠진다 + @Query(""" + select p.id as perspectiveId, count(l) as likeCount + from PerspectiveLike l + join l.perspective p + join p.battle b + where l.createdAt >= :start + and l.createdAt < :end + and p.classroom is null + and p.status = :perspectiveStatus + and b.status = :battleStatus + group by p.id, p.createdAt + order by count(l) desc, p.createdAt desc, p.id desc + """) + List findWeeklyLikeRanking(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("perspectiveStatus") PerspectiveStatus perspectiveStatus, + @Param("battleStatus") BattleStatus battleStatus, + Pageable pageable); + void deleteAllByPerspective(Perspective perspective); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java index bb58c20b..686d0d74 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java @@ -51,4 +51,7 @@ public interface PerspectiveRepository extends JpaRepository // 베댓 보상 배치: 일반 배틀 의견만 List findByBattleIdAndClassroomIsNullAndStatusOrderByLikeCountDescCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); + + @Query("select p from Perspective p join fetch p.battle where p.id in :ids") + List findAllWithBattleByIdIn(@Param("ids") List ids); } diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java b/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java new file mode 100644 index 00000000..858ad7ae --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/projection/WeeklyPerspectiveLikeCount.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.perspective.repository.projection; + +public interface WeeklyPerspectiveLikeCount { + + Long getPerspectiveId(); + + long getLikeCount(); +} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java new file mode 100644 index 00000000..07ca21d0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/perspective/service/WeeklyBestPerspectiveService.java @@ -0,0 +1,88 @@ +package com.swyp.picke.domain.perspective.service; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.repository.UserBattleRepository; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 직전 완료된 한 주(월~일, KST) 동안 좋아요를 많이 받은 일반 배틀 관점을 조회한다. + * 집계 테이블 없이 기간을 고정해 계산하므로 숨김 처리된 관점은 바로 빠진다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WeeklyBestPerspectiveService { + + private static final int MAX_ITEMS = 25; + + private final PerspectiveLikeRepository perspectiveLikeRepository; + private final PerspectiveRepository perspectiveRepository; + private final UserBattleRepository userBattleRepository; + private final Clock clock; + + public WeeklyBestPerspectiveResponse getWeeklyBest(Long userId) { + LocalDate thisMonday = LocalDate.now(clock).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekStart = thisMonday.minusWeeks(1); + + List ranking = perspectiveLikeRepository.findWeeklyLikeRanking( + weekStart.atStartOfDay(), + thisMonday.atStartOfDay(), + PerspectiveStatus.PUBLISHED, + BattleStatus.PUBLISHED, + PageRequest.of(0, MAX_ITEMS)); + if (ranking.isEmpty()) { + return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), List.of()); + } + + Map perspectives = perspectiveRepository + .findAllWithBattleByIdIn(ranking.stream().map(WeeklyPerspectiveLikeCount::getPerspectiveId).toList()) + .stream() + .collect(Collectors.toMap(Perspective::getId, Function.identity())); + Set participatedBattleIds = findParticipatedBattleIds(userId, perspectives.values()); + + List items = ranking.stream() + .map(rank -> { + Perspective perspective = perspectives.get(rank.getPerspectiveId()); + Long battleId = perspective.getBattle().getId(); + return new WeeklyBestPerspectiveResponse.Item( + perspective.getId(), + battleId, + perspective.getBattle().getTitle(), + perspective.getContent(), + rank.getLikeCount(), + participatedBattleIds.contains(battleId)); + }) + .toList(); + return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), items); + } + + // 참여 기준은 관점 작성 조건과 같은 사후투표 완료(COMPLETED)다. 비로그인은 미참여로 본다. + private Set findParticipatedBattleIds(Long userId, java.util.Collection perspectives) { + if (userId == null) { + return Set.of(); + } + Set battleIds = perspectives.stream() + .map(perspective -> perspective.getBattle().getId()) + .collect(Collectors.toSet()); + return userBattleRepository.findBattleIdsByUserAndStep(userId, battleIds, UserBattleStep.COMPLETED); + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java index 30e98539..de392128 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserBattleRepository.java @@ -27,4 +27,16 @@ Set findClassroomIdsWithActivity(@Param("userId") Long userId, // 클래스 결과 집계·리포트 대상 선별: 이 클래스 안의 진행 기록 전체 (user 는 LAZY) List findByClassroomId(Long classroomId); + + // 일반 배틀 진행 단계가 step 인 배틀만 추린다 + @Query(""" + select ub.battle.id from UserBattle ub + where ub.user.id = :userId + and ub.classroom is null + and ub.step = :step + and ub.battle.id in :battleIds + """) + Set findBattleIdsByUserAndStep(@Param("userId") Long userId, + @Param("battleIds") Collection battleIds, + @Param("step") com.swyp.picke.domain.user.enums.UserBattleStep step); } diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 70507baf..1778d86d 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -72,6 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 3. 단순 조회성 및 진행상태 업데이트 REST API .requestMatchers(HttpMethod.GET, "/api/v1/share/recap/*").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/perspectives/weekly-best").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/tags", "/api/v1/battles/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/v1/battles/**").authenticated() diff --git a/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java new file mode 100644 index 00000000..c7d4a1ef --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/perspective/controller/WeeklyBestPerspectiveApiIntegrationTest.java @@ -0,0 +1,249 @@ +package com.swyp.picke.domain.perspective.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.entity.PerspectiveLike; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserBattle; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserBattleRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 주간 베스트 관점(#626)을 HTTP 로 검증한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class WeeklyBestPerspectiveApiIntegrationTest { + + private static final String URL = "/api/v1/perspectives/weekly-best"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtProvider jwtProvider; + @Autowired private EntityManager entityManager; + @Autowired private TransactionTemplate transactionTemplate; + @Autowired private UserRepository userRepository; + @Autowired private UserBattleRepository userBattleRepository; + @Autowired private BattleRepository battleRepository; + @Autowired private BattleOptionRepository battleOptionRepository; + @Autowired private PerspectiveRepository perspectiveRepository; + @Autowired private PerspectiveLikeRepository perspectiveLikeRepository; + + private final List userBattleIds = new ArrayList<>(); + private final List likeIds = new ArrayList<>(); + private final List perspectiveIds = new ArrayList<>(); + private final List optionIds = new ArrayList<>(); + private final List battleIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + + private LocalDateTime weekStart; + private LocalDateTime weekEnd; + private User viewer; + private User liker1; + private User liker2; + + @BeforeEach + void setUp() { + LocalDate thisMonday = LocalDate.now(ZoneId.of("Asia/Seoul")) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + weekStart = thisMonday.minusWeeks(1).atStartOfDay(); + weekEnd = thisMonday.atStartOfDay(); + viewer = newUser(); + liker1 = newUser(); + liker2 = newUser(); + } + + @AfterEach + void tearDown() { + perspectiveLikeRepository.deleteAllById(likeIds); + perspectiveRepository.deleteAllById(perspectiveIds); + userBattleRepository.deleteAllById(userBattleIds); + battleOptionRepository.deleteAllById(optionIds); + battleRepository.deleteAllById(battleIds); + userRepository.deleteAllById(userIds); + } + + @Test + @DisplayName("직전 주에 눌린 좋아요 수로 정렬하고, 기간 밖 좋아요와 비공개 대상은 뺀다") + void ranksByLikesInPreviousWeek() throws Exception { + Battle battle = battle(BattleStatus.PUBLISHED); + Perspective second = perspective(battle); + like(second, liker1, weekStart); + like(second, liker2, weekEnd.minusDays(8)); // 지지난 주 + Perspective first = perspective(battle); + like(first, liker1, weekEnd.minusSeconds(1)); + like(first, liker2, weekStart.plusDays(2)); + Perspective onlyThisWeek = perspective(battle); + like(onlyThisWeek, liker1, weekEnd); + Perspective hidden = perspective(battle); + hidden.hide(); + perspectiveRepository.save(hidden); + like(hidden, liker1, weekStart.plusDays(1)); + Perspective archivedBattle = perspective(battle(BattleStatus.ARCHIVED)); + like(archivedBattle, liker1, weekStart.plusDays(1)); + + JsonNode data = fetch(tokenOf(viewer)); + + assertThat(data.get("weekStart").asText()).isEqualTo(weekStart.toLocalDate().toString()); + assertThat(data.get("weekEnd").asText()).isEqualTo(weekEnd.toLocalDate().minusDays(1).toString()); + List items = ownItems(data); + assertThat(items).extracting(item -> item.get("perspectiveId").asLong()) + .containsExactly(first.getId(), second.getId()); + assertThat(items).extracting(item -> item.get("likeCount").asLong()).containsExactly(2L, 1L); + assertThat(items.get(0).get("battleId").asLong()).isEqualTo(battle.getId()); + assertThat(items.get(0).get("battleTitle").asText()).isEqualTo(battle.getTitle()); + assertThat(items.get(0).get("content").asText()).isEqualTo("관점 본문"); + } + + @Test + @DisplayName("사후투표까지 완료한 배틀만 participated 가 true 다") + void participatedOnlyAfterPostVote() throws Exception { + Perspective completed = perspective(battle(BattleStatus.PUBLISHED)); + Perspective preVoteOnly = perspective(battle(BattleStatus.PUBLISHED)); + Perspective notJoined = perspective(battle(BattleStatus.PUBLISHED)); + like(completed, liker1, weekStart.plusDays(1)); + like(preVoteOnly, liker1, weekStart.plusDays(1)); + like(notJoined, liker1, weekStart.plusDays(1)); + step(completed.getBattle(), UserBattleStep.COMPLETED); + step(preVoteOnly.getBattle(), UserBattleStep.TTS_LISTENING); + + List items = ownItems(fetch(tokenOf(viewer))); + + assertThat(participated(items, completed)).isTrue(); + assertThat(participated(items, preVoteOnly)).isFalse(); + assertThat(participated(items, notJoined)).isFalse(); + } + + @Test + @DisplayName("토큰 없이도 목록을 보고 participated 는 false 다. 잘못된 토큰은 401 이다") + void tokenIsOptionalButValidated() throws Exception { + Perspective perspective = perspective(battle(BattleStatus.PUBLISHED)); + like(perspective, liker1, weekStart.plusDays(1)); + step(perspective.getBattle(), UserBattleStep.COMPLETED); + + List items = ownItems(fetch(null)); + + assertThat(items).hasSize(1); + assertThat(participated(items, perspective)).isFalse(); + mockMvc.perform(get(URL).header("Authorization", "Bearer invalid-token")) + .andExpect(status().isUnauthorized()); + } + + private JsonNode fetch(String token) throws Exception { + var request = get(URL); + if (token != null) { + request.header("Authorization", "Bearer " + token); + } + String body = mockMvc.perform(request) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + return objectMapper.readTree(body).get("data"); + } + + private List ownItems(JsonNode data) { + List items = new ArrayList<>(); + data.get("items").forEach(item -> { + if (perspectiveIds.contains(item.get("perspectiveId").asLong())) { + items.add(item); + } + }); + return items; + } + + private boolean participated(List items, Perspective perspective) { + return items.stream() + .filter(item -> item.get("perspectiveId").asLong() == perspective.getId()) + .findFirst().orElseThrow() + .get("participated").asBoolean(); + } + + private void step(Battle battle, UserBattleStep step) { + userBattleIds.add(userBattleRepository.save(UserBattle.builder() + .user(viewer).battle(battle).step(step).build()).getId()); + } + + private void like(Perspective perspective, User user, LocalDateTime createdAt) { + PerspectiveLike like = perspectiveLikeRepository.save(PerspectiveLike.builder() + .perspective(perspective).user(user).build()); + likeIds.add(like.getId()); + // JdbcTemplate 은 hibernate.jdbc.time_zone 변환을 거치지 않아 JVM 시간대가 KST 가 아니면 조회 조건과 어긋난다 + transactionTemplate.executeWithoutResult(status -> entityManager + .createQuery("update PerspectiveLike l set l.createdAt = :createdAt where l.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", like.getId()) + .executeUpdate()); + } + + private Perspective perspective(Battle battle) { + BattleOption option = battleOptionRepository.findByBattle(battle).get(0); + Perspective perspective = Perspective.builder() + .battle(battle).user(liker1).option(option).content("관점 본문").build(); + perspective.publish(); + Perspective saved = perspectiveRepository.save(perspective); + perspectiveIds.add(saved.getId()); + return saved; + } + + private Battle battle(BattleStatus status) { + String title = "주간베스트-" + UUID.randomUUID().toString().substring(0, 8); + Battle battle = battleRepository.save(Battle.builder() + .title(title).summary("요약").description("설명").audioDuration(300) + .status(status).creatorType(BattleCreatorType.ADMIN).build()); + battleIds.add(battle.getId()); + optionIds.add(battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("찬성").stance("근거").representative("칸트").displayOrder(1).build()).getId()); + return battle; + } + + private User newUser() { + User user = userRepository.save(User.builder() + .userTag("wb-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("주간베스트") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build()); + userIds.add(user.getId()); + return user; + } + + private String tokenOf(User user) { + return jwtProvider.createAccessToken(user.getId(), UserRole.USER.name()); + } +}