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 new file mode 100644 index 00000000..b115abc3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleController.java @@ -0,0 +1,51 @@ +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 eafacd8b..9450a078 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,13 +10,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -@Tag(name = "배틀 API", description = "배틀 조회") +@Tag(name = "배틀 API (사용자)", description = "배틀 조회") @RestController @RequestMapping("/api/v1/battles") @RequiredArgsConstructor @@ -24,34 +20,36 @@ public class BattleController { private final BattleService battleService; - @Operation(summary = "오늘의 배틀 목록 조회 (최대 5개)") + @Operation(summary = "오늘의 배틀 목록 조회 (스와이프 UI용, 최대 5개)") @GetMapping("/today") public ApiResponse getTodayBattles() { return ApiResponse.onSuccess(battleService.getTodayBattles()); } - @Operation(summary = "배틀 목록 조회") + @Operation(summary = "배틀 전체 목록 조회", description = "페이징 및 타입별(ALL, BATTLE, QUIZ, VOTE) 필터링된 배틀 목록을 조회합니다.") @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, PENDING, PUBLISHED, REJECTED, ARCHIVED)", example = "ALL") - @RequestParam(value = "status", required = false, defaultValue = "ALL") String status + @Parameter(description = "콘텐츠 타입 (ALL, BATTLE, QUIZ, VOTE)", example = "ALL") + @RequestParam(value = "type", required = false, defaultValue = "ALL") String type ) { - return ApiResponse.onSuccess(battleService.getBattles(page, size, status)); + return ApiResponse.onSuccess(battleService.getBattles(page, size, type)); } @Operation(summary = "배틀 상세 조회") @GetMapping("/{battleId}") - public ApiResponse getBattleDetail(@PathVariable Long battleId) { + public ApiResponse getBattleDetail( + @PathVariable Long battleId + ) { return ApiResponse.onSuccess(battleService.getBattleDetail(battleId)); } - @Operation(summary = "사용자 배틀 진행 상태 조회") + @Operation(summary = "사용자 배틀 진행 상태 조회 (사전투표/TTS/사후투표)") @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 71d5c27a..3511d521 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,22 +1,21 @@ package com.swyp.picke.domain.battle.converter; -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.request.AdminBattleCreateRequest; 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; @@ -26,17 +25,21 @@ 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) @@ -49,11 +52,18 @@ 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.getAudioDuration(), + battle.getType(), + battle.getItemA(), + battle.getItemADesc(), + battle.getItemB(), + battle.getItemBDesc(), battle.getTargetDate(), battle.getStatus(), battle.getCreatorType(), @@ -94,6 +111,7 @@ 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(), @@ -103,6 +121,12 @@ 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, @@ -119,7 +143,8 @@ 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( @@ -137,7 +161,8 @@ private List toOptionResponses(List options, option.getTitle(), option.getStance(), option.getRepresentative(), - urlProvider.getImageUrl(FileCategory.PHILOSOPHER, option.getImageUrl()), + option.getQuote(), + urlProvider.getImageUrl(FileCategory.PHILOSOPHER, PhilosopherType.resolveImageKey(option.getRepresentative())), toTagResponses(optionTags, null) ); }).toList(); @@ -145,16 +170,15 @@ private List toOptionResponses(List options, private List toTodayOptionResponses(List options) { if (options == null) return List.of(); - 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(); + 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(); } private List toTagResponses(List tags, TagType targetType) { @@ -164,4 +188,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 new file mode 100644 index 00000000..48aa5b4a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleCreateRequest.java @@ -0,0 +1,24 @@ +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 new file mode 100644 index 00000000..36c1c212 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleOptionRequest.java @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..aa5e4477 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/AdminBattleUpdateRequest.java @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..43c64d66 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDeleteResponse.java @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..fd382332 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/AdminBattleDetailResponse.java @@ -0,0 +1,36 @@ +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 ce34930d..51ca1760 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,6 +10,7 @@ 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 1208010c..de611ff9 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,6 +10,7 @@ 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 6ce79150..feef39fa 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,6 +6,7 @@ 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 60cd7f24..cd39f4d5 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,15 +1,23 @@ 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, - String title, - String summary, - String thumbnailUrl, - Integer viewCount, - Long participantsCount, - Integer audioDuration, - List tags, - List options -) {} + 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 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 9b50d068..b08b9455 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,13 +5,23 @@ import java.util.List; +/** + * 유저 - 배틀 상세 페이지 응답 (시안 4, 5번) + * 역할: 배틀 클릭 시 진입하는 상세 화면의 모든 정보를 담습니다. 투표 여부에 따라 UI가 변합니다. + */ public record BattleUserDetailResponse( - BattleSummaryResponse battleInfo, - String description, - String shareUrl, - VoteSide userVoteStatus, + BattleSummaryResponse battleInfo, // 기본적인 배틀 정보 (요약 DTO 재사용) + String titlePrefix, + String titleSuffix, + String itemA, + String itemADesc, + String itemB, + String itemBDesc, + String description, // 상세 본문 설명 + String shareUrl, // 공유하기 버튼용 링크 + VoteSide userVoteStatus, // 현재 유저의 투표 상태 UserBattleStep currentStep, - List categoryTags, - List philosopherTags, - List valueTags -) {} + List categoryTags, // UI 상단용 카테고리 태그 + List philosopherTags, // UI 하단용 철학자 태그 + List valueTags // 성향 분석용 가치관 태그 +) {} \ No newline at end of file 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 fe2cdac5..64720c5b 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 235a7f26..26e9567f 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 097a0061..8b14041d 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,15 +1,29 @@ 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, - String title, - String summary, - String thumbnailUrl, - Integer viewCount, - Long participantsCount, - Integer audioDuration, - List tags, - List options + 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 설명 ) {} 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 2da90246..2fd15871 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,11 +2,17 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +/** + * 유저 - 오늘의 배틀 전용 옵션 응답 + * 역할: 오늘의 배틀 시안의 세로형 카드에 들어가는 인물, 입장, 아바타 정보를 담습니다. + */ + public record TodayOptionResponse( - Long optionId, - BattleOptionLabel label, - String title, - String representative, - String stance, - String imageUrl + Long optionId, // 옵션 ID + BattleOptionLabel label,// 라벨 (A, B) + String title, // 제목 (예: 찬성한다) + String representative, // 인물 (예: 피터 싱어) + String stance, // 한 줄 입장 (예: 고통을 끝낼 권리는..) + String imageUrl, // 아바타 이미지 URL + Boolean isCorrect // 퀴즈 정답 여부 ) {} 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 e9905040..7a3ac8d5 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,27 +2,18 @@ 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.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 jakarta.persistence.*; 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") @@ -40,6 +31,28 @@ 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; @@ -64,8 +77,7 @@ public class Battle extends BaseEntity { @JoinColumn(name = "creator_id") private User creator; - @OneToMany(mappedBy = "battle", cascade = CascadeType.ALL, orphanRemoval = true) - private final List options = new ArrayList<>(); + // 홈 화면 5단 기획을 위한 필드들 @Column(name = "is_editor_pick") private Boolean isEditorPick = false; @@ -77,21 +89,22 @@ public class Battle extends BaseEntity { private LocalDateTime deletedAt; @Builder - public Battle( - String title, - String summary, - String description, - String thumbnailUrl, - LocalDate targetDate, - Integer audioDuration, - BattleStatus status, - BattleCreatorType creatorType, - User creator - ) { + 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) { 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; @@ -104,34 +117,26 @@ public Battle( this.deletedAt = null; } - 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 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 delete() { @@ -150,9 +155,4 @@ 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 ab5ee23a..8be17ceb 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,18 +2,7 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; 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.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 jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -42,35 +31,29 @@ 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 imageUrl, - Integer displayOrder - ) { + public BattleOption(Battle battle, BattleOptionLabel label, String title, String stance, + String representative, String quote, String imageUrl, Boolean isCorrect) { this.battle = battle; this.label = label; this.title = title; this.stance = stance; this.representative = representative; + this.quote = quote; this.imageUrl = imageUrl; - this.displayOrder = displayOrder; + this.isCorrect = (isCorrect != null) && isCorrect; this.voteCount = 0L; } @@ -84,21 +67,12 @@ public void decreaseVoteCount() { } } - 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; - } + 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; } -} +} \ 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 new file mode 100644 index 00000000..648e1eff --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleType.java @@ -0,0 +1,5 @@ +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 2260ed8e..d30f2a8e 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,23 +4,13 @@ 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 { - @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); - + List findByBattle(Battle battle); Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); - - @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); + List findByBattleIn(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 23f0d3dd..fb2ffce2 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,7 +3,6 @@ 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; @@ -12,7 +11,6 @@ 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 d4d5dd31..c4aa3d8d 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,103 +2,100 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.enums.BattleStatus; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import org.springframework.data.domain.Page; +import com.swyp.picke.domain.battle.enums.BattleType; 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 + // 1. EDITOR PICK - type 파라미터 추가 @Query("SELECT battle FROM Battle battle " + "WHERE battle.isEditorPick = true AND battle.status = :status " + - "AND battle.deletedAt IS NULL " + + "AND battle.type = :type AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findEditorPicks(@Param("status") BattleStatus status, Pageable pageable); + List findEditorPicks(@Param("status") BattleStatus status, @Param("type") BattleType type, Pageable pageable); - // 2. 지금 뜨는 배틀 - @Query("SELECT battle FROM Battle battle JOIN BattleVote vote ON vote.battle = battle " + - "WHERE vote.createdAt >= :yesterday " + + // 2. 지금 뜨는 배틀 - type 파라미터 추가 + @Query("SELECT battle FROM Battle battle JOIN Vote vote ON vote.battle = battle " + + "WHERE vote.createdAt >= :yesterday AND battle.type = :type " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "GROUP BY battle ORDER BY COUNT(vote) DESC") - List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, Pageable pageable); + List findTrendingBattles(@Param("yesterday") LocalDateTime yesterday, @Param("type") BattleType type, Pageable pageable); - // 3. Best 배틀 + // 3. Best 배틀 - type 파라미터 추가 @Query("SELECT battle FROM Battle battle " + - "WHERE battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + + "WHERE battle.status = 'PUBLISHED' AND battle.type = :type AND battle.deletedAt IS NULL " + "ORDER BY (battle.totalParticipantsCount + (battle.commentCount * 5)) DESC") - List findBestBattles(Pageable pageable); + List findBestBattles(@Param("type") BattleType type, Pageable pageable); // 4. 오늘의 Pické @Query("SELECT battle FROM Battle battle " + - "WHERE battle.targetDate = :today " + + "WHERE battle.type = :type AND battle.targetDate = :today " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL") - List findTodayPicks(@Param("today") LocalDate today, Pageable pageable); + List findTodayPicks(@Param("type") BattleType type, @Param("today") LocalDate today, Pageable pageable); - // 5. 새로운 배틀 + // 5. 새로운 배틀 - type 파라미터 추가 @Query("SELECT battle FROM Battle battle " + - "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 " + + "WHERE battle.id NOT IN :excludeIds AND battle.type = :type " + "AND battle.status = 'PUBLISHED' AND battle.deletedAt IS NULL " + "ORDER BY battle.createdAt DESC") - List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, Pageable pageable); + List findNewBattlesExcluding(@Param("excludeIds") List excludeIds, @Param("type") BattleType type, Pageable pageable); - // 6. 전체 배틀 목록 조회 + // 6. 전체 배틀 목록 조회 (페이징, 삭제된 항목 제외, 최신순) Page findByDeletedAtIsNullOrderByCreatedAtDesc(Pageable pageable); - Page findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(BattleStatus status, Pageable pageable); + Page findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc(BattleType type, Pageable pageable); // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); - // 탐색 탭: 전체 배틀 검색 - @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + 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") List searchAll(Pageable pageable); - @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") long countSearchAll(); // 탐색 탭: 카테고리 태그 필터 배틀 검색 @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' AND b.deletedAt IS NULL") List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + - "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + - "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.type = 'BATTLE' 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 - ); -} + Pageable pageable); +} \ No newline at end of file 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 a5d1df46..baf96eb4 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,56 +1,67 @@ package com.swyp.picke.domain.battle.service; -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.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.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); - List getEditorPicks(); + // === [사용자용 - 홈 화면 5단 로직 지원 API] === + + // 1. 에디터 픽 조회 (isEditorPick = true) + List getEditorPicks(int limit); - List getTrendingBattles(); + // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) + List getTrendingBattles(int limit); - List getBestBattles(); + // 3. Best 배틀 조회 (누적 지표 랭킹) + List getBestBattles(int limit); - List getTodayPicks(); + // 4. 오늘의 Pické 조회 (단일 타입 매칭) + List getTodayPicks(BattleType type, int limit); - List getNewBattles(List excludeIds); + // 5. 새로운 배틀 조회 (중복 제외 리스트) + List getNewBattles(List excludeIds, int limit); - BattleListResponse getBattles(int page, int size, String status); + // === [사용자용 - 기본 API] === + + // 전체 배틀 목록 페이징 조회 + BattleListResponse getBattles(int page, int size, String type); + + // 오늘의 배틀 (기존 로직 유지용) TodayBattleListResponse getTodayBattles(); + // 배틀 상세 정보 BattleUserDetailResponse getBattleDetail(Long battleId); - BattleVoteResponse BattleVote(Long battleId, Long optionId); + // 투표 실행 및 실시간 통계 결과 반환 + BattleVoteResponse vote(Long battleId, Long optionId); BattleScenarioResponse getBattleScenario(Long battleId); UserBattleStatusResponse getUserBattleStatus(Long battleId); - AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); + // === [관리자용 API] === - AdminBattleDetailResponse getAdminBattleDetail(Long battleId); + // 배틀 생성 + AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId); + // 배틀 수정 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 e8b59d6c..5956d719 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,11 +1,8 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.battle.converter.BattleConverter; -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.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.battle.dto.response.*; import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; @@ -13,6 +10,7 @@ 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; @@ -20,18 +18,15 @@ 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.BattleVote; -import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.repository.VoteRepository; 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; @@ -43,9 +38,6 @@ 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; @@ -54,23 +46,15 @@ @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 BattleVoteRepository battleVoteRepository; + private final VoteRepository voteRepository; private final BattleConverter battleConverter; private final S3UploadService s3UploadService; - private final LocalDraftFileStorageService localDraftFileStorageService; private final UserBattleService userBattleService; @Override @@ -84,81 +68,49 @@ public Battle findById(Long battleId) { } @Override - 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 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)); + public List getEditorPicks(int limit) { + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, BattleType.BATTLE, PageRequest.of(0, limit)); return convertToTodayResponses(battles); } - private List loadTrendingBattles(int limit) { - int safeLimit = Math.max(1, limit); + @Override + public List getTrendingBattles(int limit) { LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, safeLimit)); + List battles = battleRepository.findTrendingBattles(yesterday, BattleType.BATTLE, PageRequest.of(0, limit)); return convertToTodayResponses(battles); } - private List loadBestBattles(int limit) { - int safeLimit = Math.max(1, limit); - List battles = battleRepository.findBestBattles(PageRequest.of(0, safeLimit)); + @Override + public List getBestBattles(int limit) { + List battles = battleRepository.findBestBattles(BattleType.BATTLE, PageRequest.of(0, limit)); return convertToTodayResponses(battles); } - 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)); + @Override + public List getTodayPicks(BattleType type, int limit) { + List battles = battleRepository.findTodayPicks(type, LocalDate.now(), PageRequest.of(0, limit)); return convertToTodayResponses(battles); } - private List loadNewBattles(List excludeIds, int limit) { - int safeLimit = Math.max(1, limit); + @Override + public List getNewBattles(List excludeIds, int limit) { List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) ? List.of(-1L) : excludeIds; - List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, safeLimit)); + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, BattleType.BATTLE, PageRequest.of(0, limit)); return convertToTodayResponses(battles); } @Override - public BattleListResponse getBattles(int page, int size, String status) { + public BattleListResponse getBattles(int page, int size, String type) { int pageNumber = Math.max(0, page - 1); PageRequest pageRequest = PageRequest.of(pageNumber, size); - BattleStatus battleStatusFilter = parseBattleStatus(status); - Page battlePage; - if (battleStatusFilter == null) { + + if (type == null || type.equals("ALL")) { battlePage = battleRepository.findByDeletedAtIsNullOrderByCreatedAtDesc(pageRequest); } else { - battlePage = battleRepository.findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc( - battleStatusFilter, - pageRequest - ); + battlePage = battleRepository.findByTypeAndDeletedAtIsNullOrderByCreatedAtDesc( + BattleType.valueOf(type), pageRequest); } List items = battlePage.getContent().stream() @@ -174,11 +126,9 @@ public BattleListResponse getBattles(int page, int size, String status) { } @Override - @Transactional public TodayBattleListResponse getTodayBattles() { - LocalDate today = LocalDate.now(); - ensureTodayPicks(today, 5); - List battles = battleRepository.findByTargetDateAndStatusAndDeletedAtIsNull(today, BattleStatus.PUBLISHED); + List battles = battleRepository.findByTargetDateAndStatusAndTypeAndDeletedAtIsNull( + LocalDate.now(), BattleStatus.PUBLISHED, BattleType.BATTLE); List limitedBattles = battles.stream() .limit(5) @@ -189,17 +139,6 @@ 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) { @@ -219,11 +158,11 @@ public BattleUserDetailResponse getBattleDetail(Long battleId) { UserBattleStatusResponse statusResponse = userBattleService.getUserBattleStatus(user, battle); UserBattleStep currentStep = statusResponse.step(); - Optional optionalVote = battleVoteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); + Optional optionalVote = voteRepository.findByBattleIdAndUserIdWithOption(battleId, currentUserId); VoteSide voteStatus = optionalVote - .map(BattleVote -> { - if (BattleVote.getPostVoteOption() != null) { - return BattleVote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; + .map(vote -> { + if (vote.getPostVoteOption() != null) { + return vote.getPostVoteOption().getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; } return null; }) @@ -256,7 +195,7 @@ public UserBattleStatusResponse getUserBattleStatus(Long battleId) { @Override @Transactional - public BattleVoteResponse BattleVote(Long battleId, Long optionId) { + public BattleVoteResponse vote(Long battleId, Long optionId) { Battle battle = findById(battleId); BattleOption newOption = battleOptionRepository.findById(optionId) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); @@ -265,7 +204,7 @@ public BattleVoteResponse BattleVote(Long battleId, Long optionId) { User user = userRepository.findById(currentUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - battleVoteRepository.save(BattleVote.builder() + voteRepository.save(Vote.builder() .user(user) .battle(battle) .preVoteOption(newOption) @@ -293,46 +232,29 @@ public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, User admin = userRepository.findById(adminUserId == null ? 1L : adminUserId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - 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); + Battle battle = battleRepository.save(battleConverter.toEntity(request, admin)); if (request.tagIds() != null) { saveBattleTags(battle, request.tagIds().stream().distinct().toList()); } List savedOptions = new ArrayList<>(); - 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); + 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()); } + savedOptions.add(option); } Map> optionTagsMap = battleOptionTagRepository.findByBattleWithTags(battle) @@ -345,41 +267,21 @@ 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()); - String existingThumbnailKey = normalizeStoredImageReference(battle.getThumbnailUrl(), FileCategory.BATTLE); - String resolvedThumbnailKey = resolveStoredImageKey(request.thumbnailUrl(), request.status(), FileCategory.BATTLE); - if (existingThumbnailKey != null && !existingThumbnailKey.equals(resolvedThumbnailKey)) { - deleteStoredAsset(existingThumbnailKey); + if (battle.getThumbnailUrl() != null && !battle.getThumbnailUrl().equals(request.thumbnailUrl())) { + s3UploadService.deleteFile(battle.getThumbnailUrl()); } battle.update( - request.title(), - request.summary(), - request.description(), - resolvedThumbnailKey, - request.status() + 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() ); if (request.tagIds() != null) { @@ -390,56 +292,17 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe if (request.options() != null) { List existingOptions = battleOptionRepository.findByBattle(battle); - 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); + 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()); + }); } } @@ -492,7 +355,6 @@ 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())); } @@ -500,22 +362,10 @@ 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) @@ -528,95 +378,4 @@ public BattleOption findOptionByBattleIdAndLabel(Long battleId, BattleOptionLabe return battleOptionRepository.findByBattleAndLabel(battle, label) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); } - - 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); - } - } -} - - - +} \ No newline at end of file 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 e466fb6a..2cfddac7 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 4d3082cd..6aa4f55b 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,47 +1,30 @@ 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.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.home.dto.response.*; 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 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; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + @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; @@ -50,15 +33,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 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); + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); + List newRaw = battleService.getNewBattles(excludeIds, 3); return new HomeResponse( newNotice, @@ -66,145 +49,131 @@ public HomeResponse getHome(Long userId) { trendingRaw.stream().map(this::toTrending).toList(), bestRaw.stream().map(this::toBestBattle).toList(), quizRaw.stream().map(this::toTodayQuiz).toList(), - pollRaw.stream().map(this::toTodayVote).toList(), + voteRaw.stream().map(this::toTodayVote).toList(), newRaw.stream().map(this::toNewBattle).toList() ); } - private HomeEditorPickResponse toEditorPick(TodayBattleResponse battle) { + // 에디터픽 썸네일 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(); + return new HomeEditorPickResponse( - battle.battleId(), - battle.thumbnailUrl(), - findOptionTitle(battle.options(), BattleOptionLabel.A), - findOptionTitle(battle.options(), BattleOptionLabel.B), - battle.title(), - battle.summary(), - battle.tags(), - battle.viewCount() + b.battleId(), secureThumb, + optionA, optionB, + b.title(), b.summary(), + b.tags(), b.viewCount() ); } - private HomeTrendingResponse toTrending(TodayBattleResponse battle) { + private HomeTrendingResponse toTrending(TodayBattleResponse b) { return new HomeTrendingResponse( - battle.battleId(), - battle.thumbnailUrl(), - battle.title(), - battle.tags(), - battle.audioDuration(), - battle.viewCount() + b.battleId(), b.thumbnailUrl(), + b.title(), b.tags(), + b.audioDuration(), b.viewCount() ); } - private HomeBestBattleResponse toBestBattle(TodayBattleResponse battle) { + private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { + String philoA = findOptionRepresentative(b.options(), BattleOptionLabel.A); + String philoB = findOptionRepresentative(b.options(), BattleOptionLabel.B); + return new HomeBestBattleResponse( - battle.battleId(), - findOptionRepresentative(battle.options(), BattleOptionLabel.A), - findOptionRepresentative(battle.options(), BattleOptionLabel.B), - battle.title(), - battle.tags(), - battle.audioDuration(), - battle.viewCount() + b.battleId(), + philoA, philoB, + b.title(), b.tags(), + b.audioDuration(), b.viewCount() ); } - 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); - + private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { return new HomeTodayQuizResponse( - 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 + 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) ); } - 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() - )) + private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { + List options = Optional.ofNullable(b.options()).orElse(List.of()).stream() + .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) .toList(); - return new HomeTodayVoteResponse( - poll.getId(), - poll.getTitlePrefix(), - poll.getTitleSuffix(), - POLL_SUMMARY, - participantsCount, - homeOptions + b.battleId(), + b.titlePrefix(), b.titleSuffix(), + b.summary(), b.participantsCount(), + options ); } - private HomeNewBattleResponse toNewBattle(TodayBattleResponse battle) { + // 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); + return new HomeNewBattleResponse( - 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() + b.battleId(), b.thumbnailUrl(), + b.title(), b.summary(), + philoA, optionA, imageA, + philoB, optionB, imageB, + b.tags(), b.audioDuration(), b.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(option -> option.label() == label) + .filter(o -> o.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(option -> option.label() == label) + .filter(o -> o.label() == label) .map(TodayOptionResponse::representative) .filter(Objects::nonNull) - .findFirst() - .orElse(null); + .findFirst().orElse(null); + } + + private List findPhilosopherNames(List tags) { + return Optional.ofNullable(tags).orElse(List.of()).stream() + .filter(t -> t.type() == TagType.PHILOSOPHER) + .map(BattleTagResponse::name) + .toList(); } private String findRepresentativeImageUrl(List options, BattleOptionLabel label) { return Optional.ofNullable(options).orElse(List.of()).stream() - .filter(option -> option.label() == label) + .filter(o -> o.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 0ac93e0a..b7150503 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 = "인증 API", description = "소셜 로그인, 토큰 재발급, 로그아웃, 회원 탈퇴") +@Tag(name = "인증 (Auth)", description = "인증 API") 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 c17eba4c..76541533 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 = "댓글 좋아요 API", description = "댓글 좋아요 등록 및 취소") +@Tag(name = "댓글 좋아요 (Comment Like)", description = "댓글 좋아요 등록, 취소 API") @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 728a7fea..d702d8aa 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 = "관점 댓글 API", description = "관점 댓글 생성, 조회, 수정, 삭제") +@Tag(name = "관점 댓글 (Comment)", description = "관점 댓글 생성, 조회, 수정, 삭제 API") @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 03c9aa3f..545f8146 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 = "관점 API", description = "관점 생성, 조회, 수정, 삭제") +@Tag(name = "관점 (Perspective)", description = "관점 생성, 조회, 수정, 삭제 API") @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,7 +40,8 @@ public ApiResponse getPerspectiveDetail( return ApiResponse.onSuccess(perspectiveService.getPerspectiveDetail(perspectiveId, userId)); } - @Operation(summary = "관점 생성", description = "특정 배틀에 대한 사용자 관점을 생성합니다.") + // TODO: Prevote 의 여부를 Vote 도메인 개발 이후 교체 + @Operation(summary = "관점 생성", description = "특정 배틀에 대한 관점을 생성합니다. 사전 투표가 완료된 경우에만 가능합니다.") @PostMapping("/battles/{battleId}/perspectives") public ApiResponse createPerspective( @PathVariable Long battleId, @@ -50,7 +51,7 @@ public ApiResponse createPerspective( return ApiResponse.onSuccess(perspectiveService.createPerspective(battleId, userId, request)); } - @Operation(summary = "관점 목록 조회", description = "특정 배틀의 관점 목록을 커서 기반으로 조회합니다.") + @Operation(summary = "관점 리스트 조회", description = "특정 배틀의 관점 목록을 커서 기반 페이지네이션으로 조회합니다. optionLabel(A/B)로 필터링, sort(latest/popular)로 정렬 가능합니다.") @GetMapping("/battles/{battleId}/perspectives") public ApiResponse getPerspectives( @PathVariable Long battleId, @@ -63,7 +64,7 @@ public ApiResponse getPerspectives( return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel, sort)); } - @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") + @Operation(summary = "내 관점 조회", description = "특정 배틀에서 내가 작성한 관점을 조회합니다. 상태(PENDING/PUBLISHED/REJECTED 등)와 무관하게 반환하며, 작성한 관점이 없으면 404를 반환합니다.") @GetMapping("/battles/{battleId}/perspectives/me") public ApiResponse getMyPerspective( @PathVariable Long battleId, @@ -80,7 +81,7 @@ public ApiResponse deletePerspective( return ApiResponse.onSuccess(null); } - @Operation(summary = "관점 검수 재요청", description = "검수 실패 상태의 관점에 대해 검수를 다시 요청합니다.") + @Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") @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 7e090575..75a6a1b4 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 = "관점 좋아요 API", description = "관점 좋아요 조회, 등록, 취소") +@Tag(name = "관점 좋아요 (Like)", description = "관점 좋아요 조회, 등록, 취소 API") @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 eb227348..438cc00f 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 = "신고 API", description = "관점/댓글 신고") +@Tag(name = "신고 (Report)", description = "관점/댓글 신고 API") @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 c7808893..ac225705 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.BattleVoteService; +import com.swyp.picke.domain.vote.service.VoteService; 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 BattleVoteService BattleVoteService; + private final VoteService voteService; 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 = BattleVoteService.findPostVoteOptionId(perspective.getBattle().getId(), userId); + Long postVoteOptionId = voteService.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 = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = voteService.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 = BattleVoteService.findPostVoteOptionId(battleId, c.getUser().getId()); + Long postVoteOptionId = voteService.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 ed8d596c..e366aa63 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.BattleVoteService; +import com.swyp.picke.domain.vote.service.VoteService; 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 BattleVoteService BattleVoteService; + private final VoteService voteService; 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 = BattleVoteService.findPreVoteOption(battleId, userId); + BattleOption option = voteService.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/poll/controller/PollController.java b/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java deleted file mode 100644 index 97344834..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/controller/PollController.java +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 03d74fec..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/converter/PollConverter.java +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 04f4db50..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollDetailResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 76f89133..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollListResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index b619a55f..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollOptionResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index de4a34e2..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollSimpleResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 4c334ae8..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/dto/response/PollTagResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 447a9081..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/Poll.java +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index c0f86e9b..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollOption.java +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index e148a80c..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMap.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 627f6ab4..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollOptionValueTagMapId.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 1220879c..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMap.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 29263b1f..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollTagMapId.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 15502d92..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/entity/PollUserVote.java +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 5dc3dc74..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/enums/PollOptionLabel.java +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 49757284..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/enums/PollStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 47e2e727..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 1d9fcf9c..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/repository/PollOptionValueTagMapRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 535eec6c..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/repository/PollRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 77e8e9da..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/repository/PollTagMapRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index dd2039d9..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/repository/PollUserVoteRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 9b64a0f3..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/service/PollService.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 4d3f9958..00000000 --- a/src/main/java/com/swyp/picke/domain/poll/service/PollServiceImpl.java +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index f290b147..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/controller/QuizController.java +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index bdcb8bbd..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/converter/QuizConverter.java +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index c5409dec..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizDetailResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ded527d5..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizListResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 5f83007b..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizOptionResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 85556fc9..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizSimpleResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index b283ca95..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/dto/response/QuizTagResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index aade8606..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/Quiz.java +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 85fd73e0..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOption.java +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 43e94781..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMap.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index ce65910d..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizOptionValueTagMapId.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index bb19afa4..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMap.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index e61597e7..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizTagMapId.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f159720f..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/entity/QuizUserVote.java +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 2eeb5355..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizOptionLabel.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index a6063700..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/enums/QuizStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index f4c3c9b7..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index bacf283b..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizOptionValueTagMapRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index f84f5583..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index aeb7ebe9..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizTagMapRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 07f26949..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/repository/QuizUserVoteRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index c6d1678f..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/service/QuizService.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 3ee12e08..00000000 --- a/src/main/java/com/swyp/picke/domain/quiz/service/QuizServiceImpl.java +++ /dev/null @@ -1,189 +0,0 @@ -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/recommendation/controller/RecommendationController.java b/src/main/java/com/swyp/picke/domain/recommendation/controller/RecommendationController.java index 45dad51d..c05a07c7 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 = "추천 API", description = "배틀 추천 조회") +@Tag(name = "추천 (Recommendation)", description = "배틀 추천 API") @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 00d3bb86..1a37f32f 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.BattleVoteRepository; +import com.swyp.picke.domain.vote.repository.VoteRepository; 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 BattleVoteRepository BattleVoteRepository; + private final VoteRepository voteRepository; 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 = BattleVoteRepository.findParticipatedBattleIdsByUserId(userId); + List excludeBattleIds = voteRepository.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() - : BattleVoteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); + : voteRepository.findParticipatedBattleIdsByUserIds(sameTypeUserIds); // 반대 유형 유저들이 참여한 배틀 후보 ID List oppositeCandidateIds = oppositeTypeUserIds.isEmpty() ? List.of() - : BattleVoteRepository.findParticipatedBattleIdsByUserIds(oppositeTypeUserIds); + : voteRepository.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 71a4f239..723be0d9 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,12 +8,14 @@ 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 = "보상 API", description = "AdMob 광고 보상 관련 API") +@Tag(name = "보상 (Reward)", 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 9cec0289..d3fef5da 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,6 +1,7 @@ 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; @@ -13,6 +14,7 @@ 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 b309fbe7..3d66a5b1 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,6 +58,7 @@ 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/tag/controller/TagController.java b/src/main/java/com/swyp/picke/domain/tag/controller/TagController.java index 12a96ce9..094f8982 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.response.TagListResponse; +import com.swyp.picke.domain.tag.dto.request.TagRequest; +import com.swyp.picke.domain.tag.dto.response.*; 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.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 org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; -@Tag(name = "태그 API", description = "태그 조회") +@Tag(name = "태그 (Tag)", description = "태그 조회 및 관리 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -21,13 +21,46 @@ public class TagController { private final TagService tagService; - @Operation(summary = "태그 목록 조회") + @Operation(summary = "태그 목록 조회", description = "전체 태그 목록을 조회합니다. 특정 타입(type)을 지정하여 필터링할 수 있습니다.") @GetMapping("/tags") public ApiResponse getTags( - @Parameter(description = "태그 타입 필터(선택)", required = false) + @Parameter(description = "필터링할 태그 타입 (예: BATTLE 등)", 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 26382626..b3860d45 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,9 +1,7 @@ package com.swyp.picke.domain.tag.converter; -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.dto.request.TagRequest; +import com.swyp.picke.domain.tag.dto.response.*; 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 new file mode 100644 index 00000000..736bfda6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/request/TagRequest.java @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..71b350e8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagDeleteResponse.java @@ -0,0 +1,8 @@ +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 6bf53599..5e258e8d 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,6 +1,5 @@ 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 new file mode 100644 index 00000000..70554dde --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/tag/dto/response/TagResponse.java @@ -0,0 +1,12 @@ +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 deleted file mode 100644 index 41b4561a..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/entity/CategoryTag.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index ba54480b..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/entity/PhilosopherTag.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 6c9c0303..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/entity/ValueTag.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 9d71ad27..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/repository/CategoryTagRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index fdca62b4..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/repository/PhilosopherTagRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index f731d490..00000000 --- a/src/main/java/com/swyp/picke/domain/tag/repository/ValueTagRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -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 2074a1e8..97ceca46 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.admin.dto.tag.request.TagRequest; -import com.swyp.picke.domain.admin.dto.tag.response.TagDeleteResponse; +import com.swyp.picke.domain.tag.dto.request.TagRequest; +import com.swyp.picke.domain.tag.dto.response.TagDeleteResponse; import com.swyp.picke.domain.tag.dto.response.TagListResponse; -import com.swyp.picke.domain.admin.dto.tag.response.TagResponse; +import com.swyp.picke.domain.tag.dto.response.TagResponse; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; @@ -11,6 +11,7 @@ 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 8f7b0950..d1bf3b96 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,14 +1,11 @@ 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.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.dto.request.TagRequest; +import com.swyp.picke.domain.tag.dto.response.*; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.tag.repository.TagRepository; @@ -28,7 +25,6 @@ public class TagServiceImpl implements TagService { private final TagRepository tagRepository; private final BattleTagRepository battleTagRepository; - private final BattleOptionTagRepository battleOptionTagRepository; private final BattleRepository battleRepository; @Override @@ -66,16 +62,11 @@ 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); } @@ -86,7 +77,7 @@ public TagResponse updateTag(Long tagId, TagRequest request) { public TagDeleteResponse deleteTag(Long tagId) { Tag tag = findTagById(tagId); - if (isTagInUse(tag)) { + if (battleTagRepository.existsByTag(tag)) { throw new CustomException(ErrorCode.TAG_IN_USE); } @@ -104,8 +95,4 @@ 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/test/controller/TestController.java b/src/main/java/com/swyp/picke/domain/test/controller/TestController.java new file mode 100644 index 00000000..c937631e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/test/controller/TestController.java @@ -0,0 +1,34 @@ +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 f97ae6a8..2650044d 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.BattleVote; +import com.swyp.picke.domain.vote.entity.Vote; 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(BattleVote -> { - Battle battle = BattleVote.getBattle(); - BattleOption selectedOption = BattleVote.getPostVoteOption() != null - ? BattleVote.getPostVoteOption() : BattleVote.getPreVoteOption(); + .map(vote -> { + Battle battle = vote.getBattle(); + BattleOption selectedOption = vote.getPostVoteOption() != null + ? vote.getPostVoteOption() : vote.getPreVoteOption(); VoteSide side = selectedOption.getLabel() == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; String category = categoryMap.get(battle.getId()); return new BattleRecordListResponse.BattleRecordItem( battle.getId().toString(), - BattleVote.getId().toString(), + vote.getId().toString(), side, category, battle.getTitle(), battle.getSummary(), - BattleVote.getCreatedAt() + vote.getCreatedAt() ); }) .toList(); @@ -360,5 +360,3 @@ 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 0e735100..b87beb08 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.findFirstNVotedOptionIds(userId, PHILOSOPHER_CALC_THRESHOLD); + List optionIds = voteQueryService.findFirstNBattleIds(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 +} 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 0c864ca1..bf9ee7ae 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,47 +1,35 @@ 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.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.dto.response.*; 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.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; +import org.springframework.web.bind.annotation.*; -@Tag(name = "투표 API", description = "배틀/퀴즈/투표 투표 처리") +@Tag(name = "투표 (Vote)", description = "사전/사후 투표 실행 및 통계, 내 투표 내역 조회 API") @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor public class VoteController { - private final BattleVoteService battleVoteService; + // 배틀(BATTLE) 전용 서비스 + private final VoteService voteService; + // 퀴즈(QUIZ) & 투표(POLL) 전용 서비스 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)); } @@ -50,17 +38,15 @@ public ApiResponse submitQuiz( public ApiResponse submitPoll( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody PollVoteRequest request - ) { - return ApiResponse.onSuccess(pollVoteService.submitPoll(battleId, userId, request)); + @RequestBody QuizVoteRequest request) { + return ApiResponse.onSuccess(quizVoteService.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)); } @@ -68,19 +54,19 @@ public ApiResponse getMyQuizVote( @GetMapping("/battles/{battleId}/poll-vote/me") public ApiResponse getMyPollVote( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId - ) { - return ApiResponse.onSuccess(pollVoteService.getMyPollVote(battleId, userId)); + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(quizVoteService.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(battleVoteService.preVote(battleId, userId, request)); + @RequestBody VoteRequest request) { + return ApiResponse.onSuccess(voteService.preVote(battleId, userId, request)); } @Operation(summary = "[배틀] 사후 투표 실행", description = "콘텐츠 소비 후 최종 투표(사후 투표)를 진행합니다.") @@ -88,57 +74,46 @@ public ApiResponse preVote( public ApiResponse postVote( @PathVariable Long battleId, @AuthenticationPrincipal Long userId, - @RequestBody VoteRequest request - ) { - return ApiResponse.onSuccess(battleVoteService.postVote(battleId, userId, request)); + @RequestBody VoteRequest request) { + return ApiResponse.onSuccess(voteService.postVote(battleId, userId, request)); } @Operation(summary = "[배틀] 투표 통계 조회", description = "특정 배틀의 옵션별 투표 수와 비율을 조회합니다.") @GetMapping("/battles/{battleId}/vote-stats") public ApiResponse getVoteStats(@PathVariable Long battleId) { - return ApiResponse.onSuccess(battleVoteService.getVoteStats(battleId)); + return ApiResponse.onSuccess(voteService.getVoteStats(battleId)); } @Operation(summary = "[배틀] 내 투표 내역 조회", description = "특정 배틀에 대한 내 사전/사후 투표 내역과 현재 상태를 조회합니다.") @GetMapping("/battles/{battleId}/votes/me") public ApiResponse getMyVote( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId - ) { - return ApiResponse.onSuccess(battleVoteService.getMyVote(battleId, userId)); + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(voteService.getMyVote(battleId, userId)); } @Operation(summary = "[배틀] 오디오(TTS) 청취 완료 처리", description = "사전 투표 후, 오디오 재생이 완료되었을 때 호출하여 상태를 업데이트합니다.") @PostMapping("/battles/{battleId}/votes/tts-complete") public ApiResponse completeTts( @PathVariable Long battleId, - @AuthenticationPrincipal Long userId - ) { - battleVoteService.completeTts(battleId, userId); + @AuthenticationPrincipal Long userId) { + voteService.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) { - battleVoteService.deleteVotesByBattleId(battleId); + voteService.deleteVotesByBattleId(battleId); return ApiResponse.onSuccess(null); } - @Operation(summary = "[관리자] 퀴즈 투표 기록 삭제") - @DeleteMapping("/admin/votes/quiz/{battleId}") + @Operation(summary = "[관리자] 퀴즈/일반투표 기록 삭제") + @DeleteMapping("/admin/votes/quiz-poll/{battleId}") @PreAuthorize("hasRole('ADMIN')") - public ApiResponse deleteQuizVote(@PathVariable Long battleId) { + public ApiResponse deleteQuizPollVote(@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 23e0b340..4c4b741f 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,17 +5,20 @@ 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.BattleVote; +import com.swyp.picke.domain.vote.entity.Vote; + import java.time.LocalDateTime; import java.util.List; public class VoteConverter { - public static VoteResultResponse toVoteResultResponse(BattleVote vote, UserBattleStep step) { + // [수정] UserBattleStep을 인자로 받도록 변경 + public static VoteResultResponse toVoteResultResponse(Vote vote, UserBattleStep step) { return new VoteResultResponse(vote.getId(), step); } - public static MyVoteResponse toMyVoteResponse(BattleVote vote, UserBattleStep step) { + // [수정] UserBattleStep을 인자로 받아 MyVoteResponse의 status 필드에 매핑 + public static MyVoteResponse toMyVoteResponse(Vote vote, UserBattleStep step) { boolean opinionChanged = vote.getPreVoteOption() != null && vote.getPostVoteOption() != null && !vote.getPreVoteOption().getId().equals(vote.getPostVoteOption().getId()); @@ -24,23 +27,19 @@ public static MyVoteResponse toMyVoteResponse(BattleVote vote, UserBattleStep st vote.getBattle().getTitle(), toOptionInfo(vote.getPreVoteOption()), toOptionInfo(vote.getPostVoteOption()), - step, + step, // 외부에서 넘겨받은 UserBattleStep 사용 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 deleted file mode 100644 index 1a37a99a..00000000 --- a/src/main/java/com/swyp/picke/domain/vote/dto/request/PollVoteRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -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 212547fa..7ff37c42 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 0dd199d8..6a41eb6d 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 4303b5dc..3c508760 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/PollVote.java b/src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java deleted file mode 100644 index 7f650b2e..00000000 --- a/src/main/java/com/swyp/picke/domain/vote/entity/PollVote.java +++ /dev/null @@ -1,45 +0,0 @@ -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 bb6c4a7a..7bc13514 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,14 +1,10 @@ package com.swyp.picke.domain.vote.entity; -import com.swyp.picke.domain.quiz.entity.Quiz; -import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; 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 jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -16,7 +12,7 @@ @Getter @Entity -@Table(name = "quiz_user_votes") +@Table(name = "quiz_votes") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuizVote extends BaseEntity { @@ -25,21 +21,21 @@ public class QuizVote extends BaseEntity { private User user; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "quiz_id", nullable = false) - private Quiz quiz; + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "option_id", nullable = false) - private QuizOption selectedOption; + private BattleOption selectedOption; @Builder - public QuizVote(User user, Quiz quiz, QuizOption selectedOption) { + public QuizVote(User user, Battle battle, BattleOption selectedOption) { this.user = user; - this.quiz = quiz; + this.battle = battle; this.selectedOption = selectedOption; } - public void updateOption(QuizOption option) { + public void updateOption(BattleOption option) { this.selectedOption = option; } } diff --git a/src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java b/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java similarity index 77% rename from src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java rename to src/main/java/com/swyp/picke/domain/vote/entity/Vote.java index 1551e80c..47054b65 100644 --- a/src/main/java/com/swyp/picke/domain/vote/entity/BattleVote.java +++ b/src/main/java/com/swyp/picke/domain/vote/entity/Vote.java @@ -19,7 +19,7 @@ @Entity @Table(name = "votes") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class BattleVote extends BaseEntity { +public class Vote extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -41,7 +41,7 @@ public class BattleVote extends BaseEntity { private Boolean isTtsListened = false; @Builder - private BattleVote(User user, Battle battle, BattleOption preVoteOption, + private Vote(User user, Battle battle, BattleOption preVoteOption, BattleOption postVoteOption, Boolean isTtsListened) { this.user = user; this.battle = battle; @@ -50,26 +50,38 @@ private BattleVote(User user, Battle battle, BattleOption preVoteOption, this.isTtsListened = isTtsListened != null ? isTtsListened : false; } - public static BattleVote createPreVote(User user, Battle battle, BattleOption option) { - return BattleVote.builder() + /** + * 최초 투표(사전 투표) 시 사용하는 정적 팩토리 메서드 + */ + public static Vote createPreVote(User user, Battle battle, BattleOption option) { + return Vote.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/repository/PollVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java deleted file mode 100644 index 814fc16f..00000000 --- a/src/main/java/com/swyp/picke/domain/vote/repository/PollVoteRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -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 5cfd4064..060f2938 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.quiz.entity.Quiz; -import com.swyp.picke.domain.quiz.entity.QuizOption; +import com.swyp.picke.domain.battle.entity.Battle; 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 findByQuizAndUser(Quiz quiz, User user); - List findAllByQuiz(Quiz quiz); - long countByQuizAndSelectedOption(Quiz quiz, QuizOption selectedOption); + Optional findByBattleAndUser(Battle battle, User user); + long countByBattle(Battle battle); + List findAllByBattle(Battle battle); } diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java similarity index 51% rename from src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java rename to src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.java index 2e98f96c..4159beb1 100644 --- a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java +++ b/src/main/java/com/swyp/picke/domain/vote/repository/VoteRepository.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.BattleVote; +import com.swyp.picke.domain.vote.entity.Vote; 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 BattleVoteRepository extends JpaRepository { +public interface VoteRepository 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 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); + @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); - 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 BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + @Query("SELECT v FROM Vote 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 BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + @Query("SELECT v FROM Vote 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 BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.label = :label") + @Query("SELECT COUNT(v) FROM Vote 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 BattleVote v WHERE v.user.id = :userId " + + @Query("SELECT COUNT(v) FROM Vote 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 BattleVote 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 Vote 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 BattleVote v WHERE v.user.id = :userId") + @Query("SELECT v.battle.id FROM Vote v WHERE v.user.id = :userId") List findParticipatedBattleIdsByUserId(@Param("userId") Long userId); // 추천용: 특정 배틀에 참여한 유저 ID 조회 - @Query("SELECT DISTINCT v.user.id FROM BattleVote v WHERE v.battle.id IN :battleIds") + @Query("SELECT DISTINCT v.user.id FROM Vote v WHERE v.battle.id IN :battleIds") List findUserIdsByBattleIds(@Param("battleIds") List battleIds); // 추천용: 특정 유저들이 참여한 배틀 ID 조회 - @Query("SELECT v.battle.id FROM BattleVote v WHERE v.user.id IN :userIds") + @Query("SELECT v.battle.id FROM Vote 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/service/PollVoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java deleted file mode 100644 index 55fd9163..00000000 --- a/src/main/java/com/swyp/picke/domain/vote/service/PollVoteService.java +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 49af6548..00000000 --- a/src/main/java/com/swyp/picke/domain/vote/service/PollVoteServiceImpl.java +++ /dev/null @@ -1,140 +0,0 @@ -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 57963d10..52e34ba6 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,11 +1,13 @@ 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 9eac2082..13ce33e7 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,143 +1,194 @@ package com.swyp.picke.domain.vote.service; -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.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.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) { - Long quizId = battleId; - Quiz quiz = quizService.findById(quizId); + Battle battle = battleService.findById(battleId); + if (!"QUIZ".equals(battle.getType().name())) { + throw new CustomException(ErrorCode.BATTLE_NOT_QUIZ); + } - QuizOption selectedOption = quizOptionRepository.findById(request.optionId()) - .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); + QuizVote v = saveOrUpdate(battle, userId, request.optionId()); + long totalCount = quizVoteRepository.countByBattle(v.getBattle()); + + return new QuizVoteResponse( + battleId, + v.getSelectedOption().getId(), + totalCount, + calcStats(v.getBattle(), totalCount) + ); + } - if (!selectedOption.getQuiz().getId().equals(quiz.getId())) { - throw 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); } - QuizVote quizVote = saveOrUpdate(quiz, userId, selectedOption); - long totalCount = quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); + QuizVote v = saveOrUpdate(battle, userId, request.optionId()); + long totalCount = quizVoteRepository.countByBattle(v.getBattle()); - return new QuizVoteResponse( - quizId, - quizVote.getSelectedOption().getId(), + return new PollVoteResponse( + battleId, + v.getSelectedOption().getId(), totalCount, - buildStats(quiz, totalCount, true, true) + calcStats(v.getBattle(), totalCount).stream() + .map(s -> new PollVoteResponse.OptionStat(s.optionId(), s.label(), s.title(), s.voteCount(), s.ratio())) + .toList() ); } @Override public QuizVoteResponse getMyQuizVote(Long battleId, Long userId) { - Long quizId = battleId; - Quiz quiz = quizService.findById(quizId); + Battle battle = battleService.findById(battleId); + if (!"QUIZ".equals(battle.getType().name())) { + throw new CustomException(ErrorCode.BATTLE_NOT_QUIZ); + } User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - long totalCount = quiz.getTotalParticipantsCount() == null ? 0L : quiz.getTotalParticipantsCount(); + long totalCount = quizVoteRepository.countByBattle(battle); - return quizVoteRepository.findByQuizAndUser(quiz, user) - .map(quizVote -> new QuizVoteResponse( - quizId, - quizVote.getSelectedOption().getId(), + return quizVoteRepository.findByBattleAndUser(battle, user) + .map(v -> new QuizVoteResponse( + battleId, + v.getSelectedOption().getId(), totalCount, - buildStats(quiz, totalCount, true, true) + calcStats(battle, totalCount) )) - .orElseGet(() -> new QuizVoteResponse( - quizId, - null, - totalCount, - buildStats(quiz, totalCount, false, false) - )); + .orElseGet(() -> { + // [투표 전] 전체 참여자 수(totalCount), 선택지 설명(stance)는 보여주되, 개별 통계(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, o.getStance() + )) + .toList(); + return new QuizVoteResponse(battleId, null, totalCount, blindStats); + }); } @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) { - Long quizId = battleId; - Quiz quiz = quizService.findById(quizId); + // 배틀 확인 + Battle battle = battleService.findById(battleId); + + // 해당 배틀의 모든 투표 조회 + List votes = quizVoteRepository.findAllByBattle(battle); - List votes = quizVoteRepository.findAllByQuiz(quiz); - for (QuizVote ignored : votes) { - quiz.decreaseTotalParticipantsCount(); + // 투표수 감소 (배틀 옵션에 반영) + for (QuizVote v : votes) { + if (v.getSelectedOption() != null) { + v.getSelectedOption().decreaseVoteCount(); + } } quizVoteRepository.deleteAllInBatch(votes); } - private QuizVote saveOrUpdate(Quiz quiz, Long userId, QuizOption selectedOption) { + private QuizVote saveOrUpdate(Battle battle, Long userId, Long optionId) { 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.findByQuizAndUser(quiz, user) - .map(quizVote -> { - if (!quizVote.getSelectedOption().equals(selectedOption)) { - quizVote.updateOption(selectedOption); + return quizVoteRepository.findByBattleAndUser(battle, user) + .map(v -> { + // 옵션을 바꾼다면 기존 옵션 -1, 새 옵션 +1 + if (!v.getSelectedOption().equals(newOption)) { + v.getSelectedOption().decreaseVoteCount(); + newOption.increaseVoteCount(); + v.updateOption(newOption); } - return quizVote; + return v; }) .orElseGet(() -> { - quiz.increaseTotalParticipantsCount(); + // 처음 투표한다면 새 옵션 +1 + battle.addParticipant(); + newOption.increaseVoteCount(); return quizVoteRepository.save( - QuizVote.builder() - .user(user) - .quiz(quiz) - .selectedOption(selectedOption) - .build() - ); + QuizVote.builder().user(user).battle(battle).selectedOption(newOption).build()); }); - } + } - 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, - null - ); - }) - .toList(); + 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, + o.getStance() + ); + }).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 5401ef8a..fab804f0 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,51 +2,49 @@ import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.enums.BattleOptionLabel; -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 com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.repository.VoteRepository; 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 BattleVoteRepository battleVoteRepository; + private final VoteRepository voteRepository; - 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 - ? battleVoteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(userId, label, pageable) - : battleVoteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + ? voteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(userId, label, pageable) + : voteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); } public long countUserVotes(Long userId, BattleOptionLabel label) { return label != null - ? battleVoteRepository.countByUserIdAndPreVoteOptionLabel(userId, label) - : battleVoteRepository.countByUserId(userId); + ? voteRepository.countByUserIdAndPreVoteOptionLabel(userId, label) + : voteRepository.countByUserId(userId); } public long countTotalParticipation(Long userId) { - return battleVoteRepository.countByUserId(userId); + return voteRepository.countByUserId(userId); } public long countOpinionChanges(Long userId) { - return battleVoteRepository.countOpinionChangesByUserId(userId); + return voteRepository.countOpinionChangesByUserId(userId); } public int calculateBattleWinRate(Long userId) { - List postVotes = battleVoteRepository.findByUserId(userId).stream() + List postVotes = voteRepository.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 -> { @@ -64,31 +62,27 @@ public int calculateBattleWinRate(Long userId) { } public List findParticipatedBattleIds(Long userId) { - return battleVoteRepository.findByUserId(userId).stream() + return voteRepository.findByUserId(userId).stream() .map(v -> v.getBattle().getId()) .distinct() .toList(); } public List findFirstNBattleIds(Long userId, int n) { - return battleVoteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + return voteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() .map(v -> v.getBattle().getId()) .distinct() .toList(); } public List findFirstNVotedOptionIds(Long userId, int n) { - return battleVoteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + return voteRepository.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(Objects::nonNull) + .filter(java.util.Objects::nonNull) .distinct() .toList(); } diff --git a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteService.java similarity index 95% rename from src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java rename to src/main/java/com/swyp/picke/domain/vote/service/VoteService.java index 6fac6bbf..77d68fe6 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteService.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteService.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 BattleVoteService { +public interface VoteService { 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/BattleVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java similarity index 70% rename from src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java rename to src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java index 74342d90..32a2d956 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteServiceImpl.java @@ -14,23 +14,24 @@ 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.BattleVote; -import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.domain.vote.entity.Vote; +import com.swyp.picke.domain.vote.repository.VoteRepository; 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 BattleVoteServiceImpl implements BattleVoteService { +public class VoteServiceImpl implements VoteService { - private final BattleVoteRepository battleVoteRepository; + private final VoteRepository voteRepository; private final BattleService battleService; private final BattleOptionRepository battleOptionRepository; private final UserRepository userRepository; @@ -42,7 +43,7 @@ public BattleOption findPreVoteOption(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) + Vote vote = voteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); if (vote.getPreVoteOption() == null) { @@ -53,7 +54,7 @@ public BattleOption findPreVoteOption(Long battleId, Long userId) { @Override public Long findPostVoteOptionId(Long battleId, Long userId) { - return battleVoteRepository.findByBattleIdAndUserId(battleId, userId) + return voteRepository.findByBattleIdAndUserId(battleId, userId) .map(vote -> vote.getPostVoteOption() != null ? vote.getPostVoteOption().getId() : null) .orElse(null); } @@ -62,26 +63,21 @@ public Long findPostVoteOptionId(Long battleId, Long userId) { public VoteStatsResponse getVoteStats(Long battleId) { Battle battle = battleService.findById(battleId); List options = battleOptionRepository.findByBattle(battle); - long totalCount = battleVoteRepository.countByBattle(battle); + long totalCount = voteRepository.countByBattle(battle); List stats = options.stream() .map(option -> { - long count = battleVoteRepository.countByBattleAndPreVoteOption(battle, option); + long count = voteRepository.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 = battleVoteRepository.findTopByBattleOrderByUpdatedAtDesc(battle) - .map(BattleVote::getUpdatedAt) + LocalDateTime updatedAt = voteRepository.findTopByBattleOrderByUpdatedAtDesc(battle) + .map(Vote::getUpdatedAt) .orElse(null); return VoteConverter.toVoteStatsResponse(stats, totalCount, updatedAt); @@ -93,7 +89,7 @@ public MyVoteResponse getMyVote(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) + Vote vote = voteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); UserBattleStatusResponse status = userBattleService.getUserBattleStatus(user, battle); @@ -103,32 +99,37 @@ 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)); - Optional existingVote = battleVoteRepository.findByBattleAndUser(battle, user); - BattleVote vote; + // 2. 기존 투표 여부 확인 (에러 대신 Optional로 받음) + Optional existingVote = voteRepository.findByBattleAndUser(battle, user); + Vote vote; if (existingVote.isPresent()) { vote = existingVote.get(); vote.updatePreVote(option); } else { - vote = BattleVote.createPreVote(user, battle, option); - battleVoteRepository.save(vote); + vote = Vote.createPreVote(user, battle, option); + voteRepository.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); } - UserBattleStep currentStep = status.step() == UserBattleStep.NONE - ? UserBattleStep.PRE_VOTE - : status.step(); + // 5. 현재 유지 중인 단계를 반환 (수정 후에도 COMPLETED 유지 가능) + UserBattleStep currentStep = (status.step() == UserBattleStep.NONE) ? UserBattleStep.PRE_VOTE : status.step(); return new VoteResultResponse(vote.getId(), currentStep); } @@ -141,15 +142,19 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque BattleOption option = battleOptionRepository.findById(request.optionId()) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) + Vote vote = voteRepository.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); @@ -158,14 +163,23 @@ 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); - for (BattleVote vote : votes) { + // 2. 해당 배틀의 모든 투표 조회 + List votes = voteRepository.findAllByBattle(battle); + + for (Vote vote : votes) { + // 3. 유저의 진행 단계 초기화 (이건 유저별로 다 해줘야 함) userBattleService.upsertStep(vote.getUser(), battle, UserBattleStep.NONE); + + // 4. 옵션별 카운트 감소 (필요 시) + if (vote.getPreVoteOption() != null) { /* 감소 로직 */ } + if (vote.getPostVoteOption() != null) { /* 감소 로직 */ } } - battleVoteRepository.deleteAllInBatch(votes); + // 5. 투표 데이터 일괄 삭제 + voteRepository.deleteAllInBatch(votes); } @Override @@ -175,10 +189,12 @@ public void completeTts(Long battleId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - BattleVote vote = battleVoteRepository.findByBattleAndUser(battle, user) + // 1. 엔티티 상태 변경 (isTtsListened = true) + Vote vote = voteRepository.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/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 78c1fc2b..c61fd52d 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -43,13 +43,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/js/**", "/css/**", "/favicon.ico", "/api/v1/admin/login", "/api/v1/admin", "/result/**", - "/api/v1/resources/images/**", - "/api/v1/resources/audio/**", - "/api/v1/resources/local/**", - "/api/v1/admob/reward/**", "/report/**", "/battle/**", - "/.well-known/**" + "/.well-known/**", + "/api/v1/resources/images/**", + "/api/v1/resources/audio/**", + "/api/v1/admob/reward/**" ).permitAll() // 2. 관리자 HTML 화면 렌더링 요청