Skip to content

Commit

Permalink
선택지 통계 조회 기능 구현 (#83)
Browse files Browse the repository at this point in the history
* chore: (#77) 코드 컨벤션 정렬

* feat: (#77) 공통 예외 처리 구현

* feat: (#77) 동적 쿼리 사용을 위한 querydsl 추가

* refactor: (#77) 레포지토리 테스트 어노테이션 리팩토링

* feat: (#77) 연령, 성별로 그룹화하여 투표 수 조회 기능 구현

* feat: (#77) 게시글 투표 옵션에 대한 투표 통계 조회 기능 구현

* feat: (#77) 게시글 투표 옵션에 대한 투표 통계 조회 API 구현

* chore: (#77) 코드 컨벤션 정렬

* refactor: (#77) querydsl 의존성 제거

* refactor: (#77) 변수 사용 시 변수명과 동일할 때 불필요한 어노테이션 제거

* refactor: (#77) 멤버 도메인 변경으로 인한 테스트 코드 수정
  • Loading branch information
woo-chang authored and tjdtls690 committed Sep 12, 2023
1 parent 5947205 commit 0612f02
Show file tree
Hide file tree
Showing 32 changed files with 871 additions and 158 deletions.
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());
}

}
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
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));
}

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;
}

}
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;

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(Long postOptionId);

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

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

0 comments on commit 0612f02

Please sign in to comment.