diff --git a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java index 3c7d63a..2cf1dd9 100644 --- a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java @@ -54,7 +54,13 @@ public TodayBattleResponse toTodayResponse(Battle b, List tags, List tags, // 상단 태그 리스트 - List options // 중앙 세로형 대결 카드 데이터 + List options, // 중앙 세로형 대결 카드 데이터 + // 퀴즈·투표 전용 필드 + String titlePrefix, // 투표 접두사 (예: "도덕의 기준은") + String titleSuffix, // 투표 접미사 (예: "이다") + String itemA, // 퀴즈 O 선택지 + String itemADesc, // 퀴즈 O 설명 + String itemB, // 퀴즈 X 선택지 + String itemBDesc // 퀴즈 X 설명 ) {} diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java index eef4f44..4c888e0 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java @@ -54,4 +54,22 @@ public interface BattleRepository extends JpaRepository { // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); + + // 탐색 탭: 전체 배틀 검색 (정렬은 Pageable Sort로 처리) + @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + List searchAll(Pageable pageable); + + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + long countSearchAll(); + + // 탐색 탭: 카테고리 태그 필터 배틀 검색 + @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); + + @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + long countSearchByCategory(@Param("categoryName") String categoryName); } \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java index e324144..1dc6d7b 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java @@ -10,8 +10,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.swyp.app.domain.tag.enums.TagType; + import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -58,4 +61,20 @@ public Map getTopTagsByBattleIds(List battleIds, int limit) java.util.LinkedHashMap::new )); } + + public Optional getTopPhilosopherTagName(List battleIds) { + if (battleIds.isEmpty()) return Optional.empty(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + + return battleTags.stream() + .filter(bt -> bt.getTag().getType() == TagType.PHILOSOPHER) + .collect(Collectors.groupingBy( + bt -> bt.getTag().getName(), + Collectors.counting() + )) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey); + } } diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java new file mode 100644 index 0000000..8568898 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeBestBattleResponse( + Long battleId, + String philosopherA, + String philosopherB, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java new file mode 100644 index 0000000..24862c7 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java @@ -0,0 +1,16 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeEditorPickResponse( + Long battleId, + String thumbnailUrl, + String optionATitle, + String optionBTitle, + String title, + String summary, + List tags, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java similarity index 57% rename from src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java rename to src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java index c00da9d..52e883b 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java @@ -1,20 +1,19 @@ package com.swyp.app.domain.home.dto.response; import com.swyp.app.domain.battle.dto.response.BattleTagResponse; -import com.swyp.app.domain.battle.enums.BattleType; import java.util.List; -public record HomeBattleResponse( +public record HomeNewBattleResponse( Long battleId, + String thumbnailUrl, String title, String summary, - String thumbnailUrl, - BattleType type, - Integer viewCount, - Long participantsCount, - Integer audioDuration, + String philosopherA, + String philosopherAImageUrl, + String philosopherB, + String philosopherBImageUrl, List tags, - List options -) { -} + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java index 525680a..8aa1b67 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java @@ -4,10 +4,10 @@ public record HomeResponse( boolean newNotice, - List editorPicks, - List trendingBattles, - List bestBattles, - List todayPicks, - List newBattles -) { -} + List editorPicks, + List trendingBattles, + List bestBattles, + List todayQuizzes, + List todayVotes, + List newBattles +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java new file mode 100644 index 0000000..00eb2b1 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java @@ -0,0 +1,12 @@ +package com.swyp.app.domain.home.dto.response; + +public record HomeTodayQuizResponse( + Long battleId, + String title, + String summary, + Long participantsCount, + String itemA, + String itemADesc, + String itemB, + String itemBDesc +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java similarity index 67% rename from src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java rename to src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java index b2ce088..0c3f73d 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java @@ -2,8 +2,7 @@ import com.swyp.app.domain.battle.enums.BattleOptionLabel; -public record HomeBattleOptionResponse( +public record HomeTodayVoteOptionResponse( BattleOptionLabel label, - String text -) { -} + String title +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java new file mode 100644 index 0000000..33fd5b1 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java @@ -0,0 +1,12 @@ +package com.swyp.app.domain.home.dto.response; + +import java.util.List; + +public record HomeTodayVoteResponse( + Long battleId, + String titlePrefix, + String titleSuffix, + String summary, + Long participantsCount, + List options +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java new file mode 100644 index 0000000..30d1a2a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java @@ -0,0 +1,14 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeTrendingResponse( + Long battleId, + String thumbnailUrl, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 4919897..ee31bb8 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -1,20 +1,21 @@ package com.swyp.app.domain.home.service; +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.tag.enums.TagType; import com.swyp.app.domain.battle.service.BattleService; -import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; -import com.swyp.app.domain.home.dto.response.HomeBattleResponse; -import com.swyp.app.domain.home.dto.response.HomeResponse; +import com.swyp.app.domain.home.dto.response.*; import com.swyp.app.domain.notice.enums.NoticePlacement; import com.swyp.app.domain.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -29,59 +30,119 @@ public class HomeService { public HomeResponse getHome() { boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); - List editorPicks = toHomeBattles(battleService.getEditorPicks()); - List trendingBattles = toHomeBattles(battleService.getTrendingBattles()); - List bestBattles = toHomeBattles(battleService.getBestBattles()); + List editorPickRaw = battleService.getEditorPicks(); + List trendingRaw = battleService.getTrendingBattles(); + List bestRaw = battleService.getBestBattles(); + List voteRaw = battleService.getTodayPicks(BattleType.VOTE); + List quizRaw = battleService.getTodayPicks(BattleType.QUIZ); - List todayPicks = new ArrayList<>(); - todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.VOTE))); - todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.QUIZ))); - - List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); - List newBattles = toHomeBattles(battleService.getNewBattles(excludeIds)); + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); + List newRaw = battleService.getNewBattles(excludeIds); return new HomeResponse( newNotice, - editorPicks, - trendingBattles, - bestBattles, - todayPicks, - newBattles + editorPickRaw.stream().map(this::toEditorPick).toList(), + trendingRaw.stream().map(this::toTrending).toList(), + bestRaw.stream().map(this::toBestBattle).toList(), + quizRaw.stream().map(this::toTodayQuiz).toList(), + voteRaw.stream().map(this::toTodayVote).toList(), + newRaw.stream().map(this::toNewBattle).toList() + ); + } + + private HomeEditorPickResponse toEditorPick(TodayBattleResponse b) { + String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); + String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); + return new HomeEditorPickResponse( + b.battleId(), b.thumbnailUrl(), + optionA, optionB, + b.title(), b.summary(), + b.tags(), b.viewCount() + ); + } + + private HomeTrendingResponse toTrending(TodayBattleResponse b) { + return new HomeTrendingResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.tags(), + b.audioDuration(), b.viewCount() + ); + } + + private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { + List philosophers = findPhilosopherNames(b.tags()); + String philoA = philosophers.size() > 0 ? philosophers.get(0) : null; + String philoB = philosophers.size() > 1 ? philosophers.get(1) : null; + return new HomeBestBattleResponse( + b.battleId(), + philoA, philoB, + b.title(), b.tags(), + b.audioDuration(), b.viewCount() ); } - private List toHomeBattles(List battles) { - return battles.stream() - .map(this::toHomeBattle) + private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { + return new HomeTodayQuizResponse( + b.battleId(), b.title(), b.summary(), + b.participantsCount(), + b.itemA(), b.itemADesc(), + b.itemB(), b.itemBDesc() + ); + } + + private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { + List options = b.options().stream() + .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) .toList(); + return new HomeTodayVoteResponse( + b.battleId(), + b.titlePrefix(), b.titleSuffix(), + b.summary(), b.participantsCount(), + options + ); } - private HomeBattleResponse toHomeBattle(TodayBattleResponse battle) { - return new HomeBattleResponse( - battle.battleId(), - battle.title(), - battle.summary(), - battle.thumbnailUrl(), - battle.type(), - battle.viewCount(), - battle.participantsCount(), - battle.audioDuration(), - battle.tags(), - battle.options().stream() - .map(this::toHomeOption) - .toList() + private HomeNewBattleResponse toNewBattle(TodayBattleResponse b) { + List philosophers = findPhilosopherNames(b.tags()); + String philoA = philosophers.size() > 0 ? philosophers.get(0) : null; + String philoB = philosophers.size() > 1 ? philosophers.get(1) : null; + String imageA = findOptionImageUrl(b.options(), BattleOptionLabel.A); + String imageB = findOptionImageUrl(b.options(), BattleOptionLabel.B); + return new HomeNewBattleResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.summary(), + philoA, imageA, + philoB, imageB, + b.tags(), b.audioDuration(), b.viewCount() ); } - private HomeBattleOptionResponse toHomeOption(TodayOptionResponse option) { - return new HomeBattleOptionResponse(option.label(), option.title()); + private String findOptionTitle(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::title) + .findFirst().orElse(null); + } + + private List findPhilosopherNames(List tags) { + return Optional.ofNullable(tags).orElse(List.of()).stream() + .filter(t -> t.type() == TagType.PHILOSOPHER) + .map(BattleTagResponse::name) + .toList(); + } + + private String findOptionImageUrl(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::imageUrl) + .findFirst().orElse(null); } @SafeVarargs - private List collectBattleIds(List... groups) { + private List collectBattleIds(List... groups) { return List.of(groups).stream() .flatMap(List::stream) - .map(HomeBattleResponse::battleId) + .map(TodayBattleResponse::battleId) .distinct() .toList(); } diff --git a/src/main/java/com/swyp/app/domain/search/controller/SearchController.java b/src/main/java/com/swyp/app/domain/search/controller/SearchController.java new file mode 100644 index 0000000..e3468ed --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/controller/SearchController.java @@ -0,0 +1,29 @@ +package com.swyp.app.domain.search.controller; + +import com.swyp.app.domain.search.dto.response.SearchBattleListResponse; +import com.swyp.app.domain.search.enums.SearchSortType; +import com.swyp.app.domain.search.service.SearchService; +import com.swyp.app.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/search") +public class SearchController { + + private final SearchService searchService; + + @GetMapping("/battles") + public ApiResponse searchBattles( + @RequestParam(required = false) String category, + @RequestParam(required = false) SearchSortType sort, + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(searchService.searchBattles(category, sort, offset, size)); + } +} diff --git a/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java b/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java new file mode 100644 index 0000000..bb5b768 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java @@ -0,0 +1,25 @@ +package com.swyp.app.domain.search.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; +import com.swyp.app.domain.battle.enums.BattleType; + +import java.util.List; + +public record SearchBattleListResponse( + List items, + Integer nextOffset, + boolean hasNext +) { + + public record SearchBattleItem( + Long battleId, + String thumbnailUrl, + BattleType type, + String title, + String summary, + List tags, + Integer audioDuration, + Integer viewCount + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java b/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java new file mode 100644 index 0000000..e0ace4a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.search.enums; + +public enum SearchSortType { + POPULAR, + LATEST +} diff --git a/src/main/java/com/swyp/app/domain/search/service/SearchService.java b/src/main/java/com/swyp/app/domain/search/service/SearchService.java new file mode 100644 index 0000000..713442c --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/service/SearchService.java @@ -0,0 +1,90 @@ +package com.swyp.app.domain.search.service; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.search.dto.response.SearchBattleListResponse; +import com.swyp.app.domain.search.enums.SearchSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SearchService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final BattleRepository battleRepository; + private final BattleTagRepository battleTagRepository; + + public SearchBattleListResponse searchBattles(String category, SearchSortType sort, Integer offset, Integer size) { + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + SearchSortType sortType = sort == null ? SearchSortType.POPULAR : sort; + + Sort pageSort = sortType == SearchSortType.LATEST + ? Sort.by(Sort.Direction.DESC, "createdAt") + : Sort.by(Sort.Direction.DESC, "viewCount"); + Pageable pageable = PageRequest.of(pageOffset / pageSize, pageSize, pageSort); + + List battles; + long totalCount; + + if (category == null || category.isBlank()) { + battles = battleRepository.searchAll(pageable); + totalCount = battleRepository.countSearchAll(); + } else { + battles = battleRepository.searchByCategory(category, pageable); + totalCount = battleRepository.countSearchByCategory(category); + } + + Map> tagMap = loadTagMap(battles); + + List items = battles.stream() + .map(battle -> new SearchBattleListResponse.SearchBattleItem( + battle.getId(), + battle.getThumbnailUrl(), + battle.getType(), + battle.getTitle(), + battle.getSummary(), + tagMap.getOrDefault(battle.getId(), List.of()), + battle.getAudioDuration(), + battle.getViewCount() + )) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new SearchBattleListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + private Map> loadTagMap(List battles) { + List battleIds = battles.stream().map(Battle::getId).toList(); + if (battleIds.isEmpty()) return Map.of(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + return battleTags.stream() + .collect(Collectors.groupingBy( + bt -> bt.getBattle().getId(), + Collectors.mapping( + bt -> new BattleTagResponse( + bt.getTag().getId(), + bt.getTag().getName(), + bt.getTag().getType() + ), + Collectors.toList() + ) + )); + } +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java index 9804cf3..ab5149a 100644 --- a/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java +++ b/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java @@ -16,12 +16,18 @@ public record ProfileInfo( String userTag, String nickname, CharacterType characterType, + String characterLabel, + String characterImageUrl, BigDecimal mannerTemperature ) { } public record PhilosopherInfo( - PhilosopherType philosopherType + PhilosopherType philosopherType, + String philosopherLabel, + String typeName, + String description, + String imageUrl ) { } diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java index 7d7f245..ac76fe9 100644 --- a/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java +++ b/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java @@ -13,7 +13,11 @@ public record RecapResponse( ) { public record PhilosopherCard( - PhilosopherType philosopherType + PhilosopherType philosopherType, + String philosopherLabel, + String typeName, + String description, + String imageUrl ) { } diff --git a/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java b/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java index e26e5b6..4bc8cf0 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java +++ b/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java @@ -1,36 +1,32 @@ package com.swyp.app.domain.user.entity; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; import java.util.Arrays; +@Getter public enum CharacterType { - OWL("owl"), - FOX("fox"), - WOLF("wolf"), - LION("lion"), - PENGUIN("penguin"), - BEAR("bear"), - RABBIT("rabbit"), - CAT("cat"); + OWL("부엉이", "images/characters/owl.png"), + FOX("여우", "images/characters/fox.png"), + WOLF("늑대", "images/characters/wolf.png"), + LION("사자", "images/characters/lion.png"), + PENGUIN("펭귄", "images/characters/penguin.png"), + BEAR("곰", "images/characters/bear.png"), + RABBIT("토끼", "images/characters/rabbit.png"), + CAT("고양이", "images/characters/cat.png"); - private final String value; + private final String label; + private final String imageKey; - CharacterType(String value) { - this.value = value; + CharacterType(String label, String imageKey) { + this.label = label; + this.imageKey = imageKey; } - @JsonValue - public String getValue() { - return value; - } - - @JsonCreator - public static CharacterType from(String value) { + public static CharacterType from(String input) { return Arrays.stream(values()) - .filter(type -> type.value.equalsIgnoreCase(value)) + .filter(type -> type.name().equalsIgnoreCase(input) || type.label.equals(input)) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown character type: " + value)); + .orElseThrow(() -> new IllegalArgumentException("Unknown character type: " + input)); } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java b/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java index 287a520..b4e84db 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java +++ b/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java @@ -8,7 +8,7 @@ public class CharacterTypeConverter implements AttributeConverter type.label.equals(label)) + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java b/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java index 7e063f5..6131b7b 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java @@ -2,6 +2,8 @@ import com.swyp.app.global.common.BaseEntity; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -29,6 +31,9 @@ public class UserProfile extends BaseEntity { private CharacterType characterType; + @Enumerated(EnumType.STRING) + private PhilosopherType philosopherType; + private BigDecimal mannerTemperature; @Builder @@ -47,4 +52,8 @@ public void update(String nickname, CharacterType characterType) { this.characterType = characterType; } } + + public void updatePhilosopherType(PhilosopherType philosopherType) { + this.philosopherType = philosopherType; + } } diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index 9db9a2e..fa45e85 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -28,10 +28,10 @@ import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.user.entity.UserProfile; import com.swyp.app.domain.user.entity.UserSettings; -import com.swyp.app.domain.user.entity.UserTendencyScore; import com.swyp.app.domain.user.entity.VoteSide; import com.swyp.app.domain.vote.entity.Vote; import com.swyp.app.domain.vote.service.VoteQueryService; +import com.swyp.app.global.infra.s3.service.S3PresignedUrlService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,22 +54,35 @@ public class MypageService { private final VoteQueryService voteQueryService; private final BattleQueryService battleQueryService; private final PerspectiveQueryService perspectiveQueryService; + private final S3PresignedUrlService s3PresignedUrlService; + @Transactional public MypageResponse getMypage() { User user = userService.findCurrentUser(); UserProfile profile = userService.findUserProfile(user.getId()); + CharacterType characterType = profile.getCharacterType(); + String characterImageUrl = characterType != null + ? s3PresignedUrlService.generatePresignedUrl(characterType.getImageKey()) : null; + MypageResponse.ProfileInfo profileInfo = new MypageResponse.ProfileInfo( user.getUserTag(), profile.getNickname(), - profile.getCharacterType(), + characterType, + characterType != null ? characterType.getLabel() : null, + characterImageUrl, profile.getMannerTemperature() ); - // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시로 SOCRATES 반환 - MypageResponse.PhilosopherInfo philosopherInfo = new MypageResponse.PhilosopherInfo( - PhilosopherType.SOCRATES - ); + PhilosopherType philosopherType = resolvePhilosopherType(user.getId(), profile); + MypageResponse.PhilosopherInfo philosopherInfo = philosopherType != null + ? new MypageResponse.PhilosopherInfo( + philosopherType, + philosopherType.getLabel(), + philosopherType.getTypeName(), + philosopherType.getDescription(), + s3PresignedUrlService.generatePresignedUrl(philosopherType.getImageKey())) + : null; int currentPoint = creditService.getTotalPoints(user.getId()); TierCode tierCode = TierCode.fromPoints(currentPoint); @@ -84,20 +97,24 @@ public MypageResponse getMypage() { public RecapResponse getRecap() { User user = userService.findCurrentUser(); - UserTendencyScore score = userService.findUserTendencyScore(user.getId()); + UserProfile profile = userService.findUserProfile(user.getId()); - // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시 값 반환 - RecapResponse.PhilosopherCard myCard = new RecapResponse.PhilosopherCard(PhilosopherType.SOCRATES); - RecapResponse.PhilosopherCard bestMatchCard = new RecapResponse.PhilosopherCard(PhilosopherType.PLATO); - RecapResponse.PhilosopherCard worstMatchCard = new RecapResponse.PhilosopherCard(PhilosopherType.MARX); + PhilosopherType philosopherType = profile.getPhilosopherType(); + if (philosopherType == null) { + return null; + } + + RecapResponse.PhilosopherCard myCard = toPhilosopherCard(philosopherType); + RecapResponse.PhilosopherCard bestMatchCard = toPhilosopherCard(philosopherType.getBestMatch()); + RecapResponse.PhilosopherCard worstMatchCard = toPhilosopherCard(philosopherType.getWorstMatch()); RecapResponse.Scores scores = new RecapResponse.Scores( - score.getPrinciple(), - score.getReason(), - score.getIndividual(), - score.getChange(), - score.getInner(), - score.getIdeal() + philosopherType.getPrinciple(), + philosopherType.getReason(), + philosopherType.getIndividual(), + philosopherType.getChange(), + philosopherType.getInner(), + philosopherType.getIdeal() ); RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(user.getId()); @@ -285,6 +302,38 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { ); } + private static final int PHILOSOPHER_CALC_THRESHOLD = 5; + + private PhilosopherType resolvePhilosopherType(Long userId, UserProfile profile) { + if (profile.getPhilosopherType() != null) { + return profile.getPhilosopherType(); + } + + long totalVotes = voteQueryService.countTotalParticipation(userId); + if (totalVotes < PHILOSOPHER_CALC_THRESHOLD) { + return null; + } + + List battleIds = voteQueryService.findFirstNBattleIds(userId, PHILOSOPHER_CALC_THRESHOLD); + return battleQueryService.getTopPhilosopherTagName(battleIds) + .map(PhilosopherType::fromLabel) + .map(type -> { + profile.updatePhilosopherType(type); + return type; + }) + .orElse(null); + } + + private RecapResponse.PhilosopherCard toPhilosopherCard(PhilosopherType type) { + return new RecapResponse.PhilosopherCard( + type, + type.getLabel(), + type.getTypeName(), + type.getDescription(), + s3PresignedUrlService.generatePresignedUrl(type.getImageKey()) + ); + } + private VoteSide toVoteSide(BattleOptionLabel label) { return label == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; } diff --git a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java index 086be02..da6a67e 100644 --- a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java +++ b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java @@ -55,4 +55,8 @@ List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( // MypageService (recap): 사용자가 참여한 모든 투표 (배틀 목록 추출용) List findByUserId(Long userId); + + // MypageService: 철학자 유형 산출용 - 최초 N개 투표 조회 (생성순) + @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.userId = :userId ORDER BY v.createdAt ASC") + List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java b/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java index 7250919..b7d00c7 100644 --- a/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java +++ b/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java @@ -68,4 +68,11 @@ public List findParticipatedBattleIds(Long userId) { .distinct() .toList(); } + + public List findFirstNBattleIds(Long userId, int n) { + return voteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + .map(v -> v.getBattle().getId()) + .distinct() + .toList(); + } } diff --git a/src/main/java/com/swyp/app/global/config/S3Config.java b/src/main/java/com/swyp/app/global/config/S3Config.java new file mode 100644 index 0000000..f2ae86f --- /dev/null +++ b/src/main/java/com/swyp/app/global/config/S3Config.java @@ -0,0 +1,27 @@ +package com.swyp.app.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +@Configuration +public class S3Config { + + @Bean + public S3Presigner s3Presigner( + @Value("${spring.cloud.aws.region.static}") String region, + @Value("${spring.cloud.aws.credentials.access-key}") String accessKey, + @Value("${spring.cloud.aws.credentials.secret-key}") String secretKey) { + + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } +} diff --git a/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java b/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java index 14b2859..c17c9b4 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java +++ b/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java @@ -1,7 +1,9 @@ package com.swyp.app.global.infra.s3.controller; import com.swyp.app.global.common.response.ApiResponse; +import com.swyp.app.global.infra.s3.dto.FileUploadResponse; import com.swyp.app.global.infra.s3.enums.FileCategory; +import com.swyp.app.global.infra.s3.service.S3PresignedUrlService; import com.swyp.app.global.infra.s3.service.S3UploadService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,10 +28,11 @@ public class FileUploadController { private final S3UploadService s3UploadService; + private final S3PresignedUrlService s3PresignedUrlService; @Operation(summary = "S3 파일 업로드", description = "도메인 카테고리(PHILOSOPHER, BATTLE, SCENARIO)에 맞춰 파일을 업로드합니다.") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadFile( + public ApiResponse uploadFile( @Parameter(description = "업로드할 파일", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) @RequestParam("file") MultipartFile multipartFile, @@ -42,10 +45,13 @@ public ApiResponse uploadFile( // 2. 경로 생성 (예: images/battles/UUID_thumb.png) String fileName = category.getPath() + "/" + UUID.randomUUID() + "_" + multipartFile.getOriginalFilename(); - // 3. S3 업로드 - String s3Url = s3UploadService.uploadFile(fileName, tempFile); + // 3. S3 업로드 (S3 키 반환) + String s3Key = s3UploadService.uploadFile(fileName, tempFile); - return ApiResponse.onSuccess(s3Url); + // 4. 미리보기용 Presigned URL 생성 + String presignedUrl = s3PresignedUrlService.generatePresignedUrl(s3Key); + + return ApiResponse.onSuccess(new FileUploadResponse(s3Key, presignedUrl)); } private File convertMultiPartToFile(MultipartFile file) throws IOException { diff --git a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java new file mode 100644 index 0000000..beaf366 --- /dev/null +++ b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java @@ -0,0 +1,4 @@ +package com.swyp.app.global.infra.s3.dto; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +public record FileUploadResponse(String s3Key, String presignedUrl) {} diff --git a/src/main/java/com/swyp/app/global/infra/s3/enums/FileCategory.java b/src/main/java/com/swyp/app/global/infra/s3/enums/FileCategory.java index f6659ba..11cd0e8 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/enums/FileCategory.java +++ b/src/main/java/com/swyp/app/global/infra/s3/enums/FileCategory.java @@ -6,6 +6,7 @@ public enum FileCategory { PHILOSOPHER("images/philosophers"), // 철학자 이미지 + CHARACTER("images/characters"), // 캐릭터 프로필 이미지 BATTLE("images/battles"), // 배틀 썸네일 SCENARIO("audio/scenarios"); // 시나리오 음성 diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java new file mode 100644 index 0000000..ca1fe2e --- /dev/null +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java @@ -0,0 +1,65 @@ +package com.swyp.app.global.infra.s3.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.time.Duration; +import java.util.Map; +import java.util.stream.Collectors; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +@Service +@RequiredArgsConstructor +public class S3PresignedUrlService { + + private final S3Presigner s3Presigner; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${app.s3.presigned-url.expiration-hours:6}") + private int expirationHours; + + public String generatePresignedUrl(String s3KeyOrUrl) { + if (s3KeyOrUrl == null || s3KeyOrUrl.isBlank()) { + return null; + } + + String key = extractKey(s3KeyOrUrl); + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(expirationHours)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + public Map generatePresignedUrls(Map keyMap) { + if (keyMap == null || keyMap.isEmpty()) { + return keyMap; + } + return keyMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> generatePresignedUrl(e.getValue()) + )); + } + + private String extractKey(String input) { + if (input.startsWith("https://") && input.contains(".s3.") && input.contains(".amazonaws.com/")) { + int idx = input.indexOf(".amazonaws.com/") + ".amazonaws.com/".length(); + return input.substring(idx); + } + return input; + } +} diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java index b11a733..9151a59 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java @@ -24,9 +24,6 @@ public class S3UploadServiceImpl implements S3UploadService { @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; - @Value("${spring.cloud.aws.region.static}") - private String region; - @Override public String uploadFile(String key, File file) { if (file == null || !file.exists()) { @@ -49,10 +46,9 @@ public String uploadFile(String key, File file) { s3Client.putObject(putObjectRequest, RequestBody.fromFile(file)); - String fileUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key); - log.info("[AWS S3] 업로드 완료! 실제 URL: {}, Content-Type: {}", fileUrl, contentType); + log.info("[AWS S3] 업로드 완료! 키: {}, Content-Type: {}", key, contentType); - return fileUrl; + return key; } catch (Exception e) { log.error("[AWS S3] 파일 업로드 실패 - 키: {}", key, e); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2199e27..0a5bf3b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -95,4 +95,7 @@ jwt: refresh-token-expiration: 1209600000 # 14일 app: - baseUrl: http://localhost:8080 \ No newline at end of file + baseUrl: http://localhost:8080 + s3: + presigned-url: + expiration-hours: 6 \ No newline at end of file diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index e7e3403..254a194 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -5,7 +5,7 @@ import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; import com.swyp.app.domain.battle.service.BattleService; -import com.swyp.app.domain.home.dto.response.HomeBattleResponse; +import com.swyp.app.domain.home.dto.response.*; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.enums.NoticePlacement; import com.swyp.app.domain.notice.service.NoticeService; @@ -51,8 +51,8 @@ void getHome_aggregates_sections_by_spec() { TodayBattleResponse editorPick = battle("editor-id", BATTLE); TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); TodayBattleResponse bestBattle = battle("best-id", BATTLE); - TodayBattleResponse todayVotePick = battle("today-vote-id", VOTE); - TodayBattleResponse quizBattle = quiz("quiz-id"); + TodayBattleResponse todayVote = vote("vote-id"); + TodayBattleResponse todayQuiz = quiz("quiz-id"); TodayBattleResponse newBattle = battle("new-id", BATTLE); NoticeSummaryResponse notice = new NoticeSummaryResponse( @@ -70,33 +70,36 @@ void getHome_aggregates_sections_by_spec() { when(battleService.getEditorPicks()).thenReturn(List.of(editorPick)); when(battleService.getTrendingBattles()).thenReturn(List.of(trendingBattle)); when(battleService.getBestBattles()).thenReturn(List.of(bestBattle)); - when(battleService.getTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); - when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of(todayVote)); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of(todayQuiz)); when(battleService.getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), - todayVotePick.battleId(), - quizBattle.battleId() + todayVote.battleId(), + todayQuiz.battleId() ))).thenReturn(List.of(newBattle)); var response = homeService.getHome(); assertThat(response.newNotice()).isTrue(); - assertThat(response.editorPicks()).extracting(HomeBattleResponse::title).containsExactly("editor-id"); - assertThat(response.trendingBattles()).extracting(HomeBattleResponse::title).containsExactly("trending-id"); - assertThat(response.bestBattles()).extracting(HomeBattleResponse::title).containsExactly("best-id"); - assertThat(response.todayPicks()).extracting(HomeBattleResponse::title).containsExactly("today-vote-id", "quiz-id"); - assertThat(response.newBattles()).extracting(HomeBattleResponse::title).containsExactly("new-id"); - assertThat(response.todayPicks().get(0).options()).extracting(option -> option.text()).containsExactly("A", "B"); - assertThat(response.todayPicks().get(1).options()).extracting(option -> option.text()).containsExactly("A", "B", "C", "D"); + assertThat(response.editorPicks()).extracting(HomeEditorPickResponse::title).containsExactly("editor-id"); + assertThat(response.trendingBattles()).extracting(HomeTrendingResponse::title).containsExactly("trending-id"); + assertThat(response.bestBattles()).extracting(HomeBestBattleResponse::title).containsExactly("best-id"); + assertThat(response.todayQuizzes()).extracting(HomeTodayQuizResponse::title).containsExactly("quiz-id"); + assertThat(response.todayVotes()).hasSize(1); + assertThat(response.todayVotes().get(0).titlePrefix()).isEqualTo("도덕의 기준은"); + assertThat(response.todayVotes().get(0).options()).extracting(HomeTodayVoteOptionResponse::title) + .containsExactly("결과", "의도", "규칙", "덕"); + assertThat(response.todayQuizzes().get(0).itemA()).isEqualTo("정답"); + assertThat(response.newBattles()).extracting(HomeNewBattleResponse::title).containsExactly("new-id"); verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), - todayVotePick.battleId(), - quizBattle.battleId() + todayVote.battleId(), + todayQuiz.battleId() )))); } @@ -117,7 +120,8 @@ void getHome_returns_false_and_empty_lists_when_no_data() { assertThat(response.editorPicks()).isEmpty(); assertThat(response.trendingBattles()).isEmpty(); assertThat(response.bestBattles()).isEmpty(); - assertThat(response.todayPicks()).isEmpty(); + assertThat(response.todayQuizzes()).isEmpty(); + assertThat(response.todayVotes()).isEmpty(); assertThat(response.newBattles()).isEmpty(); } @@ -162,39 +166,39 @@ void getHome_newNotice_true_with_multiple_notices() { private TodayBattleResponse battle(String title, BattleType type) { return new TodayBattleResponse( - generateId(), - title, - "summary", - "thumbnail", - type, - 10, - 20L, - 90, + generateId(), title, "summary", "thumbnail", type, + 10, 20L, 90, List.of(), List.of( new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b") - ) + ), + null, null, null, null, null, null ); } private TodayBattleResponse quiz(String title) { return new TodayBattleResponse( - generateId(), - title, - "summary", - "thumbnail", - QUIZ, - 30, - 40L, - 60, + generateId(), title, "summary", "thumbnail", QUIZ, + 30, 40L, 60, + List.of(), + List.of(), + null, null, "정답", "정답 설명", "오답", "오답 설명" + ); + } + + private TodayBattleResponse vote(String title) { + return new TodayBattleResponse( + generateId(), title, "summary", "thumbnail", VOTE, + 50, 60L, 0, List.of(), List.of( - new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), - new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b"), - new TodayOptionResponse(generateId(), BattleOptionLabel.C, "C", "rep-c", "stance-c", "image-c"), - new TodayOptionResponse(generateId(), BattleOptionLabel.D, "D", "rep-d", "stance-d", "image-d") - ) + new TodayOptionResponse(generateId(), BattleOptionLabel.A, "결과", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.B, "의도", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.C, "규칙", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.D, "덕", null, null, null) + ), + "도덕의 기준은", "이다", null, null, null, null ); } } diff --git a/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java index 617a27b..5ce1473 100644 --- a/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java @@ -32,10 +32,10 @@ import com.swyp.app.domain.user.entity.UserRole; import com.swyp.app.domain.user.entity.UserSettings; import com.swyp.app.domain.user.entity.UserStatus; -import com.swyp.app.domain.user.entity.UserTendencyScore; import com.swyp.app.domain.user.entity.VoteSide; import com.swyp.app.domain.vote.entity.Vote; import com.swyp.app.domain.vote.service.VoteQueryService; +import com.swyp.app.global.infra.s3.service.S3PresignedUrlService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -52,6 +52,7 @@ import java.util.concurrent.atomic.AtomicLong; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,6 +72,8 @@ class MypageServiceTest { private BattleQueryService battleQueryService; @Mock private PerspectiveQueryService perspectiveQueryService; + @Mock + private S3PresignedUrlService s3PresignedUrlService; @InjectMocks private MypageService mypageService; @@ -86,10 +89,12 @@ private Long generateId() { void getMypage_returns_profile_philosopher_tier() { User user = createUser(1L, "myTag"); UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); when(userService.findCurrentUser()).thenReturn(user); when(userService.findUserProfile(1L)).thenReturn(profile); when(creditService.getTotalPoints(1L)).thenReturn(0); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); MypageResponse response = mypageService.getMypage(); @@ -97,7 +102,9 @@ void getMypage_returns_profile_philosopher_tier() { assertThat(response.profile().nickname()).isEqualTo("nick"); assertThat(response.profile().characterType()).isEqualTo(CharacterType.OWL); assertThat(response.profile().mannerTemperature()).isEqualByComparingTo(BigDecimal.valueOf(36.5)); - assertThat(response.philosopher().philosopherType()).isEqualTo(PhilosopherType.SOCRATES); + assertThat(response.philosopher().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.philosopher().typeName()).isEqualTo("원칙형"); + assertThat(response.philosopher().description()).isNotNull(); assertThat(response.tier().tierCode()).isEqualTo(TierCode.WANDERER); assertThat(response.tier().currentPoint()).isZero(); } @@ -106,14 +113,12 @@ void getMypage_returns_profile_philosopher_tier() { @DisplayName("철학자카드와 성향점수와 선호보고서를 반환한다") void getRecap_returns_cards_scores_report() { User user = createUser(1L, "tag"); - UserTendencyScore score = UserTendencyScore.builder() - .user(user) - .principle(10).reason(20).individual(30) - .change(40).inner(50).ideal(60) - .build(); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); when(userService.findCurrentUser()).thenReturn(user); - when(userService.findUserTendencyScore(1L)).thenReturn(score); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); when(voteQueryService.countTotalParticipation(1L)).thenReturn(15L); when(voteQueryService.countOpinionChanges(1L)).thenReturn(3L); when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(70); @@ -128,11 +133,11 @@ void getRecap_returns_cards_scores_report() { RecapResponse response = mypageService.getRecap(); - assertThat(response.myCard().philosopherType()).isEqualTo(PhilosopherType.SOCRATES); - assertThat(response.bestMatchCard().philosopherType()).isEqualTo(PhilosopherType.PLATO); - assertThat(response.worstMatchCard().philosopherType()).isEqualTo(PhilosopherType.MARX); - assertThat(response.scores().principle()).isEqualTo(10); - assertThat(response.scores().ideal()).isEqualTo(60); + assertThat(response.myCard().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.bestMatchCard().philosopherType()).isEqualTo(PhilosopherType.CONFUCIUS); + assertThat(response.worstMatchCard().philosopherType()).isEqualTo(PhilosopherType.NIETZSCHE); + assertThat(response.scores().principle()).isEqualTo(92); + assertThat(response.scores().ideal()).isEqualTo(45); assertThat(response.preferenceReport().totalParticipation()).isEqualTo(15); assertThat(response.preferenceReport().opinionChanges()).isEqualTo(3); assertThat(response.preferenceReport().battleWinRate()).isEqualTo(70); @@ -141,29 +146,17 @@ void getRecap_returns_cards_scores_report() { } @Test - @DisplayName("투표이력이 없으면 선호보고서가 0값이다") - void getRecap_returns_zero_report_when_no_votes() { + @DisplayName("철학자유형이 미산출이면 recap은 null이다") + void getRecap_returns_null_when_no_philosopher() { User user = createUser(1L, "tag"); - UserTendencyScore score = UserTendencyScore.builder() - .user(user) - .principle(0).reason(0).individual(0) - .change(0).inner(0).ideal(0) - .build(); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); when(userService.findCurrentUser()).thenReturn(user); - when(userService.findUserTendencyScore(1L)).thenReturn(score); - when(voteQueryService.countTotalParticipation(1L)).thenReturn(0L); - when(voteQueryService.countOpinionChanges(1L)).thenReturn(0L); - when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(0); - when(voteQueryService.findParticipatedBattleIds(1L)).thenReturn(List.of()); - when(battleQueryService.getTopTagsByBattleIds(List.of(), 4)).thenReturn(new LinkedHashMap<>()); + when(userService.findUserProfile(1L)).thenReturn(profile); RecapResponse response = mypageService.getRecap(); - assertThat(response.preferenceReport().totalParticipation()).isZero(); - assertThat(response.preferenceReport().opinionChanges()).isZero(); - assertThat(response.preferenceReport().battleWinRate()).isZero(); - assertThat(response.preferenceReport().favoriteTopics()).isEmpty(); + assertThat(response).isNull(); } @Test