diff --git a/build.gradle b/build.gradle index d211311..e678725 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,12 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Netty + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + // Email implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherWebClientConfig.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherWebClientConfig.java new file mode 100644 index 0000000..00a7479 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherWebClientConfig.java @@ -0,0 +1,90 @@ +package org.withtime.be.withtimebe.domain.weather.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Configuration +public class WeatherWebClientConfig { + + @Value("${weather.api.base-url}") + private String baseUrl; + + @Value("${weather.api.timeout.connect}") + private int connectTimeout; + + @Value("${weather.api.timeout.read}") + private int readTimeout; + + /** + * 기상청 API 전용 WebClient 설정 + */ + @Bean("weatherWebClient") + public WebClient weatherWebClient() { + // HTTP 클라이언트 타임아웃 설정 + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout) + .responseTimeout(Duration.ofMillis(readTimeout)) + .doOnConnected(conn -> + conn.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS)) + .addHandlerLast(new WriteTimeoutHandler(connectTimeout, TimeUnit.MILLISECONDS)) + ); + + return WebClient.builder() + .baseUrl(baseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .filter(logRequest()) + .filter(logResponse()) + .filter(handleErrors()) + .build(); + } + + /** + * 요청 로깅 필터 + */ + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { + log.info("기상청 API 요청: {} {}", clientRequest.method(), clientRequest.url()); + log.debug("요청 헤더: {}", clientRequest.headers()); + return Mono.just(clientRequest); + }); + } + + /** + * 응답 로깅 필터 + */ + private ExchangeFilterFunction logResponse() { + return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { + log.info("기상청 API 응답: {}", clientResponse.statusCode()); + log.debug("응답 헤더: {}", clientResponse.headers().asHttpHeaders()); + return Mono.just(clientResponse); + }); + } + + /** + * 에러 처리 필터 + */ + private ExchangeFilterFunction handleErrors() { + return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { + if (clientResponse.statusCode().isError()) { + log.error("기상청 API 오류 응답: {} {}", + clientResponse.statusCode().value(), + clientResponse.statusCode()); + } + return Mono.just(clientResponse); + }); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java new file mode 100644 index 0000000..7346812 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java @@ -0,0 +1,266 @@ +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.service.command.RegionCommandService; +import org.withtime.be.withtimebe.domain.weather.service.query.RegionQueryService; + +@Slf4j +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +@Tag(name = "지역 관리 API", description = "지역 등록/관리 API") +public class RegionController { + + private final RegionCommandService regionCommandService; + private final RegionQueryService regionQueryService; + + @PostMapping("/codes") + @Operation(summary = "지역코드 등록 API by 지미 [Only Admin]", description = "새로운 지역코드를 등록합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지역코드 등록 성공"), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """) + }) + public DefaultResponse createRegionCode( + @Valid @RequestBody RegionReqDTO.CreateRegionCode request) { + log.info("지역코드 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegionCode response = regionCommandService.createRegionCode(request); + return DefaultResponse.created(response); + } + + @PostMapping + @Operation(summary = "지역 등록 API by 지미 [Only Admin]", + description = "기존 지역코드를 사용하여 새로운 지역을 등록합니다. 위경도는 자동으로 격자 좌표로 변환됩니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_2: 올바르지 않은 좌표입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. + """) + }) + public DefaultResponse createRegion( + @Valid @RequestBody RegionReqDTO.CreateRegion request) { + log.info("지역 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegion(request); + return DefaultResponse.ok(response); + } + + @PostMapping("/bundle") + @Operation(summary = "지역+지역코드 동시 등록 API by 지미 [Only Admin]", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_2: 올바르지 않은 좌표입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. + """) + }) + public DefaultResponse createRegionWithNewCode( + @Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) { + log.info("지역+지역코드 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegionWithNewCode(request); + return DefaultResponse.ok(response); + } + + @GetMapping("/codes") + @Operation(summary = "지역코드 목록 조회 API by 지미 [Only Admin]", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """) + }) + public DefaultResponse getAllRegionCodes() { + log.info("지역코드 목록 조회 API 호출"); + + RegionResDTO.RegionCodeList response = regionQueryService.getAllRegionCodes(); + return DefaultResponse.ok(response); + } + + @GetMapping + @Operation(summary = "지역 목록 조회 by 지미", description = "등록된 모든 지역 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """) + }) + public DefaultResponse getAllRegions() { + log.info("지역 목록 조회 API 호출"); + + RegionResDTO.RegionList response = regionQueryService.getAllRegions(); + return DefaultResponse.ok(response); + } + + @GetMapping("/{regionId}") + @Operation(summary = "지역 상세 조회 API by 지미", description = "특정 지역의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """) + }) + public DefaultResponse getRegion( + @Parameter(description = "지역 ID", required = true) + @PathVariable Long regionId) { + + log.info("지역 상세 조회 API 호출: regionId={}", regionId); + + RegionResDTO.RegionInfo response = regionQueryService.getRegionById(regionId); + return DefaultResponse.ok(response); + } + + @GetMapping("/search") + @Operation(summary = "지역 검색 API by 지미", description = "지역명으로 검색합니다. 부분 일치 검색을 지원합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "검색 성공", useReturnTypeSchema = true) + }) + public DefaultResponse searchRegions( + @RequestParam String keyword) { + RegionResDTO.RegionSearchResult response = regionQueryService.searchRegions(keyword); + return DefaultResponse.ok(response); + } + + @DeleteMapping("/codes/{regionCodeId}") + @Operation(summary = "지역코드 삭제 API by 지미 [Only Admin]", description = "지역코드를 삭제합니다(해당 코드를 사용하는 지역이 없어야 함. 관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse deleteRegionCode( + @Parameter(description = "지역코드 ID", required = true) + @PathVariable Long regionCodeId) { + log.info("지역코드 삭제 API 호출: regionCodeId={}", regionCodeId); + + RegionResDTO.DeleteRegionCode response = regionCommandService.deleteRegionCode(regionCodeId); + return DefaultResponse.ok(response); + } + + @DeleteMapping("/{regionId}") + @Operation(summary = "지역 삭제 API by 지미 [Only Admin]", description = "지역을 삭제합니다. 연관된 모든 날씨 데이터도 함께 삭제됩니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse deleteRegion( + @Parameter(description = "지역 ID", required = true) + @PathVariable Long regionId) { + + log.info("지역 삭제 API 호출: regionId={}", regionId); + + RegionResDTO.DeleteRegion response = regionCommandService.deleteRegion(regionId); + + return DefaultResponse.ok(response); + } +} 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 new file mode 100644 index 0000000..68d82f3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java @@ -0,0 +1,63 @@ +package org.withtime.be.withtimebe.domain.weather.controller; + +import io.swagger.v3.oas.annotations.Operation; +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 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.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; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/weather") +@Tag(name = "날씨 API", description = "관리자가 수동으로 날씨 데이터를 동기화하거나 추천 정보를 생성합니다.") +public class WeatherController { + + private final WeatherTriggerService weatherTriggerService; + + @PostMapping("/trigger") + @Operation(summary = "수동 동기화 트리거 API by 지미 [Only Admin]", + description = """ + 관리자가 수동으로 다음 중 하나의 작업을 실행합니다: + - SHORT_TERM: 단기 예보 데이터 수집 + - MEDIUM_TERM: 중기 예보 데이터 수집 + - ALL: 전체 동기화 작업 수행 + --- + 모든 작업은 비동기로 실행되며, 기존 데이터는 강제로 덮어씁니다. + targetRegionIds의 값이 null이면 등록된 모든 지역을 대상으로 작업을 실행합니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "트리거 성공 (비동기 작업 시작됨)", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - 잘못된 jobType 입력 (SHORT_TERM, MEDIUM_TERM 등만 허용됨) + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """) + }) + public DefaultResponse manualTrigger( + @Valid @RequestBody WeatherSyncReqDTO.ManualTrigger request) { + + log.info("수동 트리거 요청: jobType={}, targetRegionIds={}", + request.jobType(), request.targetRegionIds()); + + WeatherSyncResDTO.ManualTriggerResult response = weatherTriggerService.triggerAsync(request); + + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java new file mode 100644 index 0000000..46773a6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java @@ -0,0 +1,159 @@ +package org.withtime.be.withtimebe.domain.weather.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; + +import java.math.BigDecimal; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RegionConverter { + + public static RegionCode toRegionCode(RegionReqDTO.CreateRegionCode request) { + return RegionCode.builder() + .landRegCode(request.landRegCode()) + .tempRegCode(request.tempRegCode()) + .name(request.name()) + .build(); + } + + public static RegionResDTO.CreateRegionCode toCreateRegionCodeResponse(RegionCode regionCode) { + return RegionResDTO.CreateRegionCode.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .message("지역코드가 성공적으로 등록되었습니다.") + .build(); + } + + public static Region toRegion(RegionReqDTO.CreateRegion request, + BigDecimal gridX, BigDecimal gridY, RegionCode regionCode) { + return Region.builder() + .name(request.name()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .gridX(gridX) + .gridY(gridY) + .regionCode(regionCode) + .build(); + } + + public static RegionResDTO.CreateRegion toCreateRegion(Region region) { + return RegionResDTO.CreateRegion.builder() + .regionId(region.getId()) + .name(region.getName()) + .latitude(region.getLatitude()) + .longitude(region.getLongitude()) + .gridX(region.getGridX()) + .gridY(region.getGridY()) + .regionCode(toRegionCodeInfo(region.getRegionCode())) + .message("지역이 성공적으로 등록되었습니다.") + .build(); + } + + public static RegionResDTO.RegionCodeInfo toRegionCodeInfo(RegionCode regionCode) { + return RegionResDTO.RegionCodeInfo.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .build(); + } + + public static Region toEntityWithNewCode(RegionReqDTO.CreateRegionWithNewCode request, + BigDecimal gridX, BigDecimal gridY, RegionCode regionCode) { + return Region.builder() + .name(request.name()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .gridX(gridX) + .gridY(gridY) + .regionCode(regionCode) + .build(); + } + + public static RegionResDTO.RegionCodeDetail toRegionCodeDetail(RegionCode regionCode, int regionCount) { + return RegionResDTO.RegionCodeDetail.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .regionCount(regionCount) + .createdAt(regionCode.getCreatedAt()) + .updatedAt(regionCode.getUpdatedAt()) + .build(); + } + + public static RegionResDTO.RegionCodeList toRegionCodeList(List regionCodesWithCount) { + List regionCodeDetails = regionCodesWithCount.stream() + .map(result -> { + RegionCode regionCode = (RegionCode) result[0]; + Long regionCount = (Long) result[1]; + return toRegionCodeDetail(regionCode, regionCount.intValue()); + }) + .toList(); + + return RegionResDTO.RegionCodeList.builder() + .regionCodes(regionCodeDetails) + .totalCount(regionCodeDetails.size()) + .build(); + } + + public static RegionResDTO.RegionInfo toRegionInfo(Region region) { + return RegionResDTO.RegionInfo.builder() + .regionId(region.getId()) + .name(region.getName()) + .latitude(region.getLatitude()) + .longitude(region.getLongitude()) + .gridX(region.getGridX()) + .gridY(region.getGridY()) + .regionCode(toRegionCodeInfo(region.getRegionCode())) + .createdAt(region.getCreatedAt()) + .updatedAt(region.getUpdatedAt()) + .build(); + } + + public static RegionResDTO.RegionList toRegionList(List regions) { + List regionInfos = regions.stream() + .map(RegionConverter::toRegionInfo) + .toList(); + + return RegionResDTO.RegionList.builder() + .regions(regionInfos) + .totalCount(regions.size()) + .build(); + } + + public static RegionResDTO.RegionSearchResult toSearchResult(List regions, String keyword) { + List regionInfos = regions.stream() + .map(RegionConverter::toRegionInfo) + .toList(); + + return RegionResDTO.RegionSearchResult.builder() + .regions(regionInfos) + .keyword(keyword) + .resultCount(regions.size()) + .build(); + } + + public static RegionResDTO.DeleteRegionCode toDeleteRegionCode(RegionCode regionCode) { + return RegionResDTO.DeleteRegionCode.builder() + .regionCodeId(regionCode.getId()) + .name(regionCode.getName()) + .message("지역코드가 성공적으로 삭제되었습니다.") + .build(); + } + + public static RegionResDTO.DeleteRegion toDeleteRegion(Region region) { + return RegionResDTO.DeleteRegion.builder() + .regionId(region.getId()) + .name(region.getName()) + .message("지역이 성공적으로 삭제되었습니다.") + .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 new file mode 100644 index 0000000..458d865 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java @@ -0,0 +1,92 @@ +package org.withtime.be.withtimebe.domain.weather.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class WeatherSyncConverter { + + public static WeatherSyncResDTO.RegionSyncResult toRegionSyncResult( + Long regionId, String regionName, boolean success, + int dataPointsProcessed, int newDataPoints, int updatedDataPoints, + String errorMessage, long processingTimeMs) { + + return WeatherSyncResDTO.RegionSyncResult.builder() + .regionId(regionId) + .regionName(regionName) + .success(success) + .dataPointsProcessed(dataPointsProcessed) + .newDataPoints(newDataPoints) + .updatedDataPoints(updatedDataPoints) + .errorMessage(errorMessage) + .processingTimeMs(processingTimeMs) + .build(); + } + + public static WeatherSyncResDTO.ShortTermSyncResult toShortTermSyncResult( + int totalRegions, int successfulRegions, int failedRegions, + int totalDataPoints, int newDataPoints, int updatedDataPoints, + LocalDate baseDate, String baseTime, + LocalDateTime startTime, LocalDateTime endTime, + List regionResults, + List errorMessages) { + + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + String message = String.format( + "단기예보 동기화 완료: 성공 %d/%d 지역, 신규 %d개, 업데이트 %d개 데이터 처리", + successfulRegions, totalRegions, newDataPoints, updatedDataPoints); + + return WeatherSyncResDTO.ShortTermSyncResult.builder() + .totalRegions(totalRegions) + .successfulRegions(successfulRegions) + .failedRegions(failedRegions) + .totalDataPoints(totalDataPoints) + .newDataPoints(newDataPoints) + .updatedDataPoints(updatedDataPoints) + .baseDate(baseDate) + .baseTime(baseTime) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .regionResults(regionResults) + .errorMessages(errorMessages) + .message(message) + .build(); + } + + public static WeatherSyncResDTO.MediumTermSyncResult toMediumTermSyncResult( + int totalRegions, int successfulRegions, int failedRegions, + int totalDataPoints, int newDataPoints, int updatedDataPoints, + LocalDate tmfc, LocalDateTime startTime, LocalDateTime endTime, + List regionResults, + List errorMessages) { + + long durationMs = java.time.Duration.between(startTime, endTime).toMillis(); + String message = String.format( + "중기예보 동기화 완료: 성공 %d/%d 지역, 신규 %d개, 업데이트 %d개 데이터 처리", + successfulRegions, totalRegions, newDataPoints, updatedDataPoints); + + return WeatherSyncResDTO.MediumTermSyncResult.builder() + .totalRegions(totalRegions) + .successfulRegions(successfulRegions) + .failedRegions(failedRegions) + .totalDataPoints(totalDataPoints) + .newDataPoints(newDataPoints) + .updatedDataPoints(updatedDataPoints) + .tmfc(tmfc) + .processingStartTime(startTime) + .processingEndTime(endTime) + .processingDurationMs(durationMs) + .regionResults(regionResults) + .errorMessages(errorMessages) + .message(message) + .build(); + } +} 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 new file mode 100644 index 0000000..cb671d0 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -0,0 +1,96 @@ +package org.withtime.be.withtimebe.domain.weather.data.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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.WeatherDataCollectionService; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "scheduler.weather.enabled", havingValue = "true", matchIfMissing = true) +public class WeatherScheduler { + + private final WeatherDataCollectionService weatherDataCollectionService; + + // 스케줄러 실행 상태 volatile로 추척 + private volatile boolean shortTermSyncRunning = false; + private volatile boolean mediumTermSyncRunning = false; + + /** + * 단기 예보 데이터 수집 스케줄러 + * 매 3시간마다 실행 (02:10, 05:10, 08:10, 11:10, 14:10, 17:10, 20:10, 23:10) + * 기상청 발표 시각보다 10분 후에 실행하여 데이터 준비 시간 확보 + */ + @Scheduled(cron = "${scheduler.weather.short-term-cron}") + @Async("weatherTaskExecutor") + public void scheduledShortTermWeatherSync() { + if (shortTermSyncRunning) { + log.warn("단기 예보 동기화가 이미 실행 중입니다. 데이터 수집을 스킵합니다."); + return; + } + + try { + shortTermSyncRunning = true; + log.info("단기 예보 동기화 스케줄러 시작"); + + LocalDateTime now = LocalDateTime.now(); + LocalDate baseDate = now.toLocalDate(); + String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + + // 모든 지역에 대해 동기화 실행 + WeatherSyncResDTO.ShortTermSyncResult result = weatherDataCollectionService.collectShortTermWeatherData( + null, baseDate, baseTime, false); + + log.info("단기 예보 동기화 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newDataPoints(), result.updatedDataPoints()); + + } catch (Exception e) { + log.error("단기 예보 동기화 스케줄러 실행 중 오류 발생", e); + } finally { + shortTermSyncRunning = false; + } + } + + /** + * 중기 예보 데이터 수집 스케줄러 + * 매 12시간마다 실행 (06:30, 18:30) + */ + @Scheduled(cron = "${scheduler.weather.medium-term-cron}") + @Async("weatherTaskExecutor") + public void scheduledMediumTermWeatherSync() { + if (mediumTermSyncRunning) { + log.warn("중기 예보 동기화가 이미 실행 중입니다. 데이터 수집을 스킵합니다."); + return; + } + + try { + mediumTermSyncRunning = true; + log.info("중기 예보 동기화 스케줄러 시작"); + + LocalDate tmfc = LocalDate.now(); + + // 모든 지역에 대해 동기화 실행 + WeatherSyncResDTO.MediumTermSyncResult result = weatherDataCollectionService.collectMediumTermWeatherData( + null, tmfc, false); + + log.info("중기 예보 동기화 스케줄러 완료: 성공 {}/{} 지역, 신규 {} 건, 업데이트 {} 건", + result.successfulRegions(), result.totalRegions(), + result.newDataPoints(), result.updatedDataPoints()); + + } catch (Exception e) { + log.error("중기 예보 동기화 스케줄러 실행 중 오류 발생", e); + } finally { + mediumTermSyncRunning = false; + } + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClient.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClient.java new file mode 100644 index 0000000..3ae0c2a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClient.java @@ -0,0 +1,14 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.entity.Region; + +import java.time.LocalDate; + +public interface WeatherApiClient { + + String callShortTermWeatherApi(Region region, LocalDate baseDate, String baseTime); + + String callMediumTermLandWeatherApi(Region region, LocalDate tmfc); + + String callMediumTermTempWeatherApi(Region region, LocalDate tmfc); +} 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 new file mode 100644 index 0000000..7a8920a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java @@ -0,0 +1,143 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +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 java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherApiClientImpl implements WeatherApiClient { + + private final WebClient weatherWebClient; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.api.short-term-forecast-url}") + private String shortTermForecastUrl; + + @Value("${weather.api.medium-term-land-url}") + private String mediumTermLandUrl; + + @Value("${weather.api.medium-term-temp-url}") + private String mediumTermTempUrl; + + @Override + public String callShortTermWeatherApi(Region region, LocalDate baseDate, String baseTime) { + try { + int gridX = region.getGridX().intValue(); + int gridY = region.getGridY().intValue(); + + log.debug("단기예보 API 호출: regionId={}, gridX={}, gridY={}, baseDate={}, baseTime={}", + region.getId(), gridX, gridY, baseDate, baseTime); + + String response = weatherWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(shortTermForecastUrl) + .queryParam("authKey", apiKey) + .queryParam("pageNo", 1) + .queryParam("numOfRows", 1052) + .queryParam("dataType", "JSON") + .queryParam("base_date", baseDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))) + .queryParam("base_time", baseTime) + .queryParam("nx", gridX) + .queryParam("ny", gridY) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (response == null || response.trim().isEmpty()) { + throw new WeatherException(WeatherErrorCode.SHORT_TERM_FORECAST_ERROR); + } + + log.debug("단기예보 API 응답 수신 완료: regionId={}, 응답길이={}", region.getId(), response.length()); + return response; + + } catch (Exception e) { + log.error("단기예보 API 호출 실패: regionId={}, gridX={}, gridY={}, baseDate={}, baseTime={}", + region.getId(), region.getGridX(), region.getGridY(), baseDate, baseTime, e); + throw new WeatherException(WeatherErrorCode.SHORT_TERM_FORECAST_ERROR); + } + } + + public String callMediumTermLandWeatherApi(Region region, LocalDate tmfc) { + try { + String landRegCode = region.getRegionCode() != null ? + region.getRegionCode().getLandRegCode() : null; + + if (landRegCode == null) { + throw new WeatherException(WeatherErrorCode.INVALID_REGION_CODE); + } + + log.debug("중기 육상예보 API 호출: regionId={}, landRegCode={}", region.getId(), landRegCode); + + String response = weatherWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(mediumTermLandUrl) + .queryParam("authKey", apiKey) + .queryParam("reg", landRegCode) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (response == null || response.trim().isEmpty()) { + throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + } + + log.debug("중기 육상예보 API 응답 수신 완료: regionId={}, 응답길이={}", region.getId(), response.length()); + return response; + + } catch (Exception e) { + log.error("중기 육상 예보 API 호출 실패: regionId={}, landRegCode={}", + region.getId(), region.getRegionCode().getLandRegCode(), e); + throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + } + } + + public String callMediumTermTempWeatherApi(Region region, LocalDate tmfc) { + try { + String tempRegCode = region.getRegionCode() != null ? + region.getRegionCode().getTempRegCode() : null; + + if (tempRegCode == null) { + throw new WeatherException(WeatherErrorCode.INVALID_REGION_CODE); + } + + log.debug("중기 기온예보 API 호출: regionId={}, tempRegCode={}", region.getId(), tempRegCode); + + String response = weatherWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(mediumTermTempUrl) + .queryParam("authKey", apiKey) + .queryParam("reg", tempRegCode) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (response == null || response.trim().isEmpty()) { + throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + } + + log.debug("중기 기온예보 API 응답 수신 완료: regionId={}, 응답길이={}", region.getId(), response.length()); + return response; + + } catch (Exception e) { + log.error("중기 기온 예보 API 호출 실패: regionId={}, tempRegCode={}", + region.getId(), region.getRegionCode().getTempRegCode(), e); + throw new WeatherException(WeatherErrorCode.MEDIUM_TERM_FORECAST_ERROR); + } + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionService.java new file mode 100644 index 0000000..3288b7a --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionService.java @@ -0,0 +1,15 @@ +package org.withtime.be.withtimebe.domain.weather.data.service; + +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +import java.time.LocalDate; +import java.util.List; + +public interface WeatherDataCollectionService { + + WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( + List regionIds, LocalDate baseDate, String baseTime, boolean forceUpdate); + + WeatherSyncResDTO.MediumTermSyncResult collectMediumTermWeatherData( + List regionIds, LocalDate tmfc, boolean forceUpdate); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionServiceImpl.java new file mode 100644 index 0000000..e73bdf8 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionServiceImpl.java @@ -0,0 +1,171 @@ +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.WeatherSyncConverter; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataHelper; +import org.withtime.be.withtimebe.domain.weather.data.utils.WeatherDataParser; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +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.Region; +import org.withtime.be.withtimebe.domain.weather.repository.RawMediumTermWeatherRepository; +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.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherDataCollectionServiceImpl implements WeatherDataCollectionService { + + private final WeatherApiClient weatherApiClient; + private final WeatherDataParser weatherDataParser; + private final RegionRepository regionRepository; + private final RawShortTermWeatherRepository rawShortTermWeatherRepository; + private final RawMediumTermWeatherRepository rawMediumTermWeatherRepository; + + /** + * 단기 예보 데이터 수집 및 저장 + */ + @Override + @Transactional + public WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( + List regionIds, LocalDate baseDate, String baseTime, boolean forceUpdate) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("단기 예보 수집 시작: regionIds={}, baseDate={}, baseTime={}", regionIds, baseDate, baseTime); + + List targetRegions = WeatherDataHelper.getTargetRegions(regionIds, regionRepository); + List regionResults = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + + int totalDataPoints = 0, newRecords = 0, updatedDataPoints = 0; + int successfulRegions = 0, failedRegions = 0; + + for (Region region : targetRegions) { + long regionStartTime = System.currentTimeMillis(); + + try { + log.debug("지역 {} 단기 예보 수집 시작", region.getName()); + + String response = weatherApiClient.callShortTermWeatherApi(region, baseDate, baseTime); + List weatherDataList = weatherDataParser.parseShortTermWeatherResponse(response, region); + WeatherDataHelper.UpsertResult upsertResult = WeatherDataHelper.upsertShortTermWeatherData( + weatherDataList, forceUpdate, rawShortTermWeatherRepository); + + totalDataPoints += upsertResult.totalProcessed(); + newRecords += upsertResult.newRecords(); + updatedDataPoints += upsertResult.updatedRecords(); + successfulRegions++; + + regionResults.add(WeatherSyncConverter.toRegionSyncResult( + region.getId(), region.getName(), true, + upsertResult.totalProcessed(), upsertResult.newRecords(), upsertResult.updatedRecords(), + null, System.currentTimeMillis() - regionStartTime)); + + log.debug("지역 {} 단기 예보 수집 완료: 신규 {}, 업데이트 {}", + region.getName(), upsertResult.newRecords(), upsertResult.updatedRecords()); + + } catch (Exception e) { + failedRegions++; + String errorMessage = String.format("지역 %s 처리 실패: %s", region.getName(), e.getMessage()); + errorMessages.add(errorMessage); + + regionResults.add(WeatherSyncConverter.toRegionSyncResult( + region.getId(), region.getName(), false, 0, 0, 0, + errorMessage, System.currentTimeMillis() - regionStartTime)); + + log.error("지역 {} 단기 예보 수집 실패", region.getName(), e); + } + } + + LocalDateTime endTime = LocalDateTime.now(); + log.info("단기 예보 수집 완료: 성공 {}/{} 지역, 신규 {}, 업데이트 {} 데이터", + successfulRegions, targetRegions.size(), newRecords, updatedDataPoints); + + return WeatherSyncConverter.toShortTermSyncResult( + targetRegions.size(), successfulRegions, failedRegions, + totalDataPoints, newRecords, updatedDataPoints, + baseDate, baseTime, startTime, endTime, regionResults, errorMessages); + } + + /** + * 중기 예보 데이터 수집 및 저장 + */ + @Override + @Transactional + public WeatherSyncResDTO.MediumTermSyncResult collectMediumTermWeatherData( + List regionIds, LocalDate tmfc, boolean forceUpdate) { + + LocalDateTime startTime = LocalDateTime.now(); + log.info("중기 예보 수집 시작: regionIds={}, tmfc={}", regionIds, tmfc); + + List targetRegions = WeatherDataHelper.getTargetRegions(regionIds, regionRepository); + List regionResults = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + + int totalDataPoints = 0, newDataPoints = 0, updatedDataPoints = 0; + int successfulRegions = 0, failedRegions = 0; + + for (Region region : targetRegions) { + long regionStartTime = System.currentTimeMillis(); + + try { + log.debug("지역 {} 중기 예보 수집 시작", region.getName()); + + String landResponse = CompletableFuture.supplyAsync(() -> + weatherApiClient.callMediumTermLandWeatherApi(region, tmfc)).get(); + String tempResponse = CompletableFuture.supplyAsync(() -> + weatherApiClient.callMediumTermTempWeatherApi(region, tmfc)).get(); + + List weatherDataList = weatherDataParser.parseMediumTermWeatherResponse( + landResponse, tempResponse, region); + WeatherDataHelper.UpsertResult upsertResult = WeatherDataHelper.upsertMediumTermWeatherData( + weatherDataList, forceUpdate, rawMediumTermWeatherRepository); + + totalDataPoints += upsertResult.totalProcessed(); + newDataPoints += upsertResult.newRecords(); + updatedDataPoints += upsertResult.updatedRecords(); + successfulRegions++; + + regionResults.add(WeatherSyncConverter.toRegionSyncResult( + region.getId(), region.getName(), true, + upsertResult.totalProcessed(), upsertResult.newRecords(), upsertResult.updatedRecords(), + null, System.currentTimeMillis() - regionStartTime)); + + log.debug("지역 {} 중기 예보 수집 완료: 신규 {}, 업데이트 {}", + region.getName(), upsertResult.newRecords(), upsertResult.updatedRecords()); + + } catch (Exception e) { + failedRegions++; + String errorMessage = String.format("지역 %s 처리 실패: %s", region.getName(), e.getMessage()); + errorMessages.add(errorMessage); + + regionResults.add(WeatherSyncConverter.toRegionSyncResult( + region.getId(), region.getName(), false, 0, 0, 0, + errorMessage, System.currentTimeMillis() - regionStartTime)); + + log.error("지역 {} 중기 예보 수집 실패", region.getName(), e); + } + } + + LocalDateTime endTime = LocalDateTime.now(); + log.info("중기 예보 수집 완료: 성공 {}/{} 지역, 신규 {}, 업데이트 {} 데이터", + successfulRegions, targetRegions.size(), newDataPoints, updatedDataPoints); + + return WeatherSyncConverter.toMediumTermSyncResult( + targetRegions.size(), successfulRegions, failedRegions, + totalDataPoints, newDataPoints, updatedDataPoints, + tmfc, startTime, endTime, regionResults, errorMessages); + } +} + 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 new file mode 100644 index 0000000..9222964 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java @@ -0,0 +1,103 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +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.Region; +import org.withtime.be.withtimebe.domain.weather.repository.RawMediumTermWeatherRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RawShortTermWeatherRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +public class WeatherDataHelper { + + // 지역 ID가 없으면 전체, 있으면 ID 기반 조회 + public static List getTargetRegions(List regionIds, RegionRepository regionRepository) { + return (regionIds == null || regionIds.isEmpty()) + ? regionRepository.findAllActiveRegions() + : regionRepository.findByIdsWithRegionCode(regionIds); + } + + // 기상청 기준 시각 계산 + public static String calculateNearestBaseTime(int currentHour) { + int[] baseTimes = {2, 5, 8, 11, 14, 17, 20, 23}; + + for (int i = baseTimes.length - 1; i >= 0; i--) { + if (currentHour >= baseTimes[i]) { + return String.format("%02d00", baseTimes[i]); + } + } + return "2300"; + } + + public static UpsertResult upsertShortTermWeatherData( + List weatherDataList, + boolean forceUpdate, + RawShortTermWeatherRepository repository) { + + int totalProcessed = 0, newRecords = 0, updatedRecords = 0; + + for (RawShortTermWeather data : weatherDataList) { + Optional existingOpt = repository + .findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( + data.getRegion().getId(), + data.getBaseDate(), + data.getBaseTime(), + data.getForecastDate(), + data.getForecastTime() + ); + + if (existingOpt.isEmpty()) { + repository.save(data); + newRecords++; + } else if (forceUpdate) { + existingOpt.get().updateWeatherData( + data.getTemperature(), data.getSky(), data.getPrecipitationProbability(), + data.getPrecipitationType(), data.getPrecipitationAmount() + ); + updatedRecords++; + } + totalProcessed++; + } + + return new UpsertResult(totalProcessed, newRecords, updatedRecords); + } + + public static UpsertResult upsertMediumTermWeatherData( + List weatherDataList, + boolean forceUpdate, + RawMediumTermWeatherRepository repository) { + + int totalProcessed = 0, newRecords = 0, updatedRecords = 0; + + for (RawMediumTermWeather data : weatherDataList) { + Optional existingOpt = repository + .findByRegionIdAndBaseDateAndForecastDate( + data.getRegion().getId(), + data.getBaseDate(), + data.getForecastDate() + ); + + if (existingOpt.isEmpty()) { + repository.save(data); + newRecords++; + } else if (forceUpdate) { + existingOpt.get().updateWeatherData( + data.getSky(), data.getPrecipitationProbability(), + data.getMinTemperature(), data.getMaxTemperature() + ); + updatedRecords++; + } + totalProcessed++; + } + + return new UpsertResult(totalProcessed, newRecords, updatedRecords); + } + + public record UpsertResult(int totalProcessed, int newRecords, int updatedRecords) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataParser.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataParser.java new file mode 100644 index 0000000..8653c4f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataParser.java @@ -0,0 +1,324 @@ +package org.withtime.be.withtimebe.domain.weather.data.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +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.Region; +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.format.DateTimeFormatter; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherDataParser { + + private final ObjectMapper objectMapper; + + /** + * 단기 예보 JSON 응답 파싱 + */ + public List parseShortTermWeatherResponse(String jsonResponse, Region region) { + try { + JsonNode root = objectMapper.readTree(jsonResponse); + JsonNode items = root.path("response").path("body").path("items").path("item"); + + if (!items.isArray()) { + return Collections.emptyList(); + } + + Map> groupedData = new HashMap<>(); + + for (JsonNode item : items) { + String baseDate = item.path("baseDate").asText(); + String baseTime = item.path("baseTime").asText(); + String fcstDate = item.path("fcstDate").asText(); + String fcstTime = item.path("fcstTime").asText(); + String category = item.path("category").asText(); + String fcstValue = item.path("fcstValue").asText(); + + String key = String.join("_", baseDate, baseTime, fcstDate, fcstTime); + groupedData.computeIfAbsent(key, k -> new HashMap<>()).put(category, fcstValue); + } + + List results = new ArrayList<>(); + + for (Map.Entry> entry : groupedData.entrySet()) { + String[] keyParts = entry.getKey().split("_"); + Map values = entry.getValue(); + + if (hasRequiredCategories(values)) { + RawShortTermWeather weather = RawShortTermWeather.builder() + .region(region) + .baseDate(LocalDate.parse(keyParts[0], DateTimeFormatter.ofPattern("yyyyMMdd"))) + .baseTime(keyParts[1]) + .forecastDate(LocalDate.parse(keyParts[2], DateTimeFormatter.ofPattern("yyyyMMdd"))) + .forecastTime(keyParts[3]) + .temperature(Double.parseDouble(values.get("TMP"))) + .sky(convertSkyValue(values.get("SKY"))) + .precipitationProbability(Double.parseDouble(values.get("POP"))) + .precipitationType(convertPtyValue(values.get("PTY"))) + .precipitationAmount(convertPcpValue(values.get("PCP"))) + .build(); + + results.add(weather); + } + } + + log.debug("단기예보 파싱 완료: regionId={}, 파싱된 데이터 수={}", region.getId(), results.size()); + return results; + + } catch (Exception e) { + log.error("단기 예보 JSON 파싱 실패: regionId={}", region.getId(), e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + } + + /** + * 중기 예보 텍스트 응답 파싱 + */ + public List parseMediumTermWeatherResponse( + String landResponse, String tempResponse, Region region) { + try { + Map landDataMap = parseMediumTermLandData(landResponse); + Map tempDataMap = parseMediumTermTempData(tempResponse); + + List results = new ArrayList<>(); + + for (String key : landDataMap.keySet()) { + MediumTermLandData landData = landDataMap.get(key); + MediumTermTempData tempData = tempDataMap.get(key); + + if (landData != null && tempData != null) { + try { + Double pop = parseDoubleValue(landData.rnSt(), "강수확률"); + Double minTmp = parseDoubleValue(tempData.min(), "최저기온"); + Double maxTmp = parseDoubleValue(tempData.max(), "최고기온"); + + if (pop != null && minTmp != null && maxTmp != null) { + RawMediumTermWeather weather = RawMediumTermWeather.builder() + .region(region) + .baseDate(LocalDate.parse(landData.tmfc().substring(0, 8), DateTimeFormatter.ofPattern("yyyyMMdd"))) + .forecastDate(LocalDate.parse(landData.tmef().substring(0, 8), DateTimeFormatter.ofPattern("yyyyMMdd"))) + .sky(convertMediumTermSkyValue(landData.sky())) + .precipitationProbability(pop) + .minTemperature(minTmp) + .maxTemperature(maxTmp) + .build(); + + results.add(weather); + log.debug("중기예보 파싱 성공: key={}, pop={}, minTmp={}, maxTmp={}", + key, pop, minTmp, maxTmp); + } else { + log.warn("중기예보 데이터 불완전하여 스킵: key={}, pop={}, minTmp={}, maxTmp={}", + key, landData.rnSt(), tempData.min(), tempData.max()); + } + } catch (Exception e) { + log.warn("중기예보 개별 데이터 파싱 실패 (스킵): key={}, landData={}, tempData={}, error={}", + key, landData, tempData, e.getMessage()); + } + } + } + + log.info("중기예보 파싱 완료: regionId={}, 성공 {}/{} 건", + region.getId(), results.size(), landDataMap.size()); + return results; + + } catch (Exception e) { + log.error("중기 예보 텍스트 파싱 실패: regionId={}", region.getId(), e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + } + + // ===== 단기 예보 관련 메서드들 ===== + private boolean hasRequiredCategories(Map values) { + return values.containsKey("TMP") && values.containsKey("SKY") && + values.containsKey("POP") && values.containsKey("PTY") && values.containsKey("PCP"); + } + + private String convertSkyValue(String skyCode) { + return switch (skyCode) { + case "1" -> "맑음"; + case "3" -> "구름많음"; + case "4" -> "흐림"; + default -> "알수없음"; + }; + } + + private String convertPtyValue(String ptyCode) { + return switch (ptyCode) { + case "0" -> "없음"; + case "1" -> "비"; + case "2" -> "비/눈"; + case "3" -> "눈"; + default -> "알수없음"; + }; + } + + private Double convertPcpValue(String pcpValue) { + if ("강수없음".equals(pcpValue)) return 0.0; + try { + return Double.parseDouble(pcpValue.replaceAll("[^0-9.]", "")); + } catch (Exception e) { + return 0.0; + } + } + + // ===== 중기 예보 관련 메서드들 ===== + private Map parseMediumTermLandData(String response) { + Map result = new HashMap<>(); + + try { + Pattern pattern = Pattern.compile("#START7777(.*?)#7777END", Pattern.DOTALL); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + String data = matcher.group(1); + String[] lines = data.split("\n"); + + for (String line : lines) { + if (line.trim().isEmpty() || line.startsWith("#")) continue; + + String[] parts = line.trim().split("\\s+"); + if (parts.length >= 11) { + MediumTermLandData landData = new MediumTermLandData( + parts[1], // TM_FC + parts[2], // TM_EF + parts[6], // SKY + parts[10] // RN_ST + ); + + String key = parts[1] + "_" + parts[2]; + result.put(key, landData); + } + } + } + } catch (Exception e) { + log.error("중기 육상예보 파싱 실패", e); + } + + log.debug("중기 육상예보 파싱 완료: {} 건", result.size()); + return result; + } + + private Map parseMediumTermTempData(String response) { + Map result = new HashMap<>(); + + try { + Pattern pattern = Pattern.compile("#START7777(.*?)#7777END", Pattern.DOTALL); + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + String data = matcher.group(1); + String[] lines = data.split("\n"); + + for (String line : lines) { + if (line.trim().isEmpty() || line.startsWith("#")) continue; + + String[] parts = line.trim().split("\\s+"); + if (parts.length >= 8) { + try { + MediumTermTempData tempData = new MediumTermTempData( + parts[1], // TM_FC + parts[2], // TM_EF + parts[6], // MIN + parts[7] // MAX + ); + + String key = parts[1] + "_" + parts[2]; + result.put(key, tempData); + + log.trace("중기 기온예보 라인 파싱: key={}, min={}, max={}", + key, parts[6], parts[7]); + } catch (Exception e) { + log.warn("중기 기온예보 라인 파싱 실패 (스킵): line='{}', error={}", + line.trim(), e.getMessage()); + } + } + } + } + } catch (Exception e) { + log.error("중기 기온예보 전체 파싱 실패", e); + } + + log.debug("중기 기온예보 파싱 완료: {} 건", result.size()); + return result; + } + + private String convertMediumTermSkyValue(String skyCode) { + return switch (skyCode) { + case "WB01" -> "맑음"; + case "WB03" -> "구름많음"; + case "WB04" -> "흐림"; + case "WB13", "WB12" -> "눈"; + default -> "알수없음"; + }; + } + + private Double parseDoubleValue(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + log.debug("{} 값이 비어있음", fieldName); + return null; + } + + String trimmedValue = value.trim(); + + if (trimmedValue.matches("^[A-Z]\\d+$")) { + log.debug("{} 코드값 감지: {} -> 기본값 사용", fieldName, trimmedValue); + return getDefaultValueForCode(trimmedValue, fieldName); + } + + if (!trimmedValue.matches("^-?\\d*\\.?\\d+$")) { + log.warn("{} 파싱 불가능한 값: {} -> null 반환", fieldName, trimmedValue); + return null; + } + + try { + double parsedValue = Double.parseDouble(trimmedValue); + + if (!isValidValue(parsedValue, fieldName)) { + log.warn("{} 값이 유효 범위를 벗어남: {} -> null 반환", fieldName, parsedValue); + return null; + } + + return parsedValue; + } catch (NumberFormatException e) { + log.warn("{} 숫자 파싱 실패: {} -> null 반환", fieldName, trimmedValue); + return null; + } + } + + private Double getDefaultValueForCode(String code, String fieldName) { + return switch (fieldName) { + case "강수확률" -> 30.0; + case "최저기온" -> 15.0; + case "최고기온" -> 25.0; + default -> { + log.debug("알 수 없는 필드명: {}, 코드: {} -> 0.0 반환", fieldName, code); + yield 0.0; + } + }; + } + + private boolean isValidValue(double value, String fieldName) { + return switch (fieldName) { + case "강수확률" -> value >= 0.0 && value <= 100.0; + case "최저기온" -> value >= -50.0 && value <= 50.0; + case "최고기온" -> value >= -50.0 && value <= 50.0; + default -> true; + }; + } + + // ===== 내부 데이터 클래스들 ===== + private record MediumTermLandData(String tmfc, String tmef, String sky, String rnSt) {} + private record MediumTermTempData(String tmfc, String tmef, String min, String max) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java new file mode 100644 index 0000000..b598b3c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; + +public class RegionReqDTO { + + public record CreateRegionCode( + @NotBlank(message = "중기 육상 예보 지역코드는 필수 입력값입니다.") + String landRegCode, + + @NotBlank(message = "중기 기온 예보 지역코드는 필수 입력값입니다.") + String tempRegCode, + + @NotBlank(message = "지역코드명은 필수 입력값입니다.") + String name + ) { + } + + public record CreateRegion( + @NotBlank(message = "지역명은 필수 입력값입니다.") + String name, + + @NotNull(message = "위도는 필수 입력값입니다.") + BigDecimal latitude, + + @NotNull(message = "경도는 필수 입력값입니다.") + BigDecimal longitude, + + @NotNull(message = "지역코드 ID는 필수 입력값입니다.") + @Positive(message = "지역코드 ID는 양수여야 합니다.") + Long regionCodeId + ) { + } + + public record CreateRegionWithNewCode( + @NotBlank(message = "지역명은 필수 입력값입니다.") + String name, + + @NotNull(message = "위도는 필수 입력값입니다.") + BigDecimal latitude, + + @NotNull(message = "경도는 필수 입력값입니다.") + BigDecimal longitude, + + @NotBlank(message = "중기 육상 예보 지역코드는 필수 입력값입니다.") + String landRegCode, + + @NotBlank(message = "중기 기온 예보 지역코드는 필수 입력값입니다.") + String tempRegCode, + + @NotBlank(message = "지역코드명은 필수 입력값입니다.") + String regionCodeName + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherSyncReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherSyncReqDTO.java new file mode 100644 index 0000000..19cbf74 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherSyncReqDTO.java @@ -0,0 +1,19 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +import java.util.List; + +public class WeatherSyncReqDTO { + + public record ManualTrigger( + @NotBlank(message = "작업 타입은 필수입니다.") + @Pattern(regexp = "^(SHORT_TERM|MEDIUM_TERM|RECOMMENDATION|CLEANUP|ALL)$", + message = "올바른 작업 타입을 입력해주세요.") + String jobType, + + List targetRegionIds + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java new file mode 100644 index 0000000..106b36f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java @@ -0,0 +1,106 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public class RegionResDTO { + + @Builder + public record CreateRegionCode( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + String message + ) { + } + + @Builder + public record RegionCodeInfo( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name + ) { + } + + @Builder + public record CreateRegion( + Long regionId, + String name, + BigDecimal latitude, + BigDecimal longitude, + BigDecimal gridX, + BigDecimal gridY, + RegionCodeInfo regionCode, + String message + ) { + } + + @Builder + public record RegionCodeDetail( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + int regionCount, // 이 지역코드를 사용하는 지역 수 + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + @Builder + public record RegionCodeList( + List regionCodes, + int totalCount + ) { + } + + @Builder + public record RegionInfo( + Long regionId, + String name, + BigDecimal latitude, + BigDecimal longitude, + BigDecimal gridX, + BigDecimal gridY, + RegionCodeInfo regionCode, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + @Builder + public record RegionList( + List regions, + int totalCount + ) { + } + + @Builder + public record RegionSearchResult( + List regions, + String keyword, + int resultCount + ) { + } + + @Builder + public record DeleteRegionCode( + Long regionCodeId, + String name, + String message + ) { + } + + @Builder + public record DeleteRegion( + Long regionId, + String name, + 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 new file mode 100644 index 0000000..c595b09 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -0,0 +1,100 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class WeatherSyncResDTO { + + /** + * 지역별 동기화 결과 + */ + @Builder + public record RegionSyncResult( + Long regionId, + String regionName, + boolean success, + int dataPointsProcessed, + int newDataPoints, + int updatedDataPoints, + String errorMessage, + long processingTimeMs + ) { + } + + /** + * 단기 예보 동기화 결과 DTO + */ + @Builder + public record ShortTermSyncResult( + int totalRegions, // 처리된 지역 수 + int successfulRegions, // 성공한 지역 수 + int failedRegions, // 실패한 지역 수 + int totalDataPoints, // 전체 데이터 포인트 수 + int newDataPoints, // 새로 추가된 데이터 포인트 수 + int updatedDataPoints, // 업데이트된 데이터 포인트 수 + LocalDate baseDate, // 기준 날짜 + String baseTime, // 기준 시간 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List regionResults, // 지역별 결과 + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + + /** + * 중기 예보 동기화 결과 DTO + */ + @Builder + public record MediumTermSyncResult( + int totalRegions, // 처리된 지역 수 + int successfulRegions, // 성공한 지역 수 + int failedRegions, // 실패한 지역 수 + int totalDataPoints, // 전체 데이터 포인트 수 + int newDataPoints, // 새로 추가된 데이터 포인트 수 + int updatedDataPoints, // 업데이트된 데이터 포인트 수 + LocalDate tmfc, // 발표 시각 + LocalDateTime processingStartTime, // 처리 시작 시간 + LocalDateTime processingEndTime, // 처리 종료 시간 + long processingDurationMs, // 처리 소요 시간 (밀리초) + List regionResults, // 지역별 결과 + List errorMessages, // 오류 메시지들 + String message // 전체 결과 메시지 + ) { + } + + /** + * 수동 트리거 결과 DTO + */ + @Builder + public record ManualTriggerResult( + String jobType, + boolean triggered, + String executionId, + LocalDateTime triggerTime, + String status, // STARTED | FAILED + String message + ) { + } + + /** + * 전체 동기화 결과 DTO (모든 작업 포함) + */ + @Builder + public record CompleteSyncResult( + ShortTermSyncResult shortTermResult, // 단기 예보 결과 + MediumTermSyncResult mediumTermResult, // 중기 예보 결과 + LocalDateTime overallStartTime, // 전체 시작 시간 + LocalDateTime overallEndTime, // 전체 종료 시간 + long overallDurationMs, // 전체 소요 시간 (밀리초) + boolean allSuccessful, // 모든 작업 성공 여부 + List summaryMessages, // 요약 메시지들 + String overallStatus // 전체 상태 + ) { + } + +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawMediumTermWeather.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawMediumTermWeather.java index c0de95f..5428589 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawMediumTermWeather.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawMediumTermWeather.java @@ -5,6 +5,7 @@ import org.withtime.be.withtimebe.global.common.BaseEntity; import java.time.LocalDate; +import java.time.LocalDateTime; @Entity @Getter @@ -40,4 +41,11 @@ public class RawMediumTermWeather extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "region_id") private Region region; + + public void updateWeatherData(String sky, Double pop, Double minTmp, Double maxTmp) { + this.sky = sky; + this.precipitationProbability = pop; + this.minTemperature = minTmp; + this.maxTemperature = maxTmp; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawShortTermWeather.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawShortTermWeather.java index 034627f..6e8598b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawShortTermWeather.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RawShortTermWeather.java @@ -5,6 +5,7 @@ import org.withtime.be.withtimebe.global.common.BaseEntity; import java.time.LocalDate; +import java.time.LocalDateTime; @Entity @Getter @@ -49,4 +50,12 @@ public class RawShortTermWeather extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "region_id") private Region region; + + public void updateWeatherData(Double tmp, String sky, Double pop, String pty, Double pcp) { + this.temperature = tmp; + this.sky = sky; + this.precipitationProbability = pop; + this.precipitationType = pty; + this.precipitationAmount = pcp; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java index f8a37d2..4d10007 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java @@ -16,24 +16,24 @@ public class Region extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "region_id") - private Long id; + private Long id; // region_id - @Column(name = "name") + @Column(nullable = false) private String name; - @Column(name = "latitude") + @Column(nullable = false, precision = 9, scale = 6) private BigDecimal latitude; - @Column(name = "longitude") + @Column(nullable = false, precision = 9, scale = 6) private BigDecimal longitude; - @Column(name = "grid_x") + @Column(name = "grid_x", nullable = false, precision = 5, scale = 2) private BigDecimal gridX; - @Column(name = "grid_y") + @Column(name = "grid_y", nullable = false, precision = 5, scale = 2) private BigDecimal gridY; - @Column(name = "reg_code") - private String regCode; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_code_id", nullable = false) + private RegionCode regionCode; } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java new file mode 100644 index 0000000..be2d0bf --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java @@ -0,0 +1,34 @@ +package org.withtime.be.withtimebe.domain.weather.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "region_code") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Getter +public class RegionCode extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // region_code_id + + @Column(name = "land_reg_code", nullable = false, unique = true) + private String landRegCode; // 중기 육상 예보용 지역 코드 + + @Column(name = "temp_reg_code", nullable = false, unique = true) + private String tempRegCode; // 중기 기온 예보용 지역 코드 + + @Column(nullable = false) + private String name; + + @OneToMany(mappedBy = "regionCode", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List regions = new ArrayList<>(); +} 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 new file mode 100644 index 0000000..2d1187b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -0,0 +1,13 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.RawMediumTermWeather; + +import java.time.LocalDate; +import java.util.Optional; + +public interface RawMediumTermWeatherRepository extends JpaRepository { + + Optional findByRegionIdAndBaseDateAndForecastDate( + Long regionId, LocalDate baseDate, LocalDate forecastDate); +} 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 new file mode 100644 index 0000000..2af1d25 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -0,0 +1,14 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.RawShortTermWeather; + +import java.time.LocalDate; +import java.util.Optional; + +public interface RawShortTermWeatherRepository extends JpaRepository { + + Optional findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( + Long regionId, LocalDate baseDate, String baseTime, LocalDate forecastDate, String forecastTime + ); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java new file mode 100644 index 0000000..fa72d29 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java @@ -0,0 +1,24 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; + +import java.util.List; + +public interface RegionCodeRepository extends JpaRepository { + + boolean existsByLandRegCode(String landRegCode); + + boolean existsByTempRegCode(String tempRegCode); + + @Query("SELECT rc, COUNT(r) FROM RegionCode rc " + + "LEFT JOIN rc.regions r " + + "GROUP BY rc " + + "ORDER BY rc.name ASC") + List findAllWithRegionCount(); + + @Query("SELECT COUNT(r) FROM Region r WHERE r.regionCode.id = :regionCodeId") + long countRegionsByRegionCodeId(@Param("regionCodeId") Long regionCodeId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java new file mode 100644 index 0000000..e6c2727 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java @@ -0,0 +1,43 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.Region; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +public interface RegionRepository extends JpaRepository { + + boolean existsByName(String name); + + @Query("SELECT r FROM Region r WHERE " + + "ABS(r.latitude - :latitude) < 0.001 AND " + + "ABS(r.longitude - :longitude) < 0.001") + List findByNearCoordinates(@Param("latitude") BigDecimal latitude, + @Param("longitude") BigDecimal longitude); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "ORDER BY r.name ASC") + List findAllActiveRegions(); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.id IN :ids " + + "ORDER BY r.name ASC") + List findByIdsWithRegionCode(@Param("ids") List ids); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.id = :id") + Optional findByIdWithRegionCode(@Param("id") Long id); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.name LIKE %:keyword% " + + "ORDER BY r.name ASC") + List searchByNameContaining(@Param("keyword") String keyword); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java new file mode 100644 index 0000000..cebca59 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; + +public interface RegionCommandService { + + RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request); + + RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request); + + RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request); + + RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId); + + RegionResDTO.DeleteRegion deleteRegion(Long regionId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java new file mode 100644 index 0000000..911ef17 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java @@ -0,0 +1,244 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.withtime.be.withtimebe.domain.weather.converter.RegionConverter; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; +import org.withtime.be.withtimebe.domain.weather.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegionCommandServiceImpl implements RegionCommandService { + + private final WebClient weatherWebClient; + + private final RegionRepository regionRepository; + private final RegionCodeRepository regionCodeRepository; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.api.grid-conversion-url}") + private String gridConversionUrl; + + @Override + public RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request) { + log.info("지역코드 등록 요청: {}", request.name()); + + validateDuplicateRegionCode(request.landRegCode(), request.tempRegCode()); + + RegionCode regionCode = RegionConverter.toRegionCode(request); + RegionCode savedRegionCode = regionCodeRepository.save(regionCode); + + log.info("지역코드 등록 완료: {} (ID: {})", savedRegionCode.getName(), savedRegionCode.getId()); + return RegionConverter.toCreateRegionCodeResponse(savedRegionCode); + } + + @Override + public RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request) { + log.info("지역 등록 요청: {}", request.name()); + + // 1. 중복 체크 + validateDuplicateRegion(request.name(), request.latitude(), request.longitude()); + + // 2. 지역코드 존재 확인 + RegionCode regionCode = regionCodeRepository.findById(request.regionCodeId()) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 3. 격자 좌표 변환 + CoordinateResult coordinateResult = convertToGridCoordinates(request.latitude(), request.longitude()); + + // 4. 지역 저장 + Region region = RegionConverter.toRegion(request, coordinateResult.gridX(), coordinateResult.gridY(), regionCode); + Region savedRegion = regionRepository.save(region); + + log.info("지역 등록 완료: {} (ID: {})", savedRegion.getName(), savedRegion.getId()); + return RegionConverter.toCreateRegion(savedRegion); + } + + @Override + public RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request) { + log.info("지역+지역코드 등록 요청: {}", request.name()); + + // 1. 중복 체크 + validateDuplicateRegion(request.name(), request.latitude(), request.longitude()); + validateDuplicateRegionCode(request.landRegCode(), request.tempRegCode()); + + // 2. 격자 좌표 변환 + CoordinateResult coordinateResult = convertToGridCoordinates(request.latitude(), request.longitude()); + + // 3. 지역코드 먼저 생성 + RegionCode regionCode = RegionCode.builder() + .landRegCode(request.landRegCode()) + .tempRegCode(request.tempRegCode()) + .name(request.regionCodeName()) + .build(); + RegionCode savedRegionCode = regionCodeRepository.save(regionCode); + + // 4. 지역 저장 + Region region = RegionConverter.toEntityWithNewCode( + request, coordinateResult.gridX(), coordinateResult.gridY(), savedRegionCode); + Region savedRegion = regionRepository.save(region); + + log.info("지역+지역코드 등록 완료: {} (ID: {})", savedRegion.getName(), savedRegion.getId()); + return RegionConverter.toCreateRegion(savedRegion); + } + + @Override + public RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId) { + RegionCode regionCode = regionCodeRepository.findById(regionCodeId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 사용 중인 지역이 있는지 확인 + long regionCount = regionCodeRepository.countRegionsByRegionCodeId(regionCodeId); + if (regionCount > 0) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + + regionCodeRepository.delete(regionCode); + return RegionConverter.toDeleteRegionCode(regionCode); + } + + @Override + public RegionResDTO.DeleteRegion deleteRegion(Long regionId) { + Region region = regionRepository.findByIdWithRegionCode(regionId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 연관된 날씨 데이터가 있는지 확인 (실제로는 CASCADE로 삭제됨) + log.warn("지역 삭제: {} (ID: {}) - 연관된 모든 날씨 데이터도 함께 삭제됩니다.", + region.getName(), region.getId()); + + regionRepository.delete(region); + + return RegionConverter.toDeleteRegion(region); + } + + // ==== 내부 유틸리티 메서드들 ==== + + private void validateDuplicateRegionCode(String landRegCode, String tempRegCode) { + if (regionCodeRepository.existsByLandRegCode(landRegCode)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + if (regionCodeRepository.existsByTempRegCode(tempRegCode)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + } + + private void validateDuplicateRegion(String name, BigDecimal latitude, BigDecimal longitude) { + // 지역명 중복 체크 + if (regionRepository.existsByName(name)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + + // 유사한 좌표 체크 (매우 가까운 거리의 지역이 이미 있는지 확인) + List nearRegions = regionRepository.findByNearCoordinates(latitude, longitude); + if (!nearRegions.isEmpty()) { + log.warn("유사한 좌표의 지역이 이미 존재합니다: {}", nearRegions.get(0).getName()); + throw new WeatherException(WeatherErrorCode.INVALID_COORDINATES); + } + } + + /** + * 기상청 API를 호출하여 위경도를 격자 좌표로 변환 + */ + private CoordinateResult convertToGridCoordinates(BigDecimal latitude, BigDecimal longitude) { + try { + String response = weatherWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(gridConversionUrl) + .queryParam("authKey", apiKey) + .queryParam("lat", latitude.toString()) + .queryParam("lon", longitude.toString()) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (response == null || response.trim().isEmpty()) { + throw new WeatherException(WeatherErrorCode.GRID_CONVERSION_ERROR); + } + + return parseGridCoordinates(response); + + } catch (Exception e) { + log.error("격자 좌표 변환 실패: lat={}, lon={}", latitude, longitude, e); + throw new WeatherException(WeatherErrorCode.GRID_CONVERSION_ERROR); + } + } + + private CoordinateResult parseGridCoordinates(String response) { + try { + log.debug("파싱할 응답: {}", response); + + // 멀티라인 응답 처리를 위한 패턴 수정 + // #START7777 이후의 데이터 라인에서 숫자들을 추출 + Pattern pattern = Pattern.compile( + "#START7777.*?\\s+([0-9.]+),\\s*([0-9.]+),\\s*([0-9]+),\\s*([0-9]+)", + Pattern.DOTALL // 개행 문자도 . 패턴에 포함 + ); + + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + // 응답에서 추출된 값들: lon, lat, x, y 순서 + String lonStr = matcher.group(1); + String latStr = matcher.group(2); + String xStr = matcher.group(3); + String yStr = matcher.group(4); + + log.debug("파싱된 값들 - LON: {}, LAT: {}, X: {}, Y: {}", + lonStr, latStr, xStr, yStr); + + BigDecimal x = new BigDecimal(xStr); + BigDecimal y = new BigDecimal(yStr); + + log.debug("격자 좌표 변환 결과: X={}, Y={}", x, y); + return new CoordinateResult(x, y); + } else { + // 대안 패턴: 라인별로 분리해서 처리 + String[] lines = response.split("\n"); + for (String line : lines) { + log.debug("처리 중인 라인: '{}'", line.trim()); + // 숫자들이 포함된 데이터 라인 찾기 + if (line.trim().matches("\\s*[0-9.]+,\\s*[0-9.]+,\\s*[0-9]+,\\s*[0-9]+.*")) { + String[] values = line.trim().split(","); + if (values.length >= 4) { + BigDecimal x = new BigDecimal(values[2].trim()); + BigDecimal y = new BigDecimal(values[3].trim()); + + log.debug("라인별 파싱 성공 - X: {}, Y: {}", x, y); + return new CoordinateResult(x, y); + } + } + } + + log.error("격자 좌표 파싱 실패: 응답 형식이 올바르지 않음. response={}", response); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + + } catch (NumberFormatException e) { + log.error("숫자 변환 오류: {}", e.getMessage(), e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } catch (Exception e) { + log.error("격자 좌표 파싱 중 오류 발생: {}", response, e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + } + + private record CoordinateResult(BigDecimal gridX, BigDecimal gridY) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerService.java new file mode 100644 index 0000000..fd9ecb5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerService.java @@ -0,0 +1,9 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import org.withtime.be.withtimebe.domain.weather.dto.request.WeatherSyncReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; + +public interface WeatherTriggerService { + + WeatherSyncResDTO.ManualTriggerResult triggerAsync(WeatherSyncReqDTO.ManualTrigger request); +} 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 new file mode 100644 index 0000000..e584767 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java @@ -0,0 +1,86 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherTriggerServiceImpl implements WeatherTriggerService{ + + private final WeatherDataCollectionService dataCollectionService; + + public WeatherSyncResDTO.ManualTriggerResult triggerAsync(WeatherSyncReqDTO.ManualTrigger request) { + LocalDateTime triggerTime = LocalDateTime.now(); + String executionId = "manual_" + System.currentTimeMillis(); + + CompletableFuture.runAsync(() -> { + try { + executeJob(request); + } catch (Exception e) { + log.error("비동기 트리거 실패", e); + } + }); + + return WeatherSyncResDTO.ManualTriggerResult.builder() + .jobType(request.jobType()) + .triggered(true) + .executionId(executionId) + .triggerTime(triggerTime) + .status("STARTED") + .message("비동기 작업 시작됨") + .build(); + } + + private Object executeJob(WeatherSyncReqDTO.ManualTrigger request) { + log.info("작업 실행 시작: jobType={}", request.jobType()); + + return switch (request.jobType()) { + case "SHORT_TERM" -> { + LocalDateTime now = LocalDateTime.now(); + LocalDate baseDate = now.toLocalDate(); + String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + yield dataCollectionService.collectShortTermWeatherData( + request.targetRegionIds(), baseDate, baseTime, true); // ← forceExecution = true + } + + case "MEDIUM_TERM" -> dataCollectionService.collectMediumTermWeatherData( + request.targetRegionIds(), LocalDate.now(), true); // ← forceExecution = true + + case "ALL" -> { + LocalDateTime now = LocalDateTime.now(); + LocalDate baseDate = now.toLocalDate(); + String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); + + var shortResult = dataCollectionService.collectShortTermWeatherData( + request.targetRegionIds(), baseDate, baseTime, true); + + var mediumResult = dataCollectionService.collectMediumTermWeatherData( + request.targetRegionIds(), LocalDate.now(), true); + + yield WeatherSyncResDTO.CompleteSyncResult.builder() + .shortTermResult(shortResult) + .mediumTermResult(mediumResult) + .overallStartTime(LocalDateTime.now()) + .overallEndTime(LocalDateTime.now()) + .overallDurationMs(0L) + .allSuccessful(true) + .summaryMessages(List.of("전체 동기화 완료")) + .overallStatus("SUCCESS") + .build(); + } + + default -> throw new IllegalArgumentException("지원하지 않는 작업 타입: " + request.jobType()); + }; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java new file mode 100644 index 0000000..9dacbe3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java @@ -0,0 +1,14 @@ +package org.withtime.be.withtimebe.domain.weather.service.query; + +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; + +public interface RegionQueryService { + + RegionResDTO.RegionCodeList getAllRegionCodes(); + + RegionResDTO.RegionList getAllRegions(); + + RegionResDTO.RegionInfo getRegionById(Long regionId); + + RegionResDTO.RegionSearchResult searchRegions(String keyword); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java new file mode 100644 index 0000000..706e7e6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java @@ -0,0 +1,48 @@ +package org.withtime.be.withtimebe.domain.weather.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.converter.RegionConverter; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RegionQueryServiceImpl implements RegionQueryService{ + + private final RegionCodeRepository regionCodeRepository; + private final RegionRepository regionRepository; + + @Override + public RegionResDTO.RegionCodeList getAllRegionCodes() { + List regionCodesWithCount = regionCodeRepository.findAllWithRegionCount(); + return RegionConverter.toRegionCodeList(regionCodesWithCount); + } + + @Override + public RegionResDTO.RegionList getAllRegions() { + List regions = regionRepository.findAllActiveRegions(); + return RegionConverter.toRegionList(regions); + } + + @Override + public RegionResDTO.RegionInfo getRegionById(Long regionId) { + Region region = regionRepository.findByIdWithRegionCode(regionId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + return RegionConverter.toRegionInfo(region); + } + + @Override + public RegionResDTO.RegionSearchResult searchRegions(String keyword) { + List regions = regionRepository.searchByNameContaining(keyword); + return RegionConverter.toSearchResult(regions, keyword); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java new file mode 100644 index 0000000..9e766b9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java @@ -0,0 +1,68 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum WeatherErrorCode implements BaseErrorCode { + + // ==== 지역 관련 에러 (404) ==== + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_0", "지역을 찾을 수 없습니다."), + WEATHER_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_1", "날씨 데이터를 찾을 수 없습니다."), + WEATHER_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_2", "날씨 템플릿을 찾을 수 없습니다."), + DAILY_RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_3", "일일 추천 정보를 찾을 수 없습니다."), + KEYWORD_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_4", "키워드를 찾을 수 없습니다."), + + // ==== 데이터 중복/충돌 에러 (400) ==== + REGION_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "WEATHER400_0", "이미 존재하는 지역입니다."), + WEATHER_TEMPLATE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "WEATHER400_1", "이미 존재하는 날씨 템플릿입니다."), + INVALID_COORDINATES(HttpStatus.BAD_REQUEST, "WEATHER400_2", "올바르지 않은 좌표입니다."), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "WEATHER400_3", "올바르지 않은 날짜 범위입니다."), + INVALID_WEATHER_DATA(HttpStatus.BAD_REQUEST, "WEATHER400_4", "올바르지 않은 날씨 데이터입니다."), + INVALID_REGION_CODE(HttpStatus.BAD_REQUEST, "WEATHER400_5", "올바르지 않은 지역코드입니다."), + INVALID_ENUM_VALUE(HttpStatus.BAD_REQUEST, "WEATHER400_6", "올바르지 않은 열거형 값입니다."), + + // ==== 권한/인증 에러 (403) ==== + ACCESS_DENIED(HttpStatus.FORBIDDEN, "WEATHER403_0", "접근 권한이 없습니다."), + ADMIN_ONLY_ACCESS(HttpStatus.FORBIDDEN, "WEATHER403_1", "관리자만 접근할 수 있습니다."), + + // ==== 기상청 API 관련 에러 (500) ==== + WEATHER_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_0", "기상청 API 호출 중 오류가 발생했습니다."), + GRID_CONVERSION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_1", "격자 좌표 변환 중 오류가 발생했습니다."), + SHORT_TERM_FORECAST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_2", "단기 예보 조회 중 오류가 발생했습니다."), + MEDIUM_TERM_FORECAST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_3", "중기 예보 조회 중 오류가 발생했습니다."), + API_RESPONSE_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_4", "API 응답 파싱 중 오류가 발생했습니다."), + + // ==== 데이터 처리 관련 에러 (500) ==== + WEATHER_DATA_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_10", "날씨 데이터 처리 중 오류가 발생했습니다."), + WEATHER_CLASSIFICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_11", "날씨 분류 중 오류가 발생했습니다."), + TEMPLATE_MATCHING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_12", "템플릿 매칭 중 오류가 발생했습니다."), + RECOMMENDATION_GENERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_13", "추천 생성 중 오류가 발생했습니다."), + + // ==== 스케줄러 관련 에러 (500) ==== + SCHEDULER_EXECUTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_20", "스케줄러 실행 중 오류가 발생했습니다."), + DATA_COLLECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_21", "데이터 수집 중 오류가 발생했습니다."), + DATA_CLEANUP_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_22", "데이터 정리 중 오류가 발생했습니다."), + + // ==== 외부 서비스 에러 (502, 503) ==== + EXTERNAL_API_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "WEATHER503_0", "외부 API 서비스를 사용할 수 없습니다."), + API_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "WEATHER429_0", "API 호출 횟수 제한에 도달했습니다."), + API_TIMEOUT_ERROR(HttpStatus.GATEWAY_TIMEOUT, "WEATHER504_0", "API 응답 시간이 초과되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java new file mode 100644 index 0000000..4b8cbc5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; + +public class WeatherException extends ServerApplicationException { + public WeatherException(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index adb2c6d..b256970 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -9,6 +9,7 @@ import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -35,6 +36,7 @@ @Configuration @RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { private static final String API_PREFIX = "/api/v1"; @@ -55,15 +57,24 @@ public class SecurityConfig { }; private RequestMatcher[] admin = { - requestMatcher(HttpMethod.GET, API_PREFIX + "/notices/trash"), - requestMatcher(HttpMethod.POST, API_PREFIX + "/notices/**"), - requestMatcher(HttpMethod.PUT, API_PREFIX + "/notices/**"), - requestMatcher(HttpMethod.PATCH, API_PREFIX + "/notices/**"), - requestMatcher(HttpMethod.DELETE, API_PREFIX + "/notices/**"), - - requestMatcher(HttpMethod.POST, API_PREFIX + "/faqs/**"), - requestMatcher(HttpMethod.PUT, API_PREFIX + "/faqs/**"), - requestMatcher(HttpMethod.DELETE, API_PREFIX + "/faqs/**"), + requestMatcher(HttpMethod.GET, API_PREFIX + "/notices/trash"), + requestMatcher(HttpMethod.POST, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.PUT, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.PATCH, API_PREFIX + "/notices/**"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/notices/**"), + + requestMatcher(HttpMethod.POST, API_PREFIX + "/faqs/**"), + requestMatcher(HttpMethod.PUT, API_PREFIX + "/faqs/**"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/faqs/**"), + + requestMatcher(HttpMethod.POST, API_PREFIX + "/regions/codes"), + requestMatcher(HttpMethod.POST, API_PREFIX + "/regions"), + requestMatcher(HttpMethod.POST, API_PREFIX + "/regions/bundle"), + requestMatcher(HttpMethod.GET, API_PREFIX + "/regions/codes"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/regions/codes/**"), + requestMatcher(HttpMethod.DELETE, API_PREFIX + "/regions/**"), + + requestMatcher(HttpMethod.POST, API_PREFIX + "/weather/trigger"), }; @Bean diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 353c6f3..8290dfd 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -40,4 +40,46 @@ beginner: scope: - Exception web-hook-url: ${DISCORD_WEBHOOK_URL} - enable: true \ No newline at end of file + enable: true + +weather: + api: + key: ${KMA_API_KEY} + base-url: https://apihub.kma.go.kr + + # API 엔드포인트 + grid-conversion-url: /api/typ01/cgi-bin/url/nph-dfs_xy_lonlat + short-term-forecast-url: /api/typ02/openApi/VilageFcstInfoService_2.0/getVilageFcst + medium-term-land-url: /api/typ01/url/fct_afs_wl.php + medium-term-temp-url: /api/typ01/url/fct_afs_wc.php + + # API 타임아웃 설정 + timeout: + connect: 5000 + read: 15000 + + # 재시도 설정 + retry: + max-attempts: 3 + backoff-delay: 2000 + +# 스케줄러 설정 +scheduler: + weather: + enabled: true # 스케줄러 활성화 여부 + + # 데이터 수집 스케줄 + short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) + medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) + cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) + + # 추천 정보 생성 스케줄 + recommendation: + short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 + medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 + + # 데이터 보관 기간 설정 + retention: + short-term-days: 7 # 단기 예보 보관 기간 + medium-term-days: 7 # 중기 예보 보관 기간 + recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f630364..91d21de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,21 +11,6 @@ spring: show-sql: true hibernate: ddl-auto: update - data: - redis: - host: ${REDIS_HOST} - port: 6379 - mail: - host: ${MAIL_SENDER_HOST} - port: 587 - username: ${MAIL_SENDER_USERNAME} - password: ${MAIL_SENDER_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true jwt: secret: ${JWT_SECRET} @@ -40,4 +25,46 @@ beginner: scope: - Exception web-hook-url: ${DISCORD_WEBHOOK_URL} - enable: false \ No newline at end of file + enable: false + +weather: + api: + key: ${KMA_API_KEY} + base-url: https://apihub.kma.go.kr + + # API 엔드포인트 + grid-conversion-url: /api/typ01/cgi-bin/url/nph-dfs_xy_lonlat + short-term-forecast-url: /api/typ02/openApi/VilageFcstInfoService_2.0/getVilageFcst + medium-term-land-url: /api/typ01/url/fct_afs_wl.php + medium-term-temp-url: /api/typ01/url/fct_afs_wc.php + + # API 타임아웃 설정 + timeout: + connect: 5000 + read: 15000 + + # 재시도 설정 + retry: + max-attempts: 3 + backoff-delay: 2000 + +# 스케줄러 설정 +scheduler: + weather: + enabled: true # 스케줄러 활성화 여부 + + # 데이터 수집 스케줄 + short-term-cron: "10 2,5,8,11,14,17,20,23 * * * *" # 단기예보 수집 (매 3시간 10분) + medium-term-cron: "30 6,18 * * * *" # 중기예보 수집 (매 12시간 30분) + cleanup-cron: "0 0 3 * * *" # 데이터 정리 (매일 새벽 3시) + + # 추천 정보 생성 스케줄 + recommendation: + short-term-cron: "0 5 * * * *" # 단기예보 추천 (매 시간 5분) - 0-2일 + medium-term-cron: "0 30 */6 * * *" # 중기예보 추천 (6시간마다 30분) - 3-6일 + + # 데이터 보관 기간 설정 + retention: + short-term-days: 7 # 단기 예보 보관 기간 + medium-term-days: 7 # 중기 예보 보관 기간 + recommendation-days: 30 # 추천 정보 보관 기간 \ No newline at end of file