Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 이상 많은 비
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,13 +30,16 @@
public class WeatherController {

private final WeatherTriggerService weatherTriggerService;
private final WeatherRecommendationGenerationService weatherRecommendationGenerationService;

@PostMapping("/trigger")
@Operation(summary = "수동 동기화 트리거 API by 지미 [Only Admin]",
description = """
관리자가 수동으로 다음 중 하나의 작업을 실행합니다:
- SHORT_TERM: 단기 예보 데이터 수집
- MEDIUM_TERM: 중기 예보 데이터 수집
- RECOMMENDATION: 날씨 기반 추천 생성
- CLEANUP: 오래된 날씨 데이터 삭제
- ALL: 전체 동기화 작업 수행
---
모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다.
Expand Down Expand Up @@ -60,4 +69,63 @@ public DefaultResponse<WeatherSyncResDTO.ManualTriggerResult> 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<WeatherResDTO.WeeklyRecommendation> 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<WeatherResDTO.WeeklyPrecipitation> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<DailyRecommendation> recommendations, Long regionId, String regionName,
LocalDate startDate, LocalDate endDate) {

Map<LocalDate, DailyRecommendation> recommendationMap = recommendations.stream()
.collect(Collectors.toMap(
DailyRecommendation::getForecastDate,
rec -> rec
));

List<WeatherResDTO.DailyWeatherRecommendation> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<WeatherSyncResDTO.RegionRecommendationResult> regionResults,
Map<WeatherType, Integer> weatherStats,
List<String> 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();
}
}
Loading