diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java index 8017fed8..f5772248 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepository.java @@ -3,15 +3,11 @@ import com.ai.lawyer.domain.poll.entity.Poll; import com.ai.lawyer.domain.post.entity.Post; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; -public interface PollRepository extends JpaRepository { +public interface PollRepository extends JpaRepository, PollRepositoryCustom { Optional findByPost(Post post); - - @Query("SELECT p FROM Poll p LEFT JOIN PollVote v ON p = v.poll GROUP BY p ORDER BY COUNT(v) DESC") - List findTopPollsByVoteCount(Pageable pageable); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryCustom.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryCustom.java new file mode 100644 index 00000000..19e393c6 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.ai.lawyer.domain.poll.repository; + +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.post.entity.Post; +import org.springframework.data.domain.Pageable; +import java.util.List; +import java.util.Optional; + +public interface PollRepositoryCustom { + Optional findByPost(Post post); + List findTopPollsByVoteCount(Pageable pageable); +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryImpl.java new file mode 100644 index 00000000..4e336ec3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.ai.lawyer.domain.poll.repository; + +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.poll.entity.QPoll; +import com.ai.lawyer.domain.poll.entity.QPollVote; +import com.ai.lawyer.domain.post.entity.Post; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class PollRepositoryImpl implements PollRepositoryCustom { + private final JPAQueryFactory queryFactory; + private final QPoll poll = QPoll.poll; + private final QPollVote pollVote = QPollVote.pollVote; + + @Override + public Optional findByPost(Post post) { + return Optional.ofNullable( + queryFactory.selectFrom(poll) + .where(poll.getPost().eq(post)) + .fetchOne() + ); + } + + @Override + public List findTopPollsByVoteCount(Pageable pageable) { + return queryFactory.selectFrom(poll) + .leftJoin(pollVote).on(poll.getPollId().eq(pollVote.getPoll().getPollId())) + .groupBy(poll.getPollId()) + .orderBy(pollVote.count().desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index 1fb4afc9..8cd5b537 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -1,42 +1,8 @@ package com.ai.lawyer.domain.poll.repository; import com.ai.lawyer.domain.poll.entity.PollVote; -import com.ai.lawyer.domain.poll.entity.Poll; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.domain.Pageable; -import org.springframework.data.repository.query.Param; -import java.util.List; -public interface PollVoteRepository extends JpaRepository { - @Query("SELECT v.poll.pollId, COUNT(v.pollVoteId) FROM PollVote v WHERE v.poll.status = :status GROUP BY v.poll.pollId ORDER BY COUNT(v.pollVoteId) DESC") - List findTopPollByStatus(@Param("status") Poll.PollStatus status); +public interface PollVoteRepository extends JpaRepository, PollVoteRepositoryCustom { +} - @Query("SELECT p.pollId, COUNT(v) as voteCount FROM PollVote v JOIN v.poll p WHERE p.status = :status GROUP BY p.pollId ORDER BY voteCount DESC") - List findTopNPollByStatus(@Param("status") Poll.PollStatus status, Pageable pageable); - - @Query("SELECT COUNT(v.pollVoteId) FROM PollVote v WHERE v.poll.pollId = :pollId") - Long countByPollId(@Param("pollId") Long pollId); - - @Query("SELECT COUNT(v.pollVoteId) FROM PollVote v WHERE v.pollOptions.pollItemsId = :pollOptionId") - Long countByPollOptionId(@Param("pollOptionId") Long pollOptionId); - - @Query("SELECT v.pollOptions.pollItemsId, v.member.gender, v.member.age, COUNT(v.pollVoteId) FROM PollVote v WHERE v.pollOptions.pollItemsId IN :pollOptionIds GROUP BY v.pollOptions.pollItemsId, v.member.gender, v.member.age") - java.util.List countStaticsByPollOptionIds(@Param("pollOptionIds") java.util.List pollOptionIds); - - @Query("SELECT o.option, " + - "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + - "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + - "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END, " + - "COUNT(v) " + - "FROM PollVote v JOIN v.pollOptions o JOIN v.member m " + - "WHERE o.poll.pollId = :pollId " + - "GROUP BY o.option, " + - "CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " + - "WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " + - "WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END") - List getOptionAgeStatics(@Param("pollId") Long pollId); - - @Query("SELECT o.option, m.gender, COUNT(v) FROM PollVote v JOIN v.pollOptions o JOIN v.member m WHERE o.poll.pollId = :pollId GROUP BY o.option, m.gender") - List getOptionGenderStatics(@Param("pollId") Long pollId); -} \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java new file mode 100644 index 00000000..80834e8a --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryCustom.java @@ -0,0 +1,17 @@ +package com.ai.lawyer.domain.poll.repository; + +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.poll.entity.PollVote; +import org.springframework.data.domain.Pageable; +import java.util.List; + +public interface PollVoteRepositoryCustom { + List findTopPollByStatus(Poll.PollStatus status); + List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable); + Long countByPollId(Long pollId); + Long countByPollOptionId(Long pollOptionId); + List countStaticsByPollOptionIds(List pollOptionIds); + List getOptionAgeStatics(Long pollId); + List getOptionGenderStatics(Long pollId); +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java new file mode 100644 index 00000000..fc6d22a3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java @@ -0,0 +1,127 @@ +package com.ai.lawyer.domain.poll.repository; + +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.poll.entity.QPoll; +import com.ai.lawyer.domain.poll.entity.QPollOptions; +import com.ai.lawyer.domain.poll.entity.QPollVote; +import com.ai.lawyer.domain.member.entity.QMember; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import com.querydsl.core.Tuple; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PollVoteRepositoryImpl implements PollVoteRepositoryCustom { + private final JPAQueryFactory queryFactory; + private final QPollVote pollVote = QPollVote.pollVote; + private final QPoll poll = QPoll.poll; + private final QPollOptions pollOptions = QPollOptions.pollOptions; + private final QMember member = QMember.member; + + @Override + public List findTopPollByStatus(Poll.PollStatus status) { + List tuples = queryFactory.select(poll.getPollId(), pollVote.count()) + .from(pollVote) + .join(pollVote.getPoll(), poll) + .where(poll.getStatus().eq(status)) + .groupBy(poll.getPollId()) + .orderBy(pollVote.count().desc()) + .fetch(); + return tuples.stream().map(Tuple::toArray).toList(); + } + + @Override + public List findTopNPollByStatus(Poll.PollStatus status, Pageable pageable) { + List tuples = queryFactory.select(poll.getPollId(), pollVote.count()) + .from(pollVote) + .join(pollVote.getPoll(), poll) + .where(poll.getStatus().eq(status)) + .groupBy(poll.getPollId()) + .orderBy(pollVote.count().desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + return tuples.stream().map(Tuple::toArray).toList(); + } + + @Override + public Long countByPollId(Long pollId) { + return queryFactory.select(pollVote.count()) + .from(pollVote) + .join(pollVote.getPoll(), poll) + .where(poll.getPollId().eq(pollId)) + .fetchOne(); + } + + @Override + public Long countByPollOptionId(Long pollOptionId) { + return queryFactory.select(pollVote.count()) + .from(pollVote) + .join(pollVote.getPollOptions(), pollOptions) + .where(pollOptions.getPollItemsId().eq(pollOptionId)) + .fetchOne(); + } + + @Override + public List countStaticsByPollOptionIds(List pollOptionIds) { + List tuples = queryFactory.select(pollOptions.getPollItemsId(), member.getGender(), member.getAge(), pollVote.count()) + .from(pollVote) + .join(pollVote.getPollOptions(), pollOptions) + .join(pollVote.getMember(), member) + .where(pollOptions.getPollItemsId().in(pollOptionIds)) + .groupBy(pollOptions.getPollItemsId(), member.getGender(), member.getAge()) + .fetch(); + return tuples.stream().map(Tuple::toArray).toList(); + } + + @Override + public List getOptionAgeStatics(Long pollId) { + List tuples = queryFactory.select( + pollOptions.getOption(), + new com.querydsl.core.types.dsl.CaseBuilder() + .when(member.getAge().lt(20)).then("10대") + .when(member.getAge().lt(30)).then("20대") + .when(member.getAge().lt(40)).then("30대") + .when(member.getAge().lt(50)).then("40대") + .when(member.getAge().lt(60)).then("50대") + .when(member.getAge().lt(70)).then("60대") + .when(member.getAge().lt(80)).then("70대") + .otherwise("80대 이상"), + pollVote.count()) + .from(pollVote) + .join(pollVote.getPollOptions(), pollOptions) + .join(pollVote.getMember(), member) + .where(pollOptions.getPoll().getPollId().eq(pollId)) + .groupBy(pollOptions.getOption(), + new com.querydsl.core.types.dsl.CaseBuilder() + .when(member.getAge().lt(20)).then("10대") + .when(member.getAge().lt(30)).then("20대") + .when(member.getAge().lt(40)).then("30대") + .when(member.getAge().lt(50)).then("40대") + .when(member.getAge().lt(60)).then("50대") + .when(member.getAge().lt(70)).then("60대") + .when(member.getAge().lt(80)).then("70대") + .otherwise("80대 이상")) + .fetch(); + return tuples.stream().map(Tuple::toArray).toList(); + } + + @Override + public List getOptionGenderStatics(Long pollId) { + List tuples = queryFactory.select( + pollOptions.getOption(), + member.getGender(), + pollVote.count()) + .from(pollVote) + .join(pollVote.getPollOptions(), pollOptions) + .join(pollVote.getMember(), member) + .where(pollOptions.getPoll().getPollId().eq(pollId)) + .groupBy(pollOptions.getOption(), member.getGender()) + .fetch(); + return tuples.stream().map(Tuple::toArray).toList(); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 926ae075..8ffb40ca 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -88,7 +88,7 @@ public PollDto createPoll(PollCreateDto request, Long memberId) { public PollDto getPoll(Long pollId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - autoCloseIfNeeded(poll); + autoClose(poll); if (poll.getStatus() == Poll.PollStatus.CLOSED) { return getPollWithStatistics(pollId); } @@ -99,7 +99,7 @@ public PollDto getPoll(Long pollId) { public List getPollsByStatus(PollDto.PollStatus status) { List polls = pollRepository.findAll(); for (Poll poll : polls) { - autoCloseIfNeeded(poll); + autoClose(poll); } List pollDtos = polls.stream() .filter(p -> p.getStatus().name().equals(status.name())) @@ -516,7 +516,7 @@ private String getAgeGroup(Integer age) { } // 자동 종료 로직 보강 - private void autoCloseIfNeeded(Poll poll) { + private void autoClose(Poll poll) { LocalDateTime now = java.time.LocalDateTime.now(); if (poll.getStatus() == Poll.PollStatus.ONGOING) { if (poll.getReservedCloseAt() != null && poll.getReservedCloseAt().isBefore(now)) { diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java new file mode 100644 index 00000000..1a92356b --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java @@ -0,0 +1,90 @@ +package com.ai.lawyer.domain.poll.service; + +import com.ai.lawyer.domain.poll.dto.PollCreateDto; +import com.ai.lawyer.domain.poll.dto.PollDto; +import com.ai.lawyer.domain.poll.dto.PollUpdateDto; +import com.ai.lawyer.domain.poll.dto.PollVoteDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.post.repository.PostRepository; +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.poll.repository.PollRepository; +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.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@Transactional +class PollAutoCloseTest { + @Autowired + private PollService pollService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PollRepository pollRepository; + + @Test + @DisplayName("autoClose 예약 종료 자동 처리 기능(정책 우회)") + void autoCloseTest() throws Exception { + // 테스트용 member 생성 + Member member = Member.builder() + .loginId("testuser@sample.com") + .password("pw") + .age(20) + .gender(Member.Gender.MALE) + .role(Member.Role.USER) + .name("테스트유저") + .build(); + member = memberRepository.save(member); + + // 테스트용 post 생성 + Post post = new Post(); + post.setPostName("테스트용 게시글"); + post.setPostContent("테스트 내용"); + post.setCategory("테스트"); + post.setCreatedAt(java.time.LocalDateTime.now()); + post.setMember(member); + post = postRepository.save(post); + + PollCreateDto createDto = new PollCreateDto(); + createDto.setPostId(post.getPostId()); + createDto.setVoteTitle("autoClose 테스트"); + createDto.setReservedCloseAt(java.time.LocalDateTime.now().plusHours(1).plusSeconds(1)); + // 투표 항목 2개 추가 + var option1 = new com.ai.lawyer.domain.poll.dto.PollOptionCreateDto(); + option1.setContent("찬성"); + var option2 = new com.ai.lawyer.domain.poll.dto.PollOptionCreateDto(); + option2.setContent("반대"); + createDto.setPollOptions(java.util.Arrays.asList(option1, option2)); + PollDto created = pollService.createPoll(createDto, member.getMemberId()); + + // 2. 생성 직후 상태는 ONGOING이어야 함 + PollDto ongoing = pollService.getPoll(created.getPollId()); + assertThat(ongoing.getStatus()).isEqualTo(PollDto.PollStatus.ONGOING); + + // 3. reservedCloseAt을 DB에서 과거로 강제 변경 + var poll = pollRepository.findById(created.getPollId()).get(); + var reservedCloseAtField = poll.getClass().getDeclaredField("reservedCloseAt"); + reservedCloseAtField.setAccessible(true); + reservedCloseAtField.set(poll, java.time.LocalDateTime.now().minusSeconds(1)); + pollRepository.save(poll); + + // 4. getPoll 호출 시 자동 종료(CLOSED)로 바뀌는지 확인 + PollDto closed = pollService.getPoll(created.getPollId()); + assertThat(closed.getStatus()).isEqualTo(PollDto.PollStatus.CLOSED); + } +}