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..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 @@ -46,6 +46,8 @@ public TodayBattleResponse toTodayResponse(Battle b, List tags, 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/dto/response/TodayBattleResponse.java b/src/main/java/com/swyp/app/domain/battle/dto/response/TodayBattleResponse.java index e24237a..1eded2e 100644 --- a/src/main/java/com/swyp/app/domain/battle/dto/response/TodayBattleResponse.java +++ b/src/main/java/com/swyp/app/domain/battle/dto/response/TodayBattleResponse.java @@ -15,7 +15,9 @@ public record TodayBattleResponse( String summary, // 중간 요약 문구 String thumbnailUrl, // 풀스크린 배경 이미지 URL BattleType type, // 타입 태그 + Integer viewCount, // 조회수 + Long participantsCount, // 누적 참여자 수 Integer audioDuration, // 소요 시간 (분:초 변환용 데이터) 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 fe6fc12..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 @@ -59,4 +59,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..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 @@ -242,4 +242,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..a2cd8e7 --- /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.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; +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.getEditorPicks()); + List trendingBattles = toHomeBattles(battleService.getTrendingBattles()); + List bestBattles = toHomeBattles(battleService.getBestBattles()); + + List todayPicks = new ArrayList<>(); + todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.VOTE))); + todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.QUIZ))); + + List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); + List newBattles = toHomeBattles(battleService.getNewBattles(excludeIds)); + + return new HomeResponse( + newNotice, + editorPicks, + trendingBattles, + bestBattles, + todayPicks, + newBattles + ); + } + + private List toHomeBattles(List battles) { + return battles.stream() + .map(this::toHomeBattle) + .toList(); + } + + private HomeBattleResponse toHomeBattle(TodayBattleResponse battle) { + return new HomeBattleResponse( + battle.battleId(), + battle.title(), + battle.summary(), + battle.thumbnailUrl(), + battle.type(), + battle.viewCount(), + battle.participantsCount(), + battle.audioDuration(), + battle.tags(), + battle.options().stream() + .map(this::toHomeOption) + .toList() + ); + } + + private HomeBattleOptionResponse toHomeOption(TodayOptionResponse 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/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java new file mode 100644 index 0000000..dfbdc76 --- /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.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; +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_명세기준으로_섹션별_데이터를_조합한다() { + 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(), + "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.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(), + 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).getNewBattles(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.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(); + + 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 TodayBattleResponse battle(String title, BattleType type) { + return new TodayBattleResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + type, + 10, + 20L, + 90, + List.of(), + 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 TodayBattleResponse quiz(String title) { + return new TodayBattleResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + QUIZ, + 30, + 40L, + 60, + List.of(), + 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") + ) + ); + } +} 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