Skip to content

Commit 0fd0b41

Browse files
committed
feat[poll]:통계로직추가
1 parent 1e109a6 commit 0fd0b41

File tree

14 files changed

+308
-50
lines changed

14 files changed

+308
-50
lines changed

backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import com.ai.lawyer.domain.poll.dto.PollCreateDto;
44
import com.ai.lawyer.domain.poll.dto.PollDto;
5+
import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto;
56
import com.ai.lawyer.domain.poll.dto.PollVoteDto;
67
import com.ai.lawyer.domain.poll.entity.PollVote;
78
import com.ai.lawyer.domain.poll.entity.PollOptions;
8-
import com.ai.lawyer.domain.poll.entity.PollStatics;
99
import com.ai.lawyer.domain.poll.service.PollService;
1010
import com.ai.lawyer.domain.post.dto.PostDetailDto;
1111
import com.ai.lawyer.domain.post.service.PostService;
@@ -54,10 +54,10 @@ public ResponseEntity<ApiResponse<PollVoteDto>> vote(@PathVariable Long pollId,
5454
return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result));
5555
}
5656

57-
@Operation(summary = "투표 통계 조회")
57+
@Operation(summary = "투표 통계 조회 (항목별 나이/성별 카운트)")
5858
@GetMapping("/{pollId}/statics")
59-
public ResponseEntity<ApiResponse<List<PollStatics>>> getPollStatics(@PathVariable Long pollId) {
60-
List<PollStatics> statics = pollService.getPollStatics(pollId);
59+
public ResponseEntity<ApiResponse<PollStaticsResponseDto>> getPollStatics(@PathVariable Long pollId) {
60+
PollStaticsResponseDto statics = pollService.getPollStatics(pollId);
6161
return ResponseEntity.ok(new ApiResponse<>(200, "투표 통계 조회 성공", statics));
6262
}
6363

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.ai.lawyer.domain.poll.dto;
2+
import lombok.*;
3+
4+
@Data
5+
@AllArgsConstructor
6+
@NoArgsConstructor
7+
@Builder
8+
public class PollAgeStaticsDto {
9+
private Long pollItemsId;
10+
private Integer pollOptionIndex;
11+
private java.util.List<AgeGroupCountDto> ageGroupCounts;
12+
13+
@Data
14+
@AllArgsConstructor
15+
@NoArgsConstructor
16+
@Builder
17+
public static class AgeGroupCountDto {
18+
private String option;
19+
private String ageGroup;
20+
private Long voteCount;
21+
}
22+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.ai.lawyer.domain.poll.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Data;
7+
import lombok.NoArgsConstructor;
8+
import java.util.List;
9+
import java.time.LocalDateTime;
10+
11+
@Data
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Builder
15+
public class PollForPostDto {
16+
@Schema(description = "투표 제목", example = "당신의 선택은?")
17+
private String voteTitle;
18+
@Schema(description = "투표 항목(2개 필수)", example = "[{\"content\": \"항목1 내용\"}, {\"content\": \"항목2 내용\"}]")
19+
private List<PollOptionCreateDto> pollOptions;
20+
private LocalDateTime reservedCloseAt;
21+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.ai.lawyer.domain.poll.dto;
2+
import lombok.*;
3+
4+
@Data
5+
@AllArgsConstructor
6+
@NoArgsConstructor
7+
@Builder
8+
public class PollGenderStaticsDto {
9+
private Long pollItemsId;
10+
private Integer pollOptionIndex;
11+
private java.util.List<GenderCountDto> genderCounts;
12+
13+
@Data
14+
@AllArgsConstructor
15+
@NoArgsConstructor
16+
@Builder
17+
public static class GenderCountDto {
18+
private String option;
19+
private String gender;
20+
private Long voteCount;
21+
}
22+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.ai.lawyer.domain.poll.dto;
2+
3+
import lombok.*;
4+
import java.util.List;
5+
6+
@Data
7+
@AllArgsConstructor
8+
@NoArgsConstructor
9+
@Builder
10+
public class PollStaticsResponseDto {
11+
private Long postId;
12+
private Long pollId;
13+
private List<PollAgeStaticsDto> optionAgeStatics;
14+
private List<PollGenderStaticsDto> optionGenderStatics;
15+
}

backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollWithPostDto.java

Lines changed: 0 additions & 14 deletions
This file was deleted.

backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollStaticsRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
public interface PollStaticsRepository extends JpaRepository<PollStatics, Long> {
99
List<PollStatics> findByPoll_PollId(Long pollId);
1010
}
11+

backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,29 @@ public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
2525
java.util.List<Object[]> countStaticsByPollOptionIds(@Param("pollOptionIds") java.util.List<Long> pollOptionIds);
2626

2727
boolean existsByPoll_PollIdAndMember_MemberId(Long pollId, Long memberId);
28+
29+
@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)
30+
List<Object[]> getGenderOptionStatics(@Param("pollId") Long pollId);
31+
32+
@Query(value = "SELECT CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " +
33+
"WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " +
34+
"WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' " +
35+
"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)
36+
List<Object[]> getAgeGenderStatics(@Param("pollId") Long pollId);
37+
38+
@Query("SELECT o.option, " +
39+
"CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " +
40+
"WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " +
41+
"WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END, " +
42+
"COUNT(v) " +
43+
"FROM PollVote v JOIN v.pollOptions o JOIN v.member m " +
44+
"WHERE o.poll.pollId = :pollId " +
45+
"GROUP BY o.option, " +
46+
"CASE WHEN m.age < 20 THEN '10대' WHEN m.age < 30 THEN '20대' " +
47+
"WHEN m.age < 40 THEN '30대' WHEN m.age < 50 THEN '40대' WHEN m.age < 60 THEN '50대' " +
48+
"WHEN m.age < 70 THEN '60대' WHEN m.age < 80 THEN '70대' ELSE '80대 이상' END")
49+
List<Object[]> getOptionAgeStatics(@Param("pollId") Long pollId);
50+
51+
@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")
52+
List<Object[]> getOptionGenderStatics(@Param("pollId") Long pollId);
2853
}

backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
import com.ai.lawyer.domain.poll.entity.PollVote;
88
import com.ai.lawyer.domain.poll.entity.PollStatics;
99
import com.ai.lawyer.domain.poll.entity.PollOptions;
10+
import com.ai.lawyer.domain.poll.dto.PollForPostDto;
11+
import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto;
1012

1113
import java.util.List;
1214

1315
public interface PollService {
1416
PollDto getPoll(Long pollId);
1517
List<PollOptions> getPollOptions(Long pollId);
1618
PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId);
17-
List<PollStatics> getPollStatics(Long pollId);
19+
PollStaticsResponseDto getPollStatics(Long pollId);
1820
void closePoll(Long pollId);
1921
void deletePoll(Long pollId);
2022
PollDto getTopPollByStatus(PollDto.PollStatus status);
@@ -26,4 +28,6 @@ public interface PollService {
2628
void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto);
2729
List<PollDto> getPollsByStatus(PollDto.PollStatus status);
2830
List<PollDto> getTopNPollsByStatus(PollDto.PollStatus status, int n);
31+
void validatePollCreate(PollCreateDto dto);
32+
void validatePollCreate(PollForPostDto dto);
2933
}

backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.ai.lawyer.domain.poll.entity.*;
44
import com.ai.lawyer.domain.poll.repository.*;
5-
import com.ai.lawyer.domain.poll.service.*;
65
import com.ai.lawyer.domain.poll.dto.PollDto;
76
import com.ai.lawyer.domain.member.entity.Member;
87
import com.ai.lawyer.domain.member.repositories.MemberRepository;
@@ -17,6 +16,7 @@
1716
import java.util.List;
1817
import java.util.ArrayList;
1918
import com.ai.lawyer.domain.poll.dto.PollCreateDto;
19+
import com.ai.lawyer.domain.poll.dto.PollForPostDto;
2020
import com.ai.lawyer.domain.poll.dto.PollOptionCreateDto;
2121
import com.ai.lawyer.domain.poll.dto.PollStaticsDto;
2222
import com.ai.lawyer.domain.poll.dto.PollOptionDto;
@@ -27,6 +27,10 @@
2727
import org.springframework.data.domain.Pageable;
2828
import java.time.LocalDateTime;
2929

30+
import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto;
31+
import com.ai.lawyer.domain.poll.dto.PollStaticsResponseDto;
32+
import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto;
33+
3034
@Service
3135
@Transactional
3236
@RequiredArgsConstructor
@@ -42,44 +46,24 @@ public class PollServiceImpl implements PollService {
4246
@Override
4347
public PollDto createPoll(PollCreateDto request, Long memberId) {
4448
if (request.getPostId() == null) {
45-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID(postId)는 필수입니다.");
46-
}
47-
if (request.getVoteTitle() == null || request.getVoteTitle().trim().isEmpty()) {
48-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 제목(voteTitle)은 필수입니다.");
49-
}
50-
if (request.getPollOptions() == null || request.getPollOptions().size() != 2) {
51-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다.");
52-
}
53-
for (PollOptionCreateDto option : request.getPollOptions()) {
54-
if (option.getContent() == null || option.getContent().trim().isEmpty()) {
55-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "각 투표 항목의 내용(content)은 필수입니다.");
56-
}
49+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 ID는 필수입니다.");
5750
}
51+
validatePollCommon(request.getVoteTitle(), request.getPollOptions(), request.getReservedCloseAt());
5852
Member member = memberRepository.findById(memberId)
5953
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다."));
6054
Post post = postRepository.findById(request.getPostId())
6155
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."));
62-
// 이미 해당 게시글에 투표가 존재하는 경우 예외 처리
6356
if (post.getPoll() != null) {
6457
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 해당 게시글에 투표가 존재합니다.");
6558
}
6659
try {
6760
LocalDateTime now = java.time.LocalDateTime.now();
68-
LocalDateTime reservedCloseAt = request.getReservedCloseAt();
69-
if (reservedCloseAt != null) {
70-
if (reservedCloseAt.isBefore(now.plusHours(1))) {
71-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다.");
72-
}
73-
if (reservedCloseAt.isAfter(now.plusDays(7))) {
74-
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다.");
75-
}
76-
}
7761
Poll poll = Poll.builder()
7862
.post(post)
7963
.voteTitle(request.getVoteTitle())
8064
.status(Poll.PollStatus.ONGOING)
8165
.createdAt(now)
82-
.reservedCloseAt(reservedCloseAt)
66+
.reservedCloseAt(request.getReservedCloseAt())
8367
.build();
8468
Poll savedPoll = pollRepository.save(poll);
8569
post.setPoll(savedPoll);
@@ -119,7 +103,9 @@ public List<PollDto> getPollsByStatus(PollDto.PollStatus status) {
119103
}
120104
List<PollDto> pollDtos = polls.stream()
121105
.filter(p -> p.getStatus().name().equals(status.name()))
122-
.map(this::convertToDto)
106+
.map(p -> status == PollDto.PollStatus.CLOSED
107+
? getPollWithStatistics(p.getPollId())
108+
: convertToDto(p))
123109
.toList();
124110
return pollDtos;
125111
}
@@ -163,12 +149,72 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) {
163149
}
164150

165151
@Override
166-
public List<PollStatics> getPollStatics(Long pollId) {
167-
// 투표 존재 여부 체크
152+
public PollStaticsResponseDto getPollStatics(Long pollId) {
168153
if (!pollRepository.existsById(pollId)) {
169154
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 투표가 존재하지 않습니다.");
170155
}
171-
return pollStaticsRepository.findByPoll_PollId(pollId);
156+
Poll poll = pollRepository.findById(pollId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다."));
157+
Long postId = poll.getPost() != null ? poll.getPost().getPostId() : null;
158+
List<PollOptions> options = pollOptionsRepository.findByPoll_PollId(pollId);
159+
java.util.Map<String, PollOptions> optionMap = new java.util.HashMap<>();
160+
for (int i = 0; i < options.size(); i++) {
161+
PollOptions opt = options.get(i);
162+
optionMap.put(opt.getOption(), opt);
163+
}
164+
// age 통계 그룹핑
165+
List<Object[]> optionAgeRaw = pollVoteRepository.getOptionAgeStatics(pollId);
166+
java.util.Map<Long, java.util.List<PollAgeStaticsDto.AgeGroupCountDto>> ageGroupMap = new java.util.HashMap<>();
167+
for (Object[] arr : optionAgeRaw) {
168+
String option = arr[0] != null ? arr[0].toString() : null;
169+
PollOptions opt = optionMap.get(option);
170+
if (opt == null) continue;
171+
Long pollItemsId = opt.getPollItemsId();
172+
PollAgeStaticsDto.AgeGroupCountDto dto = PollAgeStaticsDto.AgeGroupCountDto.builder()
173+
.option(option)
174+
.ageGroup(arr[1] != null ? arr[1].toString() : null)
175+
.voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L)
176+
.build();
177+
ageGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto);
178+
}
179+
java.util.List<PollAgeStaticsDto> optionAgeStatics = new java.util.ArrayList<>();
180+
for (int i = 0; i < options.size(); i++) {
181+
PollOptions opt = options.get(i);
182+
optionAgeStatics.add(PollAgeStaticsDto.builder()
183+
.pollItemsId(opt.getPollItemsId())
184+
.pollOptionIndex(i + 1)
185+
.ageGroupCounts(ageGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList()))
186+
.build());
187+
}
188+
// gender 통계 그룹핑
189+
List<Object[]> optionGenderRaw = pollVoteRepository.getOptionGenderStatics(pollId);
190+
java.util.Map<Long, java.util.List<PollGenderStaticsDto.GenderCountDto>> genderGroupMap = new java.util.HashMap<>();
191+
for (Object[] arr : optionGenderRaw) {
192+
String option = arr[0] != null ? arr[0].toString() : null;
193+
PollOptions opt = optionMap.get(option);
194+
if (opt == null) continue;
195+
Long pollItemsId = opt.getPollItemsId();
196+
PollGenderStaticsDto.GenderCountDto dto = PollGenderStaticsDto.GenderCountDto.builder()
197+
.option(option)
198+
.gender(arr[1] != null ? arr[1].toString() : null)
199+
.voteCount(arr[2] != null ? ((Number)arr[2]).longValue() : 0L)
200+
.build();
201+
genderGroupMap.computeIfAbsent(pollItemsId, k -> new java.util.ArrayList<>()).add(dto);
202+
}
203+
java.util.List<PollGenderStaticsDto> optionGenderStatics = new java.util.ArrayList<>();
204+
for (int i = 0; i < options.size(); i++) {
205+
PollOptions opt = options.get(i);
206+
optionGenderStatics.add(PollGenderStaticsDto.builder()
207+
.pollItemsId(opt.getPollItemsId())
208+
.pollOptionIndex(i + 1)
209+
.genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList()))
210+
.build());
211+
}
212+
return PollStaticsResponseDto.builder()
213+
.postId(postId)
214+
.pollId(pollId)
215+
.optionAgeStatics(optionAgeStatics)
216+
.optionGenderStatics(optionGenderStatics)
217+
.build();
172218
}
173219

