diff --git a/build.gradle b/build.gradle index ff88316..8143451 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,10 @@ plugins { group = 'com.valanse' version = '0.0.1-SNAPSHOT' +ext['testcontainers.version'] = '2.0.2' +ext['commons-compress.version'] = '1.28.0' +ext['commons-lang3.version'] = '3.18.0' + java { toolchain { languageVersion = JavaLanguageVersion.of(17) @@ -48,6 +52,13 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' + // 마이그레이션 SQL을 실제 MySQL에 적용해 검증하는 Testcontainers 설정 + testImplementation enforcedPlatform('org.testcontainers:testcontainers-bom:2.0.2') + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.testcontainers:testcontainers-mysql' + testImplementation 'org.apache.commons:commons-compress:1.28.0' + testImplementation 'org.apache.commons:commons-lang3:3.18.0' + // JWT 설정 implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/valanse/valanse/common/api/GlobalExceptionHandler.java b/src/main/java/com/valanse/valanse/common/api/GlobalExceptionHandler.java index 17bfa8a..f4b7d80 100644 --- a/src/main/java/com/valanse/valanse/common/api/GlobalExceptionHandler.java +++ b/src/main/java/com/valanse/valanse/common/api/GlobalExceptionHandler.java @@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.http.HttpStatus; @@ -89,6 +92,30 @@ public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNot return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e + ) { + return badRequest(e.getParameterName() + " 파라미터를 입력해주세요."); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e + ) { + return badRequest(e.getName() + " 파라미터 형식이 올바르지 않습니다."); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFoundException( + NoResourceFoundException e + ) { + Map error = new HashMap<>(); + error.put("error", "요청한 API를 찾을 수 없습니다."); + error.put("status", HttpStatus.NOT_FOUND.value()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + /** * GlobalExceptionHandler의 handleUnexpectedException 기능을 수행하는 메서드입니다. */ @@ -146,4 +173,11 @@ private boolean isProdProfile() { .map(String::trim) .anyMatch("prod"::equals); } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("error", message); + error.put("status", HttpStatus.BAD_REQUEST.value()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } } diff --git a/src/main/java/com/valanse/valanse/common/config/ClockConfig.java b/src/main/java/com/valanse/valanse/common/config/ClockConfig.java new file mode 100644 index 0000000..6f2400c --- /dev/null +++ b/src/main/java/com/valanse/valanse/common/config/ClockConfig.java @@ -0,0 +1,16 @@ +package com.valanse.valanse.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +public class ClockConfig { + + @Bean + public Clock applicationClock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} diff --git a/src/main/java/com/valanse/valanse/common/message/VoteErrorMessage.java b/src/main/java/com/valanse/valanse/common/message/VoteErrorMessage.java index dd25870..69900b5 100644 --- a/src/main/java/com/valanse/valanse/common/message/VoteErrorMessage.java +++ b/src/main/java/com/valanse/valanse/common/message/VoteErrorMessage.java @@ -7,6 +7,7 @@ public enum VoteErrorMessage { CATEGORY_REQUIRED("카테고리를 입력해주세요."), HOT_ISSUE_VOTE_NOT_FOUND("핫이슈 투표를 찾을 수 없습니다."), TRENDING_VOTE_NOT_FOUND("인기 급상승 투표를 찾을 수 없습니다."), + TRENDING_PERIOD_INVALID("조회 기간은 1일 이상 30일 이하여야 합니다."), VOTE_NOT_FOUND("투표가 존재하지 않습니다."), VOTE_DETAIL_NOT_FOUND("투표를 찾을 수 없습니다."), VOTE_OPTION_NOT_FOUND("투표 선택지가 존재하지 않습니다."), diff --git a/src/main/java/com/valanse/valanse/controller/CommentController.java b/src/main/java/com/valanse/valanse/controller/CommentController.java index 73967a9..cdf34b5 100644 --- a/src/main/java/com/valanse/valanse/controller/CommentController.java +++ b/src/main/java/com/valanse/valanse/controller/CommentController.java @@ -120,10 +120,14 @@ public ResponseEntity likeComment( * 특정 부모 댓글의 대댓글 목록과 작성자/삭제 가능 여부 정보를 조회하는 메서드입니다. */ @GetMapping("/{commentId}/replies") - public ResponseEntity> getReplies( + public ResponseEntity getReplies( @PathVariable("voteId") Long voteId, - @PathVariable("commentId") Long commentId + @PathVariable("commentId") Long commentId, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size ) { + PaginationValidator.validatePageAndSize(page, size); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Member member = null; if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) { @@ -138,7 +142,8 @@ public ResponseEntity> getReplies( } } - List replies = commentService.getReplies(member, voteId, commentId); + Pageable pageable = PageRequest.of(page, size); + PagedCommentReplyResponse replies = commentService.getReplies(member, voteId, commentId, pageable); return ResponseEntity.ok(replies); } } diff --git a/src/main/java/com/valanse/valanse/controller/MemberController.java b/src/main/java/com/valanse/valanse/controller/MemberController.java index f38ce4a..7ac8c7b 100644 --- a/src/main/java/com/valanse/valanse/controller/MemberController.java +++ b/src/main/java/com/valanse/valanse/controller/MemberController.java @@ -1,6 +1,7 @@ package com.valanse.valanse.controller; import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.api.PaginationValidator; import com.valanse.valanse.common.message.MemberErrorMessage; import com.valanse.valanse.dto.MemberProfile.MemberMyPageResponse; import com.valanse.valanse.dto.MemberProfile.MemberProfileImageResponse; @@ -22,6 +23,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.context.SecurityContextHolder; @@ -152,9 +155,15 @@ public ResponseEntity getMyProfile() { * 회원의 포인트 변동 이력을 최신순 응답으로 조회하는 메서드입니다. */ @GetMapping("/point-history") - public ResponseEntity getPointHistory() { + public ResponseEntity getPointHistory( + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size + ) { + PaginationValidator.validatePageAndSize(page, size); + Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); - PointHistoryResponse response = pointService.getPointHistory(userId); + Pageable pageable = PageRequest.of(page, size); + PointHistoryResponse response = pointService.getPointHistory(userId, pageable); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/valanse/valanse/controller/MyCommentController.java b/src/main/java/com/valanse/valanse/controller/MyCommentController.java index 4e1fb00..0566490 100644 --- a/src/main/java/com/valanse/valanse/controller/MyCommentController.java +++ b/src/main/java/com/valanse/valanse/controller/MyCommentController.java @@ -1,5 +1,6 @@ package com.valanse.valanse.controller; +import com.valanse.valanse.common.api.PaginationValidator; import com.valanse.valanse.common.auth.SecurityUtils; import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.enums.Role; @@ -9,13 +10,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.Collections; -import java.util.List; - @Tag(name = "내 댓글 API", description = "내가 작성한 댓글 조회 및 삭제 기능") @RestController @RequiredArgsConstructor @@ -53,13 +54,17 @@ public ResponseEntity deleteMyComment(@PathVariable Long commentId) { /** * 사용자가 작성한 댓글 목록을 최신순 또는 오래된순으로 조회하는 메서드입니다. */ - public ResponseEntity> getMyComments( - @RequestParam(defaultValue = "latest") String sort) { + public ResponseEntity getMyComments( + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + PaginationValidator.validatePageAndSize(page, size); Long loginId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); var member = memberService.findById(loginId); + Pageable pageable = PageRequest.of(page, size); - List myComments = commentService.getMyComments(member, sort); + PagedMyCommentResponse myComments = commentService.getMyComments(member, sort, pageable); return ResponseEntity.ok(myComments); } diff --git a/src/main/java/com/valanse/valanse/controller/ReportController.java b/src/main/java/com/valanse/valanse/controller/ReportController.java index a843888..571f96f 100644 --- a/src/main/java/com/valanse/valanse/controller/ReportController.java +++ b/src/main/java/com/valanse/valanse/controller/ReportController.java @@ -1,23 +1,24 @@ package com.valanse.valanse.controller; +import com.valanse.valanse.common.api.PaginationValidator; import com.valanse.valanse.common.auth.SecurityUtils; import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.enums.ReportType; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.dto.Report.PagedReportedTargetResponse; import com.valanse.valanse.dto.Report.ReportRequest; import com.valanse.valanse.dto.Report.ReportDetailResponse; -import com.valanse.valanse.dto.Report.ReportedTargetResponse; import com.valanse.valanse.service.MemberService.MemberService; import com.valanse.valanse.service.ReportService.ReportService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.util.List; - @Tag(name = "신고 관련 API") @RestController @RequestMapping("/report") @@ -46,15 +47,20 @@ public ResponseEntity Report(@PathVariable Long targetId, @Valid @RequestB * 관리자가 신고 누적 대상 목록을 조회하는 메서드입니다. */ @GetMapping - public ResponseEntity> getReportedTargets( + public ResponseEntity getReportedTargets( @RequestParam ReportType type, - @RequestParam(defaultValue = "latest") String sort) { + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + PaginationValidator.validatePageAndSize(page, size); + Long loginId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); Member member = SecurityUtils.isCurrentUserAdmin() ? Member.builder().id(loginId).role(Role.ADMIN).build() : memberService.findById(loginId); - List results = reportService.getReportedTargets(member, type, sort); + Pageable pageable = PageRequest.of(page, size); + PagedReportedTargetResponse results = reportService.getReportedTargets(member, type, sort, pageable); return ResponseEntity.ok().body(results); } diff --git a/src/main/java/com/valanse/valanse/controller/VoteController.java b/src/main/java/com/valanse/valanse/controller/VoteController.java index 26641bb..85098db 100644 --- a/src/main/java/com/valanse/valanse/controller/VoteController.java +++ b/src/main/java/com/valanse/valanse/controller/VoteController.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.common.api.PaginationValidator; import com.valanse.valanse.common.auth.SecurityUtils; import com.valanse.valanse.common.message.VoteErrorMessage; import com.valanse.valanse.domain.Member; @@ -20,6 +21,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -58,11 +61,14 @@ public class VoteController { /** * 사용자가 직접 생성한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다. */ - public ResponseEntity> getMyCreatedVotes( + public ResponseEntity getMyCreatedVotes( @AuthenticationPrincipal UserDetails userDetails, @RequestParam(required = false) String category, // 추가 - @RequestParam(defaultValue = "latest") String sort + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size ) { + PaginationValidator.validatePageAndSize(page, size); Long memberId = Long.parseLong(userDetails.getUsername()); VoteCategory voteCategory = null; @@ -70,7 +76,8 @@ public ResponseEntity> getMyCreatedVotes( voteCategory = convertCategory(category); } - List votes = voteService.getMyCreatedVotes(memberId, sort, voteCategory); + Pageable pageable = PageRequest.of(page, size); + PagedVoteResponse votes = voteService.getMyCreatedVotes(memberId, sort, voteCategory, pageable); return ResponseEntity.ok(votes); } @@ -82,11 +89,14 @@ public ResponseEntity> getMyCreatedVotes( /** * 사용자가 참여한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다. */ - public ResponseEntity> getMyVotedVotes( + public ResponseEntity getMyVotedVotes( @AuthenticationPrincipal UserDetails userDetails, @RequestParam(required = false) String category, // 추가 - @RequestParam(defaultValue = "latest") String sort + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "10") int size ) { + PaginationValidator.validatePageAndSize(page, size); Long memberId = Long.parseLong(userDetails.getUsername()); VoteCategory voteCategory = null; @@ -94,7 +104,8 @@ public ResponseEntity> getMyVotedVotes( voteCategory = convertCategory(category); } - List votes = voteService.getMyVotedVotes(memberId, sort, voteCategory); + Pageable pageable = PageRequest.of(page, size); + PagedVoteResponse votes = voteService.getMyVotedVotes(memberId, sort, voteCategory, pageable); return ResponseEntity.ok(votes); } @@ -105,47 +116,17 @@ private VoteCategory convertCategory(String category) { .orElseThrow(() -> new ApiException(VoteErrorMessage.CATEGORY_INVALID.message(), HttpStatus.BAD_REQUEST)); } - //여기서 부터 영서 부분 @Operation( - summary = "오늘의 핫이슈 밸런스 게임 선택지들 반환", - description = "가장 많이 참여한 밸런스 게임 투표의 상세 정보와 옵션 목록을 조회합니다. " + - "총 참여자 수가 가장 많은 투표를 반환하며, 참여자 수가 동일한 경우 가장 최근에 생성된 투표를 우선합니다. \n" + - " voteId -> 가장 투표 참여 횟수가 많은 투표의 id\n" + - " title -> 가장 투표 참여 횟수가 많은 투표의 제목\n" + - " category -> 가장 투표 참여 횟수가 많은 투표의 카테고리\n" + - " totalParticipants -> 가장 투표 참여 횟수가 많은 투표의 총 투표 수\n" + - " createdBy -> 가장 투표 참여 횟수가 많은 투표를 생성한 사람의 닉네임\n" + - " createdAt -> 투표 생성 날짜\n" + - " options -> 투표 옵션 리스트 (content, vote_count)" + summary = "기간별 인기 급상승 밸런스 게임 반환", + description = "최근 days일 동안 생성된 유효 투표 참여와 삭제되지 않은 댓글/대댓글을 합산해 " + + "반응성이 높은 투표를 최대 5개 반환합니다. 기간 내 반응이 없으면 전체 누적 반응성으로 조회합니다. " + + "관리자 고정 투표가 있으면 첫 번째에 배치하고 나머지를 반응성 순위로 채웁니다." ) - /** - * 핫이슈로 노출할 투표를 고정값 또는 반응성 기준으로 선정하는 메서드입니다. - */ - @GetMapping("/best") // 새로운 엔드포인트: /votes/best (GET 메서드) - public ResponseEntity getHotIssueVote() { - HotIssueVoteResponse response = voteService.getHotIssueVote(); - return ResponseEntity.ok(response); - } - - // 인기 급상승 토픽 - @Operation( - summary = "인기 급상승 밸런스 게임 선택지들 반환", - description = "최근 7일 이내 반응성(투표수 + 댓글수)이 가장 높은 밸런스 게임을 반환합니다. " + - "7일 이내 새로 추가되는 반응이 없을 경우 이전 데이터를 유지합니다.\n" + - " voteId -> 7일 내 반응성이 가장 높은 투표의 id\n" + - " title -> 7일 내 반응성이 가장 높은 투표의 제목\n" + - " category -> 7일 내 반응성이 가장 높은 투표의 카테고리\n" + - " totalParticipants -> 7일 내 반응성이 가장 높은 투표의 총 투표 수\n" + - " createdBy -> 7일 내 반응성이 가장 높은 투표를 생성한 사람의 닉네임\n" + - " createdAt -> 투표 생성 날짜\n" + - " options -> 투표 옵션 리스트 (content, vote_count)" - ) - /** - * 최근 기간의 반응성을 기준으로 인기 급상승 투표를 선정하는 메서드입니다. - */ @GetMapping("/trending") - public ResponseEntity getTrendingVote() { - HotIssueVoteResponse response = voteService.getTrendingVote(); + public ResponseEntity getTrendingVotes( + @RequestParam(name = "days") int days + ) { + TrendingVotesResponse response = voteService.getTrendingVotes(days); return ResponseEntity.ok(response); } @@ -296,7 +277,7 @@ public ResponseEntity deleteVote(@PathVariable("voteId") Long voteId) { } /** - * 관리자 권한으로 투표의 HOT/TRENDING 고정 상태를 변경하는 메서드입니다. + * 관리자 권한으로 투표의 트렌딩 고정 상태를 변경하는 메서드입니다. */ @PatchMapping("/{voteId}/pin") @Operation(summary = "고정", description = "관리자 권한으로 게시물을 고정합니다.") diff --git a/src/main/java/com/valanse/valanse/domain/Member.java b/src/main/java/com/valanse/valanse/domain/Member.java index 232874b..839676d 100644 --- a/src/main/java/com/valanse/valanse/domain/Member.java +++ b/src/main/java/com/valanse/valanse/domain/Member.java @@ -51,6 +51,9 @@ public class Member extends BaseEntity { private String nickname; + @Builder.Default + private boolean isBot = false; + @Builder.Default @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) private List comments = new ArrayList<>(); diff --git a/src/main/java/com/valanse/valanse/domain/enums/PinType.java b/src/main/java/com/valanse/valanse/domain/enums/PinType.java index 47fabf2..aed9b69 100644 --- a/src/main/java/com/valanse/valanse/domain/enums/PinType.java +++ b/src/main/java/com/valanse/valanse/domain/enums/PinType.java @@ -4,5 +4,5 @@ * PinType 도메인에서 사용하는 고정 선택 값을 정의하는 enum 코드입니다. */ public enum PinType { - HOT, TRENDING, NONE + TRENDING, NONE } diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java index 1b2b8c8..957977e 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentReplyResponseDto.java @@ -1,5 +1,6 @@ package com.valanse.valanse.dto.Comment; +import com.querydsl.core.annotations.QueryProjection; import com.valanse.valanse.domain.enums.VoteLabel; import lombok.Builder; import lombok.Getter; @@ -15,6 +16,7 @@ public class CommentReplyResponseDto { private Long id; private String nickname; + private Boolean isBot; private String title; private LocalDateTime createdAt; private String content; @@ -26,4 +28,28 @@ public class CommentReplyResponseDto { private Long daysAgo; private Long hoursAgo; private boolean canDelete; + + /** + * CommentReplyResponseDto 의존성을 주입하거나 객체를 초기화하는 생성자입니다. + */ + @QueryProjection + public CommentReplyResponseDto(Long id, String nickname, Boolean isBot, String title, LocalDateTime createdAt, + String content, Integer likeCount, Integer replyCount, + Boolean isDeleted, LocalDateTime deletedAt, VoteLabel label, + Long daysAgo, Long hoursAgo, Boolean canDelete) { + this.id = id; + this.nickname = nickname != null ? nickname : "탈퇴한 사용자"; + this.isBot = isBot != null && isBot; + this.title = title; + this.createdAt = createdAt; + this.content = content; + this.likeCount = likeCount != null ? likeCount : 0; + this.replyCount = replyCount != null ? replyCount : 0; + this.isDeleted = Boolean.TRUE.equals(isDeleted); + this.deletedAt = deletedAt; + this.label = label; + this.daysAgo = daysAgo; + this.hoursAgo = hoursAgo; + this.canDelete = Boolean.TRUE.equals(canDelete); + } } diff --git a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java index b6d77b3..a558196 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/CommentResponseDto.java @@ -16,6 +16,7 @@ public class CommentResponseDto { private Long commentId; private Long voteId; private String nickname; + private Boolean isBot; private String title; private LocalDateTime commentCreatedAt; // 추가 private LocalDateTime voteCreatedAt; // 추가 @@ -32,7 +33,7 @@ public class CommentResponseDto { * CommentResponseDto 의존성을 주입하거나 객체를 초기화하는 생성자입니다. */ @QueryProjection - public CommentResponseDto(Long commentId, Long voteId, String nickname, String title, + public CommentResponseDto(Long commentId, Long voteId, String nickname, Boolean isBot, String title, LocalDateTime commentCreatedAt, LocalDateTime voteCreatedAt, // 추가 String content, Integer likeCount, Integer replyCount, LocalDateTime deletedAt, String voteOptionLabel, Long daysAgo, Long hoursAgo, @@ -40,6 +41,7 @@ public CommentResponseDto(Long commentId, Long voteId, String nickname, String t this.commentId = commentId; this.voteId = voteId; this.nickname = nickname != null ? nickname : "탈퇴한 사용자"; + this.isBot = isBot != null && isBot; this.title = title; this.commentCreatedAt = commentCreatedAt; this.voteCreatedAt = voteCreatedAt; diff --git a/src/main/java/com/valanse/valanse/dto/Comment/MyCommentResponseDto.java b/src/main/java/com/valanse/valanse/dto/Comment/MyCommentResponseDto.java index af2ffb3..33771a2 100644 --- a/src/main/java/com/valanse/valanse/dto/Comment/MyCommentResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Comment/MyCommentResponseDto.java @@ -20,6 +20,7 @@ public class MyCommentResponseDto { private String content; private Long memberId; private String memberName; + private Boolean isBot; private boolean isReply; private LocalDateTime createdAt; private LocalDateTime voteCreatedAt; // 투표 생성 시간 추가 @@ -28,6 +29,29 @@ public class MyCommentResponseDto { private String voteTitle; // 추가 private String voteOptionLabel; // 추가 + /** + * MyCommentResponseDto 의존성을 주입하거나 객체를 초기화하는 생성자입니다. + */ + @QueryProjection + public MyCommentResponseDto(Long id, String title, String content, Long memberId, String memberName, + Boolean isBot, Boolean isReply, LocalDateTime createdAt, LocalDateTime voteCreatedAt, + Long voteOwnerId, String voteOwnerNickname, String voteTitle, + String voteOptionLabel) { + this.id = id; + this.title = title; + this.content = content; + this.memberId = memberId; + this.memberName = memberName; + this.isBot = isBot != null && isBot; + this.isReply = Boolean.TRUE.equals(isReply); + this.createdAt = createdAt; + this.voteCreatedAt = voteCreatedAt; + this.voteOwnerId = voteOwnerId; + this.voteOwnerNickname = voteOwnerNickname; + this.voteTitle = voteTitle; + this.voteOptionLabel = voteOptionLabel; + } + /** * MyCommentResponseDto의 fromEntity 기능을 수행하는 메서드입니다. */ @@ -38,6 +62,7 @@ public static MyCommentResponseDto fromEntity(Comment comment) { .content(comment.getContent()) .memberId(comment.getMember().getId()) .memberName(comment.getMember().getName()) + .isBot(comment.getMember().isBot()) .isReply(comment.getParent() != null) .createdAt(comment.getCreatedAt()) // 댓글 생성 시간 .voteCreatedAt(comment.getCommentGroup().getVote().getCreatedAt()) // 투표 생성 시간 @@ -61,4 +86,3 @@ public static MyCommentResponseDto fromEntity(Comment comment) { - diff --git a/src/main/java/com/valanse/valanse/dto/Comment/PagedCommentReplyResponse.java b/src/main/java/com/valanse/valanse/dto/Comment/PagedCommentReplyResponse.java new file mode 100644 index 0000000..b745c10 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Comment/PagedCommentReplyResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Comment; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +/** + * 대댓글 페이지 응답 값을 담는 DTO 코드입니다. + */ +public class PagedCommentReplyResponse { + private List replies; + private int page; + private int size; + private boolean hasNext; +} diff --git a/src/main/java/com/valanse/valanse/dto/Comment/PagedMyCommentResponse.java b/src/main/java/com/valanse/valanse/dto/Comment/PagedMyCommentResponse.java new file mode 100644 index 0000000..8b07229 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Comment/PagedMyCommentResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Comment; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +/** + * 내 댓글 페이지 응답 값을 담는 DTO 코드입니다. + */ +public class PagedMyCommentResponse { + private List comments; + private int page; + private int size; + private boolean hasNext; +} diff --git a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java index 5de14f5..f9cdc61 100644 --- a/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java +++ b/src/main/java/com/valanse/valanse/dto/PointHistory/PointHistoryResponse.java @@ -8,7 +8,10 @@ * PointHistoryResponse API 응답 또는 계층 간 전달 값을 담는 DTO 코드입니다. */ public record PointHistoryResponse( - List pointHistory + List pointHistory, + int page, + int size, + boolean hasNext ) { /** * API 요청과 응답 데이터를 전달하기 위한 DTO 코드입니다. diff --git a/src/main/java/com/valanse/valanse/dto/Report/PagedReportedTargetResponse.java b/src/main/java/com/valanse/valanse/dto/Report/PagedReportedTargetResponse.java new file mode 100644 index 0000000..7f9f2b5 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Report/PagedReportedTargetResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Report; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +/** + * 신고 대상 목록 페이지 응답 값을 담는 DTO 코드입니다. + */ +public class PagedReportedTargetResponse { + private List reports; + private int page; + private int size; + private boolean hasNext; +} diff --git a/src/main/java/com/valanse/valanse/dto/Report/ReportedCommentResponse.java b/src/main/java/com/valanse/valanse/dto/Report/ReportedCommentResponse.java index 86b84d0..417755e 100644 --- a/src/main/java/com/valanse/valanse/dto/Report/ReportedCommentResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Report/ReportedCommentResponse.java @@ -14,6 +14,7 @@ public class ReportedCommentResponse { private Long commentId; private Long voteId; private String nickname; + private Boolean isBot; private String content; private Integer likeCount; private Integer replyCount; @@ -26,6 +27,7 @@ public ReportedCommentResponse(Comment comment) { this.content = comment.getContent(); this.likeCount = comment.getLikeCount(); this.nickname = comment.getMember() != null ? comment.getMember().getNickname() : "탈퇴한 사용자"; + this.isBot = comment.getMember() != null && comment.getMember().isBot(); this.replyCount = comment.getReplyCount(); this.voteId = comment.getCommentGroup().getVote().getId(); } diff --git a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java deleted file mode 100644 index 1865961..0000000 --- a/src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -// src/main/java/com/valanse/valanse/dto/Vote/HotIssueVoteResponse.java -package com.valanse.valanse.dto.Vote; - -import com.valanse.valanse.domain.enums.PinType; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -/** - * HotIssueVoteResponse API 응답 또는 계층 간 전달 값을 담는 DTO 코드입니다. - */ -public class HotIssueVoteResponse { - private Long voteId; // 가장 투표 참여 횟수가 많은 투표의 id - private String title; // 가장 투표 참여 횟수가 많은 투표의 제목 - private String content; - private String category; // 가장 투표 참여 횟수가 많은 투표의 카테고리 - private Integer totalParticipants; // 가장 투표 참여 횟수가 많은 투표의 총 투표 수 - private String createdBy; // 가장 투표 참여 횟수가 많은 투표를 생성한 사람의 닉네임 - private String creatorTitle; - private LocalDateTime createdAt; // 투표 생성 날짜 - private PinType pinType; // 고정 여부 - private List options; // 투표 옵션 리스트 (content, vote_count) -} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/PagedVoteResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/PagedVoteResponse.java new file mode 100644 index 0000000..35f717f --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/PagedVoteResponse.java @@ -0,0 +1,18 @@ +package com.valanse.valanse.dto.Vote; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +/** + * 투표 목록 페이지 응답 값을 담는 DTO 코드입니다. + */ +public class PagedVoteResponse { + private List votes; + private int page; + private int size; + private boolean hasNext; +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/TrendingDisplayType.java b/src/main/java/com/valanse/valanse/dto/Vote/TrendingDisplayType.java new file mode 100644 index 0000000..c0fefe6 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/TrendingDisplayType.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.dto.Vote; + +/** + * 트렌딩 목록에 투표가 포함된 이유를 나타냅니다. + */ +public enum TrendingDisplayType { + PINNED, + RANKED +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/TrendingScoreType.java b/src/main/java/com/valanse/valanse/dto/Vote/TrendingScoreType.java new file mode 100644 index 0000000..18c5039 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/TrendingScoreType.java @@ -0,0 +1,9 @@ +package com.valanse.valanse.dto.Vote; + +/** + * 트렌딩 점수가 요청 기간 기준인지 전체 누적 기준인지 나타냅니다. + */ +public enum TrendingScoreType { + PERIOD, + ALL_TIME +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteItemResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteItemResponse.java new file mode 100644 index 0000000..656dd8e --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteItemResponse.java @@ -0,0 +1,31 @@ +package com.valanse.valanse.dto.Vote; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendingVoteItemResponse { + private int displayOrder; + private TrendingDisplayType displayType; + private Long voteId; + private String title; + private String content; + private String category; + private long reactivityScore; + private long voteReactionCount; + private long commentReactionCount; + private Integer totalParticipants; + private String createdBy; + private Boolean isBot; + private String creatorTitle; + private LocalDateTime createdAt; + private List options; +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteScore.java b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteScore.java new file mode 100644 index 0000000..9971029 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVoteScore.java @@ -0,0 +1,19 @@ +package com.valanse.valanse.dto.Vote; + +import java.time.LocalDateTime; + +/** + * Repository가 반환하는 투표별 반응성 집계 결과입니다. + */ +public record TrendingVoteScore( + Long voteId, + long voteReactionCount, + long commentReactionCount, + long reactivityScore, + LocalDateTime latestReactionAt +) { + + public static TrendingVoteScore empty(Long voteId) { + return new TrendingVoteScore(voteId, 0L, 0L, 0L, null); + } +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/TrendingVotesResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVotesResponse.java new file mode 100644 index 0000000..3f1c554 --- /dev/null +++ b/src/main/java/com/valanse/valanse/dto/Vote/TrendingVotesResponse.java @@ -0,0 +1,22 @@ +package com.valanse.valanse.dto.Vote; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrendingVotesResponse { + private int requestedDays; + private LocalDateTime from; + private LocalDateTime to; + private TrendingScoreType scoreType; + private boolean fallbackApplied; + private List votes; +} diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java index 29516c7..50a44e7 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteDetailResponse.java @@ -19,6 +19,7 @@ public class VoteDetailResponse { private VoteCategory category; private Integer totalVoteCount; private String creatorNickname; // 생성자의 닉네임을 원한다고 가정 + private Boolean isBot; private String creatorTitle; private LocalDateTime createdAt; private List options; // 투표 옵션을 위한 DTO diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java index 30e6833..e6c2de0 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteListResponse.java @@ -36,6 +36,7 @@ public static class VoteDto { private String category; // ENUM 이름을 String으로 반환 private Long member_id; private String nickname; + private Boolean isBot; private String member_title; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDateTime created_at; diff --git a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java index 6b6f9e3..27b4db2 100644 --- a/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java +++ b/src/main/java/com/valanse/valanse/dto/Vote/VoteResponseDto.java @@ -19,6 +19,7 @@ public class VoteResponseDto { private int totalVoteCount; private String createdAt; private String creatorTitle; + private Boolean isBot; private List options; /** @@ -39,6 +40,7 @@ public VoteResponseDto(Vote vote, String creatorTitle) { this.totalVoteCount = vote.getTotalVoteCount(); this.createdAt = vote.getCreatedAt().toLocalDate().toString(); this.creatorTitle = creatorTitle; + this.isBot = vote.getMember() != null && vote.getMember().isBot(); this.options = vote.getVoteOptions() .stream() .map(option -> new OptionDto(option.getContent(), option.getImageUrl())) diff --git a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryCustom.java b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryCustom.java index e21b1da..41d09ab 100644 --- a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryCustom.java +++ b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryCustom.java @@ -2,7 +2,9 @@ import com.valanse.valanse.domain.Comment; +import com.valanse.valanse.dto.Comment.CommentReplyResponseDto; import com.valanse.valanse.dto.Comment.CommentResponseDto; +import com.valanse.valanse.dto.Comment.MyCommentResponseDto; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -14,8 +16,11 @@ public interface CommentRepositoryCustom { Slice findCommentsByVoteIdSlice(Long voteId, String sort, Pageable pageable, Long loginId,boolean isAdmin); + Slice findMyCommentsSlice(Long memberId, String sort, Pageable pageable); + + Slice findRepliesByParentIdSlice(Long voteId, Long parentCommentId, Pageable pageable, Long loginId, boolean isAdmin); + Optional findMostLikedCommentByVoteId(Long voteId); Long countActiveCommentsByVoteId(Long voteId); } - diff --git a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java index 6911635..66d3e64 100644 --- a/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/CommentRepositoryCustom/CommentRepositoryImpl.java @@ -2,7 +2,6 @@ //import com.querydsl.core.types.dsl.CaseBuilder; //import static com.querydsl.core.types.dsl.Expressions.constant; -import com.querydsl.core.types.Expression; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberTemplate; @@ -10,8 +9,12 @@ import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.mapping.QMemberVoteOption; +import com.valanse.valanse.dto.Comment.CommentReplyResponseDto; import com.valanse.valanse.dto.Comment.CommentResponseDto; +import com.valanse.valanse.dto.Comment.MyCommentResponseDto; +import com.valanse.valanse.dto.Comment.QCommentReplyResponseDto; import com.valanse.valanse.dto.Comment.QCommentResponseDto; +import com.valanse.valanse.dto.Comment.QMyCommentResponseDto; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -75,6 +78,7 @@ public Slice findCommentsByVoteIdSlice(Long voteId, String s comment.id, vote.id, profile.nickname, + member.isBot, title.name, comment.createdAt, vote.createdAt, @@ -109,6 +113,132 @@ public Slice findCommentsByVoteIdSlice(Long voteId, String s return new SliceImpl<>(result, pageable, hasNext); } + /** + * MyCommentsSlice 조건에 맞는 데이터를 찾는 메서드입니다. + */ + @Override + public Slice findMyCommentsSlice(Long memberId, String sort, Pageable pageable) { + QComment comment = QComment.comment; + QMember member = QMember.member; + QVote vote = QVote.vote; + QMember voteOwner = new QMember("voteOwner"); + QMemberProfile voteOwnerProfile = new QMemberProfile("voteOwnerProfile"); + QVoteOption voteOption = QVoteOption.voteOption; + + List result = queryFactory + .select(new QMyCommentResponseDto( + comment.id, + comment.title, + comment.content, + member.id, + member.name, + member.isBot, + comment.parent.isNotNull(), + comment.createdAt, + vote.createdAt, + voteOwner.id, + voteOwnerProfile.nickname, + vote.title, + voteOption.label.stringValue() + )) + .from(comment) + .join(comment.member, member) + .join(comment.commentGroup.vote, vote) + .join(vote.member, voteOwner) + .leftJoin(voteOwner.profile, voteOwnerProfile) + .leftJoin(comment.voteOption, voteOption) + .where( + member.id.eq(memberId), + comment.deletedAt.isNull() + ) + .orderBy("oldest".equalsIgnoreCase(sort) ? comment.createdAt.asc() : comment.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = result.size() > pageable.getPageSize(); + if (hasNext) { + result.remove(result.size() - 1); + } + + return new SliceImpl<>(result, pageable, hasNext); + } + + /** + * RepliesByParentIdSlice 조건에 맞는 데이터를 찾는 메서드입니다. + */ + @Override + public Slice findRepliesByParentIdSlice(Long voteId, Long parentCommentId, Pageable pageable, Long loginId, boolean isAdmin) { + QComment reply = QComment.comment; + QMember member = QMember.member; + QVote vote = QVote.vote; + QMemberVoteOption mvo = QMemberVoteOption.memberVoteOption; + QVoteOption voteOption = QVoteOption.voteOption; + QMemberProfile profile = QMemberProfile.memberProfile; + QMemberProfileTitle memberProfileTitle = QMemberProfileTitle.memberProfileTitle; + QTitle title = QTitle.title; + + NumberTemplate daysAgo = Expressions.numberTemplate( + Long.class, + "floor(timestampdiff(hour, {0}, current_timestamp) / 24)", + reply.createdAt + ); + + NumberTemplate hoursAgo = Expressions.numberTemplate( + Long.class, + "mod(timestampdiff(hour, {0}, current_timestamp), 24)", + reply.createdAt + ); + + BooleanExpression canDelete = Expressions.asBoolean(false); + if (loginId != null) { + canDelete = isAdmin + ? Expressions.asBoolean(true) + : reply.member.id.eq(loginId); + } + + List result = queryFactory + .select(new QCommentReplyResponseDto( + reply.id, + profile.nickname, + member.isBot, + title.name, + reply.createdAt, + reply.content, + reply.likeCount, + reply.replyCount, + reply.deletedAt.isNotNull(), + reply.deletedAt, + voteOption.label, + daysAgo, + hoursAgo, + canDelete + )) + .from(reply) + .leftJoin(reply.member, member) + .leftJoin(member.profile, profile) + .leftJoin(profile.memberProfileTitles, memberProfileTitle).on(memberProfileTitle.equipped.isTrue()) + .leftJoin(memberProfileTitle.title, title) + .join(reply.commentGroup.vote, vote) + .leftJoin(mvo).on(mvo.member.eq(member).and(mvo.vote.eq(vote))) + .leftJoin(mvo.voteOption, voteOption) + .where( + vote.id.eq(voteId), + reply.parent.id.eq(parentCommentId) + ) + .orderBy(reply.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = result.size() > pageable.getPageSize(); + if (hasNext) { + result.remove(result.size() - 1); + } + + return new SliceImpl<>(result, pageable, hasNext); + } + /** * MostLikedCommentByVoteId 조건에 맞는 데이터를 찾는 메서드입니다. */ diff --git a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java index 4fb8647..55ae17e 100644 --- a/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java +++ b/src/main/java/com/valanse/valanse/repository/MemberProfileRepository.java @@ -17,6 +17,7 @@ public interface MemberProfileRepository extends JpaRepository { Optional findByMemberId(Long id); + List findAllByMemberIdIn(List memberIds); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select mp from MemberProfile mp where mp.member.id = :memberId") diff --git a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java index d3a0c0b..67088c6 100644 --- a/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java +++ b/src/main/java/com/valanse/valanse/repository/PointHistoryRepository.java @@ -2,6 +2,8 @@ import com.valanse.valanse.domain.PointHistory; import com.valanse.valanse.domain.enums.PointType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,6 +16,7 @@ */ public interface PointHistoryRepository extends JpaRepository { List findByMemberId(Long memberId); + Slice findByMemberIdOrderByCreatedAtDescIdDesc(Long memberId, Pageable pageable); @Query("SELECT COUNT(p) FROM PointHistory p WHERE p.member.id = :memberId AND p.type = :type AND p.createdAt >= :from") long countByMemberIdAndTypeAndCreatedAtAfter( diff --git a/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryCustom.java b/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryCustom.java index a9084a3..37ba53a 100644 --- a/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryCustom.java +++ b/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryCustom.java @@ -1,14 +1,13 @@ package com.valanse.valanse.repository.ReportRepositoryCustom; -import com.querydsl.core.Tuple; import com.valanse.valanse.domain.enums.ReportType; import com.valanse.valanse.dto.Report.ReportedTargetResponse; - -import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; /** * DB 조회와 저장을 담당하는 레포지토리 코드입니다. */ public interface ReportRepositoryCustom { - List findReportedTargets(ReportType type, String sort); + Slice findReportedTargets(ReportType type, String sort, Pageable pageable); } diff --git a/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryImpl.java index 782e556..43d8293 100644 --- a/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/ReportRepositoryCustom/ReportRepositoryImpl.java @@ -1,8 +1,11 @@ package com.valanse.valanse.repository.ReportRepositoryCustom; import com.querydsl.core.Tuple; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.valanse.valanse.domain.QComment; import com.valanse.valanse.domain.QReport; +import com.valanse.valanse.domain.QVote; import com.valanse.valanse.domain.Comment; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.enums.ReportType; @@ -12,6 +15,9 @@ import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.VoteRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; import java.util.List; @@ -34,21 +40,51 @@ public class ReportRepositoryImpl implements ReportRepositoryCustom { * ReportedTargets 조건에 맞는 데이터를 찾는 메서드입니다. */ @Override - public List findReportedTargets(ReportType type, String sort) { + public Slice findReportedTargets(ReportType type, String sort, Pageable pageable) { QReport report = QReport.report; + QVote reportedVote = QVote.vote; + QComment reportedComment = QComment.comment; + OrderSpecifier orderSpecifier = sort.equalsIgnoreCase("popular") + ? report.count().desc() + : report.createdAt.max().desc(); - // 기본은 최신순, sort 값에 따라서 인기순 정렬 가능 - List tuples = queryFactory - .select(report.targetId, report.count()) - .from(report) - .where(report.reportType.eq(type)) - .groupBy(report.targetId) - .orderBy( - sort.equalsIgnoreCase("popular") - ? report.count().desc() - : report.createdAt.max().desc() - ) - .fetch(); + List tuples; + if (type == ReportType.VOTE) { + tuples = queryFactory + .select(report.targetId, report.count()) + .from(report) + .join(reportedVote).on(reportedVote.id.eq(report.targetId)) + .where( + report.reportType.eq(type), + reportedVote.deletedAt.isNull() + ) + .groupBy(report.targetId) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + } else if (type == ReportType.COMMENT) { + tuples = queryFactory + .select(report.targetId, report.count()) + .from(report) + .join(reportedComment).on(reportedComment.id.eq(report.targetId)) + .where( + report.reportType.eq(type), + reportedComment.deletedAt.isNull() + ) + .groupBy(report.targetId) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + } else { + return new SliceImpl<>(List.of(), pageable, false); + } + + boolean hasNext = tuples.size() > pageable.getPageSize(); + if (hasNext) { + tuples.remove(tuples.size() - 1); + } List targetIds = tuples.stream() .map(tuple -> tuple.get(report.targetId)) @@ -56,7 +92,7 @@ public List findReportedTargets(ReportType type, String .toList(); if (targetIds.isEmpty()) { - return List.of(); + return new SliceImpl<>(List.of(), pageable, hasNext); } Map votesById = type == ReportType.VOTE @@ -68,8 +104,7 @@ public List findReportedTargets(ReportType type, String .collect(Collectors.toMap(Comment::getId, Function.identity())) : Map.of(); - // type에 따라서 분기 처리 - return tuples.stream() + List responses = tuples.stream() .map(t -> { Long targetId = t.get(report.targetId); Long count = t.get(report.count()); @@ -95,6 +130,8 @@ public List findReportedTargets(ReportType type, String }) .filter(Objects::nonNull) .toList(); + + return new SliceImpl<>(responses, pageable, hasNext); } } diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepository.java b/src/main/java/com/valanse/valanse/repository/VoteRepository.java index 7426177..6345795 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepository.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepository.java @@ -10,6 +10,7 @@ import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -18,7 +19,6 @@ import java.util.List; import java.util.Optional; -import java.time.LocalDateTime; @Repository /** @@ -29,16 +29,26 @@ public interface VoteRepository extends JpaRepository, VoteRepositor // 내가 생성한 투표 List findAllByMemberOrderByCreatedAtDesc(Member member); List findAllByMemberOrderByCreatedAtAsc(Member member); + Slice findSliceByMemberOrderByCreatedAtDesc(Member member, Pageable pageable); + Slice findSliceByMemberOrderByCreatedAtAsc(Member member, Pageable pageable); @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member AND v.category = :category ORDER BY v.createdAt DESC") List findAllByMemberVotedAndCategoryOrderByCreatedAtDesc(@Param("member") Member member, @Param("category") VoteCategory category); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member AND v.category = :category ORDER BY v.createdAt DESC") + Slice findSliceByMemberVotedAndCategoryOrderByCreatedAtDesc(@Param("member") Member member, @Param("category") VoteCategory category, Pageable pageable); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member AND v.category = :category ORDER BY v.createdAt ASC") List findAllByMemberVotedAndCategoryOrderByCreatedAtAsc(@Param("member") Member member, @Param("category") VoteCategory category); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member AND v.category = :category ORDER BY v.createdAt ASC") + Slice findSliceByMemberVotedAndCategoryOrderByCreatedAtAsc(@Param("member") Member member, @Param("category") VoteCategory category, Pageable pageable); + List findAllByMemberAndCategoryOrderByCreatedAtDesc(Member member, VoteCategory category); + Slice findSliceByMemberAndCategoryOrderByCreatedAtDesc(Member member, VoteCategory category, Pageable pageable); List findAllByMemberAndCategoryOrderByCreatedAtAsc(Member member, VoteCategory category); + Slice findSliceByMemberAndCategoryOrderByCreatedAtAsc(Member member, VoteCategory category, Pageable pageable); @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member AND v.category = :category ORDER BY v.createdAt DESC") List findAllByMemberVotedAndCategoryOrderByCreatedAtDesc(@Param("member") Member member, @Param("category") String category); @@ -49,26 +59,14 @@ public interface VoteRepository extends JpaRepository, VoteRepositor @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member ORDER BY v.createdAt DESC") List findAllByMemberVotedOrderByCreatedAtDesc(@Param("member") Member member); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member ORDER BY v.createdAt DESC") + Slice findSliceByMemberVotedOrderByCreatedAtDesc(@Param("member") Member member, Pageable pageable); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member ORDER BY v.createdAt ASC") List findAllByMemberVotedOrderByCreatedAtAsc(@Param("member") Member member); - //여기서 부터 영서 부분 - // 가장 많은 totalVoteCount를 가진 투표 중 가장 최근에 생성된 투표를 조회 - Optional findTopByOrderByTotalVoteCountDescCreatedAtDesc(); // - - // 추가: 특정 생성일시 이후의 투표 중 가장 많은 투표수를 가진 투표를 조회 - Optional findTopByCreatedAtAfterOrderByTotalVoteCountDescCreatedAtDesc(LocalDateTime createdAt); - - // 추가: 작일 동안 반응성이 가장 높은 투표 조회 - Optional findTopByReactivityUpdatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc( - LocalDateTime start, LocalDateTime end - ); - - Optional findTopByCreatedAtBetweenOrderByReactivityScoreDescCreatedAtDesc( - LocalDateTime start, LocalDateTime end); - - // 추가: 전체 기간 중 반응성이 가장 높은 투표 조회 (작일 데이터 없을 때 사용) - Optional findTopByOrderByReactivityScoreDescCreatedAtDesc(); + @Query("SELECT DISTINCT v FROM Vote v JOIN v.voteOptions vo JOIN vo.memberVoteOptions mvo WHERE mvo.member = :member ORDER BY v.createdAt ASC") + Slice findSliceByMemberVotedOrderByCreatedAtAsc(@Param("member") Member member, Pageable pageable); // 특정 카테고리에 해당하는 투표를 페이징하여 조회 Page findByCategory(VoteCategory category, Pageable pageable); diff --git a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java index 88f3d66..1bde663 100644 --- a/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VoteRepositoryImpl.java @@ -2,7 +2,6 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; -import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.valanse.valanse.domain.QCommentGroup; import com.valanse.valanse.domain.QMember; @@ -10,18 +9,20 @@ import com.valanse.valanse.domain.QVote; import com.valanse.valanse.domain.Vote; import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.dto.Vote.TrendingVoteScore; import com.valanse.valanse.repository.VotesCheckRepositoryCustom.VoteRepositoryCustom; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import static com.valanse.valanse.domain.QComment.comment; import static com.valanse.valanse.domain.QVoteOption.voteOption; -import static com.valanse.valanse.domain.mapping.QMemberVoteOption.memberVoteOption; @Repository @RequiredArgsConstructor @@ -31,6 +32,7 @@ public class VoteRepositoryImpl implements VoteRepositoryCustom { private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; private final QVote vote = QVote.vote; private final QMember member = QMember.member; private final QMemberProfile memberProfile = QMemberProfile.memberProfile; @@ -113,65 +115,258 @@ public List findVotesByCursor(String category, String sort, String cursor, .fetch(); } - /** - * 투표 수와 댓글 수를 합산해 핫이슈 후보를 조회하는 메서드입니다. - */ @Override - public Optional findHotIssueVote() { - return Optional.ofNullable( - queryFactory - .selectFrom(vote) - .leftJoin(vote.commentGroup, commentGroup) - .orderBy( - vote.totalVoteCount - .add(commentGroup.totalCommentCount.coalesce(0)) - .desc(), - vote.createdAt.desc() // 점수 같을 때 최신순 - ) - .fetchFirst()); + public List findTopTrendingVotes( + LocalDateTime from, + LocalDateTime to, + int limit + ) { + Query query = entityManager.createNativeQuery(""" + SELECT reactions.vote_id, + SUM(reactions.vote_count) AS vote_reaction_count, + SUM(reactions.comment_count) AS comment_reaction_count, + SUM(reactions.vote_count + reactions.comment_count) AS reactivity_score, + MAX(reactions.latest_reaction_at) AS latest_reaction_at + FROM ( + SELECT mvo.vote_id, + COUNT(*) AS vote_count, + 0 AS comment_count, + MAX(mvo.created_at) AS latest_reaction_at + FROM member_vote_option mvo + LEFT JOIN member m ON m.id = mvo.member_id + WHERE mvo.created_at >= :from + AND mvo.created_at < :to + AND mvo.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY mvo.vote_id + + UNION ALL + + SELECT cg.vote_id, + 0 AS vote_count, + COUNT(*) AS comment_count, + MAX(c.created_at) AS latest_reaction_at + FROM `comment` c + JOIN comment_group cg ON cg.id = c.comment_group_id + LEFT JOIN member m ON m.id = c.member_id + WHERE c.created_at >= :from + AND c.created_at < :to + AND c.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY cg.vote_id + ) reactions + JOIN vote v ON v.id = reactions.vote_id + WHERE v.deleted_at IS NULL + GROUP BY reactions.vote_id + ORDER BY reactivity_score DESC, + latest_reaction_at DESC, + reactions.vote_id DESC + LIMIT :limit + """); + + query.setParameter("from", from); + query.setParameter("to", to); + query.setParameter("limit", limit); + return toTrendingVoteScores(query.getResultList()); + } + + @Override + public List findTopAllTimeTrendingVotes(int limit) { + Query query = entityManager.createNativeQuery(""" + SELECT v.id AS vote_id, + COALESCE(reactions.vote_count, 0) AS vote_reaction_count, + COALESCE(reactions.comment_count, 0) AS comment_reaction_count, + COALESCE(reactions.vote_count, 0) + COALESCE(reactions.comment_count, 0) AS reactivity_score, + reactions.latest_reaction_at + FROM vote v + LEFT JOIN ( + SELECT activity.vote_id, + SUM(activity.vote_count) AS vote_count, + SUM(activity.comment_count) AS comment_count, + MAX(activity.latest_reaction_at) AS latest_reaction_at + FROM ( + SELECT mvo.vote_id, + COUNT(*) AS vote_count, + 0 AS comment_count, + MAX(mvo.created_at) AS latest_reaction_at + FROM member_vote_option mvo + LEFT JOIN member m ON m.id = mvo.member_id + WHERE mvo.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY mvo.vote_id + + UNION ALL + + SELECT cg.vote_id, + 0 AS vote_count, + COUNT(*) AS comment_count, + MAX(c.created_at) AS latest_reaction_at + FROM `comment` c + JOIN comment_group cg ON cg.id = c.comment_group_id + LEFT JOIN member m ON m.id = c.member_id + WHERE c.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY cg.vote_id + ) activity + GROUP BY activity.vote_id + ) reactions ON reactions.vote_id = v.id + WHERE v.deleted_at IS NULL + ORDER BY reactivity_score DESC, + latest_reaction_at DESC, + v.id DESC + LIMIT :limit + """); + + query.setParameter("limit", limit); + return toTrendingVoteScores(query.getResultList()); + } + + @Override + public Optional findTrendingScoreByVoteId( + Long voteId, + LocalDateTime from, + LocalDateTime to + ) { + Query query = entityManager.createNativeQuery(""" + SELECT reactions.vote_id, + SUM(reactions.vote_count) AS vote_reaction_count, + SUM(reactions.comment_count) AS comment_reaction_count, + SUM(reactions.vote_count + reactions.comment_count) AS reactivity_score, + MAX(reactions.latest_reaction_at) AS latest_reaction_at + FROM ( + SELECT mvo.vote_id, + COUNT(*) AS vote_count, + 0 AS comment_count, + MAX(mvo.created_at) AS latest_reaction_at + FROM member_vote_option mvo + LEFT JOIN member m ON m.id = mvo.member_id + WHERE mvo.vote_id = ?1 + AND mvo.created_at >= ?2 + AND mvo.created_at < ?3 + AND mvo.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY mvo.vote_id + + UNION ALL + + SELECT cg.vote_id, + 0 AS vote_count, + COUNT(*) AS comment_count, + MAX(c.created_at) AS latest_reaction_at + FROM `comment` c + JOIN comment_group cg ON cg.id = c.comment_group_id + LEFT JOIN member m ON m.id = c.member_id + WHERE cg.vote_id = ?1 + AND c.created_at >= ?2 + AND c.created_at < ?3 + AND c.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY cg.vote_id + ) reactions + JOIN vote v ON v.id = reactions.vote_id + WHERE v.deleted_at IS NULL + GROUP BY reactions.vote_id + """); + + query.setParameter(1, voteId); + query.setParameter(2, from); + query.setParameter(3, to); + return toTrendingVoteScores(query.getResultList()).stream().findFirst(); + } + + @Override + public Optional findAllTimeTrendingScoreByVoteId(Long voteId) { + Query query = entityManager.createNativeQuery(""" + SELECT v.id AS vote_id, + COALESCE(reactions.vote_count, 0) AS vote_reaction_count, + COALESCE(reactions.comment_count, 0) AS comment_reaction_count, + COALESCE(reactions.vote_count, 0) + COALESCE(reactions.comment_count, 0) AS reactivity_score, + reactions.latest_reaction_at + FROM vote v + LEFT JOIN ( + SELECT activity.vote_id, + SUM(activity.vote_count) AS vote_count, + SUM(activity.comment_count) AS comment_count, + MAX(activity.latest_reaction_at) AS latest_reaction_at + FROM ( + SELECT mvo.vote_id, + COUNT(*) AS vote_count, + 0 AS comment_count, + MAX(mvo.created_at) AS latest_reaction_at + FROM member_vote_option mvo + LEFT JOIN member m ON m.id = mvo.member_id + WHERE mvo.vote_id = ?1 + AND mvo.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY mvo.vote_id + + UNION ALL + + SELECT cg.vote_id, + 0 AS vote_count, + COUNT(*) AS comment_count, + MAX(c.created_at) AS latest_reaction_at + FROM `comment` c + JOIN comment_group cg ON cg.id = c.comment_group_id + LEFT JOIN member m ON m.id = c.member_id + WHERE cg.vote_id = ?1 + AND c.deleted_at IS NULL + AND (m.id IS NULL OR m.is_bot = FALSE) + GROUP BY cg.vote_id + ) activity + GROUP BY activity.vote_id + ) reactions ON reactions.vote_id = v.id + WHERE v.id = ?1 + AND v.deleted_at IS NULL + """); + + query.setParameter(1, voteId); + return toTrendingVoteScores(query.getResultList()).stream().findFirst(); } - /** - * 기간 내 댓글 또는 투표 활동이 있는 투표 중 반응성이 높은 항목을 조회하는 메서드입니다. - */ @Override - public Optional findTrendingVote(LocalDateTime from, LocalDateTime to) { - return Optional.ofNullable( - queryFactory - .selectFrom(vote) - .leftJoin(vote.commentGroup, commentGroup) - .where( - // 기간 내 댓글 존재 - JPAExpressions - .selectOne() - .from(comment) - .where( - comment.commentGroup.eq(commentGroup), - comment.createdAt.between(from, to), - comment.deletedAt.isNull() - ) - .exists() - .or( - // 기간 내 투표 존재 - JPAExpressions - .selectOne() - .from(memberVoteOption) - .join(memberVoteOption.voteOption, voteOption) - .where( - voteOption.vote.eq(vote), - memberVoteOption.createdAt.between(from, to) - ) - .exists() - ) - ) - .orderBy( - vote.totalVoteCount - .add(commentGroup.totalCommentCount.coalesce(0)) - .desc(), - vote.createdAt.desc() // 점수 같을 때 최신순 - ) - .fetchFirst()); + public List findTrendingVoteDetailsByIds(List voteIds) { + if (voteIds.isEmpty()) { + return List.of(); + } + + return queryFactory + .selectFrom(vote) + .distinct() + .leftJoin(vote.member, member).fetchJoin() + .leftJoin(member.profile, memberProfile).fetchJoin() + .leftJoin(vote.voteOptions, voteOption).fetchJoin() + .where(vote.id.in(voteIds)) + .fetch(); + } + + private List toTrendingVoteScores(List rows) { + return rows.stream() + .map(row -> toTrendingVoteScore((Object[]) row)) + .toList(); + } + private TrendingVoteScore toTrendingVoteScore(Object[] row) { + return new TrendingVoteScore( + ((Number) row[0]).longValue(), + ((Number) row[1]).longValue(), + ((Number) row[2]).longValue(), + ((Number) row[3]).longValue(), + toLocalDateTime(row[4]) + ); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime; + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime(); + } + throw new IllegalArgumentException("지원하지 않는 날짜 타입입니다: " + value.getClass().getName()); } } diff --git a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java index 8478914..f5c972e 100644 --- a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java +++ b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteRepositoryCustom.java @@ -1,6 +1,7 @@ package com.valanse.valanse.repository.VotesCheckRepositoryCustom; import com.valanse.valanse.domain.Vote; +import com.valanse.valanse.dto.Vote.TrendingVoteScore; import java.time.LocalDateTime; import java.util.List; @@ -11,6 +12,9 @@ */ public interface VoteRepositoryCustom { List findVotesByCursor(String category, String sort, String cursor, int size); - Optional findHotIssueVote(); - Optional findTrendingVote(LocalDateTime from, LocalDateTime to); + List findTopTrendingVotes(LocalDateTime from, LocalDateTime to, int limit); + List findTopAllTimeTrendingVotes(int limit); + Optional findTrendingScoreByVoteId(Long voteId, LocalDateTime from, LocalDateTime to); + Optional findAllTimeTrendingScoreByVoteId(Long voteId); + List findTrendingVoteDetailsByIds(List voteIds); } diff --git a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteResultQueryRepositoryImpl.java b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteResultQueryRepositoryImpl.java index 033b975..0813e84 100644 --- a/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteResultQueryRepositoryImpl.java +++ b/src/main/java/com/valanse/valanse/repository/VotesCheckRepositoryCustom/VoteResultQueryRepositoryImpl.java @@ -59,7 +59,8 @@ public VoteGenderResultResponseDto findVoteResultByGender(Long voteId, String ge .join(member.profile, profile) .where( mvo.voteOption.vote.id.eq(voteId), - profile.gender.stringValue().eq(gender) + profile.gender.stringValue().eq(gender), + member.isBot.isFalse() ) .fetchOne(); @@ -74,7 +75,8 @@ public VoteGenderResultResponseDto findVoteResultByGender(Long voteId, String ge .join(member.profile, profile) .where( mvo.voteOption.vote.id.eq(voteId), - profile.gender.stringValue().eq(gender) + profile.gender.stringValue().eq(gender), + member.isBot.isFalse() ) .groupBy(option.label, option.content) .fetch(); @@ -133,7 +135,7 @@ public VoteAgeResultResponseDto findVoteResultByAge(Long voteId) { .join(mvo.voteOption, option) .join(mvo.member, member) .join(member.profile, profile) - .where(mvo.voteOption.vote.id.eq(voteId)) + .where(mvo.voteOption.vote.id.eq(voteId), member.isBot.isFalse()) .groupBy(option.label, profile.age, option.content) .fetch(); @@ -226,7 +228,7 @@ public VoteMbtiResultResponseDto findVoteResultByMbti(Long voteId, String mbtiTy .join(mvo.member, member) .join(member.profile, profile) .join(mvo.voteOption, option) - .where(mvo.voteOption.vote.id.eq(voteId)) + .where(mvo.voteOption.vote.id.eq(voteId), member.isBot.isFalse()) .groupBy(mbtiField, option.content) .fetch(); diff --git a/src/main/java/com/valanse/valanse/service/CommentService/CommentService.java b/src/main/java/com/valanse/valanse/service/CommentService/CommentService.java index 8046a84..85fbe1b 100644 --- a/src/main/java/com/valanse/valanse/service/CommentService/CommentService.java +++ b/src/main/java/com/valanse/valanse/service/CommentService/CommentService.java @@ -12,11 +12,11 @@ public interface CommentService { void deleteMyComment(Member member, Long commentId); - List getMyComments(Member member, String sort); + PagedMyCommentResponse getMyComments(Member member, String sort, Pageable pageable); Long createComment(Long voteId, Long userId, CommentPostRequest request); PagedCommentResponse getCommentsByVoteId(Long voteId, String sort, Pageable pageable,Long userId, Boolean isAdmin); BestCommentResponseDto getBestCommentByVoteId(Long voteId); - List getReplies(Member loginUser, Long voteId, Long commentId); + PagedCommentReplyResponse getReplies(Member loginUser, Long voteId, Long commentId, Pageable pageable); } diff --git a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java index 33d90a6..b1a5b70 100644 --- a/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/CommentService/CommentServiceImpl.java @@ -20,10 +20,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - - -import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -40,8 +36,6 @@ public class CommentServiceImpl implements CommentService { private final VoteRepository voteRepository; private final CommentGroupRepository commentGroupRepository; private final CommentRepository commentRepository; - private final MemberProfileRepository memberProfileRepository; - private final MemberProfileTitleRepository memberProfileTitleRepository; private final PointService pointService; /** @@ -88,21 +82,20 @@ public void deleteMyComment(Member member, Long commentId) { */ @Override @Transactional(readOnly = true) - public List getMyComments(Member member, String sort) { + public PagedMyCommentResponse getMyComments(Member member, String sort, Pageable pageable) { Long memberId = member.getId(); - List comments; - if ("oldest".equalsIgnoreCase(sort)) { - comments = commentRepository.findByMemberIdAndDeletedAtIsNullOrderByCreatedAtAsc(memberId); - } else if ("latest".equalsIgnoreCase(sort)) { - comments = commentRepository.findByMemberIdAndDeletedAtIsNullOrderByCreatedAtDesc(memberId); - } else { + if (!"oldest".equalsIgnoreCase(sort) && !"latest".equalsIgnoreCase(sort)) { throw new ApiException(CommentErrorMessage.WRONG_SORT_PARAMETER.message(), HttpStatus.BAD_REQUEST); } - return comments.stream() - .map(MyCommentResponseDto::fromEntity) - .collect(Collectors.toList()); + Slice comments = commentRepository.findMyCommentsSlice(memberId, sort, pageable); + return PagedMyCommentResponse.builder() + .comments(comments.getContent()) + .page(pageable.getPageNumber()) + .size(pageable.getPageSize()) + .hasNext(comments.hasNext()) + .build(); } /** @@ -212,9 +205,9 @@ public BestCommentResponseDto getBestCommentByVoteId(Long voteId) { */ @Override @Transactional(readOnly = true) - public List getReplies(Member loginUser, Long voteId, Long parentCommentId) { + public PagedCommentReplyResponse getReplies(Member loginUser, Long voteId, Long parentCommentId, Pageable pageable) { // 유효한 투표인지 확인 - Vote vote = voteRepository.findById(voteId) + voteRepository.findById(voteId) .orElseThrow(() -> new ApiException(VoteErrorMessage.VOTE_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); Comment parentComment = commentRepository.findById(parentCommentId) @@ -222,57 +215,17 @@ public List getReplies(Member loginUser, Long voteId, L validateParentCommentBelongsToVote(parentComment, voteId); // 대댓글 조회 - List replies = commentRepository.findAllByParentId(parentCommentId); - - return replies.stream() - .map(reply -> { - Member replyMember = reply.getMember(); - MemberProfile profile = replyMember == null ? null - : memberProfileRepository.findByMemberId(replyMember.getId()).orElse(null); - - VoteLabel label = replyMember == null ? null : replyMember.getMemberVoteOptions().stream() - .filter(opt -> opt.getVoteOption().getVote().getId().equals(voteId)) - .map(opt -> opt.getVoteOption().getLabel()) - .findFirst() - .orElse(null); - - - LocalDateTime now = LocalDateTime.now(); - LocalDateTime createdAt = reply.getCreatedAt(); - long totalHours = ChronoUnit.HOURS.between(createdAt, now); - long daysAgo = totalHours / 24; - long hoursAgo = totalHours % 24; - - boolean isAdmin = loginUser != null && loginUser.getRole() == Role.ADMIN; - boolean canDelete = false; - if (loginUser != null) { - canDelete = isAdmin || (replyMember != null && replyMember.getId().equals(loginUser.getId())); - } - - return CommentReplyResponseDto.builder() - .id(reply.getId()) - .nickname(profile != null ? profile.getNickname() : "탈퇴한 사용자") - .title(replyMember != null ? getEquippedTitleName(replyMember.getId()) : null) - .createdAt(reply.getCreatedAt()) - .content(reply.getContent()) - .likeCount(reply.getLikeCount()) - .replyCount(reply.getReplyCount()) - .isDeleted(reply.getDeletedAt() != null) - .deletedAt(reply.getDeletedAt()) - .label(label) - .daysAgo(daysAgo) - .hoursAgo(hoursAgo) - .canDelete(canDelete) - .build(); - }) - .collect(Collectors.toList()); - } + Long loginId = loginUser != null ? loginUser.getId() : null; + boolean isAdmin = loginUser != null && loginUser.getRole() == Role.ADMIN; + Slice replies = + commentRepository.findRepliesByParentIdSlice(voteId, parentCommentId, pageable, loginId, isAdmin); - private String getEquippedTitleName(Long memberId) { - return memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(memberId) - .map(MemberProfileTitle::getTitle) - .map(Title::getName) - .orElse(null); + return PagedCommentReplyResponse.builder() + .replies(replies.getContent()) + .page(pageable.getPageNumber()) + .size(pageable.getPageSize()) + .hasNext(replies.hasNext()) + .build(); } private void validateParentCommentBelongsToVote(Comment parent, Long voteId) { diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointService.java b/src/main/java/com/valanse/valanse/service/PointService/PointService.java index 2dfb20a..165ef93 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointService.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointService.java @@ -2,6 +2,7 @@ import com.valanse.valanse.domain.enums.PointType; import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import org.springframework.data.domain.Pageable; /** * PointService 기능의 비즈니스 계약을 정의하는 서비스 인터페이스 코드입니다. @@ -9,5 +10,5 @@ public interface PointService { void givePoint(Long memberId, PointType type); void recordPointUsage(Long memberId, long amount, PointType type); - PointHistoryResponse getPointHistory(Long memberId); + PointHistoryResponse getPointHistory(Long memberId, Pageable pageable); } diff --git a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java index 9f6496d..b13635a 100644 --- a/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/PointService/PointServiceImpl.java @@ -13,6 +13,8 @@ import com.valanse.valanse.repository.MemberRepository; import com.valanse.valanse.repository.PointHistoryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,6 +60,10 @@ public void givePoint(Long memberId, PointType type) { Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) .orElseThrow(() -> new ApiException(MemberErrorMessage.MEMBER_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); + if (member.isBot()) { + return; + } + MemberProfile profile = findMemberProfileForUpdate(memberId) .orElseThrow(() -> new ApiException(ProfileErrorMessage.PROFILE_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); @@ -125,24 +131,19 @@ public void recordPointUsage(Long memberId, long amount, PointType type) { */ @Override @Transactional(readOnly = true) - public PointHistoryResponse getPointHistory(Long memberId) { + public PointHistoryResponse getPointHistory(Long memberId, Pageable pageable) { // 회원 존재 여부 확인 memberRepository.findByIdAndDeletedAtIsNull(memberId) .orElseThrow(() -> new ApiException(MemberErrorMessage.MEMBER_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); - // 포인트 히스토리 조회 (최신순으로 정렬) - List histories = pointHistoryRepository.findByMemberId(memberId); - Map fallbackRemainingPoints = calculateRemainingPoints(histories); + Slice histories = pointHistoryRepository.findByMemberIdOrderByCreatedAtDescIdDesc(memberId, pageable); + Map fallbackRemainingPoints = histories.getContent().stream() + .anyMatch(history -> history.getRemainingPoint() == null) + ? calculateRemainingPoints(pointHistoryRepository.findByMemberId(memberId)) + : Map.of(); // DTO로 변환 - List historyItems = histories.stream() - .sorted((h1, h2) -> { - // null 값 처리: null은 가장 오래된 것으로 간주 - if (h1.getCreatedAt() == null && h2.getCreatedAt() == null) return 0; - if (h1.getCreatedAt() == null) return 1; - if (h2.getCreatedAt() == null) return -1; - return h2.getCreatedAt().compareTo(h1.getCreatedAt()); // 최신순 정렬 - }) + List historyItems = histories.getContent().stream() .map(history -> new PointHistoryResponse.PointHistoryItem( history.getId(), history.getAmount(), @@ -155,7 +156,12 @@ public PointHistoryResponse getPointHistory(Long memberId) { )) .toList(); - return new PointHistoryResponse(historyItems); + return new PointHistoryResponse( + historyItems, + pageable.getPageNumber(), + pageable.getPageSize(), + histories.hasNext() + ); } private Map calculateRemainingPoints(List histories) { diff --git a/src/main/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeScheduler.java b/src/main/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeScheduler.java index b8dbca0..30268a9 100644 --- a/src/main/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeScheduler.java +++ b/src/main/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeScheduler.java @@ -25,16 +25,34 @@ public class SoftDeletePurgeScheduler { ) public void purge() { if (!properties.enabled()) return; + long startedAt = System.nanoTime(); + LocalDateTime startedNow = LocalDateTime.now(); + LocalDateTime cutoff = startedNow.minusDays(properties.retentionDays()); int comments = 0; int votes = 0; int members = 0; - while (true) { - PurgeResult result = purgeService.purgeExpired(LocalDateTime.now()); - comments += result.commentsAnonymized(); - votes += result.votesDeleted(); - members += result.membersDeleted(); - if (result.commentsAnonymized() == 0 && result.votesDeleted() == 0 && result.membersDeleted() == 0) break; + int batches = 0; + log.info("PURGE_STARTED cutoff={} retentionDays={} batchSize={}", + cutoff, properties.retentionDays(), properties.batchSize()); + try { + while (true) { + PurgeResult result = purgeService.purgeExpired(LocalDateTime.now()); + batches++; + comments += result.commentsAnonymized(); + votes += result.votesDeleted(); + members += result.membersDeleted(); + if (result.commentsAnonymized() == 0 && result.votesDeleted() == 0 && result.membersDeleted() == 0) break; + } + log.info("PURGE_COMPLETED batches={} comments={} votes={} members={} durationMs={}", + batches, comments, votes, members, elapsedMillis(startedAt)); + } catch (RuntimeException e) { + log.error("PURGE_FAILED batches={} comments={} votes={} members={} durationMs={}", + batches, comments, votes, members, elapsedMillis(startedAt), e); + throw e; } - log.info("Soft-delete purge completed: comments={}, votes={}, members={}", comments, votes, members); + } + + private long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; } } diff --git a/src/main/java/com/valanse/valanse/service/ReportService/ReportService.java b/src/main/java/com/valanse/valanse/service/ReportService/ReportService.java index 99b6fa0..5a7362c 100644 --- a/src/main/java/com/valanse/valanse/service/ReportService/ReportService.java +++ b/src/main/java/com/valanse/valanse/service/ReportService/ReportService.java @@ -3,10 +3,9 @@ import com.valanse.valanse.domain.Member; import com.valanse.valanse.domain.enums.ReportReason; import com.valanse.valanse.domain.enums.ReportType; -import com.valanse.valanse.dto.Report.ReportedTargetResponse; +import com.valanse.valanse.dto.Report.PagedReportedTargetResponse; import com.valanse.valanse.dto.Report.ReportDetailResponse; - -import java.util.List; +import org.springframework.data.domain.Pageable; /** * ReportService 기능의 비즈니스 계약을 정의하는 서비스 인터페이스 코드입니다. @@ -17,6 +16,6 @@ public interface ReportService { void report(Member member, Long targetId, ReportType reportType); void report(Member member, Long targetId, ReportType reportType, ReportReason reason, String content); // 신고 대상 조회 - List getReportedTargets(Member member,ReportType type, String sort); + PagedReportedTargetResponse getReportedTargets(Member member, ReportType type, String sort, Pageable pageable); ReportDetailResponse getReportDetail(Member member, ReportType type, Long targetId); } diff --git a/src/main/java/com/valanse/valanse/service/ReportService/ReportServiceImpl.java b/src/main/java/com/valanse/valanse/service/ReportService/ReportServiceImpl.java index 8067429..dc89434 100644 --- a/src/main/java/com/valanse/valanse/service/ReportService/ReportServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/ReportService/ReportServiceImpl.java @@ -11,10 +11,11 @@ import com.valanse.valanse.domain.enums.ReportReason; import com.valanse.valanse.domain.enums.ReportType; import com.valanse.valanse.domain.enums.Role; -import com.valanse.valanse.dto.Report.ReportedTargetResponse; +import com.valanse.valanse.dto.Report.PagedReportedTargetResponse; import com.valanse.valanse.dto.Report.ReportDetailItemResponse; import com.valanse.valanse.dto.Report.ReportDetailResponse; import com.valanse.valanse.dto.Report.ReportedCommentResponse; +import com.valanse.valanse.dto.Report.ReportedTargetResponse; import com.valanse.valanse.dto.Report.ReportedVoteResponse; import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.ReportRepository; @@ -22,6 +23,8 @@ import com.valanse.valanse.repository.VoteRepository; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -90,9 +93,15 @@ public void report(Member member, Long targetId, ReportType reportType, ReportRe */ @Override @Transactional(readOnly = true) - public List getReportedTargets(Member member, ReportType type, String sort) { + public PagedReportedTargetResponse getReportedTargets(Member member, ReportType type, String sort, Pageable pageable) { validateAdmin(member); - return reportRepositoryCustom.findReportedTargets(type, sort); + Slice reports = reportRepositoryCustom.findReportedTargets(type, sort, pageable); + return PagedReportedTargetResponse.builder() + .reports(reports.getContent()) + .page(pageable.getPageNumber()) + .size(pageable.getPageSize()) + .hasNext(reports.hasNext()) + .build(); } @Override diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteService.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteService.java index c53b7ab..513502a 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteService.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteService.java @@ -5,6 +5,7 @@ import com.valanse.valanse.dto.Vote.*; import com.valanse.valanse.domain.enums.VoteCategory; import com.valanse.valanse.dto.Vote.VoteResponseDto; +import org.springframework.data.domain.Pageable; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -15,10 +16,7 @@ */ public interface VoteService { - // 신규 추가: 인기 급상승 토픽 조회 - HotIssueVoteResponse getTrendingVote(); - - HotIssueVoteResponse getHotIssueVote(); + TrendingVotesResponse getTrendingVotes(int days); // 사용자의 투표를 처리하는 핵심 메서드. // userId: 현재 로그인한 사용자의 ID // voteId: 사용자가 투표하려는 투표의 ID @@ -26,10 +24,10 @@ public interface VoteService { VoteCancleResponseDto processVote(Long userId, Long voteId, Long voteOptionId); VoteDetailResponse getVoteDetailById(Long voteId); - List getMyCreatedVotes(Long memberId, String sort, VoteCategory category); + PagedVoteResponse getMyCreatedVotes(Long memberId, String sort, VoteCategory category, Pageable pageable); Long createVote(Long userId, VoteCreateRequest request); Long createVote(Long userId, VoteCreateRequest request, Map optionImageFiles); - List getMyVotedVotes(Long memberId, String sort, VoteCategory category); + PagedVoteResponse getMyVotedVotes(Long memberId, String sort, VoteCategory category, Pageable pageable); // /votes get 메서드 cursor기반으로 변경 VoteListResponse getVotesByCategoryAndSort(Member loginUser, String category, String sort, String cursor, int size); void deleteVote(Long userId, Long voteId); diff --git a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java index 654ba8d..4aaaca1 100644 --- a/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java +++ b/src/main/java/com/valanse/valanse/service/VoteService/VoteServiceImpl.java @@ -18,6 +18,8 @@ import com.valanse.valanse.service.StorageService.StorageService; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -26,6 +28,8 @@ import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; // 기존 코드에 있었으므로 유지 +import java.time.Clock; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; // 기존 코드에 있었으므로 유지 @@ -51,137 +55,289 @@ public class VoteServiceImpl implements VoteService { private final MemberProfileTitleRepository memberProfileTitleRepository; private final PointService pointService; private final StorageService storageService; + private final Clock clock; + + private static final int TRENDING_LIMIT = 5; + private static final int MIN_TRENDING_DAYS = 1; + private static final int MAX_TRENDING_DAYS = 30; //작은 민지가 구현한 것 /** * 사용자가 직접 생성한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다. */ @Override - public List getMyCreatedVotes(Long memberId, String sort, VoteCategory category) { + public PagedVoteResponse getMyCreatedVotes(Long memberId, String sort, VoteCategory category, Pageable pageable) { Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) .orElseThrow(() -> new ApiException(MemberErrorMessage.MEMBER_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); - List votes; + Slice votes; boolean isAllCategory = category == VoteCategory.ALL; if (isAllCategory) { votes = sort.equals("latest") ? - voteRepository.findAllByMemberOrderByCreatedAtDesc(member) : - voteRepository.findAllByMemberOrderByCreatedAtAsc(member); + voteRepository.findSliceByMemberOrderByCreatedAtDesc(member, pageable) : + voteRepository.findSliceByMemberOrderByCreatedAtAsc(member, pageable); } else { if (category == null) throw new ApiException(VoteErrorMessage.CATEGORY_REQUIRED.message(), HttpStatus.BAD_REQUEST); votes = sort.equals("latest") ? - voteRepository.findAllByMemberAndCategoryOrderByCreatedAtDesc(member, category) : - voteRepository.findAllByMemberAndCategoryOrderByCreatedAtAsc(member, category); + voteRepository.findSliceByMemberAndCategoryOrderByCreatedAtDesc(member, category, pageable) : + voteRepository.findSliceByMemberAndCategoryOrderByCreatedAtAsc(member, category, pageable); } - return votes.stream() - .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) - .collect(Collectors.toList()); + return toPagedVoteResponse(votes, pageable); } /** * 사용자가 참여한 투표 목록을 정렬과 카테고리 조건으로 조회하는 메서드입니다. */ @Override - public List getMyVotedVotes(Long memberId, String sort, VoteCategory category) { + public PagedVoteResponse getMyVotedVotes(Long memberId, String sort, VoteCategory category, Pageable pageable) { Member member = memberRepository.findByIdAndDeletedAtIsNull(memberId) .orElseThrow(() -> new ApiException(MemberErrorMessage.MEMBER_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); - List votes; + Slice votes; boolean isAllCategory = category == VoteCategory.ALL; if (isAllCategory) { votes = sort.equals("latest") ? - voteRepository.findAllByMemberVotedOrderByCreatedAtDesc(member) : - voteRepository.findAllByMemberVotedOrderByCreatedAtAsc(member); + voteRepository.findSliceByMemberVotedOrderByCreatedAtDesc(member, pageable) : + voteRepository.findSliceByMemberVotedOrderByCreatedAtAsc(member, pageable); } else { if (category == null) throw new ApiException(VoteErrorMessage.CATEGORY_REQUIRED.message(), HttpStatus.BAD_REQUEST); votes = sort.equals("latest") ? - voteRepository.findAllByMemberVotedAndCategoryOrderByCreatedAtDesc(member, category) : - voteRepository.findAllByMemberVotedAndCategoryOrderByCreatedAtAsc(member, category); + voteRepository.findSliceByMemberVotedAndCategoryOrderByCreatedAtDesc(member, category, pageable) : + voteRepository.findSliceByMemberVotedAndCategoryOrderByCreatedAtAsc(member, category, pageable); } - return votes.stream() - .map(vote -> new VoteResponseDto(vote, getEquippedTitleName(vote.getMember()))) - .collect(Collectors.toList()); + return toPagedVoteResponse(votes, pageable); + } + + private PagedVoteResponse toPagedVoteResponse(Slice votes, Pageable pageable) { + List creatorMemberIds = votes.getContent().stream() + .map(Vote::getMember) + .filter(member -> member != null && member.getId() != null) + .map(Member::getId) + .distinct() + .toList(); + Map titleNamesByMemberId = getEquippedTitleNamesByMemberIds(creatorMemberIds); + + return PagedVoteResponse.builder() + .votes(votes.getContent().stream() + .map(vote -> new VoteResponseDto( + vote, + vote.getMember() == null ? null : titleNamesByMemberId.get(vote.getMember().getId()) + )) + .collect(Collectors.toList())) + .page(pageable.getPageNumber()) + .size(pageable.getPageSize()) + .hasNext(votes.hasNext()) + .build(); } - //여기서부터 영서 코드 - /** - * 핫이슈로 노출할 투표를 고정값 또는 반응성 기준으로 선정하는 메서드입니다. - */ @Override - public HotIssueVoteResponse getHotIssueVote() { // 파라미터 없음 - // 0. 고정 게시물이 있다면 반환. - Optional pinnedHot = voteRepository.findByPinType(PinType.HOT); - if (pinnedHot.isPresent()) { - Vote hotIssueVote = pinnedHot.get(); - return getHotIssueVoteResponse(hotIssueVote); - } - - // 수정: 작일 반응성 기준으로 변경 - LocalDateTime now = LocalDateTime.now(); - LocalDateTime yesterdayStart = now.minusDays(1).withHour(0).withMinute(0).withSecond(0); - - // 작일 동안 반응성이 가장 높은 투표 조회 시도 - Optional yesterdayHotIssue = voteRepository.findTrendingVote(yesterdayStart, now); - Vote hotIssueVote; - if (yesterdayHotIssue.isPresent()) { - // 작일 반응성 데이터가 있는 경우 - hotIssueVote = yesterdayHotIssue.get(); - } else { - hotIssueVote = voteRepository.findHotIssueVote() - .orElseThrow(() -> new ApiException(VoteErrorMessage.HOT_ISSUE_VOTE_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); + public TrendingVotesResponse getTrendingVotes(int days) { + validateTrendingDays(days); + + LocalDateTime to = LocalDateTime.now(clock); + LocalDateTime from = to.minusDays(days); + List periodScores = voteRepository + .findTopTrendingVotes(from, to, TRENDING_LIMIT); + + boolean fallbackApplied = periodScores.isEmpty(); + TrendingScoreType scoreType = fallbackApplied + ? TrendingScoreType.ALL_TIME + : TrendingScoreType.PERIOD; + List rankedScores = fallbackApplied + ? voteRepository.findTopAllTimeTrendingVotes(TRENDING_LIMIT) + : periodScores; + + Optional pinnedVote = voteRepository.findByPinType(PinType.TRENDING); + List selectedVotes = selectTrendingVotes( + rankedScores, + pinnedVote, + scoreType, + from, + to + ); + + List voteItems = buildTrendingVoteItems(selectedVotes); + return TrendingVotesResponse.builder() + .requestedDays(days) + .from(from) + .to(to) + .scoreType(scoreType) + .fallbackApplied(fallbackApplied) + .votes(voteItems) + .build(); + } + + private void validateTrendingDays(int days) { + if (days < MIN_TRENDING_DAYS || days > MAX_TRENDING_DAYS) { + throw new ApiException(VoteErrorMessage.TRENDING_PERIOD_INVALID.message(), HttpStatus.BAD_REQUEST); } + } - // 3. 투표 생성자 정보 조회 (닉네임) - 기존 로직 유지 - return getHotIssueVoteResponse(hotIssueVote); + private List selectTrendingVotes( + List rankedScores, + Optional pinnedVote, + TrendingScoreType scoreType, + LocalDateTime from, + LocalDateTime to + ) { + List selectedVotes = new ArrayList<>(); + Long pinnedVoteId = pinnedVote.map(Vote::getId).orElse(null); + + if (pinnedVoteId != null) { + TrendingVoteScore pinnedScore = rankedScores.stream() + .filter(score -> score.voteId().equals(pinnedVoteId)) + .findFirst() + .orElseGet(() -> findPinnedVoteScore(pinnedVoteId, scoreType, from, to)); + selectedVotes.add(new SelectedTrendingVote( + pinnedVoteId, + pinnedScore, + TrendingDisplayType.PINNED + )); + } + + rankedScores.stream() + .filter(score -> !score.voteId().equals(pinnedVoteId)) + .limit(TRENDING_LIMIT - selectedVotes.size()) + .map(score -> new SelectedTrendingVote( + score.voteId(), + score, + TrendingDisplayType.RANKED + )) + .forEach(selectedVotes::add); + + return selectedVotes; } - // 인기 급상승 토픽 - /** - * 최근 기간의 반응성을 기준으로 인기 급상승 투표를 선정하는 메서드입니다. - */ - @Override - public HotIssueVoteResponse getTrendingVote() { - // 0. 고정 게시물이 있다면 반환. - Optional pinnedTrending = voteRepository.findByPinType(PinType.TRENDING); - if (pinnedTrending.isPresent()) { + private TrendingVoteScore findPinnedVoteScore( + Long pinnedVoteId, + TrendingScoreType scoreType, + LocalDateTime from, + LocalDateTime to + ) { + Optional score = scoreType == TrendingScoreType.PERIOD + ? voteRepository.findTrendingScoreByVoteId(pinnedVoteId, from, to) + : voteRepository.findAllTimeTrendingScoreByVoteId(pinnedVoteId); + return score.orElseGet(() -> TrendingVoteScore.empty(pinnedVoteId)); + } - Vote trendingVote = pinnedTrending.get(); + private List buildTrendingVoteItems( + List selectedVotes + ) { + if (selectedVotes.isEmpty()) { + return List.of(); + } - return getHotIssueVoteResponse(trendingVote); + List voteIds = selectedVotes.stream() + .map(SelectedTrendingVote::voteId) + .toList(); + Map votesById = voteRepository.findTrendingVoteDetailsByIds(voteIds).stream() + .collect(Collectors.toMap(Vote::getId, vote -> vote)); + + List memberIds = votesById.values().stream() + .map(Vote::getMember) + .filter(member -> member != null && member.getId() != null) + .map(Member::getId) + .distinct() + .toList(); + Map titleNamesByMemberId = getEquippedTitleNamesByMemberIds(memberIds); + Map nicknamesByMemberId = getNicknamesByMemberIds(memberIds); + + List responses = new ArrayList<>(); + for (SelectedTrendingVote selectedVote : selectedVotes) { + Vote vote = votesById.get(selectedVote.voteId()); + if (vote == null) { + continue; + } + responses.add(toTrendingVoteItemResponse( + responses.size() + 1, + selectedVote, + vote, + titleNamesByMemberId, + nicknamesByMemberId + )); + } + return responses; + } + private TrendingVoteItemResponse toTrendingVoteItemResponse( + int displayOrder, + SelectedTrendingVote selectedVote, + Vote vote, + Map titleNamesByMemberId, + Map nicknamesByMemberId + ) { + TrendingVoteScore score = selectedVote.score(); + Member creator = vote.getMember(); + String createdBy = "익명"; + String creatorTitle = null; + Boolean creatorIsBot = creator != null && creator.isBot(); + if (creator != null) { + if (nicknamesByMemberId.get(creator.getId()) != null) { + createdBy = nicknamesByMemberId.get(creator.getId()); + } else if (creator.getName() != null) { + createdBy = creator.getName(); + } + creatorTitle = titleNamesByMemberId.get(creator.getId()); } - // 7일 이전 시간 계산 - LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); - LocalDateTime now = LocalDateTime.now(); + List options = vote.getVoteOptions().stream() + .map(option -> HotIssueVoteOptionDto.builder() + .optionId(option.getId()) + .content(option.getContent()) + .imageUrl(option.getImageUrl()) + .vote_count(option.getVoteCount()) + .build()) + .toList(); - // 최근 7일 내 반응성이 가장 높은 투표 조회 - Optional recentTrendingVote = voteRepository - .findTrendingVote(sevenDaysAgo, now); + return TrendingVoteItemResponse.builder() + .displayOrder(displayOrder) + .displayType(selectedVote.displayType()) + .voteId(vote.getId()) + .title(vote.getTitle()) + .content(vote.getContent()) + .category(vote.getCategory() == null ? null : vote.getCategory().name()) + .reactivityScore(score.reactivityScore()) + .voteReactionCount(score.voteReactionCount()) + .commentReactionCount(score.commentReactionCount()) + .totalParticipants(vote.getTotalVoteCount()) + .createdBy(createdBy) + .isBot(creatorIsBot) + .creatorTitle(creatorTitle) + .createdAt(vote.getCreatedAt()) + .options(options) + .build(); + } - Vote trendingVote; - if (recentTrendingVote.isPresent()) { - // 7일 내 데이터가 있는 경우 - 새로 업데이트 - trendingVote = recentTrendingVote.get(); - } else { - // 7일 내 데이터가 없는 경우 - 이전 데이터 유지 (전체 기간에서 가장 높은 반응성) - trendingVote = voteRepository.findHotIssueVote() - .orElseThrow(() -> new ApiException(VoteErrorMessage.TRENDING_VOTE_NOT_FOUND.message(), HttpStatus.NOT_FOUND)); + private Map getNicknamesByMemberIds(List memberIds) { + if (memberIds.isEmpty()) { + return Map.of(); } - // 3. 투표 생성자 정보 조회 (닉네임) - 기존 로직 유지 - return getHotIssueVoteResponse(trendingVote); + return memberProfileRepository.findAllByMemberIdIn(memberIds).stream() + .filter(profile -> profile.getMember() != null) + .filter(profile -> profile.getNickname() != null) + .collect(Collectors.toMap( + profile -> profile.getMember().getId(), + MemberProfile::getNickname, + (existing, replacement) -> existing + )); + } + private record SelectedTrendingVote( + Long voteId, + TrendingVoteScore score, + TrendingDisplayType displayType + ) { } /** @@ -327,6 +483,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { // MemberProfile의 nickname을 가져오도록 수정 String creatorNickname = "탈퇴한 사용자"; String creatorTitle = getEquippedTitleName(vote.getMember()); + Boolean creatorIsBot = vote.getMember() != null && vote.getMember().isBot(); if (vote.getMember() != null && vote.getMember().getProfile() != null) { creatorNickname = vote.getMember().getProfile().getNickname(); } @@ -375,6 +532,7 @@ public VoteDetailResponse getVoteDetailById(Long voteId) { .category(vote.getCategory()) .totalVoteCount(vote.getTotalVoteCount()) .creatorNickname(creatorNickname) // 수정된 닉네임 사용 + .isBot(creatorIsBot) .creatorTitle(creatorTitle) .createdAt(vote.getCreatedAt()) .options(optionDtos) @@ -547,6 +705,7 @@ public VoteListResponse getVotesByCategoryAndSort(Member loginUser, String categ .category(vote.getCategory().name()) .member_id(vote.getMember() != null ? vote.getMember().getId() : null) .nickname(creatorNickname) + .isBot(vote.getMember() != null && vote.getMember().isBot()) .member_title(vote.getMember() != null ? equippedTitleNamesByMemberId.get(vote.getMember().getId()) : null) @@ -656,7 +815,7 @@ public void deleteVote(Long userId, Long voteId) { @Transactional // 고정 , 고정 해제 기능은 vote 엔티티에 메서드로 존재 /** - * 관리자 권한으로 투표의 HOT/TRENDING 고정 상태를 변경하는 메서드입니다. + * 관리자 권한으로 투표의 트렌딩 고정 상태를 변경하는 메서드입니다. */ public void updatePinStatus(Member member, Long voteId, PinType pinType) { if (member.getRole() != Role.ADMIN) { @@ -683,45 +842,6 @@ public void updatePinStatus(Member member, Long voteId, PinType pinType) { voteRepository.save(vote); } - // 핫이슈 발런스 게임을 뽑아내는 메서드 - private HotIssueVoteResponse getHotIssueVoteResponse(Vote hotIssueVote) { - String createdByNickname = "익명"; // 기본값 설정 - if (hotIssueVote.getMember() != null) { // Member가 null이 아닌 경우 - Member creatorMember = hotIssueVote.getMember(); // 생성자 Member 객체 가져오기 - MemberProfile profile = memberProfileRepository.findByMemberId(creatorMember.getId()).orElse(null); // MemberProfile 조회 - if (profile != null && profile.getNickname() != null) { // 프로필이 있고 닉네임이 있는 경우 - createdByNickname = profile.getNickname(); // 닉네임 설정 - } else { - // MemberProfile이 없거나 닉네임이 없는 경우, Member의 기본 이름 사용 (카카오 이름 등) - if (creatorMember.getName() != null) { // Member의 이름이 있는 경우 - createdByNickname = creatorMember.getName(); // 이름으로 닉네임 설정 - } - } - } - - List options = hotIssueVote.getVoteOptions().stream() - .map(option -> HotIssueVoteOptionDto.builder() // HotIssueVoteOptionDto 빌더 사용 - .optionId(option.getId()) //option ID넣기 - .content(option.getContent()) // 옵션 내용 설정 - .imageUrl(option.getImageUrl()) - .vote_count(option.getVoteCount()) // 투표 수 설정 - .build()) - .collect(Collectors.toList()); // 리스트로 수집 - - return HotIssueVoteResponse.builder() - .voteId(hotIssueVote.getId()) // 투표 ID 설정 - .title(hotIssueVote.getTitle()) // 제목 설정 - .content(hotIssueVote.getContent()) - .category(hotIssueVote.getCategory() != null ? hotIssueVote.getCategory().name() : null) // 카테고리 설정 - .totalParticipants(hotIssueVote.getTotalVoteCount()) // 총 참여자 수 설정 - .createdBy(createdByNickname) // 생성자 닉네임 설정 - .creatorTitle(getEquippedTitleName(hotIssueVote.getMember())) - .createdAt(hotIssueVote.getCreatedAt()) // 추가된 부분: createdAt 설정 - .pinType(hotIssueVote.getPinType()) - .options(options) // 옵션 리스트 설정 - .build(); - } - private String getEquippedTitleName(Member member) { if (member == null || member.getId() == null) { return null; diff --git a/src/main/resources/db/migration/V10__seed_content_bot_accounts.sql b/src/main/resources/db/migration/V10__seed_content_bot_accounts.sql new file mode 100644 index 0000000..a9f4624 --- /dev/null +++ b/src/main/resources/db/migration/V10__seed_content_bot_accounts.sql @@ -0,0 +1,61 @@ +-- content-seed-bot-001 ~ 005: 콘텐츠 자동 생성에 사용되는 봇 계정 5개를 시드합니다. +-- social_id에는 UNIQUE 제약이 없으므로(운영 데이터 중복 위험), social_id 존재 여부를 직접 확인해 +-- 재실행되어도 중복 삽입되지 않도록 구성합니다. + +INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) +SELECT NOW(6), NOW(6), 'content-seed-bot-001', 'KAKAO', 'USER', '한입만판사', '한입만판사', TRUE +WHERE NOT EXISTS (SELECT 1 FROM member WHERE social_id = 'content-seed-bot-001'); + +INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) +SELECT NOW(6), NOW(6), 'content-seed-bot-002', 'KAKAO', 'USER', '연애배심원', '연애배심원', TRUE +WHERE NOT EXISTS (SELECT 1 FROM member WHERE social_id = 'content-seed-bot-002'); + +INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) +SELECT NOW(6), NOW(6), 'content-seed-bot-003', 'KAKAO', 'USER', '장바구니철학자', '장바구니철학자', TRUE +WHERE NOT EXISTS (SELECT 1 FROM member WHERE social_id = 'content-seed-bot-003'); + +INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) +SELECT NOW(6), NOW(6), 'content-seed-bot-004', 'KAKAO', 'USER', '숨참고승부', '숨참고승부', TRUE +WHERE NOT EXISTS (SELECT 1 FROM member WHERE social_id = 'content-seed-bot-004'); + +INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) +SELECT NOW(6), NOW(6), 'content-seed-bot-005', 'KAKAO', 'USER', '결정은내일', '결정은내일', TRUE +WHERE NOT EXISTS (SELECT 1 FROM member WHERE social_id = 'content-seed-bot-005'); + +-- member_profile은 충돌한 social_id의 기존 회원이 봇(is_bot = TRUE)일 때만 생성한다. +-- 일반 회원과 social_id가 우연히 겹치더라도 그 회원의 프로필을 절대 건드리지 않기 위한 안전장치다. + +INSERT INTO member_profile (member_id, created_at, updated_at, nickname, gender, age, mbti_ie, mbti_tf, mbti, point) +SELECT m.id, NOW(6), NOW(6), '한입만판사', 'FEMALE', 'TWENTY', 'E', 'F', 'ENFP', 0 +FROM member m +WHERE m.social_id = 'content-seed-bot-001' + AND m.is_bot = TRUE + AND NOT EXISTS (SELECT 1 FROM member_profile mp WHERE mp.member_id = m.id); + +INSERT INTO member_profile (member_id, created_at, updated_at, nickname, gender, age, mbti_ie, mbti_tf, mbti, point) +SELECT m.id, NOW(6), NOW(6), '연애배심원', 'MALE', 'THIRTY', 'I', 'F', 'INFJ', 0 +FROM member m +WHERE m.social_id = 'content-seed-bot-002' + AND m.is_bot = TRUE + AND NOT EXISTS (SELECT 1 FROM member_profile mp WHERE mp.member_id = m.id); + +INSERT INTO member_profile (member_id, created_at, updated_at, nickname, gender, age, mbti_ie, mbti_tf, mbti, point) +SELECT m.id, NOW(6), NOW(6), '장바구니철학자', 'FEMALE', 'THIRTY', 'I', 'T', 'ISTJ', 0 +FROM member m +WHERE m.social_id = 'content-seed-bot-003' + AND m.is_bot = TRUE + AND NOT EXISTS (SELECT 1 FROM member_profile mp WHERE mp.member_id = m.id); + +INSERT INTO member_profile (member_id, created_at, updated_at, nickname, gender, age, mbti_ie, mbti_tf, mbti, point) +SELECT m.id, NOW(6), NOW(6), '숨참고승부', 'MALE', 'TWENTY', 'E', 'T', 'ESTP', 0 +FROM member m +WHERE m.social_id = 'content-seed-bot-004' + AND m.is_bot = TRUE + AND NOT EXISTS (SELECT 1 FROM member_profile mp WHERE mp.member_id = m.id); + +INSERT INTO member_profile (member_id, created_at, updated_at, nickname, gender, age, mbti_ie, mbti_tf, mbti, point) +SELECT m.id, NOW(6), NOW(6), '결정은내일', 'FEMALE', 'OVER_FORTY', 'I', 'T', 'INTP', 0 +FROM member m +WHERE m.social_id = 'content-seed-bot-005' + AND m.is_bot = TRUE + AND NOT EXISTS (SELECT 1 FROM member_profile mp WHERE mp.member_id = m.id); diff --git a/src/main/resources/db/migration/V5__add_trending_activity_indexes.sql b/src/main/resources/db/migration/V5__add_trending_activity_indexes.sql new file mode 100644 index 0000000..1201fb2 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_trending_activity_indexes.sql @@ -0,0 +1,5 @@ +CREATE INDEX `idx_member_vote_option_trending` + ON `member_vote_option` (`created_at`, `vote_id`, `deleted_at`); + +CREATE INDEX `idx_comment_trending` + ON `comment` (`created_at`, `comment_group_id`, `deleted_at`); diff --git a/src/main/resources/db/migration/V6__consolidate_vote_pin_type.sql b/src/main/resources/db/migration/V6__consolidate_vote_pin_type.sql new file mode 100644 index 0000000..68ececc --- /dev/null +++ b/src/main/resources/db/migration/V6__consolidate_vote_pin_type.sql @@ -0,0 +1,28 @@ +-- Keep at most one active pin. Prefer an existing TRENDING pin; otherwise +-- promote the most recently created HOT pin before removing the legacy value. +SET @trending_pin_to_keep = ( + SELECT COALESCE( + ( + SELECT MAX(`id`) + FROM `vote` + WHERE `pin_type` = 'TRENDING' + AND `deleted_at` IS NULL + ), + ( + SELECT MAX(`id`) + FROM `vote` + WHERE `pin_type` = 'HOT' + AND `deleted_at` IS NULL + ) + ) +); + +UPDATE `vote` +SET `pin_type` = CASE + WHEN `id` = @trending_pin_to_keep THEN 'TRENDING' + ELSE 'NONE' +END +WHERE `pin_type` IN ('HOT', 'TRENDING'); + +ALTER TABLE `vote` + MODIFY COLUMN `pin_type` ENUM('TRENDING', 'NONE') NOT NULL; diff --git a/src/main/resources/db/migration/V9__add_member_is_bot_column.sql b/src/main/resources/db/migration/V9__add_member_is_bot_column.sql new file mode 100644 index 0000000..74c1c5f --- /dev/null +++ b/src/main/resources/db/migration/V9__add_member_is_bot_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE member + ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/test/java/com/valanse/valanse/controller/CommentControllerPaginationTest.java b/src/test/java/com/valanse/valanse/controller/CommentControllerPaginationTest.java index 9480a58..475014d 100644 --- a/src/test/java/com/valanse/valanse/controller/CommentControllerPaginationTest.java +++ b/src/test/java/com/valanse/valanse/controller/CommentControllerPaginationTest.java @@ -1,16 +1,22 @@ package com.valanse.valanse.controller; import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.dto.Comment.PagedCommentReplyResponse; import com.valanse.valanse.service.CommentLikeService.CommentLikeService; import com.valanse.valanse.service.CommentService.CommentService; import com.valanse.valanse.service.MemberService.MemberService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class CommentControllerPaginationTest { @@ -39,4 +45,40 @@ void getComments_SizeOverMaximum_ThrowsBadRequest() { assertThat(exception.getMessage()).isEqualTo("size는 1 이상 50 이하여야 합니다."); }); } + + @Test + @DisplayName("대댓글 목록의 page가 음수이면 400 예외를 발생시킨다") + void getReplies_NegativePage_ThrowsBadRequest() { + assertThatThrownBy(() -> controller.getReplies(1L, 2L, -1, 10)) + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("page는 0 이상이어야 합니다."); + }); + } + + @Test + @DisplayName("대댓글 목록 조회는 page와 size를 Pageable로 전달한다") + void getReplies_UsesPageable() { + CommentService commentService = mock(CommentService.class); + CommentController controller = new CommentController( + commentService, + mock(CommentLikeService.class), + mock(MemberService.class) + ); + PagedCommentReplyResponse response = PagedCommentReplyResponse.builder() + .page(1) + .size(10) + .hasNext(false) + .build(); + when(commentService.getReplies(isNull(), eq(1L), eq(2L), any(Pageable.class))) + .thenReturn(response); + + ResponseEntity result = controller.getReplies(1L, 2L, 1, 10); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + org.mockito.Mockito.verify(commentService).getReplies(isNull(), eq(1L), eq(2L), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(1); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10); + assertThat(result.getBody()).isSameAs(response); + } } diff --git a/src/test/java/com/valanse/valanse/controller/MemberPointHistoryPaginationTest.java b/src/test/java/com/valanse/valanse/controller/MemberPointHistoryPaginationTest.java new file mode 100644 index 0000000..b37dd49 --- /dev/null +++ b/src/test/java/com/valanse/valanse/controller/MemberPointHistoryPaginationTest.java @@ -0,0 +1,75 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.dto.PointHistory.PointHistoryResponse; +import com.valanse.valanse.service.MemberProfileService.MemberProfileService; +import com.valanse.valanse.service.PointService.PointService; +import com.valanse.valanse.service.TitleService.TitleService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MemberPointHistoryPaginationTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("포인트 히스토리 page가 음수이면 400 예외를 발생시킨다") + void getPointHistory_NegativePage_ThrowsBadRequest() { + MemberController controller = new MemberController( + mock(MemberProfileService.class), + mock(PointService.class), + mock(TitleService.class) + ); + + assertThatThrownBy(() -> controller.getPointHistory(-1, 10)) + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("page는 0 이상이어야 합니다."); + }); + } + + @Test + @DisplayName("포인트 히스토리 조회는 page와 size를 Pageable로 전달한다") + void getPointHistory_UsesPageable() { + PointService pointService = mock(PointService.class); + MemberController controller = new MemberController( + mock(MemberProfileService.class), + pointService, + mock(TitleService.class) + ); + PointHistoryResponse response = new PointHistoryResponse(List.of(), 1, 10, false); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("1", null) + ); + when(pointService.getPointHistory(eq(1L), any(Pageable.class))).thenReturn(response); + + ResponseEntity result = controller.getPointHistory(1, 10); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(pointService).getPointHistory(eq(1L), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(1); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10); + assertThat(result.getBody()).isSameAs(response); + } +} diff --git a/src/test/java/com/valanse/valanse/controller/MyCommentControllerPaginationTest.java b/src/test/java/com/valanse/valanse/controller/MyCommentControllerPaginationTest.java new file mode 100644 index 0000000..107714f --- /dev/null +++ b/src/test/java/com/valanse/valanse/controller/MyCommentControllerPaginationTest.java @@ -0,0 +1,75 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.dto.Comment.PagedMyCommentResponse; +import com.valanse.valanse.service.CommentService.CommentService; +import com.valanse.valanse.service.MemberService.MemberService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MyCommentControllerPaginationTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("내 댓글 목록의 page가 음수이면 400 예외를 발생시킨다") + void getMyComments_NegativePage_ThrowsBadRequest() { + MyCommentController controller = new MyCommentController( + mock(CommentService.class), + mock(MemberService.class) + ); + + assertThatThrownBy(() -> controller.getMyComments("latest", -1, 10)) + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("page는 0 이상이어야 합니다."); + }); + } + + @Test + @DisplayName("내 댓글 목록 조회는 page와 size를 Pageable로 전달한다") + void getMyComments_UsesPageable() { + CommentService commentService = mock(CommentService.class); + MemberService memberService = mock(MemberService.class); + MyCommentController controller = new MyCommentController(commentService, memberService); + Member member = Member.builder().id(1L).build(); + PagedMyCommentResponse response = PagedMyCommentResponse.builder() + .page(1) + .size(10) + .hasNext(false) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("1", null) + ); + when(memberService.findById(1L)).thenReturn(member); + when(commentService.getMyComments(eq(member), eq("latest"), any(Pageable.class))) + .thenReturn(response); + + ResponseEntity result = controller.getMyComments("latest", 1, 10); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(commentService).getMyComments(eq(member), eq("latest"), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(1); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10); + assertThat(result.getBody()).isSameAs(response); + } +} diff --git a/src/test/java/com/valanse/valanse/controller/ReportControllerPaginationTest.java b/src/test/java/com/valanse/valanse/controller/ReportControllerPaginationTest.java new file mode 100644 index 0000000..6dede59 --- /dev/null +++ b/src/test/java/com/valanse/valanse/controller/ReportControllerPaginationTest.java @@ -0,0 +1,83 @@ +package com.valanse.valanse.controller; + +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.domain.enums.ReportType; +import com.valanse.valanse.dto.Report.PagedReportedTargetResponse; +import com.valanse.valanse.service.MemberService.MemberService; +import com.valanse.valanse.service.ReportService.ReportService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReportControllerPaginationTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("신고 목록의 page가 음수이면 400 예외를 발생시킨다") + void getReportedTargets_NegativePage_ThrowsBadRequest() { + ReportController controller = new ReportController( + mock(ReportService.class), + mock(MemberService.class) + ); + + assertThatThrownBy(() -> controller.getReportedTargets(ReportType.VOTE, "latest", -1, 10)) + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("page는 0 이상이어야 합니다."); + }); + } + + @Test + @DisplayName("신고 목록 조회는 page와 size를 Pageable로 전달한다") + void getReportedTargets_UsesPageable() { + ReportService reportService = mock(ReportService.class); + ReportController controller = new ReportController( + reportService, + mock(MemberService.class) + ); + PagedReportedTargetResponse response = PagedReportedTargetResponse.builder() + .page(1) + .size(10) + .hasNext(false) + .build(); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + "1", + null, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ); + when(reportService.getReportedTargets(any(), eq(ReportType.VOTE), eq("latest"), any(Pageable.class))) + .thenReturn(response); + + ResponseEntity result = + controller.getReportedTargets(ReportType.VOTE, "latest", 1, 10); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(reportService).getReportedTargets(any(), eq(ReportType.VOTE), eq("latest"), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(1); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10); + assertThat(result.getBody()).isSameAs(response); + } +} diff --git a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java index 342f1ab..3d27135 100644 --- a/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java +++ b/src/test/java/com/valanse/valanse/controller/VoteControllerTest.java @@ -7,7 +7,11 @@ import com.valanse.valanse.repository.CommentGroupRepository; import com.valanse.valanse.repository.MemberProfileRepository; import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.MemberVoteOptionRepository; +import com.valanse.valanse.repository.CommentRepository; import com.valanse.valanse.repository.VoteRepository; +import com.valanse.valanse.repository.VoteOptionRepository; +import com.valanse.valanse.domain.mapping.MemberVoteOption; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import org.hibernate.SessionFactory; @@ -43,9 +47,12 @@ public class VoteControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private VoteRepository voteRepository; + @Autowired private VoteOptionRepository voteOptionRepository; @Autowired private MemberRepository memberRepository; @Autowired private MemberProfileRepository memberProfileRepository; @Autowired private CommentGroupRepository commentGroupRepository; + @Autowired private CommentRepository commentRepository; + @Autowired private MemberVoteOptionRepository memberVoteOptionRepository; @Autowired private EntityManager entityManager; @Autowired private EntityManagerFactory entityManagerFactory; private Long member1Id; @@ -85,7 +92,7 @@ void setUp() { VoteOption optionB = VoteOption.builder().vote(hotIssueVote) .content("B. 부드러운 파스타").voteCount(40).label(VoteLabel.B).build(); hotIssueVote.getVoteOptions().addAll(Arrays.asList(optionA, optionB)); - voteRepository.save(hotIssueVote); + voteOptionRepository.saveAll(Arrays.asList(optionA, optionB)); Member member2 = Member.builder() .socialId("kakao456").email("test2@example.com").name("테스터2") @@ -106,6 +113,12 @@ void setUp() { commentGroupRepository.save(CommentGroup.builder() .vote(otherVote).totalCommentCount(5).build()); + + memberVoteOptionRepository.save(MemberVoteOption.builder() + .member(member2) + .vote(hotIssueVote) + .voteOption(optionA) + .build()); } @Test @@ -129,8 +142,11 @@ void getMyCreatedVotes_Authenticated_ReturnsVotes() throws Exception { .with(user(member1Id.toString())) .param("category", "ALL")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].voteId").value(hotIssueVoteId)); + .andExpect(jsonPath("$.votes.length()").value(1)) + .andExpect(jsonPath("$.votes[0].voteId").value(hotIssueVoteId)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.hasNext").value(false)); } @Test @@ -140,7 +156,10 @@ void getMyVotedVotes_Authenticated_ReturnsVotes() throws Exception { .with(user(member1Id.toString())) .param("category", "ALL")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); + .andExpect(jsonPath("$.votes.length()").value(0)) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(10)) + .andExpect(jsonPath("$.hasNext").value(false)); } @Test @@ -154,35 +173,112 @@ void getVoteDetail_Anonymous_ReturnsVote() throws Exception { @Test @DisplayName("반응성이 가장 높은 핫이슈 투표 정보를 성공적으로 조회한다.") void getHotIssueVote_Success() throws Exception { - mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + Vote hotIssueVote = voteRepository.findAll().stream() + .filter(vote -> "오늘의 점심 선택은?".equals(vote.getTitle())) + .findFirst() + .orElseThrow(); + CommentGroup hotCommentGroup = commentGroupRepository.findByVoteId(hotIssueVote.getId()) + .orElseThrow(); + Comment parentComment = commentRepository.save(Comment.builder() + .member(hotIssueVote.getMember()) + .commentGroup(hotCommentGroup) + .content("최상위 댓글") + .build()); + commentRepository.save(Comment.builder() + .member(hotIssueVote.getMember()) + .commentGroup(hotCommentGroup) + .parent(parentComment) + .content("대댓글") + .build()); + + mockMvc.perform(get("/votes/trending") + .param("days", "7") + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.voteId").isNumber()) - .andExpect(jsonPath("$.title").value("오늘의 점심 선택은?")) - .andExpect(jsonPath("$.category").value(VoteCategory.FOOD.name())) - .andExpect(jsonPath("$.totalParticipants").value(100)) - .andExpect(jsonPath("$.createdBy").value("테스터1닉네임")) - .andExpect(jsonPath("$.options[0].content").value("A. 맵고 얼큰한 라면")) - .andExpect(jsonPath("$.options[0].vote_count").value(60)) - .andExpect(jsonPath("$.options[1].content").value("B. 부드러운 파스타")) - .andExpect(jsonPath("$.options[1].vote_count").value(40)); + .andExpect(jsonPath("$.scoreType").value("PERIOD")) + .andExpect(jsonPath("$.fallbackApplied").value(false)) + .andExpect(jsonPath("$.votes[0].voteId").isNumber()) + .andExpect(jsonPath("$.votes[0].title").value("오늘의 점심 선택은?")) + .andExpect(jsonPath("$.votes[0].category").value(VoteCategory.FOOD.name())) + .andExpect(jsonPath("$.votes[0].reactivityScore").value(3)) + .andExpect(jsonPath("$.votes[0].voteReactionCount").value(1)) + .andExpect(jsonPath("$.votes[0].commentReactionCount").value(2)) + .andExpect(jsonPath("$.votes[0].totalParticipants").value(100)) + .andExpect(jsonPath("$.votes[0].createdBy").value("테스터1닉네임")) + .andExpect(jsonPath("$.votes[0].options[0].content").value("A. 맵고 얼큰한 라면")) + .andExpect(jsonPath("$.votes[0].options[0].vote_count").value(60)); } @Test - @DisplayName("핫이슈 투표가 없을 때 404 Not Found를 반환한다.") + @DisplayName("트렌딩 투표가 없을 때 빈 목록을 반환한다.") void getHotIssueVote_NotFound() throws Exception { + memberVoteOptionRepository.deleteAll(); commentGroupRepository.deleteAll(); voteRepository.deleteAll(); - mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.error").value("핫이슈 투표를 찾을 수 없습니다.")) - .andExpect(jsonPath("$.status").value(404)); - // 참고: GlobalExceptionHandler에서 "type" 필드는 주석 처리되어 있음 + mockMvc.perform(get("/votes/trending") + .param("days", "7") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.fallbackApplied").value(true)) + .andExpect(jsonPath("$.votes").isEmpty()); + } + + @Test + @DisplayName("트렌딩 조회 기간이 허용 범위를 벗어나면 400 Bad Request를 반환한다.") + void getTrendingVotes_InvalidDays_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes/trending") + .param("days", "31") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("조회 기간은 1일 이상 30일 이하여야 합니다.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("트렌딩 조회 기간을 누락하면 400 Bad Request를 반환한다.") + void getTrendingVotes_MissingDays_ReturnsBadRequest() throws Exception { + mockMvc.perform(get("/votes/trending") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("days 파라미터를 입력해주세요.")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName("고정 투표를 첫 번째에 배치하고 반응성 순위와 중복 없이 반환한다.") + void getTrendingVotes_PinnedVoteComesFirst() throws Exception { + Vote pinnedVote = voteRepository.findAll().stream() + .filter(vote -> "연애 밸런스 게임".equals(vote.getTitle())) + .findFirst() + .orElseThrow(); + pinnedVote.pin(PinType.TRENDING); + voteRepository.save(pinnedVote); + + mockMvc.perform(get("/votes/trending") + .param("days", "7") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.votes.length()").value(2)) + .andExpect(jsonPath("$.votes[0].title").value("연애 밸런스 게임")) + .andExpect(jsonPath("$.votes[0].displayType").value("PINNED")) + .andExpect(jsonPath("$.votes[0].reactivityScore").value(0)) + .andExpect(jsonPath("$.votes[1].title").value("오늘의 점심 선택은?")) + .andExpect(jsonPath("$.votes[1].displayType").value("RANKED")); + } + + @Test + @DisplayName("기존 best 엔드포인트는 제거된다.") + void getBestVote_Removed() throws Exception { + mockMvc.perform(get("/votes/best") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); } @Test @DisplayName("동일한 반응성을 가진 투표 중 최신 투표를 조회한다.") void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { + memberVoteOptionRepository.deleteAll(); commentGroupRepository.deleteAll(); voteRepository.deleteAll(); @@ -202,7 +298,8 @@ void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { .reactivityUpdatedAt(LocalDateTime.now().minusDays(3)) .member(member3).pinType(PinType.NONE).build(); voteRepository.save(oldVote); - commentGroupRepository.save(CommentGroup.builder().vote(oldVote).totalCommentCount(0).build()); + CommentGroup oldCommentGroup = commentGroupRepository.save( + CommentGroup.builder().vote(oldVote).totalCommentCount(1).build()); Vote newVote = Vote.builder() .category(VoteCategory.LOVE).title("새로운 핫이슈 투표") @@ -210,13 +307,27 @@ void getHotIssueVote_SameTotalVoteCount_NewerIsHotIssue() throws Exception { .reactivityUpdatedAt(LocalDateTime.now()) .member(member3).pinType(PinType.NONE).build(); voteRepository.save(newVote); - commentGroupRepository.save(CommentGroup.builder().vote(newVote).totalCommentCount(0).build()); - - mockMvc.perform(get("/votes/best").contentType(MediaType.APPLICATION_JSON)) + CommentGroup newCommentGroup = commentGroupRepository.save( + CommentGroup.builder().vote(newVote).totalCommentCount(1).build()); + + commentRepository.save(Comment.builder() + .member(member3) + .commentGroup(oldCommentGroup) + .content("오래된 투표의 댓글") + .build()); + commentRepository.save(Comment.builder() + .member(member3) + .commentGroup(newCommentGroup) + .content("새로운 투표의 댓글") + .build()); + + mockMvc.perform(get("/votes/trending") + .param("days", "7") + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("새로운 핫이슈 투표")) - .andExpect(jsonPath("$.totalParticipants").value(50)) - .andExpect(jsonPath("$.createdBy").value("테스터3닉네임")); + .andExpect(jsonPath("$.votes[0].title").value("새로운 핫이슈 투표")) + .andExpect(jsonPath("$.votes[0].totalParticipants").value(50)) + .andExpect(jsonPath("$.votes[0].createdBy").value("테스터3닉네임")); } @Test @@ -321,12 +432,12 @@ void updatePinStatus_AdminSubjectZero_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(""" { - "pinType": "HOT" + "pinType": "TRENDING" } """)) .andExpect(status().isOk()); Vote pinnedVote = voteRepository.findById(vote.getId()).orElseThrow(); - assertThat(pinnedVote.getPinType()).isEqualTo(PinType.HOT); + assertThat(pinnedVote.getPinType()).isEqualTo(PinType.TRENDING); } } diff --git a/src/test/java/com/valanse/valanse/controller/VoteMinePaginationTest.java b/src/test/java/com/valanse/valanse/controller/VoteMinePaginationTest.java new file mode 100644 index 0000000..b79f177 --- /dev/null +++ b/src/test/java/com/valanse/valanse/controller/VoteMinePaginationTest.java @@ -0,0 +1,101 @@ +package com.valanse.valanse.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.valanse.valanse.common.api.ApiException; +import com.valanse.valanse.dto.Vote.PagedVoteResponse; +import com.valanse.valanse.service.MemberService.MemberService; +import com.valanse.valanse.service.VoteService.VoteService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UserDetails; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class VoteMinePaginationTest { + + @Test + @DisplayName("내가 만든 투표 목록의 page가 음수이면 400 예외를 발생시킨다") + void getMyCreatedVotes_NegativePage_ThrowsBadRequest() { + VoteController controller = new VoteController( + mock(VoteService.class), + mock(MemberService.class), + mock(ObjectMapper.class) + ); + UserDetails userDetails = mock(UserDetails.class); + + assertThatThrownBy(() -> controller.getMyCreatedVotes(userDetails, "ALL", "latest", -1, 10)) + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("page는 0 이상이어야 합니다."); + }); + } + + @Test + @DisplayName("내가 만든 투표 목록 조회는 page와 size를 Pageable로 전달한다") + void getMyCreatedVotes_UsesPageable() { + VoteService voteService = mock(VoteService.class); + VoteController controller = new VoteController( + voteService, + mock(MemberService.class), + mock(ObjectMapper.class) + ); + UserDetails userDetails = mock(UserDetails.class); + PagedVoteResponse response = PagedVoteResponse.builder() + .page(1) + .size(10) + .hasNext(false) + .build(); + + when(userDetails.getUsername()).thenReturn("1"); + when(voteService.getMyCreatedVotes(eq(1L), any(), any(), any(Pageable.class))) + .thenReturn(response); + + ResponseEntity result = + controller.getMyCreatedVotes(userDetails, "ALL", "latest", 1, 10); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(voteService).getMyCreatedVotes(eq(1L), eq("latest"), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(1); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(10); + assertThat(result.getBody()).isSameAs(response); + } + + @Test + @DisplayName("내가 투표한 목록 조회는 page와 size를 Pageable로 전달한다") + void getMyVotedVotes_UsesPageable() { + VoteService voteService = mock(VoteService.class); + VoteController controller = new VoteController( + voteService, + mock(MemberService.class), + mock(ObjectMapper.class) + ); + UserDetails userDetails = mock(UserDetails.class); + PagedVoteResponse response = PagedVoteResponse.builder() + .page(2) + .size(5) + .hasNext(true) + .build(); + + when(userDetails.getUsername()).thenReturn("1"); + when(voteService.getMyVotedVotes(eq(1L), any(), any(), any(Pageable.class))) + .thenReturn(response); + + ResponseEntity result = + controller.getMyVotedVotes(userDetails, "ALL", "oldest", 2, 5); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(voteService).getMyVotedVotes(eq(1L), eq("oldest"), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageNumber()).isEqualTo(2); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(5); + assertThat(result.getBody()).isSameAs(response); + } +} diff --git a/src/test/java/com/valanse/valanse/dto/IsBotJsonFieldNameTest.java b/src/test/java/com/valanse/valanse/dto/IsBotJsonFieldNameTest.java new file mode 100644 index 0000000..0392eb1 --- /dev/null +++ b/src/test/java/com/valanse/valanse/dto/IsBotJsonFieldNameTest.java @@ -0,0 +1,131 @@ +package com.valanse.valanse.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.valanse.valanse.domain.Comment; +import com.valanse.valanse.domain.CommentGroup; +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.Vote; +import com.valanse.valanse.domain.enums.PinType; +import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.dto.Comment.CommentReplyResponseDto; +import com.valanse.valanse.dto.Comment.CommentResponseDto; +import com.valanse.valanse.dto.Comment.MyCommentResponseDto; +import com.valanse.valanse.dto.Report.ReportedCommentResponse; +import com.valanse.valanse.dto.Vote.TrendingVoteItemResponse; +import com.valanse.valanse.dto.Vote.VoteDetailResponse; +import com.valanse.valanse.dto.Vote.VoteListResponse; +import com.valanse.valanse.dto.Vote.VoteResponseDto; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * isBot을 노출하는 모든 응답 DTO가 실제로 "isBot"이라는 JSON 필드명으로 직렬화되는지 + * 검증합니다. Boolean 필드 + Lombok @Getter 조합은 getIsBot()을 생성하는데, 이것이 + * "isBot"이 아니라 "bot"이나 "is_bot"으로 직렬화될 여지가 있어 코드만 보고는 확신할 수 + * 없으므로 직접 실행해 확인합니다. 일반 회원·봇 회원·탈퇴 회원 세 값도 함께 검증합니다. + */ +class IsBotJsonFieldNameTest { + + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void VoteResponseDto는_isBot으로_직렬화된다() throws Exception { + assertIsBot(new VoteResponseDto(voteBy(botMember())), true); + assertIsBot(new VoteResponseDto(voteBy(humanMember())), false); + assertIsBot(new VoteResponseDto(voteBy(null)), false); + } + + @Test + void VoteDetailResponse는_isBot으로_직렬화된다() throws Exception { + assertIsBot(VoteDetailResponse.builder().voteId(1L).isBot(true).build(), true); + assertIsBot(VoteDetailResponse.builder().voteId(1L).isBot(false).build(), false); + } + + @Test + void TrendingVoteItemResponse는_isBot으로_직렬화된다() throws Exception { + assertIsBot(TrendingVoteItemResponse.builder().voteId(1L).isBot(true).build(), true); + assertIsBot(TrendingVoteItemResponse.builder().voteId(1L).isBot(false).build(), false); + } + + @Test + void VoteListResponse_VoteDto는_isBot으로_직렬화된다() throws Exception { + assertIsBot(VoteListResponse.VoteDto.builder().id(1L).isBot(true).build(), true); + assertIsBot(VoteListResponse.VoteDto.builder().id(1L).isBot(false).build(), false); + } + + @Test + void CommentResponseDto는_isBot으로_직렬화된다() throws Exception { + CommentResponseDto bot = new CommentResponseDto(1L, 1L, "닉네임", true, null, + null, null, "내용", 0, 0, null, null, 0L, 0L, false); + CommentResponseDto human = new CommentResponseDto(1L, 1L, "닉네임", false, null, + null, null, "내용", 0, 0, null, null, 0L, 0L, false); + + assertIsBot(bot, true); + assertIsBot(human, false); + } + + @Test + void CommentReplyResponseDto는_isBot으로_직렬화된다() throws Exception { + assertIsBot(CommentReplyResponseDto.builder().id(1L).isBot(true).build(), true); + assertIsBot(CommentReplyResponseDto.builder().id(1L).isBot(false).build(), false); + } + + @Test + void MyCommentResponseDto는_isBot으로_직렬화된다() throws Exception { + assertIsBot(MyCommentResponseDto.fromEntity(commentBy(botMember())), true); + assertIsBot(MyCommentResponseDto.fromEntity(commentBy(humanMember())), false); + } + + @Test + void ReportedCommentResponse는_isBot으로_직렬화된다() throws Exception { + assertIsBot(new ReportedCommentResponse(commentBy(botMember())), true); + assertIsBot(new ReportedCommentResponse(commentBy(humanMember())), false); + assertIsBot(new ReportedCommentResponse(commentBy(null)), false); + } + + private void assertIsBot(Object dto, boolean expected) throws Exception { + String json = objectMapper.writeValueAsString(dto); + assertThat(json).as("직렬화된 JSON: %s", json).contains("\"isBot\":" + expected); + assertThat(json).doesNotContain("\"bot\":").doesNotContain("\"is_bot\":"); + } + + private Member botMember() { + return Member.builder().id(1L).nickname("봇회원").isBot(true).build(); + } + + private Member humanMember() { + return Member.builder().id(2L).nickname("일반회원").isBot(false).build(); + } + + private Vote voteBy(Member member) { + Vote vote = Vote.builder() + .id(1L) + .title("title") + .content("content") + .category(VoteCategory.ALL) + .totalVoteCount(0) + .pinType(PinType.NONE) + .member(member) + .build(); + ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); + return vote; + } + + private Comment commentBy(Member member) { + Vote vote = voteBy(member); + CommentGroup commentGroup = CommentGroup.builder().id(10L).vote(vote).build(); + return Comment.builder() + .id(1L) + .content("댓글 내용") + .likeCount(0) + .replyCount(0) + .member(member) + .commentGroup(commentGroup) + .build(); + } +} diff --git a/src/test/java/com/valanse/valanse/migration/SeedContentBotAccountsMigrationTest.java b/src/test/java/com/valanse/valanse/migration/SeedContentBotAccountsMigrationTest.java new file mode 100644 index 0000000..a077e58 --- /dev/null +++ b/src/test/java/com/valanse/valanse/migration/SeedContentBotAccountsMigrationTest.java @@ -0,0 +1,178 @@ +package com.valanse.valanse.migration; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V10 봇 계정 시드 마이그레이션을 실제 MySQL 컨테이너에 적용해 검증하는 테스트입니다. + * 테스트 프로필은 Flyway를 끄고 H2를 쓰기 때문에(application-test.yml), 이 SQL은 + * 별도로 실제 MySQL에서 검증해야 한다는 plan.md의 사항을 자동화합니다. + */ +@Testcontainers +class SeedContentBotAccountsMigrationTest { + + private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration"); + + @Container + private final MySQLContainer mysql = new MySQLContainer<>("mysql:8.0"); + + @Test + void seedsCorrectBotPersonas() throws Exception { + migrateToLatest(); + + try (Connection conn = connect()) { + assertPersona(conn, "content-seed-bot-001", "한입만판사", "FEMALE", "TWENTY", "ENFP"); + assertPersona(conn, "content-seed-bot-002", "연애배심원", "MALE", "THIRTY", "INFJ"); + assertPersona(conn, "content-seed-bot-003", "장바구니철학자", "FEMALE", "THIRTY", "ISTJ"); + assertPersona(conn, "content-seed-bot-004", "숨참고승부", "MALE", "TWENTY", "ESTP"); + assertPersona(conn, "content-seed-bot-005", "결정은내일", "FEMALE", "OVER_FORTY", "INTP"); + } + } + + @Test + void skipsProfileCreationWhenSocialIdCollidesWithNonBotMember() throws Exception { + migrateTo("9"); + + try (Connection conn = connect(); Statement st = conn.createStatement()) { + st.execute("INSERT INTO member (created_at, updated_at, social_id, social_type, role, name, nickname, is_bot) " + + "VALUES (NOW(6), NOW(6), 'content-seed-bot-001', 'KAKAO', 'USER', '진짜유저', '진짜유저', FALSE)"); + } + + migrateToLatest(); + + try (Connection conn = connect()) { + // 충돌한 기존 회원은 그대로 유지되고, 프로필은 생성되지 않는다. + assertThat(memberIsBot(conn, "content-seed-bot-001")).isFalse(); + assertThat(memberNickname(conn, "content-seed-bot-001")).isEqualTo("진짜유저"); + assertThat(hasProfile(conn, "content-seed-bot-001")).isFalse(); + + // 충돌하지 않은 나머지 4개 봇은 정상적으로 시드된다. + assertPersona(conn, "content-seed-bot-002", "연애배심원", "MALE", "THIRTY", "INFJ"); + assertPersona(conn, "content-seed-bot-003", "장바구니철학자", "FEMALE", "THIRTY", "ISTJ"); + assertPersona(conn, "content-seed-bot-004", "숨참고승부", "MALE", "TWENTY", "ESTP"); + assertPersona(conn, "content-seed-bot-005", "결정은내일", "FEMALE", "OVER_FORTY", "INTP"); + } + } + + @Test + void reApplyingSeedSqlDirectlyIsIdempotent() throws Exception { + migrateToLatest(); + executeSqlFile("V10__seed_content_bot_accounts.sql"); // Flyway 밖에서 같은 SQL을 한 번 더 직접 실행 + + try (Connection conn = connect()) { + assertThat(memberCount(conn)).isEqualTo(5); + assertThat(profileCount(conn)).isEqualTo(5); + } + } + + private void migrateToLatest() { + Flyway.configure() + .dataSource(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + private void migrateTo(String targetVersion) { + Flyway.configure() + .dataSource(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword()) + .locations("classpath:db/migration") + .target(targetVersion) + .load() + .migrate(); + } + + private void executeSqlFile(String fileName) throws IOException, SQLException { + String sql = Files.readString(MIGRATION_DIR.resolve(fileName), StandardCharsets.UTF_8); + try (Connection conn = connect(); Statement st = conn.createStatement()) { + for (String statement : sql.split(";")) { + String trimmed = statement.strip(); + if (!trimmed.isEmpty()) { + st.execute(trimmed); + } + } + } + } + + private Connection connect() throws SQLException { + return DriverManager.getConnection(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword()); + } + + private void assertPersona(Connection conn, String socialId, String nickname, String gender, String age, String mbti) throws SQLException { + String sql = "SELECT mp.nickname, mp.gender, mp.age, mp.mbti FROM member m " + + "JOIN member_profile mp ON mp.member_id = m.id WHERE m.social_id = ?"; + try (var ps = conn.prepareStatement(sql)) { + ps.setString(1, socialId); + try (ResultSet rs = ps.executeQuery()) { + assertThat(rs.next()).as("profile row for %s", socialId).isTrue(); + assertThat(rs.getString("nickname")).isEqualTo(nickname); + assertThat(rs.getString("gender")).isEqualTo(gender); + assertThat(rs.getString("age")).isEqualTo(age); + assertThat(rs.getString("mbti")).isEqualTo(mbti); + } + } + } + + private boolean memberIsBot(Connection conn, String socialId) throws SQLException { + try (var ps = conn.prepareStatement("SELECT is_bot FROM member WHERE social_id = ?")) { + ps.setString(1, socialId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getBoolean(1); + } + } + } + + private String memberNickname(Connection conn, String socialId) throws SQLException { + try (var ps = conn.prepareStatement("SELECT nickname FROM member WHERE social_id = ?")) { + ps.setString(1, socialId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } + } + } + + private boolean hasProfile(Connection conn, String socialId) throws SQLException { + String sql = "SELECT COUNT(*) FROM member_profile mp JOIN member m ON m.id = mp.member_id WHERE m.social_id = ?"; + try (var ps = conn.prepareStatement(sql)) { + ps.setString(1, socialId); + try (ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getInt(1) > 0; + } + } + } + + private int memberCount(Connection conn) throws SQLException { + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM member WHERE social_id LIKE 'content-seed-bot-%'")) { + rs.next(); + return rs.getInt(1); + } + } + + private int profileCount(Connection conn) throws SQLException { + String sql = "SELECT COUNT(*) FROM member_profile mp JOIN member m ON m.id = mp.member_id " + + "WHERE m.social_id LIKE 'content-seed-bot-%'"; + try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) { + rs.next(); + return rs.getInt(1); + } + } +} diff --git a/src/test/java/com/valanse/valanse/service/BotIsolationIntegrationTest.java b/src/test/java/com/valanse/valanse/service/BotIsolationIntegrationTest.java new file mode 100644 index 0000000..fe6fc28 --- /dev/null +++ b/src/test/java/com/valanse/valanse/service/BotIsolationIntegrationTest.java @@ -0,0 +1,305 @@ +package com.valanse.valanse.service; + +import com.valanse.valanse.domain.CommentGroup; +import com.valanse.valanse.domain.Member; +import com.valanse.valanse.domain.MemberProfile; +import com.valanse.valanse.domain.Vote; +import com.valanse.valanse.domain.VoteOption; +import com.valanse.valanse.domain.enums.Age; +import com.valanse.valanse.domain.enums.Gender; +import com.valanse.valanse.domain.enums.MbtiIe; +import com.valanse.valanse.domain.enums.MbtiTf; +import com.valanse.valanse.domain.enums.PinType; +import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.SocialType; +import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.domain.enums.VoteLabel; +import com.valanse.valanse.dto.Comment.CommentPostRequest; +import com.valanse.valanse.dto.Vote.TrendingVotesResponse; +import com.valanse.valanse.dto.VotesCheck.VoteAgeResultResponseDto; +import com.valanse.valanse.dto.VotesCheck.VoteGenderResultResponseDto; +import com.valanse.valanse.dto.VotesCheck.VoteMbtiResultResponseDto; +import com.valanse.valanse.repository.CommentGroupRepository; +import com.valanse.valanse.repository.CommentRepository; +import com.valanse.valanse.repository.MemberProfileRepository; +import com.valanse.valanse.repository.MemberRepository; +import com.valanse.valanse.repository.MemberVoteOptionRepository; +import com.valanse.valanse.repository.PointHistoryRepository; +import com.valanse.valanse.repository.VoteOptionRepository; +import com.valanse.valanse.repository.VoteRepository; +import com.valanse.valanse.repository.VotesCheckRepositoryCustom.VoteResultQueryRepository; +import com.valanse.valanse.service.CommentService.CommentService; +import com.valanse.valanse.service.VoteService.VoteService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 봇 계정의 투표·댓글 활동이 성별·연령·MBTI 통계와 트렌딩 랭킹을 오염시키지 않는지 + * 검증하는 통합 테스트입니다. + */ +@SpringBootTest +@ActiveProfiles("test") +class BotIsolationIntegrationTest { + + @Autowired private VoteService voteService; + @Autowired private CommentService commentService; + @Autowired private VoteResultQueryRepository voteResultQueryRepository; + + @Autowired private MemberRepository memberRepository; + @Autowired private MemberProfileRepository memberProfileRepository; + @Autowired private VoteRepository voteRepository; + @Autowired private VoteOptionRepository voteOptionRepository; + @Autowired private MemberVoteOptionRepository memberVoteOptionRepository; + @Autowired private CommentGroupRepository commentGroupRepository; + @Autowired private CommentRepository commentRepository; + @Autowired private PointHistoryRepository pointHistoryRepository; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private JdbcTemplate jdbcTemplate; + + @BeforeEach + void cleanUpBefore() { + cleanUp(); + } + + @AfterEach + void cleanUpAfter() { + cleanUp(); + } + + @Test + @DisplayName("성별 통계는 봇 투표를 제외한다") + void genderStats_ExcludeBotVotes() { + Member creator = saveMember("real-creator2", false); + saveProfile(creator, Gender.MALE, Age.TWENTY); + Vote vote = saveVoteWithOptions(creator); + VoteOption option = vote.getVoteOptions().get(0); + + Member realVoter = saveMember("real-voter", false); + saveProfile(realVoter, Gender.FEMALE, Age.TWENTY); + voteService.processVote(realVoter.getId(), vote.getId(), option.getId()); + + Member bot = saveMember("content-seed-bot-002", true); + saveProfile(bot, Gender.FEMALE, Age.THIRTY); + voteService.processVote(bot.getId(), vote.getId(), option.getId()); + + VoteGenderResultResponseDto result = voteResultQueryRepository.findVoteResultByGender(vote.getId(), "FEMALE"); + + assertThat(result.getTotalCount()).isEqualTo(1); + } + + @Test + @DisplayName("연령 통계는 봇 투표를 제외한다") + void ageStats_ExcludeBotVotes() { + Member creator = saveMember("age-stats-creator", false); + saveProfile(creator, Gender.MALE, Age.TWENTY); + Vote vote = saveVoteWithOptions(creator); + VoteOption option = vote.getVoteOptions().get(0); + + Member realVoter = saveMember("age-stats-real-voter", false); + saveProfile(realVoter, Gender.FEMALE, Age.TWENTY); + voteService.processVote(realVoter.getId(), vote.getId(), option.getId()); + + Member bot = saveMember("content-seed-bot-006", true); + saveProfile(bot, Gender.FEMALE, Age.TWENTY); + voteService.processVote(bot.getId(), vote.getId(), option.getId()); + + VoteAgeResultResponseDto result = voteResultQueryRepository.findVoteResultByAge(vote.getId()); + + VoteAgeResultResponseDto.AgeGroupStats stats = result.getAgeRatios().get(option.getLabel().name()); + assertThat(stats.getTotalCount()).isEqualTo(1); + assertThat(stats.getAgeGroups().get("20대").getVoteCount()).isEqualTo(1); + } + + @Test + @DisplayName("MBTI 통계는 봇 투표를 제외한다") + void mbtiStats_ExcludeBotVotes() { + Member creator = saveMember("mbti-stats-creator", false); + saveProfile(creator, Gender.MALE, Age.TWENTY); + Vote vote = saveVoteWithOptions(creator); + VoteOption option = vote.getVoteOptions().get(0); + + Member realVoter = saveMember("mbti-stats-real-voter", false); + saveProfileWithMbti(realVoter, Gender.FEMALE, Age.TWENTY, MbtiIe.I, MbtiTf.F, "INFP"); + voteService.processVote(realVoter.getId(), vote.getId(), option.getId()); + + Member bot = saveMember("content-seed-bot-007", true); + saveProfileWithMbti(bot, Gender.FEMALE, Age.TWENTY, MbtiIe.I, MbtiTf.F, "INFP"); + voteService.processVote(bot.getId(), vote.getId(), option.getId()); + + VoteMbtiResultResponseDto result = voteResultQueryRepository.findVoteResultByMbti(vote.getId(), "ie"); + + VoteMbtiResultResponseDto.OptionRatio optionResult = result.getMbti_ratios().get("I").stream() + .filter(r -> r.getContent().equals(option.getContent())) + .findFirst() + .orElseThrow(); + assertThat(optionResult.getVote_count()).isEqualTo(1); + } + + @Test + @DisplayName("트렌딩 랭킹은 봇 투표·댓글만 있는 게시글을 후보에서 제외한다") + void trendingVotes_ExcludesVoteWithOnlyBotActivity() { + Member realVoteCreator = saveMember("trending-real-participation", false); + saveProfile(realVoteCreator, Gender.MALE, Age.TWENTY); + Vote voteWithRealVote = saveVoteWithOptions(realVoteCreator); + VoteOption realOption = voteWithRealVote.getVoteOptions().get(0); + + Member realVoter = saveMember("trending-real-voter", false); + saveProfile(realVoter, Gender.MALE, Age.TWENTY); + voteService.processVote(realVoter.getId(), voteWithRealVote.getId(), realOption.getId()); + + Member botOnlyCreator = saveMember("trending-bot-only", false); + saveProfile(botOnlyCreator, Gender.MALE, Age.TWENTY); + Vote voteWithBotActivity = saveVoteWithOptions(botOnlyCreator); + VoteOption botOption = voteWithBotActivity.getVoteOptions().get(0); + + Member bot = saveMember("content-seed-bot-008", true); + saveProfile(bot, Gender.FEMALE, Age.TWENTY); + voteService.processVote(bot.getId(), voteWithBotActivity.getId(), botOption.getId()); + commentService.createComment(voteWithBotActivity.getId(), bot.getId(), + CommentPostRequest.builder().content("bot comment").build()); + + TrendingVotesResponse response = voteService.getTrendingVotes(1); + + assertThat(response.getVotes()) + .extracting("voteId") + .containsExactly(voteWithRealVote.getId()); + } + + @Test + @DisplayName("트렌딩 랭킹은 탈퇴 회원의 익명화된 댓글을 기간 내 활동으로 인정한다") + void trendingVotes_IncludesAnonymizedMemberComments() { + Member voteCreator = saveMember("trending-vote-with-anonymized-comment", false); + saveProfile(voteCreator, Gender.MALE, Age.TWENTY); + Vote voteWithAnonymizedComment = saveVoteWithOptions(voteCreator); + saveCommentGroup(voteWithAnonymizedComment); + + Member withdrawnMember = saveMember("trending-withdrawn-commenter", false); + saveProfile(withdrawnMember, Gender.FEMALE, Age.TWENTY); + Long commentId = commentService.createComment(voteWithAnonymizedComment.getId(), withdrawnMember.getId(), + CommentPostRequest.builder().content("탈퇴 전에 남긴 실제 댓글").build()); + + // 회원 물리 삭제 시 SoftDeletePurgeService.anonymizeComments()가 하는 것과 동일하게 + // 댓글 작성자를 NULL로 익명화한 상황을 재현합니다. + inTransaction(() -> { + jdbcTemplate.update("update comment set member_id = null where id = ?", commentId); + return null; + }); + + Member laterCreator = saveMember("trending-vote-with-no-activity", false); + saveProfile(laterCreator, Gender.MALE, Age.TWENTY); + saveVoteWithOptions(laterCreator); // 활동이 전혀 없는, 비교 대상 투표 + + TrendingVotesResponse response = voteService.getTrendingVotes(1); + + // 익명화된 댓글이 봇 댓글처럼 배제되면 이 투표는 후보에서 아예 빠진다. + assertThat(response.getVotes()) + .extracting("voteId") + .contains(voteWithAnonymizedComment.getId()); + } + + private Member saveMember(String socialId, boolean isBot) { + return inTransaction(() -> memberRepository.save(Member.builder() + .socialId(socialId) + .email(socialId + "@example.com") + .name(socialId) + .nickname(socialId) + .role(Role.USER) + .socialType(SocialType.KAKAO) + .isBot(isBot) + .build())); + } + + private MemberProfile saveProfile(Member member, Gender gender, Age age) { + return inTransaction(() -> { + Member managedMember = memberRepository.findById(member.getId()).orElseThrow(); + return memberProfileRepository.save(MemberProfile.builder() + .member(managedMember) + .nickname(managedMember.getNickname()) + .gender(gender) + .age(age) + .point(0L) + .build()); + }); + } + + private MemberProfile saveProfileWithMbti(Member member, Gender gender, Age age, MbtiIe mbtiIe, MbtiTf mbtiTf, String mbti) { + return inTransaction(() -> { + Member managedMember = memberRepository.findById(member.getId()).orElseThrow(); + return memberProfileRepository.save(MemberProfile.builder() + .member(managedMember) + .nickname(managedMember.getNickname()) + .gender(gender) + .age(age) + .mbtiIe(mbtiIe) + .mbtiTf(mbtiTf) + .mbti(mbti) + .point(0L) + .build()); + }); + } + + private Vote saveVoteWithOptions(Member member) { + return inTransaction(() -> { + Member managedMember = memberRepository.findById(member.getId()).orElseThrow(); + Vote vote = Vote.builder() + .member(managedMember) + .title("vote") + .content("content") + .category(VoteCategory.ALL) + .totalVoteCount(0) + .pinType(PinType.NONE) + .build(); + vote.addVoteOption(VoteOption.builder() + .content("A") + .label(VoteLabel.A) + .voteCount(0) + .build()); + vote.addVoteOption(VoteOption.builder() + .content("B") + .label(VoteLabel.B) + .voteCount(0) + .build()); + return voteRepository.saveAndFlush(vote); + }); + } + + private CommentGroup saveCommentGroup(Vote vote) { + return inTransaction(() -> { + Vote managedVote = voteRepository.findById(vote.getId()).orElseThrow(); + return commentGroupRepository.saveAndFlush(CommentGroup.builder() + .vote(managedVote) + .totalCommentCount(0) + .build()); + }); + } + + private void cleanUp() { + inTransaction(() -> { + memberVoteOptionRepository.deleteAllInBatch(); + pointHistoryRepository.deleteAllInBatch(); + commentRepository.deleteAllInBatch(); + commentGroupRepository.deleteAllInBatch(); + voteOptionRepository.deleteAllInBatch(); + voteRepository.deleteAllInBatch(); + memberProfileRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + return null; + }); + } + + private T inTransaction(Supplier supplier) { + return new TransactionTemplate(transactionManager).execute(status -> supplier.get()); + } +} diff --git a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java index d80ed55..af55481 100644 --- a/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/CommentService/CommentServiceImplTest.java @@ -5,9 +5,11 @@ import com.valanse.valanse.common.message.CommentErrorMessage; import com.valanse.valanse.domain.*; import com.valanse.valanse.domain.enums.Role; +import com.valanse.valanse.domain.enums.VoteLabel; import com.valanse.valanse.dto.Comment.BestCommentResponseDto; import com.valanse.valanse.dto.Comment.CommentPostRequest; import com.valanse.valanse.dto.Comment.CommentReplyResponseDto; +import com.valanse.valanse.dto.Comment.PagedCommentReplyResponse; import com.valanse.valanse.repository.*; import com.valanse.valanse.service.PointService.PointService; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +20,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; @@ -502,10 +506,11 @@ void getBestComment_actualCount_test() { when(commentRepository.findById(10L)).thenReturn(Optional.of(parentComment)); ApiException ex = assertThrows(ApiException.class, - () -> commentService.getReplies(member, 1L, 10L)); + () -> commentService.getReplies(member, 1L, 10L, PageRequest.of(0, 10))); assertThat(ex.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); verify(commentRepository, never()).findAllByParentId(any()); + verify(commentRepository, never()).findRepliesByParentIdSlice(anyLong(), anyLong(), any(), any(), anyBoolean()); } @Test @@ -530,16 +535,33 @@ void getBestComment_actualCount_test() { when(voteRepository.findById(1L)).thenReturn(Optional.of(vote)); when(commentRepository.findById(10L)).thenReturn(Optional.of(parent)); - when(commentRepository.findAllByParentId(10L)).thenReturn(List.of(reply)); - when(memberProfileRepository.findByMemberId(1L)).thenReturn(Optional.of(profile)); - when(memberProfileTitleRepository.findByMemberProfileMemberIdAndEquippedTrue(1L)) - .thenReturn(Optional.empty()); + CommentReplyResponseDto pagedReply = new CommentReplyResponseDto( + 11L, + "테스터", + null, + null, + reply.getCreatedAt(), + reply.getContent(), + 0, + 0, + true, + reply.getDeletedAt(), + VoteLabel.A, + 0L, + 1L, + true + ); + when(commentRepository.findRepliesByParentIdSlice(eq(1L), eq(10L), any(), eq(1L), eq(false))) + .thenReturn(new SliceImpl<>(List.of(pagedReply), PageRequest.of(0, 10), false)); - List responses = commentService.getReplies(member, 1L, 10L); + PagedCommentReplyResponse response = commentService.getReplies(member, 1L, 10L, PageRequest.of(0, 10)); - assertThat(responses).singleElement().satisfies(response -> { - assertThat(response.isDeleted()).isTrue(); - assertThat(response.getDeletedAt()).isNotNull(); + assertThat(response.getPage()).isZero(); + assertThat(response.getSize()).isEqualTo(10); + assertThat(response.isHasNext()).isFalse(); + assertThat(response.getReplies()).singleElement().satisfies(replyResponse -> { + assertThat(replyResponse.isDeleted()).isTrue(); + assertThat(replyResponse.getDeletedAt()).isNotNull(); }); } } diff --git a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java index 0a3cbaa..8e52f31 100644 --- a/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/PointService/PointServiceImplTest.java @@ -16,6 +16,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; import java.time.LocalDateTime; import java.util.Arrays; @@ -24,8 +26,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -77,11 +83,11 @@ void getPointHistory_Success() { when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) .thenReturn(Optional.of(member)); - when(pointHistoryRepository.findByMemberId(memberId)) - .thenReturn(histories); + when(pointHistoryRepository.findByMemberIdOrderByCreatedAtDescIdDesc(memberId, PageRequest.of(0, 10))) + .thenReturn(new SliceImpl<>(histories, PageRequest.of(0, 10), false)); // When - PointHistoryResponse response = pointService.getPointHistory(memberId); + PointHistoryResponse response = pointService.getPointHistory(memberId, PageRequest.of(0, 10)); // Then assertThat(response).isNotNull(); @@ -103,7 +109,7 @@ void getPointHistory_MemberNotFound() { .thenReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> pointService.getPointHistory(memberId)) + assertThatThrownBy(() -> pointService.getPointHistory(memberId, PageRequest.of(0, 10))) .isInstanceOf(ApiException.class) .hasMessage(MemberErrorMessage.MEMBER_NOT_FOUND.message()); } @@ -126,11 +132,13 @@ void getPointTypeDescription_AllTypes() { when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) .thenReturn(Optional.of(member)); + when(pointHistoryRepository.findByMemberIdOrderByCreatedAtDescIdDesc(memberId, PageRequest.of(0, 10))) + .thenReturn(new SliceImpl<>(histories, PageRequest.of(0, 10), false)); when(pointHistoryRepository.findByMemberId(memberId)) .thenReturn(histories); // When - PointHistoryResponse response = pointService.getPointHistory(memberId); + PointHistoryResponse response = pointService.getPointHistory(memberId, PageRequest.of(0, 10)); // Then List items = response.pointHistory(); @@ -191,11 +199,11 @@ void getPointHistory_DateFormatting() { when(memberRepository.findByIdAndDeletedAtIsNull(memberId)) .thenReturn(Optional.of(member)); - when(pointHistoryRepository.findByMemberId(memberId)) - .thenReturn(histories); + when(pointHistoryRepository.findByMemberIdOrderByCreatedAtDescIdDesc(memberId, PageRequest.of(0, 10))) + .thenReturn(new SliceImpl<>(histories, PageRequest.of(0, 10), false)); // When - PointHistoryResponse response = pointService.getPointHistory(memberId); + PointHistoryResponse response = pointService.getPointHistory(memberId, PageRequest.of(0, 10)); // Then assertThat(response).isNotNull(); @@ -209,4 +217,74 @@ void getPointHistory_DateFormatting() { assertThat(item.type()).isEqualTo(PointType.SIGN_UP); assertThat(item.typeDescription()).isEqualTo("회원가입"); } + + @Test + @DisplayName("일반 회원에게는 포인트가 정상 지급되고 이력이 저장된다") + void givePoint_NormalMember_PointGivenAndHistorySaved() { + // Given + Long memberId = 1L; + Member member = Member.builder().id(memberId).isBot(false).build(); + MemberProfile profileBeforeUpdate = MemberProfile.builder().member(member).point(100L).build(); + MemberProfile profileAfterUpdate = MemberProfile.builder().member(member).point(105L).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberIdForUpdate(memberId)).thenReturn(Optional.of(profileBeforeUpdate)); + when(memberProfileRepository.findByMemberId(memberId)).thenReturn(Optional.of(profileAfterUpdate)); + + // When + pointService.givePoint(memberId, PointType.POST_CREATE); + + // Then + verify(memberProfileRepository).addPointAtomically(memberId, 5L); + verify(pointHistoryRepository).save(argThat(history -> + history.getMember() == member && + history.getAmount().equals(5L) && + history.getRemainingPoint().equals(105L) && + history.getType() == PointType.POST_CREATE + )); + } + + @Test + @DisplayName("회원가입 포인트는 일반 회원에게 40점 지급된다") + void givePoint_SignUp_Gives40Points() { + // Given + Long memberId = 2L; + Member member = Member.builder().id(memberId).isBot(false).build(); + MemberProfile profileBeforeUpdate = MemberProfile.builder().member(member).point(0L).build(); + MemberProfile profileAfterUpdate = MemberProfile.builder().member(member).point(40L).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(memberId)).thenReturn(Optional.of(member)); + when(memberProfileRepository.findByMemberIdForUpdate(memberId)).thenReturn(Optional.of(profileBeforeUpdate)); + when(memberProfileRepository.findByMemberId(memberId)).thenReturn(Optional.of(profileAfterUpdate)); + + // When + pointService.givePoint(memberId, PointType.SIGN_UP); + + // Then + verify(memberProfileRepository).addPointAtomically(memberId, 40L); + verify(pointHistoryRepository).save(argThat(history -> + history.getAmount().equals(40L) && + history.getRemainingPoint().equals(40L) && + history.getType() == PointType.SIGN_UP + )); + } + + @Test + @DisplayName("봇 회원에게는 포인트를 지급하지 않는다") + void givePoint_BotMember_NoPointGiven() { + // Given + Long botMemberId = 3L; + Member botMember = Member.builder().id(botMemberId).isBot(true).build(); + + when(memberRepository.findByIdAndDeletedAtIsNull(botMemberId)) + .thenReturn(Optional.of(botMember)); + + // When + pointService.givePoint(botMemberId, PointType.POST_CREATE); + + // Then + verify(memberProfileRepository, never()).findByMemberIdForUpdate(botMemberId); + verify(memberProfileRepository, never()).addPointAtomically(eq(botMemberId), anyLong()); + verify(pointHistoryRepository, never()).save(any()); + } } diff --git a/src/test/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeSchedulerTest.java b/src/test/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeSchedulerTest.java index 979b52c..b92986e 100644 --- a/src/test/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeSchedulerTest.java +++ b/src/test/java/com/valanse/valanse/service/PurgeService/SoftDeletePurgeSchedulerTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; class SoftDeletePurgeSchedulerTest { @@ -30,4 +31,19 @@ void doesNothingWhenDisabled() { verifyNoInteractions(service); } + + @Test + void propagatesFailureForSchedulerErrorHandlerAndReleasesRunningFlag() { + SoftDeletePurgeService service = mock(SoftDeletePurgeService.class); + when(service.purgeExpired(any())) + .thenThrow(new IllegalStateException("purge failed")) + .thenReturn(new PurgeResult(0, 0, 0)); + SoftDeletePurgeScheduler scheduler = new SoftDeletePurgeScheduler( + service, new PurgeProperties(true, 14, 100)); + + assertThrows(IllegalStateException.class, scheduler::purge); + scheduler.purge(); + + verify(service, times(2)).purgeExpired(any()); + } } diff --git a/src/test/java/com/valanse/valanse/service/ReportService/ReportIntegrationTest.java b/src/test/java/com/valanse/valanse/service/ReportService/ReportIntegrationTest.java index 4b9ffd3..5df8690 100644 --- a/src/test/java/com/valanse/valanse/service/ReportService/ReportIntegrationTest.java +++ b/src/test/java/com/valanse/valanse/service/ReportService/ReportIntegrationTest.java @@ -9,6 +9,7 @@ import com.valanse.valanse.domain.enums.ReportType; import com.valanse.valanse.domain.enums.Role; import com.valanse.valanse.domain.enums.VoteCategory; +import com.valanse.valanse.dto.Report.PagedReportedTargetResponse; import com.valanse.valanse.dto.Report.ReportDetailResponse; import com.valanse.valanse.dto.Report.ReportedTargetResponse; import com.valanse.valanse.repository.MemberRepository; @@ -17,6 +18,7 @@ import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; @@ -68,12 +70,13 @@ void reportedTargetsExcludeDeletedTarget() { entityManager.flush(); entityManager.clear(); - List results = - reportService.getReportedTargets(admin, ReportType.VOTE, "latest"); + PagedReportedTargetResponse results = + reportService.getReportedTargets(admin, ReportType.VOTE, "latest", PageRequest.of(0, 10)); - assertThat(results) + assertThat(results.getReports()) .extracting(ReportedTargetResponse::getTargetId) .containsExactly(activeVote.getId()); + assertThat(results.isHasNext()).isFalse(); } @Test diff --git a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java index aaa8d89..74905a4 100644 --- a/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java +++ b/src/test/java/com/valanse/valanse/service/VoteService/VoteServiceImplTest.java @@ -13,6 +13,10 @@ import com.valanse.valanse.dto.Vote.VoteCancleResponseDto; import com.valanse.valanse.dto.Vote.VoteCreateRequest; import com.valanse.valanse.dto.Vote.VoteDetailResponse; +import com.valanse.valanse.dto.Vote.TrendingDisplayType; +import com.valanse.valanse.dto.Vote.TrendingScoreType; +import com.valanse.valanse.dto.Vote.TrendingVoteScore; +import com.valanse.valanse.dto.Vote.TrendingVotesResponse; import com.valanse.valanse.repository.*; import com.valanse.valanse.service.PointService.PointService; import com.valanse.valanse.service.StorageService.StorageService; @@ -36,6 +40,9 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; @@ -57,6 +64,84 @@ class VoteServiceImplTest { @Mock private MemberProfileTitleRepository memberProfileTitleRepository; @Mock private PointService pointService; @Mock private StorageService storageService; + @Mock private Clock clock; + + @Test + @DisplayName("트렌딩 기간이 허용 범위를 벗어나면 예외가 발생한다") + void getTrendingVotes_InvalidDays() { + assertThatThrownBy(() -> voteService.getTrendingVotes(0)) + .isInstanceOf(ApiException.class) + .hasMessage("조회 기간은 1일 이상 30일 이하여야 합니다."); + + verifyNoInteractions(voteRepository); + } + + @Test + @DisplayName("기간 내 반응이 없으면 전체 누적 반응성으로 조회한다") + void getTrendingVotes_FallsBackToAllTime() { + stubClock(); + Vote first = Vote.builder().id(1L).title("첫 번째").voteOptions(List.of()).build(); + Vote second = Vote.builder().id(2L).title("두 번째").voteOptions(List.of()).build(); + List allTimeScores = List.of( + new TrendingVoteScore(1L, 5L, 3L, 8L, null), + new TrendingVoteScore(2L, 2L, 1L, 3L, null) + ); + + when(voteRepository.findTopTrendingVotes(any(), any(), eq(5))).thenReturn(List.of()); + when(voteRepository.findTopAllTimeTrendingVotes(5)).thenReturn(allTimeScores); + when(voteRepository.findByPinType(PinType.TRENDING)).thenReturn(Optional.empty()); + when(voteRepository.findTrendingVoteDetailsByIds(List.of(1L, 2L))) + .thenReturn(List.of(second, first)); + + TrendingVotesResponse response = voteService.getTrendingVotes(7); + + assertThat(response.isFallbackApplied()).isTrue(); + assertThat(response.getScoreType()).isEqualTo(TrendingScoreType.ALL_TIME); + assertThat(response.getVotes()).extracting("voteId").containsExactly(1L, 2L); + assertThat(response.getVotes()).extracting("reactivityScore").containsExactly(8L, 3L); + } + + @Test + @DisplayName("고정 투표는 중복 없이 첫 번째에 배치하고 나머지를 반응성 순으로 채운다") + void getTrendingVotes_PinnedVoteComesFirstWithoutDuplication() { + stubClock(); + List scores = List.of( + new TrendingVoteScore(1L, 10L, 0L, 10L, null), + new TrendingVoteScore(2L, 9L, 0L, 9L, null), + new TrendingVoteScore(3L, 8L, 0L, 8L, null), + new TrendingVoteScore(4L, 7L, 0L, 7L, null), + new TrendingVoteScore(5L, 6L, 0L, 6L, null) + ); + List votes = scores.stream() + .map(score -> Vote.builder() + .id(score.voteId()) + .title("투표 " + score.voteId()) + .voteOptions(List.of()) + .build()) + .toList(); + + when(voteRepository.findTopTrendingVotes(any(), any(), eq(5))).thenReturn(scores); + when(voteRepository.findByPinType(PinType.TRENDING)) + .thenReturn(Optional.of(votes.get(2))); + when(voteRepository.findTrendingVoteDetailsByIds(List.of(3L, 1L, 2L, 4L, 5L))) + .thenReturn(votes); + + TrendingVotesResponse response = voteService.getTrendingVotes(7); + + assertThat(response.isFallbackApplied()).isFalse(); + assertThat(response.getVotes()).extracting("voteId") + .containsExactly(3L, 1L, 2L, 4L, 5L); + assertThat(response.getVotes().get(0).getDisplayType()) + .isEqualTo(TrendingDisplayType.PINNED); + assertThat(response.getVotes().subList(1, 5)) + .extracting("displayType") + .containsOnly(TrendingDisplayType.RANKED); + } + + private void stubClock() { + when(clock.instant()).thenReturn(Instant.parse("2026-07-20T00:00:00Z")); + when(clock.getZone()).thenReturn(ZoneId.of("Asia/Seoul")); + } @org.junit.jupiter.api.AfterEach void clearSecurityContext() { @@ -538,27 +623,27 @@ void clearSecurityContext() { Member user = Member.builder().id(1L).role(Role.USER).build(); ApiException ex = assertThrows(ApiException.class, - () -> voteService.updatePinStatus(user, 10L, PinType.HOT)); + () -> voteService.updatePinStatus(user, 10L, PinType.TRENDING)); assertThat(ex.getMessage()).isEqualTo("권한이 없습니다."); assertThat(ex.getStatus()).isEqualTo(HttpStatus.FORBIDDEN); } @Test - @DisplayName("관리자가 HOT 핀 설정 시 기존 HOT 게시물의 핀이 해제된다") - void 핀설정_기존핀해제() { + @DisplayName("관리자가 TRENDING 핀 설정 시 기존 TRENDING 게시물의 핀이 해제된다") + void 트렌딩핀설정_기존핀해제() { Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); - Vote existingHot = Vote.builder().id(99L).build(); - existingHot.pin(PinType.HOT); + Vote existingTrending = Vote.builder().id(99L).build(); + existingTrending.pin(PinType.TRENDING); Vote newVote = Vote.builder().id(10L).build(); when(voteRepository.findById(10L)).thenReturn(Optional.of(newVote)); - when(voteRepository.findByPinType(PinType.HOT)).thenReturn(Optional.of(existingHot)); + when(voteRepository.findByPinType(PinType.TRENDING)).thenReturn(Optional.of(existingTrending)); - voteService.updatePinStatus(admin, 10L, PinType.HOT); + voteService.updatePinStatus(admin, 10L, PinType.TRENDING); - assertThat(existingHot.getPinType()).isEqualTo(PinType.NONE); // 기존 게시물 핀 해제 - assertThat(newVote.getPinType()).isEqualTo(PinType.HOT); // 새 게시물 핀 설정 + assertThat(existingTrending.getPinType()).isEqualTo(PinType.NONE); + assertThat(newVote.getPinType()).isEqualTo(PinType.TRENDING); verify(voteRepository, times(2)).save(any(Vote.class)); } @@ -567,7 +652,7 @@ void clearSecurityContext() { void 핀해제() { Member admin = Member.builder().id(1L).role(Role.ADMIN).build(); Vote vote = Vote.builder().id(10L).build(); - vote.pin(PinType.HOT); + vote.pin(PinType.TRENDING); when(voteRepository.findById(10L)).thenReturn(Optional.of(vote));