diff --git a/build.gradle b/build.gradle index 46f4563..47bbec8 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,12 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Netty + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-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..00a7479 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/config/WeatherWebClientConfig.java @@ -0,0 +1,90 @@ +package org.withtime.be.withtimebe.domain.weather.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Configuration +public class WeatherWebClientConfig { + + @Value("${weather.api.base-url}") + private String baseUrl; + + @Value("${weather.api.timeout.connect}") + private int connectTimeout; + + @Value("${weather.api.timeout.read}") + private int readTimeout; + + /** + * 기상청 API 전용 WebClient 설정 + */ + @Bean("weatherWebClient") + public WebClient weatherWebClient() { + // HTTP 클라이언트 타임아웃 설정 + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout) + .responseTimeout(Duration.ofMillis(readTimeout)) + .doOnConnected(conn -> + conn.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS)) + .addHandlerLast(new WriteTimeoutHandler(connectTimeout, TimeUnit.MILLISECONDS)) + ); + + return WebClient.builder() + .baseUrl(baseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .filter(logRequest()) + .filter(logResponse()) + .filter(handleErrors()) + .build(); + } + + /** + * 요청 로깅 필터 + */ + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { + log.info("기상청 API 요청: {} {}", clientRequest.method(), clientRequest.url()); + log.debug("요청 헤더: {}", clientRequest.headers()); + return Mono.just(clientRequest); + }); + } + + /** + * 응답 로깅 필터 + */ + private ExchangeFilterFunction logResponse() { + return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { + log.info("기상청 API 응답: {}", clientResponse.statusCode()); + log.debug("응답 헤더: {}", clientResponse.headers().asHttpHeaders()); + return Mono.just(clientResponse); + }); + } + + /** + * 에러 처리 필터 + */ + private ExchangeFilterFunction handleErrors() { + return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { + if (clientResponse.statusCode().isError()) { + log.error("기상청 API 오류 응답: {} {}", + clientResponse.statusCode().value(), + clientResponse.statusCode()); + } + return Mono.just(clientResponse); + }); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java new file mode 100644 index 0000000..c6a8352 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/controller/RegionController.java @@ -0,0 +1,272 @@ +package org.withtime.be.withtimebe.domain.weather.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.service.command.RegionCommandService; +import org.withtime.be.withtimebe.domain.weather.service.query.RegionQueryService; + +@Slf4j +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +@Tag(name = "지역 관리 API", description = "지역 등록/관리 API") +public class RegionController { + + private final RegionCommandService regionCommandService; + private final RegionQueryService regionQueryService; + + @PostMapping("/codes") + @Operation(summary = "지역코드 등록 API by 지미 [Only Admin]", description = "새로운 지역코드를 등록합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "지역코드 등록 성공"), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """) + }) + @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); + } + + @PostMapping + @Operation(summary = "지역 등록 API by 지미 [Only Admin]", + description = "기존 지역코드를 사용하여 새로운 지역을 등록합니다. 위경도는 자동으로 격자 좌표로 변환됩니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_2: 올바르지 않은 좌표입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + public DefaultResponse createRegion( + @Valid @RequestBody RegionReqDTO.CreateRegion request) { + log.info("지역 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegion(request); + return DefaultResponse.ok(response); + } + + @PostMapping("/with-new-code") + @Operation(summary = "지역+지역코드 동시 등록 API by 지미 [Only Admin]", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_2: 올바르지 않은 좌표입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + public DefaultResponse createRegionWithNewCode( + @Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) { + log.info("지역+지역코드 등록 API 호출: {}", request.name()); + + RegionResDTO.CreateRegion response = regionCommandService.createRegionWithNewCode(request); + return DefaultResponse.ok(response); + } + + @GetMapping("/codes") + @Operation(summary = "지역코드 목록 조회 API by 지미 [Only Admin]", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + public DefaultResponse getAllRegionCodes() { + log.info("지역코드 목록 조회 API 호출"); + + RegionResDTO.RegionCodeList response = regionQueryService.getAllRegionCodes(); + return DefaultResponse.ok(response); + } + + @GetMapping + @Operation(summary = "지역 목록 조회 by 지미", description = "등록된 모든 지역 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """) + }) + public DefaultResponse getAllRegions() { + log.info("지역 목록 조회 API 호출"); + + RegionResDTO.RegionList response = regionQueryService.getAllRegions(); + return DefaultResponse.ok(response); + } + + @GetMapping("/{regionId}") + @Operation(summary = "지역 상세 조회 API by 지미", description = "특정 지역의 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """) + }) + public DefaultResponse getRegion( + @Parameter(description = "지역 ID", required = true) + @PathVariable Long regionId) { + + log.info("지역 상세 조회 API 호출: regionId={}", regionId); + + RegionResDTO.RegionInfo response = regionQueryService.getRegionById(regionId); + return DefaultResponse.ok(response); + } + + @GetMapping("/search") + @Operation(summary = "지역 검색 API by 지미", description = "지역명으로 검색합니다. 부분 일치 검색을 지원합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "검색 성공", useReturnTypeSchema = true) + }) + public DefaultResponse searchRegions( + @RequestParam String keyword) { + RegionResDTO.RegionSearchResult response = regionQueryService.searchRegions(keyword); + return DefaultResponse.ok(response); + } + + @DeleteMapping("/codes/{regionCodeId}") + @Operation(summary = "지역코드 삭제 API by 지미 [Only Admin]", description = "지역코드를 삭제합니다(해당 코드를 사용하는 지역이 없어야 함. 관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + public DefaultResponse deleteRegionCode( + @Parameter(description = "지역코드 ID", required = true) + @PathVariable Long regionCodeId) { + log.info("지역코드 삭제 API 호출: regionCodeId={}", regionCodeId); + + RegionResDTO.DeleteRegionCode response = regionCommandService.deleteRegionCode(regionCodeId); + return DefaultResponse.ok(response); + } + + @DeleteMapping("/{regionId}") + @Operation(summary = "지역 삭제 API by 지미 [Only Admin]", description = "지역을 삭제합니다. 연관된 모든 날씨 데이터도 함께 삭제됩니다(관리자용).") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER400_0: 이미 존재하는 지역입니다. + - WEATHER400_5: 올바르지 않은 지역코드입니다. + """), + @ApiResponse(responseCode = "403", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER403_0: 접근 권한이 없습니다. + - WEATHER403_1: 관리자만 접근할 수 있습니다. + """), + @ApiResponse(responseCode = "404", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER404_0: 지역을 찾을 수 없습니다. + """), + @ApiResponse(responseCode = "500", + description = """ + 다음과 같은 이유로 실패할 수 있습니다: + - WEATHER500_22: 데이터 정리 중 오류가 발생했습니다. + """) + }) + @PreAuthorize("hasRole('ADMIN')") + 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 new file mode 100644 index 0000000..46773a6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/converter/RegionConverter.java @@ -0,0 +1,159 @@ +package org.withtime.be.withtimebe.domain.weather.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; + +import java.math.BigDecimal; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RegionConverter { + + public static RegionCode toRegionCode(RegionReqDTO.CreateRegionCode request) { + return RegionCode.builder() + .landRegCode(request.landRegCode()) + .tempRegCode(request.tempRegCode()) + .name(request.name()) + .build(); + } + + public static RegionResDTO.CreateRegionCode toCreateRegionCodeResponse(RegionCode regionCode) { + return RegionResDTO.CreateRegionCode.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .message("지역코드가 성공적으로 등록되었습니다.") + .build(); + } + + public static Region toRegion(RegionReqDTO.CreateRegion request, + BigDecimal gridX, BigDecimal gridY, RegionCode regionCode) { + return Region.builder() + .name(request.name()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .gridX(gridX) + .gridY(gridY) + .regionCode(regionCode) + .build(); + } + + public static RegionResDTO.CreateRegion toCreateRegion(Region region) { + return RegionResDTO.CreateRegion.builder() + .regionId(region.getId()) + .name(region.getName()) + .latitude(region.getLatitude()) + .longitude(region.getLongitude()) + .gridX(region.getGridX()) + .gridY(region.getGridY()) + .regionCode(toRegionCodeInfo(region.getRegionCode())) + .message("지역이 성공적으로 등록되었습니다.") + .build(); + } + + public static RegionResDTO.RegionCodeInfo toRegionCodeInfo(RegionCode regionCode) { + return RegionResDTO.RegionCodeInfo.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .build(); + } + + public static Region toEntityWithNewCode(RegionReqDTO.CreateRegionWithNewCode request, + BigDecimal gridX, BigDecimal gridY, RegionCode regionCode) { + return Region.builder() + .name(request.name()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .gridX(gridX) + .gridY(gridY) + .regionCode(regionCode) + .build(); + } + + public static RegionResDTO.RegionCodeDetail toRegionCodeDetail(RegionCode regionCode, int regionCount) { + return RegionResDTO.RegionCodeDetail.builder() + .regionCodeId(regionCode.getId()) + .landRegCode(regionCode.getLandRegCode()) + .tempRegCode(regionCode.getTempRegCode()) + .name(regionCode.getName()) + .regionCount(regionCount) + .createdAt(regionCode.getCreatedAt()) + .updatedAt(regionCode.getUpdatedAt()) + .build(); + } + + public static RegionResDTO.RegionCodeList toRegionCodeList(List regionCodesWithCount) { + List regionCodeDetails = regionCodesWithCount.stream() + .map(result -> { + RegionCode regionCode = (RegionCode) result[0]; + Long regionCount = (Long) result[1]; + return toRegionCodeDetail(regionCode, regionCount.intValue()); + }) + .toList(); + + return RegionResDTO.RegionCodeList.builder() + .regionCodes(regionCodeDetails) + .totalCount(regionCodeDetails.size()) + .build(); + } + + public static RegionResDTO.RegionInfo toRegionInfo(Region region) { + return RegionResDTO.RegionInfo.builder() + .regionId(region.getId()) + .name(region.getName()) + .latitude(region.getLatitude()) + .longitude(region.getLongitude()) + .gridX(region.getGridX()) + .gridY(region.getGridY()) + .regionCode(toRegionCodeInfo(region.getRegionCode())) + .createdAt(region.getCreatedAt()) + .updatedAt(region.getUpdatedAt()) + .build(); + } + + public static RegionResDTO.RegionList toRegionList(List regions) { + List regionInfos = regions.stream() + .map(RegionConverter::toRegionInfo) + .toList(); + + return RegionResDTO.RegionList.builder() + .regions(regionInfos) + .totalCount(regions.size()) + .build(); + } + + public static RegionResDTO.RegionSearchResult toSearchResult(List regions, String keyword) { + List regionInfos = regions.stream() + .map(RegionConverter::toRegionInfo) + .toList(); + + return RegionResDTO.RegionSearchResult.builder() + .regions(regionInfos) + .keyword(keyword) + .resultCount(regions.size()) + .build(); + } + + public static RegionResDTO.DeleteRegionCode toDeleteRegionCode(RegionCode regionCode) { + return RegionResDTO.DeleteRegionCode.builder() + .regionCodeId(regionCode.getId()) + .name(regionCode.getName()) + .message("지역코드가 성공적으로 삭제되었습니다.") + .build(); + } + + public static RegionResDTO.DeleteRegion toDeleteRegion(Region region) { + return RegionResDTO.DeleteRegion.builder() + .regionId(region.getId()) + .name(region.getName()) + .message("지역이 성공적으로 삭제되었습니다.") + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java new file mode 100644 index 0000000..b598b3c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/request/RegionReqDTO.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.weather.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; + +public class RegionReqDTO { + + public record CreateRegionCode( + @NotBlank(message = "중기 육상 예보 지역코드는 필수 입력값입니다.") + String landRegCode, + + @NotBlank(message = "중기 기온 예보 지역코드는 필수 입력값입니다.") + String tempRegCode, + + @NotBlank(message = "지역코드명은 필수 입력값입니다.") + String name + ) { + } + + public record CreateRegion( + @NotBlank(message = "지역명은 필수 입력값입니다.") + String name, + + @NotNull(message = "위도는 필수 입력값입니다.") + BigDecimal latitude, + + @NotNull(message = "경도는 필수 입력값입니다.") + BigDecimal longitude, + + @NotNull(message = "지역코드 ID는 필수 입력값입니다.") + @Positive(message = "지역코드 ID는 양수여야 합니다.") + Long regionCodeId + ) { + } + + public record CreateRegionWithNewCode( + @NotBlank(message = "지역명은 필수 입력값입니다.") + String name, + + @NotNull(message = "위도는 필수 입력값입니다.") + BigDecimal latitude, + + @NotNull(message = "경도는 필수 입력값입니다.") + BigDecimal longitude, + + @NotBlank(message = "중기 육상 예보 지역코드는 필수 입력값입니다.") + String landRegCode, + + @NotBlank(message = "중기 기온 예보 지역코드는 필수 입력값입니다.") + String tempRegCode, + + @NotBlank(message = "지역코드명은 필수 입력값입니다.") + String regionCodeName + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java new file mode 100644 index 0000000..106b36f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/dto/response/RegionResDTO.java @@ -0,0 +1,106 @@ +package org.withtime.be.withtimebe.domain.weather.dto.response; + +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public class RegionResDTO { + + @Builder + public record CreateRegionCode( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + String message + ) { + } + + @Builder + public record RegionCodeInfo( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name + ) { + } + + @Builder + public record CreateRegion( + Long regionId, + String name, + BigDecimal latitude, + BigDecimal longitude, + BigDecimal gridX, + BigDecimal gridY, + RegionCodeInfo regionCode, + String message + ) { + } + + @Builder + public record RegionCodeDetail( + Long regionCodeId, + String landRegCode, + String tempRegCode, + String name, + int regionCount, // 이 지역코드를 사용하는 지역 수 + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + @Builder + public record RegionCodeList( + List regionCodes, + int totalCount + ) { + } + + @Builder + public record RegionInfo( + Long regionId, + String name, + BigDecimal latitude, + BigDecimal longitude, + BigDecimal gridX, + BigDecimal gridY, + RegionCodeInfo regionCode, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } + + @Builder + public record RegionList( + List regions, + int totalCount + ) { + } + + @Builder + public record RegionSearchResult( + List regions, + String keyword, + int resultCount + ) { + } + + @Builder + public record DeleteRegionCode( + Long regionCodeId, + String name, + String message + ) { + } + + @Builder + public record DeleteRegion( + Long regionId, + String name, + String message + ) { + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java index f8a37d2..4d10007 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/Region.java @@ -16,24 +16,24 @@ public class Region extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "region_id") - private Long id; + private Long id; // region_id - @Column(name = "name") + @Column(nullable = false) private String name; - @Column(name = "latitude") + @Column(nullable = false, precision = 9, scale = 6) private BigDecimal latitude; - @Column(name = "longitude") + @Column(nullable = false, precision = 9, scale = 6) private BigDecimal longitude; - @Column(name = "grid_x") + @Column(name = "grid_x", nullable = false, precision = 5, scale = 2) private BigDecimal gridX; - @Column(name = "grid_y") + @Column(name = "grid_y", nullable = false, precision = 5, scale = 2) private BigDecimal gridY; - @Column(name = "reg_code") - private String regCode; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_code_id", nullable = false) + private RegionCode regionCode; } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java new file mode 100644 index 0000000..be2d0bf --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/entity/RegionCode.java @@ -0,0 +1,34 @@ +package org.withtime.be.withtimebe.domain.weather.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.withtime.be.withtimebe.global.common.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "region_code") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Getter +public class RegionCode extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; // region_code_id + + @Column(name = "land_reg_code", nullable = false, unique = true) + private String landRegCode; // 중기 육상 예보용 지역 코드 + + @Column(name = "temp_reg_code", nullable = false, unique = true) + private String tempRegCode; // 중기 기온 예보용 지역 코드 + + @Column(nullable = false) + private String name; + + @OneToMany(mappedBy = "regionCode", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List regions = new ArrayList<>(); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java new file mode 100644 index 0000000..fa72d29 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionCodeRepository.java @@ -0,0 +1,24 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; + +import java.util.List; + +public interface RegionCodeRepository extends JpaRepository { + + boolean existsByLandRegCode(String landRegCode); + + boolean existsByTempRegCode(String tempRegCode); + + @Query("SELECT rc, COUNT(r) FROM RegionCode rc " + + "LEFT JOIN rc.regions r " + + "GROUP BY rc " + + "ORDER BY rc.name ASC") + List findAllWithRegionCount(); + + @Query("SELECT COUNT(r) FROM Region r WHERE r.regionCode.id = :regionCodeId") + long countRegionsByRegionCodeId(@Param("regionCodeId") Long regionCodeId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java new file mode 100644 index 0000000..a1a7f9e --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/repository/RegionRepository.java @@ -0,0 +1,37 @@ +package org.withtime.be.withtimebe.domain.weather.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.withtime.be.withtimebe.domain.weather.entity.Region; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +public interface RegionRepository extends JpaRepository { + + boolean existsByName(String name); + + @Query("SELECT r FROM Region r WHERE " + + "ABS(r.latitude - :latitude) < 0.001 AND " + + "ABS(r.longitude - :longitude) < 0.001") + List findByNearCoordinates(@Param("latitude") BigDecimal latitude, + @Param("longitude") BigDecimal longitude); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "ORDER BY r.name ASC") + List findAllActiveRegions(); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.id = :id") + Optional findByIdWithRegionCode(@Param("id") Long id); + + @Query("SELECT r FROM Region r " + + "JOIN FETCH r.regionCode " + + "WHERE r.name LIKE %:keyword% " + + "ORDER BY r.name ASC") + List searchByNameContaining(@Param("keyword") String keyword); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java new file mode 100644 index 0000000..cebca59 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandService.java @@ -0,0 +1,17 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; + +public interface RegionCommandService { + + RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request); + + RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request); + + RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request); + + RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId); + + RegionResDTO.DeleteRegion deleteRegion(Long regionId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java new file mode 100644 index 0000000..911ef17 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/command/RegionCommandServiceImpl.java @@ -0,0 +1,244 @@ +package org.withtime.be.withtimebe.domain.weather.service.command; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.withtime.be.withtimebe.domain.weather.converter.RegionConverter; +import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.entity.RegionCode; +import org.withtime.be.withtimebe.domain.weather.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegionCommandServiceImpl implements RegionCommandService { + + private final WebClient weatherWebClient; + + private final RegionRepository regionRepository; + private final RegionCodeRepository regionCodeRepository; + + @Value("${weather.api.key}") + private String apiKey; + + @Value("${weather.api.grid-conversion-url}") + private String gridConversionUrl; + + @Override + public RegionResDTO.CreateRegionCode createRegionCode(RegionReqDTO.CreateRegionCode request) { + log.info("지역코드 등록 요청: {}", request.name()); + + validateDuplicateRegionCode(request.landRegCode(), request.tempRegCode()); + + RegionCode regionCode = RegionConverter.toRegionCode(request); + RegionCode savedRegionCode = regionCodeRepository.save(regionCode); + + log.info("지역코드 등록 완료: {} (ID: {})", savedRegionCode.getName(), savedRegionCode.getId()); + return RegionConverter.toCreateRegionCodeResponse(savedRegionCode); + } + + @Override + public RegionResDTO.CreateRegion createRegion(RegionReqDTO.CreateRegion request) { + log.info("지역 등록 요청: {}", request.name()); + + // 1. 중복 체크 + validateDuplicateRegion(request.name(), request.latitude(), request.longitude()); + + // 2. 지역코드 존재 확인 + RegionCode regionCode = regionCodeRepository.findById(request.regionCodeId()) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 3. 격자 좌표 변환 + CoordinateResult coordinateResult = convertToGridCoordinates(request.latitude(), request.longitude()); + + // 4. 지역 저장 + Region region = RegionConverter.toRegion(request, coordinateResult.gridX(), coordinateResult.gridY(), regionCode); + Region savedRegion = regionRepository.save(region); + + log.info("지역 등록 완료: {} (ID: {})", savedRegion.getName(), savedRegion.getId()); + return RegionConverter.toCreateRegion(savedRegion); + } + + @Override + public RegionResDTO.CreateRegion createRegionWithNewCode(RegionReqDTO.CreateRegionWithNewCode request) { + log.info("지역+지역코드 등록 요청: {}", request.name()); + + // 1. 중복 체크 + validateDuplicateRegion(request.name(), request.latitude(), request.longitude()); + validateDuplicateRegionCode(request.landRegCode(), request.tempRegCode()); + + // 2. 격자 좌표 변환 + CoordinateResult coordinateResult = convertToGridCoordinates(request.latitude(), request.longitude()); + + // 3. 지역코드 먼저 생성 + RegionCode regionCode = RegionCode.builder() + .landRegCode(request.landRegCode()) + .tempRegCode(request.tempRegCode()) + .name(request.regionCodeName()) + .build(); + RegionCode savedRegionCode = regionCodeRepository.save(regionCode); + + // 4. 지역 저장 + Region region = RegionConverter.toEntityWithNewCode( + request, coordinateResult.gridX(), coordinateResult.gridY(), savedRegionCode); + Region savedRegion = regionRepository.save(region); + + log.info("지역+지역코드 등록 완료: {} (ID: {})", savedRegion.getName(), savedRegion.getId()); + return RegionConverter.toCreateRegion(savedRegion); + } + + @Override + public RegionResDTO.DeleteRegionCode deleteRegionCode(Long regionCodeId) { + RegionCode regionCode = regionCodeRepository.findById(regionCodeId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 사용 중인 지역이 있는지 확인 + long regionCount = regionCodeRepository.countRegionsByRegionCodeId(regionCodeId); + if (regionCount > 0) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + + regionCodeRepository.delete(regionCode); + return RegionConverter.toDeleteRegionCode(regionCode); + } + + @Override + public RegionResDTO.DeleteRegion deleteRegion(Long regionId) { + Region region = regionRepository.findByIdWithRegionCode(regionId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + + // 연관된 날씨 데이터가 있는지 확인 (실제로는 CASCADE로 삭제됨) + log.warn("지역 삭제: {} (ID: {}) - 연관된 모든 날씨 데이터도 함께 삭제됩니다.", + region.getName(), region.getId()); + + regionRepository.delete(region); + + return RegionConverter.toDeleteRegion(region); + } + + // ==== 내부 유틸리티 메서드들 ==== + + private void validateDuplicateRegionCode(String landRegCode, String tempRegCode) { + if (regionCodeRepository.existsByLandRegCode(landRegCode)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + if (regionCodeRepository.existsByTempRegCode(tempRegCode)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + } + + private void validateDuplicateRegion(String name, BigDecimal latitude, BigDecimal longitude) { + // 지역명 중복 체크 + if (regionRepository.existsByName(name)) { + throw new WeatherException(WeatherErrorCode.REGION_ALREADY_EXISTS); + } + + // 유사한 좌표 체크 (매우 가까운 거리의 지역이 이미 있는지 확인) + List nearRegions = regionRepository.findByNearCoordinates(latitude, longitude); + if (!nearRegions.isEmpty()) { + log.warn("유사한 좌표의 지역이 이미 존재합니다: {}", nearRegions.get(0).getName()); + throw new WeatherException(WeatherErrorCode.INVALID_COORDINATES); + } + } + + /** + * 기상청 API를 호출하여 위경도를 격자 좌표로 변환 + */ + private CoordinateResult convertToGridCoordinates(BigDecimal latitude, BigDecimal longitude) { + try { + String response = weatherWebClient.get() + .uri(uriBuilder -> uriBuilder + .path(gridConversionUrl) + .queryParam("authKey", apiKey) + .queryParam("lat", latitude.toString()) + .queryParam("lon", longitude.toString()) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + if (response == null || response.trim().isEmpty()) { + throw new WeatherException(WeatherErrorCode.GRID_CONVERSION_ERROR); + } + + return parseGridCoordinates(response); + + } catch (Exception e) { + log.error("격자 좌표 변환 실패: lat={}, lon={}", latitude, longitude, e); + throw new WeatherException(WeatherErrorCode.GRID_CONVERSION_ERROR); + } + } + + private CoordinateResult parseGridCoordinates(String response) { + try { + log.debug("파싱할 응답: {}", response); + + // 멀티라인 응답 처리를 위한 패턴 수정 + // #START7777 이후의 데이터 라인에서 숫자들을 추출 + Pattern pattern = Pattern.compile( + "#START7777.*?\\s+([0-9.]+),\\s*([0-9.]+),\\s*([0-9]+),\\s*([0-9]+)", + Pattern.DOTALL // 개행 문자도 . 패턴에 포함 + ); + + Matcher matcher = pattern.matcher(response); + + if (matcher.find()) { + // 응답에서 추출된 값들: lon, lat, x, y 순서 + String lonStr = matcher.group(1); + String latStr = matcher.group(2); + String xStr = matcher.group(3); + String yStr = matcher.group(4); + + log.debug("파싱된 값들 - LON: {}, LAT: {}, X: {}, Y: {}", + lonStr, latStr, xStr, yStr); + + BigDecimal x = new BigDecimal(xStr); + BigDecimal y = new BigDecimal(yStr); + + log.debug("격자 좌표 변환 결과: X={}, Y={}", x, y); + return new CoordinateResult(x, y); + } else { + // 대안 패턴: 라인별로 분리해서 처리 + String[] lines = response.split("\n"); + for (String line : lines) { + log.debug("처리 중인 라인: '{}'", line.trim()); + // 숫자들이 포함된 데이터 라인 찾기 + if (line.trim().matches("\\s*[0-9.]+,\\s*[0-9.]+,\\s*[0-9]+,\\s*[0-9]+.*")) { + String[] values = line.trim().split(","); + if (values.length >= 4) { + BigDecimal x = new BigDecimal(values[2].trim()); + BigDecimal y = new BigDecimal(values[3].trim()); + + log.debug("라인별 파싱 성공 - X: {}, Y: {}", x, y); + return new CoordinateResult(x, y); + } + } + } + + log.error("격자 좌표 파싱 실패: 응답 형식이 올바르지 않음. response={}", response); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + + } catch (NumberFormatException e) { + log.error("숫자 변환 오류: {}", e.getMessage(), e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } catch (Exception e) { + log.error("격자 좌표 파싱 중 오류 발생: {}", response, e); + throw new WeatherException(WeatherErrorCode.API_RESPONSE_PARSING_ERROR); + } + } + + private record CoordinateResult(BigDecimal gridX, BigDecimal gridY) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java new file mode 100644 index 0000000..9dacbe3 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryService.java @@ -0,0 +1,14 @@ +package org.withtime.be.withtimebe.domain.weather.service.query; + +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; + +public interface RegionQueryService { + + RegionResDTO.RegionCodeList getAllRegionCodes(); + + RegionResDTO.RegionList getAllRegions(); + + RegionResDTO.RegionInfo getRegionById(Long regionId); + + RegionResDTO.RegionSearchResult searchRegions(String keyword); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java new file mode 100644 index 0000000..706e7e6 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/weather/service/query/RegionQueryServiceImpl.java @@ -0,0 +1,48 @@ +package org.withtime.be.withtimebe.domain.weather.service.query; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.withtime.be.withtimebe.domain.weather.converter.RegionConverter; +import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO; +import org.withtime.be.withtimebe.domain.weather.entity.Region; +import org.withtime.be.withtimebe.domain.weather.repository.RegionCodeRepository; +import org.withtime.be.withtimebe.domain.weather.repository.RegionRepository; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; +import org.withtime.be.withtimebe.global.error.exception.WeatherException; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class RegionQueryServiceImpl implements RegionQueryService{ + + private final RegionCodeRepository regionCodeRepository; + private final RegionRepository regionRepository; + + @Override + public RegionResDTO.RegionCodeList getAllRegionCodes() { + List regionCodesWithCount = regionCodeRepository.findAllWithRegionCount(); + return RegionConverter.toRegionCodeList(regionCodesWithCount); + } + + @Override + public RegionResDTO.RegionList getAllRegions() { + List regions = regionRepository.findAllActiveRegions(); + return RegionConverter.toRegionList(regions); + } + + @Override + public RegionResDTO.RegionInfo getRegionById(Long regionId) { + Region region = regionRepository.findByIdWithRegionCode(regionId) + .orElseThrow(() -> new WeatherException(WeatherErrorCode.REGION_NOT_FOUND)); + return RegionConverter.toRegionInfo(region); + } + + @Override + public RegionResDTO.RegionSearchResult searchRegions(String keyword) { + List regions = regionRepository.searchByNameContaining(keyword); + return RegionConverter.toSearchResult(regions, keyword); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java new file mode 100644 index 0000000..9e766b9 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/WeatherErrorCode.java @@ -0,0 +1,68 @@ +package org.withtime.be.withtimebe.global.error.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum WeatherErrorCode implements BaseErrorCode { + + // ==== 지역 관련 에러 (404) ==== + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_0", "지역을 찾을 수 없습니다."), + WEATHER_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_1", "날씨 데이터를 찾을 수 없습니다."), + WEATHER_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_2", "날씨 템플릿을 찾을 수 없습니다."), + DAILY_RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_3", "일일 추천 정보를 찾을 수 없습니다."), + KEYWORD_NOT_FOUND(HttpStatus.NOT_FOUND, "WEATHER404_4", "키워드를 찾을 수 없습니다."), + + // ==== 데이터 중복/충돌 에러 (400) ==== + REGION_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "WEATHER400_0", "이미 존재하는 지역입니다."), + WEATHER_TEMPLATE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "WEATHER400_1", "이미 존재하는 날씨 템플릿입니다."), + INVALID_COORDINATES(HttpStatus.BAD_REQUEST, "WEATHER400_2", "올바르지 않은 좌표입니다."), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "WEATHER400_3", "올바르지 않은 날짜 범위입니다."), + INVALID_WEATHER_DATA(HttpStatus.BAD_REQUEST, "WEATHER400_4", "올바르지 않은 날씨 데이터입니다."), + INVALID_REGION_CODE(HttpStatus.BAD_REQUEST, "WEATHER400_5", "올바르지 않은 지역코드입니다."), + INVALID_ENUM_VALUE(HttpStatus.BAD_REQUEST, "WEATHER400_6", "올바르지 않은 열거형 값입니다."), + + // ==== 권한/인증 에러 (403) ==== + ACCESS_DENIED(HttpStatus.FORBIDDEN, "WEATHER403_0", "접근 권한이 없습니다."), + ADMIN_ONLY_ACCESS(HttpStatus.FORBIDDEN, "WEATHER403_1", "관리자만 접근할 수 있습니다."), + + // ==== 기상청 API 관련 에러 (500) ==== + WEATHER_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_0", "기상청 API 호출 중 오류가 발생했습니다."), + GRID_CONVERSION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_1", "격자 좌표 변환 중 오류가 발생했습니다."), + SHORT_TERM_FORECAST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_2", "단기 예보 조회 중 오류가 발생했습니다."), + MEDIUM_TERM_FORECAST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_3", "중기 예보 조회 중 오류가 발생했습니다."), + API_RESPONSE_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_4", "API 응답 파싱 중 오류가 발생했습니다."), + + // ==== 데이터 처리 관련 에러 (500) ==== + WEATHER_DATA_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_10", "날씨 데이터 처리 중 오류가 발생했습니다."), + WEATHER_CLASSIFICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_11", "날씨 분류 중 오류가 발생했습니다."), + TEMPLATE_MATCHING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_12", "템플릿 매칭 중 오류가 발생했습니다."), + RECOMMENDATION_GENERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_13", "추천 생성 중 오류가 발생했습니다."), + + // ==== 스케줄러 관련 에러 (500) ==== + SCHEDULER_EXECUTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_20", "스케줄러 실행 중 오류가 발생했습니다."), + DATA_COLLECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_21", "데이터 수집 중 오류가 발생했습니다."), + DATA_CLEANUP_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "WEATHER500_22", "데이터 정리 중 오류가 발생했습니다."), + + // ==== 외부 서비스 에러 (502, 503) ==== + EXTERNAL_API_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "WEATHER503_0", "외부 API 서비스를 사용할 수 없습니다."), + API_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "WEATHER429_0", "API 호출 횟수 제한에 도달했습니다."), + API_TIMEOUT_ERROR(HttpStatus.GATEWAY_TIMEOUT, "WEATHER504_0", "API 응답 시간이 초과되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public DefaultResponseErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java new file mode 100644 index 0000000..4b8cbc5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/WeatherException.java @@ -0,0 +1,11 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; +import org.withtime.be.withtimebe.global.error.code.WeatherErrorCode; + +public class WeatherException extends ServerApplicationException { + public WeatherException(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index 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 e9cfc7a..85a8b2d 100644 --- a/src/main/resources/application-develop.yml +++ b/src/main/resources/application-develop.yml @@ -29,4 +29,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