Skip to content

Commit ce8ebb1

Browse files
authored
✨ [Feat] 날씨 데이터로 추천 데이터 생성, 오래된 데이터 삭제 및 스케줄러 추가 (#50)
2 parents 27ca54a + 478a2db commit ce8ebb1

36 files changed

+2359
-48
lines changed

src/main/java/org/withtime/be/withtimebe/WithTimeBeApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

8+
@EnableScheduling
79
@EnableJpaAuditing
810
@SpringBootApplication
911
public class WithTimeBeApplication {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.withtime.be.withtimebe.domain.weather.config;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
@Configuration
8+
@Getter
9+
@Setter
10+
public class WeatherClassificationConfig {
11+
12+
private TemperatureThresholds temperature = new TemperatureThresholds();
13+
private PrecipitationThresholds precipitation = new PrecipitationThresholds();
14+
15+
/**
16+
* 기온 분류 임계값
17+
* 새로운 기획: 중앙값 기준
18+
*/
19+
@Getter
20+
@Setter
21+
public static class TemperatureThresholds {
22+
// 쌀쌀한 날씨 ≤ 10℃
23+
private double chillyCoolBoundary = 10.0;
24+
25+
// 선선한 날씨 11~20℃
26+
private double coolMildBoundary = 20.0;
27+
28+
// 무난한 날씨 21~25℃
29+
private double mildHotBoundary = 25.0;
30+
31+
// 무더운 날씨 ≥ 26℃
32+
}
33+
34+
/**
35+
* 강수 분류 임계값
36+
* 새로운 기획: 강수확률 기반
37+
*/
38+
@Getter
39+
@Setter
40+
public static class PrecipitationThresholds {
41+
// 비 없음: 0%
42+
private double noneVeryLowBoundary = 0.0;
43+
44+
// 비 거의 없음: 1~30%
45+
private double veryLowLowBoundary = 30.0;
46+
47+
// 비 약간 가능성: 31~60%
48+
private double lowHighBoundary = 60.0;
49+
50+
// 비 올 가능성 높음: 61~90%
51+
private double highVeryHighBoundary = 90.0;
52+
53+
// 비 확실: 91~100%
54+
55+
// 기존 강수량 임계값도 유지 (단기예보에서 사용할 수 있음)
56+
private double lightAmountThreshold = 1.0; // 1mm 이상 가벼운 비
57+
private double heavyAmountThreshold = 10.0; // 10mm 이상 많은 비
58+
}
59+
}

src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
package org.withtime.be.withtimebe.domain.weather.controller;
22

33
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
45
import io.swagger.v3.oas.annotations.responses.ApiResponse;
56
import io.swagger.v3.oas.annotations.responses.ApiResponses;
67
import io.swagger.v3.oas.annotations.tags.Tag;
78
import jakarta.validation.Valid;
9+
import jakarta.validation.constraints.NotNull;
10+
import jakarta.validation.constraints.Positive;
811
import lombok.RequiredArgsConstructor;
912
import lombok.extern.slf4j.Slf4j;
1013
import org.namul.api.payload.response.DefaultResponse;
11-
import org.springframework.web.bind.annotation.PostMapping;
12-
import org.springframework.web.bind.annotation.RequestBody;
13-
import org.springframework.web.bind.annotation.RequestMapping;
14-
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.format.annotation.DateTimeFormat;
15+
import org.springframework.web.bind.annotation.*;
16+
import org.withtime.be.withtimebe.domain.weather.data.service.WeatherRecommendationGenerationService;
17+
import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherReqDTO;
18+
import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO;
1519
import org.withtime.be.withtimebe.domain.weather.service.command.WeatherTriggerService;
1620
import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO;
1721
import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO;
1822

23+
import java.time.LocalDate;
24+
1925
@Slf4j
2026
@RestController
2127
@RequiredArgsConstructor
@@ -24,13 +30,16 @@
2430
public class WeatherController {
2531

2632
private final WeatherTriggerService weatherTriggerService;
33+
private final WeatherRecommendationGenerationService weatherRecommendationGenerationService;
2734

2835
@PostMapping("/trigger")
2936
@Operation(summary = "수동 동기화 트리거 API by 지미 [Only Admin]",
3037
description = """
3138
관리자가 수동으로 다음 중 하나의 작업을 실행합니다:
3239
- SHORT_TERM: 단기 예보 데이터 수집
3340
- MEDIUM_TERM: 중기 예보 데이터 수집
41+
- RECOMMENDATION: 날씨 기반 추천 생성
42+
- CLEANUP: 오래된 날씨 데이터 삭제
3443
- ALL: 전체 동기화 작업 수행
3544
---
3645
모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다.
@@ -60,4 +69,63 @@ public DefaultResponse<WeatherSyncResDTO.ManualTriggerResult> manualTrigger(
6069

6170
return DefaultResponse.ok(response);
6271
}
72+
73+
@GetMapping("/{regionId}/weekly")
74+
@Operation(
75+
summary = "지역별 주간 날씨 기반 추천 조회",
76+
description = """
77+
특정 지역의 7일치(오늘 기준) 날씨 데이터를 바탕으로 한 데이트 추천 정보를 제공합니다.
78+
79+
- 날짜 범위: `startDate`부터 7일간 (startDate 포함)
80+
- 추천 데이터는 날씨 분류 후 템플릿 기반으로 생성됩니다.
81+
"""
82+
)
83+
@ApiResponses(value = {
84+
@ApiResponse(responseCode = "200", description = "주간 추천 조회 성공", useReturnTypeSchema = true),
85+
@ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"),
86+
@ApiResponse(responseCode = "404", description = "해당 지역의 추천 정보가 존재하지 않음")
87+
})
88+
public DefaultResponse<WeatherResDTO.WeeklyRecommendation> getWeeklyRecommendation(
89+
@Parameter(description = "지역 ID)", required = true)
90+
@PathVariable @NotNull @Positive Long regionId,
91+
92+
@Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-17")
93+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) {
94+
95+
log.info("주간 날씨 추천 조회 API 호출: regionId={}, startDate={}", regionId, startDate);
96+
WeatherReqDTO.GetWeeklyRecommendation request = WeatherReqDTO.GetWeeklyRecommendation.of(regionId, startDate);
97+
WeatherResDTO.WeeklyRecommendation response = weatherRecommendationGenerationService.getWeeklyRecommendation(request);
98+
return DefaultResponse.ok(response);
99+
}
100+
101+
@GetMapping("/{regionId}/precipitation")
102+
@Operation(
103+
summary = "지역별 7일간 강수확률 조회",
104+
description = """
105+
특정 지역의 7일간 강수확률 정보만 간단하게 조회합니다.
106+
107+
- 날짜 범위: `startDate`부터 7일간 (startDate 포함)
108+
- 중기예보 데이터를 우선적으로 사용하고, 없을 경우 단기예보 데이터 사용
109+
- 각 날짜별 강수확률과 주간 평균, 경향 분석 제공
110+
"""
111+
)
112+
@ApiResponses(value = {
113+
@ApiResponse(responseCode = "200", description = "강수확률 조회 성공", useReturnTypeSchema = true),
114+
@ApiResponse(responseCode = "400", description = "잘못된 파라미터 형식 (날짜 혹은 지역 ID 오류)"),
115+
@ApiResponse(responseCode = "404", description = "해당 지역이 존재하지 않음")
116+
})
117+
public DefaultResponse<WeatherResDTO.WeeklyPrecipitation> getWeeklyPrecipitation(
118+
@Parameter(description = "지역 ID", required = true)
119+
@PathVariable @NotNull @Positive Long regionId,
120+
121+
@Parameter(description = "조회 시작일 (YYYY-MM-DD)", required = true, example = "2025-07-18")
122+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate) {
123+
124+
log.info("7일간 강수확률 조회 API 호출: regionId={}, startDate={}", regionId, startDate);
125+
126+
WeatherReqDTO.GetWeeklyPrecipitation request = WeatherReqDTO.GetWeeklyPrecipitation.of(regionId, startDate);
127+
WeatherResDTO.WeeklyPrecipitation response = weatherRecommendationGenerationService.getWeeklyPrecipitation(request);
128+
129+
return DefaultResponse.ok(response);
130+
}
63131
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package org.withtime.be.withtimebe.domain.weather.converter;
2+
3+
import lombok.AccessLevel;
4+
import lombok.NoArgsConstructor;
5+
import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherResDTO;
6+
import org.withtime.be.withtimebe.domain.weather.entity.DailyRecommendation;
7+
import org.withtime.be.withtimebe.domain.weather.entity.Region;
8+
import org.withtime.be.withtimebe.domain.weather.entity.WeatherTemplate;
9+
10+
import java.time.LocalDate;
11+
import java.util.List;
12+
import java.util.Map;
13+
import java.util.stream.Collectors;
14+
15+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
16+
public class WeatherConverter {
17+
18+
/**
19+
* 데이터가 없는 날짜용 빈 DailyWeatherRecommendation 생성
20+
*/
21+
private static WeatherResDTO.DailyWeatherRecommendation createEmptyDailyRecommendation(LocalDate date) {
22+
return WeatherResDTO.DailyWeatherRecommendation.builder()
23+
.forecastDate(date)
24+
.weatherType(null)
25+
.tempCategory(null)
26+
.precipCategory(null)
27+
.message("해당 날짜의 날씨 추천 정보가 없습니다.")
28+
.emoji("❓")
29+
.keywords(List.of("정보없음"))
30+
.build();
31+
}
32+
33+
/**
34+
* DailyRecommendation을 DailyWeatherRecommendation DTO로 변환
35+
* 새로운 기획의 구조화된 응답 반영
36+
*/
37+
public static WeatherResDTO.DailyWeatherRecommendation toDailyWeatherRecommendation(
38+
DailyRecommendation recommendation, boolean hasRecommendation) {
39+
40+
if (!hasRecommendation) {
41+
return createEmptyDailyRecommendation(recommendation.getForecastDate());
42+
}
43+
44+
WeatherTemplate template = recommendation.getWeatherTemplate();
45+
List<String> keywords = template.getTemplateKeywords().stream()
46+
.map(tk -> tk.getKeyword().getName())
47+
.distinct()
48+
.collect(Collectors.toList());
49+
50+
return WeatherResDTO.DailyWeatherRecommendation.builder()
51+
.forecastDate(recommendation.getForecastDate())
52+
.weatherType(template.getWeatherType())
53+
.tempCategory(template.getTempCategory())
54+
.precipCategory(template.getPrecipCategory())
55+
.message(template.getMessage())
56+
.emoji(template.getEmoji())
57+
.keywords(keywords)
58+
.build();
59+
}
60+
61+
/**
62+
* DailyRecommendation 리스트를 WeeklyRecommendation DTO로 변환
63+
*/
64+
public static WeatherResDTO.WeeklyRecommendation toWeeklyRecommendation(
65+
List<DailyRecommendation> recommendations, Long regionId, String regionName,
66+
LocalDate startDate, LocalDate endDate) {
67+
68+
Map<LocalDate, DailyRecommendation> recommendationMap = recommendations.stream()
69+
.collect(Collectors.toMap(
70+
DailyRecommendation::getForecastDate,
71+
rec -> rec
72+
));
73+
74+
List<WeatherResDTO.DailyWeatherRecommendation> dailyRecommendations =
75+
startDate.datesUntil(endDate.plusDays(1))
76+
.map(date -> {
77+
DailyRecommendation rec = recommendationMap.get(date);
78+
if (rec != null) {
79+
return toDailyWeatherRecommendation(rec, true);
80+
} else {
81+
return createEmptyDailyRecommendation(date);
82+
}
83+
})
84+
.collect(Collectors.toList());
85+
86+
WeatherResDTO.RegionInfo regionInfo;
87+
if (!recommendations.isEmpty()) {
88+
Region region = recommendations.get(0).getRegion();
89+
regionInfo = toRegionInfo(region);
90+
} else {
91+
regionInfo = WeatherResDTO.RegionInfo.builder()
92+
.regionId(regionId)
93+
.regionName(regionName)
94+
.landRegCode(null)
95+
.tempRegCode(null)
96+
.build();
97+
}
98+
99+
return WeatherResDTO.WeeklyRecommendation.builder()
100+
.region(regionInfo)
101+
.startDate(startDate)
102+
.endDate(endDate)
103+
.dailyRecommendations(dailyRecommendations)
104+
.totalDays(dailyRecommendations.size())
105+
.message(String.format("%s 지역의 %s부터 %s까지 주간 날씨 추천입니다.",
106+
regionName, startDate, endDate))
107+
.build();
108+
}
109+
110+
/**
111+
* Region 엔티티를 RegionInfo DTO로 변환
112+
*/
113+
public static WeatherResDTO.RegionInfo toRegionInfo(Region region) {
114+
return WeatherResDTO.RegionInfo.builder()
115+
.regionId(region.getId())
116+
.regionName(region.getName())
117+
.landRegCode(region.getRegionCode().getLandRegCode())
118+
.tempRegCode(region.getRegionCode().getTempRegCode())
119+
.build();
120+
}
121+
}

src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import lombok.NoArgsConstructor;
55
import lombok.extern.slf4j.Slf4j;
66
import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO;
7+
import org.withtime.be.withtimebe.domain.weather.entity.enums.WeatherType;
78

89
import java.time.LocalDate;
910
import java.time.LocalDateTime;
1011
import java.util.List;
12+
import java.util.Map;
1113

1214
@Slf4j
1315
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@@ -89,4 +91,50 @@ public static WeatherSyncResDTO.MediumTermSyncResult toMediumTermSyncResult(
8991
.message(message)
9092
.build();
9193
}
94+
95+
/**
96+
* 추천 생성 결과 생성
97+
*/
98+
public static WeatherSyncResDTO.RecommendationGenerationResult toRecommendationGenerationResult(
99+
int totalRegions, int successfulRegions, int failedRegions,
100+
int totalRecommendations, int newRecommendations, int updatedRecommendations,
101+
LocalDate startDate, LocalDate endDate,
102+
LocalDateTime startTime, LocalDateTime endTime,
103+
List<WeatherSyncResDTO.RegionRecommendationResult> regionResults,
104+
Map<WeatherType, Integer> weatherStats,
105+
List<String> errorMessages) {
106+
107+
long durationMs = java.time.Duration.between(startTime, endTime).toMillis();
108+
String message = String.format(
109+
"추천 정보 생성 완료: 성공 %d/%d 지역, 신규 %d개, 업데이트 %d개 추천 생성",
110+
successfulRegions, totalRegions, newRecommendations, updatedRecommendations);
111+
112+
WeatherSyncResDTO.WeatherTypeStatistics weatherTypeStats = WeatherSyncResDTO.WeatherTypeStatistics.builder()
113+
.clearWeatherCount(weatherStats.getOrDefault(WeatherType.CLEAR, 0))
114+
.cloudyWeatherCount(weatherStats.getOrDefault(WeatherType.CLOUDY, 0))
115+
.cloudyRainCount(weatherStats.getOrDefault(WeatherType.RAINY, 0))
116+
.cloudySnowCount(weatherStats.getOrDefault(WeatherType.SNOWY, 0))
117+
.cloudyRainSnowCount(weatherStats.getOrDefault(WeatherType.RAIN_SNOW, 0))
118+
.cloudyShowerCount(weatherStats.getOrDefault(WeatherType.SHOWER, 0))
119+
.detailedStats(weatherStats)
120+
.build();
121+
122+
return WeatherSyncResDTO.RecommendationGenerationResult.builder()
123+
.totalRegions(totalRegions)
124+
.successfulRegions(successfulRegions)
125+
.failedRegions(failedRegions)
126+
.totalRecommendations(totalRecommendations)
127+
.newRecommendations(newRecommendations)
128+
.updatedRecommendations(updatedRecommendations)
129+
.startDate(startDate)
130+
.endDate(endDate)
131+
.processingStartTime(startTime)
132+
.processingEndTime(endTime)
133+
.processingDurationMs(durationMs)
134+
.regionResults(regionResults)
135+
.weatherStats(weatherTypeStats)
136+
.errorMessages(errorMessages)
137+
.message(message)
138+
.build();
139+
}
92140
}

0 commit comments

Comments
 (0)