From 0fd0b41f1c660b0244c3de093fc6adae65f32a00 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Tue, 30 Sep 2025 09:29:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat[poll]:=ED=86=B5=EA=B3=84=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poll/controller/PollController.java | 8 +- .../domain/poll/dto/PollAgeStaticsDto.java | 22 +++ .../domain/poll/dto/PollForPostDto.java | 21 +++ .../domain/poll/dto/PollGenderStaticsDto.java | 22 +++ .../poll/dto/PollStaticsResponseDto.java | 15 ++ .../domain/poll/dto/PollWithPostDto.java | 14 -- .../repository/PollStaticsRepository.java | 1 + .../poll/repository/PollVoteRepository.java | 25 ++++ .../domain/poll/service/PollService.java | 6 +- .../domain/poll/service/PollServiceImpl.java | 139 ++++++++++++++---- .../post/controller/PostController.java | 22 ++- .../post/dto/PostWithPollCreateDto.java | 13 ++ .../domain/post/service/PostService.java | 3 + .../domain/post/service/PostServiceImpl.java | 47 ++++++ 14 files changed, 308 insertions(+), 50 deletions(-) create mode 100644 backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java delete mode 100644 backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index f2bb02b4..4f7c6075 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java @@ -2,10 +2,10 @@ import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import com.ai.lawyer.domain.poll.dto.PollVoteDto; import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollOptions; -import com.ai.lawyer.domain.poll.entity.PollStatics; import com.ai.lawyer.domain.poll.service.PollService; import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.service.PostService; @@ -54,10 +54,10 @@ public ResponseEntity> vote(@PathVariable Long pollId, return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } - @Operation(summary = "투표 통계 조회") + @Operation(summary = "투표 통계 조회 (항목별 나이/성별 카운트)") @GetMapping("/{pollId}/statics") - public ResponseEntity>> getPollStatics(@PathVariable Long pollId) { - List statics = pollService.getPollStatics(pollId); + public ResponseEntity> getPollStatics(@PathVariable Long pollId) { + PollStaticsResponseDto statics = pollService.getPollStatics(pollId); return ResponseEntity.ok(new ApiResponse<>(200, "투표 통계 조회 성공", statics)); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java new file mode 100644 index 00000000..a79fd02e --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollAgeStaticsDto.java @@ -0,0 +1,22 @@ +package com.ai.lawyer.domain.poll.dto; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollAgeStaticsDto { + private Long pollItemsId; + private Integer pollOptionIndex; + private java.util.List ageGroupCounts; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class AgeGroupCountDto { + private String option; + private String ageGroup; + private Long voteCount; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java new file mode 100644 index 00000000..4a902e33 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollForPostDto.java @@ -0,0 +1,21 @@ +package com.ai.lawyer.domain.poll.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.util.List; +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PollForPostDto { + @Schema(description = "투표 제목", example = "당신의 선택은?") + private String voteTitle; + @Schema(description = "투표 항목(2개 필수)", example = "[{\"content\": \"항목1 내용\"}, {\"content\": \"항목2 내용\"}]") + private List pollOptions; + private LocalDateTime reservedCloseAt; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java new file mode 100644 index 00000000..c2bc4297 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollGenderStaticsDto.java @@ -0,0 +1,22 @@ +package com.ai.lawyer.domain.poll.dto; +import lombok.*; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollGenderStaticsDto { + private Long pollItemsId; + private Integer pollOptionIndex; + private java.util.List genderCounts; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class GenderCountDto { + private String option; + private String gender; + private Long voteCount; + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java new file mode 100644 index 00000000..a932eed3 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java @@ -0,0 +1,15 @@ +package com.ai.lawyer.domain.poll.dto; + +import lombok.*; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class PollStaticsResponseDto { + private Long postId; + private Long pollId; + private List optionAgeStatics; + private List optionGenderStatics; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java deleted file mode 100644 index 0f6f31c5..00000000 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.ai.lawyer.domain.poll.dto; - -import com.ai.lawyer.domain.post.dto.PostDto; -import lombok.*; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class PollWithPostDto { - private PollDto poll; - private PostDto post; -} - diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java index 35c0ed57..50ee6d37 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java @@ -8,3 +8,4 @@ public interface PollStaticsRepository extends JpaRepository { List findByPoll_PollId(Long pollId); } + 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 8f976d8c..712f68c1 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 @@ -25,4 +25,29 @@ public interface PollVoteRepository extends JpaRepository { java.util.List countStaticsByPollOptionIds(@Param("pollOptionIds") java.util.List pollOptionIds); boolean existsByPoll_PollIdAndMember_MemberId(Long pollId, Long memberId); + + @Query(value = "SELECT po.option, m.gender, COUNT(*) FROM poll_vote pv JOIN poll_options po ON pv.poll_items_id = po.poll_items_id JOIN member m ON pv.member_id = m.member_id WHERE po.poll_id = :pollId GROUP BY po.option, m.gender", nativeQuery = true) + List getGenderOptionStatics(@Param("pollId") Long pollId); + + @Query(value = "SELECT 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 AS ageGroup, m.gender, COUNT(*) FROM poll_vote pv JOIN member m ON pv.member_id = m.member_id JOIN poll_options po ON pv.poll_items_id = po.poll_items_id WHERE po.poll_id = :pollId GROUP BY ageGroup, m.gender", nativeQuery = true) + List getAgeGenderStatics(@Param("pollId") Long pollId); + + @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); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 92c7592f..65cba9c3 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -7,6 +7,8 @@ import com.ai.lawyer.domain.poll.entity.PollVote; import com.ai.lawyer.domain.poll.entity.PollStatics; import com.ai.lawyer.domain.poll.entity.PollOptions; +import com.ai.lawyer.domain.poll.dto.PollForPostDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import java.util.List; @@ -14,7 +16,7 @@ public interface PollService { PollDto getPoll(Long pollId); List getPollOptions(Long pollId); PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); - List getPollStatics(Long pollId); + PollStaticsResponseDto getPollStatics(Long pollId); void closePoll(Long pollId); void deletePoll(Long pollId); PollDto getTopPollByStatus(PollDto.PollStatus status); @@ -26,4 +28,6 @@ public interface PollService { void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); List getPollsByStatus(PollDto.PollStatus status); List getTopNPollsByStatus(PollDto.PollStatus status, int n); + void validatePollCreate(PollCreateDto dto); + void validatePollCreate(PollForPostDto dto); } \ No newline at end of file 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 b21002c9..feb876b0 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 @@ -2,7 +2,6 @@ import com.ai.lawyer.domain.poll.entity.*; import com.ai.lawyer.domain.poll.repository.*; -import com.ai.lawyer.domain.poll.service.*; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; @@ -17,6 +16,7 @@ import java.util.List; import java.util.ArrayList; import com.ai.lawyer.domain.poll.dto.PollCreateDto; +import com.ai.lawyer.domain.poll.dto.PollForPostDto; import com.ai.lawyer.domain.poll.dto.PollOptionCreateDto; import com.ai.lawyer.domain.poll.dto.PollStaticsDto; import com.ai.lawyer.domain.poll.dto.PollOptionDto; @@ -27,6 +27,10 @@ import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; +import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; +import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto; + @Service @Transactional @RequiredArgsConstructor @@ -42,44 +46,24 @@ public class PollServiceImpl implements PollService { @Override public PollDto createPoll(PollCreateDto request, Long memberId) { if (request.getPostId() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID(postId)는 필수입니다."); - } - if (request.getVoteTitle() == null || request.getVoteTitle().trim().isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 제목(voteTitle)은 필수입니다."); - } - if (request.getPollOptions() == null || request.getPollOptions().size() != 2) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다."); - } - for (PollOptionCreateDto option : request.getPollOptions()) { - if (option.getContent() == null || option.getContent().trim().isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "각 투표 항목의 내용(content)은 필수입니다."); - } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID는 필수입니다."); } + validatePollCommon(request.getVoteTitle(), request.getPollOptions(), request.getReservedCloseAt()); Member member = memberRepository.findById(memberId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); Post post = postRepository.findById(request.getPostId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - // 이미 해당 게시글에 투표가 존재하는 경우 예외 처리 if (post.getPoll() != null) { throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 해당 게시글에 투표가 존재합니다."); } try { LocalDateTime now = java.time.LocalDateTime.now(); - LocalDateTime reservedCloseAt = request.getReservedCloseAt(); - if (reservedCloseAt != null) { - if (reservedCloseAt.isBefore(now.plusHours(1))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다."); - } - if (reservedCloseAt.isAfter(now.plusDays(7))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); - } - } Poll poll = Poll.builder() .post(post) .voteTitle(request.getVoteTitle()) .status(Poll.PollStatus.ONGOING) .createdAt(now) - .reservedCloseAt(reservedCloseAt) + .reservedCloseAt(request.getReservedCloseAt()) .build(); Poll savedPoll = pollRepository.save(poll); post.setPoll(savedPoll); @@ -119,7 +103,9 @@ public List getPollsByStatus(PollDto.PollStatus status) { } List pollDtos = polls.stream() .filter(p -> p.getStatus().name().equals(status.name())) - .map(this::convertToDto) + .map(p -> status == PollDto.PollStatus.CLOSED + ? getPollWithStatistics(p.getPollId()) + : convertToDto(p)) .toList(); return pollDtos; } @@ -163,12 +149,72 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { } @Override - public List getPollStatics(Long pollId) { - // 투표 존재 여부 체크 + public PollStaticsResponseDto getPollStatics(Long pollId) { if (!pollRepository.existsById(pollId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 투표가 존재하지 않습니다."); } - return pollStaticsRepository.findByPoll_PollId(pollId); + Poll poll = pollRepository.findById(pollId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); + Long postId = poll.getPost() != null ? poll.getPost().getPostId() : null; + List options = pollOptionsRepository.findByPoll_PollId(pollId); + java.util.Map optionMap = new java.util.HashMap<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionMap.put(opt.getOption(), opt); + } + // age 통계 그룹핑 + List optionAgeRaw = pollVoteRepository.getOptionAgeStatics(pollId); + java.util.Map> ageGroupMap = new java.util.HashMap<>(); + for (Object[] arr : optionAgeRaw) { + String option = arr[0] != null ? arr[0].toString() : null; + PollOptions opt = optionMap.get(option); + if (opt == null) continue; + Long pollItemsId = opt.getPollItemsId(); + PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder() + .option(option) + .ageGroup(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); + ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); + } + java.util.List optionAgeStatics = new java.util.ArrayList<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionAgeStatics.add(PollAgeStaticsDto.builder() + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); + } + // gender 통계 그룹핑 + List optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId); + java.util.Map> genderGroupMap = new java.util.HashMap<>(); + for (Object[] arr : optionGenderRaw) { + String option = arr[0] != null ? arr[0].toString() : null; + PollOptions opt = optionMap.get(option); + if (opt == null) continue; + Long pollItemsId = opt.getPollItemsId(); + PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder() + .option(option) + .gender(arr[1] != null ? arr[1].toString() : null) + .voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L) + .build(); + genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto); + } + java.util.List optionGenderStatics = new java.util.ArrayList<>(); + for (int i = 0; i < options.size(); i++) { + PollOptions opt = options.get(i); + optionGenderStatics.add(PollGenderStaticsDto.builder() + .pollItemsId(opt.getPollItemsId()) + .pollOptionIndex(i + 1) + .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) + .build()); + } + return PollStaticsResponseDto.builder() + .postId(postId) + .pollId(pollId) + .optionAgeStatics(optionAgeStatics) + .optionGenderStatics(optionGenderStatics) + .build(); } // 최대 7일 동안 투표 가능 (초기 요구사항) @@ -228,7 +274,9 @@ public List getTopNPollsByStatus(PollDto.PollStatus status, int n) { List pollDtos = new java.util.ArrayList<>(); for (Object[] row : result) { Long pollId = (Long) row[0]; - pollDtos.add(getPoll(pollId)); + pollDtos.add(status == PollDto.PollStatus.CLOSED + ? getPollWithStatistics(pollId) + : getPoll(pollId)); } return pollDtos; } @@ -487,4 +535,37 @@ private void autoCloseIfNeeded(Poll poll) { public List getPollOptions(Long pollId) { return pollOptionsRepository.findByPoll_PollId(pollId); } + + private static void validatePollCommon(String voteTitle, java.util.List options, java.time.LocalDateTime reservedCloseAt) { + if (voteTitle == null || voteTitle.trim().isEmpty()) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 제목은 필수입니다."); + } + if (options == null || options.size() != 2) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다."); + } + for (com.ai.lawyer.domain.poll.dto.PollOptionCreateDto option : options) { + if (option.getContent() == null || option.getContent().trim().isEmpty()) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "각 투표 항목의 내용은 필수입니다."); + } + } + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + if (reservedCloseAt != null) { + if (reservedCloseAt.isBefore(now.plusHours(1))) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다."); + } + if (reservedCloseAt.isAfter(now.plusDays(7))) { + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다."); + } + } + } + + @Override + public void validatePollCreate(PollForPostDto dto) { + validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); + } + + @Override + public void validatePollCreate(PollCreateDto dto) { + validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); + } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index 738d0275..7b4c0803 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -4,6 +4,7 @@ import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.service.PostService; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; @@ -126,7 +127,7 @@ public ResponseEntity> getMyPostById(@PathVariable Long pos } else if (principal instanceof Long) { memberId = (Long) principal; } else { - throw new IllegalArgumentException("principal이 올바른 회원 ID가 아닙니다"); + throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); } PostDto postDto = postService.getMyPostById(postId, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 단일 조회 성공", postDto)); @@ -143,9 +144,26 @@ public ResponseEntity>> getMyPosts() { } else if (principal instanceof Long) { memberId = (Long) principal; } else { - throw new IllegalArgumentException("principal이 올바른 회원 ID가 아닙니다"); + throw new IllegalArgumentException("올바른 회원 ID가 아닙니다"); } List posts = postService.getMyPosts(memberId); return ResponseEntity.ok(new ApiResponse<>(200, "본인 게시글 전체 조회 성공", posts)); } + + @Operation(summary = "게시글+투표 동시 등록") + @PostMapping("/with-poll") + public ResponseEntity> createPostWithPoll(@RequestBody PostWithPollCreateDto dto) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object principal = authentication.getPrincipal(); + Long memberId; + if (principal instanceof org.springframework.security.core.userdetails.User user) { + memberId = Long.valueOf(user.getUsername()); + } else if (principal instanceof Long) { + memberId = (Long) principal; + } else { + throw new ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED, "인증 정보가 올바르지 않습니다."); + } + PostDetailDto result = postService.createPostWithPoll(dto, memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글+투표 등록 완료", result)); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java new file mode 100644 index 00000000..569b3658 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostWithPollCreateDto.java @@ -0,0 +1,13 @@ +package com.ai.lawyer.domain.post.dto; + +import com.ai.lawyer.domain.poll.dto.PollForPostDto; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PostWithPollCreateDto { + private PostRequestDto post; + private PollForPostDto poll; +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index a936ce18..e7814248 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -5,6 +5,7 @@ import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import java.util.List; @@ -29,4 +30,6 @@ public interface PostService { List getMyPosts(Long requesterMemberId); void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); + + PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 5bafa6e4..ad144fac 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -6,10 +6,12 @@ import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; +import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import com.ai.lawyer.domain.poll.repository.PollRepository; import com.ai.lawyer.domain.poll.entity.Poll; +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.repository.PollOptionsRepository; @@ -18,6 +20,7 @@ import com.ai.lawyer.domain.poll.service.PollService; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; @@ -45,6 +48,10 @@ public PostServiceImpl(PostRepository postRepository, MemberRepository memberRep @Override public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { + if (postRequestDto.getPostName() == null || postRequestDto.getPostName().trim().isEmpty() || + postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); + } Member member = memberRepository.findById(memberId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); Post post = Post.builder() @@ -178,6 +185,46 @@ public void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto) { postRepository.save(post); } + @Override + @Transactional + public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId) { + PostRequestDto postDto = dto.getPost(); + if (postDto == null || postDto.getPostName() == null || postDto.getPostName().trim().isEmpty() || + postDto.getPostContent() == null || postDto.getPostContent().trim().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); + } + var pollDto = dto.getPoll(); + pollService.validatePollCreate(pollDto); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다.")); + Post post = Post.builder() + .member(member) + .postName(postDto.getPostName()) + .postContent(postDto.getPostContent()) + .category(postDto.getCategory()) + .createdAt(LocalDateTime.now()) + .build(); + Post savedPost = postRepository.save(post); + Poll poll = Poll.builder() + .voteTitle(pollDto.getVoteTitle()) + .reservedCloseAt(pollDto.getReservedCloseAt()) + .createdAt(LocalDateTime.now()) + .status(Poll.PollStatus.ONGOING) + .post(savedPost) + .build(); + Poll savedPoll = pollRepository.save(poll); + for (var optionDto : pollDto.getPollOptions()) { + PollOptions option = PollOptions.builder() + .poll(savedPoll) + .option(optionDto.getContent()) + .build(); + pollOptionsRepository.save(option); + } + savedPost.setPoll(savedPoll); + postRepository.save(savedPost); + return getPostDetailById(savedPost.getPostId()); + } + private PostDto convertToDto(Post entity) { Long memberId = null; if (entity.getMember() != null) { From f89b23a717d34d2544bc68b92d20a4870741e334 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Tue, 30 Sep 2025 11:44:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat[poll]:=ED=86=B5=EA=B3=84=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/poll/service/PollService.java | 25 +++++++++++++------ .../post/controller/PostController.java | 8 ++++++ .../lawyer/domain/post/dto/PostSimpleDto.java | 20 +++++++++++++++ .../domain/post/service/PostService.java | 22 +++++++--------- .../domain/post/service/PostServiceImpl.java | 22 ++++++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 65cba9c3..95b230c4 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -13,21 +13,30 @@ import java.util.List; public interface PollService { + // ===== 조회 관련 ===== PollDto getPoll(Long pollId); + PollDto getPollWithStatistics(Long pollId); List getPollOptions(Long pollId); - PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); - PollStaticsResponseDto getPollStatics(Long pollId); - void closePoll(Long pollId); - void deletePoll(Long pollId); + List getPollsByStatus(PollDto.PollStatus status); PollDto getTopPollByStatus(PollDto.PollStatus status); + List getTopNPollsByStatus(PollDto.PollStatus status, int n); + + // ===== 통계 관련 ===== + PollStaticsResponseDto getPollStatics(Long pollId); Long getVoteCountByPollId(Long pollId); Long getVoteCountByPostId(Long postId); - PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto); - PollDto getPollWithStatistics(Long pollId); + + // ===== 투표 관련 ===== + PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + + // ===== 생성/수정/삭제 관련 ===== PollDto createPoll(PollCreateDto request, Long memberId); + PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto); void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); - List getPollsByStatus(PollDto.PollStatus status); - List getTopNPollsByStatus(PollDto.PollStatus status, int n); + void closePoll(Long pollId); + void deletePoll(Long pollId); + + // ===== 검증 관련 ===== void validatePollCreate(PollCreateDto dto); void validatePollCreate(PollForPostDto dto); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index 7b4c0803..fc8a74e6 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -6,6 +6,7 @@ import com.ai.lawyer.domain.post.dto.PostUpdateDto; import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; import com.ai.lawyer.domain.post.service.PostService; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.global.jwt.TokenProvider; @@ -70,6 +71,13 @@ public ResponseEntity>> getAllPosts() { return ResponseEntity.ok(new ApiResponse<>(200, "게시글 전체 조회 성공", posts)); } + @Operation(summary = "게시글 간편 전체 조회") + @GetMapping("/simple") + public ResponseEntity>> getAllSimplePosts() { + List posts = postService.getAllSimplePosts(); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글 간편 전체 조회 성공", posts)); + } + @Operation(summary = "게시글 단일 조회") @GetMapping("/{postId}") public ResponseEntity> getPostById(@PathVariable Long postId) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java new file mode 100644 index 00000000..966c2869 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/dto/PostSimpleDto.java @@ -0,0 +1,20 @@ +package com.ai.lawyer.domain.post.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PostSimpleDto { + private Long postId; + private Long memberId; + private PollInfo poll; + + @Data + @Builder + public static class PollInfo { + private Long pollId; + private String pollStatus; + } +} + diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index e7814248..67d88f05 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -6,30 +6,26 @@ import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import java.util.List; public interface PostService { - - PostDto createPost(PostRequestDto postRequestDto, Long memberId); - + // ===== 조회 관련 ===== PostDetailDto getPostById(Long postId); - PostDetailDto getPostDetailById(Long postId); - + List getAllPosts(); + List getAllSimplePosts(); List getPostsByMemberId(Long memberId); + // ===== 생성/수정/삭제 관련 ===== + PostDto createPost(PostRequestDto postRequestDto, Long memberId); PostDto updatePost(Long postId, PostUpdateDto postUpdateDto); - + void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); void deletePost(Long postId); + PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId); - List getAllPosts(); - + // ===== 본인 게시글 관련 ===== PostDto getMyPostById(Long postId, Long requesterMemberId); - List getMyPosts(Long requesterMemberId); - - void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); - - PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index ad144fac..a2fe1022 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -7,6 +7,7 @@ import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.dto.PostUpdateDto; import com.ai.lawyer.domain.post.dto.PostWithPollCreateDto; +import com.ai.lawyer.domain.post.dto.PostSimpleDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import com.ai.lawyer.domain.poll.repository.PollRepository; @@ -225,6 +226,27 @@ public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId return getPostDetailById(savedPost.getPostId()); } + @Override + public List getAllSimplePosts() { + List posts = postRepository.findAll(); + return posts.stream() + .map(post -> { + PostSimpleDto.PollInfo pollInfo = null; + if (post.getPoll() != null) { + pollInfo = PostSimpleDto.PollInfo.builder() + .pollId(post.getPoll().getPollId()) + .pollStatus(post.getPoll().getStatus().name()) + .build(); + } + return PostSimpleDto.builder() + .postId(post.getPostId()) + .memberId(post.getMember().getMemberId()) + .poll(pollInfo) + .build(); + }) + .collect(Collectors.toList()); + } + private PostDto convertToDto(Post entity) { Long memberId = null; if (entity.getMember() != null) { From 30110d84bc59d8c9384c4da6f9ee8d1fc12defea Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Tue, 30 Sep 2025 12:12:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix[poll]:poll=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/domain/poll/controller/PollControllerTest.java | 3 ++- .../com/ai/lawyer/domain/poll/service/PollServiceTest.java | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index 3cb05b83..3d6510b4 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.Cookie; import static org.mockito.BDDMockito.*; import com.ai.lawyer.global.jwt.TokenProvider; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; @Import(SecurityConfig.class) @AutoConfigureMockMvc @@ -92,7 +93,7 @@ void t3() throws Exception { @Test @DisplayName("투표 통계 조회") void t4() throws Exception { - Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(java.util.Collections.emptyList()); + Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(new PollStaticsResponseDto()); mockMvc.perform(get("/api/polls/1/statics") .cookie(new Cookie("accessToken", "valid-access-token"))) diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java index 6ffae02e..2a3cdebb 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollServiceTest.java @@ -4,6 +4,7 @@ import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollVoteDto; import com.ai.lawyer.domain.poll.dto.PollUpdateDto; +import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -54,9 +55,9 @@ void t3() { @Test @DisplayName("투표 통계 조회") void t4() { - java.util.List expected = java.util.Collections.emptyList(); + PollStaticsResponseDto expected = new PollStaticsResponseDto(); Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(expected); - java.util.List result = pollService.getPollStatics(1L); + PollStaticsResponseDto result = pollService.getPollStatics(1L); assertThat(result).isEqualTo(expected); }