diff --git a/src/main/java/com/semosan/api/common/config/WebClientConfig.java b/src/main/java/com/semosan/api/common/config/WebClientConfig.java index 7b81c07..36003a8 100644 --- a/src/main/java/com/semosan/api/common/config/WebClientConfig.java +++ b/src/main/java/com/semosan/api/common/config/WebClientConfig.java @@ -21,4 +21,11 @@ public WebClient appleApiWebClient() { .build(); } + @Bean + public WebClient weatherApiWebClient() { + return WebClient.builder() + .baseUrl("https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0") + .build(); + } + } diff --git a/src/main/java/com/semosan/api/common/util/GridConverter.java b/src/main/java/com/semosan/api/common/util/GridConverter.java new file mode 100644 index 0000000..81b6ffb --- /dev/null +++ b/src/main/java/com/semosan/api/common/util/GridConverter.java @@ -0,0 +1,48 @@ +package com.semosan.api.common.util; + +public class GridConverter { + + private static final double RE = 6371.00877; + private static final double GRID = 5.0; + private static final double SLAT1 = 30.0; + private static final double SLAT2 = 60.0; + private static final double OLON = 126.0; + private static final double OLAT = 38.0; + private static final double XO = 43; + private static final double YO = 136; + + private GridConverter() { + } + + public record Grid(int nx, int ny) { + } + + public static Grid toGrid(double lat, double lon) { + double degrad = Math.PI / 180.0; + + double re = RE / GRID; + double slat1 = SLAT1 * degrad; + double slat2 = SLAT2 * degrad; + double olon = OLON * degrad; + double olat = OLAT * degrad; + + double sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + double sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; + double ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + ro = re * sf / Math.pow(ro, sn); + + double ra = Math.tan(Math.PI * 0.25 + lat * degrad * 0.5); + ra = re * sf / Math.pow(ra, sn); + double theta = lon * degrad - olon; + if (theta > Math.PI) theta -= 2.0 * Math.PI; + if (theta < -Math.PI) theta += 2.0 * Math.PI; + theta *= sn; + + int nx = (int) Math.floor(ra * Math.sin(theta) + XO + 0.5); + int ny = (int) Math.floor(ro - ra * Math.cos(theta) + YO + 0.5); + + return new Grid(nx, ny); + } +} diff --git a/src/main/java/com/semosan/api/common/weather/WeatherService.java b/src/main/java/com/semosan/api/common/weather/WeatherService.java new file mode 100644 index 0000000..661dacb --- /dev/null +++ b/src/main/java/com/semosan/api/common/weather/WeatherService.java @@ -0,0 +1,94 @@ +package com.semosan.api.common.weather; + +import com.semosan.api.common.util.GridConverter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class WeatherService { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HHmm"); + + private final WebClient webClient; + private final String apiKey; + + public WeatherService( + @Qualifier("weatherApiWebClient") WebClient webClient, + @Value("${weather.api-key}") String apiKey + ) { + this.webClient = webClient; + this.apiKey = apiKey; + } + + public Optional getTemperature(double latitude, double longitude) { + try { + GridConverter.Grid grid = GridConverter.toGrid(latitude, longitude); + LocalDateTime now = adjustBaseTime(LocalDateTime.now()); + + String baseDate = now.format(DATE_FMT); + String baseTime = now.format(TIME_FMT); + + Map response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/getUltraSrtNcst") + .queryParam("serviceKey", apiKey) + .queryParam("pageNo", 1) + .queryParam("numOfRows", 10) + .queryParam("dataType", "JSON") + .queryParam("base_date", baseDate) + .queryParam("base_time", baseTime) + .queryParam("nx", grid.nx()) + .queryParam("ny", grid.ny()) + .build()) + .retrieve() + .bodyToMono(Map.class) + .block(Duration.ofSeconds(3)); + + return extractTemperature(response); + } catch (Exception e) { + log.warn("기상청 API 호출 실패: {}", e.getMessage()); + return Optional.empty(); + } + } + + @SuppressWarnings("unchecked") + private Optional extractTemperature(Map response) { + if (response == null) return Optional.empty(); + + Map root = (Map) response.get("response"); + if (root == null) return Optional.empty(); + + Map body = (Map) root.get("body"); + if (body == null) return Optional.empty(); + + Map items = (Map) body.get("items"); + if (items == null) return Optional.empty(); + + List> itemList = (List>) items.get("item"); + if (itemList == null) return Optional.empty(); + + return itemList.stream() + .filter(item -> "T1H".equals(item.get("category"))) + .findFirst() + .map(item -> Double.parseDouble(String.valueOf(item.get("obsrValue")))); + } + + private LocalDateTime adjustBaseTime(LocalDateTime now) { + if (now.getMinute() < 40) { + now = now.minusHours(1); + } + return now.withMinute(0).withSecond(0).withNano(0); + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java b/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java index bb87090..a1190c5 100644 --- a/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java +++ b/src/main/java/com/semosan/api/domain/hiking/dto/response/HikingRecordDetailResponse.java @@ -25,6 +25,7 @@ public record HikingRecordDetailResponse( Double ascentMeters, Double descentMeters, Integer calories, + Double temperature, LocalDateTime startedAt, LocalDateTime endedAt, @JsonRawValue String track, @@ -84,6 +85,7 @@ public static HikingRecordDetailResponse of( record.getAscent(), record.getDescent(), record.getCalories(), + record.getTemperature(), record.getStartedAt(), record.getEndedAt(), track, diff --git a/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java b/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java index 5473f24..7f3a60a 100644 --- a/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java +++ b/src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java @@ -77,6 +77,9 @@ public class HikingRecord extends BaseEntity { @Column(name = "ended_at") private LocalDateTime endedAt; + @Column(name = "temperature") + private Double temperature; + @Column(name = "clive_image_url", length = 500) private String cliveImageUrl; @@ -115,6 +118,10 @@ public static HikingRecord fromTrackingSession( .build(); } + public void updateTemperature(Double temperature) { + this.temperature = temperature; + } + private static int computeDurationSeconds(TrackingSession session) { if (session.getStartedAt() == null || session.getEndedAt() == null) { return 0; diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index b86b62d..e4399f8 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -19,6 +19,7 @@ import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; +import com.semosan.api.common.weather.WeatherService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -45,6 +46,7 @@ public class TrackingSessionService { private final HikingMemberRepository hikingMemberRepository; private final TrackingMilestoneTriggerService milestoneTriggerService; private final ApplicationEventPublisher eventPublisher; + private final WeatherService weatherService; /** * 세션 생성. 유저당 진행 중 세션은 1개만 허용한다. @@ -128,6 +130,12 @@ public TrackingSessionResponse complete(Long userId, Long sessionId) { stats.ascentMeters(), stats.descentMeters() ); + Mountain mountain = session.getMountain(); + if (mountain.getLatitude() != null && mountain.getLongitude() != null) { + weatherService.getTemperature(mountain.getLatitude(), mountain.getLongitude()) + .ifPresent(record::updateTemperature); + } + HikingRecord savedRecord = hikingRecordRepository.save(record); HikingMember member = HikingMember.create(savedRecord, session.getUser()); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 378837c..712f0cd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -79,6 +79,9 @@ management: export: enabled: true +weather: + api-key: ${WEATHER_API_KEY} + discord: alert: enabled: ${DISCORD_ALERT_ENABLED} diff --git a/src/main/resources/db/migration/V27__add_temperature_to_hiking_records.sql b/src/main/resources/db/migration/V27__add_temperature_to_hiking_records.sql new file mode 100644 index 0000000..e7493a2 --- /dev/null +++ b/src/main/resources/db/migration/V27__add_temperature_to_hiking_records.sql @@ -0,0 +1,2 @@ +ALTER TABLE hiking_records + ADD COLUMN temperature DOUBLE PRECISION; \ No newline at end of file