Skip to content

Commit 89fe968

Browse files
Merge pull request #73 from ssafy-salman/phase/3-safety-openapi-clients
[Phase 3] feat(safety): 안전시설 공공데이터 클라이언트 구현
2 parents 2b36bb9 + 007bd48 commit 89fe968

17 files changed

Lines changed: 1051 additions & 0 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.ssafy.salmanhae.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
public class SafetyDataProperties {
8+
9+
private static final String DEFAULT_CCTV_URL = "https://file.localdata.go.kr/file/cctv_info/info";
10+
private static final String DEFAULT_SAFEMAP_POLICE_URL = "https://www.safemap.go.kr/openapi2/IF_0036";
11+
private static final int DEFAULT_PAGE_SIZE = 1000;
12+
13+
@Value("${safety.data.public-service-key:}")
14+
private String publicServiceKey;
15+
16+
@Value("${safety.data.safemap-service-key:}")
17+
private String safemapServiceKey;
18+
19+
@Value("${safety.data.cctv-url:}")
20+
private String cctvUrl;
21+
22+
@Value("${safety.data.emergency-bell-url:}")
23+
private String emergencyBellUrl;
24+
25+
@Value("${safety.data.security-light-url:}")
26+
private String securityLightUrl;
27+
28+
@Value("${safety.data.safemap-police-url:}")
29+
private String safemapPoliceUrl;
30+
31+
@Value("${safety.data.page-size:1000}")
32+
private Integer pageSize;
33+
34+
public String publicServiceKey() {
35+
return firstNonBlank(publicServiceKey, System.getenv("PUBLIC_DATA_SERVICE_KEY"));
36+
}
37+
38+
public String safemapServiceKey() {
39+
return firstNonBlank(safemapServiceKey, System.getenv("SAFEMAP_SERVICE_KEY"));
40+
}
41+
42+
public String cctvUrl() {
43+
return defaultIfBlank(cctvUrl, DEFAULT_CCTV_URL);
44+
}
45+
46+
public String emergencyBellUrl() {
47+
return blankToEmpty(emergencyBellUrl);
48+
}
49+
50+
public String securityLightUrl() {
51+
return blankToEmpty(securityLightUrl);
52+
}
53+
54+
public String safemapPoliceUrl() {
55+
return defaultIfBlank(safemapPoliceUrl, DEFAULT_SAFEMAP_POLICE_URL);
56+
}
57+
58+
public int pageSize() {
59+
return pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : pageSize;
60+
}
61+
62+
private String defaultIfBlank(String value, String fallback) {
63+
String normalized = blankToEmpty(value);
64+
return normalized.isBlank() ? fallback : normalized;
65+
}
66+
67+
private String firstNonBlank(String primary, String fallback) {
68+
String normalized = blankToEmpty(primary);
69+
return normalized.isBlank() ? blankToEmpty(fallback) : normalized;
70+
}
71+
72+
private String blankToEmpty(String value) {
73+
return value == null ? "" : value.trim();
74+
}
75+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.ssafy.salmanhae.service.safety.ingest;
2+
3+
import java.net.URI;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.web.client.RestClient;
10+
import org.springframework.web.util.UriComponentsBuilder;
11+
12+
import com.fasterxml.jackson.databind.JsonNode;
13+
import com.fasterxml.jackson.databind.ObjectMapper;
14+
import com.ssafy.salmanhae.config.SafetyDataProperties;
15+
16+
abstract class AbstractJsonSafetyFacilityOpenApiClient implements SafetyFacilitySourceClient {
17+
18+
private static final int MAX_PAGES = 1000;
19+
20+
private final Logger log = LoggerFactory.getLogger(getClass());
21+
22+
private final String sourceName;
23+
private final String payloadName;
24+
private final SafetyDataProperties properties;
25+
private final RestClient restClient;
26+
private final ObjectMapper objectMapper;
27+
28+
AbstractJsonSafetyFacilityOpenApiClient(
29+
String sourceName,
30+
String payloadName,
31+
SafetyDataProperties properties,
32+
RestClient.Builder restClientBuilder,
33+
ObjectMapper objectMapper
34+
) {
35+
this.sourceName = sourceName;
36+
this.payloadName = payloadName;
37+
this.properties = properties;
38+
this.restClient = SafetyFacilityHttpSupport.restClient(restClientBuilder);
39+
this.objectMapper = objectMapper;
40+
}
41+
42+
@Override
43+
public String sourceName() {
44+
return sourceName;
45+
}
46+
47+
List<NormalizedSafetyFacility> fetchPagedJson(String baseUrl, String serviceKey) {
48+
if (baseUrl == null || baseUrl.isBlank()) {
49+
return List.of();
50+
}
51+
int pageSize = properties.pageSize();
52+
if (pageSize <= 0) {
53+
log.warn("Invalid page size {} for {} safety facilities", pageSize, payloadName);
54+
return List.of();
55+
}
56+
List<NormalizedSafetyFacility> facilities = new ArrayList<>();
57+
int pageNo = 1;
58+
int totalCount = -1;
59+
while (pageNo <= MAX_PAGES && (totalCount < 0 || (long) (pageNo - 1) * pageSize < totalCount)) {
60+
URI uri = UriComponentsBuilder.fromUriString(baseUrl)
61+
.queryParam("serviceKey", serviceKey)
62+
.queryParam("pageNo", pageNo)
63+
.queryParam("numOfRows", pageSize)
64+
.queryParam("type", "json")
65+
.queryParam("returnType", "json")
66+
.build()
67+
.encode()
68+
.toUri();
69+
String body;
70+
try {
71+
body = restClient.get().uri(uri).retrieve().body(String.class);
72+
} catch (RuntimeException exception) {
73+
log.warn("Failed to fetch {} safety facilities from {}", payloadName, baseUrl, exception);
74+
break;
75+
}
76+
if (body == null || body.isBlank()) {
77+
break;
78+
}
79+
JsonNode root;
80+
try {
81+
root = objectMapper.readTree(body);
82+
} catch (Exception exception) {
83+
log.warn("Failed to parse {} safety facilities from {}", payloadName, baseUrl, exception);
84+
break;
85+
}
86+
ParsedSafetyFacilityPage page;
87+
try {
88+
page = parsePage(root);
89+
} catch (RuntimeException exception) {
90+
log.warn("Failed to parse {} safety facilities from {}", payloadName, baseUrl, exception);
91+
break;
92+
}
93+
if (totalCount < 0) {
94+
totalCount = SafetyFacilityParserSupport.totalCount(root);
95+
}
96+
if (page.rawItemCount() == 0) {
97+
break;
98+
}
99+
facilities.addAll(page.facilities());
100+
if (page.rawItemCount() < pageSize) {
101+
break;
102+
}
103+
pageNo++;
104+
}
105+
if (pageNo > MAX_PAGES) {
106+
log.warn("Stopped fetching {} safety facilities after reaching max page limit {}", payloadName, MAX_PAGES);
107+
}
108+
return facilities;
109+
}
110+
111+
public List<NormalizedSafetyFacility> parseFacilities(String json) {
112+
return parsePage(json).facilities();
113+
}
114+
115+
private ParsedSafetyFacilityPage parsePage(String json) {
116+
try {
117+
return parsePage(objectMapper.readTree(json));
118+
} catch (Exception exception) {
119+
throw new IllegalArgumentException("Invalid " + payloadName + " JSON payload", exception);
120+
}
121+
}
122+
123+
private ParsedSafetyFacilityPage parsePage(JsonNode root) {
124+
List<JsonNode> items = SafetyFacilityParserSupport.itemNodes(root);
125+
List<NormalizedSafetyFacility> facilities = items.stream()
126+
.map(this::toFacility)
127+
.filter(NormalizedSafetyFacility::hasUsableCoordinates)
128+
.toList();
129+
return new ParsedSafetyFacilityPage(facilities, items.size());
130+
}
131+
132+
abstract NormalizedSafetyFacility toFacility(JsonNode node);
133+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.ssafy.salmanhae.service.safety.ingest;
2+
3+
import java.math.BigDecimal;
4+
import java.util.List;
5+
import java.util.Map;
6+
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.web.client.RestClient;
11+
12+
import com.ssafy.salmanhae.config.SafetyDataProperties;
13+
import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityType;
14+
15+
@Component
16+
public class CctvCsvClient implements SafetyFacilitySourceClient {
17+
18+
static final String SOURCE = "CCTV_CSV";
19+
20+
private static final Logger log = LoggerFactory.getLogger(CctvCsvClient.class);
21+
22+
private final SafetyDataProperties properties;
23+
private final RestClient restClient;
24+
25+
public CctvCsvClient(SafetyDataProperties properties, RestClient.Builder restClientBuilder) {
26+
this.properties = properties;
27+
this.restClient = SafetyFacilityHttpSupport.restClient(restClientBuilder);
28+
}
29+
30+
@Override
31+
public String sourceName() {
32+
return SOURCE;
33+
}
34+
35+
@Override
36+
public List<NormalizedSafetyFacility> fetchFacilities() {
37+
try {
38+
String body = restClient.get()
39+
.uri(properties.cctvUrl())
40+
.retrieve()
41+
.body(String.class);
42+
if (body == null || body.isBlank()) {
43+
return List.of();
44+
}
45+
return parseFacilities(body);
46+
} catch (RuntimeException exception) {
47+
log.warn("Failed to fetch CCTV safety facilities from {}", properties.cctvUrl(), exception);
48+
return List.of();
49+
}
50+
}
51+
52+
public List<NormalizedSafetyFacility> parseFacilities(String csv) {
53+
return SafetyFacilityParserSupport.parseCsv(csv).stream()
54+
.map(this::toFacility)
55+
.filter(NormalizedSafetyFacility::hasUsableCoordinates)
56+
.toList();
57+
}
58+
59+
private NormalizedSafetyFacility toFacility(Map<String, String> row) {
60+
BigDecimal latitude = SafetyFacilityParserSupport.decimal(SafetyFacilityParserSupport.value(
61+
row, "latitude", "lat", "위도", "WGS84위도"
62+
));
63+
BigDecimal longitude = SafetyFacilityParserSupport.decimal(SafetyFacilityParserSupport.value(
64+
row, "longitude", "lng", "lon", "경도", "WGS84경도"
65+
));
66+
String sourceId = SafetyFacilityParserSupport.value(
67+
row, "id", "source_id", "관리번호", "시설관리번호", "CCTV관리번호"
68+
);
69+
String name = SafetyFacilityParserSupport.value(row, "name", "시설명", "설치목적", "관리기관명");
70+
if (name.isBlank()) {
71+
name = "CCTV";
72+
}
73+
if (sourceId.isBlank()) {
74+
sourceId = name + ":" + latitude + ":" + longitude;
75+
}
76+
return new NormalizedSafetyFacility(
77+
SafetyFacilityType.CCTV,
78+
name,
79+
SafetyFacilityParserSupport.value(row, "address", "주소", "소재지도로명주소", "소재지지번주소"),
80+
latitude,
81+
longitude,
82+
SOURCE,
83+
sourceId,
84+
SafetyFacilityParserSupport.value(row, "description", "설명", "용도")
85+
);
86+
}
87+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.ssafy.salmanhae.service.safety.ingest;
2+
3+
import java.math.BigDecimal;
4+
import java.util.List;
5+
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.web.client.RestClient;
8+
9+
import com.fasterxml.jackson.databind.JsonNode;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import com.ssafy.salmanhae.config.SafetyDataProperties;
12+
import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityType;
13+
14+
@Component
15+
public class EmergencyBellOpenApiClient extends AbstractJsonSafetyFacilityOpenApiClient {
16+
17+
static final String SOURCE = "EMERGENCY_BELL_OPENAPI";
18+
19+
private final SafetyDataProperties properties;
20+
21+
public EmergencyBellOpenApiClient(
22+
SafetyDataProperties properties,
23+
RestClient.Builder restClientBuilder,
24+
ObjectMapper objectMapper
25+
) {
26+
super(SOURCE, "emergency bell", properties, restClientBuilder, objectMapper);
27+
this.properties = properties;
28+
}
29+
30+
@Override
31+
public List<NormalizedSafetyFacility> fetchFacilities() {
32+
return fetchPagedJson(properties.emergencyBellUrl(), properties.publicServiceKey());
33+
}
34+
35+
@Override
36+
NormalizedSafetyFacility toFacility(JsonNode node) {
37+
BigDecimal latitude = SafetyFacilityParserSupport.decimal(node, "latitude", "lat", "위도", "la");
38+
BigDecimal longitude = SafetyFacilityParserSupport.decimal(node, "longitude", "lng", "lon", "경도", "lo");
39+
String name = SafetyFacilityParserSupport.text(node, "name", "facilityName", "fcltyNm", "시설명", "bellName");
40+
if (name.isBlank()) {
41+
name = "안전비상벨";
42+
}
43+
String sourceId = SafetyFacilityParserSupport.text(
44+
node, "id", "sourceId", "source_id", "objtId", "bellId", "관리번호"
45+
);
46+
if (sourceId.isBlank()) {
47+
sourceId = name + ":" + latitude + ":" + longitude;
48+
}
49+
return new NormalizedSafetyFacility(
50+
SafetyFacilityType.EMERGENCY_BELL,
51+
name,
52+
SafetyFacilityParserSupport.text(node, "address", "adres", "addr", "주소", "rnAdres"),
53+
latitude,
54+
longitude,
55+
SOURCE,
56+
sourceId,
57+
SafetyFacilityParserSupport.text(node, "description", "설명", "remark", "비고")
58+
);
59+
}
60+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.ssafy.salmanhae.service.safety.ingest;
2+
3+
import java.math.BigDecimal;
4+
5+
import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityRow;
6+
import com.ssafy.salmanhae.model.dto.safety.SafetyFacilityType;
7+
8+
public record NormalizedSafetyFacility(
9+
SafetyFacilityType type,
10+
String name,
11+
String address,
12+
BigDecimal latitude,
13+
BigDecimal longitude,
14+
String source,
15+
String sourceId,
16+
String description
17+
) {
18+
19+
public boolean hasUsableCoordinates() {
20+
return latitude != null && longitude != null
21+
&& latitude.compareTo(BigDecimal.valueOf(-90)) >= 0
22+
&& latitude.compareTo(BigDecimal.valueOf(90)) <= 0
23+
&& longitude.compareTo(BigDecimal.valueOf(-180)) >= 0
24+
&& longitude.compareTo(BigDecimal.valueOf(180)) <= 0;
25+
}
26+
27+
public SafetyFacilityRow toRow() {
28+
return new SafetyFacilityRow(
29+
null,
30+
type,
31+
name,
32+
address,
33+
latitude,
34+
longitude,
35+
source,
36+
sourceId,
37+
description
38+
);
39+
}
40+
}

0 commit comments

Comments
 (0)