174220
// 최대 7일 동안 투표 가능 (초기 요구사항)
@@ -228,7 +274,9 @@ public List<PollDto> getTopNPollsByStatus(PollDto.PollStatus status, int n) {
228274
List<PollDto> pollDtos = new java.util.ArrayList<>();
229275
for (Object[] row : result) {
230276
Long pollId = (Long) row[0];
231-
pollDtos.add(getPoll(pollId));
277+
pollDtos.add(status == PollDto.PollStatus.CLOSED
278+
? getPollWithStatistics(pollId)
279+
: getPoll(pollId));
232280
}
233281
return pollDtos;
234282
}
@@ -487,4 +535,37 @@ private void autoCloseIfNeeded(Poll poll) {
487535
public List<PollOptions> getPollOptions(Long pollId) {
488536
return pollOptionsRepository.findByPoll_PollId(pollId);
489537
}
538+
539+
private static void validatePollCommon(String voteTitle, java.util.List<com.ai.lawyer.domain.poll.dto.PollOptionCreateDto> options, java.time.LocalDateTime reservedCloseAt) {
540+
if (voteTitle == null || voteTitle.trim().isEmpty()) {
541+
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 제목은 필수입니다.");
542+
}
543+
if (options == null || options.size() != 2) {
544+
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "투표 항목은 2개여야 합니다.");
545+
}
546+
for (com.ai.lawyer.domain.poll.dto.PollOptionCreateDto option : options) {
547+
if (option.getContent() == null || option.getContent().trim().isEmpty()) {
548+
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "각 투표 항목의 내용은 필수입니다.");
549+
}
550+
}
551+
java.time.LocalDateTime now = java.time.LocalDateTime.now();
552+
if (reservedCloseAt != null) {
553+
if (reservedCloseAt.isBefore(now.plusHours(1))) {
554+
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 현재로부터 최소 1시간 이후여야 합니다.");
555+
}
556+
if (reservedCloseAt.isAfter(now.plusDays(7))) {
557+
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "예약 종료 시간은 최대 7일 이내여야 합니다.");
558+
}
559+
}
560+
}
561+
562+
@Override
563+
public void validatePollCreate(PollForPostDto dto) {
564+
validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt());
565+
}
566+
567+
@Override
568+
public void validatePollCreate(PollCreateDto dto) {
569+
validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt());
570+
}
490571
}

0 commit comments

Comments
 (0)