diff --git a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java index 11234d3..2cf51ed 100644 --- a/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java +++ b/src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableJpaAuditing @SpringBootApplication public class WithTimeBeApplication { diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java new file mode 100644 index 0000000..529cd8a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherClassificationConfig.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.weather.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +@Setter +public class WeatherClassificationConfig { + + private TemperatureThresholds temperature = new TemperatureThresholds(); + private PrecipitationThresholds precipitation = new PrecipitationThresholds(); + + /** + * 기온 분류 임계값 + * 새로운 기획: 중앙값 기준 + */ + @Getter + @Setter + public static class TemperatureThresholds { + // 쌀쌀한 날씨 ≤ 10℃ + private double chillyCoolBoundary = 10.0; + + // 선선한 날씨 11~20℃ + private double coolMildBoundary = 20.0; + + // 무난한 날씨 21~25℃ + private double mildHotBoundary = 25.0; + + // 무더운 날씨 ≥ 26℃ + } + + /** + * 강수 분류 임계값 + * 새로운 기획: 강수확률 기반 + */ + @Getter + @Setter + public static class PrecipitationThresholds { + // 비 없음: 0% + private double noneVeryLowBoundary = 0.0; + + // 비 거의 없음: 1~30% + private double veryLowLowBoundary = 30.0; + + // 비 약간 가능성: 31~60% + private double lowHighBoundary = 60.0; + + // 비 올 가능성 높음: 61~90% + private double highVeryHighBoundary = 90.0; + + // 비 확실: 91~100% + + // 기존 강수량 임계값도 유지 (단기예보에서 사용할 수 있음) + private double lightAmountThreshold = 1.0; // 1mm 이상 가벼운 비 + private double heavyAmountThreshold = 10.0; // 10mm 이상 많은 비 + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java index 68d82f3..4a2a9e2 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java @@ -1,21 +1,27 @@ package org.withtime.be.withtimebe.domain.weather.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; 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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.namul.api.payload.response.DefaultResponse; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; import org.withtime.be.withtimebe.domain.weather.service.command.WeatherTriggerService; import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import java.time.LocalDate; + @Slf4j @RestController @RequiredArgsConstructor @@ -24,6 +30,7 @@ public class WeatherController { private final WeatherTriggerService weatherTriggerService; + private final WeatherRecommendationGenerationService weatherRecommendationGenerationService; @PostMapping("/trigger") @Operation(summary = "수동 동기화 트리거 API by 지미 [Only Admin]", @@ -31,6 +38,8 @@ public class WeatherController { 관리자가 수동으로 다음 중 하나의 작업을 실행합니다: - SHORT_TERM: 단기 예보 데이터 수집 - MEDIUM_TERM: 중기 예보 데이터 수집 + - RECOMMENDATION: 날씨 기반 추천 생성 + - CLEANUP: 오래된 날씨 데이터 삭제 - ALL: 전체 동기화 작업 수행 --- 모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다. @@ -60,4 +69,63 @@ public DefaultResponse manualTrigger( return DefaultResponse.ok(response); } + + @GetMapping("/{regionId}/weekly") + @Operation( + summary = "지역별 주간 날씨 기반 추천 조회", + description = """ + 특정 지역의 7일치(오늘 기준) 날씨 데이터를 바탕으로 한 데이트 추천 정보를 제공합니다. + + - 날짜 범위: `startDate`부터 7일간 (startDate 포함) + - 추천 데이터는 날씨 분류 후 템플릿 기반으로 생성됩니다. + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "주간 추천 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"), + @ApiResponse(responseCode = "404", description = "해당 지역의 추천 정보가 존재하지 않음") + }) + public DefaultResponse getWeeklyRecommendation( + @Parameter(description = "지역 ID)", required = true) + @PathVariable @NotNull @Positive Long regionId, + + @Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-17") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) { + + log.info("주간 날씨 추천 조회 API 호출: regionId={}, startDate={}", regionId, startDate); + WeatherReqDTO.GetWeeklyRecommendation request = WeatherReqDTO.GetWeeklyRecommendation.of(regionId, startDate); + WeatherResDTO.WeeklyRecommendation response = weatherRecommendationGenerationService.getWeeklyRecommendation(request); + return DefaultResponse.ok(response); + } + + @GetMapping("/{regionId}/precipitation") + @Operation( + summary = "지역별 7일간 강수확률 조회", + description = """ + 특정 지역의 7일간 강수확률 정보만 간단하게 조회합니다. + + - 날짜 범위: `startDate`부터 7일간 (startDate 포함) + - 중기예보 데이터를 우선적으로 사용하고, 없을 경우 단기예보 데이터 사용 + - 각 날짜별 강수확률과 주간 평균, 경향 분석 제공 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "강수확률 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"), + @ApiResponse(responseCode = "404", description = "해당 지역이 존재하지 않음") + }) + public DefaultResponse getWeeklyPrecipitation( + @Parameter(description = "지역 ID", required = true) + @PathVariable @NotNull @Positive Long regionId, + + @Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-18") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) { + + log.info("7일간 강수확률 조회 API 호출: regionId={}, startDate={}", regionId, startDate); + + WeatherReqDTO.GetWeeklyPrecipitation request = WeatherReqDTO.GetWeeklyPrecipitation.of(regionId, startDate); + WeatherResDTO.WeeklyPrecipitation response = weatherRecommendationGenerationService.getWeeklyPrecipitation(request); + + return DefaultResponse.ok(response); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java new file mode 100644 index 0000000..d1734ad --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherConverter.java @@ -0,0 +1,121 @@ +package org.withtime.be.withtimebe.domain.weather.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherConverter { + + /** + * 데이터가 없는 날짜용 빈 DailyWeatherRecommendation 생성 + */ + private static WeatherResDTO.DailyWeatherRecommendation createEmptyDailyRecommendation(LocalDate date) { + return WeatherResDTO.DailyWeatherRecommendation.builder() + .forecastDate(date) + .weatherType(null) + .tempCategory(null) + .precipCategory(null) + .message("해당 날짜의 날씨 추천 정보가 없습니다.") + .emoji("❓") + .keywords(List.of("정보없음")) + .build(); + } + + /** + * DailyRecommendation을 DailyWeatherRecommendation DTO로 변환 + * 새로운 기획의 구조화된 응답 반영 + */ + public static WeatherResDTO.DailyWeatherRecommendation toDailyWeatherRecommendation( + DailyRecommendation recommendation, boolean hasRecommendation) { + + if (!hasRecommendation) { + return createEmptyDailyRecommendation(recommendation.getForecastDate()); + } + + WeatherTemplate template = recommendation.getWeatherTemplate(); + List keywords = template.getTemplateKeywords().stream() + .map(tk -> tk.getKeyword().getName()) + .distinct() + .collect(Collectors.toList()); + + return WeatherResDTO.DailyWeatherRecommendation.builder() + .forecastDate(recommendation.getForecastDate()) + .weatherType(template.getWeatherType()) + .tempCategory(template.getTempCategory()) + .precipCategory(template.getPrecipCategory()) + .message(template.getMessage()) + .emoji(template.getEmoji()) + .keywords(keywords) + .build(); + } + + /** + * DailyRecommendation 리스트를 WeeklyRecommendation DTO로 변환 + */ + public static WeatherResDTO.WeeklyRecommendation toWeeklyRecommendation( + List recommendations, Long regionId, String regionName, + LocalDate startDate, LocalDate endDate) { + + Map recommendationMap = recommendations.stream() + .collect(Collectors.toMap( + DailyRecommendation::getForecastDate, + rec -> rec + )); + + List dailyRecommendations = + startDate.datesUntil(endDate.plusDays(1)) + .map(date -> { + DailyRecommendation rec = recommendationMap.get(date); + if (rec != null) { + return toDailyWeatherRecommendation(rec, true); + } else { + return createEmptyDailyRecommendation(date); + } + }) + .collect(Collectors.toList()); + + WeatherResDTO.RegionInfo regionInfo; + if (!recommendations.isEmpty()) { + Region region = recommendations.get(0).getRegion(); + regionInfo = toRegionInfo(region); + } else { + regionInfo = WeatherResDTO.RegionInfo.builder() + .regionId(regionId) + .regionName(regionName) + .landRegCode(null) + .tempRegCode(null) + .build(); + } + + return WeatherResDTO.WeeklyRecommendation.builder() + .region(regionInfo) + .startDate(startDate) + .endDate(endDate) + .dailyRecommendations(dailyRecommendations) + .totalDays(dailyRecommendations.size()) + .message(String.format("%s 지역의 %s부터 %s까지 주간 날씨 추천입니다.", + regionName, startDate, endDate)) + .build(); + } + + /** + * Region 엔티티를 RegionInfo DTO로 변환 + */ + public static WeatherResDTO.RegionInfo toRegionInfo(Region region) { + return WeatherResDTO.RegionInfo.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .landRegCode(region.getRegionCode().getLandRegCode()) + .tempRegCode(region.getRegionCode().getTempRegCode()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java index 458d865..0622746 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java @@ -4,10 +4,12 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; @Slf4j @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -89,4 +91,50 @@ public static WeatherSyncResDTO.MediumTermSyncResult toMediumTermSyncResult( .message(message) .build(); } + + /** + * 추천 생성 결과 생성 + */ + public static WeatherSyncResDTO.RecommendationGenerationResult toRecommendationGenerationResult( + int totalRegions, int successfulRegions, int failedRegions, + int totalRecommendations, int newRecommendations, int updatedRecommendations, + LocalDate startDate, LocalDate endDate, + LocalDateTime startTime, LocalDateTime endTime, + List regionResults, + Map weatherStats, + List errorMessages) { + + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + String message = String.format( + "추천 정보 생성 완료: 성공 %d/%d 지역, 신규 %d개, 업데이트 %d개 추천 생성", + successfulRegions, totalRegions, newRecommendations, updatedRecommendations); + + WeatherSyncResDTO.WeatherTypeStatistics weatherTypeStats = WeatherSyncResDTO.WeatherTypeStatistics.builder() + .clearWeatherCount(weatherStats.getOrDefault(WeatherType.CLEAR, 0)) + .cloudyWeatherCount(weatherStats.getOrDefault(WeatherType.CLOUDY, 0)) + .cloudyRainCount(weatherStats.getOrDefault(WeatherType.RAINY, 0)) + .cloudySnowCount(weatherStats.getOrDefault(WeatherType.SNOWY, 0)) + .cloudyRainSnowCount(weatherStats.getOrDefault(WeatherType.RAIN_SNOW, 0)) + .cloudyShowerCount(weatherStats.getOrDefault(WeatherType.SHOWER, 0)) + .detailedStats(weatherStats) + .build(); + + return WeatherSyncResDTO.RecommendationGenerationResult.builder() + .totalRegions(totalRegions) + .successfulRegions(successfulRegions) + .failedRegions(failedRegions) + .totalRecommendations(totalRecommendations) + .newRecommendations(newRecommendations) + .updatedRecommendations(updatedRecommendations) + .startDate(startDate) + .endDate(endDate) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .regionResults(regionResults) + .weatherStats(weatherTypeStats) + .errorMessages(errorMessages) + .message(message) + .build(); + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java new file mode 100644 index 0000000..44b7cb1 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/initializer/WeatherDataInitializer.java @@ -0,0 +1,184 @@ +package org.withtime.be.withtimebe.domain.weather.data.initializer; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.entity.Keyword; +import org.withtime.be.withtimebe.domain.weather.entity.TemplateKeyword; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; +import org.withtime.be.withtimebe.domain.weather.repository.KeywordRepository; +import org.withtime.be.withtimebe.domain.weather.repository.TemplateKeywordRepository; +import org.withtime.be.withtimebe.domain.weather.repository.WeatherTemplateRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherDataInitializer { + + private final KeywordRepository keywordRepository; + private final WeatherTemplateRepository weatherTemplateRepository; + private final TemplateKeywordRepository templateKeywordRepository; + + @PostConstruct + @Transactional + public void init() { + initKeywords(); + initWeatherTemplates(); + initTemplateKeywords(); + } + + private void initKeywords() { + List keywordNames = List.of( + "활발한 활동", "전망 좋은 곳", "활기찬", + "감성적인", "탐험 중심", "쇼핑+데이트 복합", + "느긋하게 쉬기", "사진중심", "전시 공간", "북카페/책방" + ); + + List existing = keywordRepository.findAll().stream() + .map(Keyword::getName) + .toList(); + + List newKeywords = keywordNames.stream() + .filter(name -> !existing.contains(name)) + .map(name -> Keyword.builder().name(name).build()) + .toList(); + + keywordRepository.saveAll(newKeywords); + log.info("[ WeatherDataInitializer ] 키워드 데이터 초기화 완료 (새로 저장된 키워드 수: {})", newKeywords.size()); + } + + private void initWeatherTemplates() { + if (weatherTemplateRepository.count() > 0) { + log.info("[ WeatherDataInitializer ] 템플릿이 이미 존재합니다. 초기화 생략"); + return; + } + + List templates = List.of( + // ============ 1. 맑음 (CLEAR) - 강수확률 낮음만 ============ + // 맑음 + 모든 기온 + 강수확률 없음/매우 낮음 + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 쌀쌀한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 선선한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무난한 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.NONE).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLEAR).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("맑은 하늘과 무더운 날씨가 예정되어 있습니다.날씨가 맑은 오늘, 야외에서 기분 좋은 데이트를 즐겨보세요!").emoji("sunny️").build(), + + // ============ 2. 흐림 (CLOUDY) - 중간 강수확률 ============ + // 흐림 + 모든 기온 + 약간 낮음/낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 쌀쌀한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 선선한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무난한 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.CLOUDY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("흐린 하늘과 무더운 날씨가 예정되어 있습니다.흐린 날씨엔 감성을 더한 골목 탐방이나 복합공간 데이트가 잘 어울려요.").emoji("cloudy").build(), + + // ============ 3. 비 (RAINY) - 강수확률 높음만 ============ + // 비 + 모든 기온 + 높음/매우 높음 + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 쌀쌀한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 선선한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 무난한 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다.비 오는 날엔 실내에서 여유롭게 보내는 감성 데이트를 추천해요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAINY).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.VERY_HIGH).message("비와 함께 무더운 날씨가 예정되어 있습니다.우산이 필요한 오늘, 조용한 실내 공간에서 감성 가득한 시간을 보내보세요.").emoji("rainy️").build(), + + // ============ 4. 눈 (SNOWY) - 추운 날씨만 ============ + // 눈 + 쌀쌀함/선선함만 + 약간 낮음/낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.VERY_LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("눈과 함께 쌀쌀한 날씨가 예정되어 있습니다.눈 내리는 풍경 속에서 감성과 추억이 가득한 데이트를 즐겨보세요.").emoji("snowy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.VERY_LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("눈과 함께 선선한 날씨가 예정되어 있습니다.하얀 눈과 함께 감성적인 장소에서 특별한 하루를 만들어보세요.").emoji("snowy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SNOWY).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("눈과 함께 선선한 날씨가 예정되어 있습니다.눈 내리는 풍경 속에서 감성과 추억이 가득한 데이트를 즐겨보세요.").emoji("snowy️").build(), + + // ============ 5. 비/눈 (RAIN_SNOW) - 추운 날씨만 ============ + // 비/눈 + 쌀쌀함/선선함만 + 낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다.변덕스러운 날씨엔 실내 전시나 감성 공간에서 편안한 데이트를 즐겨보세요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 쌀쌀한 날씨가 예정되어 있습니다.비와 눈이 섞인 날엔 실내에서 감성을 채우는 데이트가 좋아요.").emoji("rainy️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다.변덕스러운 날씨엔 실내 전시나 감성 공간에서 편안한 데이트를 즐겨보세요.").emoji("rainy️").build(), + WeatherTemplate.builder().weatherType(WeatherType.RAIN_SNOW).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("비/눈이 같이오는 선선한 날씨가 예정되어 있습니다.비와 눈이 섞인 날엔 실내에서 감성을 채우는 데이트가 좋아요.").emoji("rainy️").build(), + + // ============ 6. 소나기 (SHOWER) - 모든 기온 가능 ============ + // 소나기 + 모든 기온 + 낮음/높음 + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.CHILLY).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 쌀쌀한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.COOL).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 선선한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.MILD).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 무난한 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build(), + + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.LOW).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다.소나기 예보가 있다면, 실내에서 여유롭게 보내는 하루는 어떠세요?").emoji("shower️").build(), + WeatherTemplate.builder().weatherType(WeatherType.SHOWER).tempCategory(TempCategory.HOT).precipCategory(PrecipCategory.HIGH).message("소나기가 예정된 무더운 날씨가 예정되어 있습니다.갑작스러운 소나기를 피해, 조용한 북카페나 책방에서의 데이트를 추천해요.").emoji("shower️").build() + ); + + weatherTemplateRepository.saveAll(templates); + log.info("[ WeatherDataInitializer ] 템플릿 {}개 저장 완료", templates.size()); + } + + private void initTemplateKeywords() { + List templates = weatherTemplateRepository.findAll(); + List keywords = keywordRepository.findAll(); + List newMappings = new ArrayList<>(); + + Set existingMappings = templateKeywordRepository.findAll().stream() + .map(tk -> tk.getWeatherTemplate().getId() + "-" + tk.getKeyword().getId()) + .collect(Collectors.toSet()); + + for (WeatherTemplate template : templates) { + for (Keyword keyword : keywords) { + if (!shouldMap(template.getWeatherType(), keyword.getName())) continue; + + String key = template.getId() + "-" + keyword.getId(); + + if (existingMappings.contains(key)) continue; + + newMappings.add(TemplateKeyword.builder() + .weatherTemplate(template) + .keyword(keyword) + .build()); + } + } + + templateKeywordRepository.saveAll(newMappings); + log.info("[ WeatherDataInitializer ] 템플릿-키워드 매핑 완료 (새로 추가된 매핑 수: {})", newMappings.size()); + } + + private boolean shouldMap(WeatherType weather, String keyword) { + return switch (weather) { + case CLEAR -> List.of("활발한 활동", "전망 좋은 곳", "활기찬").contains(keyword); + case CLOUDY -> List.of("감성적인", "탐험 중심", "쇼핑+데이트 복합").contains(keyword); + case RAINY -> List.of("감성적인", "쇼핑+데이트 복합", "느긋하게 쉬기").contains(keyword); + case SNOWY -> List.of("감성적인", "전망 좋은 곳", "사진중심").contains(keyword); + case RAIN_SNOW -> List.of("감성적인", "사진중심", "전시 공간").contains(keyword); + case SHOWER -> List.of("사진중심", "북카페/책방", "느긋하게 쉬기").contains(keyword); + }; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java index cb671d0..d48cfd5 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -6,12 +6,14 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCleanupService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; @Slf4j @Component @@ -20,10 +22,15 @@ public class WeatherScheduler { private final WeatherDataCollectionService weatherDataCollectionService; + private final WeatherRecommendationGenerationService weatherRecommendationGenerationService; + private final WeatherDataCleanupService dataCleanupService; // 스케줄러 실행 상태 volatile로 추척 private volatile boolean shortTermSyncRunning = false; private volatile boolean mediumTermSyncRunning = false; + private volatile boolean shortTermRecommendationRunning = false; + private volatile boolean mediumTermRecommendationRunning = false; + private volatile boolean cleanupRunning = false; /** * 단기 예보 데이터 수집 스케줄러 @@ -42,13 +49,14 @@ public void scheduledShortTermWeatherSync() { shortTermSyncRunning = true; log.info("단기 예보 동기화 스케줄러 시작"); - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + + log.debug("계산된 base_date: {}, base_time: {}", baseDateTime.baseDate(), baseDateTime.baseTime()); // 모든 지역에 대해 동기화 실행 WeatherSyncResDTO.ShortTermSyncResult result = weatherDataCollectionService.collectShortTermWeatherData( - null, baseDate, baseTime, false); + null, baseDateTime.getBaseDateAsLocalDate(), baseDateTime.baseTime(), false); log.info("단기 예보 동기화 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", result.successfulRegions(), result.totalRegions(), @@ -93,4 +101,169 @@ public void scheduledMediumTermWeatherSync() { mediumTermSyncRunning = false; } } + + /** + * 단기예보 기반 추천 정보 생성 스케줄러 (0-3일, 실제 단기예보 데이터 범위) + * 매 시간 5분에 실행 - 단기예보는 1시간마다 업데이트 + */ + @Scheduled(cron = "${scheduler.weather.recommendation.short-term-cron}") + @Async("weatherTaskExecutor") + public void scheduledShortTermRecommendationGeneration() { + if (shortTermRecommendationRunning) { + log.warn("단기예보 추천 생성이 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + shortTermRecommendationRunning = true; + log.info("단기예보 추천 생성 스케줄러 시작 (실제 단기예보 데이터 기반)"); + + // 오늘부터 4일간만 처리 (실제 단기예보 데이터가 있는 범위) + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(3); + + WeatherSyncResDTO.RecommendationGenerationResult result = + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, true, "단기예보"); // 강제 재생성으로 최신 데이터 반영 + + log.info("단기예보 추천 생성 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newRecommendations(), result.updatedRecommendations()); + + } catch (Exception e) { + log.error("단기예보 추천 생성 스케줄러 실행 중 오류 발생", e); + } finally { + shortTermRecommendationRunning = false; + } + } + + /** + * 중기예보 기반 추천 정보 생성 스케줄러 (4-10일, 실제 중기예보 데이터 범위) + * 매 6시간 30분에 실행 - 중기예보는 12시간마다 업데이트되므로 6시간마다 충분 + */ + @Scheduled(cron = "${scheduler.weather.recommendation.medium-term-cron}") + @Async("weatherTaskExecutor") + public void scheduledMediumTermRecommendationGeneration() { + if (mediumTermRecommendationRunning) { + log.warn("중기예보 추천 생성이 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + mediumTermRecommendationRunning = true; + log.info("중기예보 추천 생성 스케줄러 시작 (실제 중기예보 데이터 기반)"); + + // 4일후부터 3일간만 처리 (일반적인 서비스 범위) + LocalDate startDate = LocalDate.now().plusDays(4); + LocalDate endDate = LocalDate.now().plusDays(6); + + WeatherSyncResDTO.RecommendationGenerationResult result = + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, true, "중기예보"); // 강제 재생성 + + log.info("중기예보 추천 생성 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newRecommendations(), result.updatedRecommendations()); + + } catch (Exception e) { + log.error("중기예보 추천 생성 스케줄러 실행 중 오류 발생", e); + } finally { + mediumTermRecommendationRunning = false; + } + } + + /** + * 데이터 정리 스케줄러 + * 매일 새벽 3시에 실행 + */ + @Scheduled(cron = "${scheduler.weather.cleanup-cron}") + @Async("weatherTaskExecutor") + public void scheduledDataCleanup() { + if (cleanupRunning) { + log.warn("데이터 정리가 이미 실행 중입니다. 스킵합니다."); + return; + } + + try { + cleanupRunning = true; + log.info("데이터 정리 스케줄러 시작"); + + // 7일 이전 데이터 정리 + int retentionDays = 7; + WeatherSyncResDTO.CleanupResult result = dataCleanupService.cleanupOldWeatherData( + retentionDays, true, true, true, false); + + log.info("데이터 정리 스케줄러 완료: 보관기간 {}일, 처리시간 {}ms", + retentionDays, result.processingDurationMs()); + + if (result.shortTermStats() != null) { + log.info("단기예보 정리: {} 건 삭제", result.shortTermStats().recordsDeleted()); + } + if (result.mediumTermStats() != null) { + log.info("중기예보 정리: {} 건 삭제", result.mediumTermStats().recordsDeleted()); + } + if (result.recommendationStats() != null) { + log.info("추천정보 정리: {} 건 삭제", result.recommendationStats().recordsDeleted()); + } + + } catch (Exception e) { + log.error("데이터 정리 스케줄러 실행 중 오류 발생", e); + } finally { + cleanupRunning = false; + } + } + + /** + * 애플리케이션 시작 시 초기 데이터 동기화 + * 서버 재시작 후 최신 데이터 확보 + */ + @Scheduled(initialDelay = 60000, fixedDelay = Long.MAX_VALUE) // 1분 후 1회 실행 + @Async("weatherTaskExecutor") + public void initialDataSync() { + log.info("애플리케이션 시작 후 초기 데이터 동기화 시작"); + + try { + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + + CompletableFuture shortTermFuture = CompletableFuture.runAsync(() -> { + try { + weatherDataCollectionService.collectShortTermWeatherData( + null, baseDateTime.getBaseDateAsLocalDate(), baseDateTime.baseTime(), false); + log.info("초기 단기 예보 동기화 완료"); + } catch (Exception e) { + log.error("초기 단기 예보 동기화 실패", e); + } + }); + + // 중기 예보 동기화 + CompletableFuture mediumTermFuture = CompletableFuture.runAsync(() -> { + try { + weatherDataCollectionService.collectMediumTermWeatherData(null, LocalDate.now(), false); + log.info("초기 중기 예보 동기화 완료"); + } catch (Exception e) { + log.error("초기 중기 예보 동기화 실패", e); + } + }); + + // 두 동기화 작업 완료 후 추천 정보 생성 + CompletableFuture.allOf(shortTermFuture, mediumTermFuture).thenRun(() -> { + try { + // 전체 기간 추천 정보 생성 (실제 데이터 존재 여부 기반) + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + weatherRecommendationGenerationService.generateRecommendations( + null, startDate, endDate, false, "초기동기화"); + log.info("초기 추천 정보 생성 완료"); + } catch (Exception e) { + log.error("초기 추천 정보 생성 실패", e); + } + }); + + log.info("초기 데이터 동기화 작업이 시작되었습니다."); + + } catch (Exception e) { + log.error("초기 데이터 동기화 중 오류 발생", e); + } + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java index 7a8920a..be647a4 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java @@ -8,7 +8,9 @@ import org.withtime.be.withtimebe.domain.weather.entity.Region; import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; import org.withtime.be.withtimebe.global.error.exception.WeatherException; +import reactor.util.retry.Retry; +import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -54,6 +56,11 @@ public String callShortTermWeatherApi(Region region, LocalDate baseDate, String .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("단기예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.SHORT_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -89,6 +96,11 @@ public String callMediumTermLandWeatherApi(Region region, LocalDate tmfc) { .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("중기 육상예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -124,6 +136,11 @@ public String callMediumTermTempWeatherApi(Region region, LocalDate tmfc) { .build()) .retrieve() .bodyToMono(String.class) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) + .onRetryExhaustedThrow((retryBackoffSpec, signal) -> { + log.error("중기 기온예보 API 3회 재시도 실패", signal.failure()); + return new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + })) .block(); if (response == null || response.trim().isEmpty()) { @@ -139,5 +156,4 @@ public String callMediumTermTempWeatherApi(Region region, LocalDate tmfc) { throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); } } - } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java new file mode 100644 index 0000000..c502ab9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationService.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; + +import java.time.LocalDate; +import java.util.List; + +public interface WeatherClassificationService { + + WeatherResDTO.WeatherClassificationResult classifyShortTermWeatherWithCentralTemp( + List shortTermData, Long regionId, LocalDate targetDate); + + WeatherResDTO.WeatherClassificationResult classifyMediumTermWeather( + List mediumTermData, Long regionId, LocalDate targetDate); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java new file mode 100644 index 0000000..8c4ca9e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherClassificationServiceImpl.java @@ -0,0 +1,104 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.config.WeatherClassificationConfig; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherClassificationUtils; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherClassificationServiceImpl implements WeatherClassificationService { + + private final WeatherClassificationConfig config; + + @Override + public WeatherResDTO.WeatherClassificationResult classifyShortTermWeatherWithCentralTemp( + List shortTermData, Long regionId, LocalDate targetDate) { + + if (shortTermData.isEmpty()) { + log.warn("단기 예보 데이터가 없습니다: regionId={}, date={}", regionId, targetDate); + return createDefaultClassification(); + } + + try { + // 1. 강수확률용 대표 시간대 선택 + RawShortTermWeather representativeData = WeatherClassificationUtils.selectRepresentativeShortTermData(shortTermData, targetDate); + + // 2. 온도 중앙값 계산을 위한 최저/최고 온도 조회 + WeatherClassificationUtils.TemperatureRange tempRange = WeatherClassificationUtils.calculateTemperatureRange(shortTermData, targetDate); + + // 3. 날씨 유형 분류 + WeatherType weatherType = WeatherClassificationUtils.classifyWeatherTypeFromShortTerm(representativeData); + + // 4. 온도 분류 - 중앙값 사용 + TempCategory tempCategory = WeatherClassificationUtils.classifyTempCategoryFromRange( + tempRange.minTemp(), tempRange.maxTemp(), config.getTemperature()); + + // 5. 강수 분류 - 현재 시간 기준 시간대 사용 + PrecipCategory precipCategory = WeatherClassificationUtils.classifyPrecipCategoryByProbability( + representativeData.getPrecipitationProbability(), config.getPrecipitation()); + + log.debug("단기예보 중앙값 분류 완료: regionId={}, date={}, weather={}, temp={}, precip={}, 온도범위={}~{}°C, 강수확률={}%", + regionId, targetDate, weatherType, tempCategory, precipCategory, + tempRange.minTemp(), tempRange.maxTemp(), representativeData.getPrecipitationProbability()); + + return new WeatherResDTO.WeatherClassificationResult(weatherType, tempCategory, precipCategory, + tempRange.getAvgTemp(), representativeData.getPrecipitationProbability(), + representativeData.getPrecipitationAmount(), "단기예보(중앙값)"); + + } catch (Exception e) { + log.error("단기예보 중앙값 분류 중 오류 발생: regionId={}, date={}", regionId, targetDate, e); + return createDefaultClassification(); + } + } + + @Override + public WeatherResDTO.WeatherClassificationResult classifyMediumTermWeather( + List mediumTermData, Long regionId, LocalDate targetDate) { + + if (mediumTermData.isEmpty()) { + log.warn("중기 예보 데이터가 없습니다: regionId={}, date={}", regionId, targetDate); + return createDefaultClassification(); + } + + try { + RawMediumTermWeather representativeData = WeatherClassificationUtils.selectRepresentativeMediumTermData(mediumTermData, targetDate); + + WeatherType weatherType = WeatherClassificationUtils.classifyWeatherTypeFromMediumTerm(representativeData); + TempCategory tempCategory = WeatherClassificationUtils.classifyTempCategoryFromRange( + representativeData.getMinTemperature(), representativeData.getMaxTemperature(), config.getTemperature()); + PrecipCategory precipCategory = WeatherClassificationUtils.classifyPrecipCategoryByProbability( + representativeData.getPrecipitationProbability(), config.getPrecipitation()); + + log.debug("중기 예보 분류 완료: regionId={}, date={}, weather={}, temp={}, precip={}, 최저={}°C, 최고={}°C, 강수확률={}%%", + regionId, targetDate, weatherType, tempCategory, precipCategory, + representativeData.getMinTemperature(), representativeData.getMaxTemperature(), representativeData.getPrecipitationProbability()); + + double avgTemp = (representativeData.getMinTemperature() + representativeData.getMaxTemperature()) / 2.0; + return new WeatherResDTO.WeatherClassificationResult(weatherType, tempCategory, precipCategory, + avgTemp, representativeData.getPrecipitationProbability(), 0.0, "중기예보"); + + } catch (Exception e) { + log.error("중기 예보 분류 중 오류 발생: regionId={}, date={}", regionId, targetDate, e); + return createDefaultClassification(); + } + } + + private WeatherResDTO.WeatherClassificationResult createDefaultClassification() { + return new WeatherResDTO.WeatherClassificationResult( + WeatherType.CLOUDY, TempCategory.MILD, PrecipCategory.NONE, + 20.0, 30.0, 0.0, "기본값"); + } +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java new file mode 100644 index 0000000..60fa107 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +public interface WeatherDataCleanupService { + + WeatherSyncResDTO.CleanupResult cleanupOldWeatherData( + Integer retentionDays, boolean cleanupShortTerm, boolean cleanupMediumTerm, boolean cleanupRecommendations, boolean dryRun); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java new file mode 100644 index 0000000..2aaeb55 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCleanupServiceImpl.java @@ -0,0 +1,241 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.repository.DailyRecommendationRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RawMediumTermWeatherRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RawShortTermWeatherRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherDataCleanupServiceImpl implements WeatherDataCleanupService{ + + private final RawShortTermWeatherRepository shortTermWeatherRepository; + private final RawMediumTermWeatherRepository mediumTermWeatherRepository; + private final DailyRecommendationRepository dailyRecommendationRepository; + + /** + * 오래된 날씨 데이터 정리 + */ + @Override + @Transactional + public WeatherSyncResDTO.CleanupResult cleanupOldWeatherData( + Integer retentionDays, boolean cleanupShortTerm, boolean cleanupMediumTerm, + boolean cleanupRecommendations, boolean dryRun) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("데이터 정리 시작: retentionDays={}, dryRun={}, cleanupShortTerm={}, cleanupMediumTerm={}, cleanupRecommendations={}", + retentionDays, dryRun, cleanupShortTerm, cleanupMediumTerm, cleanupRecommendations); + + LocalDate cutoffDate = LocalDate.now().minusDays(retentionDays); + List errorMessages = new ArrayList<>(); + + try { + // 단기 예보 데이터 정리 + WeatherSyncResDTO.CleanupStats shortTermStats = null; + if (cleanupShortTerm) { + shortTermStats = cleanupShortTermData(cutoffDate, dryRun); + } + + // 중기 예보 데이터 정리 + WeatherSyncResDTO.CleanupStats mediumTermStats = null; + if (cleanupMediumTerm) { + mediumTermStats = cleanupMediumTermData(cutoffDate, dryRun); + } + + // 추천 정보 정리 + WeatherSyncResDTO.CleanupStats recommendationStats = null; + if (cleanupRecommendations) { + recommendationStats = cleanupRecommendationData(cutoffDate, dryRun); + } + + LocalDateTime endTime = LocalDateTime.now(); + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + + return WeatherSyncResDTO.CleanupResult.builder() + .dryRun(dryRun) + .retentionDays(retentionDays) + .cutoffDate(cutoffDate) + .shortTermStats(shortTermStats) + .mediumTermStats(mediumTermStats) + .recommendationStats(recommendationStats) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .errorMessages(errorMessages) + .message("데이터 정리 성공") + .build(); + + } catch (Exception e) { + log.error("데이터 정리 중 오류 발생", e); + errorMessages.add("데이터 정리 실패: " + e.getMessage()); + + LocalDateTime endTime = LocalDateTime.now(); + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + + return WeatherSyncResDTO.CleanupResult.builder() + .dryRun(dryRun) + .retentionDays(retentionDays) + .cutoffDate(cutoffDate) + .shortTermStats(null) + .mediumTermStats(null) + .recommendationStats(null) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .errorMessages(errorMessages) + .message("데이터 정리 실패") + .build(); + } + } + + /** + * 단기 예보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupShortTermData(LocalDate cutoffDate, boolean dryRun) { + log.debug("단기 예보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = shortTermWeatherRepository.countOldData(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = shortTermWeatherRepository.getOldDataStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("단기예보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = shortTermWeatherRepository.deleteOldData(cutoffDate); + log.info("단기예보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("단기예보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("단기예보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("단기 예보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("단기예보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } + + /** + * 중기 예보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupMediumTermData(LocalDate cutoffDate, boolean dryRun) { + log.debug("중기 예보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = mediumTermWeatherRepository.countOldData(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = mediumTermWeatherRepository.getOldDataStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("중기예보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = mediumTermWeatherRepository.deleteOldData(cutoffDate); + log.info("중기예보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("중기예보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("중기예보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("중기 예보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("중기예보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } + + /** + * 추천 정보 데이터 정리 + */ + private WeatherSyncResDTO.CleanupStats cleanupRecommendationData(LocalDate cutoffDate, boolean dryRun) { + log.debug("추천 정보 데이터 정리: cutoffDate={}, dryRun={}", cutoffDate, dryRun); + + try { + // 삭제 대상 레코드 수 정확히 조회 + long recordsFound = dailyRecommendationRepository.countOldRecommendations(cutoffDate); + + // 상세 통계 정보 조회 (로깅용) + Object[] statistics = dailyRecommendationRepository.getOldRecommendationStatistics(cutoffDate); + if (statistics != null && statistics.length == 3 && statistics[2] != null) { + LocalDate oldestDate = (LocalDate) statistics[0]; + LocalDate newestDate = (LocalDate) statistics[1]; + Long count = (Long) statistics[2]; + log.debug("추천정보 삭제 대상 통계: 최오래된날짜={}, 최신날짜={}, 총개수={}", + oldestDate, newestDate, count); + } + + int recordsDeleted = 0; + if (!dryRun && recordsFound > 0) { + // 실제 삭제 실행 및 삭제된 레코드 수 반환 + recordsDeleted = dailyRecommendationRepository.deleteOldRecommendations(cutoffDate); + log.info("추천정보 데이터 삭제 완료: 예상 {}, 실제 삭제 {}", recordsFound, recordsDeleted); + } else if (dryRun) { + log.info("추천정보 데이터 정리 시뮬레이션: {} 건이 삭제 대상입니다", recordsFound); + } + + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("추천정보") + .executed(!dryRun) + .recordsFound((int) recordsFound) + .recordsDeleted(recordsDeleted) + .build(); + + } catch (Exception e) { + log.error("추천 정보 데이터 정리 실패", e); + return WeatherSyncResDTO.CleanupStats.builder() + .dataType("추천정보") + .executed(false) + .recordsFound(0) + .recordsDeleted(0) + .build(); + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java new file mode 100644 index 0000000..f941e56 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationService.java @@ -0,0 +1,18 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +import java.time.LocalDate; +import java.util.List; + +public interface WeatherRecommendationGenerationService { + + WeatherSyncResDTO.RecommendationGenerationResult generateRecommendations( + List regionIds, LocalDate startDate, LocalDate endDate, boolean forceRegenerate, String recommendationType); + + WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation(WeatherReqDTO.GetWeeklyRecommendation request); + + WeatherResDTO.WeeklyPrecipitation getWeeklyPrecipitation(WeatherReqDTO.GetWeeklyPrecipitation request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java new file mode 100644 index 0000000..c3c7e54 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherRecommendationGenerationServiceImpl.java @@ -0,0 +1,399 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.weather.converter.WeatherConverter; +import org.withtime.be.withtimebe.domain.weather.converter.WeatherSyncConverter; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherClassificationUtils; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherRecommendationUtils; +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.*; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; +import org.withtime.be.withtimebe.domain.weather.repository.*; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherRecommendationGenerationServiceImpl implements WeatherRecommendationGenerationService { + + private final RegionRepository regionRepository; + private final RawShortTermWeatherRepository shortTermWeatherRepository; + private final RawMediumTermWeatherRepository mediumTermWeatherRepository; + private final WeatherTemplateRepository weatherTemplateRepository; + private final DailyRecommendationRepository dailyRecommendationRepository; + private final WeatherClassificationService classificationService; + + @Override + @Transactional + public WeatherSyncResDTO.RecommendationGenerationResult generateRecommendations( + List regionIds, LocalDate startDate, LocalDate endDate, + boolean forceRegenerate, String recommendationType) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("{} 추천 정보 생성 시작: regionIds={}, startDate={}, endDate={}, forceRegenerate={}", + recommendationType, regionIds, startDate, endDate, forceRegenerate); + + List targetRegions = WeatherDataHelper.getTargetRegions(regionIds, regionRepository); + List regionResults = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + Map weatherStats = new HashMap<>(); + + Map templateMap = WeatherRecommendationUtils.createTemplateMap(weatherTemplateRepository.findAllWithKeywords()); + + for (Region region : targetRegions) { + long regionStartTime = System.currentTimeMillis(); + try { + RegionRecommendationResult result = generateRecommendationsForRegion( + region, startDate, endDate, forceRegenerate, templateMap, recommendationType); + + updateStats(regionResults, weatherStats, region, result, regionStartTime); + + } catch (Exception e) { + handleError(regionResults, errorMessages, region, e, regionStartTime, recommendationType); + } + } + + LocalDateTime endTime = LocalDateTime.now(); + log.info("{} 추천 정보 생성 완료: 성공 {}/{} 지역, 신규 {}, 업데이트 {} 추천, 처리시간 {}ms", + recommendationType, + regionResults.stream().filter(WeatherSyncResDTO.RegionRecommendationResult::success).count(), + targetRegions.size(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::newRecommendations).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::updatedRecommendations).sum(), + ChronoUnit.MILLIS.between(startTime, endTime)); + + return WeatherSyncConverter.toRecommendationGenerationResult( + targetRegions.size(), + (int) regionResults.stream().filter(WeatherSyncResDTO.RegionRecommendationResult::success).count(), + (int) regionResults.stream().filter(r -> !r.success()).count(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::recommendationsGenerated).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::newRecommendations).sum(), + regionResults.stream().mapToInt(WeatherSyncResDTO.RegionRecommendationResult::updatedRecommendations).sum(), + startDate, endDate, startTime, endTime, regionResults, weatherStats, errorMessages); + } + + @Override + public WeatherResDTO.WeeklyRecommendation getWeeklyRecommendation( + WeatherReqDTO.GetWeeklyRecommendation request) { + log.info("주간 날씨 추천 조회 요청: regionId={}, startDate={}", + request.regionId(), request.startDate()); + + // 1. 지역 존재 확인 + Region region = validateRegionExists(request.regionId()); + + // 2. 주간 추천 정보 조회 + LocalDate endDate = request.getEndDate(); + List recommendations = + dailyRecommendationRepository.findWeeklyRecommendations( + request.regionId(), request.startDate(), endDate.plusDays(1)); + + log.info("주간 날씨 추천 조회 완료: regionId={}, 조회된 데이터 수={}", + request.regionId(), recommendations.size()); + + return WeatherConverter.toWeeklyRecommendation( + recommendations, region.getId(), region.getName(), + request.startDate(), endDate); + } + + @Override + public WeatherResDTO.WeeklyPrecipitation getWeeklyPrecipitation( + WeatherReqDTO.GetWeeklyPrecipitation request) { + + log.info("주간 강수확률 조회 요청: regionId={}, startDate={}", + request.regionId(), request.startDate()); + + // 1. 지역 존재 확인 + Region region = validateRegionExists(request.regionId()); + LocalDate endDate = request.getEndDate(); + + // 2. 7일치 데이터 한 번에 조회 + List mediumTermDataList = + mediumTermWeatherRepository.findByRegionIdAndForecastDateRange( + request.regionId(), request.startDate(), endDate); + + List shortTermDataList = + shortTermWeatherRepository.findByRegionIdAndForecastDateRange( + request.regionId(), request.startDate(), endDate); + + // 3. 날짜별로 그룹핑 (메모리에서 처리) + Map> mediumTermByDate = mediumTermDataList.stream() + .collect(Collectors.groupingBy(RawMediumTermWeather::getForecastDate)); + + Map> shortTermByDate = shortTermDataList.stream() + .collect(Collectors.groupingBy(RawShortTermWeather::getForecastDate)); + + // 4. 7일간 강수확률 정보 구성 + List dailyPrecipitations = new ArrayList<>(); + + for (LocalDate date = request.startDate(); !date.isAfter(endDate); date = date.plusDays(1)) { + WeatherResDTO.DailyPrecipitation dailyPrecip = getPrecipitationForDateOptimized( + date, mediumTermByDate.get(date), shortTermByDate.get(date)); + dailyPrecipitations.add(dailyPrecip); + } + + log.info("주간 강수확률 조회 완료: regionId={}, 조회된 데이터 수={}, 중기예보 {}건, 단기예보 {}건", + request.regionId(), dailyPrecipitations.size(), + mediumTermDataList.size(), shortTermDataList.size()); + + return WeatherResDTO.WeeklyPrecipitation.builder() + .region(WeatherConverter.toRegionInfo(region)) + .startDate(request.startDate()) + .endDate(endDate) + .dailyPrecipitations(dailyPrecipitations) + .totalDays(dailyPrecipitations.size()) + .message(String.format("%s 지역의 %s부터 %s까지 7일간 강수확률 정보입니다.", + region.getName(), request.startDate(), endDate)) + .build(); + } + + private void updateStats(List regionResults, + Map weatherStats, + Region region, RegionRecommendationResult result, long startTime) { + + WeatherRecommendationUtils.mergeWeatherStats(weatherStats, result.weatherTypeStats()); + + regionResults.add(WeatherSyncResDTO.RegionRecommendationResult.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .success(true) + .recommendationsGenerated(result.recommendationsGenerated()) + .newRecommendations(result.newRecommendations()) + .updatedRecommendations(result.updatedRecommendations()) + .processedDates(result.processedDates()) + .errorMessage(null) + .processingTimeMs(System.currentTimeMillis() - startTime) + .build()); + } + + private void handleError(List regionResults, + List errorMessages, + Region region, Exception e, long startTime, String type) { + long timeTaken = System.currentTimeMillis() - startTime; + String msg = String.format("지역 %s 추천 생성 실패: %s", region.getName(), e.getMessage()); + log.error("{} 추천 생성: 지역 {} 실패", type, region.getName(), e); + errorMessages.add(msg); + regionResults.add(WeatherSyncResDTO.RegionRecommendationResult.builder() + .regionId(region.getId()) + .regionName(region.getName()) + .success(false) + .recommendationsGenerated(0) + .newRecommendations(0) + .updatedRecommendations(0) + .processedDates(Collections.emptyList()) + .errorMessage(msg) + .processingTimeMs(timeTaken) + .build()); + } + + private RegionRecommendationResult generateRecommendationsForRegion( + Region region, LocalDate startDate, LocalDate endDate, + boolean forceRegenerate, Map templateMap, String type) { + + List processedDates = new ArrayList<>(); + Map stats = new HashMap<>(); + int generated = 0, created = 0, updated = 0; + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + try { + RecommendationResult result = generateRecommendationForDate(region, date, forceRegenerate, templateMap); + if (result != null) { + generated++; + if (result.isNew()) created++; else updated++; + processedDates.add(date.toString()); + stats.merge(result.weatherType(), 1, Integer::sum); + } + } catch (Exception e) { + log.warn("{} 추천 생성 실패: {} {}", type, region.getName(), date); + } + } + + return new RegionRecommendationResult(generated, created, updated, processedDates, stats); + } + + private RecommendationResult generateRecommendationForDate(Region region, LocalDate date, + boolean forceRegenerate, Map templateMap) { + + Optional existing = dailyRecommendationRepository.findByRegionIdAndDateWithTemplate(region.getId(), date); + if (existing.isPresent() && !forceRegenerate) return null; + + WeatherResDTO.WeatherClassificationResult classification = classifyWeatherForDate(region, date); + if (!classification.isValid()) return null; + + WeatherTemplate template = WeatherRecommendationUtils.findMatchingTemplate(classification, templateMap); + if (template == null) return null; + + existing.ifPresent(dailyRecommendationRepository::delete); + + dailyRecommendationRepository.save( + DailyRecommendation.builder() + .region(region) + .weatherTemplate(template) + .forecastDate(date) + .build() + ); + + return new RecommendationResult(classification.weatherType(), existing.isEmpty()); + } + + private WeatherResDTO.WeatherClassificationResult classifyWeatherForDate(Region region, LocalDate date) { + + // 1. 두 데이터 소스 모두 조회 + List mediumTermData = mediumTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + List shortTermData = shortTermWeatherRepository.findLatestByRegionIdAndForecastDate(region.getId(), date); + + // 2. 스마트 우선순위 결정 + DataSourceDecision decision = determineOptimalDataSource(date, shortTermData, mediumTermData); + + // 3. 결정된 데이터 소스 사용 + return switch (decision.source()) { + case SHORT_TERM -> { + log.debug("단기예보 사용: {} (이유: {})", date, decision.reason()); + yield classificationService.classifyShortTermWeatherWithCentralTemp(shortTermData, region.getId(), date); + } + case MEDIUM_TERM -> { + log.debug("중기예보 사용: {} (이유: {})", date, decision.reason()); + yield classificationService.classifyMediumTermWeather(mediumTermData, region.getId(), date); + } + case NONE -> { + log.error("사용 가능한 날씨 데이터 없음: {}", date); + throw new WeatherException(WeatherErrorCode.WEATHER_DATA_NOT_FOUND); + } + }; + } + + private DataSourceDecision determineOptimalDataSource(LocalDate targetDate, + List shortTermData, + List mediumTermData) { + + LocalDate today = LocalDate.now(); + long daysFromToday = ChronoUnit.DAYS.between(today, targetDate); + + // 1. 데이터 존재 여부 확인 + boolean hasShortTerm = shortTermData != null && !shortTermData.isEmpty(); + boolean hasMediumTerm = mediumTermData != null && !mediumTermData.isEmpty(); + + if (!hasShortTerm && !hasMediumTerm) { + return new DataSourceDecision(DataSource.NONE, "데이터 없음"); + } + + // 2. 단기예보 우선 범위 (0~3일): 단기예보가 더 정확 + if (daysFromToday >= 0 && daysFromToday <= 3) { + if (hasShortTerm) { + // 단기예보 데이터 품질 검사 + if (shortTermData.size() >= 8) { + return new DataSourceDecision(DataSource.SHORT_TERM, + String.format("단기예보 범위 내(%d일 후), 충분한 데이터(%d개)", daysFromToday, shortTermData.size())); + } else { + log.warn("단기예보 데이터 부족: {}일 후, {}개 데이터", daysFromToday, shortTermData.size()); + return hasMediumTerm ? + new DataSourceDecision(DataSource.MEDIUM_TERM, "단기예보 데이터 부족으로 중기예보 사용") : + new DataSourceDecision(DataSource.SHORT_TERM, "단기예보 데이터 부족하지만 중기예보 없음"); + } + } else { + return new DataSourceDecision(DataSource.MEDIUM_TERM, "단기예보 없음"); + } + } + + // 3. 중기예보 우선 범위 (4일~): 중기예보가 적절 + else if (daysFromToday >= 4) { + if (hasMediumTerm) { + return new DataSourceDecision(DataSource.MEDIUM_TERM, + String.format("중기예보 범위 내(%d일 후)", daysFromToday)); + } else { + return new DataSourceDecision(DataSource.SHORT_TERM, "중기예보 없어서 단기예보 사용"); + } + } + + // 4. 과거 날짜: 단기예보 우선 (더 정확했던 데이터) + else { + if (hasShortTerm) { + return new DataSourceDecision(DataSource.SHORT_TERM, + String.format("과거 날짜(%d일 전), 단기예보 우선", Math.abs(daysFromToday))); + } else { + return new DataSourceDecision(DataSource.MEDIUM_TERM, "과거 날짜, 단기예보 없음"); + } + } + } + + private WeatherResDTO.DailyPrecipitation getPrecipitationForDateOptimized( + LocalDate date, + List mediumTermData, + List shortTermData) { + try { + // 1. 중기예보 우선 사용 + if (mediumTermData != null && !mediumTermData.isEmpty()) { + RawMediumTermWeather data = WeatherClassificationUtils + .selectRepresentativeMediumTermData(mediumTermData, date); + + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(data.getPrecipitationProbability()) + .build(); + } + + // 2. 단기예보 사용 + if (shortTermData != null && !shortTermData.isEmpty()) { + RawShortTermWeather data = WeatherClassificationUtils + .selectRepresentativeShortTermData(shortTermData, date); + + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(data.getPrecipitationProbability()) + .build(); + } + + // 3. 데이터가 없는 경우 + log.debug("강수확률 데이터 없음: date={}", date); + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(null) + .build(); + + } catch (Exception e) { + log.error("강수확률 조회 중 오류 발생: date={}", date, e); + return WeatherResDTO.DailyPrecipitation.builder() + .forecastDate(date) + .precipitationProbability(null) + .build(); + } + } + + private Region validateRegionExists(Long regionId) { + return regionRepository.findById(regionId) + .orElseThrow(() -> { + log.error("존재하지 않는 지역: regionId={}", regionId); + return new WeatherException(WeatherErrorCode.REGION_NOT_FOUND); + }); + } + + private record RegionRecommendationResult(int recommendationsGenerated, int newRecommendations, + int updatedRecommendations, List processedDates, + Map weatherTypeStats) {} + + private record RecommendationResult(WeatherType weatherType, boolean isNew) {} + + /** + * 데이터 소스 결정 결과 + */ + private record DataSourceDecision(DataSource source, String reason) {} + + /** + * 데이터 소스 열거형 + */ + private enum DataSource { + SHORT_TERM, MEDIUM_TERM, NONE + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java new file mode 100644 index 0000000..01846b7 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherClassificationUtils.java @@ -0,0 +1,153 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.weather.config.WeatherClassificationConfig; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherClassificationUtils { + + public static RawShortTermWeather selectRepresentativeShortTermData(List dataList, LocalDate targetDate) { + return dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .sorted((a, b) -> { + int baseDateCompare = b.getBaseDate().compareTo(a.getBaseDate()); + if (baseDateCompare != 0) return baseDateCompare; + int baseTimeCompare = b.getBaseTime().compareTo(a.getBaseTime()); + if (baseTimeCompare != 0) return baseTimeCompare; + int aTimeScore = getTimeScore(a.getForecastTime()); + int bTimeScore = getTimeScore(b.getForecastTime()); + return Integer.compare(bTimeScore, aTimeScore); + }) + .findFirst() + .orElse(dataList.get(0)); + } + + public static RawMediumTermWeather selectRepresentativeMediumTermData(List dataList, LocalDate targetDate) { + return dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .max(Comparator.comparing(RawMediumTermWeather::getBaseDate)) + .orElse(dataList.get(0)); + } + + private static int getTimeScore(String fcstTime) { + try { + LocalTime currentTime = LocalTime.now(); + + // 예보 시간 파싱 + int fcstHour = Integer.parseInt(fcstTime.substring(0, 2)); + int fcstMinute = Integer.parseInt(fcstTime.substring(2, 4)); + LocalTime forecastTime = LocalTime.of(fcstHour, fcstMinute); + + // 현재 시간과 예보 시간 차이 계산 (분 단위) + long timeDifference = Math.abs(ChronoUnit.MINUTES.between(currentTime, forecastTime)); + + // 시간 차이가 작을수록 높은 점수 부여 + // 12시간(720분) 이상 차이나면 0점 + if (timeDifference >= 720) { + return 0; + } + + // 점수 계산: 최대 1000점에서 시간 차이만큼 감점 + // 720분 차이까지 선형적으로 감소 + int score = Math.max(0, 1000 - (int)(timeDifference * 1000 / 720)); + + return score; + + } catch (Exception e) { + log.warn("시간 점수 계산 중 오류 발생: fcstTime={}", fcstTime, e); + return 50; // 기본값 반환 + } + } + + public static WeatherType classifyWeatherTypeFromShortTerm(RawShortTermWeather data) { + String pty = data.getPrecipitationType(); + String sky = data.getSky(); + + return switch (pty) { + case "눈" -> WeatherType.SNOWY; + case "비/눈" -> WeatherType.RAIN_SNOW; + case "비", "빗방울" -> WeatherType.RAINY; + case "눈날림", "빗방울눈날림" -> WeatherType.SHOWER; + default -> switch (sky) { + case "맑음" -> WeatherType.CLEAR; + case "구름많음", "흐림" -> WeatherType.CLOUDY; + default -> WeatherType.CLOUDY; + }; + }; + } + + public static WeatherType classifyWeatherTypeFromMediumTerm(RawMediumTermWeather data) { + return switch (data.getSky()) { + case "맑음" -> WeatherType.CLEAR; + case "구름많음", "흐림" -> WeatherType.CLOUDY; + case "눈" -> WeatherType.SNOWY; + case "비" -> WeatherType.RAINY; + case "소나기" -> WeatherType.SHOWER; + default -> WeatherType.CLOUDY; + }; + } + + public static TempCategory classifyTempCategory(Double temperature, WeatherClassificationConfig.TemperatureThresholds thresholds) { + if (temperature == null) return TempCategory.MILD; + if (temperature <= thresholds.getChillyCoolBoundary()) return TempCategory.CHILLY; + else if (temperature <= thresholds.getCoolMildBoundary()) return TempCategory.COOL; + else if (temperature <= thresholds.getMildHotBoundary()) return TempCategory.MILD; + else return TempCategory.HOT; + } + + public static TempCategory classifyTempCategoryFromRange(Double minTemp, Double maxTemp, WeatherClassificationConfig.TemperatureThresholds thresholds) { + if (minTemp == null || maxTemp == null) return TempCategory.MILD; + double avgTemp = (minTemp + maxTemp) / 2.0; + if (maxTemp > thresholds.getMildHotBoundary() + 3) return TempCategory.HOT; + return classifyTempCategory(avgTemp, thresholds); + } + + public static PrecipCategory classifyPrecipCategoryByProbability(Double precipProbability, WeatherClassificationConfig.PrecipitationThresholds thresholds) { + if (precipProbability == null) precipProbability = 0.0; + if (precipProbability <= thresholds.getNoneVeryLowBoundary()) return PrecipCategory.NONE; + else if (precipProbability <= thresholds.getVeryLowLowBoundary()) return PrecipCategory.VERY_LOW; + else if (precipProbability <= thresholds.getLowHighBoundary()) return PrecipCategory.LOW; + else if (precipProbability <= thresholds.getHighVeryHighBoundary()) return PrecipCategory.HIGH; + else return PrecipCategory.VERY_HIGH; + } + + public static TemperatureRange calculateTemperatureRange(List dataList, LocalDate targetDate) { + List temperatures = dataList.stream() + .filter(data -> data.getForecastDate().equals(targetDate)) + .map(RawShortTermWeather::getTemperature) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (temperatures.isEmpty()) { + return new TemperatureRange(20.0, 20.0); // 기본값 + } + + double minTemp = temperatures.stream().mapToDouble(Double::doubleValue).min().orElse(20.0); + double maxTemp = temperatures.stream().mapToDouble(Double::doubleValue).max().orElse(20.0); + + return new TemperatureRange(minTemp, maxTemp); + } + + public record TemperatureRange(Double minTemp, Double maxTemp) { + public Double getAvgTemp() { + return (minTemp + maxTemp) / 2.0; + } + } +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java index 9222964..d53d30b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java @@ -1,5 +1,7 @@ package org.withtime.be.withtimebe.domain.weather.data.utils; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; @@ -9,11 +11,14 @@ import org.withtime.be.withtimebe.domain.weather.repository.RawShortTermWeatherRepository; import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; @Slf4j -@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class WeatherDataHelper { // 지역 ID가 없으면 전체, 있으면 ID 기반 조회 @@ -35,6 +40,37 @@ public static String calculateNearestBaseTime(int currentHour) { return "2300"; } + public static BaseDateTime calculateBaseDateTime() { + LocalDateTime now = LocalDateTime.now(); + return calculateBaseDateTime(now); + } + + public static BaseDateTime calculateBaseDateTime(LocalDateTime targetTime) { + int currentHour = targetTime.getHour(); + + // base_time 계산 + String baseTime = calculateNearestBaseTime(currentHour); + + // base_date 계산 + LocalDate baseDate = targetTime.toLocalDate(); + + // 02:00 이전이면 전날 날짜 + 2300 사용 + if (currentHour < 2) { + baseDate = baseDate.minusDays(1); + baseTime = "2300"; + } + + String baseDateStr = baseDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + return new BaseDateTime(baseDateStr, baseTime); + } + + public record BaseDateTime(String baseDate, String baseTime) { + public LocalDate getBaseDateAsLocalDate() { + return LocalDate.parse(baseDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + } + public static UpsertResult upsertShortTermWeatherData( List weatherDataList, boolean forceUpdate, diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java new file mode 100644 index 0000000..fd404ac --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherRecommendationUtils.java @@ -0,0 +1,93 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherRecommendationUtils { + + public static String createTemplateKey(WeatherType weatherType, TempCategory tempCategory, PrecipCategory precipCategory) { + return String.format("%s_%s_%s", weatherType, tempCategory, precipCategory); + } + + public static Map createTemplateMap(List templates) { + return templates.stream().collect(Collectors.toMap( + t -> createTemplateKey(t.getWeatherType(), t.getTempCategory(), t.getPrecipCategory()), + t -> t, + (existing, duplicate) -> { + log.debug("중복 템플릿 키 유지: {}", existing.getId()); + return existing; + } + )); + } + + public static void mergeWeatherStats(Map globalStats, + Map regionStats) { + for (Map.Entry entry : regionStats.entrySet()) { + globalStats.merge(entry.getKey(), entry.getValue(), Integer::sum); + } + } + + public static WeatherTemplate findMatchingTemplate( + WeatherResDTO.WeatherClassificationResult classification, + Map templateMap) { + + String key = createTemplateKey(classification.weatherType(), classification.tempCategory(), classification.precipCategory()); + WeatherTemplate template = templateMap.get(key); + + if (template != null) { + log.trace("정확 템플릿 매칭 성공: {} -> ID={}", key, template.getId()); + return template; + } + + log.debug("정확 매칭 실패: {}, 대체 템플릿 탐색 시작", key); + return findFallbackTemplate(classification, templateMap); + } + + private static WeatherTemplate findFallbackTemplate( + WeatherResDTO.WeatherClassificationResult classification, + Map templateMap) { + + for (PrecipCategory fallback : generatePrecipFallbackOrder(classification.precipCategory())) { + String altKey = createTemplateKey(classification.weatherType(), classification.tempCategory(), fallback); + WeatherTemplate altTemplate = templateMap.get(altKey); + if (altTemplate != null) { + log.debug("대체 템플릿 매칭: {} -> ID={} (from {})", altKey, altTemplate.getId(), classification.precipCategory()); + return altTemplate; + } + } + + return findByWeatherAndTempOnly(classification.weatherType(), classification.tempCategory(), templateMap); + } + + private static WeatherTemplate findByWeatherAndTempOnly(WeatherType weatherType, TempCategory tempCategory, + Map templateMap) { + for (PrecipCategory category : PrecipCategory.values()) { + String key = createTemplateKey(weatherType, tempCategory, category); + WeatherTemplate template = templateMap.get(key); + if (template != null) return template; + } + return null; + } + + private static List generatePrecipFallbackOrder(PrecipCategory current) { + return switch (current) { + case VERY_HIGH -> List.of(PrecipCategory.VERY_HIGH, PrecipCategory.HIGH, PrecipCategory.LOW, PrecipCategory.VERY_LOW, PrecipCategory.NONE); + case HIGH -> List.of(PrecipCategory.HIGH, PrecipCategory.VERY_HIGH, PrecipCategory.LOW, PrecipCategory.VERY_LOW, PrecipCategory.NONE); + case LOW -> List.of(PrecipCategory.LOW, PrecipCategory.HIGH, PrecipCategory.VERY_LOW, PrecipCategory.VERY_HIGH, PrecipCategory.NONE); + case VERY_LOW -> List.of(PrecipCategory.VERY_LOW, PrecipCategory.LOW, PrecipCategory.NONE, PrecipCategory.HIGH, PrecipCategory.VERY_HIGH); + case NONE -> List.of(PrecipCategory.NONE, PrecipCategory.VERY_LOW, PrecipCategory.LOW, PrecipCategory.HIGH, PrecipCategory.VERY_HIGH); + }; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java new file mode 100644 index 0000000..3322f30 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherReqDTO.java @@ -0,0 +1,72 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.time.LocalDate; + +public class WeatherReqDTO { + + public record GetWeeklyRecommendation( + @NotNull(message = "지역 ID는 필수 입력값입니다.") + @Positive(message = "지역 ID는 양수여야 합니다.") + Long regionId, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate + ) { + /** + * 시작 날짜 유효성 검증을 포함한 정적 팩토리 메서드 + * 과거 7일 ~ 미래 7일까지만 조회 가능 + */ + public static GetWeeklyRecommendation of(Long regionId, LocalDate startDate) { + if (startDate != null) { + LocalDate now = LocalDate.now(); + LocalDate minDate = now.minusDays(7); + LocalDate maxDate = now.plusDays(7); + + if (startDate.isBefore(minDate) || startDate.isAfter(maxDate)) { + throw new IllegalArgumentException( + "조회 가능한 시작 날짜 범위를 벗어났습니다. (7일 전 ~ 7일 후)"); + } + } + + return new GetWeeklyRecommendation(regionId, startDate); + } + + /** + * 종료 날짜 계산 (시작일 + 6일) + */ + public LocalDate getEndDate() { + return startDate.plusDays(6); + } + } + + public record GetWeeklyPrecipitation( + @NotNull(message = "지역 ID는 필수 입력값입니다.") + @Positive(message = "지역 ID는 양수여야 합니다.") + Long regionId, + + @NotNull(message = "시작 날짜는 필수 입력값입니다.") + LocalDate startDate + ) { + public static GetWeeklyPrecipitation of(Long regionId, LocalDate startDate) { + if (startDate != null) { + LocalDate now = LocalDate.now(); + LocalDate minDate = now.minusDays(7); + LocalDate maxDate = now.plusDays(10); + + if (startDate.isBefore(minDate) || startDate.isAfter(maxDate)) { + throw new IllegalArgumentException( + "조회 가능한 시작 날짜 범위를 벗어났습니다. (7일 전 ~ 10일 후)"); + } + } + + return new GetWeeklyPrecipitation(regionId, startDate); + } + public LocalDate getEndDate() { + return startDate.plusDays(6); + } + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java new file mode 100644 index 0000000..bfc6c54 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherResDTO.java @@ -0,0 +1,137 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; +import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class WeatherResDTO { + + public record WeatherClassificationResult( + WeatherType weatherType, + TempCategory tempCategory, + PrecipCategory precipCategory, + Double temperature, + Double precipProbability, + Double precipAmount, + String dataSource + ) { + public boolean isValid() { + return weatherType != null && tempCategory != null && precipCategory != null; + } + + public String getSummary() { + return String.format("%s, %s (%.1f°C, %.0f%%) [%s]", + weatherType, tempCategory, temperature, precipProbability, dataSource); + } + } + + /** + * 날씨 추천 정보 (단일 날짜) + */ + @Builder + public record WeatherRecommendation( + Long recommendationId, + LocalDate forecastDate, + RegionInfo region, + WeatherInfo weather, + RecommendationInfo recommendation, + LocalDateTime updatedAt + ) { + } + + + /** + * 지역 정보 (날씨 관련 응답용) + */ + @Builder + public record RegionInfo( + Long regionId, + String regionName, + String landRegCode, + String tempRegCode + ) { + } + + /** + * 추천 정보 (메시지, 이모지, 키워드) + */ + @Builder + public record RecommendationInfo( + String message, + String emoji, + List keywords + ) { + } + + /** + * 주간 날씨 추천 정보 (7일치) + */ + @Builder + public record WeeklyRecommendation( + RegionInfo region, + LocalDate startDate, + LocalDate endDate, + List dailyRecommendations, + int totalDays, + String message + ) { + } + + /** + * 날씨 정보 (강수확률 정보 포함) + */ + @Builder + public record WeatherInfo( + WeatherType weatherType, // CLEAR, CLOUDY, CLOUDY_RAIN 등 + TempCategory tempCategory, // CHILLY, COOL, MILD, HOT + PrecipCategory precipCategory, // NONE, VERY_LOW, LOW, HIGH, VERY_HIGH + String weatherDescription, // "맑고", "흐리고", "비오고" 등 + String tempDescription, // "쌀쌀한 날", "선선한 날", "무난한 날", "무더운 날" + String precipDescription // "비 없음", "비 거의 없음", "비 약간 가능성" 등 + ) { + } + + /** + * 일별 날씨 추천 (강수확률 정보 포함) + */ + @Builder + public record DailyWeatherRecommendation( + LocalDate forecastDate, + WeatherType weatherType, + TempCategory tempCategory, + PrecipCategory precipCategory, + String message, + String emoji, + List keywords + ) { + } + + /** + * 일별 강수확률 정보 + */ + @Builder + public record DailyPrecipitation( + LocalDate forecastDate, + Double precipitationProbability // 강수확률 (%) + ) { + } + + /** + * 7일간 강수확률 정보 + */ + @Builder + public record WeeklyPrecipitation( + RegionInfo region, + LocalDate startDate, + LocalDate endDate, + List dailyPrecipitations, + int totalDays, + String message + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java index c595b09..34289e7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -1,10 +1,12 @@ package org.withtime.be.withtimebe.domain.weather.dto.response; import lombok.Builder; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; public class WeatherSyncResDTO { @@ -88,6 +90,8 @@ public record ManualTriggerResult( public record CompleteSyncResult( ShortTermSyncResult shortTermResult, // 단기 예보 결과 MediumTermSyncResult mediumTermResult, // 중기 예보 결과 + RecommendationGenerationResult recommendationResult, // 추천 생성 결과 + CleanupResult cleanupResult, // 정리 작업 결과 LocalDateTime overallStartTime, // 전체 시작 시간 LocalDateTime overallEndTime, // 전체 종료 시간 long overallDurationMs, // 전체 소요 시간 (밀리초) @@ -97,4 +101,90 @@ public record CompleteSyncResult( ) { } + /** + * 지역별 추천 생성 결과 + */ + @Builder + public record RegionRecommendationResult( + Long regionId, + String regionName, + boolean success, + int recommendationsGenerated, + int newRecommendations, + int updatedRecommendations, + List processedDates, // 처리된 날짜들 + String errorMessage, + long processingTimeMs + ) { + } + + /** + * 날씨 타입별 통계 + */ + @Builder + public record WeatherTypeStatistics( + int clearWeatherCount, + int cloudyWeatherCount, + int cloudyRainCount, + int cloudySnowCount, + int cloudyRainSnowCount, + int cloudyShowerCount, + Map detailedStats + ) { + } + + /** + * 추천 정보 생성 결과 DTO + */ + @Builder + public record RecommendationGenerationResult( + int totalRegions, // 처리된 지역 수 + int successfulRegions, // 성공한 지역 수 + int failedRegions, // 실패한 지역 수 + int totalRecommendations, // 전체 생성된 추천 수 + int newRecommendations, // 새로 생성된 추천 수 + int updatedRecommendations, // 업데이트된 추천 수 + LocalDate startDate, // 시작 날짜 + LocalDate endDate, // 종료 날짜 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List regionResults, // 지역별 결과 + WeatherTypeStatistics weatherStats, // 날씨별 통계 + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + + /** + * 정리 작업별 통계 + */ + @Builder + public record CleanupStats( + String dataType, // 데이터 타입 (단기/중기/추천) + boolean executed, // 실행 여부 + int recordsFound, // 발견된 레코드 수 + int recordsDeleted // 삭제된 레코드 수 + ) { + } + + /** + * 데이터 정리 결과 DTO + */ + @Builder + public record CleanupResult( + boolean dryRun, // Dry run 여부 + int retentionDays, // 보관 기간 + LocalDate cutoffDate, // 삭제 기준 날짜 + CleanupStats shortTermStats, // 단기 예보 정리 결과 + CleanupStats mediumTermStats, // 중기 예보 정리 결과 + CleanupStats recommendationStats, // 추천 정보 정리 결과 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java index 75d7c6e..ff2b397 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/DailyRecommendation.java @@ -19,7 +19,7 @@ public class DailyRecommendation extends BaseEntity { @Column(name = "daily_recommendation_id") private Long id; - @Column(name = "forecate_date", nullable = false) + @Column(name = "forecast_date", nullable = false) private LocalDate forecastDate; @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java index 79726cc..9a848f3 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/TemplateKeyword.java @@ -17,10 +17,10 @@ public class TemplateKeyword { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "weather_id") - private Keyword weather; + @JoinColumn(name = "weather_template_id", nullable = false) + private WeatherTemplate weatherTemplate; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "weather_template_id") - private WeatherTemplate weatherTemplate; + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java index c6bf70b..50b8303 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/WeatherTemplate.java @@ -5,9 +5,12 @@ import lombok.*; import org.withtime.be.withtimebe.domain.weather.entity.enums.PrecipCategory; import org.withtime.be.withtimebe.domain.weather.entity.enums.TempCategory; -import org.withtime.be.withtimebe.domain.weather.entity.enums.Weather; +import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType; import org.withtime.be.withtimebe.global.common.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @@ -23,7 +26,7 @@ public class WeatherTemplate extends BaseEntity { @Enumerated(EnumType.STRING) @Column(name = "weather", nullable = false) - private Weather weather; + private WeatherType weatherType; @Enumerated(EnumType.STRING) @Column(name = "temp_category", nullable = false) @@ -36,9 +39,10 @@ public class WeatherTemplate extends BaseEntity { @Column(name = "message") private String message; - @Column(name = "keywords") - private String keywords; - @Column(name = "emoji") private String emoji; + + @OneToMany(mappedBy = "weatherTemplate", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List templateKeywords = new ArrayList<>(); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java index d2992ef..dc776a2 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/PrecipCategory.java @@ -1,7 +1,34 @@ package org.withtime.be.withtimebe.domain.weather.entity.enums; public enum PrecipCategory { - 없음, - 약간_비옴, - 강수_많음 + + /** + * 비 없음 (0%) + * 맑거나 흐림 + */ + NONE, + + /** + * 비 거의 없음 (1~30%) + * 가볍게 산책 가능, 실외 일정 유지 가능 + */ + VERY_LOW, + + /** + * 비 약간 가능성 (31~60%) + * 우산 필요 가능성 있음, 유연한 동선 필요 + */ + LOW, + + /** + * 비 올 가능성 높음 (61~90%) + * 실외 지양, 실내 위주 일정 추천 + */ + HIGH, + + /** + * 비 확실 (91~100%) + * 실외 활동 지양, 완전 실내형 코스 구성 + */ + VERY_HIGH } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java index 419987e..78e4248 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/TempCategory.java @@ -1,8 +1,8 @@ package org.withtime.be.withtimebe.domain.weather.entity.enums; public enum TempCategory { - 쌀쌀함, - 선선함, - 적당함, - 무더움 + CHILLY, // 쌀쌀한 날씨 (≤10℃) + COOL, // 선선한 날씨 (11~20℃) + MILD, // 무난한 날씨 (21~25℃) + HOT // 무더운 날씨 (≥26℃) } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java deleted file mode 100644 index 55ccc9e..0000000 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/Weather.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.withtime.be.withtimebe.domain.weather.entity.enums; - -public enum Weather { - 맑음, - 흐림, - 눈 -} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java new file mode 100644 index 0000000..6e69797 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/enums/WeatherType.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.domain.weather.entity.enums; + +public enum WeatherType { + CLEAR, // 맑음 + CLOUDY, // 구름 많음, 흐림 + RAINY, // 비 (구름 많고 비, 흐리고 비) + SNOWY, // 눈 (구름 많고 눈, 흐리고 눈) + RAIN_SNOW, // 비/눈 (흐리고 비/눈, 구름 많고 비/눈) + SHOWER // 소나기 (흐리고 소나기, 구름 많고 소나기) +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java new file mode 100644 index 0000000..b1396de --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/DailyRecommendationRepository.java @@ -0,0 +1,68 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface DailyRecommendationRepository extends JpaRepository { + + /** + * 특정 지역, 특정 날짜의 추천 정보 조회 + * WeatherTemplate, Keyword 정보까지 함께 fetch join + */ + @Query("SELECT dr FROM DailyRecommendation dr " + + "JOIN FETCH dr.weatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k " + + "WHERE dr.region.id = :regionId " + + "AND dr.forecastDate = :date") + Optional findByRegionIdAndDateWithTemplate( + @Param("regionId") Long regionId, + @Param("date") LocalDate date); + + /** + * 특정 지역의 주간 추천 정보 조회 (7일치) + * 시작 날짜부터 7일간의 데이터 조회 + */ + @Query("SELECT dr FROM DailyRecommendation dr " + + "JOIN FETCH dr.weatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k " + + "WHERE dr.region.id = :regionId " + + "AND dr.forecastDate >= :startDate " + + "AND dr.forecastDate < :endDate " + + "ORDER BY dr.forecastDate ASC") + List findWeeklyRecommendations( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + /** + * 오래된 추천 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(dr) FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + long countOldRecommendations(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 추천 데이터 상세 정보 조회 (통계용) + */ + @Query("SELECT MIN(dr.forecastDate), MAX(dr.forecastDate), COUNT(dr) " + + "FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + Object[] getOldRecommendationStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 추천 데이터 삭제용 (cutoffDate 이전) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM DailyRecommendation dr WHERE dr.forecastDate < :cutoffDate") + int deleteOldRecommendations(@Param("cutoffDate") LocalDate cutoffDate); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java new file mode 100644 index 0000000..a61e7d3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/KeywordRepository.java @@ -0,0 +1,8 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.Keyword; + +public interface KeywordRepository extends JpaRepository { + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java index 2d1187b..8650aed 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -1,13 +1,66 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface RawMediumTermWeatherRepository extends JpaRepository { Optional findByRegionIdAndBaseDateAndForecastDate( Long regionId, LocalDate baseDate, LocalDate forecastDate); + + /** + * 특정 지역의 특정 날짜 중기 예보 데이터 조회 (분류용) + */ + @Query("SELECT rmtw FROM RawMediumTermWeather rmtw " + + "WHERE rmtw.region.id = :regionId " + + "AND rmtw.forecastDate = :forecastDate " + + "ORDER BY rmtw.baseDate DESC") + List findLatestByRegionIdAndForecastDate( + @Param("regionId") Long regionId, + @Param("forecastDate") LocalDate forecastDate); + + /** + * 지역별 날짜 범위 중기예보 배치 조회 + */ + @Query(""" + SELECT rmtw FROM RawMediumTermWeather rmtw + WHERE rmtw.region.id = :regionId + AND rmtw.forecastDate BETWEEN :startDate AND :endDate + ORDER BY rmtw.forecastDate ASC, rmtw.baseDate DESC + """) + List findByRegionIdAndForecastDateRange( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + /** + * 오래된 중기 예보 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 예보 대상 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(rmtw) FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + long countOldData(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 중기 예보 데이터 상세 정보 조회 (통계용) + */ + @Query("SELECT MIN(rmtw.forecastDate), MAX(rmtw.forecastDate), COUNT(rmtw) " + + "FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + Object[] getOldDataStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 중기 예보 데이터 삭제 (cutoffDate 이전 예보 대상 데이터) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM RawMediumTermWeather rmtw WHERE rmtw.forecastDate < :cutoffDate") + int deleteOldData(@Param("cutoffDate") LocalDate cutoffDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java index 2af1d25..f1c4477 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -1,9 +1,13 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface RawShortTermWeatherRepository extends JpaRepository { @@ -11,4 +15,55 @@ public interface RawShortTermWeatherRepository extends JpaRepository findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( Long regionId, LocalDate baseDate, String baseTime, LocalDate forecastDate, String forecastTime ); + + /** + * 특정 지역의 특정 날짜 예보 데이터 조회 (분류용) + */ + @Query("SELECT rstw FROM RawShortTermWeather rstw " + + "WHERE rstw.region.id = :regionId " + + "AND rstw.forecastDate = :forecastDate " + + "ORDER BY rstw.baseDate DESC, rstw.baseTime DESC") + List findLatestByRegionIdAndForecastDate( + @Param("regionId") Long regionId, + @Param("forecastDate") LocalDate forecastDate); + + /** + * 지역별 날짜 범위 단기예보 배치 조회 + */ + @Query(""" + SELECT rstw FROM RawShortTermWeather rstw + WHERE rstw.region.id = :regionId + AND rstw.forecastDate BETWEEN :startDate AND :endDate + ORDER BY rstw.forecastDate ASC, rstw.baseDate DESC, rstw.baseTime DESC + """) + List findByRegionIdAndForecastDateRange( + @Param("regionId") Long regionId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + /** + * 오래된 단기 예보 데이터 개수 조회 (삭제 대상 확인용) + * @param cutoffDate 기준 날짜 (이 날짜 이전 예보 대상 데이터가 삭제 대상) + * @return 삭제 대상 레코드 수 + */ + @Query("SELECT COUNT(rstw) FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + long countOldData(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 단기 예보 데이터 상세 정보 조회 (통계용) + * @param cutoffDate 기준 날짜 + * @return [최오래된예보날짜, 최신예보날짜, 레코드수] + */ + @Query("SELECT MIN(rstw.forecastDate), MAX(rstw.forecastDate), COUNT(rstw) " + + "FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + Object[] getOldDataStatistics(@Param("cutoffDate") LocalDate cutoffDate); + + /** + * 오래된 단기 예보 데이터 삭제 (cutoffDate 이전 예보 대상 데이터) + * @return 삭제된 레코드 수 + */ + @Modifying + @Query("DELETE FROM RawShortTermWeather rstw WHERE rstw.forecastDate < :cutoffDate") + int deleteOldData(@Param("cutoffDate") LocalDate cutoffDate); } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java new file mode 100644 index 0000000..90ecd68 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/TemplateKeywordRepository.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.TemplateKeyword; + +public interface TemplateKeywordRepository extends JpaRepository { + +} + diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java new file mode 100644 index 0000000..3dd34fb --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/WeatherTemplateRepository.java @@ -0,0 +1,15 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate; + +import java.util.List; + +public interface WeatherTemplateRepository extends JpaRepository { + + @Query("SELECT wt FROM WeatherTemplate wt " + + "JOIN FETCH wt.templateKeywords tk " + + "JOIN FETCH tk.keyword k") + List findAllWithKeywords(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java index e584767..a381a0d 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java @@ -3,7 +3,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCleanupService; import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService; import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO; import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; @@ -19,6 +21,8 @@ public class WeatherTriggerServiceImpl implements WeatherTriggerService{ private final WeatherDataCollectionService dataCollectionService; + private final WeatherRecommendationGenerationService recommendationGenerationService; + private final WeatherDataCleanupService dataCleanupService; public WeatherSyncResDTO.ManualTriggerResult triggerAsync(WeatherSyncReqDTO.ManualTrigger request) { LocalDateTime triggerTime = LocalDateTime.now(); @@ -47,30 +51,55 @@ private Object executeJob(WeatherSyncReqDTO.ManualTrigger request) { return switch (request.jobType()) { case "SHORT_TERM" -> { - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + log.debug("SHORT_TERM 작업 - 계산된 base_date: {}, base_time: {}", + baseDateTime.baseDate(), baseDateTime.baseTime()); + yield dataCollectionService.collectShortTermWeatherData( - request.targetRegionIds(), baseDate, baseTime, true); // ← forceExecution = true + request.targetRegionIds(), baseDateTime.getBaseDateAsLocalDate(), + baseDateTime.baseTime(), true); // forceExecution = true } case "MEDIUM_TERM" -> dataCollectionService.collectMediumTermWeatherData( - request.targetRegionIds(), LocalDate.now(), true); // ← forceExecution = true + request.targetRegionIds(), LocalDate.now(), true); // forceExecution = true + + case "RECOMMENDATION" -> { + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + yield recommendationGenerationService.generateRecommendations( + request.targetRegionIds(), startDate, endDate, true, "일반"); + } + + case "CLEANUP" -> dataCleanupService.cleanupOldWeatherData( + 7, true, true, true, false); case "ALL" -> { - LocalDateTime now = LocalDateTime.now(); - LocalDate baseDate = now.toLocalDate(); - String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + // 올바른 base_date와 base_time 계산 + WeatherDataHelper.BaseDateTime baseDateTime = WeatherDataHelper.calculateBaseDateTime(); + log.debug("ALL 작업 - 계산된 base_date: {}, base_time: {}", + baseDateTime.baseDate(), baseDateTime.baseTime()); - var shortResult = dataCollectionService.collectShortTermWeatherData( - request.targetRegionIds(), baseDate, baseTime, true); + WeatherSyncResDTO.ShortTermSyncResult shortResult = dataCollectionService.collectShortTermWeatherData( + request.targetRegionIds(), baseDateTime.getBaseDateAsLocalDate(), + baseDateTime.baseTime(), true); - var mediumResult = dataCollectionService.collectMediumTermWeatherData( + WeatherSyncResDTO.MediumTermSyncResult mediumResult = dataCollectionService.collectMediumTermWeatherData( request.targetRegionIds(), LocalDate.now(), true); + LocalDate startDate = LocalDate.now(); + LocalDate endDate = startDate.plusDays(6); + WeatherSyncResDTO.RecommendationGenerationResult recommendationResult = recommendationGenerationService.generateRecommendations( + request.targetRegionIds(), startDate, endDate, true, "일반"); + + WeatherSyncResDTO.CleanupResult cleanupResult = dataCleanupService.cleanupOldWeatherData( + 7, true, true, true, false); + yield WeatherSyncResDTO.CompleteSyncResult.builder() .shortTermResult(shortResult) .mediumTermResult(mediumResult) + .recommendationResult(recommendationResult) + .cleanupResult(cleanupResult) .overallStartTime(LocalDateTime.now()) .overallEndTime(LocalDateTime.now()) .overallDurationMs(0L) diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 34e0df7..3f5e593 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -95,7 +95,7 @@ weather: # API 타임아웃 설정 timeout: connect: 5000 - read: 15000 + read: 20000 # 재시도 설정 retry: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ebb1f92..d06fb42 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -95,7 +95,7 @@ weather: # API 타임아웃 설정 timeout: connect: 5000 - read: 15000 + read: 20000 # 재시도 설정 retry: