푸드득 - 추가 기능

ChoRong0824·2025년 3월 6일
0

Web

목록 보기
36/51

이전 포스팅이 너무 길어, 추가하게되었습니다.

도움 1, 2

LocalDate와 LocalDateTime의 차이

  • LocalDate
    오직 날짜만을 표현합니다 (년, 월, 일).
    시간, 분, 초, 밀리초 등의 시간 정보는 포함하지 않습니다.
    예를 들어, 생년월일이나 특정 날짜만 필요할 때 사용합니다.

  • LocalDateTime
    날짜와 시간을 모두 표현합니다 (년, 월, 일, 시, 분, 초, 나노초).
    보다 정밀한 시간 정보가 필요할 때 사용합니다.
    이벤트의 정확한 시간이나 로그 데이터의 타임스탬프로 유용하게 쓰입니다.
    간단하게 말하면, LocalDate는 "2024-04-27" 같이 날짜만 필요할 때 사용하고, LocalDateTime은 "2024-04-27T14:00"처럼 특정 일시를 정밀하게 표현할 필요가 있을 때 사용합니다.

minusMonths(1)과 minusDays(1) 차이를 명확히 알아야함.
Days는 말 그대로 일 간 평균을 구하거나 합을 구할때 쓰는거고,
Months는 말 그대로 월 간이다.


지금, 관리자로 회원가입하면 role이 user로 뜬다.
이 부분을 직접 mysql console로
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@example.com'; 이렇게 직접 수정해준다면, 관리자 권한을 갖게 되는 것이다.

{
  "name" : "문성준",
  "email": "mun2@example.com",
  "password": "123456@qwseRRd",
  "role": "ADMIN" // 
}

이렇게 request 보내고 있는데, 이렇게 되면 보안에 있어 상당히 취약점이 드러나게 되는 것이다. 그래서 리팩토링 할 때 개선할 것이다.

서버 오류 발생: Argument [2025-03-05] of type [java.time.LocalDate] did not match parameter type [java.time.LocalDateTime (n/a)]
-> OrderRepository.getToTalSalesSince(LocalDate startDate)의 JPQL 쿼리에서 발생하는 문제임을 확인.
-> 여기서 startDate가 LocalDate 타입인데, OrderRepository의 JPQL 쿼리는 createdAt을 LocalDateTime과 비교하려고 하고 있는중임.
따라서 바꿔주기만 하면 됩니다.

예쓰!

다사다난했다.. 정말.. 후,, 에러까지 다 찍어보고 쉽지않은 길이었습니다..
그래도 해결해서 나이쒀.~ 그리고 이번에 에러를 만나면서 깨우친거 1개 있습니다.

글로벌 익셉션은 GOD 그 자체다! 글로벌 익셉션은 꼭 활용하자!!!!

package com.example.foodduck.admin.controller;

import com.example.foodduck.admin.service.AdminDashboardService;
import com.example.foodduck.admin.service.AdminReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;


import java.util.Map;

/**
 * 관리자 통계 api
 * @author 문성준
 * @version 25.03.06.13:51
 * 추가적으로, 메뉴 판매량 통계 구현 (쿠폰 및 day 할인을 위함)
 */
@RestController
@RequestMapping("/admin") // /admin/dashboard 고민중.
@RequiredArgsConstructor
//TODO: 공부해보기, @PreAuthorize("hasRole('ROLE_ADMIN')") : https://velog.io/@kwj1830/codestates29, https://au1802.tistory.com/74, https://chae528.tistory.com/75
public class AdminDashboardController {

    private final AdminDashboardService adminDashboardService;
    private final AdminReviewService adminReviewService;

    @GetMapping("/dashboard/orders")
    public Map<String, Object> getOrderStatistics(@RequestParam String period) {
        return adminDashboardService.getOrderStatistics(period);
    }

    @GetMapping("/dashboard/sales")
    public Map<String, Object> getSalesStatistics(@RequestParam String period) {
        return adminDashboardService.getSalesStatistics(period);
    }

    @GetMapping("/dashboard/menus")
    public Map<String, Object> getMenuSalesStatistics(@RequestParam String period) {
        return adminDashboardService.getMenuSalesStatistics(period);
    }
      // 리뷰 삭제
//    @DeleteMapping("/reviews/{reviewId}")
//    public void deleteReview(@PathVariable Long reviewId) {
//        adminReviewService.deleteReview(reviewId);
//    }

}
package com.example.foodduck.admin.service;

import com.example.foodduck.menu.repository.MenuRepository;
import com.example.foodduck.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 일간/월간 판매된 판매된 메뉴의 단위의 수를 확인하고, 추후에 쿠폰 발급이나, 할인에 쓸거임.
 */
