Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

선택지 통계 조회 기능 구현 #83

Merged
merged 12 commits into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/

### local environment test files ###
src/main/generated
5 changes: 2 additions & 3 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,13 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'

testImplementation 'io.rest-assured:rest-assured'
testImplementation 'io.rest-assured:spring-mock-mvc'

runtimeOnly 'com.h2database:h2'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

testImplementation 'io.rest-assured:rest-assured'
testImplementation 'io.rest-assured:spring-mock-mvc'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

@Configuration
public class SwaggerBeanConfig {

public SwaggerBeanConfig(final MappingJackson2HttpMessageConverter converter) {
final List<MediaType> supportedMediaTypes = new ArrayList<>(converter.getSupportedMediaTypes());
supportedMediaTypes.add(new MediaType("application", "octet-stream"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -23,15 +22,18 @@ public class Member extends BaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(length = 15, nullable = false)
@Column(length = 15, unique = true, nullable = false)
private String nickname;

@Enumerated(value = EnumType.STRING)
@Column(length = 20, nullable = false)
private Gender gender;

@Column(nullable = false)
private LocalDateTime birthDate;
private String ageRange;

@Column(nullable = false)
private String birthday;

@Enumerated(value = EnumType.STRING)
@Column(length = 20, nullable = false)
Expand All @@ -46,15 +48,17 @@ public class Member extends BaseEntity {
@Builder
private Member(
final String nickname,
final LocalDateTime birthDate,
final Gender gender,
final String ageRange,
final String birthday,
final SocialType socialType,
final String socialId,
final Integer point
) {
this.nickname = nickname;
this.birthDate = birthDate;
this.gender = gender;
this.ageRange = ageRange;
this.birthday = birthday;
this.socialType = socialType;
this.socialId = socialId;
this.point = point;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
import com.votogether.domain.member.entity.Member;
import com.votogether.domain.member.entity.SocialType;
import com.votogether.domain.post.dto.request.PostCreateRequest;
import com.votogether.domain.post.dto.response.VoteOptionStatisticsResponse;
import com.votogether.domain.post.service.PostService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
Expand Down Expand Up @@ -42,17 +44,33 @@ public ResponseEntity<Void> save(
) {
// TODO : 일단 돌아가게 하기 위한 member 저장 (실제 어플에선 삭제될 코드)
final Member member = Member.builder()
.socialType(SocialType.GOOGLE)
.socialId("tjdtls690")
.nickname("Abel")
.gender(Gender.MALE)
.birthday("0718")
.ageRange("10~14")
.socialType(SocialType.GOOGLE)
.socialId("tjdtls690")
.point(100)
.birthDate(LocalDateTime.now())
.build();

final Long postId = postService.save(request, member, images);
return ResponseEntity.created(URI.create("/posts/" + postId)).build();
}

@Operation(summary = "게시글 투표 선택지 통계 조회", description = "게시글 특정 투표 선택지에 대한 통계를 조회한다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "게시글 투표 선택지 통계 조회 성공"),
@ApiResponse(responseCode = "400", description = "게시글 투표 옵션이 게시글에 속하지 않아 조회 실패"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 게시글이거나 게시글 투표 옵션")
})
@GetMapping(value = "/{postId}/options/{optionId}")
public ResponseEntity<VoteOptionStatisticsResponse> getVoteOptionStatistics(
@PathVariable final Long postId,
@PathVariable final Long optionId
) {
final VoteOptionStatisticsResponse response = postService.getVoteOptionStatistics(postId, optionId);
return ResponseEntity.ok(response);
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@

@Schema(name = "게시글 관련 데이터", description = "게시글에 관련한 데이터들입니다.")
@Builder
public record PostCreateRequest (
public record PostCreateRequest(
List<Long> categoryIds,
String title,
String content,
List<String> postOptionContents,

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
LocalDateTime deadline
){
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.votogether.domain.post.dto.response;

import com.votogether.domain.member.entity.Gender;
import java.util.Map;

public record VoteCountForAgeGroupResponse(String ageGroup, int voteCount, int maleCount, int femaleCount) {

public static VoteCountForAgeGroupResponse of(final String ageGroup, final Map<Gender, Long> genderGroup) {
final int maleCount = genderGroup.getOrDefault(Gender.MALE, 0L).intValue();
final int femaleCount = genderGroup.getOrDefault(Gender.FEMALE, 0L).intValue();
final int voteCount = maleCount + femaleCount;

return new VoteCountForAgeGroupResponse(ageGroup, voteCount, maleCount, femaleCount);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.votogether.domain.post.dto.response;

import com.votogether.domain.member.entity.Gender;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.Getter;

public record VoteOptionStatisticsResponse(
int totalVoteCount,
int totalMaleCount,
int totalFemaleCount,
List<VoteCountForAgeGroupResponse> ageGroup
) {

public static VoteOptionStatisticsResponse from(final Map<String, Map<Gender, Long>> voteStatusGroup) {
final List<VoteCountForAgeGroupResponse> ageGroupStatistics = Arrays.stream(AgeBracket.values())
.map(ageBracket -> {
final Map<Gender, Long> genderVotes =
voteStatusGroup.getOrDefault(ageBracket.getAge(), new HashMap<>());
return VoteCountForAgeGroupResponse.of(ageBracket.getBracket(), genderVotes);
})
.collect(Collectors.toList());

final int totalVoteCount = ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::voteCount).sum();
final int totalMaleCount = ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::maleCount).sum();
final int totalFemaleCount =
ageGroupStatistics.stream().mapToInt(VoteCountForAgeGroupResponse::femaleCount).sum();

return new VoteOptionStatisticsResponse(totalVoteCount, totalMaleCount, totalFemaleCount, ageGroupStatistics);
}

@Getter
enum AgeBracket {

UNDER_TEN("1~9", "10대 미만"),
TEN("10~19", "10대"),
TWENTY("20~29", "20대"),
THIRTY("30~39", "30대"),
FORTY("40~49", "40대"),
FIFTY("50~59", "50대"),
OVER_SIXTY("60~", "60 이상"),
;

private final String age;
private final String bracket;

AgeBracket(final String age, final String bracket) {
this.age = age;
this.bracket = bracket;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
Expand Down Expand Up @@ -94,4 +94,8 @@ private static PostOption toPostOptionEntity(
.build();
}

public boolean isBelongsTo(final Post post) {
return Objects.equals(this.post.getId(), post.getId());
}
Copy link
Collaborator

@aiaiaiai1 aiaiaiai1 Jul 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree : 이렇게 확인하면 Post에서 postOption을 확인하는 방법과 달리 조회 쿼리가 안나가도 되니까 더 좋은거같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그 부분을 고려하고 개발을 진행했는데 알아봐주시는군요 👍🏻


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.votogether.domain.post.exception;

import com.votogether.exception.ExceptionType;
import lombok.Getter;

@Getter
public enum PostExceptionType implements ExceptionType {

POST_NOT_FOUND(1000, "해당 게시글이 존재하지 않습니다."),
POST_OPTION_NOT_FOUND(1001, "해당 게시글 투표 옵션이 존재하지 않습니다."),
UNRELATED_POST_OPTION(1002, "게시글 투표 옵션이 게시글과 연관되어 있지 않습니다."),
;

private final int code;
private final String message;

PostExceptionType(final int code, final String message) {
this.code = code;
this.message = message;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,41 @@

import com.votogether.domain.category.entity.Category;
import com.votogether.domain.category.repository.CategoryRepository;
import com.votogether.domain.member.entity.Gender;
import com.votogether.domain.member.entity.Member;
import com.votogether.domain.member.repository.MemberRepository;
import com.votogether.domain.post.dto.request.PostCreateRequest;
import com.votogether.domain.post.dto.response.VoteOptionStatisticsResponse;
import com.votogether.domain.post.entity.Post;
import com.votogether.domain.post.entity.PostBody;
import com.votogether.domain.post.entity.PostOption;
import com.votogether.domain.post.exception.PostExceptionType;
import com.votogether.domain.post.repository.PostOptionRepository;
import com.votogether.domain.post.repository.PostRepository;
import com.votogether.domain.vote.dto.VoteStatus;
import com.votogether.domain.vote.repository.VoteRepository;
import com.votogether.exception.BadRequestException;
import com.votogether.exception.NotFoundException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@RequiredArgsConstructor
@Transactional
@Service
public class PostService {

private final PostRepository postRepository;
private final PostOptionRepository postOptionRepository;
private final CategoryRepository categoryRepository;
private final MemberRepository memberRepository;
private final VoteRepository voteRepository;

@Transactional
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q : 혹시 메서드가 아직 2개 뿐이어서 아직은 굳이 클래스에 붙일 필요가 없다고 생각하신건지 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 처음에는 클래스 상단에 @Transactional(readOnly = true)를 붙이고 트랜잭션이 사용되는 메서드에 별도로 트랜잭션 어노테이션을 붙이는 방법을 사용했었습니다 ㅎㅎ

하지만 해당 방법을 사용하면서 어떤 메서드는 붙이고 어떤 메서드는 붙이지 않아 정작 필요한 경우에도 어노테이션을 붙이지 않는 실수가 많이 나오게 되었습니다. 실제로 붙이지 않아서 문제 상황을 겪은 경험도 있었습니다 🥲 따라서 조금 더 주의깊게 어노테이션을 붙이고자 각 메서드를 붙이는 방법을 고려해보게 되었고, 각 메서드가 어떤 트랜잭션 설정에서 동작하는지도 명시적으로 보여줄 수 있어서 각 메서드에 붙이는 방법으로 변경해서 적용 중에 있습니다.

이 부분도 팀 프로젝트에서 맞추면 되는 컨벤션이라 생각하기에 함께 얘기해보면 좋을 것 같아요 :)

public Long save(
final PostCreateRequest postCreateRequest,
final Member member,
Expand Down Expand Up @@ -62,4 +76,46 @@ private PostBody toPostBody(final PostCreateRequest postCreateRequest) {
.build();
}

@Transactional(readOnly = true)
public VoteOptionStatisticsResponse getVoteOptionStatistics(final Long postId, final Long optionId) {
final Post post = postRepository.findById(postId)
.orElseThrow(() -> new NotFoundException(PostExceptionType.POST_NOT_FOUND));
final PostOption postOption = postOptionRepository.findById(optionId)
.orElseThrow(() -> new NotFoundException(PostExceptionType.POST_OPTION_NOT_FOUND));

if (!postOption.isBelongsTo(post)) {
throw new BadRequestException(PostExceptionType.UNRELATED_POST_OPTION);
}

final List<VoteStatus> voteStatuses =
voteRepository.findVoteCountByPostOptionIdGroupByAgeRangeAndGender(postOption.getId());
return VoteOptionStatisticsResponse.from(groupVoteStatus(voteStatuses));
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: 작성자 본인만 투표 통계를 확인할 수 있어서 게시글을 자기가 썼는지에 대한 검증이 필요한거같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

회원가입 관련 기능이랑 합쳐지고 리팩토링 진행 예정입니다 :)

private Map<String, Map<Gender, Long>> groupVoteStatus(final List<VoteStatus> voteStatuses) {
return voteStatuses.stream()
.collect(Collectors.groupingBy(
status -> groupAgeRange(status.ageRange()),
HashMap::new,
Collectors.groupingBy(
VoteStatus::gender,
HashMap::new,
Collectors.summingLong(VoteStatus::count)
)
));
}

private String groupAgeRange(final String ageRange) {
final List<String> teens = List.of("10~14", "15~19");
final List<String> over60 = List.of("60~69", "70~79", "80~89", "90~");

if (teens.contains(ageRange)) {
return "10~19";
}
if (over60.contains(ageRange)) {
return "60~";
}
return ageRange;
}

Comment on lines +95 to +120
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 : List<VoteStatus> 의 일급컬렉션을 만들어서 서비스의 private 메서드를 일급 컬렉션 객체로 옮길 수 있을거같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런 방법도 고려해볼 수 있을 것 같습니다! 그럼 해당 일급 컬렉션은 어떤 패키지에 속하게 되는걸까요? 🤔

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.votogether.domain.vote.dto;

import com.votogether.domain.member.entity.Gender;

public record VoteStatus(String ageRange, Gender gender, long count) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@

import com.votogether.domain.member.entity.Member;
import com.votogether.domain.post.entity.PostOption;
import com.votogether.domain.vote.dto.VoteStatus;
import com.votogether.domain.vote.entity.Vote;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface VoteRepository extends JpaRepository<Vote, Long> {

@Query("SELECT new com.votogether.domain.vote.dto.VoteStatus(m.ageRange, m.gender, COUNT(v)) " +
"FROM Vote v " +
"JOIN v.member m " +
"WHERE v.postOption.id = :postOptionId " +
"GROUP BY m.ageRange, m.gender")
List<VoteStatus> findVoteCountByPostOptionIdGroupByAgeRangeAndGender(@Param("postOptionId") Long postOptionId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q

@param의 name 속성은 파라미터 명이 일치하면 생략할 수 있는 것으로 알고 있는데, 속성을 넣어준 이유가 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아벨이 말씀하신대로 JPQL에 사용하는 값이 변수명과 동일한 경우 생략할 수 있습니다 :)
변수명으로 정보를 전달하고 있기에 생략하는게 좋은 것 같습니다. @Param 어노테이션 자체가 필요없어져 삭제했습니다!


Optional<Vote> findByMemberAndPostOption(final Member member, final PostOption postOption);

List<Vote> findByMemberAndPostOptionIn(final Member member, final List<PostOption> postOptions);
Expand Down
Loading