chore: develop 머지 충돌 해결 (RELEASE-NOTES.md)
This commit is contained in:
커밋
b34efe37de
@ -53,6 +53,8 @@ jobs:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
OPENSKY_CLIENT_ID: ${{ secrets.OPENSKY_CLIENT_ID }}
|
||||
OPENSKY_CLIENT_SECRET: ${{ secrets.OPENSKY_CLIENT_SECRET }}
|
||||
run: |
|
||||
DEPLOY_DIR=/deploy/kcg-backend
|
||||
mkdir -p $DEPLOY_DIR/backup
|
||||
@ -68,6 +70,8 @@ jobs:
|
||||
[ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$OPENSKY_CLIENT_ID" ] && echo "OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}" >> $DEPLOY_DIR/.env
|
||||
[ -n "$OPENSKY_CLIENT_SECRET" ] && echo "OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}" >> $DEPLOY_DIR/.env
|
||||
|
||||
# JAR 내부에 application-prod.yml이 있으면 외부 파일 제거
|
||||
if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then
|
||||
|
||||
@ -3,6 +3,7 @@ package gc.mda.kcg.collector.aircraft;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
||||
@ -11,9 +12,15 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
@ -24,22 +31,23 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class OpenSkyCollector {
|
||||
|
||||
private static final String BASE_URL = "https://opensky-network.org/api";
|
||||
|
||||
// 이란/중동 bbox
|
||||
private static final String IRAN_PARAMS = "lamin=24&lomin=30&lamax=42&lomax=62";
|
||||
// 한국/동아시아 bbox
|
||||
private static final String KOREA_PARAMS = "lamin=20&lomin=115&lamax=45&lomax=145";
|
||||
private static final long TOKEN_REFRESH_MARGIN_SEC = 120;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AircraftCacheStore cacheStore;
|
||||
private final AircraftPositionRepository positionRepository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
|
||||
private String accessToken;
|
||||
private Instant tokenExpiresAt = Instant.EPOCH;
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 300_000)
|
||||
public void collectIran() {
|
||||
List<AircraftDto> aircraft = fetchStates(IRAN_PARAMS);
|
||||
if (!aircraft.isEmpty()) {
|
||||
@ -48,12 +56,12 @@ public class OpenSkyCollector {
|
||||
persistAll(aircraft, "opensky", "iran");
|
||||
tracker.recordSuccess("opensky-iran", "iran", aircraft.size());
|
||||
} else {
|
||||
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429");
|
||||
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 인증 실패");
|
||||
}
|
||||
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
|
||||
@Scheduled(initialDelay = 180_000, fixedDelay = 300_000)
|
||||
public void collectKorea() {
|
||||
List<AircraftDto> aircraft = fetchStates(KOREA_PARAMS);
|
||||
if (!aircraft.isEmpty()) {
|
||||
@ -62,15 +70,23 @@ public class OpenSkyCollector {
|
||||
persistAll(aircraft, "opensky", "korea");
|
||||
tracker.recordSuccess("opensky-korea", "korea", aircraft.size());
|
||||
} else {
|
||||
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429");
|
||||
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 인증 실패");
|
||||
}
|
||||
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
|
||||
}
|
||||
|
||||
private List<AircraftDto> fetchStates(String bboxParams) {
|
||||
try {
|
||||
String url = BASE_URL + "/states/all?" + bboxParams;
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||
String token = getAccessToken();
|
||||
String url = appProperties.getCollector().getOpenSkyBaseUrl() + "/states/all?" + bboxParams;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (token != null) {
|
||||
headers.setBearerAuth(token);
|
||||
}
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
||||
if (response.getStatusCode().value() == 429) {
|
||||
log.warn("OpenSky 429 rate limited, 스킵");
|
||||
return List.of();
|
||||
@ -83,6 +99,47 @@ public class OpenSkyCollector {
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
if (accessToken != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(TOKEN_REFRESH_MARGIN_SEC))) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
String clientId = appProperties.getCollector().getOpenSkyClientId();
|
||||
String clientSecret = appProperties.getCollector().getOpenSkyClientSecret();
|
||||
if (clientId == null || clientSecret == null) {
|
||||
log.debug("OpenSky OAuth2 미설정, 익명 모드로 동작");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String authUrl = appProperties.getCollector().getOpenSkyAuthUrl();
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
|
||||
body.add("grant_type", "client_credentials");
|
||||
body.add("client_id", clientId);
|
||||
body.add("client_secret", clientSecret);
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(authUrl, request, String.class);
|
||||
|
||||
JsonNode json = objectMapper.readTree(response.getBody());
|
||||
accessToken = json.get("access_token").asText();
|
||||
int expiresIn = json.get("expires_in").asInt();
|
||||
tokenExpiresAt = Instant.now().plusSeconds(expiresIn);
|
||||
|
||||
log.info("OpenSky OAuth2 토큰 발급 완료 (만료: {}초)", expiresIn);
|
||||
return accessToken;
|
||||
} catch (Exception e) {
|
||||
log.warn("OpenSky OAuth2 토큰 발급 실패: {}, 익명 모드로 폴백", e.getMessage());
|
||||
accessToken = null;
|
||||
tokenExpiresAt = Instant.EPOCH;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAll(List<AircraftDto> aircraft, String source, String region) {
|
||||
try {
|
||||
Instant now = Instant.now();
|
||||
|
||||
@ -118,8 +118,7 @@ public class OsintCollector {
|
||||
if (articleUrl == null || title == null || title.isBlank()) continue;
|
||||
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue;
|
||||
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
|
||||
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
|
||||
if (osintFeedRepository.existsByTitle(title)) continue;
|
||||
|
||||
String seendate = article.path("seendate").asText(null);
|
||||
Instant publishedAt = parseGdeltDate(seendate);
|
||||
@ -140,8 +139,12 @@ public class OsintCollector {
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
try {
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
} catch (Exception ex) {
|
||||
log.debug("GDELT 중복 스킵: {}", title);
|
||||
}
|
||||
}
|
||||
log.debug("GDELT {} 저장: {}건", region, saved);
|
||||
return saved;
|
||||
@ -184,8 +187,7 @@ public class OsintCollector {
|
||||
|
||||
if (link == null || title == null || title.isBlank()) continue;
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue;
|
||||
if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter(
|
||||
region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue;
|
||||
if (osintFeedRepository.existsByTitle(title)) continue;
|
||||
|
||||
Instant publishedAt = parseRssDate(pubDate);
|
||||
|
||||
@ -201,8 +203,12 @@ public class OsintCollector {
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
try {
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
} catch (Exception ex) {
|
||||
log.debug("Google News 중복 스킵: {}", title);
|
||||
}
|
||||
}
|
||||
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
||||
return saved;
|
||||
@ -265,6 +271,6 @@ public class OsintCollector {
|
||||
}
|
||||
|
||||
private String encodeQuery(String query) {
|
||||
return query.replace(" ", "+");
|
||||
return java.net.URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,9 @@ public class AppProperties {
|
||||
public static class Collector {
|
||||
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
|
||||
private String openSkyBaseUrl = "https://opensky-network.org/api";
|
||||
private String openSkyAuthUrl = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
||||
private String openSkyClientId;
|
||||
private String openSkyClientSecret;
|
||||
private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc";
|
||||
private String googleNewsBaseUrl = "https://news.google.com/rss/search";
|
||||
private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php";
|
||||
|
||||
@ -9,7 +9,7 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
|
||||
boolean existsBySourceAndSourceUrl(String source, String sourceUrl);
|
||||
|
||||
boolean existsByRegionAndTitleAndCollectedAtAfter(String region, String title, Instant since);
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
}
|
||||
|
||||
@ -11,3 +11,6 @@ app:
|
||||
client-id: YOUR_GOOGLE_CLIENT_ID
|
||||
auth:
|
||||
allowed-domain: gcsc.co.kr
|
||||
collector:
|
||||
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
|
||||
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET
|
||||
|
||||
@ -11,5 +11,8 @@ app:
|
||||
client-id: ${GOOGLE_CLIENT_ID}
|
||||
auth:
|
||||
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||
collector:
|
||||
open-sky-client-id: ${OPENSKY_CLIENT_ID:}
|
||||
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
|
||||
cors:
|
||||
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev
|
||||
|
||||
@ -21,6 +21,14 @@
|
||||
- 센서차트 기본 숨김
|
||||
- CCTV 레이어 리팩토링
|
||||
|
||||
## [2026-03-19.2]
|
||||
|
||||
### 추가
|
||||
- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)
|
||||
|
||||
### 변경
|
||||
- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304)
|
||||
|
||||
## [2026-03-19]
|
||||
|
||||
### 변경
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user