Skip to content

Commit 74e521d

Browse files
authored
✨ [Feat] 단기 예보, 중기 예보 데이터 수집 스케줄러 및 트리거 API (#45)
2 parents 00b8c15 + ace4406 commit 74e521d

36 files changed

+2583
-35
lines changed

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ dependencies {
4949
// redis
5050
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
5151

52+
// WebClient
53+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
54+
55+
// Netty
56+
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
57+
5258
// Email
5359
implementation 'org.springframework.boot:spring-boot-starter-mail'
5460
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.withtime.be.withtimebe.domain.weather.config;
2+
3+
import io.netty.channel.ChannelOption;
4+
import io.netty.handler.timeout.ReadTimeoutHandler;
5+
import io.netty.handler.timeout.WriteTimeoutHandler;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
11+
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
12+
import org.springframework.web.reactive.function.client.WebClient;
13+
import reactor.core.publisher.Mono;
14+
import reactor.netty.http.client.HttpClient;
15+
16+
import java.time.Duration;
17+
import java.util.concurrent.TimeUnit;
18+
19+
@Slf4j
20+
@Configuration
21+
public class WeatherWebClientConfig {
22+
23+
@Value("${weather.api.base-url}")
24+
private String baseUrl;
25+
26+
@Value("${weather.api.timeout.connect}")
27+
private int connectTimeout;
28+
29+
@Value("${weather.api.timeout.read}")
30+
private int readTimeout;
31+
32+
/**
33+
* 기상청 API 전용 WebClient 설정
34+
*/
35+
@Bean("weatherWebClient")
36+
public WebClient weatherWebClient() {
37+
// HTTP 클라이언트 타임아웃 설정
38+
HttpClient httpClient = HttpClient.create()
39+
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
40+
.responseTimeout(Duration.ofMillis(readTimeout))
41+
.doOnConnected(conn ->
42+
conn.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS))
43+
.addHandlerLast(new WriteTimeoutHandler(connectTimeout, TimeUnit.MILLISECONDS))
44+
);
45+
46+
return WebClient.builder()
47+
.baseUrl(baseUrl)
48+
.clientConnector(new ReactorClientHttpConnector(httpClient))
49+
.filter(logRequest())
50+
.filter(logResponse())
51+
.filter(handleErrors())
52+
.build();
53+
}
54+
55+
/**
56+
* 요청 로깅 필터
57+
*/
58+
private ExchangeFilterFunction logRequest() {
59+
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
60+
log.info("기상청 API 요청: {} {}", clientRequest.method(), clientRequest.url());
61+
log.debug("요청 헤더: {}", clientRequest.headers());
62+
return Mono.just(clientRequest);
63+
});
64+
}
65+
66+
/**
67+
* 응답 로깅 필터
68+
*/
69+
private ExchangeFilterFunction logResponse() {
70+
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
71+
log.info("기상청 API 응답: {}", clientResponse.statusCode());
72+
log.debug("응답 헤더: {}", clientResponse.headers().asHttpHeaders());
73+
return Mono.just(clientResponse);
74+
});
75+
}
76+
77+
/**
78+
* 에러 처리 필터
79+
*/
80+
private ExchangeFilterFunction handleErrors() {
81+
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
82+
if (clientResponse.statusCode().isError()) {
83+
log.error("기상청 API 오류 응답: {} {}",
84+
clientResponse.statusCode().value(),
85+
clientResponse.statusCode());
86+
}
87+
return Mono.just(clientResponse);
88+
});
89+
}
90+
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package org.withtime.be.withtimebe.domain.weather.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.namul.api.payload.response.DefaultResponse;
12+
import org.springframework.security.access.prepost.PreAuthorize;
13+
import org.springframework.web.bind.annotation.*;
14+
import org.withtime.be.withtimebe.domain.weather.dto.request.RegionReqDTO;
15+
import org.withtime.be.withtimebe.domain.weather.dto.response.RegionResDTO;
16+
import org.withtime.be.withtimebe.domain.weather.service.command.RegionCommandService;
17+
import org.withtime.be.withtimebe.domain.weather.service.query.RegionQueryService;
18+
19+
@Slf4j
20+
@RestController
21+
@RequestMapping("/api/v1/regions")
22+
@RequiredArgsConstructor
23+
@Tag(name = "지역 관리 API", description = "지역 등록/관리 API")
24+
public class RegionController {
25+
26+
private final RegionCommandService regionCommandService;
27+
private final RegionQueryService regionQueryService;
28+
29+
@PostMapping("/codes")
30+
@Operation(summary = "지역코드 등록 API by 지미 [Only Admin]", description = "새로운 지역코드를 등록합니다(관리자용).")
31+
@ApiResponses(value = {
32+
@ApiResponse(responseCode = "200", description = "지역코드 등록 성공"),
33+
@ApiResponse(responseCode = "400",
34+
description = """
35+
다음과 같은 이유로 실패할 수 있습니다:
36+
- WEATHER400_0: 이미 존재하는 지역입니다.
37+
- WEATHER400_5: 올바르지 않은 지역코드입니다.
38+
"""),
39+
@ApiResponse(responseCode = "403",
40+
description = """
41+
다음과 같은 이유로 실패할 수 있습니다:
42+
- WEATHER403_0: 접근 권한이 없습니다.
43+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
44+
""")
45+
})
46+
public DefaultResponse<RegionResDTO.CreateRegionCode> createRegionCode(
47+
@Valid @RequestBody RegionReqDTO.CreateRegionCode request) {
48+
log.info("지역코드 등록 API 호출: {}", request.name());
49+
50+
RegionResDTO.CreateRegionCode response = regionCommandService.createRegionCode(request);
51+
return DefaultResponse.created(response);
52+
}
53+
54+
@PostMapping
55+
@Operation(summary = "지역 등록 API by 지미 [Only Admin]",
56+
description = "기존 지역코드를 사용하여 새로운 지역을 등록합니다. 위경도는 자동으로 격자 좌표로 변환됩니다(관리자용).")
57+
@ApiResponses(value = {
58+
@ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true),
59+
@ApiResponse(responseCode = "400",
60+
description = """
61+
다음과 같은 이유로 실패할 수 있습니다:
62+
- WEATHER400_0: 이미 존재하는 지역입니다.
63+
- WEATHER400_2: 올바르지 않은 좌표입니다.
64+
- WEATHER400_5: 올바르지 않은 지역코드입니다.
65+
"""),
66+
@ApiResponse(responseCode = "403",
67+
description = """
68+
다음과 같은 이유로 실패할 수 있습니다:
69+
- WEATHER403_0: 접근 권한이 없습니다.
70+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
71+
"""),
72+
@ApiResponse(responseCode = "404",
73+
description = """
74+
다음과 같은 이유로 실패할 수 있습니다:
75+
- WEATHER404_0: 지역을 찾을 수 없습니다.
76+
"""),
77+
@ApiResponse(responseCode = "500",
78+
description = """
79+
다음과 같은 이유로 실패할 수 있습니다:
80+
- WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다.
81+
""")
82+
})
83+
public DefaultResponse<RegionResDTO.CreateRegion> createRegion(
84+
@Valid @RequestBody RegionReqDTO.CreateRegion request) {
85+
log.info("지역 등록 API 호출: {}", request.name());
86+
87+
RegionResDTO.CreateRegion response = regionCommandService.createRegion(request);
88+
return DefaultResponse.ok(response);
89+
}
90+
91+
@PostMapping("/bundle")
92+
@Operation(summary = "지역+지역코드 동시 등록 API by 지미 [Only Admin]", description = "새로운 지역코드와 함께 지역을 등록합니다(관리자용).")
93+
@ApiResponses(value = {
94+
@ApiResponse(responseCode = "200", description = "등록 성공", useReturnTypeSchema = true),
95+
@ApiResponse(responseCode = "400",
96+
description = """
97+
다음과 같은 이유로 실패할 수 있습니다:
98+
- WEATHER400_0: 이미 존재하는 지역입니다.
99+
- WEATHER400_2: 올바르지 않은 좌표입니다.
100+
- WEATHER400_5: 올바르지 않은 지역코드입니다.
101+
"""),
102+
@ApiResponse(responseCode = "403",
103+
description = """
104+
다음과 같은 이유로 실패할 수 있습니다:
105+
- WEATHER403_0: 접근 권한이 없습니다.
106+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
107+
"""),
108+
@ApiResponse(responseCode = "500",
109+
description = """
110+
다음과 같은 이유로 실패할 수 있습니다:
111+
- WEATHER500_1: 격자 좌표 변환 중 오류가 발생했습니다.
112+
""")
113+
})
114+
public DefaultResponse<RegionResDTO.CreateRegion> createRegionWithNewCode(
115+
@Valid @RequestBody RegionReqDTO.CreateRegionWithNewCode request) {
116+
log.info("지역+지역코드 등록 API 호출: {}", request.name());
117+
118+
RegionResDTO.CreateRegion response = regionCommandService.createRegionWithNewCode(request);
119+
return DefaultResponse.ok(response);
120+
}
121+
122+
@GetMapping("/codes")
123+
@Operation(summary = "지역코드 목록 조회 API by 지미 [Only Admin]", description = "등록된 모든 지역코드 목록을 조회합니다(관리자용).")
124+
@ApiResponses(value = {
125+
@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true),
126+
@ApiResponse(responseCode = "403",
127+
description = """
128+
다음과 같은 이유로 실패할 수 있습니다:
129+
- WEATHER403_0: 접근 권한이 없습니다.
130+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
131+
""")
132+
})
133+
public DefaultResponse<RegionResDTO.RegionCodeList> getAllRegionCodes() {
134+
log.info("지역코드 목록 조회 API 호출");
135+
136+
RegionResDTO.RegionCodeList response = regionQueryService.getAllRegionCodes();
137+
return DefaultResponse.ok(response);
138+
}
139+
140+
@GetMapping
141+
@Operation(summary = "지역 목록 조회 by 지미", description = "등록된 모든 지역 목록을 조회합니다.")
142+
@ApiResponses(value = {
143+
@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true),
144+
@ApiResponse(responseCode = "403",
145+
description = """
146+
다음과 같은 이유로 실패할 수 있습니다:
147+
- WEATHER403_0: 접근 권한이 없습니다.
148+
""")
149+
})
150+
public DefaultResponse<RegionResDTO.RegionList> getAllRegions() {
151+
log.info("지역 목록 조회 API 호출");
152+
153+
RegionResDTO.RegionList response = regionQueryService.getAllRegions();
154+
return DefaultResponse.ok(response);
155+
}
156+
157+
@GetMapping("/{regionId}")
158+
@Operation(summary = "지역 상세 조회 API by 지미", description = "특정 지역의 상세 정보를 조회합니다.")
159+
@ApiResponses(value = {
160+
@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true),
161+
@ApiResponse(responseCode = "403",
162+
description = """
163+
다음과 같은 이유로 실패할 수 있습니다:
164+
- WEATHER403_0: 접근 권한이 없습니다.
165+
"""),
166+
@ApiResponse(responseCode = "404",
167+
description = """
168+
다음과 같은 이유로 실패할 수 있습니다:
169+
- WEATHER404_0: 지역을 찾을 수 없습니다.
170+
""")
171+
})
172+
public DefaultResponse<RegionResDTO.RegionInfo> getRegion(
173+
@Parameter(description = "지역 ID", required = true)
174+
@PathVariable Long regionId) {
175+
176+
log.info("지역 상세 조회 API 호출: regionId={}", regionId);
177+
178+
RegionResDTO.RegionInfo response = regionQueryService.getRegionById(regionId);
179+
return DefaultResponse.ok(response);
180+
}
181+
182+
@GetMapping("/search")
183+
@Operation(summary = "지역 검색 API by 지미", description = "지역명으로 검색합니다. 부분 일치 검색을 지원합니다.")
184+
@ApiResponses(value = {
185+
@ApiResponse(responseCode = "200", description = "검색 성공", useReturnTypeSchema = true)
186+
})
187+
public DefaultResponse<RegionResDTO.RegionSearchResult> searchRegions(
188+
@RequestParam String keyword) {
189+
RegionResDTO.RegionSearchResult response = regionQueryService.searchRegions(keyword);
190+
return DefaultResponse.ok(response);
191+
}
192+
193+
@DeleteMapping("/codes/{regionCodeId}")
194+
@Operation(summary = "지역코드 삭제 API by 지미 [Only Admin]", description = "지역코드를 삭제합니다(해당 코드를 사용하는 지역이 없어야 함. 관리자용).")
195+
@ApiResponses(value = {
196+
@ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true),
197+
@ApiResponse(responseCode = "400",
198+
description = """
199+
다음과 같은 이유로 실패할 수 있습니다:
200+
- WEATHER400_0: 이미 존재하는 지역입니다.
201+
- WEATHER400_5: 올바르지 않은 지역코드입니다.
202+
"""),
203+
@ApiResponse(responseCode = "403",
204+
description = """
205+
다음과 같은 이유로 실패할 수 있습니다:
206+
- WEATHER403_0: 접근 권한이 없습니다.
207+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
208+
"""),
209+
@ApiResponse(responseCode = "404",
210+
description = """
211+
다음과 같은 이유로 실패할 수 있습니다:
212+
- WEATHER404_0: 지역을 찾을 수 없습니다.
213+
"""),
214+
@ApiResponse(responseCode = "500",
215+
description = """
216+
다음과 같은 이유로 실패할 수 있습니다:
217+
- WEATHER500_22: 데이터 정리 중 오류가 발생했습니다.
218+
""")
219+
})
220+
public DefaultResponse<RegionResDTO.DeleteRegionCode> deleteRegionCode(
221+
@Parameter(description = "지역코드 ID", required = true)
222+
@PathVariable Long regionCodeId) {
223+
log.info("지역코드 삭제 API 호출: regionCodeId={}", regionCodeId);
224+
225+
RegionResDTO.DeleteRegionCode response = regionCommandService.deleteRegionCode(regionCodeId);
226+
return DefaultResponse.ok(response);
227+
}
228+
229+
@DeleteMapping("/{regionId}")
230+
@Operation(summary = "지역 삭제 API by 지미 [Only Admin]", description = "지역을 삭제합니다. 연관된 모든 날씨 데이터도 함께 삭제됩니다(관리자용).")
231+
@ApiResponses(value = {
232+
@ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true),
233+
@ApiResponse(responseCode = "400",
234+
description = """
235+
다음과 같은 이유로 실패할 수 있습니다:
236+
- WEATHER400_0: 이미 존재하는 지역입니다.
237+
- WEATHER400_5: 올바르지 않은 지역코드입니다.
238+
"""),
239+
@ApiResponse(responseCode = "403",
240+
description = """
241+
다음과 같은 이유로 실패할 수 있습니다:
242+
- WEATHER403_0: 접근 권한이 없습니다.
243+
- WEATHER403_1: 관리자만 접근할 수 있습니다.
244+
"""),
245+
@ApiResponse(responseCode = "404",
246+
description = """
247+
다음과 같은 이유로 실패할 수 있습니다:
248+
- WEATHER404_0: 지역을 찾을 수 없습니다.
249+
"""),
250+
@ApiResponse(responseCode = "500",
251+
description = """
252+
다음과 같은 이유로 실패할 수 있습니다:
253+
- WEATHER500_22: 데이터 정리 중 오류가 발생했습니다.
254+
""")
255+
})
256+
public DefaultResponse<RegionResDTO.DeleteRegion> deleteRegion(
257+
@Parameter(description = "지역 ID", required = true)
258+
@PathVariable Long regionId) {
259+
260+
log.info("지역 삭제 API 호출: regionId={}", regionId);
261+
262+
RegionResDTO.DeleteRegion response = regionCommandService.deleteRegion(regionId);
263+
264+
return DefaultResponse.ok(response);
265+
}
266+
}

0 commit comments

Comments
 (0)