Skip to content

Commit e0bff9a

Browse files
authored
[FEAT]: 투표 게시글 생성 및 투표 기능 구현 (#48)
* [Feat]: 투표 게시글 생성 기능 구현 * [Feat]: 투표 게시글 본문에 보여줄 선택지 응답 DTO 구현 * [Feat]: Post, PollVote 엔티티 @JdbcTypeCode(SqlTypes.JSON) 추가로 JSON 타입 처리 * [Feat]: 선택한 투표 번호를 입력하는 요청 DTO 추가 * [Feat]: 투표 기능 및 결과 조회 기능 구현 * [Feat]: 게시글, 댓글 인덱스 추가 * [Feat]: 투표 게시글 생성 테스트 구현 * [Feat]: 데이터베이스 제약조건 예외 추가 * [Feat]: 투표 관련 테스트 추가 * [Feat]: 투표 참여 엔티티 userHash 필드 제거 및 유니크 제약조건 삭제 * [Feat]: 잡담, 투표 게시글 및 댓글 더미데이터 추가
1 parent a83497d commit e0bff9a

File tree

20 files changed

+736
-45
lines changed

20 files changed

+736
-45
lines changed

back/src/main/java/com/back/domain/comment/entity/Comment.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
* 계층형 댓글 구조를 지원합니다.
2121
*/
2222
@Entity
23-
@Table(name = "comments")
23+
@Table(name = "comments",
24+
indexes = {
25+
@Index(name = "idx_comment_post_created", columnList = "post_id, created_date desc"),
26+
@Index(name = "idx_comment_post_like", columnList = "post_id, like_count desc")
27+
})
2428
@Getter
2529
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2630
@AllArgsConstructor(access = AccessLevel.PROTECTED)
Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,41 @@
11
package com.back.domain.poll.controller;
22

3+
import com.back.domain.poll.dto.PollResponse;
4+
import com.back.domain.poll.dto.VoteRequest;
35
import com.back.domain.poll.service.PollVoteService;
6+
import com.back.global.security.CustomUserDetails;
7+
import jakarta.validation.Valid;
48
import lombok.RequiredArgsConstructor;
5-
import org.springframework.web.bind.annotation.RequestMapping;
6-
import org.springframework.web.bind.annotation.RestController;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
11+
import org.springframework.web.bind.annotation.*;
712

813
/**
914
* 투표 관련 API 요청을 처리하는 컨트롤러.
1015
*/
1116
@RestController
12-
@RequestMapping("/api/v1/polls")
17+
@RequestMapping("/api/v1/posts/{postId}/polls")
1318
@RequiredArgsConstructor
1419
public class PollVoteController {
1520

1621
private final PollVoteService pollVoteService;
1722

23+
@PostMapping
24+
public ResponseEntity<Void> vote(
25+
@PathVariable Long postId,
26+
@RequestBody @Valid VoteRequest request,
27+
@AuthenticationPrincipal CustomUserDetails cs) {
28+
29+
pollVoteService.vote(cs.getUser(), postId, request);
30+
return ResponseEntity.ok().build();
31+
}
32+
33+
@GetMapping
34+
public ResponseEntity<PollResponse> getVote(
35+
@PathVariable Long postId,
36+
@AuthenticationPrincipal CustomUserDetails cs) {
37+
38+
PollResponse response = pollVoteService.getVote(postId);
39+
return ResponseEntity.ok().body(response);
40+
}
1841
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.back.domain.poll.converter;
2+
3+
import com.back.domain.poll.dto.PollOptionResponse;
4+
import com.back.domain.poll.dto.PollRequest;
5+
import com.back.domain.poll.dto.PollResponse;
6+
import com.back.domain.poll.dto.VoteRequest;
7+
import com.back.global.exception.ApiException;
8+
import com.back.global.exception.ErrorCode;
9+
import com.fasterxml.jackson.core.JsonProcessingException;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.stereotype.Component;
13+
14+
import java.util.HashMap;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.UUID;
18+
19+
/**
20+
* PollVote, Post 필드 JSON 변환기
21+
*/
22+
@Component
23+
@RequiredArgsConstructor
24+
public class PollConverter {
25+
26+
private final ObjectMapper objectMapper;
27+
28+
// json 형태의 문자열 -> PollResponse
29+
public PollResponse fromPollJson(String voteContent) {
30+
if (voteContent == null) return null;
31+
try {
32+
return objectMapper.readValue(voteContent, PollResponse.class);
33+
} catch (JsonProcessingException e) {
34+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT);
35+
}
36+
}
37+
38+
// json 형태의 문자열 -> 리스트
39+
public List<Integer> fromChoiceJson(String choiceJson) {
40+
if (choiceJson == null) return List.of();
41+
try {
42+
VoteRequest req = objectMapper.readValue(choiceJson, VoteRequest.class);
43+
return req.choice();
44+
} catch (JsonProcessingException e) {
45+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT);
46+
}
47+
}
48+
49+
// [1,2] => "{"choice":[1,2]} 형태로 변환
50+
public String toChoiceJson(List<Integer> selectedIndexes) {
51+
try {
52+
Map<String, Object> map = Map.of("choice", selectedIndexes);
53+
return objectMapper.writeValueAsString(map);
54+
} catch (JsonProcessingException e) {
55+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT);
56+
}
57+
}
58+
59+
// {"options":[{"index":1,"text":"첫 번째 옵션"},{...}, "pollUid":"UUID"}
60+
public String toPollContentJson(UUID pollUid, List<PollRequest.PollOption> options) {
61+
try {
62+
Map<String, Object> pollMap = new HashMap<>();
63+
pollMap.put("pollUid", pollUid.toString());
64+
pollMap.put("options", options);
65+
return objectMapper.writeValueAsString(pollMap);
66+
} catch (JsonProcessingException e) {
67+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT);
68+
}
69+
}
70+
71+
// json 형태의 문자열 -> PollOptionResponse
72+
public PollOptionResponse fromPollOptionJson(String voteContent) {
73+
if (voteContent == null) return null;
74+
try {
75+
return objectMapper.readValue(voteContent, PollOptionResponse.class);
76+
} catch (JsonProcessingException e) {
77+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT);
78+
}
79+
}
80+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.back.domain.poll.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* 투표 선택지 응답 DTO
7+
*/
8+
public record PollOptionResponse(
9+
List<VoteOption> options
10+
) {
11+
public record VoteOption(
12+
int index,
13+
String text
14+
) {}
15+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.poll.dto;
2+
3+
import jakarta.validation.constraints.Min;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Size;
6+
7+
import java.util.List;
8+
9+
public record PollRequest(
10+
@NotNull
11+
@Size(min = 2, max = 10)
12+
List<PollOption> options
13+
) {
14+
public record PollOption(
15+
@Min(value = 1, message = "투표 옵션 인덱스는 1 이상이어야 합니다.")
16+
int index,
17+
@NotNull(message = "투표 옵션 텍스트는 필수입니다.")
18+
String text
19+
) {}
20+
}
21+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.back.domain.poll.dto;
2+
3+
import java.util.List;
4+
5+
/**
6+
* 투표 결과를 보여주는 DTO
7+
*/
8+
public record PollResponse(
9+
String pollUid,
10+
List<VoteDetail> options
11+
) {
12+
public record VoteDetail(
13+
int index,
14+
String text,
15+
Integer voteCount
16+
) {}
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.back.domain.poll.dto;
2+
3+
import jakarta.validation.constraints.NotEmpty;
4+
5+
import java.util.List;
6+
7+
/**
8+
* 선택한 투표 번호
9+
* 다중 선택 가능 [1, 2] or [1] ...
10+
*/
11+
public record VoteRequest(
12+
@NotEmpty(message = "최소 하나의 선택지는 필수입니다")
13+
List<Integer> choice
14+
) {
15+
}

back/src/main/java/com/back/domain/poll/entity/PollVote.java

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import lombok.Builder;
99
import lombok.Getter;
1010
import lombok.NoArgsConstructor;
11-
import lombok.Setter;
11+
import org.hibernate.annotations.JdbcTypeCode;
12+
import org.hibernate.type.SqlTypes;
1213

1314
import java.util.UUID;
1415

@@ -19,14 +20,12 @@
1920
@Entity
2021
@Table(name = "poll_votes",
2122
uniqueConstraints = {
22-
@UniqueConstraint(name = "uq_logged_in_once", columnNames = {"post_id", "pollUid", "user_id"}),
23-
@UniqueConstraint(name = "uq_anonymous_once", columnNames = {"post_id", "pollUid", "userHash"})
23+
@UniqueConstraint(name = "uq_logged_in_once", columnNames = {"post_id", "pollUid", "user_id"})
2424
}
2525
)
2626
@Getter
27-
@Setter
28-
@NoArgsConstructor
29-
@AllArgsConstructor
27+
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
28+
@AllArgsConstructor(access = lombok.AccessLevel.PROTECTED)
3029
@Builder
3130
public class PollVote extends BaseEntity {
3231

@@ -41,9 +40,8 @@ public class PollVote extends BaseEntity {
4140
@JoinColumn(name = "user_id")
4241
private User user;
4342

44-
@Column(length = 128)
45-
private String userHash;
46-
43+
@JdbcTypeCode(SqlTypes.JSON)
4744
@Column(nullable = false, columnDefinition = "jsonb")
4845
private String choiceJson;
46+
4947
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
package com.back.domain.poll.repository;
22

33
import com.back.domain.poll.entity.PollVote;
4+
import com.back.domain.post.entity.Post;
5+
import com.back.domain.user.entity.User;
46
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
59
import org.springframework.stereotype.Repository;
610

11+
import java.util.List;
12+
import java.util.Map;
13+
714
/**
815
* 투표 참여 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository.
916
*/
1017
@Repository
1118
public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
19+
List<PollVote> findByPostId(@Param("postId") Long postId);
1220
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,92 @@
11
package com.back.domain.poll.service;
22

3+
import com.back.domain.poll.converter.PollConverter;
4+
import com.back.domain.poll.dto.PollResponse;
5+
import com.back.domain.poll.dto.VoteRequest;
6+
import com.back.domain.poll.entity.PollVote;
37
import com.back.domain.poll.repository.PollVoteRepository;
8+
import com.back.domain.post.entity.Post;
9+
import com.back.domain.post.repository.PostRepository;
10+
import com.back.domain.user.entity.User;
11+
import com.back.global.exception.ApiException;
12+
import com.back.global.exception.ErrorCode;
13+
import jakarta.validation.Valid;
414
import lombok.RequiredArgsConstructor;
515
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.UUID;
21+
import java.util.stream.Collectors;
622

723
/**
824
* 투표 관련 비즈니스 로직을 처리하는 서비스.
925
*/
1026
@Service
1127
@RequiredArgsConstructor
28+
@Transactional(readOnly = true)
1229
public class PollVoteService {
1330

1431
private final PollVoteRepository pollVoteRepository;
32+
private final PostRepository postRepository;
33+
private final PollConverter pollConverter;
34+
35+
@Transactional
36+
public void vote(User user, Long postId, @Valid VoteRequest request) {
37+
Post post = postRepository.findById(postId)
38+
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));
39+
40+
// 요청에서 투표 항목 파싱
41+
PollResponse pollContent = pollConverter.fromPollJson(post.getVoteContent());
42+
43+
// 유효한 옵션 검증
44+
validationOptions(request, pollContent);
45+
46+
// 선택값 JSON 변환
47+
String choiceJson = pollConverter.toChoiceJson(request.choice());
48+
49+
PollVote pollVote = PollVote.builder()
50+
.post(post)
51+
.pollUid(UUID.fromString(pollContent.pollUid()))
52+
.user(user)
53+
.choiceJson(choiceJson)
54+
.build();
55+
56+
pollVoteRepository.save(pollVote);
57+
}
58+
59+
public PollResponse getVote(Long postId) {
60+
Post post = postRepository.findById(postId)
61+
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));
62+
63+
// PollVote 조회 후 choiceJson 필드 파싱 [1,2], [2] ...
64+
// 평탄화 작업 진행 후 1, 2, 2 카운팅
65+
Map<Integer, Long> countMap = pollVoteRepository.findByPostId(postId).stream()
66+
.flatMap(pv -> pollConverter.fromChoiceJson(pv.getChoiceJson()).stream())
67+
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
68+
69+
PollResponse pollContent = pollConverter.fromPollJson(post.getVoteContent());
70+
71+
// 옵션별 voteCount 매핑
72+
List<PollResponse.VoteDetail> options = pollContent.options().stream()
73+
.map(opt -> new PollResponse.VoteDetail(
74+
opt.index(),
75+
opt.text(),
76+
countMap.getOrDefault(opt.index(), 0L).intValue()
77+
))
78+
.toList();
79+
80+
return new PollResponse(pollContent.pollUid(), options);
81+
}
1582

83+
private static void validationOptions(VoteRequest request, PollResponse pollContent) {
84+
for (Integer selectedIndex : request.choice()) {
85+
boolean exists = pollContent.options().stream()
86+
.anyMatch(opt -> opt.index() == selectedIndex);
87+
if (!exists) {
88+
throw new ApiException(ErrorCode.POLL_VOTE_INVALID_OPTION);
89+
}
90+
}
91+
}
1692
}

0 commit comments

Comments
 (0)