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

feat: 물품 주문자들의 성별 및 나이대별 평균 별점 구현 #161

Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ public SecurityFilterChain filterChain(final HttpSecurity http) throws Exception
.requestMatchers(new AntPathRequestMatcher("/api/v1/login"),
new AntPathRequestMatcher("/api/v1/signup"),
new AntPathRequestMatcher("/api/v1/products/**"),
new AntPathRequestMatcher("/api/v1/rate/product/**"))
new AntPathRequestMatcher("/api/v1/rate/product/*"),
new AntPathRequestMatcher("/api/v1/rate/product/*/all"))
.permitAll()
.anyRequest().authenticated()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.gugucon.shopping.auth.dto.MemberPrincipal;
import com.gugucon.shopping.rate.dto.request.RateCreateRequest;
import com.gugucon.shopping.rate.dto.response.GroupRateResponse;
import com.gugucon.shopping.rate.dto.response.RateDetailResponse;
import com.gugucon.shopping.rate.dto.response.RateResponse;
import com.gugucon.shopping.rate.service.RateService;
Expand All @@ -11,6 +12,8 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/rate")
@RequiredArgsConstructor
Expand Down Expand Up @@ -44,4 +47,10 @@ public RateResponse getCustomRate(@PathVariable final Long productId,
@AuthenticationPrincipal final MemberPrincipal principal) {
return rateService.getCustomRate(productId, principal);
}

