diff --git a/src/main/java/everymeal/server/global/aop/log/LogAspect.java b/src/main/java/everymeal/server/global/aop/log/LogAspect.java new file mode 100644 index 0000000..8d2879b --- /dev/null +++ b/src/main/java/everymeal/server/global/aop/log/LogAspect.java @@ -0,0 +1,42 @@ +package everymeal.server.global.aop.log; + + +import everymeal.server.global.exception.ApplicationException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class LogAspect { + + private LogTrace logTrace; + + @Autowired + public LogAspect(LogTrace logTrace) { + this.logTrace = logTrace; + } + + @Around("everymeal.server.global.aop.log.Pointcuts.allService()") + public Object executingTimeLog(ProceedingJoinPoint joinPoint) throws Throwable { + TraceInfo traceInfo = null; + try { + traceInfo = logTrace.start(joinPoint.getSignature().toShortString()); + Object result = joinPoint.proceed(); + logTrace.end(traceInfo); + return result; + } catch (ApplicationException e) { + if (traceInfo != null) { + logTrace.apiException(e, traceInfo); + } + throw e; + } catch (Exception e) { + if (traceInfo != null) { + logTrace.exception(e, traceInfo); + } + throw e; + } + } +} diff --git a/src/main/java/everymeal/server/global/aop/log/LogTrace.java b/src/main/java/everymeal/server/global/aop/log/LogTrace.java new file mode 100644 index 0000000..abcdf95 --- /dev/null +++ b/src/main/java/everymeal/server/global/aop/log/LogTrace.java @@ -0,0 +1,89 @@ +package everymeal.server.global.aop.log; + + +import everymeal.server.global.exception.ApplicationException; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class LogTrace { + + private ThreadLocal threadId = ThreadLocal.withInitial(this::createThreadId); + + public TraceInfo start(String method) { + syncTrace(); + String id = threadId.get(); + long startTime = System.currentTimeMillis(); + logger().info("[" + id + "] " + method + " ==== start"); + return new TraceInfo(id, method, startTime); + } + + public void end(TraceInfo traceInfo) { + long endTime = System.currentTimeMillis(); + long resultTime = endTime - traceInfo.startTime(); + if (resultTime >= 1000) + logger().warn( + "[" + + traceInfo.theadId() + + "] " + + traceInfo.method() + + " ==== execute time = " + + resultTime + + "ms"); + else + logger().info( + "[" + + traceInfo.theadId() + + "] " + + traceInfo.method() + + " ==== execute time = " + + resultTime + + "ms"); + removeThreadLocal(); + } + + public void apiException(ApplicationException e, TraceInfo traceInfo) { + logger().error( + "[" + + traceInfo.theadId() + + "] " + + traceInfo.method() + + " ==== API EXCEPTION! [" + + e.getErrorCode() + + "] " + + e.getMessage()); + removeThreadLocal(); + } + + public void exception(Exception e, TraceInfo traceInfo) { + logger().error( + "[" + + traceInfo.theadId() + + "] " + + traceInfo.method() + + " ==== INTERNAL ERROR! " + + e.getMessage()); + removeThreadLocal(); + } + + private void syncTrace() { + String id = threadId.get(); + if (id == null) { + threadId.set(createThreadId()); + } + } + + private String createThreadId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + private void removeThreadLocal() { + threadId.remove(); + } + + private Logger logger() { + return LoggerFactory.getLogger(LogTrace.class); + } +} diff --git a/src/main/java/everymeal/server/global/aop/log/Pointcuts.java b/src/main/java/everymeal/server/global/aop/log/Pointcuts.java new file mode 100644 index 0000000..48f378c --- /dev/null +++ b/src/main/java/everymeal/server/global/aop/log/Pointcuts.java @@ -0,0 +1,17 @@ +package everymeal.server.global.aop.log; + + +import org.aspectj.lang.annotation.Pointcut; + +public class Pointcuts { + + @Pointcut("execution(* everymeal.server.meal.*.*(..))") + public void all() {} + + @Pointcut("execution(* everymeal.server..*Service.*(..))") + public void allService() {} + + @Pointcut( + "execution(* everymeal.server..*Repository.*(..)) || execution(* everymeal.server..*RepositoryImpl.*(..))") + public void allQuery() {} +} diff --git a/src/main/java/everymeal/server/global/aop/log/TraceInfo.java b/src/main/java/everymeal/server/global/aop/log/TraceInfo.java new file mode 100644 index 0000000..87a4f3e --- /dev/null +++ b/src/main/java/everymeal/server/global/aop/log/TraceInfo.java @@ -0,0 +1,3 @@ +package everymeal.server.global.aop.log; + +public record TraceInfo(String theadId, String method, Long startTime) {} diff --git a/src/main/java/everymeal/server/global/config/QueryDslConfig.java b/src/main/java/everymeal/server/global/config/QueryDslConfig.java index b2fbe09..fe42287 100644 --- a/src/main/java/everymeal/server/global/config/QueryDslConfig.java +++ b/src/main/java/everymeal/server/global/config/QueryDslConfig.java @@ -1,6 +1,7 @@ package everymeal.server.global.config; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -14,6 +15,6 @@ public class QueryDslConfig { @Bean public JPAQueryFactory jpaQueryFactory(EntityManager em) { - return new JPAQueryFactory(em); + return new JPAQueryFactory(JPQLTemplates.DEFAULT, em); } } diff --git a/src/main/java/everymeal/server/global/exception/ExceptionList.java b/src/main/java/everymeal/server/global/exception/ExceptionList.java index 86e5f18..2d6abb8 100644 --- a/src/main/java/everymeal/server/global/exception/ExceptionList.java +++ b/src/main/java/everymeal/server/global/exception/ExceptionList.java @@ -12,7 +12,7 @@ public enum ExceptionList { RESTAURANT_NOT_FOUND("M0002", HttpStatus.NOT_FOUND, "등록된 식당이 아닙니다."), UNIVERSITY_NOT_FOUND("M0003", HttpStatus.NOT_FOUND, "등록된 학교가 아닙니다."), INVALID_MEAL_OFFEREDAT_REQUEST( - "M0004", HttpStatus.BAD_REQUEST, "등록되어 있는 식단 데이터 보다 과거의 날짜로 등록할 수 없습니다."), + "M0004", HttpStatus.BAD_REQUEST, "동일한 데이터를 갖는 식단 데이터가 이미 존재합니다."), INVALID_REQUEST("R0001", HttpStatus.BAD_REQUEST, "Request의 Data Type이 올바르지 않습니다."), USER_NOT_FOUND("U0001", HttpStatus.NOT_FOUND, "등록된 유저가 아닙니다."), diff --git a/src/main/java/everymeal/server/meal/controller/MealController.java b/src/main/java/everymeal/server/meal/controller/MealController.java index 5d67925..a59ccbb 100644 --- a/src/main/java/everymeal/server/meal/controller/MealController.java +++ b/src/main/java/everymeal/server/meal/controller/MealController.java @@ -97,7 +97,9 @@ public ApplicationResponse> getDayMeal( /** * 주간 단위 식단 조회 API * - * @param + * @param restaurantIdx 식당 아이디 + * @param offeredAt 조회날짜 + * @author dldmsql */ @GetMapping("/week") @Operation(summary = "주간 식단 조회") @@ -108,6 +110,6 @@ public ApplicationResponse> getWeekMeal( description = "조회하고자 하는 시작 날짜 ( yyyy-MM-dd )", defaultValue = "2023-10-01") String offeredAt) { - return ApplicationResponse.ok(mealService.getWeekMealList(restaurantIdx, offeredAt)); + return ApplicationResponse.ok(mealService.getWeekMealListTest(restaurantIdx, offeredAt)); } } diff --git a/src/main/java/everymeal/server/meal/controller/dto/request/MealRegisterReq.java b/src/main/java/everymeal/server/meal/controller/dto/request/MealRegisterReq.java index 58f06e6..d655a53 100644 --- a/src/main/java/everymeal/server/meal/controller/dto/request/MealRegisterReq.java +++ b/src/main/java/everymeal/server/meal/controller/dto/request/MealRegisterReq.java @@ -2,17 +2,28 @@ import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; public record MealRegisterReq( @Schema( description = "메뉴를 ',' 구분자를 기준으로 묶어서 하나의 문자열로 보내주세요.", defaultValue = "갈비탕, 깍두기, 흰쌀밥") + @NotBlank String menu, - @Schema(description = "식사 분류 ( 조식 | 중식 | 석식 | 특식 ) ENUM으로 관리합니다.", defaultValue = "LUNCH") + @Schema( + description = "식사 분류 ( BREAKFAST | LUNCH | DINNER ) ENUM으로 관리합니다.", + defaultValue = "LUNCH") + @NotBlank String mealType, - @Schema(description = "식사 운영 상태 ( 운영 | 미운영 | 단축운영 )", defaultValue = "OPEN") + @Schema(description = "식사 운영 상태 ( OPEN | CLOSED | SHORT_OPEN )", defaultValue = "OPEN") String mealStatus, - @Schema(description = "식사 제공 날짜 ( yyyy-MM-dd ) ", defaultValue = "2023-10-01") - LocalDate offeredAt, - @Schema(description = "가격을 Double 형태로 관리합니다.", defaultValue = "10000.0") Double price) {} + @Schema(description = "식사 제공 날짜 ( yyyy-MM-dd ) ", defaultValue = "2023-10-01") @NotBlank + Instant offeredAt, + @Schema(description = "가격을 Double 형태로 관리합니다.", defaultValue = "0.0") Double price, + @Schema( + description = + "음식의 카테고리 ( DEFAULT | KOREAN | JAPANESE | CHINESE | SNACKBAR | WESTERN ) 중 단일 메뉴라면 DEFAULT를 입력해주세요.", + defaultValue = "DEFAULT") + @NotBlank + String category) {} diff --git a/src/main/java/everymeal/server/meal/controller/dto/response/DayMealListGetRes.java b/src/main/java/everymeal/server/meal/controller/dto/response/DayMealListGetRes.java index 1038239..9c76901 100644 --- a/src/main/java/everymeal/server/meal/controller/dto/response/DayMealListGetRes.java +++ b/src/main/java/everymeal/server/meal/controller/dto/response/DayMealListGetRes.java @@ -2,15 +2,18 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import everymeal.server.meal.entity.Meal; +import everymeal.server.meal.entity.MealCategory; import everymeal.server.meal.entity.MealStatus; import everymeal.server.meal.entity.MealType; -import java.time.LocalDate; -import java.util.List; +import java.time.Instant; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; +@NoArgsConstructor +@AllArgsConstructor @Getter @Setter @Builder @@ -21,21 +24,9 @@ public class DayMealListGetRes { private MealStatus mealStatus; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") - private LocalDate offeredAt; + private Instant offeredAt; private Double price; - - public static List of(List mealList) { - return mealList.stream() - .map( - meal -> - DayMealListGetRes.builder() - .menu(meal.getMenu()) - .mealType(meal.getMealType()) - .mealStatus(meal.getMealStatus()) - .offeredAt(meal.getOfferedAt()) - .price(meal.getPrice()) - .build()) - .toList(); - } + private MealCategory category; + private String restaurantName; } diff --git a/src/main/java/everymeal/server/meal/controller/dto/response/WeekMealListGetRes.java b/src/main/java/everymeal/server/meal/controller/dto/response/WeekMealListGetRes.java index 3ec0a37..60dc2f4 100644 --- a/src/main/java/everymeal/server/meal/controller/dto/response/WeekMealListGetRes.java +++ b/src/main/java/everymeal/server/meal/controller/dto/response/WeekMealListGetRes.java @@ -2,41 +2,28 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Comparator; +import com.querydsl.core.annotations.QueryProjection; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter +@NoArgsConstructor @Builder public class WeekMealListGetRes { @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") - private LocalDate offeredAt; + private Instant offeredAt; - private List dayMealListGetResList; + private List dayMealListGetResListTest; - public static List of(List mealListGetRes) { - // offeredAt을 기준으로 DayMealList 그룹핑 - Map> groupedData = - mealListGetRes.stream() - .collect(Collectors.groupingBy(DayMealListGetRes::getOfferedAt)); - List weekMealList = new ArrayList<>(); - for (Map.Entry> entry : groupedData.entrySet()) { - WeekMealListGetRes weekMeal = - WeekMealListGetRes.builder() - .offeredAt(entry.getKey()) - .dayMealListGetResList(entry.getValue()) - .build(); - weekMealList.add(weekMeal); - } - // offeredAt을 기준으로 오름차순 정렬 - weekMealList.sort(Comparator.comparing(WeekMealListGetRes::getOfferedAt)); - return weekMealList; + @QueryProjection + public WeekMealListGetRes( + Instant offeredAt, List dayMealListGetResListTest) { + this.offeredAt = offeredAt; + this.dayMealListGetResListTest = dayMealListGetResListTest; } } diff --git a/src/main/java/everymeal/server/meal/entity/Meal.java b/src/main/java/everymeal/server/meal/entity/Meal.java index a0802d7..3d4b0ef 100644 --- a/src/main/java/everymeal/server/meal/entity/Meal.java +++ b/src/main/java/everymeal/server/meal/entity/Meal.java @@ -7,15 +7,18 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.time.LocalDate; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.Instant; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter -@Table +@Table(indexes = {@Index(name = "idx__mealType__offeredAt", columnList = "mealType, offeredAt")}) @Entity @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) public class Meal { @@ -32,10 +35,14 @@ public class Meal { @Enumerated(EnumType.STRING) private MealStatus mealStatus; - private LocalDate offeredAt; + @Temporal(TemporalType.TIMESTAMP) + private Instant offeredAt; private Double price; + @Enumerated(EnumType.STRING) + private MealCategory category; + @ManyToOne private Restaurant restaurant; @Builder @@ -43,14 +50,16 @@ public Meal( String menu, MealType mealType, MealStatus mealStatus, - LocalDate offeredAt, + Instant offeredAt, Double price, + MealCategory category, Restaurant restaurant) { this.menu = menu; this.mealType = mealType; this.mealStatus = mealStatus; this.offeredAt = offeredAt; this.price = price; + this.category = category; this.restaurant = restaurant; } } diff --git a/src/main/java/everymeal/server/meal/entity/MealCategory.java b/src/main/java/everymeal/server/meal/entity/MealCategory.java new file mode 100644 index 0000000..9324b6a --- /dev/null +++ b/src/main/java/everymeal/server/meal/entity/MealCategory.java @@ -0,0 +1,21 @@ +package everymeal.server.meal.entity; + + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public enum MealCategory { + DEFAULT("단일메뉴"), + KOREAN("한식"), + JAPANESE("일식"), + CHINESE("중식"), + WESTERN("양식"), + SNACKBAR("분식"); + private String value; + + MealCategory(String value) { + this.value = value; + } +} diff --git a/src/main/java/everymeal/server/meal/entity/MealStatus.java b/src/main/java/everymeal/server/meal/entity/MealStatus.java index 3c5bf45..7c4754f 100644 --- a/src/main/java/everymeal/server/meal/entity/MealStatus.java +++ b/src/main/java/everymeal/server/meal/entity/MealStatus.java @@ -12,9 +12,9 @@ public enum MealStatus { SHORT_OPEN("단축운영"), ; - private String name; + private String value; - MealStatus(String name) { - this.name = name; + MealStatus(String value) { + this.value = value; } } diff --git a/src/main/java/everymeal/server/meal/entity/MealType.java b/src/main/java/everymeal/server/meal/entity/MealType.java index cef97d5..26dfa49 100644 --- a/src/main/java/everymeal/server/meal/entity/MealType.java +++ b/src/main/java/everymeal/server/meal/entity/MealType.java @@ -7,15 +7,14 @@ @Getter @RequiredArgsConstructor public enum MealType { - BREAKFAST("조식"), - LUNCH("중식"), - DINNER("석식"), - SPECIAL("특식"), + BREAKFAST("아침"), + LUNCH("점심"), + DINNER("저녁"), ; - private String name; + private String value; - MealType(String name) { - this.name = name; + MealType(String value) { + this.value = value; } } diff --git a/src/main/java/everymeal/server/meal/repository/MealRepositoryCustom.java b/src/main/java/everymeal/server/meal/repository/MealRepositoryCustom.java index cddef4e..0dc0d2d 100644 --- a/src/main/java/everymeal/server/meal/repository/MealRepositoryCustom.java +++ b/src/main/java/everymeal/server/meal/repository/MealRepositoryCustom.java @@ -1,18 +1,22 @@ package everymeal.server.meal.repository; -import everymeal.server.meal.controller.dto.request.MealRegisterReq; +import everymeal.server.meal.controller.dto.response.DayMealListGetRes; +import everymeal.server.meal.controller.dto.response.WeekMealListGetRes; import everymeal.server.meal.entity.Meal; -import java.time.LocalDate; +import everymeal.server.meal.entity.MealType; +import everymeal.server.meal.entity.Restaurant; +import java.time.Instant; import java.util.List; import org.springframework.stereotype.Repository; @Repository public interface MealRepositoryCustom { - List findAllByAfterOfferedAt(MealRegisterReq mealRegisterReq, Long restaurantIdx); + List findAllByOfferedAtOnDateAndMealType( + Instant offeredAt, MealType mealType, Restaurant restaurant); - List findAllByOfferedAt(LocalDate offeredAt, Long restaurantIdx); + List findAllByOfferedAtOnDate(Instant offeredAt, Restaurant restaurant); - List findAllByBetweenOfferedAtAndEndedAt( - LocalDate startedAt, LocalDate endedAt, Long restaurantIdx); + List getWeekMealList( + Restaurant restaurant, Instant mondayInstant, Instant sundayInstant); } diff --git a/src/main/java/everymeal/server/meal/repository/MealRepositoryImpl.java b/src/main/java/everymeal/server/meal/repository/MealRepositoryImpl.java index 7f87eae..7cd9bfe 100644 --- a/src/main/java/everymeal/server/meal/repository/MealRepositoryImpl.java +++ b/src/main/java/everymeal/server/meal/repository/MealRepositoryImpl.java @@ -1,70 +1,205 @@ package everymeal.server.meal.repository; +import static com.querydsl.core.group.GroupBy.groupBy; +import static everymeal.server.meal.entity.QMeal.meal; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; -import everymeal.server.meal.controller.dto.request.MealRegisterReq; +import everymeal.server.meal.controller.dto.response.DayMealListGetRes; +import everymeal.server.meal.controller.dto.response.WeekMealListGetRes; import everymeal.server.meal.entity.Meal; +import everymeal.server.meal.entity.MealCategory; +import everymeal.server.meal.entity.MealStatus; +import everymeal.server.meal.entity.MealType; import everymeal.server.meal.entity.QMeal; -import everymeal.server.meal.entity.QRestaurant; -import java.time.LocalDate; +import everymeal.server.meal.entity.Restaurant; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class MealRepositoryImpl implements MealRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; - + /** + * ============================================================================================ + * GLOBAL STATIC CONSTANTS + * ============================================================================================= + */ + private final String UN_REGISTERED_MEAL = "등록된 식단이 없습니다."; + /** + * ============================================================================================ + * 요청 DTO 내의 제공일자 이후의 데이터가 존재하는 지를 확인합니다.
+ * + * @param offeredAt 제공일자 + * @param mealType 식사구분 ( 조식/중식/석식 ) + * @param restaurant 학생식당 + * @return List + * ========================================================================================= + */ @Override - public List findAllByAfterOfferedAt(MealRegisterReq mealRegisterReq, Long restaurantIdx) { + public List findAllByOfferedAtOnDateAndMealType( + Instant offeredAt, MealType mealType, Restaurant restaurant) { + QMeal qMeal = meal; var queryResult = jpaQueryFactory - .select(QMeal.meal) - .from(QMeal.meal) - .leftJoin(QMeal.meal.restaurant, QRestaurant.restaurant) - .on(QRestaurant.restaurant.idx.eq(restaurantIdx)) - .where(isAfterOfferedAt(mealRegisterReq.offeredAt())) + .selectFrom(qMeal) + .where( + qMeal.restaurant.eq(restaurant), + isEqOfferedAt(offeredAt), + isEqMealType(mealType)) .fetch(); return queryResult; } + /** + * ============================================================================================ + * 일별 식사구분에 따른 식사 데이터 조회
+ * + * @param offeredAt 제공일자 + * @param restaurant 식당 + * @return List + * ========================================================================================= + */ @Override - public List findAllByOfferedAt(LocalDate offeredAt, Long restaurantIdx) { + public List findAllByOfferedAtOnDate( + Instant offeredAt, Restaurant restaurant) { + QMeal qMeal = meal; var queryResult = jpaQueryFactory - .selectFrom(QMeal.meal) - .leftJoin(QMeal.meal.restaurant, QRestaurant.restaurant) - .on(QRestaurant.restaurant.idx.eq(restaurantIdx)) - .where(isEqOfferedAt(offeredAt)) - .fetch(); - return queryResult; - } + .selectFrom(qMeal) + .where(qMeal.restaurant.eq(restaurant), isEqOfferedAt(offeredAt)) + .transform( + groupBy(qMeal.mealType) + .as( + GroupBy.list( + Projections.constructor( + DayMealListGetRes.class, + qMeal.menu.as("menu"), + qMeal.mealType.as("mealType"), + qMeal.mealStatus.as("mealStatus"), + qMeal.offeredAt.as("offeredAt"), + qMeal.price.as("price"), + qMeal.category.as("category"), + qMeal.restaurant.name.as( + "restaurantName"))))); + List resultList = new ArrayList<>(); + for (MealType mealType : MealType.values()) { + List dayMeals = queryResult.get(mealType); + if (dayMeals == null || dayMeals.isEmpty()) { + resultList.add( + new DayMealListGetRes( + UN_REGISTERED_MEAL, + mealType, + MealStatus.CLOSED, + offeredAt, + 0.0, + MealCategory.DEFAULT, + restaurant.getName())); + } else { + resultList.addAll(dayMeals); + } + } + return resultList; + } + /** + * ============================================================================================ + * 주간 식사 데이터 조회
+ * + * @param restaurant 식당 + * @param mondayInstant 월요일 + * @param sundayInstant 일요일 + * @return List + * ========================================================================================= + */ @Override - public List findAllByBetweenOfferedAtAndEndedAt( - LocalDate startedAt, LocalDate endedAt, Long restaurantIdx) { - var queryResult = + public List getWeekMealList( + Restaurant restaurant, Instant mondayInstant, Instant sundayInstant) { + QMeal qMeal = meal; + Map transform = jpaQueryFactory - .selectFrom(QMeal.meal) - .leftJoin(QMeal.meal.restaurant, QRestaurant.restaurant) - .on(QRestaurant.restaurant.idx.eq(restaurantIdx)) - .where(isBetweenOfferedAt(startedAt, endedAt)) - .orderBy(QMeal.meal.offeredAt.desc(), QMeal.meal.mealType.desc()) - .fetch(); - return queryResult; - } + .selectFrom(qMeal) + .where( + qMeal.restaurant + .eq(restaurant) + .and(qMeal.offeredAt.between(mondayInstant, sundayInstant))) + .transform( + groupBy(qMeal.offeredAt) + .as( + Projections.constructor( + WeekMealListGetRes.class, + qMeal.offeredAt, + GroupBy.list( + Projections.constructor( + DayMealListGetRes.class, + qMeal.menu.as("menu"), + qMeal.mealType.as( + "mealType"), + qMeal.mealStatus.as( + "mealStatus"), + qMeal.offeredAt.as( + "offeredAt"), + qMeal.price.as("price"), + qMeal.category.as( + "category"), + qMeal.restaurant.name.as( + "restaurantName")))))); + Instant currentInstant = mondayInstant; + while (!currentInstant.isAfter(sundayInstant)) { + transform.putIfAbsent( + currentInstant, + new WeekMealListGetRes( + currentInstant, + Arrays.asList( + new DayMealListGetRes( + UN_REGISTERED_MEAL, + MealType.BREAKFAST, + MealStatus.CLOSED, + currentInstant, + 0.0, + MealCategory.DEFAULT, + restaurant.getName()), + new DayMealListGetRes( + UN_REGISTERED_MEAL, + MealType.LUNCH, + MealStatus.CLOSED, + currentInstant, + 0.0, + MealCategory.DEFAULT, + restaurant.getName()), + new DayMealListGetRes( + UN_REGISTERED_MEAL, + MealType.DINNER, + MealStatus.CLOSED, + currentInstant, + 0.0, + MealCategory.DEFAULT, + restaurant.getName())))); + currentInstant = currentInstant.plus(1, ChronoUnit.DAYS); + } - private BooleanExpression isBetweenOfferedAt(LocalDate startedAt, LocalDate endedAt) { - return QMeal.meal.offeredAt.between(startedAt, endedAt); + return transform.keySet().stream().map(transform::get).toList(); } - /** offeredAt 과 동일 한 경우 */ - private BooleanExpression isEqOfferedAt(LocalDate offeredAt) { - return QMeal.meal.offeredAt.eq(offeredAt); + /** 단일 메뉴, 복합 메뉴를 판별 */ + private BooleanExpression isSingleMenu(boolean isSingle) { + return isSingle ? meal.category.eq(MealCategory.DEFAULT) : null; } - /** offeredAt 이후인 경우 */ - private BooleanExpression isAfterOfferedAt(LocalDate offeredAt) { - return QMeal.meal.offeredAt.after(offeredAt); + /** offeredAt 과 동일한 경우 */ + private BooleanExpression isEqOfferedAt(Instant offeredAt) { + Instant startOfDay = offeredAt.truncatedTo(ChronoUnit.DAYS); // 예: 2023-10-01 + Instant endOfDay = startOfDay.plus(1, ChronoUnit.DAYS).minus(1, ChronoUnit.MILLIS); + return QMeal.meal.offeredAt.between(startOfDay, endOfDay); + } + /** mealType 과 동일한 경우 */ + private BooleanExpression isEqMealType(MealType mealType) { + return meal.mealType.eq(mealType); } } diff --git a/src/main/java/everymeal/server/meal/service/MealService.java b/src/main/java/everymeal/server/meal/service/MealService.java index 6d9aa4e..cc28cc9 100644 --- a/src/main/java/everymeal/server/meal/service/MealService.java +++ b/src/main/java/everymeal/server/meal/service/MealService.java @@ -18,5 +18,5 @@ public interface MealService { List getDayMealList(Long restaurantIdx, String offeredAt); - List getWeekMealList(Long restaurantIdx, String offeredAt); + List getWeekMealListTest(Long restaurantIdx, String offeredAt); } diff --git a/src/main/java/everymeal/server/meal/service/MealServiceImpl.java b/src/main/java/everymeal/server/meal/service/MealServiceImpl.java index c5d9d36..aa6eefb 100644 --- a/src/main/java/everymeal/server/meal/service/MealServiceImpl.java +++ b/src/main/java/everymeal/server/meal/service/MealServiceImpl.java @@ -10,6 +10,7 @@ import everymeal.server.meal.controller.dto.response.RestaurantListGetRes; import everymeal.server.meal.controller.dto.response.WeekMealListGetRes; import everymeal.server.meal.entity.Meal; +import everymeal.server.meal.entity.MealCategory; import everymeal.server.meal.entity.MealStatus; import everymeal.server.meal.entity.MealType; import everymeal.server.meal.entity.Restaurant; @@ -18,7 +19,11 @@ import everymeal.server.meal.repository.RestaurantRepository; import everymeal.server.university.entity.University; import everymeal.server.university.repository.UniversityRepository; -import java.time.LocalDate; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -35,14 +40,28 @@ public class MealServiceImpl implements MealService { private final MealRepositoryCustom mealRepositoryCustom; private final UniversityRepository universityRepository; private final RestaurantRepository restaurantRepository; + /** + * ============================================================================================ + * GLOBAL STATIC CONSTANTS + * ============================================================================================= + */ + private final String TIME_PARSING_INFO = "T00:00:00"; + /** + * ============================================================================================ + * 학생 식당 등록
+ * 관리자에 의한 학교 등록이 선행되어야 합니다. University_name, University_campusName 을 구분자로 학교를 식별합니다. + * + * @param restaurantRegisterReq 식당 등록 요청 DTO + * @return true + *

등록된 학교가 없는 경우, + * @throws ApplicationException 404 등록된 학교가 아닙니다.
+ * ========================================================================================= + */ @Override @Transactional public Boolean createRestaurant(RestaurantRegisterReq restaurantRegisterReq) { - /** - * 시나리오 1. 관리자에 의한 학교 등록 ( University ) - 학교 구분키 name & campusName 2. 관리자에 의한 학교별 학생식당 등록 ( - * Restaurant ) - */ + // 학교 조회 University university = universityRepository .findByNameAndCampusNameAndIsDeletedFalse( @@ -52,6 +71,7 @@ public Boolean createRestaurant(RestaurantRegisterReq restaurantRegisterReq) { .findFirst() .orElseThrow( () -> new ApplicationException(ExceptionList.UNIVERSITY_NOT_FOUND)); + // 식당 등록 Restaurant restaurant = Restaurant.builder() .name(restaurantRegisterReq.restaurantName()) @@ -61,6 +81,18 @@ public Boolean createRestaurant(RestaurantRegisterReq restaurantRegisterReq) { return restaurantRepository.save(restaurant).getIdx() != null; } + /** + * ============================================================================================ + * 학식 식단 등록 ( 주간, 하루 모두 등록 가능 ) + * + * @param weekMealRegisterReq 식단 등록 요청 DTO + * @return true + *

식당이 없는 경우, + * @throws ApplicationException 404 존재하지 않는 식당입니다.
+ * REQ 데이터 중 offeredAt, Restaurant, MealType 이 동일한 데이터가 존재한다면, + * @throws ApplicationException 400 등록되어 있는 식단 데이터 보다 과거의 날짜로 등록할 수 없습니다.
+ * ========================================================================================= + */ @Override @Transactional public Boolean createWeekMeal(WeekMealRegisterReq weekMealRegisterReq) { @@ -74,34 +106,49 @@ public Boolean createWeekMeal(WeekMealRegisterReq weekMealRegisterReq) { weekMealRegisterReq .registerReqList() .sort(Comparator.comparing(MealRegisterReq::offeredAt)); - /** - * 조건) 가장 늦은 offeredAt를 기준으로 날짜가 이후인 경우에만 추가할 수 있어야 한다. ( 데이터 간 충돌 방지를 위해서 ) 가정) REQ로 들어온 - * offeredAt(식사제공날짜)가 이미 테이블 내에 포함되어 있다. 행동) REQ 중 가장 빠른 offeredAt을 기준으로 테이블 내에 데이터가 존재하는지 - * 조회 list.size() > 0 존재한다면, 오류 처리 존재하지 않는다면, 테이블에 삽입 - */ - List meals = - mealRepositoryCustom.findAllByAfterOfferedAt( - weekMealRegisterReq.registerReqList().get(0), restaurant.getIdx()); - if (meals.size() > 0) - throw new ApplicationException(ExceptionList.INVALID_MEAL_OFFEREDAT_REQUEST); - // 주간 단위 식단 생성 + // 식단 등록 List mealList = new ArrayList<>(); for (MealRegisterReq req : weekMealRegisterReq.registerReqList()) { - Meal meal = - Meal.builder() - .mealStatus(MealStatus.valueOf(req.mealStatus())) - .mealType(MealType.valueOf(req.mealType())) - .menu(req.menu()) - .restaurant(restaurant) - .price(req.price()) - .offeredAt(req.offeredAt()) - .build(); - mealList.add(meal); + // 제공날짜, 학생식당, 식사분류가 동일한 데이터가 이미 존재하면, 덮어쓰기 불가능 오류 + if (!mealRepositoryCustom + .findAllByOfferedAtOnDateAndMealType( + req.offeredAt(), MealType.valueOf(req.mealType()), restaurant) + .isEmpty()) { + throw new ApplicationException(ExceptionList.INVALID_MEAL_OFFEREDAT_REQUEST); + } else { + Instant iOfferedAt = Instant.from(req.offeredAt()); + MealStatus mealStatus = + req.mealStatus() == null + ? MealStatus.OPEN + : MealStatus.valueOf(req.mealStatus()); + Double price = req.price() == null ? 0.0 : req.price(); + Meal meal = + Meal.builder() + .mealStatus(mealStatus) + .mealType(MealType.valueOf(req.mealType())) + .menu(req.menu()) + .restaurant(restaurant) + .price(price) + .offeredAt(iOfferedAt) + .category(MealCategory.valueOf(req.category())) + .build(); + mealList.add(meal); + } } mealRepository.saveAll(mealList); return true; } - + /** + * ============================================================================================ + * 학생식당 리스트 조회 + * + * @param universityName 학교 한글명 + * @param campusName 캠퍼스 이름 + * @return List + *

학교가 없는 경우, + * @throws ApplicationException 404 존재하지 않는 학교입니다.
+ * ========================================================================================= + */ @Override public List getRestaurantList(String universityName, String campusName) { // 학교 등록 여부 판단 @@ -117,37 +164,85 @@ public List getRestaurantList(String universityName, Strin restaurantRepository.findAllByUniversityAndUseYnTrue(university); return RestaurantListGetRes.of(restaurants); } - + /** + * ============================================================================================ + * 학식 식단 Day 조회
+ * 등록되지 않은 식단 데이터는 아침/점심/저녁 포맷팅에 맞게 응답 데이터를 생성해서 반환합니다. + * + * @param restaurantIdx 식당 아이디 + * @param offeredAt 제공 일자 --- yyyy-MM-dd + * @return List + *

식당이 없는 경우, + * @throws ApplicationException 404 존재하지 않는 식당입니다.
+ * ========================================================================================= + */ @Override public List getDayMealList(Long restaurantIdx, String offeredAt) { + // offeredAt을 LocalDateTime로 바꿉니다. + LocalDateTime ldOfferedAt = LocalDateTime.parse(offeredAt + TIME_PARSING_INFO); + // 학생 식당 등록 여부 판단 Restaurant restaurant = restaurantRepository .findById(restaurantIdx) .orElseThrow( () -> new ApplicationException(ExceptionList.RESTAURANT_NOT_FOUND)); - // REQ offeredAt에 해당하는 식단 조회 - List meals = - mealRepositoryCustom.findAllByOfferedAt( - LocalDate.parse(offeredAt), restaurant.getIdx()); - return DayMealListGetRes.of(meals); - } + // LocalDateTime을 한국 시간 (Asia/Seoul)으로 변환한 다음 Instant로 변환합니다. + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + Instant iOfferedAt = ldOfferedAt.atZone(seoulZoneId).toInstant().plus(1, ChronoUnit.DAYS); + + return mealRepositoryCustom.findAllByOfferedAtOnDate(iOfferedAt, restaurant); + } + /** + * ============================================================================================ + * 학식 식단 Week 조회
+ * 등록되지 않은 식단 데이터는 아침/점심/저녁 포맷팅에 맞게 응답 데이터를 생성해서 반환합니다. + * + * @param restaurantIdx 식당 아이디 + * @param offeredAt 제공 일자 --- yyyy-MM-dd + * @return List + *

식당이 없는 경우, + * @throws ApplicationException 404 존재하지 않는 식당입니다.
+ * ========================================================================================= + */ @Override - public List getWeekMealList(Long restaurantIdx, String offeredAt) { - // 학생 식당 등록 여부 판단 + public List getWeekMealListTest(Long restaurantIdx, String offeredAt) { Restaurant restaurant = restaurantRepository .findById(restaurantIdx) .orElseThrow( () -> new ApplicationException(ExceptionList.RESTAURANT_NOT_FOUND)); - // REQ offeredAt에 해당하는 식단 조회 - LocalDate startedAt = LocalDate.parse(offeredAt); - LocalDate endedAt = startedAt.plusDays(7); - List meals = - mealRepositoryCustom.findAllByBetweenOfferedAtAndEndedAt( - startedAt, endedAt, restaurant.getIdx()); - List dayMealListGetResList = DayMealListGetRes.of(meals); - return WeekMealListGetRes.of(dayMealListGetResList); + + // 현재 날짜와 시간을 가져옵니다. + LocalDateTime ldOfferedAt = LocalDateTime.parse(offeredAt + TIME_PARSING_INFO); + + // 현재 요일을 가져옵니다. + DayOfWeek currentDayOfWeek = ldOfferedAt.getDayOfWeek(); + + // 월요일과 일요일의 날짜를 계산합니다. + LocalDateTime monday; + LocalDateTime sunday; + + if (currentDayOfWeek == DayOfWeek.MONDAY) { + // 현재 요일이 월요일인 경우, 현재 날짜를 월요일로 설정하고 일요일을 6일 후로 설정합니다. + monday = ldOfferedAt; + sunday = ldOfferedAt.plusDays(6); + } else { + // 그 외의 경우, 현재 요일로부터 월요일과 일요일을 계산합니다. + monday = + ldOfferedAt.minusDays( + currentDayOfWeek.getValue() - (long) DayOfWeek.MONDAY.getValue()); + sunday = + ldOfferedAt.plusDays( + DayOfWeek.SUNDAY.getValue() - (long) currentDayOfWeek.getValue()); + } + + // LocalDateTime을 한국 시간 (Asia/Seoul)으로 변환한 다음 Instant로 변환합니다. + ZoneId seoulZoneId = ZoneId.of("Asia/Seoul"); + Instant mondayInstant = monday.atZone(seoulZoneId).toInstant(); + Instant sundayInstant = sunday.atZone(seoulZoneId).toInstant(); + + return mealRepositoryCustom.getWeekMealList(restaurant, mondayInstant, sundayInstant); } } diff --git a/src/test/java/everymeal/server/meal/controller/MealControllerTest.java b/src/test/java/everymeal/server/meal/controller/MealControllerTest.java index f3e4a8f..2b02238 100644 --- a/src/test/java/everymeal/server/meal/controller/MealControllerTest.java +++ b/src/test/java/everymeal/server/meal/controller/MealControllerTest.java @@ -9,9 +9,10 @@ import everymeal.server.meal.controller.dto.request.MealRegisterReq; import everymeal.server.meal.controller.dto.request.RestaurantRegisterReq; import everymeal.server.meal.controller.dto.request.WeekMealRegisterReq; +import everymeal.server.meal.entity.MealCategory; import everymeal.server.meal.entity.MealStatus; import everymeal.server.meal.entity.MealType; -import java.time.LocalDate; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -32,8 +33,9 @@ void createWeekMeal() throws Exception { "갈비탕, 깍두기, 흰쌀밥", MealType.BREAKFAST.name(), MealStatus.OPEN.name(), - LocalDate.now(), - 10000.0); + Instant.now(), + 10000.0, + MealCategory.DEFAULT.name()); list.add(mealReq); } WeekMealRegisterReq req = new WeekMealRegisterReq(list, 1L); diff --git a/src/test/java/everymeal/server/meal/service/MealServiceImplTest.java b/src/test/java/everymeal/server/meal/service/MealServiceImplTest.java index 58d84cc..6663d90 100644 --- a/src/test/java/everymeal/server/meal/service/MealServiceImplTest.java +++ b/src/test/java/everymeal/server/meal/service/MealServiceImplTest.java @@ -14,6 +14,7 @@ import everymeal.server.meal.controller.dto.response.RestaurantListGetRes; import everymeal.server.meal.controller.dto.response.WeekMealListGetRes; import everymeal.server.meal.entity.Meal; +import everymeal.server.meal.entity.MealCategory; import everymeal.server.meal.entity.MealStatus; import everymeal.server.meal.entity.MealType; import everymeal.server.meal.entity.Restaurant; @@ -22,7 +23,9 @@ import everymeal.server.meal.repository.RestaurantRepository; import everymeal.server.university.entity.University; import everymeal.server.university.repository.UniversityRepository; +import java.time.Instant; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -87,15 +90,16 @@ void createWeekMeal() throws Exception { restaurantRegisterReq.restaurantName())); List list = new ArrayList<>(); - LocalDate today = LocalDate.now(); + Instant today = Instant.now(); for (int i = 0; i < 7; i++) { MealRegisterReq mealReq = new MealRegisterReq( "갈비탕, 깍두기, 흰쌀밥", MealType.BREAKFAST.name(), MealStatus.OPEN.name(), - today.plusDays(i), - 10000.0); + today.plus(i, ChronoUnit.DAYS), + 10000.0, + MealCategory.DEFAULT.name()); list.add(mealReq); } WeekMealRegisterReq req = new WeekMealRegisterReq(list, restaurant.getIdx()); @@ -125,23 +129,26 @@ void getWeekMealList() throws Exception { restaurantRegisterReq.restaurantName())); List list = new ArrayList<>(); - LocalDate today = LocalDate.now(); + Instant today = Instant.now(); for (int i = 0; i < 7; i++) { + Instant offeredAt = today.plus(i, ChronoUnit.DAYS); MealRegisterReq mealReq = new MealRegisterReq( "갈비탕, 깍두기, 흰쌀밥", MealType.BREAKFAST.name(), MealStatus.OPEN.name(), - today.plusDays(i), - 10000.0); + offeredAt, + 10000.0, + MealCategory.DEFAULT.name()); list.add(mealReq); } WeekMealRegisterReq req = new WeekMealRegisterReq(list, restaurant.getIdx()); mealService.createWeekMeal(req); // when + String offeredAt = today.toString().split("T")[0]; List response = - mealService.getWeekMealList(restaurant.getIdx(), today.toString()); + mealService.getWeekMealListTest(restaurant.getIdx(), offeredAt); // then assertEquals(response.size(), req.registerReqList().size()); @@ -164,25 +171,27 @@ void getDayMealList() throws Exception { restaurantRegisterReq.address(), restaurantRegisterReq.restaurantName())); List list = new ArrayList<>(); - for (int i = 0; i < 7; i++) { - MealRegisterReq mealReq = - new MealRegisterReq( - "갈비탕, 깍두기, 흰쌀밥", - MealType.BREAKFAST.name(), - MealStatus.OPEN.name(), - LocalDate.now(), - 10000.0); - list.add(mealReq); - } + Instant today = Instant.now(); + MealRegisterReq mealReq = + new MealRegisterReq( + "갈비탕, 깍두기, 흰쌀밥", + MealType.BREAKFAST.name(), + MealStatus.OPEN.name(), + today, + 10000.0, + MealCategory.DEFAULT.name()); + list.add(mealReq); WeekMealRegisterReq req = new WeekMealRegisterReq(list, restaurant.getIdx()); mealService.createWeekMeal(req); // when + String offeredAt = LocalDate.now().toString().split("T")[0]; List response = - mealService.getDayMealList(restaurant.getIdx(), LocalDate.now().toString()); + mealService.getDayMealList(restaurant.getIdx(), offeredAt); // then - assertEquals(response.size(), req.registerReqList().size()); + assertEquals(response.size(), 3); + assertEquals(response.get(1).getMenu(), "등록된 식단이 없습니다."); } @DisplayName("학교별 학생 식당 조회") @@ -233,15 +242,16 @@ void createRestaurantWhenUniversityIsNotFound() throws Exception { void createWeekMealWhenRestaurantIsNotFound() throws Exception { // given List list = new ArrayList<>(); - LocalDate today = LocalDate.now(); + Instant today = Instant.now(); for (int i = 0; i < 7; i++) { MealRegisterReq mealReq = new MealRegisterReq( "갈비탕, 깍두기, 흰쌀밥", MealType.BREAKFAST.name(), MealStatus.OPEN.name(), - today.plusDays(i), - 10000.0); + today.plus(i, ChronoUnit.DAYS), + 10000.0, + MealCategory.DEFAULT.name()); list.add(mealReq); } WeekMealRegisterReq invalidReq = new WeekMealRegisterReq(list, 9999L); @@ -255,7 +265,7 @@ void createWeekMealWhenRestaurantIsNotFound() throws Exception { } @Test - @DisplayName("등록되어 있는 식단 데이터 보다 과거의 날짜로 식단을 등록하려는 경 - 덮어쓰기") + @DisplayName("등록되어 있는 식단 데이터 덮어 쓰기") void createWeekMealBeforeLastMealOfferedAt() throws Exception { // given RestaurantRegisterReq restaurantRegisterReq = getRestaurantRegisterReq(); @@ -277,21 +287,23 @@ void createWeekMealBeforeLastMealOfferedAt() throws Exception { .menu("떡볶이, 어묵탕, 튀김") .mealType(MealType.LUNCH) .mealStatus(MealStatus.OPEN) - .offeredAt(LocalDate.now()) + .offeredAt(Instant.now()) .price(5000.0) + .category(MealCategory.DEFAULT) .restaurant(restaurant) .build()); List list = new ArrayList<>(); - LocalDate today = LocalDate.now(); + Instant today = Instant.now(); for (int i = 0; i < 7; i++) { MealRegisterReq mealReq = new MealRegisterReq( "갈비탕, 깍두기, 흰쌀밥", - MealType.BREAKFAST.name(), + MealType.LUNCH.name(), MealStatus.OPEN.name(), - today.minusDays(i), - 10000.0); + today.plus(i, ChronoUnit.DAYS), + 10000.0, + MealCategory.DEFAULT.name()); list.add(mealReq); } WeekMealRegisterReq invalidReq = new WeekMealRegisterReq(list, restaurant.getIdx());