@Service
@RequiredArgsConstructor
public class AdminDashboardService {
    private final OrderRepository orderRepository;
    private final MenuRepository menuRepository;
    // private final ReviewRepository reviewRepository; -> 리뷰 완성되면 바로 추ㅏㄱ

    // 통계 : Statistics
    public Map<String, Object> getOrderStatistics(String period) {
        LocalDateTime startDate = calculateStartDate(period);
        long orderCount = orderRepository.countByCreatedAtAfter(startDate);

        Map<String, Object> response = new HashMap<>();
        response.put("period", period);
        response.put("orderCount", orderCount);
        return response;
    }

    // 일/월 간 통계를 구하기 위해 날짜 기준을 반환함.
    private LocalDateTime calculateStartDate(String period) {
        if ("month".equalsIgnoreCase(period)) {
            return LocalDateTime.of(LocalDate.now().minusMonths(1), LocalTime.MIN); //minusDays(1);일/ 월간 구분 되는 로직 생각하면서 짜야함.
        }
        return LocalDateTime.of(LocalDate.now().minusDays(1), LocalTime.MIN);
    }

    // 일/월 간 매출 통계 조회
    public Map<String, Object> getSalesStatistics(String period) {
        LocalDateTime startDate = calculateStartDate(period);
        Long totalSales = orderRepository.getToTalSalesSince(startDate);

        Map<String, Object> response = new HashMap<>();
        response.put("period", period);
        // edit: 25.03.06 18:13 ㅋㅋㅋ 바보같이.. 왜 빨간줄뜨지 하면서 멍하니 있다ㅋㅋㅋ Long으로 선언하여 null을 체크할 수 있도록 수정.
        response.put("totalSales", (totalSales != null) ? totalSales : 0L); // null 방지해서 0L 사용
        return response;
    }

    // 일/월 간 메뉴별 판매량
    public Map<String, Object> getMenuSalesStatistics(String period) {
        LocalDateTime startDate = calculateStartDate(period);
        Map<String, Long> menuSales = menuRepository.getMenuSalesSince(startDate);

        Map<String, Object> response = new HashMap<>();
        response.put("period", period);
        response.put("menuSales", menuSales != null ? menuSales : new HashMap<>()); // 원래는 menu sales 만 넣었는데, 왼쪽으로 수정. null 방지하여 빈 map 반환할 수 있또록.
        return response;
    }

//    // 악성 리뷰 제거 기능 -> 리뷰 리포지토리 완성되면 추가 할 예정
//    public void deleteReview(Long reviewId) {
//        reviewRepository.deleteById(reviewId);
//    }

}
package com.example.foodduck.admin.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

/**
 *  가게 리뷰 삭제
 *  @author 문성준
 *  @version 25.03.06.13:51
 *  악성 리뷰에 대해 관리자만 삭제 가능하도록.
 */
@Service
@RequiredArgsConstructor
public class AdminReviewService {
//    private final ReviewRepository reviewRepository;
//
//    public void deleteReview(Long reviewId) {
//        reviewRepository.deleteById(reviewId);
//    }
}
package com.example.foodduck.exception;

import jakarta.persistence.EntityNotFoundException;
import org.apache.coyote.BadRequestException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
    }

    /*
    03.06.18:28, 관리자 대시보드 테스트 시, 500eroor 따라서, 뭐 때문인지 찍어보려고 수정.
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleException(Exception ex) {
        Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
        logger.error("서버 오류 발생: ", ex); //로그에 예외 메시지 출력
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("서버 오류 발생: " + ex.getMessage());
    }

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<String> handleEntityNotFoundException(EntityNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
    }

    @ExceptionHandler(BadRequestException.class)
    public ResponseEntity<String> handleBadRequestException(BadRequestException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
    }

    // 주문 시간 외의 주문에 대한 예외처리: 403
    @ExceptionHandler(OutOfOrderTimeException.class)
    public ResponseEntity<String> handleOrderTimeException(OutOfOrderTimeException ex) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ex.getMessage());
    }

    // 가게 최소 주문 금액 미만 주문에 대한 예외처리: 400
    @ExceptionHandler(MinimumOrderAmountException.class)
    public ResponseEntity<String> handleMinimumOrderAmountException(MinimumOrderAmountException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<List<String>> handleException(MethodArgumentNotValidException ex) {
        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
        List<String> fieldErrorList = fieldErrors.stream()
                .map(FieldError::getDefaultMessage)  // 각 필드의 오류 메시지를 가져온다.
                .toList();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(fieldErrorList);
    }
}
package com.example.foodduck.menu.repository;

import com.example.foodduck.menu.entity.Menu;
import com.example.foodduck.store.entity.Store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

public interface MenuRepository extends JpaRepository<Menu, Long> {
    List<Menu> findByStore(Store store);

    /**
     * @author 문성준
     * @version 25.03.06 16:32
     * 특정 기간 동안 메뉴별 판매량
     */
