From 1ea577aac426f9bdcd06bc92549dfab8aff4d165 Mon Sep 17 00:00:00 2001 From: jucheonsu Date: Fri, 10 Apr 2026 18:19:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Breaking=20Change]=20Battle/Quiz/Poll=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Vote=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminBattleController.java | 51 --- .../battle/controller/BattleController.java | 26 +- .../battle/converter/BattleConverter.java | 72 ++-- .../dto/request/AdminBattleCreateRequest.java | 24 -- .../dto/request/AdminBattleOptionRequest.java | 16 - .../dto/request/AdminBattleUpdateRequest.java | 23 -- .../response/AdminBattleDeleteResponse.java | 13 - .../response/AdminBattleDetailResponse.java | 36 -- .../dto/response/BattleOptionResponse.java | 3 +- .../dto/response/BattleScenarioResponse.java | 3 +- .../dto/response/BattleSimpleResponse.java | 3 +- .../dto/response/BattleSummaryResponse.java | 28 +- .../response/BattleUserDetailResponse.java | 26 +- .../dto/response/BattleVoteResponse.java | 2 +- .../dto/response/TodayBattleListResponse.java | 2 +- .../dto/response/TodayBattleResponse.java | 32 +- .../dto/response/TodayOptionResponse.java | 18 +- .../picke/domain/battle/entity/Battle.java | 120 +++--- .../domain/battle/entity/BattleOption.java | 64 ++- .../picke/domain/battle/enums/BattleType.java | 5 - .../repository/BattleOptionRepository.java | 14 +- .../repository/BattleOptionTagRepository.java | 2 + .../battle/repository/BattleRepository.java | 77 ++-- .../domain/battle/service/BattleService.java | 53 +-- .../battle/service/BattleServiceImpl.java | 375 ++++++++++++++---- .../poll/controller/PollController.java | 38 ++ .../domain/poll/converter/PollConverter.java | 85 ++++ .../poll/dto/response/PollDetailResponse.java | 14 + .../poll/dto/response/PollListResponse.java | 13 + .../poll/dto/response/PollOptionResponse.java | 14 + .../poll/dto/response/PollSimpleResponse.java | 15 + .../poll/dto/response/PollTagResponse.java | 12 + .../swyp/picke/domain/poll/entity/Poll.java | 70 ++++ .../picke/domain/poll/entity/PollOption.java | 65 +++ .../poll/entity/PollOptionValueTagMap.java | 39 ++ .../poll/entity/PollOptionValueTagMapId.java | 16 + .../picke/domain/poll/entity/PollTagMap.java | 39 ++ .../domain/poll/entity/PollTagMapId.java | 16 + .../domain/poll/entity/PollUserVote.java | 43 ++ .../domain/poll/enums/PollOptionLabel.java | 5 + .../picke/domain/poll/enums/PollStatus.java | 8 + .../poll/repository/PollOptionRepository.java | 14 + .../PollOptionValueTagMapRepository.java | 14 + .../poll/repository/PollRepository.java | 37 ++ .../poll/repository/PollTagMapRepository.java | 14 + .../repository/PollUserVoteRepository.java | 16 + .../domain/poll/service/PollService.java | 35 ++ .../domain/poll/service/PollServiceImpl.java | 186 +++++++++ .../quiz/controller/QuizController.java | 38 ++ .../domain/quiz/converter/QuizConverter.java | 85 ++++ .../quiz/dto/response/QuizDetailResponse.java | 17 + .../quiz/dto/response/QuizListResponse.java | 13 + .../quiz/dto/response/QuizOptionResponse.java | 12 + .../quiz/dto/response/QuizSimpleResponse.java | 15 + .../quiz/dto/response/QuizTagResponse.java | 12 + .../swyp/picke/domain/quiz/entity/Quiz.java | 65 +++ .../picke/domain/quiz/entity/QuizOption.java | 71 ++++ .../quiz/entity/QuizOptionValueTagMap.java | 39 ++ .../quiz/entity/QuizOptionValueTagMapId.java | 16 + .../picke/domain/quiz/entity/QuizTagMap.java | 39 ++ .../domain/quiz/entity/QuizTagMapId.java | 16 + .../domain/quiz/entity/QuizUserVote.java | 43 ++ .../domain/quiz/enums/QuizOptionLabel.java | 6 + .../picke/domain/quiz/enums/QuizStatus.java | 8 + .../quiz/repository/QuizOptionRepository.java | 14 + .../QuizOptionValueTagMapRepository.java | 14 + .../quiz/repository/QuizRepository.java | 37 ++ .../quiz/repository/QuizTagMapRepository.java | 14 + .../repository/QuizUserVoteRepository.java | 16 + .../domain/quiz/service/QuizService.java | 35 ++ .../domain/quiz/service/QuizServiceImpl.java | 189 +++++++++ .../domain/tag/controller/TagController.java | 49 +-- .../domain/tag/converter/TagConverter.java | 6 +- .../domain/tag/dto/request/TagRequest.java | 13 - .../tag/dto/response/TagDeleteResponse.java | 8 - .../tag/dto/response/TagListResponse.java | 1 + .../domain/tag/dto/response/TagResponse.java | 12 - .../picke/domain/tag/entity/CategoryTag.java | 35 ++ .../domain/tag/entity/PhilosopherTag.java | 36 ++ .../picke/domain/tag/entity/ValueTag.java | 36 ++ .../tag/repository/CategoryTagRepository.java | 8 + .../repository/PhilosopherTagRepository.java | 8 + .../tag/repository/ValueTagRepository.java | 8 + .../picke/domain/tag/service/TagService.java | 7 +- .../domain/tag/service/TagServiceImpl.java | 19 +- .../vote/controller/VoteController.java | 85 ++-- .../domain/vote/converter/VoteConverter.java | 23 +- .../vote/dto/request/PollVoteRequest.java | 7 + .../vote/dto/request/QuizVoteRequest.java | 2 +- .../vote/dto/response/MyVoteResponse.java | 2 +- .../vote/dto/response/PollVoteResponse.java | 2 +- .../entity/{Vote.java => BattleVote.java} | 26 +- .../picke/domain/vote/entity/PollVote.java | 45 +++ .../picke/domain/vote/entity/QuizVote.java | 24 +- ...ository.java => BattleVoteRepository.java} | 42 +- .../vote/repository/PollVoteRepository.java | 14 + .../vote/repository/QuizVoteRepository.java | 12 +- ...oteService.java => BattleVoteService.java} | 2 +- ...ceImpl.java => BattleVoteServiceImpl.java} | 78 ++-- .../domain/vote/service/PollVoteService.java | 10 + .../vote/service/PollVoteServiceImpl.java | 140 +++++++ .../domain/vote/service/QuizVoteService.java | 4 +- .../vote/service/QuizVoteServiceImpl.java | 205 ++++------ .../domain/vote/service/VoteQueryService.java | 46 ++- 104 files changed, 2821 insertions(+), 904 deletions(-) delete mode 100644 src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java delete mode 100644 src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/controller/PollController.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/Poll.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/service/PollService.java create mode 100644 src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java create mode 100644 src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java delete mode 100644 src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java delete mode 100644 src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java delete mode 100644 src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/entity/CategoryTag.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/entity/PhilosopherTag.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/entity/ValueTag.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/repository/CategoryTagRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/repository/PhilosopherTagRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/tag/repository/ValueTagRepository.java create mode 100644 src/main/java/com/swyp/picke/domain/vote/dto/request/PollVoteRequest.java rename src/main/java/com/swyp/picke/domain/vote/entity/{Vote.java => BattleVote.java} (77%) create mode 100644 src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java rename src/main/java/com/swyp/picke/domain/vote/repository/{VoteRepository.java => BattleVoteRepository.java} (51%) create mode 100644 src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java rename src/main/java/com/swyp/picke/domain/vote/service/{VoteService.java => BattleVoteService.java} (95%) rename src/main/java/com/swyp/picke/domain/vote/service/{VoteServiceImpl.java => BattleVoteServiceImpl.java} (70%) create mode 100644 src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java create mode 100644 src/main/java/com/swyp/picke/domain/vote/service/PollVoteServiceImpl.java diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java deleted file mode 100644 index b115abc3..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.swyp.picke.domain.battle.controller; - -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; -import com.swyp.picke.domain.battle.dto.response.AdminBattleDeleteResponse; -import com.swyp.picke.domain.battle.dto.response.AdminBattleDetailResponse; -import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.global.common.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "배틀 API (관리자)", description = "배틀 생성/수정/삭제 (관리자 전용)") -@RestController -@RequestMapping("/api/v1/admin/battles") -@RequiredArgsConstructor -@PreAuthorize("hasRole('ADMIN')") -public class AdminBattleController { - - private final BattleService battleService; - - @Operation(summary = "배틀 생성") - @PostMapping - public ApiResponse createBattle( - @RequestBody @Valid AdminBattleCreateRequest request, - @AuthenticationPrincipal Long adminUserId - ) { - return ApiResponse.onSuccess(battleService.createBattle(request, adminUserId)); - } - - @Operation(summary = "배틀 수정 (변경 필드만 포함)") - @PatchMapping("/{battleId}") - public ApiResponse updateBattle( - @PathVariable Long battleId, - @RequestBody @Valid AdminBattleUpdateRequest request - ) { - return ApiResponse.onSuccess(battleService.updateBattle(battleId, request)); - } - - @Operation(summary = "배틀 삭제") - @DeleteMapping("/{battleId}") - public ApiResponse deleteBattle( - @PathVariable Long battleId - ) { - return ApiResponse.onSuccess(battleService.deleteBattle(battleId)); - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java index 9450a078..eafacd8b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleController.java @@ -10,9 +10,13 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +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; -@Tag(name = "배틀 API (사용자)", description = "배틀 조회") +@Tag(name = "배틀 API", description = "배틀 조회") @RestController @RequestMapping("/api/v1/battles") @RequiredArgsConstructor @@ -20,36 +24,34 @@ public class BattleController { private final BattleService battleService; - @Operation(summary = "오늘의 배틀 목록 조회 (스와이프 UI용, 최대 5개)") + @Operation(summary = "오늘의 배틀 목록 조회 (최대 5개)") @GetMapping("/today") public ApiResponse getTodayBattles() { return ApiResponse.onSuccess(battleService.getTodayBattles()); } - @Operation(summary = "배틀 전체 목록 조회", description = "페이징 및 타입별(ALL, BATTLE, QUIZ, VOTE) 필터링된 배틀 목록을 조회합니다.") + @Operation(summary = "배틀 목록 조회") @GetMapping public ApiResponse getBattles( @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") @RequestParam(value = "page", defaultValue = "1") int page, @Parameter(description = "페이지 크기", example = "10") @RequestParam(value = "size", defaultValue = "10") int size, - @Parameter(description = "콘텐츠 타입 (ALL, BATTLE, QUIZ, VOTE)", example = "ALL") - @RequestParam(value = "type", required = false, defaultValue = "ALL") String type + @Parameter(description = "콘텐츠 상태 (ALL, PENDING, PUBLISHED, REJECTED, ARCHIVED)", example = "ALL") + @RequestParam(value = "status", required = false, defaultValue = "ALL") String status ) { - return ApiResponse.onSuccess(battleService.getBattles(page, size, type)); + return ApiResponse.onSuccess(battleService.getBattles(page, size, status)); } @Operation(summary = "배틀 상세 조회") @GetMapping("/{battleId}") - public ApiResponse getBattleDetail( - @PathVariable Long battleId - ) { + public ApiResponse getBattleDetail(@PathVariable Long battleId) { return ApiResponse.onSuccess(battleService.getBattleDetail(battleId)); } - @Operation(summary = "사용자 배틀 진행 상태 조회 (사전투표/TTS/사후투표)") + @Operation(summary = "사용자 배틀 진행 상태 조회") @GetMapping("/{battleId}/status") public ApiResponse getUserBattleStatus(@PathVariable Long battleId) { return ApiResponse.onSuccess(battleService.getUserBattleStatus(battleId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java index 3511d521..71d5c27a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -1,21 +1,22 @@ package com.swyp.picke.domain.battle.converter; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.battle.dto.response.*; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleCreatorType; -import com.swyp.picke.domain.user.enums.PhilosopherType; -import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.enums.VoteSide; import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -25,21 +26,17 @@ public class BattleConverter { private final ResourceUrlProvider urlProvider; private static final String BASE_SHARE_URL = "https://pique.app/battles/"; + private static final Comparator OPTION_SORTER = + Comparator.comparing((BattleOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(BattleOption::getId); public Battle toEntity(AdminBattleCreateRequest request, User admin) { return Battle.builder() .title(request.title()) - .titlePrefix(request.titlePrefix()) - .titleSuffix(request.titleSuffix()) - .itemA(request.itemA()) - .itemADesc(request.itemADesc()) - .itemB(request.itemB()) - .itemBDesc(request.itemBDesc()) .summary(request.summary()) .description(request.description()) .thumbnailUrl(request.thumbnailUrl()) - .type(request.type()) - .targetDate(request.targetDate()) .status(request.status()) .creatorType(BattleCreatorType.ADMIN) .creator(admin) @@ -52,18 +49,11 @@ public TodayBattleResponse toTodayResponse(Battle battle, List tags, List return new AdminBattleDetailResponse( battle.getId(), battle.getTitle(), - battle.getTitlePrefix(), - battle.getTitleSuffix(), battle.getSummary(), battle.getDescription(), urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), - battle.getType(), - battle.getItemA(), - battle.getItemADesc(), - battle.getItemB(), - battle.getItemBDesc(), + battle.getAudioDuration(), battle.getTargetDate(), battle.getStatus(), battle.getCreatorType(), @@ -111,7 +94,6 @@ public BattleUserDetailResponse toUserDetailResponse( battle.getTitle(), battle.getSummary(), urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), - battle.getType(), battle.getViewCount() == null ? 0 : battle.getViewCount(), participantsCount == null ? 0L : participantsCount, battle.getAudioDuration() == null ? 0 : battle.getAudioDuration(), @@ -121,12 +103,6 @@ public BattleUserDetailResponse toUserDetailResponse( return new BattleUserDetailResponse( summary, - battle.getTitlePrefix(), - battle.getTitleSuffix(), - battle.getItemA(), - battle.getItemADesc(), - battle.getItemB(), - battle.getItemBDesc(), battle.getDescription(), BASE_SHARE_URL + battle.getId(), userVoteStatus, @@ -143,8 +119,7 @@ public BattleScenarioResponse toScenarioResponse(Battle battle, List toOptionResponses(List options, Map> optionTagsMap) { if (options == null) return List.of(); return options.stream() + .sorted(OPTION_SORTER) .map(option -> { List optionTags = optionTagsMap.getOrDefault(option.getId(), List.of()); return new BattleOptionResponse( @@ -161,8 +137,7 @@ private List toOptionResponses(List options, option.getTitle(), option.getStance(), option.getRepresentative(), - option.getQuote(), - urlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(option.getRepresentative())), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), toTagResponses(optionTags, null) ); }).toList(); @@ -170,15 +145,16 @@ private List toOptionResponses(List options, private List toTodayOptionResponses(List options) { if (options == null) return List.of(); - return options.stream().map(option -> new TodayOptionResponse( - option.getId(), - option.getLabel(), - option.getTitle(), - option.getRepresentative(), - option.getStance(), - urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), - option.getIsCorrect() - )).toList(); + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new TodayOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getRepresentative(), + option.getStance(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()) + )).toList(); } private List toTagResponses(List tags, TagType targetType) { @@ -188,4 +164,4 @@ private List toTagResponses(List tags, TagType targetTyp .map(tag -> new BattleTagResponse(tag.getId(), tag.getName(), tag.getType())) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java deleted file mode 100644 index 48aa5b4a..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.swyp.picke.domain.battle.dto.request; - -import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; -import java.time.LocalDate; -import java.util.List; - -public record AdminBattleCreateRequest( - String title, - String titlePrefix, - String titleSuffix, - String summary, - String description, - String thumbnailUrl, - BattleType type, - BattleStatus status, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - LocalDate targetDate, - List tagIds, - List options -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java deleted file mode 100644 index 36c1c212..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.swyp.picke.domain.battle.dto.request; - -import com.swyp.picke.domain.battle.enums.BattleOptionLabel; - -import java.util.List; - -public record AdminBattleOptionRequest( - BattleOptionLabel label, - String title, - String stance, - String representative, - String quote, - String imageUrl, - Boolean isCorrect, - List tagIds // 옵션 전용 태그 (철학자, 가치관 - 추후 사용자 유형 분석에 사용) -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java deleted file mode 100644 index aa5e4477..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.swyp.picke.domain.battle.dto.request; - -import com.swyp.picke.domain.battle.enums.BattleStatus; -import java.time.LocalDate; -import java.util.List; - -public record AdminBattleUpdateRequest( - String title, - String titlePrefix, - String titleSuffix, - String summary, - String description, - String thumbnailUrl, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - LocalDate targetDate, - Integer audioDuration, - BattleStatus status, - List tagIds, - List options -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java deleted file mode 100644 index 43c64d66..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp.picke.domain.battle.dto.response; - -import java.time.LocalDateTime; - -/** - * 관리자 - 배틀 삭제 응답 - * 역할: 배틀이 성공적으로 소프트 딜리트 되었는지 확인하고 삭제 시점을 반환합니다. - */ - -public record AdminBattleDeleteResponse( - Boolean success, // 삭제 성공 여부 - LocalDateTime deletedAt // 삭제 처리된 일시 (Soft Delete) -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java deleted file mode 100644 index fd382332..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.swyp.picke.domain.battle.dto.response; - -import com.swyp.picke.domain.battle.enums.BattleCreatorType; -import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 관리자 - 배틀 상세 상세 조회 응답 - * 역할: 관리자가 배틀의 모든 설정 값(상태, 생성자 타입, 수정일 등)을 확인하고 수정할 때 사용합니다. - */ - -public record AdminBattleDetailResponse( - Long battleId, - String title, - String titlePrefix, - String titleSuffix, - String summary, - String description, - String thumbnailUrl, - BattleType type, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - LocalDate targetDate, - BattleStatus status, - BattleCreatorType creatorType, - List tags, - List options, - LocalDateTime createdAt, - LocalDateTime updatedAt -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java index 51ca1760..ce34930d 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleOptionResponse.java @@ -10,7 +10,6 @@ public record BattleOptionResponse( String title, String stance, String representative, - String quote, String imageUrl, List tags -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java index de611ff9..1208010c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleScenarioResponse.java @@ -10,7 +10,6 @@ public record PhilosopherProfileResponse( String label, String name, String stance, - String quote, String imageUrl ) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java index feef39fa..6ce79150 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSimpleResponse.java @@ -6,7 +6,6 @@ public record BattleSimpleResponse( Long battleId, String title, String thumbnailUrl, - String type, String status, LocalDateTime createdAt -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java index cd39f4d5..60cd7f24 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleSummaryResponse.java @@ -1,23 +1,15 @@ package com.swyp.picke.domain.battle.dto.response; -import com.swyp.picke.domain.battle.enums.BattleType; - import java.util.List; -/** - * 유저 - 배틀 요약 정보 응답 - * 역할: 홈 화면의 각 섹션 카드나 리스트에서 '미리보기' 형태로 보여줄 데이터입니다. - */ - public record BattleSummaryResponse( - Long battleId, // 배틀 고유 ID - String title, // 배틀 제목 - String summary, // 배틀 요약 (누군가는 이것을...) - String thumbnailUrl, // 카드 배경 이미지 URL - BattleType type, // 배틀 타입 태그 (#BATTLE, #VOTE 등) - Integer viewCount, // 조회수 - Long participantsCount, // 누적 참여자 수 - Integer audioDuration, // 오디오 소요 시간 - List tags, // 카테고리/인물 태그 리스트 - List options // 선택지 요약 (A vs B) -) {} \ No newline at end of file + Long battleId, + String title, + String summary, + String thumbnailUrl, + Integer viewCount, + Long participantsCount, + Integer audioDuration, + List tags, + List options +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java index b08b9455..9b50d068 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleUserDetailResponse.java @@ -5,23 +5,13 @@ import java.util.List; -/** - * 유저 - 배틀 상세 페이지 응답 (시안 4, 5번) - * 역할: 배틀 클릭 시 진입하는 상세 화면의 모든 정보를 담습니다. 투표 여부에 따라 UI가 변합니다. - */ public record BattleUserDetailResponse( - BattleSummaryResponse battleInfo, // 기본적인 배틀 정보 (요약 DTO 재사용) - String titlePrefix, - String titleSuffix, - String itemA, - String itemADesc, - String itemB, - String itemBDesc, - String description, // 상세 본문 설명 - String shareUrl, // 공유하기 버튼용 링크 - VoteSide userVoteStatus, // 현재 유저의 투표 상태 + BattleSummaryResponse battleInfo, + String description, + String shareUrl, + VoteSide userVoteStatus, UserBattleStep currentStep, - List categoryTags, // UI 상단용 카테고리 태그 - List philosopherTags, // UI 하단용 철학자 태그 - List valueTags // 성향 분석용 가치관 태그 -) {} \ No newline at end of file + List categoryTags, + List philosopherTags, + List valueTags +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java index 64720c5b..fe2cdac5 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleVoteResponse.java @@ -12,4 +12,4 @@ public record BattleVoteResponse( Long selectedOptionId, // 유저가 방금 선택한 옵션 ID Long totalParticipants, // 실시간 전체 참여자 수 List results // 옵션별 득표 현황 리스트 -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java index 26e9567f..235a7f26 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleListResponse.java @@ -10,4 +10,4 @@ public record TodayBattleListResponse( List items, // 오늘의 배틀 리스트 Integer totalCount // 목록 총 개수 -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java index 8b14041d..097a0061 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayBattleResponse.java @@ -1,29 +1,15 @@ package com.swyp.picke.domain.battle.dto.response; -import com.swyp.picke.domain.battle.enums.BattleType; - import java.util.List; -/** - * 유저 - 오늘의 배틀 상세 응답 (시안 6번) - * 역할: 어두운 배경의 풀스크린 UI에 필요한 배경 이미지, 시간 등을 담습니다. - */ public record TodayBattleResponse( - Long battleId, // 배틀 고유 ID - String title, // 배틀 제목 - String summary, // 중간 요약 문구 - String thumbnailUrl, // 풀스크린 배경 이미지 URL - BattleType type, // 타입 태그 - Integer viewCount, // 조회수 - Long participantsCount, // 누적 참여자 수 - Integer audioDuration, // 소요 시간 (분:초 변환용 데이터) - List tags, // 상단 태그 리스트 - List options, // 중앙 세로형 대결 카드 데이터 - // 퀴즈·투표 전용 필드 - String titlePrefix, // 투표 접두사 (예: "도덕의 기준은") - String titleSuffix, // 투표 접미사 (예: "이다") - String itemA, // 퀴즈 O 선택지 - String itemADesc, // 퀴즈 O 설명 - String itemB, // 퀴즈 X 선택지 - String itemBDesc // 퀴즈 X 설명 + Long battleId, + String title, + String summary, + String thumbnailUrl, + Integer viewCount, + Long participantsCount, + Integer audioDuration, + List tags, + List options ) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java index 2fd15871..2da90246 100644 --- a/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/TodayOptionResponse.java @@ -2,17 +2,11 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -/** - * 유저 - 오늘의 배틀 전용 옵션 응답 - * 역할: 오늘의 배틀 시안의 세로형 카드에 들어가는 인물, 입장, 아바타 정보를 담습니다. - */ - public record TodayOptionResponse( - Long optionId, // 옵션 ID - BattleOptionLabel label,// 라벨 (A, B) - String title, // 제목 (예: 찬성한다) - String representative, // 인물 (예: 피터 싱어) - String stance, // 한 줄 입장 (예: 고통을 끝낼 권리는..) - String imageUrl, // 아바타 이미지 URL - Boolean isCorrect // 퀴즈 정답 여부 + Long optionId, + BattleOptionLabel label, + String title, + String representative, + String stance, + String imageUrl ) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java index 7a3ac8d5..e9905040 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -2,18 +2,27 @@ import com.swyp.picke.domain.battle.enums.BattleCreatorType; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDate; -import java.time.LocalDateTime; - @Getter @Entity @Table(name = "battles") @@ -31,28 +40,6 @@ public class Battle extends BaseEntity { @Column(name = "thumbnail_url", length = 500) private String thumbnailUrl; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private BattleType type; - - @Column(name = "title_prefix") - private String titlePrefix; - - @Column(name = "title_suffix") - private String titleSuffix; - - @Column(name = "item_a") - private String itemA; - - @Column(name = "item_a_desc") - private String itemADesc; - - @Column(name = "item_b") - private String itemB; - - @Column(name = "item_b_desc") - private String itemBDesc; - @Column(name = "view_count") private Integer viewCount = 0; @@ -77,7 +64,8 @@ public class Battle extends BaseEntity { @JoinColumn(name = "creator_id") private User creator; - // 홈 화면 5단 기획을 위한 필드들 + @OneToMany(mappedBy = "battle", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); @Column(name = "is_editor_pick") private Boolean isEditorPick = false; @@ -89,22 +77,21 @@ public class Battle extends BaseEntity { private LocalDateTime deletedAt; @Builder - public Battle(String title, String summary, String description, String thumbnailUrl, - BattleType type, String titlePrefix, String titleSuffix, - String itemA, String itemADesc, String itemB, String itemBDesc, - LocalDate targetDate, Integer audioDuration, BattleStatus status, - BattleCreatorType creatorType, User creator) { + public Battle( + String title, + String summary, + String description, + String thumbnailUrl, + LocalDate targetDate, + Integer audioDuration, + BattleStatus status, + BattleCreatorType creatorType, + User creator + ) { this.title = title; this.summary = summary; this.description = description; this.thumbnailUrl = thumbnailUrl; - this.type = type; - this.titlePrefix = titlePrefix; - this.titleSuffix = titleSuffix; - this.itemA = itemA; - this.itemADesc = itemADesc; - this.itemB = itemB; - this.itemBDesc = itemBDesc; this.targetDate = targetDate; this.audioDuration = audioDuration; this.status = status; @@ -117,26 +104,34 @@ public Battle(String title, String summary, String description, String thumbnail this.deletedAt = null; } - public void update(String title, String titlePrefix, String titleSuffix, - String itemA, String itemADesc, String itemB, String itemBDesc, - String summary, String description, - String thumbnailUrl, LocalDate targetDate, - Integer audioDuration, BattleStatus status) { - if (title != null) this.title = title; - if (titlePrefix != null) this.titlePrefix = titlePrefix; - if (titleSuffix != null) this.titleSuffix = titleSuffix; - - if (itemA != null) this.itemA = itemA; - if (itemADesc != null) this.itemADesc = itemADesc; - if (itemB != null) this.itemB = itemB; - if (itemBDesc != null) this.itemBDesc = itemBDesc; - - if (summary != null) this.summary = summary; - if (description != null) this.description = description; - if (thumbnailUrl != null) this.thumbnailUrl = thumbnailUrl; - if (targetDate != null) this.targetDate = targetDate; - if (audioDuration != null) this.audioDuration = audioDuration; - if (status != null) this.status = status; + public void update( + String title, + String summary, + String description, + String thumbnailUrl, + BattleStatus status + ) { + if (title != null) { + this.title = title; + } + if (summary != null) { + this.summary = summary; + } + if (description != null) { + this.description = description; + } + if (thumbnailUrl != null) { + this.thumbnailUrl = thumbnailUrl; + } + if (targetDate != null) { + this.targetDate = targetDate; + } + if (audioDuration != null) { + this.audioDuration = audioDuration; + } + if (status != null) { + this.status = status; + } } public void delete() { @@ -155,4 +150,9 @@ public void addParticipant() { public void updateAudioDuration(Integer audioDuration) { this.audioDuration = audioDuration; } + + public void updateTargetDate(LocalDate targetDate) { + this.targetDate = targetDate; + } + } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java index 8be17ceb..ab5ee23a 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleOption.java @@ -2,7 +2,18 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -31,29 +42,35 @@ public class BattleOption extends BaseEntity { @Column(length = 100) private String representative; - @Column(columnDefinition = "TEXT") - private String quote; - @Column(name = "vote_count") private Long voteCount = 0L; - @Column(name = "is_correct") - private Boolean isCorrect = false; - @Column(name = "image_url", length = 500) private String imageUrl; + @Column(name = "display_order") + private Integer displayOrder; + + @OneToMany(mappedBy = "battleOption", cascade = CascadeType.ALL, orphanRemoval = true) + private final List tags = new ArrayList<>(); + @Builder - public BattleOption(Battle battle, BattleOptionLabel label, String title, String stance, - String representative, String quote, String imageUrl, Boolean isCorrect) { + public BattleOption( + Battle battle, + BattleOptionLabel label, + String title, + String stance, + String representative, + String imageUrl, + Integer displayOrder + ) { this.battle = battle; this.label = label; this.title = title; this.stance = stance; this.representative = representative; - this.quote = quote; this.imageUrl = imageUrl; - this.isCorrect = (isCorrect != null) && isCorrect; + this.displayOrder = displayOrder; this.voteCount = 0L; } @@ -67,12 +84,21 @@ public void decreaseVoteCount() { } } - public void update(String title, String stance, String representative, String quote, String imageUrl, Boolean isCorrect) { - if (title != null) this.title = title; - if (stance != null) this.stance = stance; - if (representative != null) this.representative = representative; - if (quote != null) this.quote = quote; - if (imageUrl != null) this.imageUrl = imageUrl; - if (isCorrect != null) this.isCorrect = isCorrect; + public void update(String title, String stance, String representative, String imageUrl) { + if (title != null) { + this.title = title; + } + if (stance != null) { + this.stance = stance; + } + if (representative != null) { + this.representative = representative; + } + if (imageUrl != null) { + this.imageUrl = imageUrl; + } + if (displayOrder != null) { + this.displayOrder = displayOrder; + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java deleted file mode 100644 index 648e1eff..00000000 --- a/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.swyp.picke.domain.battle.enums; - -public enum BattleType { - BATTLE, QUIZ, VOTE -} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java index d30f2a8e..2260ed8e 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionRepository.java @@ -4,13 +4,23 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.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; public interface BattleOptionRepository extends JpaRepository { - List findByBattle(Battle battle); + @Query("SELECT bo FROM BattleOption bo " + + "WHERE bo.battle = :battle " + + "ORDER BY COALESCE(bo.displayOrder, 9999), bo.label, bo.id") + List findByBattle(@Param("battle") Battle battle); + Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); - List findByBattleIn(List battles); + + @Query("SELECT bo FROM BattleOption bo " + + "WHERE bo.battle IN :battles " + + "ORDER BY bo.battle.id, COALESCE(bo.displayOrder, 9999), bo.label, bo.id") + List findByBattleIn(@Param("battles") List battles); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java index fb2ffce2..23f0d3dd 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleOptionTagRepository.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.entity.BattleOptionTag; +import com.swyp.picke.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; @@ -11,6 +12,7 @@ public interface BattleOptionTagRepository extends JpaRepository { List findByBattleOption(BattleOption battleOption); + boolean existsByTag(Tag tag); @Query("SELECT bot FROM BattleOptionTag bot JOIN FETCH bot.tag WHERE bot.battleOption.battle = :battle") List findByBattleWithTags(@Param("battle") Battle battle); diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index c4aa3d8d..d4d5dd31 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -2,100 +2,103 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; 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 org.springframework.data.domain.Page; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; public interface BattleRepository extends JpaRepository { - // 1. EDITOR PICK - type 파라미터 추가 + // 1. EDITOR PICK @Query("SELECT battle FROM Battle battle " + "WHERE battle.isEditorPick = true AND battle.status = :status " + - "AND battle.type = :type AND battle.deletedAt IS NULL " + + "AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findEditorPicks(@Param("status") BattleStatus status, @Param("type") BattleType type, Pageable pageable); + List findEditorPicks(@Param("status") BattleStatus status, Pageable pageable); - // 2. 지금 뜨는 배틀 - type 파라미터 추가 - @Query("SELECT battle FROM Battle battle JOIN Vote vote ON vote.battle = battle " + - "WHERE vote.createdAt >= :yesterday AND battle.type = :type " + + // 2. 지금 뜨는 배틀 + @Query("SELECT battle FROM Battle battle JOIN BattleVote vote ON vote.battle = battle " + + "WHERE vote.createdAt >= :yesterday " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "GROUP BY battle ORDER BY COUNT(vote) DESC") - List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, @Param("type") BattleType type, Pageable pageable); + List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, Pageable pageable); - // 3. Best 배틀 - type 파라미터 추가 + // 3. Best 배틀 @Query("SELECT battle FROM Battle battle " + - "WHERE battle.status = 'PUBLISHED' AND battle.type = :type AND battle.deletedAt IS NULL " + + "WHERE battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") - List findBestBattles(@Param("type") BattleType type, Pageable pageable); + List findBestBattles(Pageable pageable); // 4. 오늘의 Pické @Query("SELECT battle FROM Battle battle " + - "WHERE battle.type = :type AND battle.targetDate = :today " + + "WHERE battle.targetDate = :today " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL") - List findTodayPicks(@Param("type") BattleType type, @Param("today") LocalDate today, Pageable pageable); + List findTodayPicks(@Param("today") LocalDate today, Pageable pageable); - // 5. 새로운 배틀 - type 파라미터 추가 + // 5. 새로운 배틀 @Query("SELECT battle FROM Battle battle " + - "WHERE battle.id NOT IN :excludeIds AND battle.type = :type " + + "WHERE battle.status = 'PUBLISHED' " + + "AND battle.deletedAt IS NULL " + + "AND (battle.targetDate IS NULL OR battle.targetDate < :today) " + + "ORDER BY CASE WHEN battle.targetDate IS NULL THEN 0 ELSE 1 END, battle.targetDate ASC, battle.createdAt ASC") + List findAutoAssignableTodayPicks(@Param("today") LocalDate today, Pageable pageable); + + @Query("SELECT battle FROM Battle battle " + + "WHERE battle.id NOT IN :excludeIds " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, @Param("type") BattleType type, Pageable pageable); + List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, Pageable pageable); - // 6. 전체 배틀 목록 조회 (페이징, 삭제된 항목 제외, 최신순) + // 6. 전체 배틀 목록 조회 Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); - Page findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc(BattleType type, Pageable pageable); + Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); - List findByTargetDateAndStatusAndTypeAndDeletedAtIsNull(LocalDate targetDate, BattleStatus status, BattleType type); - - // 탐색 탭: 전체 배틀 검색 (정렬은 Pageable Sort로 처리) - @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + // 탐색 탭: 전체 배틀 검색 + @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchAll(Pageable pageable); - @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") long countSearchAll(); // 탐색 탭: 카테고리 태그 필터 배틀 검색 @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") long countSearchByCategory(@Param("categoryName") String categoryName); // 추천 폴백용: 전체 배틀 대상 인기 점수순 조회 (철학자 유형 로직 미구현 시 사용) // Score = V*1.0 + C*1.5 + Vw*0.2 @Query("SELECT b FROM Battle b " + "WHERE b.id NOT IN :excludeBattleIds " + - "AND b.type = 'BATTLE' " + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") List findPopularBattlesExcluding( @Param("excludeBattleIds") List excludeBattleIds, - Pageable pageable); + Pageable pageable + ); // 추천용: 특정 유저들이 참여한 배틀 중 이미 참여한 배틀 제외하고 인기 점수순 조회 // Score = V*1.0 + C*1.5 + Vw*0.2 (R은 추후 반영 예정) @Query("SELECT b FROM Battle b " + "WHERE b.id IN :candidateBattleIds " + "AND b.id NOT IN :excludeBattleIds " + - "AND b.type = 'BATTLE' " + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL " + "ORDER BY (b.totalParticipantsCount * 1.0 + b.commentCount * 1.5 + b.viewCount * 0.2) DESC") List findRecommendedBattles( @Param("candidateBattleIds") List candidateBattleIds, @Param("excludeBattleIds") List excludeBattleIds, - Pageable pageable); -} \ No newline at end of file + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index baf96eb4..a5d1df46 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,67 +1,56 @@ package com.swyp.picke.domain.battle.service; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; -import com.swyp.picke.domain.battle.dto.response.*; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.dto.response.BattleListResponse; +import com.swyp.picke.domain.battle.dto.response.BattleScenarioResponse; +import com.swyp.picke.domain.battle.dto.response.BattleUserDetailResponse; +import com.swyp.picke.domain.battle.dto.response.BattleVoteResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleListResponse; +import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; - import java.util.List; public interface BattleService { - // === [내부 공통/조회 메서드] === Battle findById(Long battleId); - BattleOption findOptionById(Long optionId); - BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label); - - // === [사용자용 - 홈 화면 5단 로직 지원 API] === - // 1. 에디터 픽 조회 (isEditorPick = true) - List getEditorPicks(int limit); + BattleOption findOptionById(Long optionId); - // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) - List getTrendingBattles(int limit); + BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabel label); - // 3. Best 배틀 조회 (누적 지표 랭킹) - List getBestBattles(int limit); + List getEditorPicks(); - // 4. 오늘의 Pické 조회 (단일 타입 매칭) - List getTodayPicks(BattleType type, int limit); + List getTrendingBattles(); - // 5. 새로운 배틀 조회 (중복 제외 리스트) - List getNewBattles(List excludeIds, int limit); + List getBestBattles(); + List getTodayPicks(); - // === [사용자용 - 기본 API] === + List getNewBattles(List excludeIds); - // 전체 배틀 목록 페이징 조회 - BattleListResponse getBattles(int page, int size, String type); + BattleListResponse getBattles(int page, int size, String status); - // 오늘의 배틀 (기존 로직 유지용) TodayBattleListResponse getTodayBattles(); - // 배틀 상세 정보 BattleUserDetailResponse getBattleDetail(Long battleId); - // 투표 실행 및 실시간 통계 결과 반환 - BattleVoteResponse vote(Long battleId, Long optionId); + BattleVoteResponse BattleVote(Long battleId, Long optionId); BattleScenarioResponse getBattleScenario(Long battleId); UserBattleStatusResponse getUserBattleStatus(Long battleId); - // === [관리자용 API] === - - // 배틀 생성 AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); - // 배틀 수정 + AdminBattleDetailResponse getAdminBattleDetail(Long battleId); + AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); - // 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다) AdminBattleDeleteResponse deleteBattle(Long battleId); } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 5956d719..e8b59d6c 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -1,8 +1,11 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.battle.converter.BattleConverter; -import com.swyp.picke.domain.battle.dto.request.AdminBattleCreateRequest; -import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; +import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.battle.dto.response.*; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; @@ -10,7 +13,6 @@ import com.swyp.picke.domain.battle.entity.BattleTag; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.enums.BattleType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.battle.repository.BattleOptionRepository; @@ -18,15 +20,18 @@ import com.swyp.picke.domain.battle.repository.BattleRepository; import com.swyp.picke.domain.battle.repository.BattleTagRepository; import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.tag.repository.TagRepository; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.VoteSide; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.service.UserBattleService; -import com.swyp.picke.domain.vote.entity.Vote; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.service.S3UploadService; import com.swyp.picke.global.util.SecurityUtil; import lombok.RequiredArgsConstructor; @@ -38,6 +43,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.net.URI; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.*; import java.util.stream.Collectors; @@ -46,15 +54,23 @@ @Transactional(readOnly = true) public class BattleServiceImpl implements BattleService { + private static final int HOME_EDITOR_PICK_LIMIT = 10; + private static final int HOME_TRENDING_LIMIT = 4; + private static final int HOME_BEST_LIMIT = 3; + private static final int HOME_TODAY_PICK_LIMIT = 1; + private static final int HOME_NEW_LIMIT = 3; + private static final Pattern RESOURCE_IMAGE_PATH_PATTERN = Pattern.compile("/api/v1/resources/images/([A-Z_]+)/(.+)"); + private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; private final BattleTagRepository battleTagRepository; private final BattleOptionTagRepository battleOptionTagRepository; private final TagRepository tagRepository; private final UserRepository userRepository; - private final VoteRepository voteRepository; + private final BattleVoteRepository battleVoteRepository; private final BattleConverter battleConverter; private final S3UploadService s3UploadService; + private final LocalDraftFileStorageService localDraftFileStorageService; private final UserBattleService userBattleService; @Override @@ -68,49 +84,81 @@ public Battle findById(Long battleId) { } @Override - public List getEditorPicks(int limit) { - List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, BattleType.BATTLE, PageRequest.of(0, limit)); - return convertToTodayResponses(battles); + public List getEditorPicks() { + return loadEditorPicks(HOME_EDITOR_PICK_LIMIT); + } + + @Override + public List getTrendingBattles() { + return loadTrendingBattles(HOME_TRENDING_LIMIT); + } + + @Override + public List getBestBattles() { + return loadBestBattles(HOME_BEST_LIMIT); + } + + @Override + @Transactional + public List getTodayPicks() { + return loadTodayPicks(HOME_TODAY_PICK_LIMIT); } @Override - public List getTrendingBattles(int limit) { + public List getNewBattles(List excludeIds) { + return loadNewBattles(excludeIds, HOME_NEW_LIMIT); + } + + private List loadEditorPicks(int limit) { + int safeLimit = Math.max(1, limit); + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, safeLimit)); + return convertToTodayResponses(battles); + } + + private List loadTrendingBattles(int limit) { + int safeLimit = Math.max(1, limit); LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - List battles = battleRepository.findTrendingBattles(yesterday, BattleType.BATTLE, PageRequest.of(0, limit)); + List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getBestBattles(int limit) { - List battles = battleRepository.findBestBattles(BattleType.BATTLE, PageRequest.of(0, limit)); + private List loadBestBattles(int limit) { + int safeLimit = Math.max(1, limit); + List battles = battleRepository.findBestBattles(PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getTodayPicks(BattleType type, int limit) { - List battles = battleRepository.findTodayPicks(type, LocalDate.now(), PageRequest.of(0, limit)); + private List loadTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + ensureTodayPicks(today, safeLimit); + + List battles = battleRepository.findTodayPicks(today, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } - @Override - public List getNewBattles(List excludeIds, int limit) { + private List loadNewBattles(List excludeIds, int limit) { + int safeLimit = Math.max(1, limit); List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) ? List.of(-1L) : excludeIds; - List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, BattleType.BATTLE, PageRequest.of(0, limit)); + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, safeLimit)); return convertToTodayResponses(battles); } @Override - public BattleListResponse getBattles(int page, int size, String type) { + public BattleListResponse getBattles(int page, int size, String status) { int pageNumber = Math.max(0, page - 1); PageRequest pageRequest = PageRequest.of(pageNumber, size); - Page battlePage; + BattleStatus battleStatusFilter = parseBattleStatus(status); - if (type == null || type.equals("ALL")) { + Page battlePage; + if (battleStatusFilter == null) { battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); } else { - battlePage = battleRepository.findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc( - BattleType.valueOf(type), pageRequest); + battlePage = battleRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc( + battleStatusFilter, + pageRequest + ); } List items = battlePage.getContent().stream() @@ -126,9 +174,11 @@ public BattleListResponse getBattles(int page, int size, String type) { } @Override + @Transactional public TodayBattleListResponse getTodayBattles() { - List battles = battleRepository.findByTargetDateAndStatusAndTypeAndDeletedAtIsNull( - LocalDate.now(), BattleStatus.PUBLISHED, BattleType.BATTLE); + LocalDate today = LocalDate.now(); + ensureTodayPicks(today, 5); + List battles = battleRepository.findByTargetDateAndStatusAndDeletedAtIsNull(today, BattleStatus.PUBLISHED); List limitedBattles = battles.stream() .limit(5) @@ -139,6 +189,17 @@ public TodayBattleListResponse getTodayBattles() { return new TodayBattleListResponse(items, items.size()); } + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = battleRepository.findTodayPicks(today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = battleRepository.findAutoAssignableTodayPicks(today, PageRequest.of(0, missingCount)); + for (Battle candidate : candidates) { + candidate.updateTargetDate(today); + } + } + @Override @Transactional(readOnly = true) public BattleUserDetailResponse getBattleDetail(Long battleId) { @@ -158,11 +219,11 @@ public BattleUserDetailResponse getBattleDetail(Long battleId) { UserBattleStatusResponse statusResponse = userBattleService.getUserBattleStatus(user, battle); UserBattleStep currentStep = statusResponse.step(); - Optional optionalVote = voteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); + Optional optionalVote = battleVoteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); VoteSide voteStatus = optionalVote - .map(vote -> { - if (vote.getPostVoteOption() != null) { - return vote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; + .map(BattleVote -> { + if (BattleVote.getPostVoteOption() != null) { + return BattleVote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; } return null; }) @@ -195,7 +256,7 @@ public UserBattleStatusResponse getUserBattleStatus(Long battleId) { @Override @Transactional - public BattleVoteResponse vote(Long battleId, Long optionId) { + public BattleVoteResponse BattleVote(Long battleId, Long optionId) { Battle battle = findById(battleId); BattleOption newOption = battleOptionRepository.findById(optionId) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); @@ -204,7 +265,7 @@ public BattleVoteResponse vote(Long battleId, Long optionId) { User user = userRepository.findById(currentUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - voteRepository.save(Vote.builder() + battleVoteRepository.save(BattleVote.builder() .user(user) .battle(battle) .preVoteOption(newOption) @@ -232,29 +293,46 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, User admin = userRepository.findById(adminUserId == null ? 1L : adminUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Battle battle = battleRepository.save(battleConverter.toEntity(request, admin)); + validateBattleOptionCount(request.options()); + + String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); + Battle battle = battleConverter.toEntity(request, admin); + battle.update( + request.title(), + request.summary(), + request.description(), + resolvedThumbnailKey, + request.status() + ); + battle = battleRepository.save(battle); if (request.tagIds() != null) { saveBattleTags(battle, request.tagIds().stream().distinct().toList()); } List savedOptions = new ArrayList<>(); - for (var optionRequest : request.options()) { - BattleOption option = battleOptionRepository.save(BattleOption.builder() - .battle(battle) - .label(optionRequest.label()) - .title(optionRequest.title()) - .stance(optionRequest.stance()) - .representative(optionRequest.representative()) - .quote(optionRequest.quote()) - .imageUrl(optionRequest.imageUrl()) - .isCorrect(optionRequest.isCorrect()) - .build()); - - if (optionRequest.tagIds() != null) { - saveBattleOptionTags(option, optionRequest.tagIds().stream().distinct().toList()); + if (request.options() != null) { + for (AdminBattleOptionRequest optionRequest : request.options()) { + String resolvedImageKey = resolveStoredImageKey( + optionRequest.imageUrl(), + request.status(), + FileCategory.PHILOSOPHER + ); + BattleOption option = BattleOption.builder() + .battle(battle) + .label(optionRequest.label()) + .title(optionRequest.title()) + .stance(optionRequest.stance()) + .representative(optionRequest.representative()) + .imageUrl(resolvedImageKey) + .build(); + option = battleOptionRepository.save(option); + + if (optionRequest.tagIds() != null) { + saveBattleOptionTags(option, optionRequest.tagIds().stream().distinct().toList()); + } + savedOptions.add(option); } - savedOptions.add(option); } Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) @@ -267,21 +345,41 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), savedOptions, optionTagsMap); } + @Override + @Transactional(readOnly = true) + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse getAdminBattleDetail(Long battleId) { + Battle battle = findById(battleId); + List options = battleOptionRepository.findByBattle(battle); + Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) + .stream() + .collect(Collectors.groupingBy( + bot -> bot.getBattleOption().getId(), + Collectors.mapping(BattleOptionTag::getTag, Collectors.toList()) + )); + + return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), options, optionTagsMap); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request) { Battle battle = findById(battleId); + validateBattleOptionCount(request.options()); - if (battle.getThumbnailUrl() != null && !battle.getThumbnailUrl().equals(request.thumbnailUrl())) { - s3UploadService.deleteFile(battle.getThumbnailUrl()); + String existingThumbnailKey = normalizeStoredImageReference(battle.getThumbnailUrl(), FileCategory.BATTLE); + String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); + if (existingThumbnailKey != null && !existingThumbnailKey.equals(resolvedThumbnailKey)) { + deleteStoredAsset(existingThumbnailKey); } battle.update( - request.title(), request.titlePrefix(), request.titleSuffix(), - request.itemA(), request.itemADesc(), request.itemB(), request.itemBDesc(), - request.summary(), request.description(), request.thumbnailUrl(), - request.targetDate(), request.audioDuration(), request.status() + request.title(), + request.summary(), + request.description(), + resolvedThumbnailKey, + request.status() ); if (request.tagIds() != null) { @@ -292,17 +390,56 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe if (request.options() != null) { List existingOptions = battleOptionRepository.findByBattle(battle); - for (var optionRequest : request.options()) { - existingOptions.stream() - .filter(option -> option.getLabel() == optionRequest.label()) - .findFirst() - .ifPresent(option -> { - if (option.getImageUrl() != null && !option.getImageUrl().equals(optionRequest.imageUrl())) { - s3UploadService.deleteFile(option.getImageUrl()); - } - option.update(optionRequest.title(), optionRequest.stance(), - optionRequest.representative(), optionRequest.quote(), optionRequest.imageUrl(), optionRequest.isCorrect()); - }); + Map existingOptionMap = existingOptions.stream() + .collect(Collectors.toMap(BattleOption::getLabel, option -> option)); + + Set requestedLabels = new HashSet<>(); + + for (AdminBattleOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + + BattleOption option = existingOptionMap.get(optionRequest.label()); + String resolvedOptionImageKey = resolveStoredImageKey( + optionRequest.imageUrl(), + request.status(), + FileCategory.PHILOSOPHER + ); + if (option == null) { + option = BattleOption.builder() + .battle(battle) + .label(optionRequest.label()) + .title(optionRequest.title()) + .stance(optionRequest.stance()) + .representative(optionRequest.representative()) + .imageUrl(resolvedOptionImageKey) + .build(); + option = battleOptionRepository.save(option); + } else { + String existingOptionImageKey = normalizeStoredImageReference(option.getImageUrl(), FileCategory.PHILOSOPHER); + if (existingOptionImageKey != null && !existingOptionImageKey.equals(resolvedOptionImageKey)) { + deleteStoredAsset(existingOptionImageKey); + } + option.update(optionRequest.title(), optionRequest.stance(), + optionRequest.representative(), resolvedOptionImageKey); + } + + replaceBattleOptionTags(option, optionRequest.tagIds()); + } + + List removedOptions = existingOptions.stream() + .filter(existing -> !requestedLabels.contains(existing.getLabel())) + .toList(); + + for (BattleOption removedOption : removedOptions) { + deleteStoredAsset(removedOption.getImageUrl()); + List optionTags = battleOptionTagRepository.findByBattleOption(removedOption); + if (!optionTags.isEmpty()) { + battleOptionTagRepository.deleteAll(optionTags); + } + } + + if (!removedOptions.isEmpty()) { + battleOptionRepository.deleteAll(removedOptions); } } @@ -355,6 +492,7 @@ private List getTagsByBattle(Battle battle) { private void saveBattleTags(Battle battle, List ids) { tagRepository.findAllById(ids).stream() .filter(tag -> tag.getDeletedAt() == null) + .filter(tag -> tag.getType() == TagType.CATEGORY) .forEach(tag -> battleTagRepository.save( BattleTag.builder().battle(battle).tag(tag).build())); } @@ -362,10 +500,22 @@ private void saveBattleTags(Battle battle, List ids) { private void saveBattleOptionTags(BattleOption option, List tagIds) { tagRepository.findAllById(tagIds).stream() .filter(tag -> tag.getDeletedAt() == null) + .filter(tag -> tag.getType() == TagType.PHILOSOPHER || tag.getType() == TagType.VALUE) .forEach(tag -> battleOptionTagRepository.save( BattleOptionTag.builder().battleOption(option).tag(tag).build())); } + private void replaceBattleOptionTags(BattleOption option, List tagIds) { + if (tagIds == null) return; + + List existingTags = battleOptionTagRepository.findByBattleOption(option); + if (!existingTags.isEmpty()) { + battleOptionTagRepository.deleteAll(existingTags); + } + + saveBattleOptionTags(option, tagIds.stream().distinct().toList()); + } + @Override public BattleOption findOptionById(Long optionId) { return battleOptionRepository.findById(optionId) @@ -378,4 +528,95 @@ public BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabe return battleOptionRepository.findByBattleAndLabel(battle, label) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); } -} \ No newline at end of file + + private String resolveStoredImageKey(String rawReference, BattleStatus targetStatus, FileCategory fallbackCategory) { + String normalized = normalizeStoredImageReference(rawReference, fallbackCategory); + if (normalized == null) { + return null; + } + if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) { + return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService); + } + return normalized; + } + + private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) { + if (rawReference == null || rawReference.isBlank()) { + return null; + } + + String trimmed = rawReference.trim(); + String localNormalized = localDraftFileStorageService.normalizeLocalDraftKey(trimmed); + if (localDraftFileStorageService.isLocalDraftReference(localNormalized)) { + return localNormalized; + } + + String path = extractPath(trimmed); + Matcher matcher = RESOURCE_IMAGE_PATH_PATTERN.matcher(path); + if (matcher.find()) { + String categoryName = matcher.group(1); + String fileName = matcher.group(2); + try { + FileCategory category = FileCategory.valueOf(categoryName); + return category.getPath() + "/" + fileName; + } catch (IllegalArgumentException ignored) { + if (fallbackCategory != null) { + return fallbackCategory.getPath() + "/" + fileName; + } + } + } + + return trimmed; + } + + private String extractPath(String value) { + if (value.startsWith("http://") || value.startsWith("https://")) { + try { + URI uri = URI.create(value); + return uri.getPath(); + } catch (IllegalArgumentException ignored) { + return value; + } + } + return value; + } + + private void deleteStoredAsset(String rawReference) { + String normalized = normalizeStoredImageReference(rawReference, null); + if (normalized == null) { + return; + } + + if (localDraftFileStorageService.isLocalDraftReference(normalized)) { + localDraftFileStorageService.deleteIfLocalReference(normalized); + return; + } + + s3UploadService.deleteFile(normalized); + } + + private BattleStatus parseBattleStatus(String status) { + if (status == null || status.isBlank() || "ALL".equalsIgnoreCase(status)) { + return null; + } + + try { + return BattleStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + private void validateBattleOptionCount(List options) { + if (options == null) { + throw new CustomException(ErrorCode.BATTLE_INVALID_OPTION_COUNT); + } + int count = options.size(); + if (count < 2 || count > 4) { + throw new CustomException(ErrorCode.BATTLE_INVALID_OPTION_COUNT); + } + } +} + + + diff --git a/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java b/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java new file mode 100644 index 00000000..97344834 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.poll.controller; + +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.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; + +@Tag(name = "투표 콘텐츠 API", description = "투표 콘텐츠 조회") +@RestController +@RequestMapping("/api/v1/polls") +@RequiredArgsConstructor +public class PollController { + + private final PollService pollService; + + @Operation(summary = "투표 콘텐츠 목록 조회") + @GetMapping + public ApiResponse getPolls( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size + ) { + return ApiResponse.onSuccess(pollService.getPolls(page, size)); + } + + @Operation(summary = "투표 콘텐츠 상세 조회") + @GetMapping("/{pollId}") + public ApiResponse getPollDetail(@PathVariable Long pollId) { + return ApiResponse.onSuccess(pollService.getPollDetail(pollId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java b/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java new file mode 100644 index 00000000..03d74fec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.poll.converter; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.dto.response.PollOptionResponse; +import com.swyp.picke.domain.poll.dto.response.PollSimpleResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import java.util.Comparator; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class PollConverter { + + private static final Comparator OPTION_SORTER = + Comparator.comparing((PollOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(PollOption::getId); + + public Poll toEntity(AdminPollCreateRequest request) { + return Poll.builder() + .titlePrefix(request.titlePrefix()) + .titleSuffix(request.titleSuffix()) + .status(request.status()) + .build(); + } + + public PollListResponse toListResponse(Page pollPage) { + List items = pollPage.getContent().stream() + .map(this::toSimpleResponse) + .toList(); + return new PollListResponse(items, pollPage.getNumber() + 1, pollPage.getTotalPages(), pollPage.getTotalElements()); + } + + public PollSimpleResponse toSimpleResponse(Poll poll) { + return new PollSimpleResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getStatus() + ); + } + + public AdminPollDetailResponse toAdminDetailResponse(Poll poll, List options) { + return new AdminPollDetailResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getTargetDate(), + poll.getStatus(), + toOptionResponses(options) + ); + } + + public PollDetailResponse toDetailResponse(Poll poll, List options) { + return new PollDetailResponse( + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + poll.getTargetDate(), + poll.getStatus(), + toOptionResponses(options) + ); + } + + private List toOptionResponses(List options) { + if (options == null) { + return List.of(); + } + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new PollOptionResponse( + option.getId(), + option.getLabel(), + option.getTitle(), + option.getDisplayOrder(), + option.getVoteCount() + )) + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java new file mode 100644 index 00000000..04f4db50 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollStatus; +import java.time.LocalDate; +import java.util.List; + +public record PollDetailResponse( + Long pollId, + String titlePrefix, + String titleSuffix, + LocalDate targetDate, + PollStatus status, + List options +) {} diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java new file mode 100644 index 00000000..76f89133 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.poll.dto.response; + +import java.util.List; + +public record PollListResponse( + List items, + int page, + int totalPages, + long totalElements +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java new file mode 100644 index 00000000..b619a55f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollOptionLabel; + +public record PollOptionResponse( + Long optionId, + PollOptionLabel label, + String title, + Integer displayOrder, + Long voteCount +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java new file mode 100644 index 00000000..de4a34e2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.poll.enums.PollStatus; + +import java.time.LocalDateTime; + +public record PollSimpleResponse( + Long pollId, + String titlePrefix, + String titleSuffix, + PollStatus status +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java new file mode 100644 index 00000000..4c334ae8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.poll.dto.response; + +import com.swyp.picke.domain.tag.enums.TagType; + +public record PollTagResponse( + Long tagId, + String name, + TagType type +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java b/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java new file mode 100644 index 00000000..447a9081 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java @@ -0,0 +1,70 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Table(name = "poll_contents") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Poll extends BaseEntity { + + @Column(name = "title_prefix", nullable = false, length = 200) + private String titlePrefix; + + @Column(name = "title_suffix", length = 200) + private String titleSuffix; + + @Column(name = "target_date") + private LocalDate targetDate; + + @Column(name = "total_participants_count", nullable = false) + private Long totalParticipantsCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PollStatus status; + + @OneToMany(mappedBy = "poll", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); + + @Builder + public Poll(String titlePrefix, String titleSuffix, LocalDate targetDate, PollStatus status) { + this.titlePrefix = titlePrefix; + this.titleSuffix = titleSuffix; + this.targetDate = targetDate; + this.status = status; + this.totalParticipantsCount = 0L; + } + + public void update(String titlePrefix, String titleSuffix, LocalDate targetDate, PollStatus status) { + if (titlePrefix != null) this.titlePrefix = titlePrefix; + if (titleSuffix != null) this.titleSuffix = titleSuffix; + if (targetDate != null) this.targetDate = targetDate; + if (status != null) this.status = status; + } + + public void increaseTotalParticipantsCount() { + this.totalParticipantsCount = (this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount) + 1L; + } + + public void decreaseTotalParticipantsCount() { + long current = this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount; + this.totalParticipantsCount = Math.max(0L, current - 1L); + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java new file mode 100644 index 00000000..c0f86e9b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java @@ -0,0 +1,65 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_options") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PollOptionLabel label; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(name = "vote_count", nullable = false) + private Long voteCount; + + @Builder + public PollOption(Poll poll, PollOptionLabel label, String title, Integer displayOrder, Long voteCount) { + this.poll = poll; + this.label = label; + this.title = title; + this.displayOrder = displayOrder; + this.voteCount = voteCount == null ? 0L : voteCount; + } + + + public void update(String title) { + if (title != null) this.title = title; + if (displayOrder != null) this.displayOrder = displayOrder; + } + + public void increaseVoteCount() { + this.voteCount = (this.voteCount == null ? 0L : this.voteCount) + 1; + } + + public void decreaseVoteCount() { + if (this.voteCount != null && this.voteCount > 0) { + this.voteCount--; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java new file mode 100644 index 00000000..e148a80c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.tag.entity.ValueTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_option_value_tags") +@IdClass(PollOptionValueTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollOptionValueTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_option_id", nullable = false) + private PollOption pollOption; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "value_tag_id", nullable = false) + private ValueTag valueTag; + + @Builder + public PollOptionValueTagMap(PollOption pollOption, ValueTag valueTag) { + this.pollOption = pollOption; + this.valueTag = valueTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java new file mode 100644 index 00000000..627f6ab4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class PollOptionValueTagMapId implements Serializable { + private Long pollOption; + private Long valueTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java new file mode 100644 index 00000000..1220879c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.tag.entity.CategoryTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_tags") +@IdClass(PollTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_tag_id", nullable = false) + private CategoryTag categoryTag; + + @Builder + public PollTagMap(Poll poll, CategoryTag categoryTag) { + this.poll = poll; + this.categoryTag = categoryTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java new file mode 100644 index 00000000..29263b1f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class PollTagMapId implements Serializable { + private Long poll; + private Long categoryTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java b/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java new file mode 100644 index 00000000..15502d92 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.poll.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollUserVote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private PollOption selectedOption; + + @Builder + public PollUserVote(User user, Poll poll, PollOption selectedOption) { + this.user = user; + this.poll = poll; + this.selectedOption = selectedOption; + } + + public void updateOption(PollOption option) { + this.selectedOption = option; + } +} diff --git a/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java b/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java new file mode 100644 index 00000000..5dc3dc74 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.poll.enums; + +public enum PollOptionLabel { + A, B, C, D +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java b/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java new file mode 100644 index 00000000..49757284 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.poll.enums; + +public enum PollStatus { + PENDING, + PUBLISHED, + ARCHIVED +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java new file mode 100644 index 00000000..47e2e727 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PollOptionRepository extends JpaRepository { + List findByPollOrderByDisplayOrderAscLabelAscIdAsc(Poll poll); + Optional findByPollAndLabel(Poll poll, PollOptionLabel label); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java new file mode 100644 index 00000000..1d9fcf9c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.entity.PollOptionValueTagMap; +import com.swyp.picke.domain.poll.entity.PollOptionValueTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PollOptionValueTagMapRepository extends JpaRepository { + List findByPollOption(PollOption pollOption); + void deleteByPollOption(PollOption pollOption); +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java new file mode 100644 index 00000000..535eec6c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.enums.PollStatus; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Page; +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; + +public interface PollRepository extends JpaRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + @Query("SELECT p FROM Poll p WHERE p.status = :status AND p.targetDate = :targetDate ORDER BY p.createdAt ASC") + List findTodayPicks( + @Param("status") PollStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); + + @Query(""" + SELECT p + FROM Poll p + WHERE p.status = :status + AND (p.targetDate IS NULL OR p.targetDate <> :targetDate) + ORDER BY CASE WHEN p.targetDate IS NULL THEN 0 ELSE 1 END, + p.targetDate ASC, + p.createdAt ASC + """) + List findAutoAssignableTodayPicks( + @Param("status") PollStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java new file mode 100644 index 00000000..77e8e9da --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollTagMap; +import com.swyp.picke.domain.poll.entity.PollTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PollTagMapRepository extends JpaRepository { + List findByPoll(Poll poll); + void deleteByPoll(Poll poll); +} + diff --git a/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java b/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java new file mode 100644 index 00000000..dd2039d9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.poll.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.entity.PollUserVote; +import com.swyp.picke.domain.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PollUserVoteRepository extends JpaRepository { + Optional findByPollAndUser(Poll poll, User user); + long countByPoll(Poll poll); + long countByPollAndSelectedOption(Poll poll, PollOption selectedOption); + List findAllByPoll(Poll poll); +} diff --git a/src/main/java/com/swyp/picke/domain/poll/service/PollService.java b/src/main/java/com/swyp/picke/domain/poll/service/PollService.java new file mode 100644 index 00000000..9b64a0f3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/service/PollService.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.poll.service; + +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import java.util.List; + +public interface PollService { + Poll findById(Long pollId); + + PollListResponse getPolls(int page, int size); + + List getTodayPicks(int limit); + + List getOptions(Poll poll); + + long countVotes(Poll poll); + + PollDetailResponse getPollDetail(Long pollId); + + AdminPollDetailResponse getAdminPollDetail(Long pollId); + + AdminPollDetailResponse createPoll(AdminPollCreateRequest request); + + AdminPollDetailResponse updatePoll(Long pollId, AdminPollUpdateRequest request); + + AdminPollDeleteResponse deletePoll(Long pollId); +} + + diff --git a/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java b/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java new file mode 100644 index 00000000..4d3f9958 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java @@ -0,0 +1,186 @@ +package com.swyp.picke.domain.poll.service; + +import com.swyp.picke.domain.poll.converter.PollConverter; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollCreateRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollOptionRequest; +import com.swyp.picke.domain.admin.dto.poll.request.AdminPollUpdateRequest; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDeleteResponse; +import com.swyp.picke.domain.admin.dto.poll.response.AdminPollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollDetailResponse; +import com.swyp.picke.domain.poll.dto.response.PollListResponse; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.enums.PollOptionLabel; +import com.swyp.picke.domain.poll.enums.PollStatus; +import com.swyp.picke.domain.poll.repository.PollOptionRepository; +import com.swyp.picke.domain.poll.repository.PollRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.prepost.PreAuthorize; +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.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PollServiceImpl implements PollService { + + private final PollRepository pollRepository; + private final PollOptionRepository pollOptionRepository; + private final PollConverter pollConverter; + + @Override + public Poll findById(Long pollId) { + return pollRepository.findById(pollId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + } + + @Override + public PollListResponse getPolls(int page, int size) { + int pageNumber = Math.max(0, page - 1); + Page pollPage = pollRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(pageNumber, size)); + return pollConverter.toListResponse(pollPage); + } + + @Override + @Transactional + public List getTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + + ensureTodayPicks(today, safeLimit); + return pollRepository.findTodayPicks(PollStatus.PUBLISHED, today, PageRequest.of(0, safeLimit)); + } + + @Override + public List getOptions(Poll poll) { + return pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + } + + @Override + public long countVotes(Poll poll) { + return poll.getTotalParticipantsCount() == null ? 0L : poll.getTotalParticipantsCount(); + } + + @Override + public PollDetailResponse getPollDetail(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + return pollConverter.toDetailResponse(poll, options); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse getAdminPollDetail(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + return pollConverter.toAdminDetailResponse(poll, options); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse createPoll(AdminPollCreateRequest request) { + Poll poll = pollConverter.toEntity(request); + poll = pollRepository.save(poll); + + List savedOptions = new ArrayList<>(); + if (request.options() != null) { + for (AdminPollOptionRequest optionRequest : request.options()) { + PollOption option = PollOption.builder() + .poll(poll) + .label(optionRequest.label()) + .title(optionRequest.title()) + .build(); + option = pollOptionRepository.save(option); + savedOptions.add(option); + } + } + + return pollConverter.toAdminDetailResponse(poll, savedOptions); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDetailResponse updatePoll(Long pollId, AdminPollUpdateRequest request) { + Poll poll = findById(pollId); + poll.update( + request.titlePrefix(), + request.titleSuffix(), + request.targetDate(), + request.status() + ); + + if (request.options() != null) { + List existingOptions = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + Map existingOptionMap = new HashMap<>(); + for (PollOption option : existingOptions) { + existingOptionMap.put(option.getLabel(), option); + } + + Set requestedLabels = new HashSet<>(); + for (AdminPollOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + PollOption option = existingOptionMap.get(optionRequest.label()); + + if (option == null) { + option = PollOption.builder() + .poll(poll) + .label(optionRequest.label()) + .title(optionRequest.title()) + .build(); + option = pollOptionRepository.save(option); + } else { + option.update(optionRequest.title()); + } + } + + for (PollOption existingOption : existingOptions) { + if (requestedLabels.contains(existingOption.getLabel())) continue; + pollOptionRepository.delete(existingOption); + } + } + + return getAdminPollDetail(pollId); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminPollDeleteResponse deletePoll(Long pollId) { + Poll poll = findById(pollId); + List options = pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll); + pollOptionRepository.deleteAll(options); + pollRepository.delete(poll); + return new AdminPollDeleteResponse(true, LocalDateTime.now()); + } + + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = pollRepository.findTodayPicks(PollStatus.PUBLISHED, today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = pollRepository.findAutoAssignableTodayPicks( + PollStatus.PUBLISHED, + today, + PageRequest.of(0, missingCount) + ); + for (Poll candidate : candidates) { + candidate.update(null, null, today, null); + } + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java b/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java new file mode 100644 index 00000000..f290b147 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.quiz.controller; + +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.service.QuizService; +import com.swyp.picke.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; + +@Tag(name = "퀴즈 API", description = "퀴즈 콘텐츠 조회") +@RestController +@RequestMapping("/api/v1/quizzes") +@RequiredArgsConstructor +public class QuizController { + + private final QuizService quizService; + + @Operation(summary = "퀴즈 목록 조회") + @GetMapping + public ApiResponse getQuizzes( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size + ) { + return ApiResponse.onSuccess(quizService.getQuizzes(page, size)); + } + + @Operation(summary = "퀴즈 상세 조회") + @GetMapping("/{quizId}") + public ApiResponse getQuizDetail(@PathVariable Long quizId) { + return ApiResponse.onSuccess(quizService.getQuizDetail(quizId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java b/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java new file mode 100644 index 00000000..bdcb8bbd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.quiz.converter; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizOptionResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizSimpleResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import java.util.Comparator; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +@Component +public class QuizConverter { + + private static final Comparator OPTION_SORTER = + Comparator.comparing((QuizOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(QuizOption::getId); + + public Quiz toEntity(AdminQuizCreateRequest request) { + return Quiz.builder() + .title(request.title()) + .status(request.status()) + .build(); + } + + public QuizListResponse toListResponse(Page quizPage) { + List items = quizPage.getContent().stream() + .map(this::toSimpleResponse) + .toList(); + return new QuizListResponse(items, quizPage.getNumber() + 1, quizPage.getTotalPages(), quizPage.getTotalElements()); + } + + public QuizSimpleResponse toSimpleResponse(Quiz quiz) { + return new QuizSimpleResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getStatus(), + quiz.getCreatedAt() + ); + } + + public AdminQuizDetailResponse toAdminDetailResponse(Quiz quiz, List options) { + return new AdminQuizDetailResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getTargetDate(), + quiz.getStatus(), + toOptionResponses(options) + ); + } + + public QuizDetailResponse toDetailResponse(Quiz quiz, List options) { + return new QuizDetailResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getTargetDate(), + quiz.getStatus(), + toOptionResponses(options), + quiz.getCreatedAt(), + quiz.getUpdatedAt() + ); + } + + private List toOptionResponses(List options) { + if (options == null) { + return List.of(); + } + return options.stream() + .sorted(OPTION_SORTER) + .map(option -> new QuizOptionResponse( + option.getId(), + option.getLabel(), + option.getText(), + option.getDetailText(), + option.getIsCorrect(), + option.getDisplayOrder() + )) + .toList(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java new file mode 100644 index 00000000..c5409dec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record QuizDetailResponse( + Long quizId, + String title, + LocalDate targetDate, + QuizStatus status, + List options, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java new file mode 100644 index 00000000..ded527d5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import java.util.List; + +public record QuizListResponse( + List items, + int page, + int totalPages, + long totalElements +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java new file mode 100644 index 00000000..5f83007b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; + +public record QuizOptionResponse( + Long optionId, + QuizOptionLabel label, + String text, + String detailText, + Boolean isCorrect, + Integer displayOrder +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java new file mode 100644 index 00000000..85556fc9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; + +import java.time.LocalDateTime; + +public record QuizSimpleResponse( + Long quizId, + String title, + QuizStatus status, + LocalDateTime createdAt +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java new file mode 100644 index 00000000..b283ca95 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.quiz.dto.response; + +import com.swyp.picke.domain.tag.enums.TagType; + +public record QuizTagResponse( + Long tagId, + String name, + TagType type +) { +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java b/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java new file mode 100644 index 00000000..aade8606 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java @@ -0,0 +1,65 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Entity +@Table(name = "quizzes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Quiz extends BaseEntity { + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "target_date") + private LocalDate targetDate; + + @Column(name = "total_participants_count", nullable = false) + private Long totalParticipantsCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private QuizStatus status; + + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private final List options = new ArrayList<>(); + + @Builder + public Quiz(String title, LocalDate targetDate, QuizStatus status) { + this.title = title; + this.targetDate = targetDate; + this.status = status; + this.totalParticipantsCount = 0L; + } + + public void update(String title, LocalDate targetDate, QuizStatus status) { + if (title != null) this.title = title; + if (targetDate != null) this.targetDate = targetDate; + if (status != null) this.status = status; + } + + public void increaseTotalParticipantsCount() { + this.totalParticipantsCount = (this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount) + 1L; + } + + public void decreaseTotalParticipantsCount() { + long current = this.totalParticipantsCount == null ? 0L : this.totalParticipantsCount; + this.totalParticipantsCount = Math.max(0L, current - 1L); + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java new file mode 100644 index 00000000..85fd73e0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java @@ -0,0 +1,71 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_options") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizOption extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private QuizOptionLabel label; + + @Column(nullable = false, length = 300) + private String text; + + @Column(name = "detail_text", length = 1000) + private String detailText; + + @Column(name = "is_correct", nullable = false) + private Boolean isCorrect = false; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Builder + public QuizOption( + Quiz quiz, + QuizOptionLabel label, + String text, + String detailText, + Boolean isCorrect, + Integer displayOrder + ) { + this.quiz = quiz; + this.label = label; + this.text = text; + this.detailText = detailText; + this.isCorrect = (isCorrect != null) ? isCorrect : false; + this.displayOrder = displayOrder; + } + + void assignQuiz(Quiz quiz) { + this.quiz = quiz; + } + + public void update(String text, String detailText, Boolean isCorrect) { + if (text != null) this.text = text; + if (detailText != null) this.detailText = detailText; + if (isCorrect != null) this.isCorrect = isCorrect; + if (displayOrder != null) this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java new file mode 100644 index 00000000..43e94781 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.tag.entity.ValueTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_option_value_tags") +@IdClass(QuizOptionValueTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizOptionValueTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_option_id", nullable = false) + private QuizOption quizOption; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "value_tag_id", nullable = false) + private ValueTag valueTag; + + @Builder + public QuizOptionValueTagMap(QuizOption quizOption, ValueTag valueTag) { + this.quizOption = quizOption; + this.valueTag = valueTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java new file mode 100644 index 00000000..ce65910d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class QuizOptionValueTagMapId implements Serializable { + private Long quizOption; + private Long valueTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java new file mode 100644 index 00000000..bb19afa4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.tag.entity.CategoryTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_tags") +@IdClass(QuizTagMapId.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizTagMap { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_tag_id", nullable = false) + private CategoryTag categoryTag; + + @Builder + public QuizTagMap(Quiz quiz, CategoryTag categoryTag) { + this.quiz = quiz; + this.categoryTag = categoryTag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java new file mode 100644 index 00000000..e61597e7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.entity; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@EqualsAndHashCode +public class QuizTagMapId implements Serializable { + private Long quiz; + private Long categoryTag; +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java new file mode 100644 index 00000000..f159720f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.quiz.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "quiz_user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizUserVote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private QuizOption selectedOption; + + @Builder + public QuizUserVote(User user, Quiz quiz, QuizOption selectedOption) { + this.user = user; + this.quiz = quiz; + this.selectedOption = selectedOption; + } + + public void updateOption(QuizOption option) { + this.selectedOption = option; + } +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java new file mode 100644 index 00000000..2eeb5355 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java @@ -0,0 +1,6 @@ +package com.swyp.picke.domain.quiz.enums; + +public enum QuizOptionLabel { + A, B, C, D +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java new file mode 100644 index 00000000..a6063700 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.quiz.enums; + +public enum QuizStatus { + PENDING, + PUBLISHED, + ARCHIVED +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java new file mode 100644 index 00000000..f4c3c9b7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface QuizOptionRepository extends JpaRepository { + List findByQuizOrderByDisplayOrderAscLabelAscIdAsc(Quiz quiz); + Optional findByQuizAndLabel(Quiz quiz, QuizOptionLabel label); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java new file mode 100644 index 00000000..bacf283b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.entity.QuizOptionValueTagMap; +import com.swyp.picke.domain.quiz.entity.QuizOptionValueTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface QuizOptionValueTagMapRepository extends JpaRepository { + List findByQuizOption(QuizOption quizOption); + void deleteByQuizOption(QuizOption quizOption); +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java new file mode 100644 index 00000000..f84f5583 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java @@ -0,0 +1,37 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.domain.Page; +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; + +public interface QuizRepository extends JpaRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + @Query("SELECT q FROM Quiz q WHERE q.status = :status AND q.targetDate = :targetDate ORDER BY q.createdAt ASC") + List findTodayPicks( + @Param("status") QuizStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); + + @Query(""" + SELECT q + FROM Quiz q + WHERE q.status = :status + AND (q.targetDate IS NULL OR q.targetDate <> :targetDate) + ORDER BY CASE WHEN q.targetDate IS NULL THEN 0 ELSE 1 END, + q.targetDate ASC, + q.createdAt ASC + """) + List findAutoAssignableTodayPicks( + @Param("status") QuizStatus status, + @Param("targetDate") LocalDate targetDate, + Pageable pageable + ); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java new file mode 100644 index 00000000..aeb7ebe9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizTagMap; +import com.swyp.picke.domain.quiz.entity.QuizTagMapId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface QuizTagMapRepository extends JpaRepository { + List findByQuiz(Quiz quiz); + void deleteByQuiz(Quiz quiz); +} + diff --git a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java new file mode 100644 index 00000000..07f26949 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.quiz.repository; + +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.entity.QuizUserVote; +import com.swyp.picke.domain.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuizUserVoteRepository extends JpaRepository { + Optional findByQuizAndUser(Quiz quiz, User user); + long countByQuiz(Quiz quiz); + long countByQuizAndSelectedOption(Quiz quiz, QuizOption selectedOption); + List findAllByQuiz(Quiz quiz); +} diff --git a/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java b/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java new file mode 100644 index 00000000..c6d1678f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.quiz.service; + +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import java.util.List; + +public interface QuizService { + Quiz findById(Long quizId); + + QuizListResponse getQuizzes(int page, int size); + + List getTodayPicks(int limit); + + List getOptions(Quiz quiz); + + long countVotes(Quiz quiz); + + QuizDetailResponse getQuizDetail(Long quizId); + + AdminQuizDetailResponse getAdminQuizDetail(Long quizId); + + AdminQuizDetailResponse createQuiz(AdminQuizCreateRequest request); + + AdminQuizDetailResponse updateQuiz(Long quizId, AdminQuizUpdateRequest request); + + AdminQuizDeleteResponse deleteQuiz(Long quizId); +} + + diff --git a/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java b/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java new file mode 100644 index 00000000..3ee12e08 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java @@ -0,0 +1,189 @@ +package com.swyp.picke.domain.quiz.service; + +import com.swyp.picke.domain.quiz.converter.QuizConverter; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizCreateRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizOptionRequest; +import com.swyp.picke.domain.admin.dto.quiz.request.AdminQuizUpdateRequest; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDeleteResponse; +import com.swyp.picke.domain.admin.dto.quiz.response.AdminQuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizDetailResponse; +import com.swyp.picke.domain.quiz.dto.response.QuizListResponse; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.enums.QuizStatus; +import com.swyp.picke.domain.quiz.repository.QuizOptionRepository; +import com.swyp.picke.domain.quiz.repository.QuizRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.access.prepost.PreAuthorize; +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.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuizServiceImpl implements QuizService { + + private final QuizRepository quizRepository; + private final QuizOptionRepository quizOptionRepository; + private final QuizConverter quizConverter; + + @Override + public Quiz findById(Long quizId) { + return quizRepository.findById(quizId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + } + + @Override + public QuizListResponse getQuizzes(int page, int size) { + int pageNumber = Math.max(0, page - 1); + Page quizPage = quizRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(pageNumber, size)); + return quizConverter.toListResponse(quizPage); + } + + @Override + @Transactional + public List getTodayPicks(int limit) { + int safeLimit = Math.max(1, limit); + LocalDate today = LocalDate.now(); + + ensureTodayPicks(today, safeLimit); + return quizRepository.findTodayPicks(QuizStatus.PUBLISHED, today, PageRequest.of(0, safeLimit)); + } + + @Override + public List getOptions(Quiz quiz) { + return quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + } + + @Override + public long countVotes(Quiz quiz) { + return quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); + } + + @Override + public QuizDetailResponse getQuizDetail(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + return quizConverter.toDetailResponse(quiz, options); + } + + @Override + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse getAdminQuizDetail(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + return quizConverter.toAdminDetailResponse(quiz, options); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse createQuiz(AdminQuizCreateRequest request) { + Quiz quiz = quizConverter.toEntity(request); + quiz = quizRepository.save(quiz); + + List savedOptions = new ArrayList<>(); + if (request.options() != null) { + for (AdminQuizOptionRequest optionRequest : request.options()) { + QuizOption option = QuizOption.builder() + .quiz(quiz) + .label(optionRequest.label()) + .text(optionRequest.text()) + .detailText(optionRequest.detailText()) + .isCorrect(optionRequest.isCorrect()) + .build(); + option = quizOptionRepository.save(option); + savedOptions.add(option); + } + } + + return quizConverter.toAdminDetailResponse(quiz, savedOptions); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDetailResponse updateQuiz(Long quizId, AdminQuizUpdateRequest request) { + Quiz quiz = findById(quizId); + quiz.update(request.title(), request.targetDate(), request.status()); + + if (request.options() != null) { + List existingOptions = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + Map existingOptionMap = new HashMap<>(); + for (QuizOption option : existingOptions) { + existingOptionMap.put(option.getLabel(), option); + } + + Set requestedLabels = new HashSet<>(); + for (AdminQuizOptionRequest optionRequest : request.options()) { + requestedLabels.add(optionRequest.label()); + QuizOption option = existingOptionMap.get(optionRequest.label()); + + if (option == null) { + option = QuizOption.builder() + .quiz(quiz) + .label(optionRequest.label()) + .text(optionRequest.text()) + .detailText(optionRequest.detailText()) + .isCorrect(optionRequest.isCorrect()) + .build(); + option = quizOptionRepository.save(option); + } else { + option.update( + optionRequest.text(), + optionRequest.detailText(), + optionRequest.isCorrect() + ); + } + } + + for (QuizOption existingOption : existingOptions) { + if (requestedLabels.contains(existingOption.getLabel())) continue; + quizOptionRepository.delete(existingOption); + } + } + + return getAdminQuizDetail(quizId); + } + + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminQuizDeleteResponse deleteQuiz(Long quizId) { + Quiz quiz = findById(quizId); + List options = quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz); + quizOptionRepository.deleteAll(options); + quizRepository.delete(quiz); + return new AdminQuizDeleteResponse(true, LocalDateTime.now()); + } + + private void ensureTodayPicks(LocalDate today, int requiredCount) { + List todays = quizRepository.findTodayPicks(QuizStatus.PUBLISHED, today, PageRequest.of(0, requiredCount)); + int missingCount = requiredCount - todays.size(); + if (missingCount <= 0) return; + + List candidates = quizRepository.findAutoAssignableTodayPicks( + QuizStatus.PUBLISHED, + today, + PageRequest.of(0, missingCount) + ); + for (Quiz candidate : candidates) { + candidate.update(null, today, null); + } + } +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/controller/TagController.java b/src/main/java/com/swyp/picke/domain/tag/controller/TagController.java index 094f8982..12a96ce9 100644 --- a/src/main/java/com/swyp/picke/domain/tag/controller/TagController.java +++ b/src/main/java/com/swyp/picke/domain/tag/controller/TagController.java @@ -1,19 +1,19 @@ package com.swyp.picke.domain.tag.controller; -import com.swyp.picke.domain.tag.dto.request.TagRequest; -import com.swyp.picke.domain.tag.dto.response.*; +import com.swyp.picke.domain.tag.dto.response.TagListResponse; import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.tag.service.TagService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; -@Tag(name = "태그 (Tag)", description = "태그 조회 및 관리 API") +@Tag(name = "태그 API", description = "태그 조회") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -21,46 +21,13 @@ public class TagController { private final TagService tagService; - @Operation(summary = "태그 목록 조회", description = "전체 태그 목록을 조회합니다. 특정 타입(type)을 지정하여 필터링할 수 있습니다.") + @Operation(summary = "태그 목록 조회") @GetMapping("/tags") public ApiResponse getTags( - @Parameter(description = "필터링할 태그 타입 (예: BATTLE 등)", required = false) + @Parameter(description = "태그 타입 필터(선택)", required = false) @RequestParam(name = "type", required = false) TagType type) { TagListResponse response = tagService.getTags(type); return ApiResponse.onSuccess(response); } - - @Operation(summary = "태그 생성 (관리자)", description = "관리자가 새로운 태그를 생성합니다.") - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/admin/tags") - public ApiResponse createTag( - @Valid @RequestBody TagRequest request) { - - TagResponse response = tagService.createTag(request); - return ApiResponse.onSuccess(response); - } - - @Operation(summary = "태그 수정 (관리자)", description = "관리자가 기존 태그의 이름이나 정보를 수정합니다.") - @PreAuthorize("hasRole('ADMIN')") - @PatchMapping("/admin/tags/{tag_id}") - public ApiResponse updateTag( - @Parameter(description = "수정할 태그의 ID", example = "1") - @PathVariable("tag_id") Long tagId, - @Valid @RequestBody TagRequest request) { - - TagResponse response = tagService.updateTag(tagId, request); - return ApiResponse.onSuccess(response); - } - - @Operation(summary = "태그 삭제 (관리자)", description = "관리자가 특정 태그를 삭제합니다. 단, 배틀에 사용 중인 태그는 삭제할 수 없습니다.") - @PreAuthorize("hasRole('ADMIN')") - @DeleteMapping("/admin/tags/{tag_id}") - public ApiResponse deleteTag( - @Parameter(description = "삭제할 태그의 ID", example = "1") - @PathVariable("tag_id") Long tagId) { - - TagDeleteResponse response = tagService.deleteTag(tagId); - return ApiResponse.onSuccess(response); - } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/converter/TagConverter.java b/src/main/java/com/swyp/picke/domain/tag/converter/TagConverter.java index b3860d45..26382626 100644 --- a/src/main/java/com/swyp/picke/domain/tag/converter/TagConverter.java +++ b/src/main/java/com/swyp/picke/domain/tag/converter/TagConverter.java @@ -1,7 +1,9 @@ package com.swyp.picke.domain.tag.converter; -import com.swyp.picke.domain.tag.dto.request.TagRequest; -import com.swyp.picke.domain.tag.dto.response.*; +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; +import com.swyp.picke.domain.tag.dto.response.TagListResponse; import com.swyp.picke.domain.tag.entity.Tag; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java b/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java deleted file mode 100644 index 736bfda6..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp.picke.domain.tag.dto.request; - -import com.swyp.picke.domain.tag.enums.TagType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record TagRequest( - @NotBlank(message = "태그 이름을 입력해주세요.") - String name, - - @NotNull(message = "태그 타입을 선택해주세요.") - TagType type -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java deleted file mode 100644 index 71b350e8..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.swyp.picke.domain.tag.dto.response; - -import java.time.LocalDateTime; - -public record TagDeleteResponse( - boolean success, - LocalDateTime deletedAt -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagListResponse.java b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagListResponse.java index 5e258e8d..6bf53599 100644 --- a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagListResponse.java +++ b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagListResponse.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.tag.dto.response; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; import java.util.List; public record TagListResponse( diff --git a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java deleted file mode 100644 index 70554dde..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.swyp.picke.domain.tag.dto.response; - -import com.swyp.picke.domain.tag.enums.TagType; -import java.time.LocalDateTime; - -public record TagResponse( - Long tagId, - String name, - TagType type, - LocalDateTime createdAt, - LocalDateTime updatedAt -) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/tag/entity/CategoryTag.java b/src/main/java/com/swyp/picke/domain/tag/entity/CategoryTag.java new file mode 100644 index 00000000..41b4561a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/entity/CategoryTag.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.tag.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "category_tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CategoryTag { + + @Id + @Column(name = "tag_id") + private Long tagId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Builder + public CategoryTag(Tag tag) { + this.tag = tag; + } +} diff --git a/src/main/java/com/swyp/picke/domain/tag/entity/PhilosopherTag.java b/src/main/java/com/swyp/picke/domain/tag/entity/PhilosopherTag.java new file mode 100644 index 00000000..ba54480b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/entity/PhilosopherTag.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.tag.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "philosopher_tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhilosopherTag { + + @Id + @Column(name = "tag_id") + private Long tagId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Builder + public PhilosopherTag(Tag tag) { + this.tag = tag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/entity/ValueTag.java b/src/main/java/com/swyp/picke/domain/tag/entity/ValueTag.java new file mode 100644 index 00000000..6c9c0303 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/entity/ValueTag.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.tag.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "value_tags") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ValueTag { + + @Id + @Column(name = "tag_id") + private Long tagId; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + @Builder + public ValueTag(Tag tag) { + this.tag = tag; + } +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/repository/CategoryTagRepository.java b/src/main/java/com/swyp/picke/domain/tag/repository/CategoryTagRepository.java new file mode 100644 index 00000000..9d71ad27 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/repository/CategoryTagRepository.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.tag.repository; + +import com.swyp.picke.domain.tag.entity.CategoryTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryTagRepository extends JpaRepository { +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/repository/PhilosopherTagRepository.java b/src/main/java/com/swyp/picke/domain/tag/repository/PhilosopherTagRepository.java new file mode 100644 index 00000000..fdca62b4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/repository/PhilosopherTagRepository.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.tag.repository; + +import com.swyp.picke.domain.tag.entity.PhilosopherTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PhilosopherTagRepository extends JpaRepository { +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/repository/ValueTagRepository.java b/src/main/java/com/swyp/picke/domain/tag/repository/ValueTagRepository.java new file mode 100644 index 00000000..f731d490 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/repository/ValueTagRepository.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.tag.repository; + +import com.swyp.picke.domain.tag.entity.ValueTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ValueTagRepository extends JpaRepository { +} + diff --git a/src/main/java/com/swyp/picke/domain/tag/service/TagService.java b/src/main/java/com/swyp/picke/domain/tag/service/TagService.java index 97ceca46..2074a1e8 100644 --- a/src/main/java/com/swyp/picke/domain/tag/service/TagService.java +++ b/src/main/java/com/swyp/picke/domain/tag/service/TagService.java @@ -1,9 +1,9 @@ package com.swyp.picke.domain.tag.service; -import com.swyp.picke.domain.tag.dto.request.TagRequest; -import com.swyp.picke.domain.tag.dto.response.TagDeleteResponse; +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; import com.swyp.picke.domain.tag.dto.response.TagListResponse; -import com.swyp.picke.domain.tag.dto.response.TagResponse; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; @@ -11,7 +11,6 @@ public interface TagService { List findByBattleId(Long battleId); - TagListResponse getTags(TagType type); TagResponse createTag(TagRequest request); TagResponse updateTag(Long tagId, TagRequest request); diff --git a/src/main/java/com/swyp/picke/domain/tag/service/TagServiceImpl.java b/src/main/java/com/swyp/picke/domain/tag/service/TagServiceImpl.java index d1bf3b96..8f7b0950 100644 --- a/src/main/java/com/swyp/picke/domain/tag/service/TagServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/tag/service/TagServiceImpl.java @@ -1,11 +1,14 @@ package com.swyp.picke.domain.tag.service; import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; import com.swyp.picke.domain.battle.repository.BattleRepository; import com.swyp.picke.domain.battle.repository.BattleTagRepository; import com.swyp.picke.domain.tag.converter.TagConverter; -import com.swyp.picke.domain.tag.dto.request.TagRequest; -import com.swyp.picke.domain.tag.dto.response.*; +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; +import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; +import com.swyp.picke.domain.tag.dto.response.TagListResponse; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.tag.repository.TagRepository; @@ -25,6 +28,7 @@ public class TagServiceImpl implements TagService { private final TagRepository tagRepository; private final BattleTagRepository battleTagRepository; + private final BattleOptionTagRepository battleOptionTagRepository; private final BattleRepository battleRepository; @Override @@ -62,11 +66,16 @@ public TagResponse createTag(TagRequest request) { @PreAuthorize("hasRole('ADMIN')") public TagResponse updateTag(Long tagId, TagRequest request) { Tag tag = findTagById(tagId); + boolean typeChanged = tag.getType() != request.type(); if (!tag.getName().equals(request.name()) || tag.getType() != request.type()) { validateDuplicateTag(request.name(), request.type()); } + if (typeChanged && isTagInUse(tag)) { + throw new CustomException(ErrorCode.TAG_TYPE_CHANGE_FORBIDDEN); + } + tag.updateTag(request.name(), request.type()); return TagConverter.toDetailResponse(tag); } @@ -77,7 +86,7 @@ public TagResponse updateTag(Long tagId, TagRequest request) { public TagDeleteResponse deleteTag(Long tagId) { Tag tag = findTagById(tagId); - if (battleTagRepository.existsByTag(tag)) { + if (isTagInUse(tag)) { throw new CustomException(ErrorCode.TAG_IN_USE); } @@ -95,4 +104,8 @@ private void validateDuplicateTag(String name, TagType type) { throw new CustomException(ErrorCode.TAG_DUPLICATED); } } + + private boolean isTagInUse(Tag tag) { + return battleTagRepository.existsByTag(tag) || battleOptionTagRepository.existsByTag(tag); + } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java b/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java index bf9ee7ae..0c864ca1 100644 --- a/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java +++ b/src/main/java/com/swyp/picke/domain/vote/controller/VoteController.java @@ -1,35 +1,47 @@ package com.swyp.picke.domain.vote.controller; +import com.swyp.picke.domain.vote.dto.request.PollVoteRequest; import com.swyp.picke.domain.vote.dto.request.QuizVoteRequest; import com.swyp.picke.domain.vote.dto.request.VoteRequest; -import com.swyp.picke.domain.vote.dto.response.*; +import com.swyp.picke.domain.vote.dto.response.MyVoteResponse; +import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; +import com.swyp.picke.domain.vote.dto.response.QuizVoteResponse; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; +import com.swyp.picke.domain.vote.service.BattleVoteService; +import com.swyp.picke.domain.vote.service.PollVoteService; import com.swyp.picke.domain.vote.service.QuizVoteService; -import com.swyp.picke.domain.vote.service.VoteService; import com.swyp.picke.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.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -@Tag(name = "투표 (Vote)", description = "사전/사후 투표 실행 및 통계, 내 투표 내역 조회 API") +@Tag(name = "투표 API", description = "배틀/퀴즈/투표 투표 처리") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor public class VoteController { - // 배틀(BATTLE) 전용 서비스 - private final VoteService voteService; - // 퀴즈(QUIZ) & 투표(POLL) 전용 서비스 + private final BattleVoteService battleVoteService; private final QuizVoteService quizVoteService; + private final PollVoteService pollVoteService; - @Operation(summary = "[퀴즈] 선택 제출") + @Operation(summary = "[퀴즈] 답안 제출") @PostMapping("/battles/{battleId}/quiz-vote") public ApiResponse submitQuiz( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody QuizVoteRequest request) { + @RequestBody QuizVoteRequest request + ) { return ApiResponse.onSuccess(quizVoteService.submitQuiz(battleId, userId, request)); } @@ -38,15 +50,17 @@ public ApiResponse submitQuiz( public ApiResponse submitPoll( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody QuizVoteRequest request) { - return ApiResponse.onSuccess(quizVoteService.submitPoll(battleId, userId, request)); + @RequestBody PollVoteRequest request + ) { + return ApiResponse.onSuccess(pollVoteService.submitPoll(battleId, userId, request)); } @Operation(summary = "[퀴즈] 내 퀴즈 참여 내역 조회", description = "내가 선택한 퀴즈 옵션과 통계를 조회합니다.") @GetMapping("/battles/{battleId}/quiz-vote/me") public ApiResponse getMyQuizVote( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId) { + @AuthenticationPrincipal Long userId + ) { return ApiResponse.onSuccess(quizVoteService.getMyQuizVote(battleId, userId)); } @@ -54,19 +68,19 @@ public ApiResponse getMyQuizVote( @GetMapping("/battles/{battleId}/poll-vote/me") public ApiResponse getMyPollVote( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId) { - return ApiResponse.onSuccess(quizVoteService.getMyPollVote(battleId, userId)); + @AuthenticationPrincipal Long userId + ) { + return ApiResponse.onSuccess(pollVoteService.getMyPollVote(battleId, userId)); } - // 2. 배틀(BATTLE) 관련 API - @Operation(summary = "[배틀] 사전 투표 실행", description = "배틀 진입 시 첫 투표(사전 투표)를 진행합니다.") @PostMapping("/battles/{battleId}/votes/pre") public ApiResponse preVote( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody VoteRequest request) { - return ApiResponse.onSuccess(voteService.preVote(battleId, userId, request)); + @RequestBody VoteRequest request + ) { + return ApiResponse.onSuccess(battleVoteService.preVote(battleId, userId, request)); } @Operation(summary = "[배틀] 사후 투표 실행", description = "콘텐츠 소비 후 최종 투표(사후 투표)를 진행합니다.") @@ -74,46 +88,57 @@ public ApiResponse preVote( public ApiResponse postVote( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody VoteRequest request) { - return ApiResponse.onSuccess(voteService.postVote(battleId, userId, request)); + @RequestBody VoteRequest request + ) { + return ApiResponse.onSuccess(battleVoteService.postVote(battleId, userId, request)); } @Operation(summary = "[배틀] 투표 통계 조회", description = "특정 배틀의 옵션별 투표 수와 비율을 조회합니다.") @GetMapping("/battles/{battleId}/vote-stats") public ApiResponse getVoteStats(@PathVariable Long battleId) { - return ApiResponse.onSuccess(voteService.getVoteStats(battleId)); + return ApiResponse.onSuccess(battleVoteService.getVoteStats(battleId)); } @Operation(summary = "[배틀] 내 투표 내역 조회", description = "특정 배틀에 대한 내 사전/사후 투표 내역과 현재 상태를 조회합니다.") @GetMapping("/battles/{battleId}/votes/me") public ApiResponse getMyVote( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId) { - return ApiResponse.onSuccess(voteService.getMyVote(battleId, userId)); + @AuthenticationPrincipal Long userId + ) { + return ApiResponse.onSuccess(battleVoteService.getMyVote(battleId, userId)); } @Operation(summary = "[배틀] 오디오(TTS) 청취 완료 처리", description = "사전 투표 후, 오디오 재생이 완료되었을 때 호출하여 상태를 업데이트합니다.") @PostMapping("/battles/{battleId}/votes/tts-complete") public ApiResponse completeTts( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId) { - voteService.completeTts(battleId, userId); + @AuthenticationPrincipal Long userId + ) { + battleVoteService.completeTts(battleId, userId); return ApiResponse.onSuccess(null); } - @Operation(summary = "[관리자] 배틀 투표 삭제") + @Operation(summary = "[관리자] 배틀 투표 기록 삭제") @DeleteMapping("/admin/votes/battle/{battleId}") @PreAuthorize("hasRole('ADMIN')") public ApiResponse deleteBattleVote(@PathVariable Long battleId) { - voteService.deleteVotesByBattleId(battleId); + battleVoteService.deleteVotesByBattleId(battleId); return ApiResponse.onSuccess(null); } - @Operation(summary = "[관리자] 퀴즈/일반투표 기록 삭제") - @DeleteMapping("/admin/votes/quiz-poll/{battleId}") + @Operation(summary = "[관리자] 퀴즈 투표 기록 삭제") + @DeleteMapping("/admin/votes/quiz/{battleId}") @PreAuthorize("hasRole('ADMIN')") - public ApiResponse deleteQuizPollVote(@PathVariable Long battleId) { + public ApiResponse deleteQuizVote(@PathVariable Long battleId) { quizVoteService.deleteQuizVoteByBattleId(battleId); return ApiResponse.onSuccess(null); } + + @Operation(summary = "[관리자] 투표 콘텐츠 투표 기록 삭제") + @DeleteMapping("/admin/votes/poll/{battleId}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse deletePollVote(@PathVariable Long battleId) { + pollVoteService.deletePollVoteByBattleId(battleId); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/swyp/picke/domain/vote/converter/VoteConverter.java b/src/main/java/com/swyp/picke/domain/vote/converter/VoteConverter.java index 4c4b741f..23e0b340 100644 --- a/src/main/java/com/swyp/picke/domain/vote/converter/VoteConverter.java +++ b/src/main/java/com/swyp/picke/domain/vote/converter/VoteConverter.java @@ -5,20 +5,17 @@ import com.swyp.picke.domain.vote.dto.response.MyVoteResponse; import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; -import com.swyp.picke.domain.vote.entity.Vote; - +import com.swyp.picke.domain.vote.entity.BattleVote; import java.time.LocalDateTime; import java.util.List; public class VoteConverter { - // [수정] UserBattleStep을 인자로 받도록 변경 - public static VoteResultResponse toVoteResultResponse(Vote vote, UserBattleStep step) { + public static VoteResultResponse toVoteResultResponse(BattleVote vote, UserBattleStep step) { return new VoteResultResponse(vote.getId(), step); } - // [수정] UserBattleStep을 인자로 받아 MyVoteResponse의 status 필드에 매핑 - public static MyVoteResponse toMyVoteResponse(Vote vote, UserBattleStep step) { + public static MyVoteResponse toMyVoteResponse(BattleVote vote, UserBattleStep step) { boolean opinionChanged = vote.getPreVoteOption() != null && vote.getPostVoteOption() != null && !vote.getPreVoteOption().getId().equals(vote.getPostVoteOption().getId()); @@ -27,19 +24,23 @@ public static MyVoteResponse toMyVoteResponse(Vote vote, UserBattleStep step) { vote.getBattle().getTitle(), toOptionInfo(vote.getPreVoteOption()), toOptionInfo(vote.getPostVoteOption()), - step, // 외부에서 넘겨받은 UserBattleStep 사용 + step, opinionChanged ); } - // 투표 통계 변환 - public static VoteStatsResponse toVoteStatsResponse(List stats, long totalCount, LocalDateTime updatedAt) { + public static VoteStatsResponse toVoteStatsResponse( + List stats, + long totalCount, + LocalDateTime updatedAt + ) { return new VoteStatsResponse(stats, totalCount, updatedAt); } - // 옵션 정보를 응답용으로 변환 (null 안전 처리) private static MyVoteResponse.OptionInfo toOptionInfo(BattleOption option) { - if (option == null) return null; + if (option == null) { + return null; + } return new MyVoteResponse.OptionInfo(option.getId(), option.getLabel().name(), option.getTitle()); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/dto/request/PollVoteRequest.java b/src/main/java/com/swyp/picke/domain/vote/dto/request/PollVoteRequest.java new file mode 100644 index 00000000..1a37a99a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/dto/request/PollVoteRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.vote.dto.request; + +public record PollVoteRequest( + Long optionId +) {} + + diff --git a/src/main/java/com/swyp/picke/domain/vote/dto/request/QuizVoteRequest.java b/src/main/java/com/swyp/picke/domain/vote/dto/request/QuizVoteRequest.java index 7ff37c42..212547fa 100644 --- a/src/main/java/com/swyp/picke/domain/vote/dto/request/QuizVoteRequest.java +++ b/src/main/java/com/swyp/picke/domain/vote/dto/request/QuizVoteRequest.java @@ -2,4 +2,4 @@ public record QuizVoteRequest( Long optionId -) {} +) {} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/dto/response/MyVoteResponse.java b/src/main/java/com/swyp/picke/domain/vote/dto/response/MyVoteResponse.java index 6a41eb6d..0dd199d8 100644 --- a/src/main/java/com/swyp/picke/domain/vote/dto/response/MyVoteResponse.java +++ b/src/main/java/com/swyp/picke/domain/vote/dto/response/MyVoteResponse.java @@ -10,4 +10,4 @@ public record MyVoteResponse( boolean opinionChanged ) { public record OptionInfo(Long optionId, String label, String title) {} -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/dto/response/PollVoteResponse.java b/src/main/java/com/swyp/picke/domain/vote/dto/response/PollVoteResponse.java index 3c508760..4303b5dc 100644 --- a/src/main/java/com/swyp/picke/domain/vote/dto/response/PollVoteResponse.java +++ b/src/main/java/com/swyp/picke/domain/vote/dto/response/PollVoteResponse.java @@ -9,4 +9,4 @@ public record PollVoteResponse( List stats ) { public record OptionStat(Long optionId, String label, String title, long voteCount, double ratio) {} -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java b/src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java similarity index 77% rename from src/main/java/com/swyp/picke/domain/vote/entity/Vote.java rename to src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java index 47054b65..1551e80c 100644 --- a/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java +++ b/src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java @@ -19,7 +19,7 @@ @Entity @Table(name = "votes") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Vote extends BaseEntity { +public class BattleVote extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -41,7 +41,7 @@ public class Vote extends BaseEntity { private Boolean isTtsListened = false; @Builder - private Vote(User user, Battle battle, BattleOption preVoteOption, + private BattleVote(User user, Battle battle, BattleOption preVoteOption, BattleOption postVoteOption, Boolean isTtsListened) { this.user = user; this.battle = battle; @@ -50,38 +50,26 @@ private Vote(User user, Battle battle, BattleOption preVoteOption, this.isTtsListened = isTtsListened != null ? isTtsListened : false; } - /** - * 최초 투표(사전 투표) 시 사용하는 정적 팩토리 메서드 - */ - public static Vote createPreVote(User user, Battle battle, BattleOption option) { - return Vote.builder() + public static BattleVote createPreVote(User user, Battle battle, BattleOption option) { + return BattleVote.builder() .user(user) .battle(battle) .preVoteOption(option) .isTtsListened(false) - // status 설정 삭제됨 + // status ?ㅼ젙 ??젣?? .build(); } - /** - * 사전 투표 옵션 수정 메서드 - */ public void updatePreVote(BattleOption preVoteOption) { this.preVoteOption = preVoteOption; } - /** - * 사후 투표 업데이트 - */ public void doPostVote(BattleOption postOption) { this.postVoteOption = postOption; - // status 업데이트 삭제됨 } - /** - * TTS 청취 상태 업데이트 - */ public void completeTts() { this.isTtsListened = true; } -} \ No newline at end of file +} + diff --git a/src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java b/src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java new file mode 100644 index 00000000..7f650b2e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java @@ -0,0 +1,45 @@ +package com.swyp.picke.domain.vote.entity; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "poll_user_votes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PollVote extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "poll_id", nullable = false) + private Poll poll; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private PollOption selectedOption; + + @Builder + public PollVote(User user, Poll poll, PollOption selectedOption) { + this.user = user; + this.poll = poll; + this.selectedOption = selectedOption; + } + + public void updateOption(PollOption option) { + this.selectedOption = option; + } +} diff --git a/src/main/java/com/swyp/picke/domain/vote/entity/QuizVote.java b/src/main/java/com/swyp/picke/domain/vote/entity/QuizVote.java index 7bc13514..bb6c4a7a 100644 --- a/src/main/java/com/swyp/picke/domain/vote/entity/QuizVote.java +++ b/src/main/java/com/swyp/picke/domain/vote/entity/QuizVote.java @@ -1,10 +1,14 @@ package com.swyp.picke.domain.vote.entity; -import com.swyp.picke.domain.battle.entity.Battle; -import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.global.common.BaseEntity; -import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -12,7 +16,7 @@ @Getter @Entity -@Table(name = "quiz_votes") +@Table(name = "quiz_user_votes") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuizVote extends BaseEntity { @@ -21,21 +25,21 @@ public class QuizVote extends BaseEntity { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "battle_id", nullable = false) - private Battle battle; + @JoinColumn(name = "quiz_id", nullable = false) + private Quiz quiz; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "option_id", nullable = false) - private BattleOption selectedOption; + private QuizOption selectedOption; @Builder - public QuizVote(User user, Battle battle, BattleOption selectedOption) { + public QuizVote(User user, Quiz quiz, QuizOption selectedOption) { this.user = user; - this.battle = battle; + this.quiz = quiz; this.selectedOption = selectedOption; } - public void updateOption(BattleOption option) { + public void updateOption(QuizOption option) { this.selectedOption = option; } } diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java similarity index 51% rename from src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java rename to src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java index 4159beb1..2e98f96c 100644 --- a/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java +++ b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java @@ -4,7 +4,7 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.user.entity.User; -import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.entity.BattleVote; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -13,57 +13,57 @@ import java.util.List; import java.util.Optional; -public interface VoteRepository extends JpaRepository { +public interface BattleVoteRepository extends JpaRepository { - List findAllByBattle(Battle battle); + List findAllByBattle(Battle battle); - Optional findByBattleIdAndUserId(Long battleId, Long userId); + Optional findByBattleIdAndUserId(Long battleId, Long userId); - @Query("SELECT v FROM Vote v LEFT JOIN FETCH v.postVoteOption WHERE v.battle.id = :battleId AND v.user.id = :userId") - Optional findByBattleIdAndUserIdWithOption(@Param("battleId") Long battleId, @Param("userId") Long userId); + @Query("SELECT v FROM BattleVote v LEFT JOIN FETCH v.postVoteOption WHERE v.battle.id = :battleId AND v.user.id = :userId") + Optional findByBattleIdAndUserIdWithOption(@Param("battleId") Long battleId, @Param("userId") Long userId); - Optional findByBattleAndUser(Battle battle, User user); + Optional findByBattleAndUser(Battle battle, User user); long countByBattle(Battle battle); long countByBattleAndPreVoteOption(Battle battle, BattleOption preVoteOption); - Optional findTopByBattleOrderByUpdatedAtDesc(Battle battle); + Optional findTopByBattleOrderByUpdatedAtDesc(Battle battle); - @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + "WHERE v.user.id = :userId ORDER BY v.createdAt DESC") - List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); - @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + "WHERE v.user.id = :userId AND v.preVoteOption.label = :label ORDER BY v.createdAt DESC") - List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( + List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( @Param("userId") Long userId, @Param("label") BattleOptionLabel label, Pageable pageable); long countByUserId(Long userId); - @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :userId AND v.preVoteOption.label = :label") + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.label = :label") long countByUserIdAndPreVoteOptionLabel(@Param("userId") Long userId, @Param("label") BattleOptionLabel label); - @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :userId " + + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId " + "AND v.postVoteOption IS NOT NULL " + "AND v.preVoteOption <> v.postVoteOption") long countOpinionChangesByUserId(@Param("userId") Long userId); - List findByUserId(Long userId); + List findByUserId(Long userId); // MypageService: 철학자 유형 산출용 - 최초 N개 투표 조회 (생성순) - @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.user.id = :userId ORDER BY v.createdAt ASC") - List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle WHERE v.user.id = :userId ORDER BY v.createdAt ASC") + List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); // 추천용: 유저가 참여한 배틀 ID 조회 - @Query("SELECT v.battle.id FROM Vote v WHERE v.user.id = :userId") + @Query("SELECT v.battle.id FROM BattleVote v WHERE v.user.id = :userId") List findParticipatedBattleIdsByUserId(@Param("userId") Long userId); // 추천용: 특정 배틀에 참여한 유저 ID 조회 - @Query("SELECT DISTINCT v.user.id FROM Vote v WHERE v.battle.id IN :battleIds") + @Query("SELECT DISTINCT v.user.id FROM BattleVote v WHERE v.battle.id IN :battleIds") List findUserIdsByBattleIds(@Param("battleIds") List battleIds); // 추천용: 특정 유저들이 참여한 배틀 ID 조회 - @Query("SELECT v.battle.id FROM Vote v WHERE v.user.id IN :userIds") + @Query("SELECT v.battle.id FROM BattleVote v WHERE v.user.id IN :userIds") List findParticipatedBattleIdsByUserIds(@Param("userIds") List userIds); -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java new file mode 100644 index 00000000..814fc16f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.vote.repository; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.vote.entity.PollVote; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PollVoteRepository extends JpaRepository { + Optional findByPollAndUser(Poll poll, User user); + long countByPoll(Poll poll); + List findAllByPoll(Poll poll); +} diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/QuizVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/QuizVoteRepository.java index 060f2938..5cfd4064 100644 --- a/src/main/java/com/swyp/picke/domain/vote/repository/QuizVoteRepository.java +++ b/src/main/java/com/swyp/picke/domain/vote/repository/QuizVoteRepository.java @@ -1,15 +1,15 @@ package com.swyp.picke.domain.vote.repository; -import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.vote.entity.QuizVote; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface QuizVoteRepository extends JpaRepository { - Optional findByBattleAndUser(Battle battle, User user); - long countByBattle(Battle battle); - List findAllByBattle(Battle battle); + Optional findByQuizAndUser(Quiz quiz, User user); + List findAllByQuiz(Quiz quiz); + long countByQuizAndSelectedOption(Quiz quiz, QuizOption selectedOption); } diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java similarity index 95% rename from src/main/java/com/swyp/picke/domain/vote/service/VoteService.java rename to src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java index 77d68fe6..6fac6bbf 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java @@ -6,7 +6,7 @@ import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; -public interface VoteService { +public interface BattleVoteService { BattleOption findPreVoteOption(Long battleId, Long userId); Long findPostVoteOptionId(Long battleId, Long userId); VoteStatsResponse getVoteStats(Long battleId); diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java similarity index 70% rename from src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java rename to src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java index 32a2d956..74342d90 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java @@ -14,24 +14,23 @@ import com.swyp.picke.domain.vote.dto.response.MyVoteResponse; import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; import com.swyp.picke.domain.vote.dto.response.VoteStatsResponse; -import com.swyp.picke.domain.vote.entity.Vote; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.List; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class VoteServiceImpl implements VoteService { +public class BattleVoteServiceImpl implements BattleVoteService { - private final VoteRepository voteRepository; + private final BattleVoteRepository battleVoteRepository; private final BattleService battleService; private final BattleOptionRepository battleOptionRepository; private final UserRepository userRepository; @@ -43,7 +42,7 @@ public BattleOption findPreVoteOption(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUser(battle, user) + BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); if (vote.getPreVoteOption() == null) { @@ -54,7 +53,7 @@ public BattleOption findPreVoteOption(Long battleId, Long userId) { @Override public Long findPostVoteOptionId(Long battleId, Long userId) { - return voteRepository.findByBattleIdAndUserId(battleId, userId) + return battleVoteRepository.findByBattleIdAndUserId(battleId, userId) .map(vote -> vote.getPostVoteOption() != null ? vote.getPostVoteOption().getId() : null) .orElse(null); } @@ -63,21 +62,26 @@ public Long findPostVoteOptionId(Long battleId, Long userId) { public VoteStatsResponse getVoteStats(Long battleId) { Battle battle = battleService.findById(battleId); List options = battleOptionRepository.findByBattle(battle); - long totalCount = voteRepository.countByBattle(battle); + long totalCount = battleVoteRepository.countByBattle(battle); List stats = options.stream() .map(option -> { - long count = voteRepository.countByBattleAndPreVoteOption(battle, option); + long count = battleVoteRepository.countByBattleAndPreVoteOption(battle, option); double ratio = totalCount > 0 ? Math.round((double) count / totalCount * 1000.0) / 10.0 : 0.0; return new VoteStatsResponse.OptionStat( - option.getId(), option.getLabel().name(), option.getTitle(), count, ratio); + option.getId(), + option.getLabel().name(), + option.getTitle(), + count, + ratio + ); }) .toList(); - LocalDateTime updatedAt = voteRepository.findTopByBattleOrderByUpdatedAtDesc(battle) - .map(Vote::getUpdatedAt) + LocalDateTime updatedAt = battleVoteRepository.findTopByBattleOrderByUpdatedAtDesc(battle) + .map(BattleVote::getUpdatedAt) .orElse(null); return VoteConverter.toVoteStatsResponse(stats, totalCount, updatedAt); @@ -89,7 +93,7 @@ public MyVoteResponse getMyVote(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUser(battle, user) + BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); @@ -99,37 +103,32 @@ public MyVoteResponse getMyVote(Long battleId, Long userId) { @Override @Transactional public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest request) { - // 1. 기본 정보 조회 (배틀, 유저, 선택한 옵션) Battle battle = battleService.findById(battleId); User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); BattleOption option = battleOptionRepository.findById(request.optionId()) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - // 2. 기존 투표 여부 확인 (에러 대신 Optional로 받음) - Optional existingVote = voteRepository.findByBattleAndUser(battle, user); - Vote vote; + Optional existingVote = battleVoteRepository.findByBattleAndUser(battle, user); + BattleVote vote; if (existingVote.isPresent()) { vote = existingVote.get(); vote.updatePreVote(option); } else { - vote = Vote.createPreVote(user, battle, option); - voteRepository.save(vote); + vote = BattleVote.createPreVote(user, battle, option); + battleVoteRepository.save(vote); battle.addParticipant(); } - // 3. 현재 유저의 진행 단계 확인 UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); - - // 4. 단계 업데이트 (처음 참여하는 경우에만 단계를 PRE_VOTE로 변경) - // 이미 POST_VOTE나 COMPLETED라면 단계를 강제로 낮추지 않음 if (status.step() == UserBattleStep.NONE) { userBattleService.upsertStep(user, battle, UserBattleStep.PRE_VOTE); } - // 5. 현재 유지 중인 단계를 반환 (수정 후에도 COMPLETED 유지 가능) - UserBattleStep currentStep = (status.step() == UserBattleStep.NONE) ? UserBattleStep.PRE_VOTE : status.step(); + UserBattleStep currentStep = status.step() == UserBattleStep.NONE + ? UserBattleStep.PRE_VOTE + : status.step(); return new VoteResultResponse(vote.getId(), currentStep); } @@ -142,19 +141,15 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque BattleOption option = battleOptionRepository.findById(request.optionId()) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUser(battle, user) + BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); - // [검증] 사전 투표를 완료한 상태(혹은 오디오 청취 완료 상태)인지 확인 UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); if (status.step() == UserBattleStep.NONE) { throw new CustomException(ErrorCode.PRE_VOTE_REQUIRED); } - // 1. 사후 투표 업데이트 vote.doPostVote(option); - - // 2. 최종 완료 단계(COMPLETED)로 업데이트 userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); return new VoteResultResponse(vote.getId(), UserBattleStep.COMPLETED); @@ -163,23 +158,14 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque @Override @Transactional public void deleteVotesByBattleId(Long battleId) { - // 1. 배틀 조회 Battle battle = battleService.findById(battleId); + List votes = battleVoteRepository.findAllByBattle(battle); - // 2. 해당 배틀의 모든 투표 조회 - List votes = voteRepository.findAllByBattle(battle); - - for (Vote vote : votes) { - // 3. 유저의 진행 단계 초기화 (이건 유저별로 다 해줘야 함) + for (BattleVote vote : votes) { userBattleService.upsertStep(vote.getUser(), battle, UserBattleStep.NONE); - - // 4. 옵션별 카운트 감소 (필요 시) - if (vote.getPreVoteOption() != null) { /* 감소 로직 */ } - if (vote.getPostVoteOption() != null) { /* 감소 로직 */ } } - // 5. 투표 데이터 일괄 삭제 - voteRepository.deleteAllInBatch(votes); + battleVoteRepository.deleteAllInBatch(votes); } @Override @@ -189,12 +175,10 @@ public void completeTts(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - // 1. 엔티티 상태 변경 (isTtsListened = true) - Vote vote = voteRepository.findByBattleAndUser(battle, user) + BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); vote.completeTts(); - // 2. 단계를 POST_VOTE(사후 투표 가능 단계)로 업데이트 userBattleService.upsertStep(user, battle, UserBattleStep.POST_VOTE); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java new file mode 100644 index 00000000..55fd9163 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.vote.dto.request.PollVoteRequest; +import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; + +public interface PollVoteService { + PollVoteResponse submitPoll(Long battleId, Long userId, PollVoteRequest request); + PollVoteResponse getMyPollVote(Long battleId, Long userId); + void deletePollVoteByBattleId(Long battleId); +} diff --git a/src/main/java/com/swyp/picke/domain/vote/service/PollVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/PollVoteServiceImpl.java new file mode 100644 index 00000000..49af6548 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/vote/service/PollVoteServiceImpl.java @@ -0,0 +1,140 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.repository.PollOptionRepository; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.vote.dto.request.PollVoteRequest; +import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; +import com.swyp.picke.domain.vote.entity.PollVote; +import com.swyp.picke.domain.vote.repository.PollVoteRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PollVoteServiceImpl implements PollVoteService { + + private final PollService pollService; + private final PollOptionRepository pollOptionRepository; + private final PollVoteRepository pollVoteRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public PollVoteResponse submitPoll(Long battleId, Long userId, PollVoteRequest request) { + Long pollId = battleId; + Poll poll = pollService.findById(pollId); + + PollOption selectedOption = pollOptionRepository.findById(request.optionId()) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + + if (!selectedOption.getPoll().getId().equals(poll.getId())) { + throw new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND); + } + + PollVote pollVote = saveOrUpdate(poll, userId, selectedOption); + long totalCount = poll.getTotalParticipantsCount() == null ? 0L : poll.getTotalParticipantsCount(); + + return new PollVoteResponse( + pollId, + pollVote.getSelectedOption().getId(), + totalCount, + buildStats(poll, totalCount, true) + ); + } + + @Override + public PollVoteResponse getMyPollVote(Long battleId, Long userId) { + Long pollId = battleId; + Poll poll = pollService.findById(pollId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + long totalCount = poll.getTotalParticipantsCount() == null ? 0L : poll.getTotalParticipantsCount(); + + return pollVoteRepository.findByPollAndUser(poll, user) + .map(pollVote -> new PollVoteResponse( + pollId, + pollVote.getSelectedOption().getId(), + totalCount, + buildStats(poll, totalCount, true) + )) + .orElseGet(() -> new PollVoteResponse( + pollId, + null, + totalCount, + buildStats(poll, totalCount, false) + )); + } + + @Override + @Transactional + public void deletePollVoteByBattleId(Long battleId) { + Long pollId = battleId; + Poll poll = pollService.findById(pollId); + + List votes = pollVoteRepository.findAllByPoll(poll); + for (PollVote pollVote : votes) { + poll.decreaseTotalParticipantsCount(); + if (pollVote.getSelectedOption() != null) { + pollVote.getSelectedOption().decreaseVoteCount(); + } + } + pollVoteRepository.deleteAllInBatch(votes); + } + + private PollVote saveOrUpdate(Poll poll, Long userId, PollOption selectedOption) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + return pollVoteRepository.findByPollAndUser(poll, user) + .map(pollVote -> { + if (!pollVote.getSelectedOption().equals(selectedOption)) { + pollVote.getSelectedOption().decreaseVoteCount(); + selectedOption.increaseVoteCount(); + pollVote.updateOption(selectedOption); + } + return pollVote; + }) + .orElseGet(() -> { + selectedOption.increaseVoteCount(); + poll.increaseTotalParticipantsCount(); + return pollVoteRepository.save( + PollVote.builder() + .user(user) + .poll(poll) + .selectedOption(selectedOption) + .build() + ); + }); + } + + private List buildStats(Poll poll, long totalCount, boolean revealCounts) { + return pollOptionRepository.findByPollOrderByDisplayOrderAscLabelAscIdAsc(poll).stream() + .map(option -> { + long count = revealCounts ? (option.getVoteCount() == null ? 0L : option.getVoteCount()) : 0L; + double ratio = (!revealCounts || totalCount == 0) + ? 0.0 + : Math.round((double) count / totalCount * 1000) / 10.0; + + return new PollVoteResponse.OptionStat( + option.getId(), + option.getLabel().name(), + option.getTitle(), + count, + ratio + ); + }) + .toList(); + } +} + diff --git a/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteService.java index 52e34ba6..57963d10 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteService.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteService.java @@ -1,13 +1,11 @@ package com.swyp.picke.domain.vote.service; import com.swyp.picke.domain.vote.dto.request.QuizVoteRequest; -import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; import com.swyp.picke.domain.vote.dto.response.QuizVoteResponse; public interface QuizVoteService { QuizVoteResponse submitQuiz(Long battleId, Long userId, QuizVoteRequest request); - PollVoteResponse submitPoll(Long battleId, Long userId, QuizVoteRequest request); QuizVoteResponse getMyQuizVote(Long battleId, Long userId); - PollVoteResponse getMyPollVote(Long battleId, Long userId); void deleteQuizVoteByBattleId(Long battleId); } + diff --git a/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImpl.java index 322b1838..0960705d 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/QuizVoteServiceImpl.java @@ -1,190 +1,143 @@ package com.swyp.picke.domain.vote.service; -import com.swyp.picke.domain.battle.entity.Battle; -import com.swyp.picke.domain.battle.entity.BattleOption; -import com.swyp.picke.domain.battle.repository.BattleOptionRepository; -import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.repository.QuizOptionRepository; +import com.swyp.picke.domain.quiz.service.QuizService; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.vote.dto.request.QuizVoteRequest; -import com.swyp.picke.domain.vote.dto.response.PollVoteResponse; import com.swyp.picke.domain.vote.dto.response.QuizVoteResponse; import com.swyp.picke.domain.vote.entity.QuizVote; import com.swyp.picke.domain.vote.repository.QuizVoteRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class QuizVoteServiceImpl implements QuizVoteService { + private final QuizService quizService; + private final QuizOptionRepository quizOptionRepository; private final QuizVoteRepository quizVoteRepository; - private final BattleService battleService; - private final BattleOptionRepository battleOptionRepository; private final UserRepository userRepository; @Override @Transactional public QuizVoteResponse submitQuiz(Long battleId, Long userId, QuizVoteRequest request) { - Battle battle = battleService.findById(battleId); - if (!"QUIZ".equals(battle.getType().name())) { - throw new CustomException(ErrorCode.BATTLE_NOT_QUIZ); - } - - QuizVote v = saveOrUpdate(battle, userId, request.optionId()); - long totalCount = quizVoteRepository.countByBattle(v.getBattle()); + Long quizId = battleId; + Quiz quiz = quizService.findById(quizId); - return new QuizVoteResponse( - battleId, - v.getSelectedOption().getId(), - totalCount, - calcStats(v.getBattle(), totalCount) - ); - } + QuizOption selectedOption = quizOptionRepository.findById(request.optionId()) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - @Override - @Transactional - public PollVoteResponse submitPoll(Long battleId, Long userId, QuizVoteRequest request) { - Battle battle = battleService.findById(battleId); - if (!"VOTE".equals(battle.getType().name())) { - throw new CustomException(ErrorCode.BATTLE_NOT_POLL); + if (!selectedOption.getQuiz().getId().equals(quiz.getId())) { + throw new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND); } - QuizVote v = saveOrUpdate(battle, userId, request.optionId()); - long totalCount = quizVoteRepository.countByBattle(v.getBattle()); + QuizVote quizVote = saveOrUpdate(quiz, userId, selectedOption); + long totalCount = quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); - return new PollVoteResponse( - battleId, - v.getSelectedOption().getId(), + return new QuizVoteResponse( + quizId, + quizVote.getSelectedOption().getId(), totalCount, - calcStats(v.getBattle(), totalCount).stream() - .map(s -> new PollVoteResponse.OptionStat(s.optionId(), s.label(), s.title(), s.voteCount(), s.ratio())) - .toList() + buildStats(quiz, totalCount, true, true) ); } @Override public QuizVoteResponse getMyQuizVote(Long battleId, Long userId) { - Battle battle = battleService.findById(battleId); - if (!"QUIZ".equals(battle.getType().name())) { - throw new CustomException(ErrorCode.BATTLE_NOT_QUIZ); - } + Long quizId = battleId; + Quiz quiz = quizService.findById(quizId); User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - long totalCount = quizVoteRepository.countByBattle(battle); + long totalCount = quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); - return quizVoteRepository.findByBattleAndUser(battle, user) - .map(v -> new QuizVoteResponse( - battleId, - v.getSelectedOption().getId(), + return quizVoteRepository.findByQuizAndUser(quiz, user) + .map(quizVote -> new QuizVoteResponse( + quizId, + quizVote.getSelectedOption().getId(), totalCount, - calcStats(battle, totalCount) + buildStats(quiz, totalCount, true, true) )) - .orElseGet(() -> { - // [투표 전] 전체 참여자 수(totalCount)는 보여주되, 개별 통계(voteCount, ratio)는 0으로 숨김 - List blindStats = battleOptionRepository.findByBattle(battle).stream() - .map(o -> new QuizVoteResponse.OptionStat(o.getId(), o.getLabel().name(), o.getTitle(), o.getIsCorrect(), 0L, 0.0)) - .toList(); - return new QuizVoteResponse(battleId, null, totalCount, blindStats); - }); + .orElseGet(() -> new QuizVoteResponse( + quizId, + null, + totalCount, + buildStats(quiz, totalCount, false, false) + )); } @Override - public PollVoteResponse getMyPollVote(Long battleId, Long userId) { - Battle battle = battleService.findById(battleId); - if (!"VOTE".equals(battle.getType().name())) { - throw new CustomException(ErrorCode.BATTLE_NOT_POLL); - } - - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - long totalCount = quizVoteRepository.countByBattle(battle); - - return quizVoteRepository.findByBattleAndUser(battle, user) - .map(v -> { - List stats = calcStats(battle, totalCount).stream() - .map(s -> new PollVoteResponse.OptionStat(s.optionId(), s.label(), s.title(), s.voteCount(), s.ratio())) - .toList(); - - return new PollVoteResponse( - battleId, - v.getSelectedOption().getId(), - totalCount, - stats - ); - }) - .orElseGet(() -> { - // [투표 전] 전체 참여자 수(totalCount)는 보여주되, 개별 통계(voteCount, ratio)는 0으로 숨김 - List blindStats = battleOptionRepository.findByBattle(battle).stream() - .map(o -> new PollVoteResponse.OptionStat(o.getId(), o.getLabel().name(), o.getTitle(), 0L, 0.0)) - .toList(); - return new PollVoteResponse(battleId, null, totalCount, blindStats); - }); - } - @Transactional public void deleteQuizVoteByBattleId(Long battleId) { - // 배틀 확인 - Battle battle = battleService.findById(battleId); - - // 해당 배틀의 모든 투표 조회 - List votes = quizVoteRepository.findAllByBattle(battle); + Long quizId = battleId; + Quiz quiz = quizService.findById(quizId); - // 투표수 감소 (배틀 옵션에 반영) - for (QuizVote v : votes) { - if (v.getSelectedOption() != null) { - v.getSelectedOption().decreaseVoteCount(); - } + List votes = quizVoteRepository.findAllByQuiz(quiz); + for (QuizVote ignored : votes) { + quiz.decreaseTotalParticipantsCount(); } quizVoteRepository.deleteAllInBatch(votes); } - private QuizVote saveOrUpdate(Battle battle, Long userId, Long optionId) { + private QuizVote saveOrUpdate(Quiz quiz, Long userId, QuizOption selectedOption) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - BattleOption newOption = battleOptionRepository.findById(optionId) - .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - return quizVoteRepository.findByBattleAndUser(battle, user) - .map(v -> { - // 옵션을 바꾼다면 기존 옵션 -1, 새 옵션 +1 - if (!v.getSelectedOption().equals(newOption)) { - v.getSelectedOption().decreaseVoteCount(); - newOption.increaseVoteCount(); - v.updateOption(newOption); + return quizVoteRepository.findByQuizAndUser(quiz, user) + .map(quizVote -> { + if (!quizVote.getSelectedOption().equals(selectedOption)) { + quizVote.updateOption(selectedOption); } - return v; + return quizVote; }) .orElseGet(() -> { - // 처음 투표한다면 새 옵션 +1 - battle.addParticipant(); - newOption.increaseVoteCount(); + quiz.increaseTotalParticipantsCount(); return quizVoteRepository.save( - QuizVote.builder().user(user).battle(battle).selectedOption(newOption).build()); + QuizVote.builder() + .user(user) + .quiz(quiz) + .selectedOption(selectedOption) + .build() + ); }); - } + } - private List calcStats(Battle battle, long totalCount) { - return battleOptionRepository.findByBattle(battle).stream().map(o -> { - long count = (o.getVoteCount() == null) ? 0L : o.getVoteCount(); - double ratio = totalCount == 0 ? 0.0 : Math.round((double) count / totalCount * 1000) / 10.0; - return new QuizVoteResponse.OptionStat( - o.getId(), - o.getLabel().name(), - o.getTitle(), - o.getIsCorrect(), - count, - ratio - ); - }).toList(); + private List buildStats( + Quiz quiz, + long totalCount, + boolean revealCorrect, + boolean revealCounts + ) { + return quizOptionRepository.findByQuizOrderByDisplayOrderAscLabelAscIdAsc(quiz).stream() + .map(option -> { + long voteCount = revealCounts + ? quizVoteRepository.countByQuizAndSelectedOption(quiz, option) + : 0L; + + double ratio = (!revealCounts || totalCount == 0) + ? 0.0 + : Math.round((double) voteCount / totalCount * 1000) / 10.0; + + return new QuizVoteResponse.OptionStat( + option.getId(), + option.getLabel().name(), + option.getText(), + revealCorrect ? option.getIsCorrect() : null, + voteCount, + ratio, + option.getDetailText() + ); + }) + .toList(); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java index fab804f0..5401ef8a 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java @@ -2,49 +2,51 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.vote.entity.Vote; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class VoteQueryService { - private final VoteRepository voteRepository; + private final BattleVoteRepository battleVoteRepository; - public List findUserVotes(Long userId, int offset, int size, BattleOptionLabel label) { + public List findUserVotes(Long userId, int offset, int size, BattleOptionLabel label) { PageRequest pageable = PageRequest.of(offset / size, size); return label != null - ? voteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(userId, label, pageable) - : voteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + ? battleVoteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(userId, label, pageable) + : battleVoteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); } public long countUserVotes(Long userId, BattleOptionLabel label) { return label != null - ? voteRepository.countByUserIdAndPreVoteOptionLabel(userId, label) - : voteRepository.countByUserId(userId); + ? battleVoteRepository.countByUserIdAndPreVoteOptionLabel(userId, label) + : battleVoteRepository.countByUserId(userId); } public long countTotalParticipation(Long userId) { - return voteRepository.countByUserId(userId); + return battleVoteRepository.countByUserId(userId); } public long countOpinionChanges(Long userId) { - return voteRepository.countOpinionChangesByUserId(userId); + return battleVoteRepository.countOpinionChangesByUserId(userId); } public int calculateBattleWinRate(Long userId) { - List postVotes = voteRepository.findByUserId(userId).stream() + List postVotes = battleVoteRepository.findByUserId(userId).stream() .filter(v -> v.getPostVoteOption() != null) .toList(); - if (postVotes.isEmpty()) return 0; + if (postVotes.isEmpty()) { + return 0; + } long wins = postVotes.stream() .filter(v -> { @@ -62,27 +64,31 @@ public int calculateBattleWinRate(Long userId) { } public List findParticipatedBattleIds(Long userId) { - return voteRepository.findByUserId(userId).stream() + return battleVoteRepository.findByUserId(userId).stream() .map(v -> v.getBattle().getId()) .distinct() .toList(); } public List findFirstNBattleIds(Long userId, int n) { - return voteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + return battleVoteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() .map(v -> v.getBattle().getId()) .distinct() .toList(); } public List findFirstNVotedOptionIds(Long userId, int n) { - return voteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + return battleVoteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() .map(v -> { - if (v.getPostVoteOption() != null) return v.getPostVoteOption().getId(); - if (v.getPreVoteOption() != null) return v.getPreVoteOption().getId(); + if (v.getPostVoteOption() != null) { + return v.getPostVoteOption().getId(); + } + if (v.getPreVoteOption() != null) { + return v.getPreVoteOption().getId(); + } return null; }) - .filter(java.util.Objects::nonNull) + .filter(Objects::nonNull) .distinct() .toList(); } From e7767b0bd78bcceecd034ffbd7ab804e4b8ea345 Mon Sep 17 00:00:00 2001 From: jucheonsu Date: Fri, 10 Apr 2026 18:25:02 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Fix]=20=ED=99=88/=EC=A1=B0=ED=9A=8C/?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A0=AC/=EC=A7=91=EA=B3=84=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/controller/HomeController.java | 4 +- .../domain/home/service/HomeService.java | 215 ++++++++++-------- .../oauth/controller/AuthController.java | 2 +- .../controller/CommentLikeController.java | 4 +- .../PerspectiveCommentController.java | 6 +- .../controller/PerspectiveController.java | 13 +- .../controller/PerspectiveLikeController.java | 4 +- .../controller/ReportController.java | 2 +- .../service/PerspectiveCommentService.java | 12 +- .../service/PerspectiveService.java | 8 +- .../controller/RecommendationController.java | 4 +- .../service/RecommendationService.java | 12 +- .../controller/AdMobRewardController.java | 4 +- .../response/SearchBattleListResponse.java | 2 - .../domain/search/service/SearchService.java | 1 - .../test/controller/TestController.java | 34 --- .../domain/user/service/MypageService.java | 20 +- .../domain/user/service/UserService.java | 4 +- 18 files changed, 172 insertions(+), 179 deletions(-) delete mode 100644 src/main/java/com/swyp/picke/domain/test/controller/TestController.java diff --git a/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java b/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java index 2cfddac7..e466fb6a 100644 --- a/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java +++ b/src/main/java/com/swyp/picke/domain/home/controller/HomeController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "홈 API", description = "홈 화면 집계 조회") +@Tag(name = "홈 API", description = "홈 화면 데이터 조회") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -19,7 +19,7 @@ public class HomeController { private final HomeService homeService; - @Operation(summary = "홈 화면 집계 조회") + @Operation(summary = "홈 화면 데이터 조회") @GetMapping("/home") public ApiResponse getHome(@AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(homeService.getHome(userId)); diff --git a/src/main/java/com/swyp/picke/domain/home/service/HomeService.java b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java index 6aa4f55b..4d3082cd 100644 --- a/src/main/java/com/swyp/picke/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/picke/domain/home/service/HomeService.java @@ -1,30 +1,47 @@ package com.swyp.picke.domain.home.service; -import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; import com.swyp.picke.domain.battle.dto.response.TodayBattleResponse; import com.swyp.picke.domain.battle.dto.response.TodayOptionResponse; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -import com.swyp.picke.domain.battle.enums.BattleType; -import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.battle.service.BattleService; -import com.swyp.picke.domain.home.dto.response.*; +import com.swyp.picke.domain.home.dto.response.HomeBestBattleResponse; +import com.swyp.picke.domain.home.dto.response.HomeEditorPickResponse; +import com.swyp.picke.domain.home.dto.response.HomeNewBattleResponse; +import com.swyp.picke.domain.home.dto.response.HomeResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayQuizResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayVoteOptionResponse; +import com.swyp.picke.domain.home.dto.response.HomeTodayVoteResponse; +import com.swyp.picke.domain.home.dto.response.HomeTrendingResponse; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.domain.poll.entity.Poll; +import com.swyp.picke.domain.poll.entity.PollOption; +import com.swyp.picke.domain.poll.service.PollService; +import com.swyp.picke.domain.quiz.entity.Quiz; +import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.quiz.enums.QuizOptionLabel; +import com.swyp.picke.domain.quiz.service.QuizService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class HomeService { + private static final int HOME_TODAY_PICK_LIMIT = 1; + private static final String QUIZ_SUMMARY = "왼쪽과 오른쪽 중 정답을 선택하세요"; + private static final String POLL_SUMMARY = "빈칸에 들어갈 가장 적절한 답을 골라주세요"; + private final BattleService battleService; + private final QuizService quizService; + private final PollService pollService; private final NotificationService notificationService; private final S3PresignedUrlService s3PresignedUrlService; @@ -33,15 +50,15 @@ public HomeResponse getHome(Long userId) { if (userId != null) { newNotice = notificationService.hasNewBroadcast(userId, NotificationCategory.NOTICE); } - // DB 쿼리 단계에서 LIMIT을 걸어 필요한 개수만 깔끔하게 조회! - List editorPickRaw = battleService.getEditorPicks(10); - List trendingRaw = battleService.getTrendingBattles(4); - List bestRaw = battleService.getBestBattles(3); - List voteRaw = battleService.getTodayPicks(BattleType.VOTE, 1); - List quizRaw = battleService.getTodayPicks(BattleType.QUIZ, 1); - List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); - List newRaw = battleService.getNewBattles(excludeIds, 3); + List editorPickRaw = battleService.getEditorPicks(); + List trendingRaw = battleService.getTrendingBattles(); + List bestRaw = battleService.getBestBattles(); + List quizRaw = quizService.getTodayPicks(HOME_TODAY_PICK_LIMIT); + List pollRaw = pollService.getTodayPicks(HOME_TODAY_PICK_LIMIT); + + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw); + List newRaw = battleService.getNewBattles(excludeIds); return new HomeResponse( newNotice, @@ -49,131 +66,145 @@ public HomeResponse getHome(Long userId) { trendingRaw.stream().map(this::toTrending).toList(), bestRaw.stream().map(this::toBestBattle).toList(), quizRaw.stream().map(this::toTodayQuiz).toList(), - voteRaw.stream().map(this::toTodayVote).toList(), + pollRaw.stream().map(this::toTodayVote).toList(), newRaw.stream().map(this::toNewBattle).toList() ); } - // 에디터픽 썸네일 Presigned URL 적용 - private HomeEditorPickResponse toEditorPick(TodayBattleResponse b) { - String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); - String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); - - String secureThumb = b.thumbnailUrl(); - + private HomeEditorPickResponse toEditorPick(TodayBattleResponse battle) { return new HomeEditorPickResponse( - b.battleId(), secureThumb, - optionA, optionB, - b.title(), b.summary(), - b.tags(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + findOptionTitle(battle.options(), BattleOptionLabel.A), + findOptionTitle(battle.options(), BattleOptionLabel.B), + battle.title(), + battle.summary(), + battle.tags(), + battle.viewCount() ); } - private HomeTrendingResponse toTrending(TodayBattleResponse b) { + private HomeTrendingResponse toTrending(TodayBattleResponse battle) { return new HomeTrendingResponse( - b.battleId(), b.thumbnailUrl(), - b.title(), b.tags(), - b.audioDuration(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { - String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); - String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); - + private HomeBestBattleResponse toBestBattle(TodayBattleResponse battle) { return new HomeBestBattleResponse( - b.battleId(), - philoA, philoB, - b.title(), b.tags(), - b.audioDuration(), b.viewCount() + battle.battleId(), + findOptionRepresentative(battle.options(), BattleOptionLabel.A), + findOptionRepresentative(battle.options(), BattleOptionLabel.B), + battle.title(), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { + private HomeTodayQuizResponse toTodayQuiz(Quiz quiz) { + List options = quizService.getOptions(quiz); + long participantsCount = quizService.countVotes(quiz); + + QuizOption optionA = findQuizOption(options, QuizOptionLabel.A); + QuizOption optionB = findQuizOption(options, QuizOptionLabel.B); + return new HomeTodayQuizResponse( - b.battleId(), b.title(), b.summary(), - b.participantsCount(), - b.itemA(), b.itemADesc(), - findOptionIsCorrect(b.options(), BattleOptionLabel.A), - b.itemB(), b.itemBDesc(), - findOptionIsCorrect(b.options(), BattleOptionLabel.B) + quiz.getId(), + quiz.getTitle(), + QUIZ_SUMMARY, + participantsCount, + optionA != null ? optionA.getText() : null, + optionA != null ? optionA.getDetailText() : null, + false, + optionB != null ? optionB.getText() : null, + optionB != null ? optionB.getDetailText() : null, + false ); } - private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { - List options = Optional.ofNullable(b.options()).orElse(List.of()).stream() - .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) + private HomeTodayVoteResponse toTodayVote(Poll poll) { + List options = pollService.getOptions(poll); + long participantsCount = pollService.countVotes(poll); + + List homeOptions = options.stream() + .sorted(Comparator + .comparing((PollOption option) -> option.getDisplayOrder() == null ? Integer.MAX_VALUE : option.getDisplayOrder()) + .thenComparing(option -> option.getLabel() == null ? "" : option.getLabel().name()) + .thenComparing(option -> option.getId() == null ? Long.MAX_VALUE : option.getId())) + .map(option -> new HomeTodayVoteOptionResponse( + BattleOptionLabel.valueOf(option.getLabel().name()), + option.getTitle() + )) .toList(); + return new HomeTodayVoteResponse( - b.battleId(), - b.titlePrefix(), b.titleSuffix(), - b.summary(), b.participantsCount(), - options + poll.getId(), + poll.getTitlePrefix(), + poll.getTitleSuffix(), + POLL_SUMMARY, + participantsCount, + homeOptions ); } - // newBattle 썸네일 Presigned URL 적용 - private HomeNewBattleResponse toNewBattle(TodayBattleResponse b) { - String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); - String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); - - String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); - String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); - - String imageA = findRepresentativeImageUrl(b.options(), BattleOptionLabel.A); - String imageB = findRepresentativeImageUrl(b.options(), BattleOptionLabel.B); - + private HomeNewBattleResponse toNewBattle(TodayBattleResponse battle) { return new HomeNewBattleResponse( - b.battleId(), b.thumbnailUrl(), - b.title(), b.summary(), - philoA, optionA, imageA, - philoB, optionB, imageB, - b.tags(), b.audioDuration(), b.viewCount() + battle.battleId(), + battle.thumbnailUrl(), + battle.title(), + battle.summary(), + findOptionRepresentative(battle.options(), BattleOptionLabel.A), + findOptionTitle(battle.options(), BattleOptionLabel.A), + findRepresentativeImageUrl(battle.options(), BattleOptionLabel.A), + findOptionRepresentative(battle.options(), BattleOptionLabel.B), + findOptionTitle(battle.options(), BattleOptionLabel.B), + findRepresentativeImageUrl(battle.options(), BattleOptionLabel.B), + battle.tags(), + battle.audioDuration(), + battle.viewCount() ); } - private Boolean findOptionIsCorrect(List options, BattleOptionLabel label) { - return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) - .map(TodayOptionResponse::isCorrect) - .findFirst() - .map(Boolean.TRUE::equals) - .orElse(false); - } - private String findOptionTitle(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::title) .filter(Objects::nonNull) - .findFirst().orElse(null); + .findFirst() + .orElse(null); } - // 옵션에서 철학자 이름(Representative)을 추출하는 메서드 private String findOptionRepresentative(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::representative) .filter(Objects::nonNull) - .findFirst().orElse(null); - } - - private List findPhilosopherNames(List tags) { - return Optional.ofNullable(tags).orElse(List.of()).stream() - .filter(t -> t.type() == TagType.PHILOSOPHER) - .map(BattleTagResponse::name) - .toList(); + .findFirst() + .orElse(null); } private String findRepresentativeImageUrl(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(o -> o.label() == label) + .filter(option -> option.label() == label) .map(TodayOptionResponse::imageUrl) .filter(Objects::nonNull) .findFirst() .orElse(null); } + private QuizOption findQuizOption(List options, QuizOptionLabel label) { + return options.stream() + .filter(option -> option.getLabel() == label) + .findFirst() + .orElse(null); + } + @SafeVarargs private List collectBattleIds(List... groups) { return List.of(groups).stream() diff --git a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java index b7150503..0ac93e0a 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java +++ b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java @@ -17,7 +17,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "인증 (Auth)", description = "인증 API") +@Tag(name = "인증 API", description = "소셜 로그인, 토큰 재발급, 로그아웃, 회원 탈퇴") public class AuthController { private final AuthService authService; diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java index 76541533..c17eba4c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "댓글 좋아요 (Comment Like)", description = "댓글 좋아요 등록, 취소 API") +@Tag(name = "댓글 좋아요 API", description = "댓글 좋아요 등록 및 취소") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -28,7 +28,7 @@ public ApiResponse addLike(@PathVariable Long commentId, return ApiResponse.onSuccess(commentLikeService.addLike(commentId, userId)); } - @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글에 등록한 좋아요를 취소합니다.") + @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글의 좋아요를 취소합니다.") @DeleteMapping("/comments/{commentId}/likes") public ApiResponse removeLike(@PathVariable Long commentId, @AuthenticationPrincipal Long userId) { diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java index d702d8aa..728a7fea 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveCommentController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 댓글 (Comment)", description = "관점 댓글 생성, 조회, 수정, 삭제 API") +@Tag(name = "관점 댓글 API", description = "관점 댓글 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -51,7 +51,7 @@ public ApiResponse getComments( return ApiResponse.onSuccess(commentService.getComments(perspectiveId, userId, cursor, size)); } - @Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회합니다. stance는 투표한 옵션의 라벨(A/B)로 반환됩니다.") + @Operation(summary = "댓글 목록 조회 (옵션 라벨)", description = "특정 관점의 댓글 목록을 커서 기반 페이지네이션으로 조회하며, stance를 투표한 옵션 라벨(A/B)로 반환합니다.") @GetMapping("/perspectives/{perspectiveId}/comments/labeled") public ApiResponse getCommentsWithLabel( @PathVariable Long perspectiveId, @@ -73,7 +73,7 @@ public ApiResponse deleteComment( return ApiResponse.onSuccess(null); } - @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글의 내용을 수정합니다.") + @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글 내용을 수정합니다.") @PatchMapping("/perspectives/{perspectiveId}/comments/{commentId}") public ApiResponse updateComment( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 545f8146..03c9aa3f 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 (Perspective)", description = "관점 생성, 조회, 수정, 삭제 API") +@Tag(name = "관점 API", description = "관점 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -32,7 +32,7 @@ public class PerspectiveController { private final PerspectiveService perspectiveService; - @Operation(summary = "관점 단건 조회", description = "특정 관점의 상세 정보를 조회합니다.") + @Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.") @GetMapping("/perspectives/{perspectiveId}") public ApiResponse getPerspectiveDetail( @PathVariable Long perspectiveId, @@ -40,8 +40,7 @@ public ApiResponse getPerspectiveDetail( return ApiResponse.onSuccess(perspectiveService.getPerspectiveDetail(perspectiveId, userId)); } - // TODO: Prevote 의 여부를 Vote 도메인 개발 이후 교체 - @Operation(summary = "관점 생성", description = "특정 배틀에 대한 관점을 생성합니다. 사전 투표가 완료된 경우에만 가능합니다.") + @Operation(summary = "관점 생성", description = "특정 배틀에 대한 사용자 관점을 생성합니다.") @PostMapping("/battles/{battleId}/perspectives") public ApiResponse createPerspective( @PathVariable Long battleId, @@ -51,7 +50,7 @@ public ApiResponse createPerspective( return ApiResponse.onSuccess(perspectiveService.createPerspective(battleId, userId, request)); } - @Operation(summary = "관점 리스트 조회", description = "특정 배틀의 관점 목록을 커서 기반 페이지네이션으로 조회합니다. optionLabel(A/B)로 필터링, sort(latest/popular)로 정렬 가능합니다.") + @Operation(summary = "관점 목록 조회", description = "특정 배틀의 관점 목록을 커서 기반으로 조회합니다.") @GetMapping("/battles/{battleId}/perspectives") public ApiResponse getPerspectives( @PathVariable Long battleId, @@ -64,7 +63,7 @@ public ApiResponse getPerspectives( return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel, sort)); } - @Operation(summary = "내 관점 조회", description = "특정 배틀에서 내가 작성한 관점을 조회합니다. 상태(PENDING/PUBLISHED/REJECTED 등)와 무관하게 반환하며, 작성한 관점이 없으면 404를 반환합니다.") + @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") @GetMapping("/battles/{battleId}/perspectives/me") public ApiResponse getMyPerspective( @PathVariable Long battleId, @@ -81,7 +80,7 @@ public ApiResponse deletePerspective( return ApiResponse.onSuccess(null); } - @Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") + @Operation(summary = "관점 검수 재요청", description = "검수 실패 상태의 관점에 대해 검수를 다시 요청합니다.") @PostMapping("/perspectives/{perspectiveId}/moderation/retry") public ApiResponse retryModeration( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java index 75a6a1b4..7e090575 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "관점 좋아요 (Like)", description = "관점 좋아요 조회, 등록, 취소 API") +@Tag(name = "관점 좋아요 API", description = "관점 좋아요 조회, 등록, 취소") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -37,7 +37,7 @@ public ApiResponse addLike( return ApiResponse.onSuccess(likeService.addLike(perspectiveId, userId)); } - @Operation(summary = "좋아요 취소", description = "특정 관점에 등록한 좋아요를 취소합니다.") + @Operation(summary = "좋아요 취소", description = "특정 관점의 좋아요를 취소합니다.") @DeleteMapping("/perspectives/{perspectiveId}/likes") public ApiResponse removeLike( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java index 438cc00f..eb227348 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "신고 (Report)", description = "관점/댓글 신고 API") +@Tag(name = "신고 API", description = "관점/댓글 신고") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java index ac225705..c7808893 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveCommentService.java @@ -17,7 +17,7 @@ import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.service.UserService; -import com.swyp.picke.domain.vote.service.VoteService; +import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; @@ -41,7 +41,7 @@ public class PerspectiveCommentService { private final UserRepository userRepository; private final CommentLikeRepository commentLikeRepository; private final UserService userQueryService; - private final VoteService voteService; + private final BattleVoteService BattleVoteService; private final BattleService battleService; private final S3PresignedUrlService s3PresignedUrlService; @@ -62,7 +62,7 @@ public CreateCommentResponse createComment(Long perspectiveId, Long userId, Crea UserSummary userSummary = userQueryService.findSummaryById(userId); String characterImageUrl = resolveCharacterImageUrl(userSummary.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); String stance = null; if (postVoteOptionId != null) { stance = battleService.findOptionById(postVoteOptionId).getStance(); @@ -96,7 +96,7 @@ public CommentListResponse getComments(Long perspectiveId, Long userId, String c .map(c -> { UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); String stance = null; if (postVoteOptionId != null) { BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -140,7 +140,7 @@ public CommentListResponse getCommentsWithLabel(Long perspectiveId, Long userId, .map(c -> { UserSummary user = userQueryService.findSummaryById(c.getUser().getId()); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - Long postVoteOptionId = voteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); String stance = null; if (postVoteOptionId != null) { BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -209,4 +209,4 @@ private String resolveCharacterImageUrl(String characterType) { } return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index e366aa63..ed8d596c 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -21,7 +21,7 @@ import com.swyp.picke.domain.user.dto.response.UserSummary; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.service.UserService; -import com.swyp.picke.domain.vote.service.VoteService; +import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; @@ -44,7 +44,7 @@ public class PerspectiveService { private final PerspectiveCommentRepository perspectiveCommentRepository; private final PerspectiveLikeRepository perspectiveLikeRepository; private final BattleService battleService; - private final VoteService voteService; + private final BattleVoteService BattleVoteService; private final UserService userQueryService; private final UserRepository userRepository; private final GptModerationService gptModerationService; @@ -82,7 +82,7 @@ public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, C throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); } - BattleOption option = voteService.findPreVoteOption(battleId, userId); + BattleOption option = BattleVoteService.findPreVoteOption(battleId, userId); Perspective perspective = Perspective.builder() .battle(battle) @@ -217,4 +217,4 @@ private String resolveCharacterImageUrl(String characterType) { } return s3PresignedUrlService.generatePresignedUrl(CharacterType.resolveImageKey(characterType)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java index c05a07c7..45dad51d 100644 --- a/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "추천 (Recommendation)", description = "배틀 추천 API") +@Tag(name = "추천 API", description = "배틀 추천 조회") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -20,7 +20,7 @@ public class RecommendationController { private final RecommendationService recommendationService; - @Operation(summary = "흥미 기반 배틀 추천 조회", description = "특정 배틀 기반으로 흥미로운 배틀 목록을 추천합니다.") + @Operation(summary = "흥미 기반 배틀 추천 조회", description = "특정 배틀을 기준으로 흥미로운 배틀 목록을 추천합니다.") @GetMapping("/battles/{battleId}/recommendations/interesting") public ApiResponse getInterestingBattles( @PathVariable Long battleId, diff --git a/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java b/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java index 1a37f32f..00d3bb86 100644 --- a/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/swyp/picke/domain/recommendation/service/RecommendationService.java @@ -13,7 +13,7 @@ import com.swyp.picke.domain.user.service.UserService; import com.swyp.picke.global.infra.s3.enums.FileCategory; import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; -import com.swyp.picke.domain.vote.repository.VoteRepository; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -35,7 +35,7 @@ public class RecommendationService { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; private final BattleOptionTagRepository battleOptionTagRepository; - private final VoteRepository voteRepository; + private final BattleVoteRepository BattleVoteRepository; private final UserService userService; private final ResourceUrlProvider urlProvider; @@ -47,7 +47,7 @@ public RecommendationListResponse getInterestingBattles(Long battleId, Long user PhilosopherType oppositeType = myType.getWorstMatch(); // 현재 유저가 이미 참여한 배틀 ID 목록 (제외 대상) - List excludeBattleIds = voteRepository.findParticipatedBattleIdsByUserId(userId); + List excludeBattleIds = BattleVoteRepository.findParticipatedBattleIdsByUserId(userId); if (excludeBattleIds.isEmpty()) excludeBattleIds = List.of(-1L); List sameTypeUserIds = findUserIdsByPhilosopherType(myType); @@ -56,12 +56,12 @@ public RecommendationListResponse getInterestingBattles(Long battleId, Long user // 같은 유형 유저들이 참여한 배틀 후보 ID List sameCandidateIds = sameTypeUserIds.isEmpty() ? List.of() - : voteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); // 반대 유형 유저들이 참여한 배틀 후보 ID List oppositeCandidateIds = oppositeTypeUserIds.isEmpty() ? List.of() - : voteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); + : BattleVoteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); // 인기 점수 기준 배틀 조회 (Score = V*1.0 + C*1.5 + Vw*0.2) // 철학자 유형 로직 미구현 시 인기 배틀로 폴백 @@ -130,4 +130,4 @@ private RecommendationListResponse.Item toItem(Battle battle) { private List findUserIdsByPhilosopherType(PhilosopherType type) { return List.of(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java index 723be0d9..71a4f239 100644 --- a/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java +++ b/src/main/java/com/swyp/picke/domain/reward/controller/AdMobRewardController.java @@ -8,14 +8,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springdoc.core.annotations.ParameterObject; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @Slf4j -@Tag(name = "보상 (Reward)", description = "AdMob 광고 보상 관련 API") +@Tag(name = "보상 API", description = "AdMob 광고 보상 관련 API") @RestController @RequestMapping("/api/v1/admob") @RequiredArgsConstructor diff --git a/src/main/java/com/swyp/picke/domain/search/dto/response/SearchBattleListResponse.java b/src/main/java/com/swyp/picke/domain/search/dto/response/SearchBattleListResponse.java index d3fef5da..9cec0289 100644 --- a/src/main/java/com/swyp/picke/domain/search/dto/response/SearchBattleListResponse.java +++ b/src/main/java/com/swyp/picke/domain/search/dto/response/SearchBattleListResponse.java @@ -1,7 +1,6 @@ package com.swyp.picke.domain.search.dto.response; import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; -import com.swyp.picke.domain.battle.enums.BattleType; import java.util.List; @@ -14,7 +13,6 @@ public record SearchBattleListResponse( public record SearchBattleItem( Long battleId, String thumbnailUrl, - BattleType type, String title, String summary, List tags, diff --git a/src/main/java/com/swyp/picke/domain/search/service/SearchService.java b/src/main/java/com/swyp/picke/domain/search/service/SearchService.java index 3d66a5b1..b309fbe7 100644 --- a/src/main/java/com/swyp/picke/domain/search/service/SearchService.java +++ b/src/main/java/com/swyp/picke/domain/search/service/SearchService.java @@ -58,7 +58,6 @@ public SearchBattleListResponse searchBattles(String category, SearchSortType so .map(battle -> new SearchBattleListResponse.SearchBattleItem( battle.getId(), urlProvider.getImageUrl(FileCategory.BATTLE, battle.getThumbnailUrl()), - battle.getType(), battle.getTitle(), battle.getSummary(), tagMap.getOrDefault(battle.getId(), List.of()), diff --git a/src/main/java/com/swyp/picke/domain/test/controller/TestController.java b/src/main/java/com/swyp/picke/domain/test/controller/TestController.java deleted file mode 100644 index c937631e..00000000 --- a/src/main/java/com/swyp/picke/domain/test/controller/TestController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.swyp.picke.domain.test.controller; - -import com.swyp.picke.domain.oauth.jwt.JwtProvider; -import com.swyp.picke.global.common.response.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; -import java.util.Map; - -@RestController -@RequestMapping("/api/test") -@RequiredArgsConstructor -public class TestController { - - private final JwtProvider jwtProvider; - - @GetMapping("/response") - public ApiResponse> testResponse() { - List teamMembers = List.of("주천수", "팀원2", "팀원3", "팀원4"); - return ApiResponse.onSuccess(teamMembers); - } - - @GetMapping("/token") - public ApiResponse> getTestToken( - @RequestParam(defaultValue = "1") Long userId - ) { - String token = jwtProvider.createAccessToken(userId, "USER"); - return ApiResponse.onSuccess(Map.of("accessToken", token)); - } -} \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java index 2650044d..f97ae6a8 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java @@ -18,7 +18,7 @@ import com.swyp.picke.domain.user.entity.UserProfile; import com.swyp.picke.domain.user.entity.UserSettings; import com.swyp.picke.domain.user.enums.VoteSide; -import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.service.VoteQueryService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; @@ -142,29 +142,29 @@ public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, V BattleOptionLabel label = voteSide != null ? toOptionLabel(voteSide) : null; - List votes = voteQueryService.findUserVotes(user.getId(), pageOffset, pageSize, label); + List votes = voteQueryService.findUserVotes(user.getId(), pageOffset, pageSize, label); long totalCount = voteQueryService.countUserVotes(user.getId(), label); List battleIds = votes.stream().map(v -> v.getBattle().getId()).toList(); - Map categoryMap = battleQueryService.getCategoryNamesByBattleIds(battleIds); // 추가 필요 + Map categoryMap = battleQueryService.getCategoryNamesByBattleIds(battleIds); // 異붽? ?꾩슂 List items = votes.stream() - .map(vote -> { - Battle battle = vote.getBattle(); - BattleOption selectedOption = vote.getPostVoteOption() != null - ? vote.getPostVoteOption() : vote.getPreVoteOption(); + .map(BattleVote -> { + Battle battle = BattleVote.getBattle(); + BattleOption selectedOption = BattleVote.getPostVoteOption() != null + ? BattleVote.getPostVoteOption() : BattleVote.getPreVoteOption(); VoteSide side = selectedOption.getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; String category = categoryMap.get(battle.getId()); return new BattleRecordListResponse.BattleRecordItem( battle.getId().toString(), - vote.getId().toString(), + BattleVote.getId().toString(), side, category, battle.getTitle(), battle.getSummary(), - vote.getCreatedAt() + BattleVote.getCreatedAt() ); }) .toList(); @@ -360,3 +360,5 @@ private String resolveCharacterImageUrl(String characterType) { return s3PresignedUrlService.generatePresignedUrl(imageKey); } } + + diff --git a/src/main/java/com/swyp/picke/domain/user/service/UserService.java b/src/main/java/com/swyp/picke/domain/user/service/UserService.java index b87beb08..0e735100 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/UserService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/UserService.java @@ -74,7 +74,7 @@ public PhilosopherType getPhilosopherType(Long userId) { return PhilosopherType.SOCRATES; } - List optionIds = voteQueryService.findFirstNBattleIds(userId, PHILOSOPHER_CALC_THRESHOLD); + List optionIds = voteQueryService.findFirstNVotedOptionIds(userId, PHILOSOPHER_CALC_THRESHOLD); return battleQueryService.getTopPhilosopherTagNameFromOptions(optionIds) .map(PhilosopherType::fromLabel) .map(type -> { @@ -124,4 +124,4 @@ public UserTendencyScore findUserTendencyScore(Long userId) { return userTendencyScoreRepository.findByUserId(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } -} +} \ No newline at end of file From 8d1a257a01c6cb9ca8344efcd3da971ae56d2fe3 Mon Sep 17 00:00:00 2001 From: jucheonsu Date: Fri, 10 Apr 2026 18:25:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Chore]=20=EB=B6=84=EB=A6=AC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B0=98=EC=98=81=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/swyp/picke/global/config/SecurityConfig.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 379c1266..a419af3f 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -43,12 +43,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/js/**", "/css/**", "/favicon.ico", "/api/v1/admin/login", "/api/v1/admin", "/result/**", - "/report/**", - "/battle/**", - "/.well-known/**", "/api/v1/resources/images/**", "/api/v1/resources/audio/**", - "/api/v1/admob/reward/**" + "/api/v1/resources/local/**", + "/api/v1/admob/reward/**", + "/report/**", + "/battle/**", + "/.well-known/**" ).permitAll() // 2. 관리자 HTML 화면 렌더링 요청