From 605000318fd555c9b9cb5a0e3cab060e2565dcf8 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 19:52:54 +0900 Subject: [PATCH 01/18] =?UTF-8?q?:rocket:chore:=20application.yml=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-develop.yml | 48 +++++++++++++++++++--- src/main/resources/application.yml | 48 +++++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index e9cfc7a..86a98b9 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -11,10 +11,6 @@ spring: show-sql: true hibernate: ddl-auto: update - data: - redis: - host: ${REDIS_HOST} - port: 6379 jwt: secret: ${JWT_SECRET} @@ -29,4 +25,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 a8e9cfd..91d21de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,10 +11,6 @@ spring: show-sql: true hibernate: ddl-auto: update - data: - redis: - host: ${REDIS_HOST} - port: 6379 jwt: secret: ${JWT_SECRET} @@ -29,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 From 96151d59b7933028decd6e9d3678826846afeb4e Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 19:55:05 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20Region?= =?UTF-8?q?=EC=97=90=EC=84=9C=20RegionCode=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/weather/entity/Region.java | 18 +++++----- .../domain/weather/entity/RegionCode.java | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java 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<>(); +} From bff791f93e0250a7011daafef578501080a59758 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 19:56:42 +0900 Subject: [PATCH 03/18] =?UTF-8?q?=E2=9C=A8feat:=20WeatherWebClientConfig?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 ++ .../config/WeatherWebClientConfig.java | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherWebClientConfig.java diff --git a/build.gradle b/build.gradle index 494dbc6..49cc1d7 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,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' } tasks.named('test') { 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..39de863 --- /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 + 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); + }); + } +} From 5ad9000108aa62e79d7598c56b5dc30bf6486269 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:01:44 +0900 Subject: [PATCH 04/18] =?UTF-8?q?=E2=9C=A8feat:=20Exception=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/WeatherErrorCode.java | 68 +++++++++++++++++++ .../error/exception/WeatherException.java | 11 +++ 2 files changed, 79 insertions(+) create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java create mode 100644 src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java 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); + } +} From 93f65ab9be120012866d99a5c30c51563dae8579 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:08:38 +0900 Subject: [PATCH 05/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 59 +++++++++++++++++++ .../weather/converter/RegionConverter.java | 29 +++++++++ .../weather/dto/request/RegionReqDTO.java | 18 ++++++ .../weather/dto/response/RegionResDTO.java | 16 +++++ .../repository/RegionCodeRepository.java | 11 ++++ .../service/command/RegionCommandService.java | 9 +++ .../command/RegionCommandServiceImpl.java | 49 +++++++++++++++ 7 files changed, 191 insertions(+) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java 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..27c31d4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java @@ -0,0 +1,59 @@ +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.security.access.prepost.PreAuthorize; +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.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.service.command.RegionCommandService; + +@Slf4j +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +@Tag(name = "지역 관리 API", description = "지역 등록/관리 API") +public class RegionController { + + private final RegionCommandService regionCommandService; + + @PostMapping("/codes") + @Operation(summary = "지역코드 등록 by 김지명", description = "새로운 지역코드를 등록합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지역코드 등록 성공"), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + public DefaultResponse createRegionCode( + @Valid @RequestBody RegionReqDTO.CreateRegionCode request) { + log.info("지역코드 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegionCode response = regionCommandService.createRegionCode(request); + return DefaultResponse.created(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..07418ce --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java @@ -0,0 +1,29 @@ +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.RegionCode; + +@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(); + } +} 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..c434b3b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java @@ -0,0 +1,18 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public class RegionReqDTO { + + public record CreateRegionCode( + @NotBlank(message = "중기 육상 예보 지역코드는 필수 입력값입니다.") + String landRegCode, + + @NotBlank(message = "중기 기온 예보 지역코드는 필수 입력값입니다.") + String tempRegCode, + + @NotBlank(message = "지역코드명은 필수 입력값입니다.") + String name + ) { + } +} 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..48124a0 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java @@ -0,0 +1,16 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; + +public class RegionResDTO { + + @Builder + public record CreateRegionCode( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + String message + ) { + } +} 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..1135a96 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; + +public interface RegionCodeRepository extends JpaRepository { + + boolean existsByLandRegCode(String landRegCode); + + boolean existsByTempRegCode(String tempRegCode); +} 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..d873a42 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java @@ -0,0 +1,9 @@ +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); +} 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..957591f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java @@ -0,0 +1,49 @@ +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.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.RegionCode; +import org.withtime.be.withtimebe.domain.weather.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegionCommandServiceImpl implements RegionCommandService { + + 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); + } + + 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); + } + } +} From 8ccdc3cfea4549898bfd4001bfac7ac622097200 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:25:29 +0900 Subject: [PATCH 06/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/WeatherWebClientConfig.java | 2 +- .../weather/controller/RegionController.java | 39 +++++ .../weather/converter/RegionConverter.java | 37 +++++ .../weather/dto/request/RegionReqDTO.java | 20 +++ .../weather/dto/response/RegionResDTO.java | 24 ++++ .../weather/repository/RegionRepository.java | 20 +++ .../service/command/RegionCommandService.java | 2 + .../command/RegionCommandServiceImpl.java | 136 ++++++++++++++++++ 8 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java 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 index 39de863..00a7479 100644 --- 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 @@ -32,7 +32,7 @@ public class WeatherWebClientConfig { /** * 기상청 API 전용 WebClient 설정 */ - @Bean + @Bean("weatherWebClient") public WebClient weatherWebClient() { // HTTP 클라이언트 타임아웃 설정 HttpClient httpClient = HttpClient.create() 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 index 27c31d4..3f49845 100644 --- 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 @@ -56,4 +56,43 @@ public DefaultResponse createRegionCode( RegionResDTO.CreateRegionCode response = regionCommandService.createRegionCode(request); return DefaultResponse.created(response); } + + @PostMapping + @Operation(summary = "지역 등록 by 김지명", + 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: 격자 좌표 변환 중 오류가 발생했습니다. + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse createRegion( + @Valid @RequestBody RegionReqDTO.CreateRegion request) { + log.info("지역 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegion(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 index 07418ce..a72da0b 100644 --- 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 @@ -4,8 +4,11 @@ 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; + @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RegionConverter { @@ -26,4 +29,38 @@ public static RegionResDTO.CreateRegionCode toCreateRegionCodeResponse(RegionCod .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(); + } } 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 index c434b3b..06e5a42 100644 --- 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 @@ -1,6 +1,10 @@ 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 { @@ -15,4 +19,20 @@ public record CreateRegionCode( 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 + ) { + } } 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 index 48124a0..28b636e 100644 --- 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 @@ -2,6 +2,8 @@ import lombok.Builder; +import java.math.BigDecimal; + public class RegionResDTO { @Builder @@ -13,4 +15,26 @@ public record CreateRegionCode( 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 + ) { + } } 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..5e6eadd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java @@ -0,0 +1,20 @@ +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; + +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); +} 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 index d873a42..1ca7012 100644 --- 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 @@ -6,4 +6,6 @@ public interface RegionCommandService { RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request); + + RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request); } 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 index 957591f..6bda0a3 100644 --- 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 @@ -4,19 +4,30 @@ 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}") @@ -38,6 +49,28 @@ public RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionC 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); + } + private void validateDuplicateRegionCode(String landRegCode, String tempRegCode) { if (regionCodeRepository.existsByLandRegCode(landRegCode)) { throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); @@ -46,4 +79,107 @@ private void validateDuplicateRegionCode(String landRegCode, String 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) {} } From 194fb6e10b58d714c1a9abbed8c850a448146ade Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:28:38 +0900 Subject: [PATCH 07/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD+?= =?UTF-8?q?=EC=A7=80=EC=97=AD=EC=BD=94=EB=93=9C=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 32 +++++++++++++++++++ .../weather/converter/RegionConverter.java | 12 +++++++ .../weather/dto/request/RegionReqDTO.java | 21 ++++++++++++ .../service/command/RegionCommandService.java | 2 ++ .../command/RegionCommandServiceImpl.java | 28 ++++++++++++++++ 5 files changed, 95 insertions(+) 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 index 3f49845..f152440 100644 --- 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 @@ -95,4 +95,36 @@ public DefaultResponse createRegion( return DefaultResponse.ok(response); } + @PostMapping("/with-new-code") + @Operation(summary = "지역+지역코드 동시 등록 by 김지명", 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: 격자 좌표 변환 중 오류가 발생했습니다. + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse createRegionWithNewCode( + @Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) { + log.info("지역+지역코드 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegionWithNewCode(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 index a72da0b..e96a3b5 100644 --- 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 @@ -63,4 +63,16 @@ public static RegionResDTO.RegionCodeInfo toRegionCodeInfo(RegionCode regionCode .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(); + } } 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 index 06e5a42..b598b3c 100644 --- 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 @@ -35,4 +35,25 @@ public record CreateRegion( 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/service/command/RegionCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java index 1ca7012..e0a5da5 100644 --- 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 @@ -8,4 +8,6 @@ public interface RegionCommandService { RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request); RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request); + + RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request); } 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 index 6bda0a3..8c34f4b 100644 --- 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 @@ -71,6 +71,34 @@ public RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request) 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); + } + private void validateDuplicateRegionCode(String landRegCode, String tempRegCode) { if (regionCodeRepository.existsByLandRegCode(landRegCode)) { throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); From 12d3b98211654a2db6b9d8042d7089c4092663ef Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:36:09 +0900 Subject: [PATCH 08/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 31 ++++++++++++++++--- .../weather/converter/RegionConverter.java | 28 +++++++++++++++++ .../weather/dto/response/RegionResDTO.java | 22 +++++++++++++ .../repository/RegionCodeRepository.java | 10 ++++++ .../service/query/RegionQueryService.java | 8 +++++ .../service/query/RegionQueryServiceImpl.java | 26 ++++++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java 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 index f152440..6be5c35 100644 --- 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 @@ -9,13 +9,11 @@ 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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.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 @@ -25,6 +23,7 @@ public class RegionController { private final RegionCommandService regionCommandService; + private final RegionQueryService regionQueryService; @PostMapping("/codes") @Operation(summary = "지역코드 등록 by 김지명", description = "새로운 지역코드를 등록합니다(관리자용).") @@ -127,4 +126,28 @@ public DefaultResponse createRegionWithNewCode( return DefaultResponse.ok(response); } + @GetMapping("/codes") + @Operation(summary = "지역코드 목록 조회 API by 김지명", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse getAllRegionCodes() { + log.info("지역코드 목록 조회 API 호출"); + + RegionResDTO.RegionCodeList response = regionQueryService.getAllRegionCodes(); + 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 index e96a3b5..d2e1088 100644 --- 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 @@ -8,6 +8,7 @@ import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; import java.math.BigDecimal; +import java.util.List; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RegionConverter { @@ -75,4 +76,31 @@ public static Region toEntityWithNewCode(RegionReqDTO.CreateRegionWithNewCode re .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(); + } } 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 index 28b636e..34cb838 100644 --- 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 @@ -3,6 +3,8 @@ import lombok.Builder; import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; public class RegionResDTO { @@ -37,4 +39,24 @@ public record CreateRegion( String message ) { } + + @Builder + public record RegionCodeDetail( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + String description, + int regionCount, // 이 지역코드를 사용하는 지역 수 + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + @Builder + public record RegionCodeList( + List regionCodes, + int totalCount + ) { + } } 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 index 1135a96..c791960 100644 --- 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 @@ -1,11 +1,21 @@ package org.withtime.be.withtimebe.domain.weather.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.withtime.be.withtimebe.domain.weather.entity.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(); + } 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..901ac7e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java @@ -0,0 +1,8 @@ +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(); +} 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..f586a8b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java @@ -0,0 +1,26 @@ +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.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RegionQueryServiceImpl implements RegionQueryService{ + + private final RegionCodeRepository regionCodeRepository; + + @Override + public RegionResDTO.RegionCodeList getAllRegionCodes() { + List regionCodesWithCount = regionCodeRepository.findAllWithRegionCount(); + return RegionConverter.toRegionCodeList(regionCodesWithCount); + } + +} From ca0067f1c48bb4aed5b530b4bcea3fc4920c7cb5 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:39:36 +0900 Subject: [PATCH 09/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 21 ++++++++++++++++ .../weather/converter/RegionConverter.java | 25 +++++++++++++++++++ .../weather/dto/response/RegionResDTO.java | 21 ++++++++++++++++ .../weather/repository/RegionRepository.java | 5 ++++ .../service/query/RegionQueryService.java | 2 ++ .../service/query/RegionQueryServiceImpl.java | 8 ++++++ 6 files changed, 82 insertions(+) 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 index 6be5c35..7831868 100644 --- 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 @@ -149,5 +149,26 @@ public DefaultResponse getAllRegionCodes() { return DefaultResponse.ok(response); } + @GetMapping + @Operation(summary = "지역 목록 조회 by 김지명", description = "등록된 모든 지역 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + public DefaultResponse getAllRegions() { + log.info("지역 목록 조회 API 호출"); + + RegionResDTO.RegionList response = regionQueryService.getAllRegions(); + 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 index d2e1088..c0cb1e0 100644 --- 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 @@ -103,4 +103,29 @@ public static RegionResDTO.RegionCodeList toRegionCodeList(List region .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(); + } } 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 index 34cb838..74da8f2 100644 --- 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 @@ -59,4 +59,25 @@ public record RegionCodeList( 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 + ) { + } } 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 index 5e6eadd..ac237b6 100644 --- 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 @@ -17,4 +17,9 @@ public interface RegionRepository extends JpaRepository { "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(); } 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 index 901ac7e..03676d9 100644 --- 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 @@ -5,4 +5,6 @@ public interface RegionQueryService { RegionResDTO.RegionCodeList getAllRegionCodes(); + + RegionResDTO.RegionList getAllRegions(); } 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 index f586a8b..34d72ae 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -16,6 +17,7 @@ public class RegionQueryServiceImpl implements RegionQueryService{ private final RegionCodeRepository regionCodeRepository; + private final RegionRepository regionRepository; @Override public RegionResDTO.RegionCodeList getAllRegionCodes() { @@ -23,4 +25,10 @@ public RegionResDTO.RegionCodeList getAllRegionCodes() { return RegionConverter.toRegionCodeList(regionCodesWithCount); } + @Override + public RegionResDTO.RegionList getAllRegions() { + List regions = regionRepository.findAllActiveRegions(); + return RegionConverter.toRegionList(regions); + } + } From 07da5149bda3a4f493120441c232f8bd37ccd4b9 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:43:01 +0900 Subject: [PATCH 10/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 31 +++++++++++++++++++ .../weather/repository/RegionRepository.java | 6 ++++ .../service/query/RegionQueryService.java | 2 ++ .../service/query/RegionQueryServiceImpl.java | 8 +++++ 4 files changed, 47 insertions(+) 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 index 7831868..4cdff67 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -171,4 +172,34 @@ public DefaultResponse getAllRegions() { return DefaultResponse.ok(response); } + @GetMapping("/{regionId}") + @Operation(summary = "지역 상세 조회 by 김지명", description = "특정 지역의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. + """) + }) + 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); + } + } 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 index ac237b6..3fb9267 100644 --- 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 @@ -7,6 +7,7 @@ import java.math.BigDecimal; import java.util.List; +import java.util.Optional; public interface RegionRepository extends JpaRepository { @@ -22,4 +23,9 @@ List findByNearCoordinates(@Param("latitude") BigDecimal latitude, "JOIN FETCH r.regionCode " + "ORDER BY r.name ASC") List findAllActiveRegions(); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.id = :id") + Optional findByIdWithRegionCode(@Param("id") Long id); } 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 index 03676d9..6397f41 100644 --- 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 @@ -7,4 +7,6 @@ public interface RegionQueryService { RegionResDTO.RegionCodeList getAllRegionCodes(); RegionResDTO.RegionList getAllRegions(); + + RegionResDTO.RegionInfo getRegionById(Long regionId); } 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 index 34d72ae..4ea3528 100644 --- 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 @@ -8,6 +8,8 @@ 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; @@ -31,4 +33,10 @@ public RegionResDTO.RegionList getAllRegions() { 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); + } } From 5f7ad207afc6590cbc894f093f0e406dbb2f4db3 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:47:44 +0900 Subject: [PATCH 11/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 33 +++++++------------ .../weather/converter/RegionConverter.java | 12 +++++++ .../weather/dto/response/RegionResDTO.java | 8 +++++ .../weather/repository/RegionRepository.java | 6 ++++ .../service/query/RegionQueryService.java | 2 ++ .../service/query/RegionQueryServiceImpl.java | 6 ++++ 6 files changed, 45 insertions(+), 22 deletions(-) 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 index 4cdff67..2b69d97 100644 --- 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 @@ -41,11 +41,6 @@ public class RegionController { 다음과 같은 이유로 실패할 수 있습니다: - WEATHER403_0: 접근 권한이 없습니다. - WEATHER403_1: 관리자만 접근할 수 있습니다. - """), - @ApiResponse(responseCode = "500", - description = """ - 다음과 같은 이유로 실패할 수 있습니다: - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) @PreAuthorize("hasRole('ADMIN')") @@ -84,7 +79,6 @@ public DefaultResponse createRegionCode( description = """ 다음과 같은 이유로 실패할 수 있습니다: - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) public DefaultResponse createRegion( @@ -116,7 +110,6 @@ public DefaultResponse createRegion( description = """ 다음과 같은 이유로 실패할 수 있습니다: - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) public DefaultResponse createRegionWithNewCode( @@ -136,11 +129,6 @@ public DefaultResponse createRegionWithNewCode( 다음과 같은 이유로 실패할 수 있습니다: - WEATHER403_0: 접근 권한이 없습니다. - WEATHER403_1: 관리자만 접근할 수 있습니다. - """), - @ApiResponse(responseCode = "500", - description = """ - 다음과 같은 이유로 실패할 수 있습니다: - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) public DefaultResponse getAllRegionCodes() { @@ -158,11 +146,6 @@ public DefaultResponse getAllRegionCodes() { description = """ 다음과 같은 이유로 실패할 수 있습니다: - WEATHER403_0: 접근 권한이 없습니다. - """), - @ApiResponse(responseCode = "500", - description = """ - 다음과 같은 이유로 실패할 수 있습니다: - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) public DefaultResponse getAllRegions() { @@ -185,11 +168,6 @@ public DefaultResponse getAllRegions() { description = """ 다음과 같은 이유로 실패할 수 있습니다: - WEATHER404_0: 지역을 찾을 수 없습니다. - """), - @ApiResponse(responseCode = "500", - description = """ - 다음과 같은 이유로 실패할 수 있습니다: - - WEATHER500_10: 날씨 데이터 처리 중 오류가 발생했습니다. """) }) public DefaultResponse getRegion( @@ -202,4 +180,15 @@ public DefaultResponse getRegion( return DefaultResponse.ok(response); } + @GetMapping("/search") + @Operation(summary = "지역 검색 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); + } + } 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 index c0cb1e0..55d7832 100644 --- 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 @@ -128,4 +128,16 @@ public static RegionResDTO.RegionList toRegionList(List regions) { .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(); + } } 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 index 74da8f2..71e02bc 100644 --- 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 @@ -80,4 +80,12 @@ public record RegionList( int totalCount ) { } + + @Builder + public record RegionSearchResult( + List regions, + String keyword, + int resultCount + ) { + } } 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 index 3fb9267..a1a7f9e 100644 --- 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 @@ -28,4 +28,10 @@ List findByNearCoordinates(@Param("latitude") BigDecimal latitude, "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/query/RegionQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java index 6397f41..9dacbe3 100644 --- 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 @@ -9,4 +9,6 @@ public interface RegionQueryService { 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 index 4ea3528..706e7e6 100644 --- 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 @@ -39,4 +39,10 @@ public RegionResDTO.RegionInfo getRegionById(Long 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); + } } From b966e8253b32f5b487e862e53ea0d788c7b1bef9 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 20:57:52 +0900 Subject: [PATCH 12/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 35 +++++++++++++++++++ .../weather/converter/RegionConverter.java | 8 +++++ .../weather/dto/response/RegionResDTO.java | 8 +++++ .../repository/RegionCodeRepository.java | 3 ++ .../service/command/RegionCommandService.java | 2 ++ .../command/RegionCommandServiceImpl.java | 18 ++++++++++ 6 files changed, 74 insertions(+) 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 index 2b69d97..942a969 100644 --- 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 @@ -191,4 +191,39 @@ public DefaultResponse searchRegions( return DefaultResponse.ok(response); } + @DeleteMapping("/codes/{regionCodeId}") + @Operation(summary = "지역코드 삭제 by 김지명", 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); + } } 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 index 55d7832..9514360 100644 --- 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 @@ -140,4 +140,12 @@ public static RegionResDTO.RegionSearchResult toSearchResult(List region .resultCount(regions.size()) .build(); } + + public static RegionResDTO.DeleteRegionCode toDeleteRegionCode(RegionCode regionCode) { + return RegionResDTO.DeleteRegionCode.builder() + .regionCodeId(regionCode.getId()) + .name(regionCode.getName()) + .message("지역코드가 성공적으로 삭제되었습니다.") + .build(); + } } 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 index 71e02bc..a24cd67 100644 --- 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 @@ -88,4 +88,12 @@ public record RegionSearchResult( int resultCount ) { } + + @Builder + public record DeleteRegionCode( + Long regionCodeId, + String name, + String message + ) { + } } 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 index c791960..fa72d29 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -18,4 +19,6 @@ public interface RegionCodeRepository extends JpaRepository { "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/service/command/RegionCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java index e0a5da5..87adef3 100644 --- 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 @@ -10,4 +10,6 @@ public interface RegionCommandService { RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request); RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request); + + RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId); } 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 index 8c34f4b..44eac32 100644 --- 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 @@ -99,6 +99,24 @@ public RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegi 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); + } + + + // ==== 내부 유틸리티 메서드들 ==== + private void validateDuplicateRegionCode(String landRegCode, String tempRegCode) { if (regionCodeRepository.existsByLandRegCode(landRegCode)) { throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); From f13a2f7007213b8cd633772b6bcd5b3c6bb4d257 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 21:03:02 +0900 Subject: [PATCH 13/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 39 +++++++++++++++++++ .../weather/converter/RegionConverter.java | 8 ++++ .../weather/dto/response/RegionResDTO.java | 8 ++++ .../service/command/RegionCommandService.java | 2 + .../command/RegionCommandServiceImpl.java | 13 +++++++ 5 files changed, 70 insertions(+) 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 index 942a969..02a0594 100644 --- 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 @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.namul.api.payload.response.DefaultResponse; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; @@ -226,4 +227,42 @@ public DefaultResponse deleteRegionCode( RegionResDTO.DeleteRegionCode response = regionCommandService.deleteRegionCode(regionCodeId); return DefaultResponse.ok(response); } + + @DeleteMapping("/{regionId}") + @Operation(summary = "지역 삭제 by 김지명", 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/converter/RegionConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java index 9514360..46773a6 100644 --- 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 @@ -148,4 +148,12 @@ public static RegionResDTO.DeleteRegionCode toDeleteRegionCode(RegionCode region .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/dto/response/RegionResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java index a24cd67..9f2918b 100644 --- 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 @@ -96,4 +96,12 @@ public record DeleteRegionCode( String message ) { } + + @Builder + public record DeleteRegion( + Long regionId, + String name, + String message + ) { + } } 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 index 87adef3..cebca59 100644 --- 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 @@ -12,4 +12,6 @@ public interface RegionCommandService { 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 index 44eac32..911ef17 100644 --- 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 @@ -114,6 +114,19 @@ public RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId) { 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); + } // ==== 내부 유틸리티 메서드들 ==== From 5a38940df6de20d07b0f0c99d5768170efa97986 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sun, 13 Jul 2025 10:44:57 +0900 Subject: [PATCH 14/18] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20ADMIN?= =?UTF-8?q?=EB=A7=8C=20=ED=97=88=EC=9A=A9=ED=95=98=EB=8A=94=20API=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 24 +++++++++++-------- .../weather/dto/response/RegionResDTO.java | 1 - .../global/security/SecurityConfig.java | 2 ++ src/main/resources/application-develop.yml | 4 ++++ 4 files changed, 20 insertions(+), 11 deletions(-) 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 index 02a0594..c6a8352 100644 --- 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 @@ -9,7 +9,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.namul.api.payload.response.DefaultResponse; -import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; @@ -28,7 +27,7 @@ public class RegionController { private final RegionQueryService regionQueryService; @PostMapping("/codes") - @Operation(summary = "지역코드 등록 by 김지명", description = "새로운 지역코드를 등록합니다(관리자용).") + @Operation(summary = "지역코드 등록 API by 지미 [Only Admin]", description = "새로운 지역코드를 등록합니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "지역코드 등록 성공"), @ApiResponse(responseCode = "400", @@ -54,7 +53,7 @@ public DefaultResponse createRegionCode( } @PostMapping - @Operation(summary = "지역 등록 by 김지명", + @Operation(summary = "지역 등록 API by 지미 [Only Admin]", description = "기존 지역코드를 사용하여 새로운 지역을 등록합니다. 위경도는 자동으로 격자 좌표로 변환됩니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), @@ -82,6 +81,7 @@ public DefaultResponse createRegionCode( - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. """) }) + @PreAuthorize("hasRole('ADMIN')") public DefaultResponse createRegion( @Valid @RequestBody RegionReqDTO.CreateRegion request) { log.info("지역 등록 API 호출: {}", request.name()); @@ -91,7 +91,7 @@ public DefaultResponse createRegion( } @PostMapping("/with-new-code") - @Operation(summary = "지역+지역코드 동시 등록 by 김지명", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).") + @Operation(summary = "지역+지역코드 동시 등록 API by 지미 [Only Admin]", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", @@ -113,6 +113,7 @@ public DefaultResponse createRegion( - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. """) }) + @PreAuthorize("hasRole('ADMIN')") public DefaultResponse createRegionWithNewCode( @Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) { log.info("지역+지역코드 등록 API 호출: {}", request.name()); @@ -122,7 +123,7 @@ public DefaultResponse createRegionWithNewCode( } @GetMapping("/codes") - @Operation(summary = "지역코드 목록 조회 API by 김지명", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).") + @Operation(summary = "지역코드 목록 조회 API by 지미 [Only Admin]", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "403", @@ -132,6 +133,7 @@ public DefaultResponse createRegionWithNewCode( - WEATHER403_1: 관리자만 접근할 수 있습니다. """) }) + @PreAuthorize("hasRole('ADMIN')") public DefaultResponse getAllRegionCodes() { log.info("지역코드 목록 조회 API 호출"); @@ -140,7 +142,7 @@ public DefaultResponse getAllRegionCodes() { } @GetMapping - @Operation(summary = "지역 목록 조회 by 김지명", description = "등록된 모든 지역 목록을 조회합니다.") + @Operation(summary = "지역 목록 조회 by 지미", description = "등록된 모든 지역 목록을 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "403", @@ -157,7 +159,7 @@ public DefaultResponse getAllRegions() { } @GetMapping("/{regionId}") - @Operation(summary = "지역 상세 조회 by 김지명", description = "특정 지역의 상세 정보를 조회합니다.") + @Operation(summary = "지역 상세 조회 API by 지미", description = "특정 지역의 상세 정보를 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "403", @@ -182,7 +184,7 @@ public DefaultResponse getRegion( } @GetMapping("/search") - @Operation(summary = "지역 검색 by 김지명", description = "지역명으로 검색합니다. 부분 일치 검색을 지원합니다.") + @Operation(summary = "지역 검색 API by 지미", description = "지역명으로 검색합니다. 부분 일치 검색을 지원합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "검색 성공", useReturnTypeSchema = true) }) @@ -193,7 +195,7 @@ public DefaultResponse searchRegions( } @DeleteMapping("/codes/{regionCodeId}") - @Operation(summary = "지역코드 삭제 by 김지명", description = "지역코드를 삭제합니다(해당 코드를 사용하는 지역이 없어야 함. 관리자용).") + @Operation(summary = "지역코드 삭제 API by 지미 [Only Admin]", description = "지역코드를 삭제합니다(해당 코드를 사용하는 지역이 없어야 함. 관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", @@ -219,6 +221,7 @@ public DefaultResponse searchRegions( - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. """) }) + @PreAuthorize("hasRole('ADMIN')") public DefaultResponse deleteRegionCode( @Parameter(description = "지역코드 ID", required = true) @PathVariable Long regionCodeId) { @@ -229,7 +232,7 @@ public DefaultResponse deleteRegionCode( } @DeleteMapping("/{regionId}") - @Operation(summary = "지역 삭제 by 김지명", description = "지역을 삭제합니다. 연관된 모든 날씨 데이터도 함께 삭제됩니다(관리자용).") + @Operation(summary = "지역 삭제 API by 지미 [Only Admin]", description = "지역을 삭제합니다. 연관된 모든 날씨 데이터도 함께 삭제됩니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", @@ -255,6 +258,7 @@ public DefaultResponse deleteRegionCode( - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. """) }) + @PreAuthorize("hasRole('ADMIN')") public DefaultResponse deleteRegion( @Parameter(description = "지역 ID", required = true) @PathVariable Long regionId) { 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 index 9f2918b..106b36f 100644 --- 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 @@ -46,7 +46,6 @@ public record RegionCodeDetail( String landRegCode, String tempRegCode, String name, - String description, int regionCount, // 이 지역코드를 사용하는 지역 수 LocalDateTime createdAt, LocalDateTime updatedAt 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 e0f9195..96e797f 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 @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; 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; @@ -32,6 +33,7 @@ @Configuration @RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { private static final String API_PREFIX = "/api/v1"; diff --git a/src/main/resources/application-develop.yml b/src/main/resources/application-develop.yml index 86a98b9..85a8b2d 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -11,6 +11,10 @@ spring: show-sql: true hibernate: ddl-auto: update + data: + redis: + host: ${REDIS_HOST} + port: 6379 jwt: secret: ${JWT_SECRET} From fd29bbcfedbcdfeba5b346a72537f2cc5ca325da Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Tue, 15 Jul 2025 08:15:47 +0900 Subject: [PATCH 15/18] =?UTF-8?q?=E2=9C=A8feat:=20=EB=8B=A8=EA=B8=B0=20?= =?UTF-8?q?=EC=98=88=EB=B3=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/WeatherSyncConverter.java | 63 +++++++++ .../data/scheduler/WeatherScheduler.java | 72 ++++++++++ .../data/service/WeatherApiClient.java | 11 ++ .../data/service/WeatherApiClientImpl.java | 66 +++++++++ .../service/WeatherDataCollectionService.java | 13 ++ .../WeatherDataCollectionServiceImpl.java | 132 ++++++++++++++++++ .../weather/data/utils/WeatherDataParser.java | 112 +++++++++++++++ .../dto/response/WeatherSyncResDTO.java | 49 +++++++ .../weather/entity/RawShortTermWeather.java | 9 ++ .../RawShortTermWeatherRepository.java | 13 ++ .../weather/repository/RegionRepository.java | 6 + 11 files changed, 546 insertions(+) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClient.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionServiceImpl.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataParser.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java 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..e2035bd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java @@ -0,0 +1,63 @@ +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(); + } +} 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..d174edd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -0,0 +1,72 @@ +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.stereotype.Component; +import org.withtime.be.withtimebe.domain.weather.data.service.WeatherDataCollectionService; +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분 후에 실행하여 데이터 준비 시간 확보 + */ + public void scheduledShortTermWeatherSync() { + if (shortTermSyncRunning) { + log.warn("단기 예보 동기화가 이미 실행 중입니다. 데이터 수집을 스킵합니다."); + return; + } + + try { + shortTermSyncRunning = true; + log.info("단기 예보 동기화 스케줄러 시작"); + + LocalDateTime now = LocalDateTime.now(); + LocalDate baseDate = now.toLocalDate(); + String baseTime = 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; + } + } + + // === 내부 유틸리티 메서드 === + private 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]); + } + } + // 현재 시각이 새벽 2시 이전이면 전날의 23시 + return "2300"; + } +} 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..52e937b --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClient.java @@ -0,0 +1,11 @@ +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); + +} 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..f5642b5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java @@ -0,0 +1,66 @@ +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 webClient; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.api.short-term-forecast-url}") + private String shortTermForecastUrl; + + @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 = webClient.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); + } + } +} 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..5308aec --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionService.java @@ -0,0 +1,13 @@ +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); + +} 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..9f18dd4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherDataCollectionServiceImpl.java @@ -0,0 +1,132 @@ +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.WeatherDataParser; +import org.withtime.be.withtimebe.domain.weather.dto.response.WeatherSyncResDTO; +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.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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WeatherDataCollectionServiceImpl implements WeatherDataCollectionService { + + private final WeatherApiClient weatherApiClient; + private final WeatherDataParser weatherDataParser; + private final RegionRepository regionRepository; + private final RawShortTermWeatherRepository shortTermWeatherRepository; + + @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 = getTargetRegions(regionIds); + 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); + UpsertResult upsertResult = upsertShortTermWeatherData(weatherDataList, forceUpdate); + + 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); + } + + + // ==== 내부 유틸리티 메서드들 ==== + + private List getTargetRegions(List regionIds) { + return (regionIds == null || regionIds.isEmpty()) + ? regionRepository.findAllActiveRegions() + : regionRepository.findByIdsWithRegionCode(regionIds); + } + + private UpsertResult upsertShortTermWeatherData(List weatherDataList, boolean forceUpdate) { + int totalProcessed = 0, newRecords = 0, updatedRecords = 0; + + for (RawShortTermWeather weatherData : weatherDataList) { + Optional existingOpt = shortTermWeatherRepository + .findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( + weatherData.getRegion().getId(), + weatherData.getBaseDate(), + weatherData.getBaseTime(), + weatherData.getForecastDate(), + weatherData.getForecastTime() + ); + + if (existingOpt.isEmpty()) { + shortTermWeatherRepository.save(weatherData); + newRecords++; + } else if (forceUpdate) { + existingOpt.get().updateWeatherData( + weatherData.getTemperature(), weatherData.getSky(), weatherData.getPrecipitationProbability(), + weatherData.getPrecipitationType(), weatherData.getPrecipitationAmount() + ); + updatedRecords++; + } + totalProcessed++; + } + + return new UpsertResult(totalProcessed, newRecords, updatedRecords); + } + + private 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..156f911 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataParser.java @@ -0,0 +1,112 @@ +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.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.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherDataParser { + + private final ObjectMapper objectMapper; + + 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); + } + } + + 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; + } + } +} 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..e2f9099 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -0,0 +1,49 @@ +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 // 전체 결과 메시지 + ) { + } + +} 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/repository/RawShortTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java new file mode 100644 index 0000000..e1c5815 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.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.RawShortTermWeather; + +import java.time.LocalDate; +import java.util.Optional; + +public interface RawShortTermWeatherRepository extends JpaRepository { + + Optional findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( + Long regionId, LocalDate baseDate, String baseTime, LocalDate fcstDate, String fcstTime); +} 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 index a1a7f9e..e6c2727 100644 --- 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 @@ -24,6 +24,12 @@ List findByNearCoordinates(@Param("latitude") BigDecimal latitude, "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") From a70c89109e6f1e8fdd4f353ead89b2a4f6c7dd41 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Tue, 15 Jul 2025 09:12:27 +0900 Subject: [PATCH 16/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=A4=91=EA=B8=B0=20?= =?UTF-8?q?=EC=98=88=EB=B3=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/WeatherSyncConverter.java | 29 +++ .../data/scheduler/WeatherScheduler.java | 46 +++- .../data/service/WeatherApiClient.java | 3 + .../data/service/WeatherApiClientImpl.java | 81 ++++++- .../service/WeatherDataCollectionService.java | 2 + .../WeatherDataCollectionServiceImpl.java | 109 ++++++--- .../weather/data/utils/WeatherDataHelper.java | 103 +++++++++ .../weather/data/utils/WeatherDataParser.java | 212 ++++++++++++++++++ .../dto/response/WeatherSyncResDTO.java | 21 ++ .../weather/entity/RawMediumTermWeather.java | 8 + .../RawMediumTermWeatherRepository.java | 13 ++ 11 files changed, 579 insertions(+), 48 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java index e2035bd..458d865 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/WeatherSyncConverter.java @@ -60,4 +60,33 @@ public static WeatherSyncResDTO.ShortTermSyncResult toShortTermSyncResult( .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 index d174edd..cb671d0 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/scheduler/WeatherScheduler.java @@ -3,8 +3,11 @@ 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; @@ -27,6 +30,8 @@ public class WeatherScheduler { * 매 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("단기 예보 동기화가 이미 실행 중입니다. 데이터 수집을 스킵합니다."); @@ -39,7 +44,7 @@ public void scheduledShortTermWeatherSync() { LocalDateTime now = LocalDateTime.now(); LocalDate baseDate = now.toLocalDate(); - String baseTime = calculateNearestBaseTime(now.getHour()); + String baseTime = WeatherDataHelper.calculateNearestBaseTime(now.getHour()); // 모든 지역에 대해 동기화 실행 WeatherSyncResDTO.ShortTermSyncResult result = weatherDataCollectionService.collectShortTermWeatherData( @@ -56,17 +61,36 @@ public void scheduledShortTermWeatherSync() { } } - // === 내부 유틸리티 메서드 === - private String calculateNearestBaseTime(int currentHour) { - int[] baseTimes = {2, 5, 8, 11, 14, 17, 20, 23}; + /** + * 중기 예보 데이터 수집 스케줄러 + * 매 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(); - // 현재 시각보다 이전 또는 같은 가장 가까운 기준시각 찾기 - for (int i = baseTimes.length - 1; i >= 0; i--) { - if (currentHour >= baseTimes[i]) { - return String.format("%02d00", baseTimes[i]); - } + // 모든 지역에 대해 동기화 실행 + 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; } - // 현재 시각이 새벽 2시 이전이면 전날의 23시 - return "2300"; } } 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 index 52e937b..3ae0c2a 100644 --- 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 @@ -8,4 +8,7 @@ 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 index f5642b5..7a8920a 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/service/WeatherApiClientImpl.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class WeatherApiClientImpl implements WeatherApiClient { - private final WebClient webClient; + private final WebClient weatherWebClient; @Value("${weather.api.key}") private String apiKey; @@ -25,6 +25,12 @@ public class WeatherApiClientImpl implements WeatherApiClient { @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 { @@ -34,7 +40,7 @@ public String callShortTermWeatherApi(Region region, LocalDate baseDate, String log.debug("단기예보 API 호출: regionId={}, gridX={}, gridY={}, baseDate={}, baseTime={}", region.getId(), gridX, gridY, baseDate, baseTime); - String response = webClient.get() + String response = weatherWebClient.get() .uri(uriBuilder -> uriBuilder .path(shortTermForecastUrl) .queryParam("authKey", apiKey) @@ -63,4 +69,75 @@ public String callShortTermWeatherApi(Region region, LocalDate baseDate, String 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 index 5308aec..3288b7a 100644 --- 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 @@ -10,4 +10,6 @@ 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 index 9f18dd4..e73bdf8 100644 --- 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 @@ -5,10 +5,13 @@ 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; @@ -17,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; @Slf4j @Service @@ -26,8 +30,12 @@ public class WeatherDataCollectionServiceImpl implements WeatherDataCollectionSe private final WeatherApiClient weatherApiClient; private final WeatherDataParser weatherDataParser; private final RegionRepository regionRepository; - private final RawShortTermWeatherRepository shortTermWeatherRepository; + private final RawShortTermWeatherRepository rawShortTermWeatherRepository; + private final RawMediumTermWeatherRepository rawMediumTermWeatherRepository; + /** + * 단기 예보 데이터 수집 및 저장 + */ @Override @Transactional public WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( @@ -36,7 +44,7 @@ public WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( LocalDateTime startTime = LocalDateTime.now(); log.info("단기 예보 수집 시작: regionIds={}, baseDate={}, baseTime={}", regionIds, baseDate, baseTime); - List targetRegions = getTargetRegions(regionIds); + List targetRegions = WeatherDataHelper.getTargetRegions(regionIds, regionRepository); List regionResults = new ArrayList<>(); List errorMessages = new ArrayList<>(); @@ -51,7 +59,8 @@ public WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( String response = weatherApiClient.callShortTermWeatherApi(region, baseDate, baseTime); List weatherDataList = weatherDataParser.parseShortTermWeatherResponse(response, region); - UpsertResult upsertResult = upsertShortTermWeatherData(weatherDataList, forceUpdate); + WeatherDataHelper.UpsertResult upsertResult = WeatherDataHelper.upsertShortTermWeatherData( + weatherDataList, forceUpdate, rawShortTermWeatherRepository); totalDataPoints += upsertResult.totalProcessed(); newRecords += upsertResult.newRecords(); @@ -89,44 +98,74 @@ public WeatherSyncResDTO.ShortTermSyncResult collectShortTermWeatherData( 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); - private List getTargetRegions(List regionIds) { - return (regionIds == null || regionIds.isEmpty()) - ? regionRepository.findAllActiveRegions() - : regionRepository.findByIdsWithRegionCode(regionIds); - } + 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); - private UpsertResult upsertShortTermWeatherData(List weatherDataList, boolean forceUpdate) { - int totalProcessed = 0, newRecords = 0, updatedRecords = 0; - - for (RawShortTermWeather weatherData : weatherDataList) { - Optional existingOpt = shortTermWeatherRepository - .findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( - weatherData.getRegion().getId(), - weatherData.getBaseDate(), - weatherData.getBaseTime(), - weatherData.getForecastDate(), - weatherData.getForecastTime() - ); - - if (existingOpt.isEmpty()) { - shortTermWeatherRepository.save(weatherData); - newRecords++; - } else if (forceUpdate) { - existingOpt.get().updateWeatherData( - weatherData.getTemperature(), weatherData.getSky(), weatherData.getPrecipitationProbability(), - weatherData.getPrecipitationType(), weatherData.getPrecipitationAmount() - ); - updatedRecords++; + 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); } - totalProcessed++; } - return new UpsertResult(totalProcessed, newRecords, updatedRecords); - } + LocalDateTime endTime = LocalDateTime.now(); + log.info("중기 예보 수집 완료: 성공 {}/{} 지역, 신규 {}, 업데이트 {} 데이터", + successfulRegions, targetRegions.size(), newDataPoints, updatedDataPoints); - private record UpsertResult(int totalProcessed, int newRecords, int updatedRecords) {} + 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..8110e57 --- /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 + .findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( + 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 + .findByRegionIdAndTmfcAndTmef( + 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 index 156f911..8653c4f 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -13,6 +14,8 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Slf4j @Component @@ -21,6 +24,9 @@ public class WeatherDataParser { private final ObjectMapper objectMapper; + /** + * 단기 예보 JSON 응답 파싱 + */ public List parseShortTermWeatherResponse(String jsonResponse, Region region) { try { JsonNode root = objectMapper.readTree(jsonResponse); @@ -77,6 +83,63 @@ public List parseShortTermWeatherResponse(String jsonRespon } } + /** + * 중기 예보 텍스트 응답 파싱 + */ + 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"); @@ -109,4 +172,153 @@ private Double convertPcpValue(String pcpValue) { 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/response/WeatherSyncResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java index e2f9099..95dd211 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -46,4 +46,25 @@ public record ShortTermSyncResult( ) { } + /** + * 중기 예보 동기화 결과 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 // 전체 결과 메시지 + ) { + } + } 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/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java new file mode 100644 index 0000000..d3dcbb2 --- /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 findByRegionIdAndTmfcAndTmef( + Long regionId, LocalDate tmfc, LocalDate tmef); +} From 40a95f9794db77a517d0865c9d895118c7f8367f Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Tue, 15 Jul 2025 09:50:26 +0900 Subject: [PATCH 17/18] =?UTF-8?q?=E2=9C=A8feat:=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/WeatherController.java | 63 ++++++++++++++ .../weather/data/utils/WeatherDataHelper.java | 4 +- .../dto/request/WeatherSyncReqDTO.java | 19 ++++ .../dto/response/WeatherSyncResDTO.java | 30 +++++++ .../RawMediumTermWeatherRepository.java | 4 +- .../RawShortTermWeatherRepository.java | 5 +- .../command/WeatherTriggerService.java | 9 ++ .../command/WeatherTriggerServiceImpl.java | 86 +++++++++++++++++++ 8 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/controller/WeatherController.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/WeatherSyncReqDTO.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerService.java create mode 100644 src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/WeatherTriggerServiceImpl.java 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/data/utils/WeatherDataHelper.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java index 8110e57..9222964 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/data/utils/WeatherDataHelper.java @@ -44,7 +44,7 @@ public static UpsertResult upsertShortTermWeatherData( for (RawShortTermWeather data : weatherDataList) { Optional existingOpt = repository - .findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( + .findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( data.getRegion().getId(), data.getBaseDate(), data.getBaseTime(), @@ -77,7 +77,7 @@ public static UpsertResult upsertMediumTermWeatherData( for (RawMediumTermWeather data : weatherDataList) { Optional existingOpt = repository - .findByRegionIdAndTmfcAndTmef( + .findByRegionIdAndBaseDateAndForecastDate( data.getRegion().getId(), data.getBaseDate(), data.getForecastDate() 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/WeatherSyncResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java index 95dd211..c595b09 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/WeatherSyncResDTO.java @@ -67,4 +67,34 @@ public record MediumTermSyncResult( ) { } + /** + * 수동 트리거 결과 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/repository/RawMediumTermWeatherRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java index d3dcbb2..2d1187b 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawMediumTermWeatherRepository.java @@ -8,6 +8,6 @@ public interface RawMediumTermWeatherRepository extends JpaRepository { - Optional findByRegionIdAndTmfcAndTmef( - Long regionId, LocalDate tmfc, LocalDate tmef); + 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 index e1c5815..2af1d25 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RawShortTermWeatherRepository.java @@ -8,6 +8,7 @@ public interface RawShortTermWeatherRepository extends JpaRepository { - Optional findByRegionIdAndBaseDateAndBaseTimeAndFcstDateAndFcstTime( - Long regionId, LocalDate baseDate, String baseTime, LocalDate fcstDate, String fcstTime); + Optional findByRegionIdAndBaseDateAndBaseTimeAndForecastDateAndForecastTime( + Long regionId, LocalDate baseDate, String baseTime, LocalDate forecastDate, String forecastTime + ); } 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()); + }; + } +} From ace44066b55e29cf7a0e0d3f1a67b3ce72c2c3e1 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Tue, 15 Jul 2025 11:33:12 +0900 Subject: [PATCH 18/18] =?UTF-8?q?=F0=9F=90=9Bfix:=20SecurityConfig=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B2=98=EB=A6=AC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/controller/RegionController.java | 8 +----- .../global/security/SecurityConfig.java | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 16 deletions(-) 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 index c6a8352..7346812 100644 --- 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 @@ -43,7 +43,6 @@ public class RegionController { - WEATHER403_1: 관리자만 접근할 수 있습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse createRegionCode( @Valid @RequestBody RegionReqDTO.CreateRegionCode request) { log.info("지역코드 등록 API 호출: {}", request.name()); @@ -81,7 +80,6 @@ public DefaultResponse createRegionCode( - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse createRegion( @Valid @RequestBody RegionReqDTO.CreateRegion request) { log.info("지역 등록 API 호출: {}", request.name()); @@ -90,7 +88,7 @@ public DefaultResponse createRegion( return DefaultResponse.ok(response); } - @PostMapping("/with-new-code") + @PostMapping("/bundle") @Operation(summary = "지역+지역코드 동시 등록 API by 지미 [Only Admin]", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), @@ -113,7 +111,6 @@ public DefaultResponse createRegion( - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse createRegionWithNewCode( @Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) { log.info("지역+지역코드 등록 API 호출: {}", request.name()); @@ -133,7 +130,6 @@ public DefaultResponse createRegionWithNewCode( - WEATHER403_1: 관리자만 접근할 수 있습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse getAllRegionCodes() { log.info("지역코드 목록 조회 API 호출"); @@ -221,7 +217,6 @@ public DefaultResponse searchRegions( - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse deleteRegionCode( @Parameter(description = "지역코드 ID", required = true) @PathVariable Long regionCodeId) { @@ -258,7 +253,6 @@ public DefaultResponse deleteRegionCode( - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. """) }) - @PreAuthorize("hasRole('ADMIN')") public DefaultResponse deleteRegion( @Parameter(description = "지역 ID", required = true) @PathVariable Long regionId) { 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 05d6b32..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 @@ -57,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