//    @Query("SELECT m.menuName, COUNT(o) " +
//            "FROM Order o JOIN o.menu m " +
//            "WHERE o.createdAt >= :date " +
//            "GROUP BY m.menuName")

    /*
    코드 수정, order 주문 할 때, 수량을 입력하지 않고 그냥 userId, storeId, menuId만 요청하는 것을 확인.
    따라서,  Order가 REQUESTED 상태인 경우만 판매량에 포함하도록 수정함.
    즉, 주문 건수에 대해서만 계산 가능함. quantity가 없기 때문에.
    */

    // LocalDateTime 사용으로 -> 일부 수정
    @Query("SELECT m.menuName, COUNT(o) " +
                "FROM Order o JOIN o.menu m " +
                "WHERE o.createdAt >= :startDate AND o.orderStatus = 'REQUESTED' " +
                "GROUP BY m.menuName")
    Map<String, Long> getMenuSalesSince(@Param("date") LocalDateTime startDate);
}
package com.example.foodduck.order.repository;

import com.example.foodduck.order.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * 주문 기능 쿼리를 DB로 보내 실행하는 인터페이스
 * @author 이호수
 * @version JPA repository 를 상속받아 기본 기능 사용
 */

public interface OrderRepository extends JpaRepository<Order, Long> {
    /**
     * @author 문성준
     * @version 25.03.06 17:36
     * 특정 상태의 일/월간 주문 개수 조회 및 특정 기간 동안 주문 총액 계산하기 위해서 추가.
     */
    // @Query("SELECT COUNT(o) FROM Order o WHERE o.createdAt >= :startDate AND o.orderStatus = :status")
    // 특정 상태(REQUESTED)의 일/월 간 주문 개수 조회 -> 일단 계산해야하니 개수 조회로 코드 수정.
    @Query("SELECT COUNT(o) FROM Order o WHERE o.createdAt >= :startDate AND o.orderStatus = 'REQUESTED'")
    long countByCreatedAtAfter(LocalDateTime startDate);



    //@Query("SELECT SUM(o.quantity * m.price) FROM Order o JOIN o.menu m WHERE o.createdAt >= :startDate")
    /*
    quantity 필드가 없으므로, SUM(o.quantity * m.price)을 사용 못함을 판단.
    따라서, 특정 상태(REQUESTED)의 주문 개수 조회로 수정해야 할 것 같음.
     */
    // 메뉴 가격은 반영 되고, 특정 기간 동안 주문 총액을 계산함.
    @Query("SELECT SUM(m.price) FROM Order o JOIN o.menu m WHERE o.createdAt >= :startDate AND o.orderStatus = 'REQUESTED'")
    Long getToTalSalesSince(@Param("startDate") LocalDateTime startDate);
}

테스트 코드 작성ㅅ ㅣ작

(수정 전 코드) -> 학습용

package com.example.foodduck.user;

import com.example.foodduck.user.dto.request.UserJoinRequest;
import com.example.foodduck.user.dto.response.UserResponse;
import com.example.foodduck.user.entity.User;
import com.example.foodduck.user.entity.UserRole;
import com.example.foodduck.user.repository.UserRepository;
import com.example.foodduck.user.service.UserService;
import nl.altindag.log.LogCaptor;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.lang.reflect.Field;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.util.ReflectionTestUtils.setField;

/**
 * Junit5, Mockito, Assertions(assertj), LogCaptor
 * logcaptor를 사용하여 예외 메시지 검증
 */
