diff --git a/src/main/java/be/ddd/application/batch/CafeBeverageBatchService.java b/src/main/java/be/ddd/application/batch/CafeBeverageBatchService.java index f615080..dc81030 100644 --- a/src/main/java/be/ddd/application/batch/CafeBeverageBatchService.java +++ b/src/main/java/be/ddd/application/batch/CafeBeverageBatchService.java @@ -9,7 +9,6 @@ import be.ddd.domain.repo.CafeStoreRepository; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -25,8 +24,7 @@ public class CafeBeverageBatchService { private final CafeBeverageRepository repository; private final WebClient.Builder webClientBuilder; - private final String lambdaUrl = - "https://u6wvrcscqwe7rdbblr3xebajf40avfxz.lambda-url.ap-northeast-2.on.aws/"; + private final String lambdaUrl = "http://127.0.0.1:8000/api/v1/beverages"; private final CafeStoreRepository cafeStoreRepository; public List fetchAll() { @@ -47,7 +45,7 @@ public CafeBeverage toEntity(LambdaBeverageDto dto) { log.info( "[DEBUG] Processing DTO for beverage: '{}'. Sizes received: {}", dto.name(), - dto.beverageNutritions().stream().map(n -> n.size()).collect(Collectors.toList())); + dto.beverageNutritions() == null ? List.of() : dto.beverageNutritions().keySet()); Objects.requireNonNull(dto.name(), "Beverage name required"); List existingBeverages = repository.findAllByName(dto.name()); if (!existingBeverages.isEmpty()) { diff --git a/src/main/java/be/ddd/application/batch/dto/BeverageNutritionDto.java b/src/main/java/be/ddd/application/batch/dto/BeverageNutritionDto.java index 2907dc6..f808254 100644 --- a/src/main/java/be/ddd/application/batch/dto/BeverageNutritionDto.java +++ b/src/main/java/be/ddd/application/batch/dto/BeverageNutritionDto.java @@ -51,4 +51,16 @@ private Optional parseDouble(String value) { return Optional.empty(); } } + + public BeverageNutritionDto withSize(String size) { + return new BeverageNutritionDto( + size, + servingMl, + servingKcal, + saturatedFatG, + proteinG, + sodiumMg, + sugarG, + caffeineMg); + } } diff --git a/src/main/java/be/ddd/application/batch/dto/LambdaBeverageDto.java b/src/main/java/be/ddd/application/batch/dto/LambdaBeverageDto.java index 68bdefd..57c47e3 100644 --- a/src/main/java/be/ddd/application/batch/dto/LambdaBeverageDto.java +++ b/src/main/java/be/ddd/application/batch/dto/LambdaBeverageDto.java @@ -1,12 +1,13 @@ package be.ddd.application.batch.dto; -import be.ddd.domain.entity.crawling.*; -import java.util.List; +import be.ddd.application.batch.dto.deserializer.BeverageNutritionsDeserializer; +import be.ddd.domain.entity.crawling.BeverageType; +import be.ddd.domain.entity.crawling.CafeBeverage; +import be.ddd.domain.entity.crawling.CafeStore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.Map; import java.util.Optional; import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Collectors; public record LambdaBeverageDto( String brand, @@ -14,7 +15,8 @@ public record LambdaBeverageDto( String image, String beverageType, String beverageTemperature, - List beverageNutritions) { + @JsonDeserialize(using = BeverageNutritionsDeserializer.class) + Map beverageNutritions) { public CafeBeverage toEntity(CafeStore cafeStore) { BeverageType type = @@ -23,14 +25,6 @@ public CafeBeverage toEntity(CafeStore cafeStore) { .map(BeverageType::valueOf) .orElse(BeverageType.ANY); - Map nutritionsMap = - beverageNutritions.stream() - .collect( - Collectors.toMap( - BeverageNutritionDto::size, - Function.identity(), - (existing, replacement) -> existing)); - return CafeBeverage.of( name, UUID.randomUUID(), @@ -38,6 +32,6 @@ public CafeBeverage toEntity(CafeStore cafeStore) { image, type, beverageTemperature, - nutritionsMap); + beverageNutritions == null ? Map.of() : beverageNutritions); } } diff --git a/src/main/java/be/ddd/application/batch/dto/deserializer/BeverageNutritionsDeserializer.java b/src/main/java/be/ddd/application/batch/dto/deserializer/BeverageNutritionsDeserializer.java new file mode 100644 index 0000000..0a49842 --- /dev/null +++ b/src/main/java/be/ddd/application/batch/dto/deserializer/BeverageNutritionsDeserializer.java @@ -0,0 +1,63 @@ +package be.ddd.application.batch.dto.deserializer; + +import be.ddd.application.batch.dto.BeverageNutritionDto; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Jackson deserializer that accepts both array- and map-shaped beverage nutrition payloads. + * + *

Lambda 응답이 {@code ["size": "..."]} 리스트이든 {@code {"TALL": {...}}} 맵이든 동일하게 파싱한다. 맵 형태일 경우 키를 + * size 값으로 주입한다. + */ +public class BeverageNutritionsDeserializer + extends JsonDeserializer> { + + @Override + public Map deserialize( + JsonParser parser, DeserializationContext ctxt) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + if (node == null || node.isNull()) { + return Map.of(); + } + + ObjectMapper mapper = (ObjectMapper) parser.getCodec(); + Map result = new LinkedHashMap<>(); + + if (node.isArray()) { + for (JsonNode element : node) { + BeverageNutritionDto dto = mapper.treeToValue(element, BeverageNutritionDto.class); + String sizeKey = + element.hasNonNull("size") ? element.get("size").asText() : dto.size(); + if (sizeKey == null || sizeKey.isBlank()) { + continue; + } + result.put(sizeKey, dto.withSize(sizeKey)); + } + } else if (node.isObject()) { + node.fields() + .forEachRemaining( + entry -> { + try { + BeverageNutritionDto dto = + mapper.treeToValue( + entry.getValue(), BeverageNutritionDto.class); + result.put(entry.getKey(), dto.withSize(entry.getKey())); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } else if (node.isValueNode() && node.asToken() == JsonToken.VALUE_NULL) { + return Map.of(); + } + + return result; + } +} diff --git a/src/main/java/be/ddd/application/notification/NotificationSchedulingService.java b/src/main/java/be/ddd/application/notification/NotificationSchedulingService.java index cb1faef..cd34d5c 100644 --- a/src/main/java/be/ddd/application/notification/NotificationSchedulingService.java +++ b/src/main/java/be/ddd/application/notification/NotificationSchedulingService.java @@ -1,12 +1,12 @@ package be.ddd.application.notification; import be.ddd.application.discord.DiscordNotificationService; -import be.ddd.common.util.CustomClock; +import be.ddd.common.util.KoreanTimeService; import be.ddd.domain.entity.member.Member; import be.ddd.domain.repo.IntakeHistoryRepository; import be.ddd.domain.repo.MemberRepository; -import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -27,12 +27,14 @@ public class NotificationSchedulingService { private final IntakeHistoryRepository intakeHistoryRepository; private final FCMService fcmService; private final DiscordNotificationService discordNotificationService; + private final KoreanTimeService koreanTimeService; @Async @Scheduled(cron = "0 * * * * *") public void sendSugarIntakeNotifications() { - LocalTime now = CustomClock.now().toLocalTime(); - log.info("[NotificationTest] 1. Current fake time: {}", now); + ZonedDateTime koreaNow = koreanTimeService.now(); + LocalTime now = koreaNow.toLocalTime(); + log.info("[NotificationTest] 1. Current KST time: {}", koreaNow); List members = memberRepository.findAllByNotificationEnabledAndReminderTime(now); log.info( @@ -49,7 +51,8 @@ public void sendSugarIntakeNotifications() { members.stream().map(Member::getProviderId).collect(Collectors.toList()); Map totalSugars = - intakeHistoryRepository.sumSugarByMemberIdsAndDate(memberIds, LocalDateTime.now()); + intakeHistoryRepository.sumSugarByMemberIdsAndDate( + memberIds, koreaNow.toLocalDateTime()); log.info( "[NotificationTest] 3. Found sugar intake data for {} members.", totalSugars.size()); diff --git a/src/main/java/be/ddd/common/util/KoreanTimeService.java b/src/main/java/be/ddd/common/util/KoreanTimeService.java new file mode 100644 index 0000000..790b6af --- /dev/null +++ b/src/main/java/be/ddd/common/util/KoreanTimeService.java @@ -0,0 +1,36 @@ +package be.ddd.common.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import org.springframework.stereotype.Component; + +/** + * Provides the current time in Korean Standard Time (Asia/Seoul). + * + *

This service wraps {@link CustomClock} so tests that fix the clock keep working while giving + * callers stable {@link ZoneId} aware timestamps. + */ +@Component +public class KoreanTimeService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + public ZonedDateTime now() { + return CustomClock.now().atZone(KST); + } + + public LocalDateTime currentDateTime() { + return now().toLocalDateTime(); + } + + public LocalDate currentDate() { + return now().toLocalDate(); + } + + public LocalTime currentTime() { + return now().toLocalTime(); + } +} diff --git a/src/main/java/be/ddd/domain/entity/crawling/CafeBeverage.java b/src/main/java/be/ddd/domain/entity/crawling/CafeBeverage.java index 8ee289d..89d1d9b 100644 --- a/src/main/java/be/ddd/domain/entity/crawling/CafeBeverage.java +++ b/src/main/java/be/ddd/domain/entity/crawling/CafeBeverage.java @@ -41,7 +41,7 @@ public class CafeBeverage extends BaseTimeEntity { private BeverageType beverageType; @Enumerated(EnumType.STRING) - private SugarLevel sugarLevel; + private SugarLevel sugarLevel = SugarLevel.HIGH; @OneToMany(mappedBy = "cafeBeverage", cascade = CascadeType.ALL, orphanRemoval = true) private List sizes = new ArrayList<>(); @@ -50,7 +50,9 @@ public void updateFromDto(LambdaBeverageDto dto) { /* log.info( "[DEBUG] Updating beverage: '{}'. DTO contains sizes: {}. DB entity has sizes: {}", this.name, - dto.beverageNutritions().stream().map(n -> n.size()).collect(Collectors.toList()), + dto.beverageNutritions() == null + ? List.of() + : dto.beverageNutritions().keySet(), this.sizes.stream().map(BeverageSizeInfo::getSizeType).collect(Collectors.toList())); */ if (dto.image() != null) { @@ -62,33 +64,34 @@ public void updateFromDto(LambdaBeverageDto dto) { BeverageType.valueOf(dto.beverageType().toUpperCase().replace(" ", "_")); } - if (dto.beverageNutritions() != null && !dto.beverageNutritions().isEmpty()) { + Map nutritionMap = dto.beverageNutritions(); + if (nutritionMap != null && !nutritionMap.isEmpty()) { Map existingSizes = this.sizes.stream() .collect( Collectors.toMap( BeverageSizeInfo::getSizeType, Function.identity())); - dto.beverageNutritions() - .forEach( - nutritionDto -> { - BeverageSize size = BeverageSize.fromString(nutritionDto.size()); - if (size == null) { - // Log a warning or handle the unknown size as appropriate - return; - } - BeverageNutrition nutrition = BeverageNutrition.from(nutritionDto); - if (existingSizes.containsKey(size)) { - existingSizes.get(size).updateBeverageNutrition(nutrition); - } else { - addSizeInfo(new BeverageSizeInfo(this, size, nutrition)); - } - }); - - // 대표 당 레벨 업데이트 (예: TALL 사이즈 기준) + nutritionMap.forEach( + (sizeKey, nutritionDto) -> { + BeverageSize size = BeverageSize.fromString(sizeKey); + if (size == null) { + // Log a warning or handle the unknown size as appropriate + return; + } + BeverageNutrition nutrition = BeverageNutrition.from(nutritionDto); + if (existingSizes.containsKey(size)) { + existingSizes.get(size).updateBeverageNutrition(nutrition); + } else { + addSizeInfo(new BeverageSizeInfo(this, size, nutrition)); + } + }); + + // 대표 당 레벨 업데이트 (예: TALL 우선, 없으면 첫 사이즈) this.sizes.stream() .filter(s -> s.getSizeType() == BeverageSize.TALL) .findFirst() + .or(() -> this.sizes.stream().findFirst()) .ifPresent( sizeInfo -> { BeverageNutrition nutrition = sizeInfo.getBeverageNutrition(); @@ -99,6 +102,10 @@ public void updateFromDto(LambdaBeverageDto dto) { sizeInfo.getSizeType().getVolume()); } }); + + if (this.sugarLevel == null) { + this.sugarLevel = SugarLevel.HIGH; + } } } @@ -127,17 +134,19 @@ public static CafeBeverage of( beverage.imgUrl = imgUrl; beverage.beverageType = beverageType; - beverageNutritions.forEach( - (sizeStr, nutritionDto) -> { - BeverageSize size = BeverageSize.fromString(sizeStr); - if (size == null) { - // Log a warning or handle the unknown size as appropriate - return; - } - BeverageNutrition nutrition = BeverageNutrition.from(nutritionDto); - BeverageSizeInfo sizeInfo = new BeverageSizeInfo(beverage, size, nutrition); - beverage.sizes.add(sizeInfo); - }); + if (beverageNutritions != null) { + beverageNutritions.forEach( + (sizeStr, nutritionDto) -> { + BeverageSize size = BeverageSize.fromString(sizeStr); + if (size == null) { + // Log a warning or handle the unknown size as appropriate + return; + } + BeverageNutrition nutrition = BeverageNutrition.from(nutritionDto); + BeverageSizeInfo sizeInfo = new BeverageSizeInfo(beverage, size, nutrition); + beverage.sizes.add(sizeInfo); + }); + } beverage.sizes.stream() .filter(s -> s.getSizeType() == BeverageSize.TALL) @@ -154,6 +163,10 @@ public static CafeBeverage of( } }); + if (beverage.sugarLevel == null) { + beverage.sugarLevel = SugarLevel.HIGH; + } + return beverage; }