From 605000318fd555c9b9cb5a0e3cab060e2565dcf8 Mon Sep 17 00:00:00 2001 From: Jimyeong Kim Date: Sat, 12 Jul 2025 19:52:54 +0900 Subject: [PATCH 01/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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/14] =?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}