From 8b9f51672b79d5e3702680b641b03fc08ccc61b8 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:40:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?#37=20[Feat]=20=ED=99=88=20API=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B3=B5=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/home-api.md | 212 ++++++++++-------- .../battle/converter/BattleConverter.java | 17 +- .../domain/battle/service/BattleService.java | 7 +- .../battle/service/BattleServiceImpl.java | 43 +++- .../home/controller/HomeController.java | 26 +++ .../response/HomeBattleOptionResponse.java | 9 + .../home/dto/response/HomeBattleResponse.java | 21 ++ .../home/dto/response/HomeResponse.java | 13 ++ .../app/domain/home/service/HomeService.java | 89 ++++++++ .../notice/controller/NoticeController.java | 43 ++++ .../dto/response/NoticeDetailResponse.java | 20 ++ .../dto/response/NoticeListResponse.java | 9 + .../dto/response/NoticeSummaryResponse.java | 19 ++ .../swyp/app/domain/notice/entity/Notice.java | 67 ++++++ .../domain/notice/entity/NoticePlacement.java | 6 + .../app/domain/notice/entity/NoticeType.java | 6 + .../notice/repository/NoticeRepository.java | 36 +++ .../domain/notice/service/NoticeService.java | 72 ++++++ .../global/common/exception/ErrorCode.java | 5 +- .../app/global/config/SecurityConfig.java | 2 + .../battle/service/BattleServiceImplTest.java | 122 ++++++++++ .../domain/home/service/HomeServiceTest.java | 152 +++++++++++++ .../notice/service/NoticeServiceTest.java | 65 ++++++ src/test/resources/application.yml | 43 ++++ 24 files changed, 1004 insertions(+), 100 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/home/controller/HomeController.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/service/HomeService.java create mode 100644 src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/Notice.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java create mode 100644 src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java create mode 100644 src/main/java/com/swyp/app/domain/notice/service/NoticeService.java create mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java create mode 100644 src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java create mode 100644 src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java diff --git a/docs/api-specs/home-api.md b/docs/api-specs/home-api.md index 3b83e2c..ecebb59 100644 --- a/docs/api-specs/home-api.md +++ b/docs/api-specs/home-api.md @@ -2,14 +2,11 @@ ## 1. 설계 메모 -- `Home`은 원천 도메인이 아니라 여러 도메인을 조합하는 집계 API입니다. -- 메인 화면에서 바로 응답하는 즉답형 기능은 `quiz` 도메인으로 분리합니다. -- 홈 화면은 아래 데이터를 한 번에 조합해서 반환합니다. - - HOT 배틀 - - PICK 배틀 - - 퀴즈 - - 최신 배틀 -- 공지는 홈 상단 노출 대상만 조회합니다. +- 홈은 여러 조회 결과를 한 번에 내려주는 집계 API입니다. +- 이번 문서는 `GET /api/v1/home` 하나만 정의합니다. +- 공지 목록/상세는 홈에서 직접 내려주지 않고, 마이페이지 공지 탭에서 처리합니다. +- 홈에서는 공지 내용 대신 `newNotice` boolean만 내려서 새 공지 유입 여부만 표시합니다. +- `todayPicks` 안에는 찬반형과 4지선다형이 함께 포함됩니다. --- @@ -17,112 +14,135 @@ ### 2.1 `GET /api/v1/home` -홈 화면 집계 조회 API. +홈 화면 진입 시 필요한 데이터를 한 번에 조회합니다. -반환 항목: +반환 섹션: -- HOT 배틀 -- PICK 배틀 -- 퀴즈 2지선다 -- 퀴즈 4지선다 -- 최신 배틀 목록 +- `newNotice`: 새 공지가 있는지 여부 +- `editorPicks`: Editor Pick +- `trendingBattles`: 지금 뜨는 배틀 +- `bestBattles`: Best 배틀 +- `todayPicks`: 오늘의 Pické +- `newBattles`: 새로운 배틀 ```json { - "hot_battle": { - "battle_id": "battle_001", - "title": "안락사 도입, 찬성 vs 반대", - "summary": "인간에게 품위 있는 죽음을 허용해야 할까?", - "thumbnail_url": "https://cdn.example.com/battle/hot-001.png" - }, - "pick_battle": { - "battle_id": "battle_002", - "title": "공리주의 vs 의무론", - "summary": "도덕 판단의 기준은 결과일까 원칙일까?", - "thumbnail_url": "https://cdn.example.com/battle/pick-002.png" - }, - "quizzes": [ + "newNotice": true, + "editorPicks": [ { - "quiz_id": "quiz_001", - "type": "BINARY", - "title": "AI가 만든 그림도 예술일까?", + "battleId": "7b6c8d81-40f4-4f1e-9f13-4cc2fa0a3a10", + "title": "연애 상대의 전 애인 사진, 지워달라고 말한다 vs 그냥 둔다", + "summary": "에디터가 직접 골라본 오늘의 주제", + "thumbnailUrl": "https://cdn.example.com/battle/editor-pick-001.png", + "type": "BATTLE", + "viewCount": 182, + "participantsCount": 562, + "audioDuration": 153, + "tags": [], + "options": [] + } + ], + "trendingBattles": [ + { + "battleId": "40f4c311-0bd8-4baf-85df-58f8eaf1bf1f", + "title": "안락사 도입, 찬성 vs 반대", + "summary": "최근 24시간 참여가 급증한 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/hot-001.png", + "type": "BATTLE", + "viewCount": 120, + "participantsCount": 420, + "audioDuration": 180, + "tags": [], + "options": [] + } + ], + "bestBattles": [ + { + "battleId": "11c22d33-44e5-6789-9abc-123456789def", + "title": "반려동물 출입 가능 식당, 확대해야 한다 vs 제한해야 한다", + "summary": "누적 참여와 댓글 반응이 높은 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/best-001.png", + "type": "BATTLE", + "viewCount": 348, + "participantsCount": 1103, + "audioDuration": 201, + "tags": [], + "options": [] + } + ], + "todayPicks": [ + { + "battleId": "4e5291d2-b514-4d2a-a8fb-1258ae21a001", + "title": "배달 일회용 수저 기본 제공, 찬성 vs 반대", + "summary": "오늘의 Pické 찬반형 예시", + "thumbnailUrl": "https://cdn.example.com/battle/today-vote-001.png", + "type": "VOTE", + "viewCount": 97, + "participantsCount": 238, + "audioDuration": 96, + "tags": [], "options": [ - { "code": "A", "label": "그렇다" }, - { "code": "B", "label": "아니다" } + { + "label": "A", + "text": "찬성" + }, + { + "label": "B", + "text": "반대" + } ] }, { - "quiz_id": "quiz_002", - "type": "MULTIPLE_CHOICE", - "title": "도덕 판단의 기준은?", + "battleId": "9f8e7d6c-5b4a-3210-9abc-7f6e5d4c3b2a", + "title": "다음 중 세계에서 가장 큰 사막은?", + "summary": "오늘의 Pické 4지선다형 예시", + "thumbnailUrl": "https://cdn.example.com/battle/today-quiz-001.png", + "type": "QUIZ", + "viewCount": 76, + "participantsCount": 191, + "audioDuration": 88, + "tags": [], "options": [ - { "code": "A", "label": "결과" }, - { "code": "B", "label": "의도" }, - { "code": "C", "label": "규칙" }, - { "code": "D", "label": "상황" } + { + "label": "A", + "text": "사하라 사막" + }, + { + "label": "B", + "text": "고비 사막" + }, + { + "label": "C", + "text": "남극 대륙" + }, + { + "label": "D", + "text": "아라비아 사막" + } ] } ], - "latest_battles": [ + "newBattles": [ { - "battle_id": "battle_101", - "title": "정의란 무엇인가", - "summary": "정의의 기준은 모두에게 같아야 할까?", - "thumbnail_url": "https://cdn.example.com/battle/latest-101.png" + "battleId": "aa11bb22-cc33-44dd-88ee-ff0011223344", + "title": "회사 회식은 근무의 연장이다 vs 사적인 친목이다", + "summary": "홈의 다른 섹션과 중복되지 않는 최신 배틀", + "thumbnailUrl": "https://cdn.example.com/battle/new-001.png", + "type": "BATTLE", + "viewCount": 24, + "participantsCount": 71, + "audioDuration": 142, + "tags": [], + "options": [] } ] } ``` -### 2.2 `POST /api/v1/quiz/{quizId}/responses` - -홈 화면에서 퀴즈 응답 저장. +비고: -요청: - -```json -{ - "selected_option_code": "A" -} -``` - -응답: - -```json -{ - "quiz_id": "quiz_001", - "selected_option_code": "A", - "submitted_at": "2026-03-08T12:00:00Z" -} -``` - ---- - -## 3. 공지 API - -### 3.1 `GET /api/v1/notices` - -현재 노출 가능한 전체 공지 목록 조회. - -쿼리 파라미터: - -- `placement`: 선택, 예시 `HOME_TOP` -- `limit`: 선택 - -응답: - -```json -{ - "items": [ - { - "notice_id": "notice_001", - "title": "3월 신규 딜레마 업데이트", - "body": "매일 새로운 딜레마가 추가돼요.", - "notice_type": "ANNOUNCEMENT", - "is_pinned": true, - "starts_at": "2026-03-01T00:00:00Z", - "ends_at": "2026-03-31T23:59:59Z" - } - ] -} -``` +- `newNotice`는 홈에서 공지 내용을 직접 노출하지 않고, 마이페이지 공지 탭으로 이동시키기 위한 신규 공지 존재 여부입니다. +- `editorPicks`, `trendingBattles`, `bestBattles`, `newBattles`는 동일한 배틀 요약 카드 구조를 사용합니다. +- `todayPicks`는 `type`으로 찬반형과 4지선다형을 구분합니다. +- `todayPicks`의 4지선다형은 별도 `quizzes` 필드로 분리하지 않고 이 배열 안에 포함합니다. +- 데이터가 없으면 리스트 섹션은 빈 배열을, `newNotice`는 `false`를 반환합니다. 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 9403062..167b4f8 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 @@ -52,6 +52,21 @@ public TodayBattleResponse toTodayResponse(Battle b, List tags, List tags, List opts) { + return new BattleSummaryResponse( + b.getId(), + b.getTitle(), + b.getSummary(), + b.getThumbnailUrl(), + b.getType(), + b.getViewCount() == null ? 0 : b.getViewCount(), + b.getTotalParticipantsCount() == null ? 0L : b.getTotalParticipantsCount(), + b.getAudioDuration() == null ? 0 : b.getAudioDuration(), + toTagResponses(tags, null), + toOptionResponses(opts) + ); + } + public AdminBattleDetailResponse toAdminDetailResponse(Battle b, List tags, List opts) { return new AdminBattleDetailResponse( b.getId(), @@ -114,4 +129,4 @@ private List toTagResponses(List tags, TagType targetTyp .map(t -> new BattleTagResponse(t.getId(), t.getName(), t.getType())) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java index fe6fc12..6e383fe 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java @@ -23,18 +23,23 @@ public interface BattleService { // 1. 에디터 픽 조회 (isEditorPick = true) List getEditorPicks(); + List getHomeEditorPicks(); // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) List getTrendingBattles(); + List getHomeTrendingBattles(); // 3. Best 배틀 조회 (누적 지표 랭킹) List getBestBattles(); + List getHomeBestBattles(); // 4. 오늘의 Pické 조회 (단일 타입 매칭) List getTodayPicks(BattleType type); + List getHomeTodayPicks(BattleType type); // 5. 새로운 배틀 조회 (중복 제외 리스트) List getNewBattles(List excludeIds); + List getHomeNewBattles(List excludeIds); // === [사용자용 - 기본 API] === @@ -59,4 +64,4 @@ public interface BattleService { // 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다) AdminBattleDeleteResponse deleteBattle(UUID battleId); -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java index cf243ce..4bd43b7 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java @@ -67,6 +67,12 @@ public List getEditorPicks() { return convertToTodayResponses(battles); } + @Override + public List getHomeEditorPicks() { + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)); + return convertToSummaryResponses(battles); + } + @Override public List getTrendingBattles() { LocalDateTime yesterday = LocalDateTime.now().minusDays(1); @@ -74,18 +80,37 @@ public List getTrendingBattles() { return convertToTodayResponses(battles); } + @Override + public List getHomeTrendingBattles() { + LocalDateTime yesterday = LocalDateTime.now().minusDays(1); + List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, 5)); + return convertToSummaryResponses(battles); + } + @Override public List getBestBattles() { List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); return convertToTodayResponses(battles); } + @Override + public List getHomeBestBattles() { + List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); + return convertToSummaryResponses(battles); + } + @Override public List getTodayPicks(BattleType type) { List battles = battleRepository.findTodayPicks(type, LocalDate.now()); return convertToTodayResponses(battles); } + @Override + public List getHomeTodayPicks(BattleType type) { + List battles = battleRepository.findTodayPicks(type, LocalDate.now()); + return convertToSummaryResponses(battles); + } + @Override public List getNewBattles(List excludeIds) { List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) @@ -94,6 +119,14 @@ public List getNewBattles(List excludeIds) { return convertToTodayResponses(battles); } + @Override + public List getHomeNewBattles(List excludeIds) { + List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) + ? List.of(UUID.randomUUID()) : excludeIds; + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)); + return convertToSummaryResponses(battles); + } + // [사용자용 - 기본 API] @Override @@ -217,6 +250,14 @@ private List convertToTodayResponses(List battles) }).toList(); } + private List convertToSummaryResponses(List battles) { + return battles.stream().map(battle -> { + List tags = getTagsByBattle(battle); + List options = battleOptionRepository.findByBattle(battle); + return battleConverter.toSummaryResponse(battle, tags, options); + }).toList(); + } + private List getTagsByBattle(Battle b) { return battleTagRepository.findByBattle(b).stream() .map(BattleTag::getTag) @@ -242,4 +283,4 @@ public BattleOption findOptionByBattleIdAndLabel(UUID battleId, BattleOptionLabe return battleOptionRepository.findByBattleAndLabel(b, label) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/home/controller/HomeController.java b/src/main/java/com/swyp/app/domain/home/controller/HomeController.java new file mode 100644 index 0000000..8c11bb4 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/controller/HomeController.java @@ -0,0 +1,26 @@ +package com.swyp.app.domain.home.controller; + +import com.swyp.app.domain.home.dto.response.HomeResponse; +import com.swyp.app.domain.home.service.HomeService; +import com.swyp.app.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "홈 API", description = "홈 화면 집계 조회") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class HomeController { + + private final HomeService homeService; + + @Operation(summary = "홈 화면 집계 조회") + @GetMapping("/home") + public ApiResponse getHome() { + return ApiResponse.onSuccess(homeService.getHome()); + } +} 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/HomeBattleOptionResponse.java new file mode 100644 index 0000000..b2ce088 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java @@ -0,0 +1,9 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.enums.BattleOptionLabel; + +public record HomeBattleOptionResponse( + BattleOptionLabel label, + String text +) { +} 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/HomeBattleResponse.java new file mode 100644 index 0000000..713f1ce --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java @@ -0,0 +1,21 @@ +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; +import java.util.UUID; + +public record HomeBattleResponse( + UUID battleId, + String title, + String summary, + String thumbnailUrl, + BattleType type, + Integer viewCount, + Long participantsCount, + Integer audioDuration, + List tags, + List options +) { +} 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 new file mode 100644 index 0000000..525680a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java @@ -0,0 +1,13 @@ +package com.swyp.app.domain.home.dto.response; + +import java.util.List; + +public record HomeResponse( + boolean newNotice, + List editorPicks, + List trendingBattles, + List bestBattles, + List todayPicks, + List newBattles +) { +} 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 new file mode 100644 index 0000000..77de9c8 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -0,0 +1,89 @@ +package com.swyp.app.domain.home.service; + +import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.enums.BattleType; +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.notice.entity.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.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HomeService { + + private static final int NOTICE_EXISTS_LIMIT = 1; + + private final BattleService battleService; + private final NoticeService noticeService; + + public HomeResponse getHome() { + boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); + + List editorPicks = toHomeBattles(battleService.getHomeEditorPicks()); + List trendingBattles = toHomeBattles(battleService.getHomeTrendingBattles()); + List bestBattles = toHomeBattles(battleService.getHomeBestBattles()); + + List todayPicks = new ArrayList<>(); + todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.VOTE))); + todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.QUIZ))); + + List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); + List newBattles = toHomeBattles(battleService.getHomeNewBattles(excludeIds)); + + return new HomeResponse( + newNotice, + editorPicks, + trendingBattles, + bestBattles, + todayPicks, + newBattles + ); + } + + private List toHomeBattles(List battles) { + return battles.stream() + .map(this::toHomeBattle) + .toList(); + } + + private HomeBattleResponse toHomeBattle(BattleSummaryResponse 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 HomeBattleOptionResponse toHomeOption(BattleOptionResponse option) { + return new HomeBattleOptionResponse(option.label(), option.title()); + } + + @SafeVarargs + private List collectBattleIds(List... groups) { + return List.of(groups).stream() + .flatMap(List::stream) + .map(HomeBattleResponse::battleId) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java b/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java new file mode 100644 index 0000000..c7e06e7 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java @@ -0,0 +1,43 @@ +package com.swyp.app.domain.notice.controller; + +import com.swyp.app.domain.notice.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.notice.dto.response.NoticeListResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.service.NoticeService; +import com.swyp.app.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@Tag(name = "공지 API", description = "공지사항 조회") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notices") +public class NoticeController { + + private final NoticeService noticeService; + + @Operation(summary = "활성 공지 목록 조회") + @GetMapping + public ApiResponse getNotices( + @RequestParam(required = false) NoticeType type, + @RequestParam(required = false) NoticePlacement placement, + @RequestParam(required = false) Integer limit + ) { + return ApiResponse.onSuccess(noticeService.getNoticeList(type, placement, limit)); + } + + @Operation(summary = "활성 공지 상세 조회") + @GetMapping("/{noticeId}") + public ApiResponse getNoticeDetail(@PathVariable UUID noticeId) { + return ApiResponse.onSuccess(noticeService.getNoticeDetail(noticeId)); + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java new file mode 100644 index 0000000..43e464f --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java @@ -0,0 +1,20 @@ +package com.swyp.app.domain.notice.dto.response; + +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoticeDetailResponse( + UUID noticeId, + String title, + String body, + NoticeType type, + NoticePlacement placement, + boolean pinned, + LocalDateTime startsAt, + LocalDateTime endsAt, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java new file mode 100644 index 0000000..d83f91a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java @@ -0,0 +1,9 @@ +package com.swyp.app.domain.notice.dto.response; + +import java.util.List; + +public record NoticeListResponse( + List items, + int totalCount +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java new file mode 100644 index 0000000..abe78d5 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java @@ -0,0 +1,19 @@ +package com.swyp.app.domain.notice.dto.response; + +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoticeSummaryResponse( + UUID noticeId, + String title, + String body, + NoticeType type, + NoticePlacement placement, + boolean pinned, + LocalDateTime startsAt, + LocalDateTime endsAt +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/Notice.java b/src/main/java/com/swyp/app/domain/notice/entity/Notice.java new file mode 100644 index 0000000..531d297 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/Notice.java @@ -0,0 +1,67 @@ +package com.swyp.app.domain.notice.entity; + +import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Entity +@Table(name = "notices") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, length = 150) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "notice_type", nullable = false, length = 30) + private NoticeType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private NoticePlacement placement; + + @Column(name = "is_pinned", nullable = false) + private boolean pinned; + + @Column(name = "starts_at", nullable = false) + private LocalDateTime startsAt; + + @Column(name = "ends_at") + private LocalDateTime endsAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private Notice(String title, String body, NoticeType type, NoticePlacement placement, boolean pinned, + LocalDateTime startsAt, LocalDateTime endsAt) { + this.title = title; + this.body = body; + this.type = type; + this.placement = placement; + this.pinned = pinned; + this.startsAt = startsAt; + this.endsAt = endsAt; + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java b/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java new file mode 100644 index 0000000..180382e --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.notice.entity; + +public enum NoticePlacement { + HOME_TOP, + NOTICE_BOARD +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java b/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java new file mode 100644 index 0000000..be76097 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.notice.entity; + +public enum NoticeType { + ANNOUNCEMENT, + EVENT +} diff --git a/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java b/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..b7322be --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,36 @@ +package com.swyp.app.domain.notice.repository; + +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +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; +import java.util.UUID; + +public interface NoticeRepository extends JpaRepository { + + @Query("SELECT notice FROM Notice notice " + + "WHERE notice.deletedAt IS NULL " + + "AND notice.startsAt <= :now " + + "AND (notice.endsAt IS NULL OR notice.endsAt >= :now) " + + "AND (:type IS NULL OR notice.type = :type) " + + "AND (:placement IS NULL OR notice.placement = :placement) " + + "ORDER BY notice.pinned DESC, notice.startsAt DESC, notice.createdAt DESC") + List findActiveNotices(@Param("now") LocalDateTime now, + @Param("type") NoticeType type, + @Param("placement") NoticePlacement placement, + Pageable pageable); + + @Query("SELECT notice FROM Notice notice " + + "WHERE notice.id = :noticeId " + + "AND notice.deletedAt IS NULL " + + "AND notice.startsAt <= :now " + + "AND (notice.endsAt IS NULL OR notice.endsAt >= :now)") + Optional findActiveById(@Param("noticeId") UUID noticeId, @Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java b/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java new file mode 100644 index 0000000..840e56c --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java @@ -0,0 +1,72 @@ +package com.swyp.app.domain.notice.service; + +import com.swyp.app.domain.notice.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.notice.dto.response.NoticeListResponse; +import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.repository.NoticeRepository; +import com.swyp.app.global.common.exception.CustomException; +import com.swyp.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeService { + + private static final int DEFAULT_LIMIT = 20; + + private final NoticeRepository noticeRepository; + + public List getActiveNotices(NoticePlacement placement, NoticeType type, Integer limit) { + int pageSize = limit == null || limit <= 0 ? DEFAULT_LIMIT : limit; + return noticeRepository.findActiveNotices(LocalDateTime.now(), type, placement, PageRequest.of(0, pageSize)) + .stream() + .map(this::toSummaryResponse) + .toList(); + } + + public NoticeListResponse getNoticeList(NoticeType type, NoticePlacement placement, Integer limit) { + List items = getActiveNotices(placement, type, limit); + return new NoticeListResponse(items, items.size()); + } + + public NoticeDetailResponse getNoticeDetail(UUID noticeId) { + Notice notice = noticeRepository.findActiveById(noticeId, LocalDateTime.now()) + .orElseThrow(() -> new CustomException(ErrorCode.NOTICE_NOT_FOUND)); + + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getBody(), + notice.getType(), + notice.getPlacement(), + notice.isPinned(), + notice.getStartsAt(), + notice.getEndsAt(), + notice.getCreatedAt() + ); + } + + private NoticeSummaryResponse toSummaryResponse(Notice notice) { + return new NoticeSummaryResponse( + notice.getId(), + notice.getTitle(), + notice.getBody(), + notice.getType(), + notice.getPlacement(), + notice.isPinned(), + notice.getStartsAt(), + notice.getEndsAt() + ); + } +} diff --git a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java index a611538..07e2ebb 100644 --- a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java @@ -29,6 +29,9 @@ public enum ErrorCode { // OAuth (Social Login) INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_400_PROVIDER", "지원하지 않는 소셜 로그인 provider입니다."), + // Notice + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE_404", "존재하지 않는 공지사항입니다."), + // Battle BATTLE_NOT_FOUND (HttpStatus.NOT_FOUND, "BATTLE_404", "존재하지 않는 배틀입니다."), BATTLE_CLOSED (HttpStatus.CONFLICT, "BATTLE_409_CLS", "종료된 배틀입니다."), @@ -75,4 +78,4 @@ public enum ErrorCode { private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/global/config/SecurityConfig.java b/src/main/java/com/swyp/app/global/config/SecurityConfig.java index 8f8b4d1..d4f232d 100644 --- a/src/main/java/com/swyp/app/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/app/global/config/SecurityConfig.java @@ -34,6 +34,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/v1/auth/**", + "/api/v1/home", + "/api/v1/notices/**", "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java new file mode 100644 index 0000000..e66b2ef --- /dev/null +++ b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java @@ -0,0 +1,122 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import com.swyp.app.domain.tag.repository.TagRepository; +import com.swyp.app.domain.user.repository.UserRepository; +import com.swyp.app.domain.vote.repository.VoteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplTest { + + @Mock + private BattleRepository battleRepository; + @Mock + private BattleOptionRepository battleOptionRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleOptionTagRepository battleOptionTagRepository; + @Mock + private TagRepository tagRepository; + @Mock + private UserRepository userRepository; + @Mock + private VoteRepository voteRepository; + @Mock + private BattleConverter battleConverter; + + @InjectMocks + private BattleServiceImpl battleService; + + private Battle battle; + + @BeforeEach + void setUp() { + battle = Battle.builder() + .title("battle") + .type(BattleType.BATTLE) + .targetDate(LocalDate.now()) + .status(BattleStatus.PUBLISHED) + .build(); + } + + @Test + void getHomeTrendingBattles_요약응답으로_변환한다() { + BattleSummaryResponse summary = new BattleSummaryResponse( + UUID.randomUUID(), + "trending", + "summary", + "thumbnail", + BattleType.BATTLE, + 12, + 34L, + 56, + List.of(), + List.of() + ); + + when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) + .thenReturn(summary); + + var result = battleService.getHomeTrendingBattles(); + + assertThat(result).containsExactly(summary); + } + + @Test + void getHomeNewBattles_제외아이디가_비어있으면_조회용_기본값을_사용한다() { + BattleSummaryResponse summary = new BattleSummaryResponse( + UUID.randomUUID(), + "new", + "summary", + "thumbnail", + BattleType.BATTLE, + 1, + 2L, + 3, + List.of(), + List.of() + ); + + when(battleRepository.findNewBattlesExcluding(any(), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) + .thenReturn(summary); + + var result = battleService.getHomeNewBattles(List.of()); + + assertThat(result).containsExactly(summary); + } +} 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 new file mode 100644 index 0000000..5e6ac99 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -0,0 +1,152 @@ +package com.swyp.app.domain.home.service; + +import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +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.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.service.NoticeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static com.swyp.app.domain.battle.enums.BattleType.BATTLE; +import static com.swyp.app.domain.battle.enums.BattleType.QUIZ; +import static com.swyp.app.domain.battle.enums.BattleType.VOTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HomeServiceTest { + + @Mock + private BattleService battleService; + @Mock + private NoticeService noticeService; + + @InjectMocks + private HomeService homeService; + + @Test + void getHome_명세기준으로_섹션별_데이터를_조합한다() { + BattleSummaryResponse editorPick = battle("editor-id", BATTLE); + BattleSummaryResponse trendingBattle = battle("trending-id", BATTLE); + BattleSummaryResponse bestBattle = battle("best-id", BATTLE); + BattleSummaryResponse todayVotePick = battle("today-vote-id", VOTE); + BattleSummaryResponse quizBattle = quiz("quiz-id"); + BattleSummaryResponse newBattle = battle("new-id", BATTLE); + + NoticeSummaryResponse notice = new NoticeSummaryResponse( + UUID.randomUUID(), + "notice", + "body", + null, + NoticePlacement.HOME_TOP, + true, + LocalDateTime.now().minusDays(1), + null + ); + + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of(notice)); + when(battleService.getHomeEditorPicks()).thenReturn(List.of(editorPick)); + when(battleService.getHomeTrendingBattles()).thenReturn(List.of(trendingBattle)); + when(battleService.getHomeBestBattles()).thenReturn(List.of(bestBattle)); + when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); + when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); + when(battleService.getHomeNewBattles(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVotePick.battleId(), + quizBattle.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"); + + verify(battleService).getHomeNewBattles(argThat(ids -> ids.equals(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVotePick.battleId(), + quizBattle.battleId() + )))); + } + + @Test + void getHome_데이터가_없으면_false와_빈리스트를_반환한다() { + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); + when(battleService.getHomeEditorPicks()).thenReturn(List.of()); + when(battleService.getHomeTrendingBattles()).thenReturn(List.of()); + when(battleService.getHomeBestBattles()).thenReturn(List.of()); + when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getHomeNewBattles(List.of())).thenReturn(List.of()); + + var response = homeService.getHome(); + + assertThat(response.newNotice()).isFalse(); + assertThat(response.editorPicks()).isEmpty(); + assertThat(response.trendingBattles()).isEmpty(); + assertThat(response.bestBattles()).isEmpty(); + assertThat(response.todayPicks()).isEmpty(); + assertThat(response.newBattles()).isEmpty(); + } + + private BattleSummaryResponse battle(String title, BattleType type) { + return new BattleSummaryResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + type, + 10, + 20L, + 90, + List.of(), + List.of( + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()) + ) + ); + } + + private BattleSummaryResponse quiz(String title) { + return new BattleSummaryResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + QUIZ, + 30, + 40L, + 60, + List.of(), + List.of( + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "stance-c", "rep-c", "quote-c", "image-c", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "stance-d", "rep-d", "quote-d", "image-d", List.of()) + ) + ); + } +} diff --git a/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java new file mode 100644 index 0000000..8e7be80 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java @@ -0,0 +1,65 @@ +package com.swyp.app.domain.notice.service; + +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.repository.NoticeRepository; +import com.swyp.app.global.common.exception.CustomException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceTest { + + @Mock + private NoticeRepository noticeRepository; + + @InjectMocks + private NoticeService noticeService; + + @Test + void getNoticeList_활성공지_목록을_개수와_함께_반환한다() { + Notice notice = Notice.builder() + .title("공지") + .body("내용") + .type(NoticeType.ANNOUNCEMENT) + .placement(NoticePlacement.HOME_TOP) + .pinned(true) + .startsAt(LocalDateTime.now().minusDays(1)) + .endsAt(LocalDateTime.now().plusDays(1)) + .build(); + + when(noticeRepository.findActiveNotices(any(LocalDateTime.class), eq(NoticeType.ANNOUNCEMENT), + eq(NoticePlacement.HOME_TOP), any(Pageable.class))).thenReturn(List.of(notice)); + + var response = noticeService.getNoticeList(NoticeType.ANNOUNCEMENT, NoticePlacement.HOME_TOP, 5); + + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().title()).isEqualTo("공지"); + } + + @Test + void getNoticeDetail_활성공지가_없으면_예외를_던진다() { + UUID noticeId = UUID.randomUUID(); + when(noticeRepository.findActiveById(eq(noticeId), any(LocalDateTime.class))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> noticeService.getNoticeDetail(noticeId)) + .isInstanceOf(CustomException.class); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3262fa0..bbfa70b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -11,3 +11,46 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.H2Dialect + + cloud: + gcp: + credentials: + location: /tmp/test-gcp.json + aws: + s3: + bucket: test-bucket + region: + static: ap-northeast-2 + credentials: + access-key: test-access-key + secret-key: test-secret-key + +oauth: + kakao: + client-id: test-kakao-client-id + client-secret: test-kakao-client-secret + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + +jwt: + secret: dGVzdC10ZXN0LXRlc3QtdGVzdC10ZXN0LXRlc3QtdGVzdA== + access-token-expiration: 1800000 + refresh-token-expiration: 2592000000 + +openai: + api-key: test-openai-key + url: https://api.openai.com/v1/chat/completions + model: gpt-4o-mini + tts: + url: https://api.openai.com/v1/audio/speech + model: gpt-4o-mini-tts + +elevenlabs: + api-key: test-elevenlabs-key + model: test-model + voice-id: + a: test-voice-a + b: test-voice-b + user: test-voice-user + narrator: test-voice-narrator From c6ad852312904e648bd590daace5a6175abb81e6 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:45:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?#37=20[Fix]=20=ED=99=88=20API=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=EC=BB=A8=EB=B2=84=ED=84=B0=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../battle/converter/BattleConverter.java | 15 +-- .../dto/response/TodayBattleResponse.java | 4 +- .../domain/battle/service/BattleService.java | 5 - .../battle/service/BattleServiceImpl.java | 41 ------ .../app/domain/home/service/HomeService.java | 22 ++-- .../battle/service/BattleServiceImplTest.java | 122 ------------------ .../domain/home/service/HomeServiceTest.java | 62 ++++----- 7 files changed, 46 insertions(+), 225 deletions(-) delete mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java 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 167b4f8..9bfb5a6 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 @@ -41,19 +41,6 @@ public Battle toEntity(AdminBattleCreateRequest request, User admin) { public TodayBattleResponse toTodayResponse(Battle b, List tags, List opts) { return new TodayBattleResponse( - b.getId(), - b.getTitle(), - b.getSummary(), - b.getThumbnailUrl(), - b.getType(), - b.getAudioDuration() == null ? 0 : b.getAudioDuration(), - toTagResponses(tags, null), - toTodayOptionResponses(opts) - ); - } - - public BattleSummaryResponse toSummaryResponse(Battle b, List tags, List opts) { - return new BattleSummaryResponse( b.getId(), b.getTitle(), b.getSummary(), @@ -63,7 +50,7 @@ public BattleSummaryResponse toSummaryResponse(Battle b, List tags, List tags, // 상단 태그 리스트 List options // 중앙 세로형 대결 카드 데이터 -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java index 6e383fe..dc7303f 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java @@ -23,23 +23,18 @@ public interface BattleService { // 1. 에디터 픽 조회 (isEditorPick = true) List getEditorPicks(); - List getHomeEditorPicks(); // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) List getTrendingBattles(); - List getHomeTrendingBattles(); // 3. Best 배틀 조회 (누적 지표 랭킹) List getBestBattles(); - List getHomeBestBattles(); // 4. 오늘의 Pické 조회 (단일 타입 매칭) List getTodayPicks(BattleType type); - List getHomeTodayPicks(BattleType type); // 5. 새로운 배틀 조회 (중복 제외 리스트) List getNewBattles(List excludeIds); - List getHomeNewBattles(List excludeIds); // === [사용자용 - 기본 API] === diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java index 4bd43b7..9551555 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java @@ -67,12 +67,6 @@ public List getEditorPicks() { return convertToTodayResponses(battles); } - @Override - public List getHomeEditorPicks() { - List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)); - return convertToSummaryResponses(battles); - } - @Override public List getTrendingBattles() { LocalDateTime yesterday = LocalDateTime.now().minusDays(1); @@ -80,37 +74,18 @@ public List getTrendingBattles() { return convertToTodayResponses(battles); } - @Override - public List getHomeTrendingBattles() { - LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, 5)); - return convertToSummaryResponses(battles); - } - @Override public List getBestBattles() { List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); return convertToTodayResponses(battles); } - @Override - public List getHomeBestBattles() { - List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); - return convertToSummaryResponses(battles); - } - @Override public List getTodayPicks(BattleType type) { List battles = battleRepository.findTodayPicks(type, LocalDate.now()); return convertToTodayResponses(battles); } - @Override - public List getHomeTodayPicks(BattleType type) { - List battles = battleRepository.findTodayPicks(type, LocalDate.now()); - return convertToSummaryResponses(battles); - } - @Override public List getNewBattles(List excludeIds) { List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) @@ -119,14 +94,6 @@ public List getNewBattles(List excludeIds) { return convertToTodayResponses(battles); } - @Override - public List getHomeNewBattles(List excludeIds) { - List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) - ? List.of(UUID.randomUUID()) : excludeIds; - List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)); - return convertToSummaryResponses(battles); - } - // [사용자용 - 기본 API] @Override @@ -250,14 +217,6 @@ private List convertToTodayResponses(List battles) }).toList(); } - private List convertToSummaryResponses(List battles) { - return battles.stream().map(battle -> { - List tags = getTagsByBattle(battle); - List options = battleOptionRepository.findByBattle(battle); - return battleConverter.toSummaryResponse(battle, tags, options); - }).toList(); - } - private List getTagsByBattle(Battle b) { return battleTagRepository.findByBattle(b).stream() .map(BattleTag::getTag) 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 77de9c8..a2cd8e7 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,7 +1,7 @@ package com.swyp.app.domain.home.service; -import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +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.BattleType; import com.swyp.app.domain.battle.service.BattleService; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; @@ -30,16 +30,16 @@ public class HomeService { public HomeResponse getHome() { boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); - List editorPicks = toHomeBattles(battleService.getHomeEditorPicks()); - List trendingBattles = toHomeBattles(battleService.getHomeTrendingBattles()); - List bestBattles = toHomeBattles(battleService.getHomeBestBattles()); + List editorPicks = toHomeBattles(battleService.getEditorPicks()); + List trendingBattles = toHomeBattles(battleService.getTrendingBattles()); + List bestBattles = toHomeBattles(battleService.getBestBattles()); List todayPicks = new ArrayList<>(); - todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.VOTE))); - todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.QUIZ))); + 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.getHomeNewBattles(excludeIds)); + List newBattles = toHomeBattles(battleService.getNewBattles(excludeIds)); return new HomeResponse( newNotice, @@ -51,13 +51,13 @@ public HomeResponse getHome() { ); } - private List toHomeBattles(List battles) { + private List toHomeBattles(List battles) { return battles.stream() .map(this::toHomeBattle) .toList(); } - private HomeBattleResponse toHomeBattle(BattleSummaryResponse battle) { + private HomeBattleResponse toHomeBattle(TodayBattleResponse battle) { return new HomeBattleResponse( battle.battleId(), battle.title(), @@ -74,7 +74,7 @@ private HomeBattleResponse toHomeBattle(BattleSummaryResponse battle) { ); } - private HomeBattleOptionResponse toHomeOption(BattleOptionResponse option) { + private HomeBattleOptionResponse toHomeOption(TodayOptionResponse option) { return new HomeBattleOptionResponse(option.label(), option.title()); } diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java deleted file mode 100644 index e66b2ef..0000000 --- a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleOptionTagRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import com.swyp.app.domain.tag.repository.TagRepository; -import com.swyp.app.domain.user.repository.UserRepository; -import com.swyp.app.domain.vote.repository.VoteRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BattleServiceImplTest { - - @Mock - private BattleRepository battleRepository; - @Mock - private BattleOptionRepository battleOptionRepository; - @Mock - private BattleTagRepository battleTagRepository; - @Mock - private BattleOptionTagRepository battleOptionTagRepository; - @Mock - private TagRepository tagRepository; - @Mock - private UserRepository userRepository; - @Mock - private VoteRepository voteRepository; - @Mock - private BattleConverter battleConverter; - - @InjectMocks - private BattleServiceImpl battleService; - - private Battle battle; - - @BeforeEach - void setUp() { - battle = Battle.builder() - .title("battle") - .type(BattleType.BATTLE) - .targetDate(LocalDate.now()) - .status(BattleStatus.PUBLISHED) - .build(); - } - - @Test - void getHomeTrendingBattles_요약응답으로_변환한다() { - BattleSummaryResponse summary = new BattleSummaryResponse( - UUID.randomUUID(), - "trending", - "summary", - "thumbnail", - BattleType.BATTLE, - 12, - 34L, - 56, - List.of(), - List.of() - ); - - when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) - .thenReturn(summary); - - var result = battleService.getHomeTrendingBattles(); - - assertThat(result).containsExactly(summary); - } - - @Test - void getHomeNewBattles_제외아이디가_비어있으면_조회용_기본값을_사용한다() { - BattleSummaryResponse summary = new BattleSummaryResponse( - UUID.randomUUID(), - "new", - "summary", - "thumbnail", - BattleType.BATTLE, - 1, - 2L, - 3, - List.of(), - List.of() - ); - - when(battleRepository.findNewBattlesExcluding(any(), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) - .thenReturn(summary); - - var result = battleService.getHomeNewBattles(List.of()); - - assertThat(result).containsExactly(summary); - } -} 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 5e6ac99..dfbdc76 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 @@ -1,7 +1,7 @@ package com.swyp.app.domain.home.service; -import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +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.battle.service.BattleService; @@ -40,12 +40,12 @@ class HomeServiceTest { @Test void getHome_명세기준으로_섹션별_데이터를_조합한다() { - BattleSummaryResponse editorPick = battle("editor-id", BATTLE); - BattleSummaryResponse trendingBattle = battle("trending-id", BATTLE); - BattleSummaryResponse bestBattle = battle("best-id", BATTLE); - BattleSummaryResponse todayVotePick = battle("today-vote-id", VOTE); - BattleSummaryResponse quizBattle = quiz("quiz-id"); - BattleSummaryResponse newBattle = battle("new-id", BATTLE); + 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 newBattle = battle("new-id", BATTLE); NoticeSummaryResponse notice = new NoticeSummaryResponse( UUID.randomUUID(), @@ -59,12 +59,12 @@ class HomeServiceTest { ); when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of(notice)); - when(battleService.getHomeEditorPicks()).thenReturn(List.of(editorPick)); - when(battleService.getHomeTrendingBattles()).thenReturn(List.of(trendingBattle)); - when(battleService.getHomeBestBattles()).thenReturn(List.of(bestBattle)); - when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); - when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); - when(battleService.getHomeNewBattles(List.of( + 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.getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), @@ -83,7 +83,7 @@ class HomeServiceTest { 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"); - verify(battleService).getHomeNewBattles(argThat(ids -> ids.equals(List.of( + verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), @@ -95,12 +95,12 @@ class HomeServiceTest { @Test void getHome_데이터가_없으면_false와_빈리스트를_반환한다() { when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); - when(battleService.getHomeEditorPicks()).thenReturn(List.of()); - when(battleService.getHomeTrendingBattles()).thenReturn(List.of()); - when(battleService.getHomeBestBattles()).thenReturn(List.of()); - when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of()); - when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of()); - when(battleService.getHomeNewBattles(List.of())).thenReturn(List.of()); + when(battleService.getEditorPicks()).thenReturn(List.of()); + when(battleService.getTrendingBattles()).thenReturn(List.of()); + when(battleService.getBestBattles()).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of())).thenReturn(List.of()); var response = homeService.getHome(); @@ -112,8 +112,8 @@ class HomeServiceTest { assertThat(response.newBattles()).isEmpty(); } - private BattleSummaryResponse battle(String title, BattleType type) { - return new BattleSummaryResponse( + private TodayBattleResponse battle(String title, BattleType type) { + return new TodayBattleResponse( UUID.randomUUID(), title, "summary", @@ -124,14 +124,14 @@ private BattleSummaryResponse battle(String title, BattleType type) { 90, List.of(), List.of( - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()) + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b") ) ); } - private BattleSummaryResponse quiz(String title) { - return new BattleSummaryResponse( + private TodayBattleResponse quiz(String title) { + return new TodayBattleResponse( UUID.randomUUID(), title, "summary", @@ -142,10 +142,10 @@ private BattleSummaryResponse quiz(String title) { 60, List.of(), List.of( - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "stance-c", "rep-c", "quote-c", "image-c", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "stance-d", "rep-d", "quote-d", "image-d", List.of()) + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "rep-c", "stance-c", "image-c"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "rep-d", "stance-d", "image-d") ) ); } From 284a8a1a48bd0d5136dab7bf2dd0671f3cc9ce73 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:59:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?#37=20[Refactor]=20=ED=99=88=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=EC=A1=B0=ED=9A=8C=20V2=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BattleOptionRepository.java | 4 + .../repository/BattleTagRepository.java | 6 +- .../battle/service/BattleServiceImplV2.java | 123 ++++++++++++++++++ .../battle/service/HomeBattleService.java | 20 +++ .../app/domain/home/service/HomeService.java | 4 +- .../service/BattleServiceImplV2Test.java | 118 +++++++++++++++++ .../domain/home/service/HomeServiceTest.java | 4 +- 7 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java create mode 100644 src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java create mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java index d00339f..cd69891 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java @@ -4,6 +4,8 @@ import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -12,6 +14,8 @@ public interface BattleOptionRepository extends JpaRepository { List findByBattle(Battle battle); + @Query("SELECT battleOption FROM BattleOption battleOption WHERE battleOption.battle IN :battles ORDER BY battleOption.battle.id ASC, battleOption.label ASC") + List findByBattleInOrderByBattleIdAscLabelAsc(@Param("battles") List battles); Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); } diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java index 38a5c8a..53afda9 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java @@ -4,12 +4,16 @@ import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface BattleTagRepository extends JpaRepository { List findByBattle(Battle battle); + @Query("SELECT battleTag FROM BattleTag battleTag JOIN FETCH battleTag.tag WHERE battleTag.battle IN :battles") + List findByBattleIn(@Param("battles") List battles); void deleteByBattle(Battle battle); boolean existsByTag(Tag tag); -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java new file mode 100644 index 0000000..f0918c9 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -0,0 +1,123 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleServiceImplV2 implements HomeBattleService { + + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; + private final BattleConverter battleConverter; + + @Override + public List getEditorPicks() { + return convertToTodayResponses( + battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)) + ); + } + + @Override + public List getTrendingBattles() { + return convertToTodayResponses( + battleRepository.findTrendingBattles(LocalDateTime.now().minusDays(1), PageRequest.of(0, 5)) + ); + } + + @Override + public List getBestBattles() { + return convertToTodayResponses( + battleRepository.findBestBattles(PageRequest.of(0, 5)) + ); + } + + @Override + public List getTodayPicks(BattleType type) { + return convertToTodayResponses( + battleRepository.findTodayPicks(type, LocalDate.now()) + ); + } + + @Override + public List getNewBattles(List excludeIds) { + List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) + ? List.of(UUID.randomUUID()) + : excludeIds; + + return convertToTodayResponses( + battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)) + ); + } + + private List convertToTodayResponses(List battles) { + if (battles.isEmpty()) { + return List.of(); + } + + Map> tagsByBattleId = loadTagsByBattleId(battles); + Map> optionsByBattleId = loadOptionsByBattleId(battles); + + return battles.stream() + .map(battle -> battleConverter.toTodayResponse( + battle, + tagsByBattleId.getOrDefault(battle.getId(), List.of()), + optionsByBattleId.getOrDefault(battle.getId(), List.of()) + )) + .toList(); + } + + private Map> loadTagsByBattleId(List battles) { + Map> tagsByBattleId = new HashMap<>(); + + for (BattleTag battleTag : battleTagRepository.findByBattleIn(battles)) { + Tag tag = battleTag.getTag(); + if (tag.getDeletedAt() != null) { + continue; + } + + UUID battleId = battleTag.getBattle().getId(); + tagsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(tag); + } + + return tagsByBattleId; + } + + private Map> loadOptionsByBattleId(List battles) { + Map> optionsByBattleId = new HashMap<>(); + + for (BattleOption option : battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(battles)) { + UUID battleId = option.getBattle().getId(); + optionsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(option); + } + + optionsByBattleId.values() + .forEach(options -> options.sort(Comparator.comparing(BattleOption::getLabel))); + + return optionsByBattleId; + } +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java new file mode 100644 index 0000000..d5a9958 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java @@ -0,0 +1,20 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.enums.BattleType; + +import java.util.List; +import java.util.UUID; + +public interface HomeBattleService { + + List getEditorPicks(); + + List getTrendingBattles(); + + List getBestBattles(); + + List getTodayPicks(BattleType type); + + List getNewBattles(List excludeIds); +} 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 a2cd8e7..1d068d8 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 @@ -3,7 +3,7 @@ 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.BattleType; -import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.battle.service.HomeBattleService; 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; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final BattleService battleService; + private final HomeBattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java new file mode 100644 index 0000000..f451623 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java @@ -0,0 +1,118 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import com.swyp.app.domain.tag.enums.TagType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplV2Test { + + @Mock + private BattleRepository battleRepository; + @Mock + private BattleOptionRepository battleOptionRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleConverter battleConverter; + + @InjectMocks + private BattleServiceImplV2 battleService; + + private Battle battle; + private BattleOption optionA; + private BattleOption optionB; + private Tag tag; + + @BeforeEach + void setUp() { + battle = Battle.builder() + .title("battle") + .summary("summary") + .thumbnailUrl("thumbnail") + .type(BattleType.BATTLE) + .targetDate(LocalDate.now()) + .status(BattleStatus.PUBLISHED) + .build(); + + optionA = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.A) + .title("A") + .stance("stance-a") + .representative("rep-a") + .imageUrl("image-a") + .build(); + + optionB = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.B) + .title("B") + .stance("stance-b") + .representative("rep-b") + .imageUrl("image-b") + .build(); + + tag = Tag.builder() + .name("태그") + .type(TagType.CATEGORY) + .build(); + } + + @Test + void getTrendingBattles_배치조회한_태그와_옵션으로_변환한다() { + BattleTag battleTag = BattleTag.builder().battle(battle).tag(tag).build(); + TodayBattleResponse response = new TodayBattleResponse( + UUID.randomUUID(), + "title", + "summary", + "thumbnail", + BattleType.BATTLE, + 1, + 2L, + 3, + List.of(), + List.of() + ); + + when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleTagRepository.findByBattleIn(List.of(battle))).thenReturn(List.of(battleTag)); + when(battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle))).thenReturn(List.of(optionA, optionB)); + when(battleConverter.toTodayResponse(eq(battle), eq(List.of(tag)), eq(List.of(optionA, optionB)))) + .thenReturn(response); + + var result = battleService.getTrendingBattles(); + + assertThat(result).containsExactly(response); + verify(battleTagRepository).findByBattleIn(List.of(battle)); + verify(battleOptionRepository).findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle)); + } +} 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 dfbdc76..bc54eed 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 @@ -4,7 +4,7 @@ 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.battle.service.BattleService; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private BattleService battleService; + private HomeBattleService battleService; @Mock private NoticeService noticeService; From 4d2fca47ff904c7edb7221d217172a50d5a7cb4a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:05:56 +0900 Subject: [PATCH 4/6] =?UTF-8?q?#37=20[Refactor]=20=ED=99=88=20V2=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=85=EC=B9=AD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/app/domain/battle/service/BattleServiceImplV2.java | 2 +- .../service/{HomeBattleService.java => HomeServiceV2.java} | 2 +- .../java/com/swyp/app/domain/home/service/HomeService.java | 4 ++-- .../com/swyp/app/domain/home/service/HomeServiceTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/swyp/app/domain/battle/service/{HomeBattleService.java => HomeServiceV2.java} (93%) diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java index f0918c9..b3a87f3 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeBattleService { +public class BattleServiceImplV2 implements HomeServiceV2 { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java similarity index 93% rename from src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java rename to src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java index d5a9958..067ac82 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.UUID; -public interface HomeBattleService { +public interface HomeServiceV2 { List getEditorPicks(); 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 1d068d8..49d2b33 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 @@ -3,7 +3,7 @@ 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.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.HomeServiceV2; 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; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeBattleService battleService; + private final HomeServiceV2 battleService; private final NoticeService noticeService; public HomeResponse getHome() { 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 bc54eed..eb3dd8c 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 @@ -4,7 +4,7 @@ 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.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.HomeServiceV2; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeBattleService battleService; + private HomeServiceV2 battleService; @Mock private NoticeService noticeService; From d1c3c981a5771f3b886f93bb7e37e276979cd491 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:10:35 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Revert=20"#37=20[Refactor]=20=ED=99=88=20V2?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=85=EC=B9=AD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4d2fca47ff904c7edb7221d217172a50d5a7cb4a. --- .../swyp/app/domain/battle/service/BattleServiceImplV2.java | 2 +- .../service/{HomeServiceV2.java => HomeBattleService.java} | 2 +- .../java/com/swyp/app/domain/home/service/HomeService.java | 4 ++-- .../com/swyp/app/domain/home/service/HomeServiceTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/swyp/app/domain/battle/service/{HomeServiceV2.java => HomeBattleService.java} (93%) diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java index b3a87f3..f0918c9 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeServiceV2 { +public class BattleServiceImplV2 implements HomeBattleService { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java similarity index 93% rename from src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java rename to src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java index 067ac82..d5a9958 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.UUID; -public interface HomeServiceV2 { +public interface HomeBattleService { List getEditorPicks(); 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 49d2b33..1d068d8 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 @@ -3,7 +3,7 @@ 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.BattleType; -import com.swyp.app.domain.battle.service.HomeServiceV2; +import com.swyp.app.domain.battle.service.HomeBattleService; 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; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeServiceV2 battleService; + private final HomeBattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { 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 eb3dd8c..bc54eed 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 @@ -4,7 +4,7 @@ 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.battle.service.HomeServiceV2; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeServiceV2 battleService; + private HomeBattleService battleService; @Mock private NoticeService noticeService; From 980b9248fa485a5a6c584e0bb341d3cbde3c7c79 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:10:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Revert=20"#37=20[Refactor]=20=ED=99=88=20?= =?UTF-8?q?=EB=B0=B0=ED=8B=80=20=EC=A1=B0=ED=9A=8C=20V2=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 284a8a1a48bd0d5136dab7bf2dd0671f3cc9ce73. --- .../repository/BattleOptionRepository.java | 4 - .../repository/BattleTagRepository.java | 6 +- .../battle/service/BattleServiceImplV2.java | 123 ------------------ .../battle/service/HomeBattleService.java | 20 --- .../app/domain/home/service/HomeService.java | 4 +- .../service/BattleServiceImplV2Test.java | 118 ----------------- .../domain/home/service/HomeServiceTest.java | 4 +- 7 files changed, 5 insertions(+), 274 deletions(-) delete mode 100644 src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java delete mode 100644 src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java delete mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java index cd69891..d00339f 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java @@ -4,8 +4,6 @@ import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -14,8 +12,6 @@ public interface BattleOptionRepository extends JpaRepository { List findByBattle(Battle battle); - @Query("SELECT battleOption FROM BattleOption battleOption WHERE battleOption.battle IN :battles ORDER BY battleOption.battle.id ASC, battleOption.label ASC") - List findByBattleInOrderByBattleIdAscLabelAsc(@Param("battles") List battles); Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); } diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java index 53afda9..38a5c8a 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java @@ -4,16 +4,12 @@ import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface BattleTagRepository extends JpaRepository { List findByBattle(Battle battle); - @Query("SELECT battleTag FROM BattleTag battleTag JOIN FETCH battleTag.tag WHERE battleTag.battle IN :battles") - List findByBattleIn(@Param("battles") List battles); void deleteByBattle(Battle battle); boolean existsByTag(Tag tag); -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java deleted file mode 100644 index f0918c9..0000000 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeBattleService { - - private final BattleRepository battleRepository; - private final BattleOptionRepository battleOptionRepository; - private final BattleTagRepository battleTagRepository; - private final BattleConverter battleConverter; - - @Override - public List getEditorPicks() { - return convertToTodayResponses( - battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)) - ); - } - - @Override - public List getTrendingBattles() { - return convertToTodayResponses( - battleRepository.findTrendingBattles(LocalDateTime.now().minusDays(1), PageRequest.of(0, 5)) - ); - } - - @Override - public List getBestBattles() { - return convertToTodayResponses( - battleRepository.findBestBattles(PageRequest.of(0, 5)) - ); - } - - @Override - public List getTodayPicks(BattleType type) { - return convertToTodayResponses( - battleRepository.findTodayPicks(type, LocalDate.now()) - ); - } - - @Override - public List getNewBattles(List excludeIds) { - List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) - ? List.of(UUID.randomUUID()) - : excludeIds; - - return convertToTodayResponses( - battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)) - ); - } - - private List convertToTodayResponses(List battles) { - if (battles.isEmpty()) { - return List.of(); - } - - Map> tagsByBattleId = loadTagsByBattleId(battles); - Map> optionsByBattleId = loadOptionsByBattleId(battles); - - return battles.stream() - .map(battle -> battleConverter.toTodayResponse( - battle, - tagsByBattleId.getOrDefault(battle.getId(), List.of()), - optionsByBattleId.getOrDefault(battle.getId(), List.of()) - )) - .toList(); - } - - private Map> loadTagsByBattleId(List battles) { - Map> tagsByBattleId = new HashMap<>(); - - for (BattleTag battleTag : battleTagRepository.findByBattleIn(battles)) { - Tag tag = battleTag.getTag(); - if (tag.getDeletedAt() != null) { - continue; - } - - UUID battleId = battleTag.getBattle().getId(); - tagsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(tag); - } - - return tagsByBattleId; - } - - private Map> loadOptionsByBattleId(List battles) { - Map> optionsByBattleId = new HashMap<>(); - - for (BattleOption option : battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(battles)) { - UUID battleId = option.getBattle().getId(); - optionsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(option); - } - - optionsByBattleId.values() - .forEach(options -> options.sort(Comparator.comparing(BattleOption::getLabel))); - - return optionsByBattleId; - } -} diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java deleted file mode 100644 index d5a9958..0000000 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.enums.BattleType; - -import java.util.List; -import java.util.UUID; - -public interface HomeBattleService { - - List getEditorPicks(); - - List getTrendingBattles(); - - List getBestBattles(); - - List getTodayPicks(BattleType type); - - List getNewBattles(List excludeIds); -} 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 1d068d8..a2cd8e7 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 @@ -3,7 +3,7 @@ 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.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +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; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeBattleService battleService; + private final BattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java deleted file mode 100644 index f451623..0000000 --- a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleOptionLabel; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import com.swyp.app.domain.tag.enums.TagType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BattleServiceImplV2Test { - - @Mock - private BattleRepository battleRepository; - @Mock - private BattleOptionRepository battleOptionRepository; - @Mock - private BattleTagRepository battleTagRepository; - @Mock - private BattleConverter battleConverter; - - @InjectMocks - private BattleServiceImplV2 battleService; - - private Battle battle; - private BattleOption optionA; - private BattleOption optionB; - private Tag tag; - - @BeforeEach - void setUp() { - battle = Battle.builder() - .title("battle") - .summary("summary") - .thumbnailUrl("thumbnail") - .type(BattleType.BATTLE) - .targetDate(LocalDate.now()) - .status(BattleStatus.PUBLISHED) - .build(); - - optionA = BattleOption.builder() - .battle(battle) - .label(BattleOptionLabel.A) - .title("A") - .stance("stance-a") - .representative("rep-a") - .imageUrl("image-a") - .build(); - - optionB = BattleOption.builder() - .battle(battle) - .label(BattleOptionLabel.B) - .title("B") - .stance("stance-b") - .representative("rep-b") - .imageUrl("image-b") - .build(); - - tag = Tag.builder() - .name("태그") - .type(TagType.CATEGORY) - .build(); - } - - @Test - void getTrendingBattles_배치조회한_태그와_옵션으로_변환한다() { - BattleTag battleTag = BattleTag.builder().battle(battle).tag(tag).build(); - TodayBattleResponse response = new TodayBattleResponse( - UUID.randomUUID(), - "title", - "summary", - "thumbnail", - BattleType.BATTLE, - 1, - 2L, - 3, - List.of(), - List.of() - ); - - when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleTagRepository.findByBattleIn(List.of(battle))).thenReturn(List.of(battleTag)); - when(battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle))).thenReturn(List.of(optionA, optionB)); - when(battleConverter.toTodayResponse(eq(battle), eq(List.of(tag)), eq(List.of(optionA, optionB)))) - .thenReturn(response); - - var result = battleService.getTrendingBattles(); - - assertThat(result).containsExactly(response); - verify(battleTagRepository).findByBattleIn(List.of(battle)); - verify(battleOptionRepository).findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle)); - } -} 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 bc54eed..dfbdc76 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 @@ -4,7 +4,7 @@ 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.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.BattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeBattleService battleService; + private BattleService battleService; @Mock private NoticeService noticeService;