//@ExtendWith(SpringExtension.class) // https://velog.io/@geunwoobaek/Spring-Junit5-Test%EC%A0%95%EB%A6%AC
// 공식문서 : https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/junit/jupiter/SpringExtension.html
// 현재 최신 버전 @SpringBootTest 대신 @ExtendWith(MockitoExtension.class) 적용
// @SpringBootTest
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    // https://giron.tistory.com/115 , https://agi1004.tistory.com/66, https://tall-developer.tistory.com/44
    private UserService userService;

    private LogCaptor logCaptor;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        logCaptor = LogCaptor.forClass(UserService.class);
    }

    /**
     * 회원가입 시, 유효한 유저 정보가 저장되는지 확인
     */
    @Test
    void 회원가입_성공() throws Exception {
        /*
        UserJoinRequest request = new UserJoinRequest("테스트1", "test@ex.com", "pw12345678", "USER");
        joinRequest 보니까, NoArgs어노테이션은 있는데 모든 필드를 받는 생성자는 없었음.
        -> 직접 생성하거나 어노테이션 쓰면 됨. 테스트 해보려면 모든 필드를 받으면서하는게 좋음.
        따라서 코드 수정 하기 전에 고민 해봄.
        테스트만을 위해서 원래 없던 어노테이션인 @AllArgsConstructor 를 추가하는게 과연 올바른 방식일까 ?
        이거 말곤 없는 것일까? 이래도 괜찮은 방식일까? 라는 생각을 하게 되었다.
        나는 "경우에 따라 다르다"고 생각된다. test를 위해 원래 구현되어있던 코드를 수정하는 것은 지양하는 것은 기억이 난다.
        따라서, UserJoinRequest 에는 생성자가 없기 때문에, new User JoinRequest는 못 쓰니까,
        해당 방법은 깔끔히 버림. 근데, 구현된 코드를 수정하지 않고 테스트를 작성해야하니까,
        테스트에서 리플렉션을 활용하여 필드를 설정하는 방법으로 진행.
        즉, 객체를 new 키워드로 생성하지 않고, setAccessible(true)를 활용한 필드 직접 주입
        참고: https://jaeseo0519.tistory.com/267
         */
        //given
        UserJoinRequest userJoinRequest = createUserJoinRequest("test", "test@test.com", "test12345678", "USER");
        when(userRepository.existsByEmail(userJoinRequest.getEmail())).thenReturn(false);
        when(passwordEncoder.encode(userJoinRequest.getPassword())).thenReturn("암호화된pw");

        User mockUser = new User("test", "test@test.com", "암호화된pw", UserRole.USER);
        when(userRepository.save(any(User.class))).thenReturn(mockUser);

        //doAnswer(invocation -> invocation.getArgument(0)).when(userRepository).save(any(User.class));
        /*
        doAnswer(invocation -> invocation.getArgument(0))
                .when(userRepository)
                .save(any(User.class));
        */

        //when
        UserResponse savedUser = userService.registerUser(userJoinRequest);

        //then
        Assertions.assertThat(savedUser).isNotNull();
        Assertions.assertThat(savedUser.getEmail()).isEqualTo(userJoinRequest.getEmail());
        // reponse 확인해보니,         this.role = user.getRole().name();
        Assertions.assertThat(savedUser.getRole()).isEqualTo("USER");
        // Assertions.assertThat(savedUser.getRole()).isEqualTo(UserRole.valueOf(userJoinRequest.getRole().toUpperCase()));
        //UserService.registerUser()에서 UserRole이 String으로 저장되어 있고,
        //테스트 코드에서 UserRole.USER(Enum)과 "USER"(String)을 비교하고 있음.
        // isEqualTo(UserRole.USER)이런 식으로 넣으면, String으로 비교하기 때문에 수정.

        verify(userRepository, times(1)).save(any(User.class));

        Assertions.assertThat(logCaptor.getInfoLogs()).contains("회원가입 성공: " + userJoinRequest.getEmail());

    }

    /**
     * UserJoinRequest 객체 생성하여 필드 값을 직접 설정해서 돌아가게끔 하려고 해당 메서드 구현
     */
    private UserJoinRequest createUserJoinRequest(String name, String email, String password, String role) throws Exception {
        UserJoinRequest request = new UserJoinRequest();
        setField(request, "name", name);
        setField(request, "email", email);
        setField(request, "password", password);
        setField(request, "role", role);
        return request;
    }

    /**
     * 리플렉션을 사용하여 private 필드 값을 설정하는 메서드
     */
    private void setField(Object target, String fieldName, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }

    /**
     * 회원가입 시 이메일 중복 체크 예외 발생 확인 + 로그 검증
     */
    @Test
    void 회원가입_실패_이메일중복() throws Exception {
        // Given
        UserJoinRequest request = createUserJoinRequest("테스트유저", "test@ex.com", "Password@123", "USER");
        when(userRepository.existsByEmail(request.getEmail())).thenReturn(true);

        // When & Then
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
            userService.registerUser(request);
        });

        Assertions.assertThat(exception.getMessage()).isEqualTo("이미 존재하는 이메일입니다");

        Assertions.assertThat(logCaptor.getErrorLogs()).contains("회원가입 실패: 이미 존재하는 이메일입니다");
    }

}
profile
백엔드를 지향하며, 컴퓨터공학과를 졸업한 취준생입니다. 많이 부족하지만 열심히 노력해서 실력을 갈고 닦겠습니다. 부족하고 틀린 부분이 있을 수도 있지만 이쁘게 봐주시면 감사하겠습니다. 틀린 부분은 댓글 남겨주시면 제가 따로 학습 및 자료를 찾아봐서 제 것으로 만들도록 하겠습니다. 귀중한 시간 방문해주셔서 감사합니다.

0개의 댓글