@GetMapping("/product/{productId}/all")
@ResponseStatus(HttpStatus.OK)
public List<GroupRateResponse> getGroupRates(@PathVariable final Long productId) {
return rateService.getGroupRates(productId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.gugucon.shopping.rate.dto.response;

import com.gugucon.shopping.member.domain.vo.BirthYearRange;
import com.gugucon.shopping.member.domain.vo.Gender;
import com.gugucon.shopping.rate.repository.dto.GroupAverageRateDto;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class GroupRateResponse {

private Gender gender;
private BirthYearRange birthYearRange;
private RateResponse rate;

public static GroupRateResponse of(final GroupAverageRateDto groupAverageRateDto, final double averageRate) {
return new GroupRateResponse(groupAverageRateDto.getGender(),
groupAverageRateDto.getBirthYearRange(),
new RateResponse(groupAverageRateDto.getCount(), averageRate));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import com.gugucon.shopping.member.domain.vo.Gender;
import com.gugucon.shopping.rate.domain.entity.Rate;
import com.gugucon.shopping.rate.repository.dto.AverageRateDto;
import java.util.Optional;
import com.gugucon.shopping.rate.repository.dto.GroupAverageRateDto;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface RateRepository extends JpaRepository<Rate, Long> {

Expand All @@ -34,4 +37,10 @@ public interface RateRepository extends JpaRepository<Rate, Long> {
AverageRateDto findScoresByMemberGenderAndMemberBirthYear(final Long productId,
final Gender gender,
final BirthYearRange birthYearRange);

@Query("SELECT new com.gugucon.shopping.rate.repository.dto.GroupAverageRateDto" +
"(rs.gender, rs.birthYearRange, rs.count, rs.totalScore) " +
"FROM RateStat rs " +
"WHERE rs.productId = :productId")
List<GroupAverageRateDto> findAllScoresByMemberGenderAndMemberBirthYear(final Long productId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

findAllGroup 어쩌구로 이름 바꾸면 좋을 것 같아요

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.gugucon.shopping.rate.repository.dto;

import com.gugucon.shopping.member.domain.vo.BirthYearRange;
import com.gugucon.shopping.member.domain.vo.Gender;
import lombok.Getter;

@Getter
public class GroupAverageRateDto {

private final Gender gender;
private final BirthYearRange birthYearRange;
private final Long count;
private final Long totalScore;

public GroupAverageRateDto(final Gender gender,
final BirthYearRange birthYearRange,
final Long count,
final Long totalScore) {
this.gender = gender;
this.birthYearRange = birthYearRange;
this.count = count;
this.totalScore = totalScore;
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/gugucon/shopping/rate/service/RateService.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@
import com.gugucon.shopping.order.repository.OrderItemRepository;
import com.gugucon.shopping.rate.domain.entity.Rate;
import com.gugucon.shopping.rate.dto.request.RateCreateRequest;
import com.gugucon.shopping.rate.dto.response.GroupRateResponse;
import com.gugucon.shopping.rate.dto.response.RateDetailResponse;
import com.gugucon.shopping.rate.dto.response.RateResponse;
import com.gugucon.shopping.rate.repository.RateRepository;
import com.gugucon.shopping.rate.repository.dto.AverageRateDto;
import com.gugucon.shopping.rate.repository.dto.GroupAverageRateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Comparator;
import java.util.List;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
Expand Down Expand Up @@ -74,10 +79,21 @@ public RateResponse getCustomRate(final Long productId, final MemberPrincipal pr
return new RateResponse(rates.getCount(), averageRate);
}

public List<GroupRateResponse> getGroupRates(final Long productId) {
return rateRepository.findAllScoresByMemberGenderAndMemberBirthYear(productId).stream()
.sorted(Comparator.comparing(o -> o.getBirthYearRange().getStartDate(), Comparator.reverseOrder()))
.map(rateDto -> GroupRateResponse.of(rateDto, calculateAverageOf(rateDto)))
.toList();
}

private double calculateAverageOf(final AverageRateDto averageRateDto) {
return roundDownAverage((double) averageRateDto.getTotalScore() / averageRateDto.getCount());
}

private double calculateAverageOf(final GroupAverageRateDto averageRateDto) {
return roundDownAverage((double) averageRateDto.getTotalScore() / averageRateDto.getCount());
}

private double roundDownAverage(final double average) {
return Math.floor(average * 100) / 100.0;
}
Expand Down
50 changes: 50 additions & 0 deletions src/main/resources/templates/product-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta content="IE=edge" http-equiv="X-UA-Compatible"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link rel="stylesheet" th:href="@{/css/index.css}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<title>Shopping</title>
</head>
<body>
Expand Down Expand Up @@ -41,6 +42,10 @@ <h3 class="highlight-text">별점</h3>
<h3 class="highlight-text">사용자와 비슷한 사람들이 남긴 별점</h3>
<span class="highlight-text custom-rate"></span>
</div>
<div class="p-20 mt-20">
<h3 class="highlight-text">성별 및 나이대별 평균 별점</h3>
<canvas id="rate-chart" width="400px" height="300px"></canvas>
</div>
</div>
<div class="flex justify-between p-20 mt-20">
<h3 class="highlight-text">추천상품 상위 30개</h3>
Expand Down Expand Up @@ -113,6 +118,51 @@ <h3 class="highlight-text">추천상품 상위 30개</h3>
document.querySelector('.custom-rate-box').style.display = 'block';
});
}

fetch(`/api/v1/rate/product/${productId}/all`, {
method: 'GET',
}).then((response) => {
if (response.ok) {
return response.json();
}
response.json().then((data) => alert(data.message));
window.location.href = "/";
}).then((data) => {
let chartData = [];
data.forEach(d => chartData.push(d.rate.averageRate));
let rateChart = document.getElementById('rate-chart').getContext('2d');
let massPopChart = new Chart(rateChart, {
type: 'bar',
data: {
labels: ['10대 이하 남자', '10대 이하 여자', '20대 남자', '20대 여자', '30대 남자', '30대 여자', '40대 이상 남자', '40대 이상 여자'],
datasets: [{
label: '성별 및 나이대별 별점',
data: chartData,
backgroundColor: '#2ac1bc',
barThickness: 10,
}]
},
options: {
responsive: false,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
yAxis: {
max: 5,
min: 0,
ticks: {
stepSize: 1
}
},
}
}
});
});

});

function onNextClick() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,5 @@
package com.gugucon.shopping.integration;

import static com.gugucon.shopping.member.domain.vo.BirthYearRange.EARLY_TWENTIES;
import static com.gugucon.shopping.member.domain.vo.BirthYearRange.LATE_TWENTIES;
import static com.gugucon.shopping.member.domain.vo.BirthYearRange.MID_TWENTIES;
import static com.gugucon.shopping.member.domain.vo.BirthYearRange.OVER_FORTIES;
import static com.gugucon.shopping.member.domain.vo.BirthYearRange.THIRTIES;
import static com.gugucon.shopping.member.domain.vo.BirthYearRange.UNDER_TEENS;
import static com.gugucon.shopping.utils.ApiUtils.buyProduct;
import static com.gugucon.shopping.utils.ApiUtils.buyProductWithSuccess;
import static com.gugucon.shopping.utils.ApiUtils.chargePoint;
import static com.gugucon.shopping.utils.ApiUtils.createRateToOrderedItem;
import static com.gugucon.shopping.utils.ApiUtils.getFirstOrderItem;
import static com.gugucon.shopping.utils.ApiUtils.insertCartItem;
import static com.gugucon.shopping.utils.ApiUtils.loginAfterSignUp;
import static com.gugucon.shopping.utils.ApiUtils.mockServerSuccess;
import static com.gugucon.shopping.utils.ApiUtils.payOrderByPoint;
import static com.gugucon.shopping.utils.ApiUtils.placeOrder;
import static com.gugucon.shopping.utils.ApiUtils.putOrder;
import static com.gugucon.shopping.utils.StatsUtils.createInitialRateStat;
import static org.assertj.core.api.Assertions.assertThat;

import com.gugucon.shopping.common.exception.ErrorCode;
import com.gugucon.shopping.common.exception.ErrorResponse;
import com.gugucon.shopping.integration.config.IntegrationTest;
Expand All @@ -33,14 +13,14 @@
import com.gugucon.shopping.order.dto.request.OrderPayRequest;
import com.gugucon.shopping.pay.dto.request.PointPayRequest;
import com.gugucon.shopping.rate.dto.request.RateCreateRequest;
import com.gugucon.shopping.rate.dto.response.GroupRateResponse;
import com.gugucon.shopping.rate.dto.response.RateDetailResponse;
import com.gugucon.shopping.rate.dto.response.RateResponse;
import com.gugucon.shopping.utils.DomainUtils;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;
import java.time.LocalDate;
import org.assertj.core.data.Percentage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand All @@ -50,6 +30,14 @@
import org.springframework.http.HttpStatus;
import org.springframework.web.client.RestTemplate;

import java.time.LocalDate;
import java.util.Arrays;

import static com.gugucon.shopping.member.domain.vo.BirthYearRange.*;
import static com.gugucon.shopping.utils.ApiUtils.*;
import static com.gugucon.shopping.utils.StatsUtils.createInitialRateStat;
import static org.assertj.core.api.Assertions.assertThat;

@IntegrationTest
@DisplayName("별점 기능 통합 테스트")
class RateIntegrationTest {
Expand Down Expand Up @@ -535,6 +523,35 @@ void getCustomRate3() {
assertThat(rateResponse.getAverageRate()).isCloseTo(3.0, Percentage.withPercentage(99.9));
}

@Test
@DisplayName("상품을 주문한 모든 주문자들의 성별 및 나이대에 따른 평균 별점 정보를 가져온다")
void getAverageRatesByGenderAndBirthYearRange() {
// given
final Long productId = insertProduct("good Product");
initializeAllAgeAndGenderProductStats(productId);

final int expectedAllCount = 10;
createRateToProduct(productId, expectedAllCount);

// when
final ExtractableResponse<Response> response = RestAssured
.given().log().all()
.when()
.get("/api/v1/rate/product/{productId}/all", productId)
.then()
.contentType(ContentType.JSON)
.extract();

// then
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());

final GroupRateResponse[] groupRateResponses = response.as(GroupRateResponse[].class);
assertThat(groupRateResponses).hasSize(BirthYearRange.values().length * Gender.values().length);
final long allCount = Arrays.stream(groupRateResponses)
.mapToLong(groupRateResponse -> groupRateResponse.getRate().getRateCount()).sum();
assertThat(allCount).isEqualTo(expectedAllCount);
}

private int getYear(final BirthYearRange birthYearRange) {
return birthYearRange.getStartDate().getYear();
}
Expand Down Expand Up @@ -563,8 +580,7 @@ private SignupRequest createSignupRequest(final int sequence,

private Long insertProduct(final String productName) {
final Product product = DomainUtils.createProductWithoutId(productName, 1000, 100);
productRepository.save(product);
return product.getId();
return productRepository.save(product).getId();
}

private void createProductStats(final Gender gender, final BirthYearRange birthYearRange, final long productId) {
Expand Down