fix(deploy): 배포 자동화 + 수집기 모니터링 + 이란 signal-batch 연동 (#32)
All checks were successful
Deploy KCG / deploy (push) Successful in 1m10s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m10s
Co-authored-by: htlee <htlee@gcsc.co.kr> Co-committed-by: htlee <htlee@gcsc.co.kr>
This commit is contained in:
부모
ed9a2e3233
커밋
fe1de4bf51
@ -52,6 +52,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
DEPLOY_DIR=/deploy/kcg-backend
|
DEPLOY_DIR=/deploy/kcg-backend
|
||||||
mkdir -p $DEPLOY_DIR/backup
|
mkdir -p $DEPLOY_DIR/backup
|
||||||
@ -66,6 +67,18 @@ jobs:
|
|||||||
: > $DEPLOY_DIR/.env
|
: > $DEPLOY_DIR/.env
|
||||||
[ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env
|
[ -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 "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env
|
||||||
|
[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env
|
||||||
|
|
||||||
|
# JAR 내부에 application-prod.yml이 있으면 외부 파일 제거
|
||||||
|
if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then
|
||||||
|
rm -f $DEPLOY_DIR/application-prod.yml
|
||||||
|
echo "JAR 내부 application-prod.yml 감지 → 외부 파일 제거"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# systemd 서비스 파일 배포 (watcher가 반영)
|
||||||
|
cp deploy/kcg-backend.service $DEPLOY_DIR/kcg-backend.service
|
||||||
|
cp deploy/kcg-backend-watcher.service $DEPLOY_DIR/kcg-backend-watcher.service
|
||||||
|
cp deploy/kcg-backend-watcher.path $DEPLOY_DIR/kcg-backend-watcher.path
|
||||||
|
|
||||||
# JAR 교체 + 재시작 트리거
|
# JAR 교체 + 재시작 트리거
|
||||||
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar
|
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -77,7 +77,6 @@ frontend/node_modules/
|
|||||||
backend/target/
|
backend/target/
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/src/main/resources/application-local.yml
|
backend/src/main/resources/application-local.yml
|
||||||
backend/src/main/resources/application-prod.yml
|
|
||||||
|
|
||||||
# === Prediction ===
|
# === Prediction ===
|
||||||
prediction/__pycache__/
|
prediction/__pycache__/
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
package gc.mda.kcg.collector;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CollectorStatusController {
|
||||||
|
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
|
@GetMapping("/collector-status")
|
||||||
|
public Map<String, Object> getStatus() {
|
||||||
|
List<CollectorStatusTracker.CollectorStatus> statuses = tracker.getAll();
|
||||||
|
return Map.of(
|
||||||
|
"collectors", statuses.stream().map(s -> Map.of(
|
||||||
|
"name", s.getName(),
|
||||||
|
"region", s.getRegion() != null ? s.getRegion() : "",
|
||||||
|
"lastSuccess", s.getLastSuccess() != null ? s.getLastSuccess().toString() : "",
|
||||||
|
"lastFailure", s.getLastFailure() != null ? s.getLastFailure().toString() : "",
|
||||||
|
"lastCount", s.getLastCount(),
|
||||||
|
"lastError", s.getLastError() != null ? s.getLastError() : "",
|
||||||
|
"totalSuccess", s.getTotalSuccess(),
|
||||||
|
"totalFailure", s.getTotalFailure(),
|
||||||
|
"totalItems", s.getTotalItems()
|
||||||
|
)).toList(),
|
||||||
|
"serverTime", java.time.Instant.now().toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package gc.mda.kcg.collector;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CollectorStatusTracker {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class CollectorStatus {
|
||||||
|
private final String name;
|
||||||
|
private volatile String region;
|
||||||
|
private volatile Instant lastSuccess;
|
||||||
|
private volatile Instant lastFailure;
|
||||||
|
private volatile int lastCount;
|
||||||
|
private volatile String lastError;
|
||||||
|
private volatile long totalSuccess;
|
||||||
|
private volatile long totalFailure;
|
||||||
|
private volatile long totalItems;
|
||||||
|
|
||||||
|
public CollectorStatus(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordSuccess(String region, int count) {
|
||||||
|
this.region = region;
|
||||||
|
this.lastSuccess = Instant.now();
|
||||||
|
this.lastCount = count;
|
||||||
|
this.lastError = null;
|
||||||
|
this.totalSuccess++;
|
||||||
|
this.totalItems += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordFailure(String region, String error) {
|
||||||
|
this.region = region;
|
||||||
|
this.lastFailure = Instant.now();
|
||||||
|
this.lastError = error;
|
||||||
|
this.totalFailure++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, CollectorStatus> statuses = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public void recordSuccess(String name, String region, int count) {
|
||||||
|
statuses.computeIfAbsent(name, CollectorStatus::new).recordSuccess(region, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFailure(String name, String region, String error) {
|
||||||
|
statuses.computeIfAbsent(name, CollectorStatus::new).recordFailure(region, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CollectorStatus> getAll() {
|
||||||
|
return statuses.values().stream()
|
||||||
|
.sorted(Comparator.comparing(CollectorStatus::getName))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package gc.mda.kcg.collector.aircraft;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
||||||
@ -35,6 +36,7 @@ public class AirplanesLiveCollector {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AircraftCacheStore cacheStore;
|
private final AircraftCacheStore cacheStore;
|
||||||
private final AircraftPositionRepository positionRepository;
|
private final AircraftPositionRepository positionRepository;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
||||||
|
|
||||||
@ -131,7 +133,9 @@ public class AirplanesLiveCollector {
|
|||||||
cacheStore.mergeAndUpdate("iran");
|
cacheStore.mergeAndUpdate("iran");
|
||||||
|
|
||||||
// DB 적재
|
// DB 적재
|
||||||
persistAll(mergeForPersistence(iranRegionBuffers, mil), "airplaneslive", "iran");
|
List<AircraftDto> iranMerged = mergeForPersistence(iranRegionBuffers, mil);
|
||||||
|
persistAll(iranMerged, "airplaneslive", "iran");
|
||||||
|
tracker.recordSuccess("airplaneslive-iran", "iran", cacheStore.get("iran").size());
|
||||||
log.debug("Airplanes.live 이란 수집 완료 — 캐시: {}", cacheStore.get("iran").size());
|
log.debug("Airplanes.live 이란 수집 완료 — 캐시: {}", cacheStore.get("iran").size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,7 +160,9 @@ public class AirplanesLiveCollector {
|
|||||||
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_MIL, mil);
|
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_MIL, mil);
|
||||||
cacheStore.mergeAndUpdate("korea");
|
cacheStore.mergeAndUpdate("korea");
|
||||||
|
|
||||||
persistAll(mergeForPersistence(koreaRegionBuffers, mil), "airplaneslive", "korea");
|
List<AircraftDto> koreaMerged = mergeForPersistence(koreaRegionBuffers, mil);
|
||||||
|
persistAll(koreaMerged, "airplaneslive", "korea");
|
||||||
|
tracker.recordSuccess("airplaneslive-korea", "korea", cacheStore.get("korea").size());
|
||||||
log.debug("Airplanes.live 한국 수집 완료 — 캐시: {}", cacheStore.get("korea").size());
|
log.debug("Airplanes.live 한국 수집 완료 — 캐시: {}", cacheStore.get("korea").size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package gc.mda.kcg.collector.aircraft;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
import gc.mda.kcg.domain.aircraft.AircraftPosition;
|
||||||
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
|
||||||
@ -34,6 +35,7 @@ public class OpenSkyCollector {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AircraftCacheStore cacheStore;
|
private final AircraftCacheStore cacheStore;
|
||||||
private final AircraftPositionRepository positionRepository;
|
private final AircraftPositionRepository positionRepository;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
|
||||||
|
|
||||||
@ -44,6 +46,9 @@ public class OpenSkyCollector {
|
|||||||
cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
|
cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
|
||||||
cacheStore.mergeAndUpdate("iran");
|
cacheStore.mergeAndUpdate("iran");
|
||||||
persistAll(aircraft, "opensky", "iran");
|
persistAll(aircraft, "opensky", "iran");
|
||||||
|
tracker.recordSuccess("opensky-iran", "iran", aircraft.size());
|
||||||
|
} else {
|
||||||
|
tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429");
|
||||||
}
|
}
|
||||||
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
|
log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
|
||||||
}
|
}
|
||||||
@ -55,6 +60,9 @@ public class OpenSkyCollector {
|
|||||||
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
|
cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
|
||||||
cacheStore.mergeAndUpdate("korea");
|
cacheStore.mergeAndUpdate("korea");
|
||||||
persistAll(aircraft, "opensky", "korea");
|
persistAll(aircraft, "opensky", "korea");
|
||||||
|
tracker.recordSuccess("opensky-korea", "korea", aircraft.size());
|
||||||
|
} else {
|
||||||
|
tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429");
|
||||||
}
|
}
|
||||||
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
|
log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package gc.mda.kcg.collector.osint;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
import gc.mda.kcg.config.AppProperties;
|
import gc.mda.kcg.config.AppProperties;
|
||||||
import gc.mda.kcg.config.CacheConfig;
|
import gc.mda.kcg.config.CacheConfig;
|
||||||
import gc.mda.kcg.domain.osint.OsintDto;
|
import gc.mda.kcg.domain.osint.OsintDto;
|
||||||
@ -51,6 +52,9 @@ public class OsintCollector {
|
|||||||
private final OsintFeedRepository osintFeedRepository;
|
private final OsintFeedRepository osintFeedRepository;
|
||||||
private final AppProperties appProperties;
|
private final AppProperties appProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
|
private boolean nextIsIran = true;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
@ -62,25 +66,38 @@ public class OsintCollector {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(initialDelay = 30_000, fixedDelay = 120_000)
|
@Scheduled(initialDelay = 30_000, fixedDelay = 10_000)
|
||||||
public void collectIran() {
|
public void collectOsint() {
|
||||||
log.debug("OSINT 이란 수집 시작");
|
String region = nextIsIran ? "iran" : "korea";
|
||||||
collectGdelt("iran", IRAN_KEYWORDS);
|
String keywords = nextIsIran ? IRAN_KEYWORDS : KOREA_KEYWORDS;
|
||||||
collectGoogleNews("iran", "Iran Hormuz military", "en");
|
nextIsIran = !nextIsIran;
|
||||||
refreshCache("iran");
|
|
||||||
log.debug("OSINT 이란 수집 완료");
|
log.debug("OSINT {} 수집 시작", region);
|
||||||
|
try {
|
||||||
|
int gdeltCount = collectGdelt(region, keywords);
|
||||||
|
|
||||||
|
// Google News는 GDELT 호출 후 5초 대기 (rate limit 대응)
|
||||||
|
try {
|
||||||
|
Thread.sleep(5_000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(initialDelay = 45_000, fixedDelay = 120_000)
|
String newsQuery = "iran".equals(region) ? "Iran Hormuz military" : KOREA_KEYWORDS;
|
||||||
public void collectKorea() {
|
String newsLang = "iran".equals(region) ? "en" : "ko";
|
||||||
log.debug("OSINT 한국 수집 시작");
|
int newsCount = collectGoogleNews(region, newsQuery, newsLang);
|
||||||
collectGdelt("korea", KOREA_KEYWORDS);
|
refreshCache(region);
|
||||||
collectGoogleNews("korea", KOREA_KEYWORDS, "ko");
|
|
||||||
refreshCache("korea");
|
tracker.recordSuccess("osint-" + region, region, gdeltCount + newsCount);
|
||||||
log.debug("OSINT 한국 수집 완료");
|
log.debug("OSINT {} 수집 완료", region);
|
||||||
|
} catch (Exception e) {
|
||||||
|
tracker.recordFailure("osint-" + region, region, e.getMessage());
|
||||||
|
log.warn("OSINT {} 수집 실패: {}", region, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void collectGdelt(String region, String keywords) {
|
private int collectGdelt(String region, String keywords) {
|
||||||
try {
|
try {
|
||||||
String url = String.format(
|
String url = String.format(
|
||||||
"%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h",
|
"%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h",
|
||||||
@ -88,11 +105,11 @@ public class OsintCollector {
|
|||||||
encodeQuery(keywords)
|
encodeQuery(keywords)
|
||||||
);
|
);
|
||||||
String body = restTemplate.getForObject(url, String.class);
|
String body = restTemplate.getForObject(url, String.class);
|
||||||
if (body == null || body.isBlank()) return;
|
if (body == null || body.isBlank()) return 0;
|
||||||
|
|
||||||
JsonNode root = objectMapper.readTree(body);
|
JsonNode root = objectMapper.readTree(body);
|
||||||
JsonNode articles = root.path("articles");
|
JsonNode articles = root.path("articles");
|
||||||
if (!articles.isArray()) return;
|
if (!articles.isArray()) return 0;
|
||||||
|
|
||||||
int saved = 0;
|
int saved = 0;
|
||||||
for (JsonNode article : articles) {
|
for (JsonNode article : articles) {
|
||||||
@ -125,12 +142,14 @@ public class OsintCollector {
|
|||||||
saved++;
|
saved++;
|
||||||
}
|
}
|
||||||
log.debug("GDELT {} 저장: {}건", region, saved);
|
log.debug("GDELT {} 저장: {}건", region, saved);
|
||||||
|
return saved;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("GDELT {} 수집 실패: {}", region, e.getMessage());
|
log.warn("GDELT {} 수집 실패: {}", region, e.getMessage());
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void collectGoogleNews(String region, String query, String lang) {
|
private int collectGoogleNews(String region, String query, String lang) {
|
||||||
try {
|
try {
|
||||||
boolean isKorean = "ko".equals(lang);
|
boolean isKorean = "ko".equals(lang);
|
||||||
String hl = isKorean ? "ko" : "en";
|
String hl = isKorean ? "ko" : "en";
|
||||||
@ -146,7 +165,7 @@ public class OsintCollector {
|
|||||||
);
|
);
|
||||||
|
|
||||||
String body = restTemplate.getForObject(url, String.class);
|
String body = restTemplate.getForObject(url, String.class);
|
||||||
if (body == null || body.isBlank()) return;
|
if (body == null || body.isBlank()) return 0;
|
||||||
|
|
||||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
@ -182,8 +201,10 @@ public class OsintCollector {
|
|||||||
saved++;
|
saved++;
|
||||||
}
|
}
|
||||||
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
||||||
|
return saved;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage());
|
log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage());
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package gc.mda.kcg.collector.satellite;
|
package gc.mda.kcg.collector.satellite;
|
||||||
|
|
||||||
|
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||||
import gc.mda.kcg.config.AppProperties;
|
import gc.mda.kcg.config.AppProperties;
|
||||||
import gc.mda.kcg.config.CacheConfig;
|
import gc.mda.kcg.config.CacheConfig;
|
||||||
import gc.mda.kcg.domain.satellite.SatelliteDto;
|
import gc.mda.kcg.domain.satellite.SatelliteDto;
|
||||||
@ -44,6 +45,7 @@ public class SatelliteCollector {
|
|||||||
private final CacheManager cacheManager;
|
private final CacheManager cacheManager;
|
||||||
private final SatelliteTleRepository repository;
|
private final SatelliteTleRepository repository;
|
||||||
private final AppProperties appProperties;
|
private final AppProperties appProperties;
|
||||||
|
private final CollectorStatusTracker tracker;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
@ -88,10 +90,14 @@ public class SatelliteCollector {
|
|||||||
if (!allEntities.isEmpty()) {
|
if (!allEntities.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
repository.saveAll(allEntities);
|
repository.saveAll(allEntities);
|
||||||
|
tracker.recordSuccess("satellite", "global", allEntities.size());
|
||||||
log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size());
|
log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
tracker.recordFailure("satellite", "global", e.getMessage());
|
||||||
log.error("위성 TLE DB 적재 실패: {}", e.getMessage());
|
log.error("위성 TLE DB 적재 실패: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tracker.recordFailure("satellite", "global", "TLE 데이터 없음");
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCacheFromDb();
|
loadCacheFromDb();
|
||||||
|
|||||||
15
backend/src/main/resources/application-prod.yml
Normal file
15
backend/src/main/resources/application-prod.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${DB_URL:jdbc:postgresql://211.208.115.83:5432/kcgdb?currentSchema=kcg,public}
|
||||||
|
username: ${DB_USERNAME:kcg_app}
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
app:
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
|
||||||
|
google:
|
||||||
|
client-id: ${GOOGLE_CLIENT_ID}
|
||||||
|
auth:
|
||||||
|
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||||
|
cors:
|
||||||
|
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev
|
||||||
@ -3,7 +3,7 @@ spring:
|
|||||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: none
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
default_schema: kcg
|
default_schema: kcg
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
Description=Watch for KCG Backend deploy trigger
|
Description=Watch for KCG Backend deploy trigger
|
||||||
|
|
||||||
[Path]
|
[Path]
|
||||||
PathModified=/deploy/kcg-backend/.deploy-trigger
|
PathModified=/devdata/services/kcg/backend/.deploy-trigger
|
||||||
Unit=kcg-backend-watcher.service
|
Unit=kcg-backend-watcher.service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@ -3,4 +3,15 @@ Description=Restart KCG Backend on deploy
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/bin/systemctl restart kcg-backend
|
ExecStart=/bin/bash -c '\
|
||||||
|
DEPLOY_DIR=/devdata/services/kcg/backend; \
|
||||||
|
SYSTEMD_DIR=/etc/systemd/system; \
|
||||||
|
CHANGED=0; \
|
||||||
|
for f in kcg-backend.service kcg-backend-watcher.service kcg-backend-watcher.path; do \
|
||||||
|
if [ -f "$DEPLOY_DIR/$f" ] && ! diff -q "$DEPLOY_DIR/$f" "$SYSTEMD_DIR/$f" >/dev/null 2>&1; then \
|
||||||
|
cp "$DEPLOY_DIR/$f" "$SYSTEMD_DIR/$f"; \
|
||||||
|
CHANGED=1; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
[ "$CHANGED" = "1" ] && systemctl daemon-reload; \
|
||||||
|
systemctl restart kcg-backend'
|
||||||
|
|||||||
@ -11,7 +11,6 @@ EnvironmentFile=-/devdata/services/kcg/backend/.env
|
|||||||
ExecStart=/usr/lib/jvm/java-21-openjdk-21.0.10.0.7-1.el9.x86_64/bin/java \
|
ExecStart=/usr/lib/jvm/java-21-openjdk-21.0.10.0.7-1.el9.x86_64/bin/java \
|
||||||
-Xms2g -Xmx4g \
|
-Xms2g -Xmx4g \
|
||||||
-Dspring.profiles.active=prod \
|
-Dspring.profiles.active=prod \
|
||||||
-Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \
|
|
||||||
-jar /devdata/services/kcg/backend/kcg.jar
|
-jar /devdata/services/kcg/backend/kcg.jar
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { useTheme } from './hooks/useTheme';
|
|||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LoginPage from './components/auth/LoginPage';
|
import LoginPage from './components/auth/LoginPage';
|
||||||
|
import CollectorMonitor from './components/CollectorMonitor';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
// MarineTraffic-style ship classification
|
// MarineTraffic-style ship classification
|
||||||
@ -193,6 +194,8 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
ferryWatch: false,
|
ferryWatch: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||||
|
|
||||||
const replay = useReplay();
|
const replay = useReplay();
|
||||||
const monitor = useMonitor();
|
const monitor = useMonitor();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@ -233,7 +236,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
// Fetch base ship data — never overwrite with empty to prevent flicker
|
// Fetch Iran ship data (signal-batch + sample military, 5-min cycle)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@ -246,7 +249,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const interval = setInterval(load, 15_000);
|
const interval = setInterval(load, 300_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
@ -904,6 +907,14 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
|
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-toggles">
|
<div className="header-toggles">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-toggle-btn"
|
||||||
|
onClick={() => setShowCollectorMonitor(v => !v)}
|
||||||
|
title="수집기 모니터링"
|
||||||
|
>
|
||||||
|
MON
|
||||||
|
</button>
|
||||||
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||||
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||||
</button>
|
</button>
|
||||||
@ -1157,6 +1168,10 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCollectorMonitor && (
|
||||||
|
<CollectorMonitor onClose={() => setShowCollectorMonitor(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
181
frontend/src/components/CollectorMonitor.tsx
Normal file
181
frontend/src/components/CollectorMonitor.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { fetchCollectorStatus } from '../services/collectorStatus';
|
||||||
|
import type { CollectorInfo } from '../services/collectorStatus';
|
||||||
|
|
||||||
|
interface CollectorMonitorProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(isoString: string): string {
|
||||||
|
if (!isoString) return '-';
|
||||||
|
const diff = Date.now() - new Date(isoString).getTime();
|
||||||
|
if (diff < 0) return '방금';
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec}초 전`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min}분 전`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr}시간 전`;
|
||||||
|
return `${Math.floor(hr / 24)}일 전`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(info: CollectorInfo): string {
|
||||||
|
if (!info.lastSuccess) return '#ef4444';
|
||||||
|
const elapsed = Date.now() - new Date(info.lastSuccess).getTime();
|
||||||
|
if (elapsed < 5 * 60_000) return '#22c55e';
|
||||||
|
if (elapsed < 30 * 60_000) return '#eab308';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectorMonitor = ({ onClose }: CollectorMonitorProps) => {
|
||||||
|
const [collectors, setCollectors] = useState<CollectorInfo[]>([]);
|
||||||
|
const [serverTime, setServerTime] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchCollectorStatus();
|
||||||
|
setCollectors(data.collectors);
|
||||||
|
setServerTime(data.serverTime);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '연결 실패');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const interval = setInterval(refresh, 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--kcg-panel-bg, rgba(15, 23, 42, 0.95))',
|
||||||
|
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '20px',
|
||||||
|
minWidth: '600px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
color: 'var(--kcg-text, #e2e8f0)',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.5)',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>
|
||||||
|
수집기 모니터링
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{serverTime && (
|
||||||
|
<span style={{ fontSize: '11px', opacity: 0.6 }}>
|
||||||
|
서버: {new Date(serverTime).toLocaleTimeString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))',
|
||||||
|
color: 'var(--kcg-text, #e2e8f0)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--kcg-text, #e2e8f0)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '0 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '8px 12px', background: 'rgba(239, 68, 68, 0.2)', borderRadius: '6px', marginBottom: '12px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.3))' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>상태</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>수집기</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>최근 건수</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>성공/실패</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>총 수집</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>마지막 성공</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', fontSize: '11px', opacity: 0.7 }}>에러</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{collectors.map((c) => (
|
||||||
|
<tr key={c.name} style={{ borderBottom: '1px solid var(--kcg-border, rgba(100, 116, 139, 0.15))' }}>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getStatusColor(c),
|
||||||
|
}} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', fontWeight: 500 }}>{c.name}</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'right' }}>{c.lastCount}</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||||
|
<span style={{ color: '#22c55e' }}>{c.totalSuccess}</span>
|
||||||
|
{' / '}
|
||||||
|
<span style={{ color: c.totalFailure > 0 ? '#ef4444' : 'inherit' }}>{c.totalFailure}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'right' }}>{c.totalItems.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '8px', opacity: 0.8 }}>{formatRelativeTime(c.lastSuccess)}</td>
|
||||||
|
<td style={{
|
||||||
|
padding: '8px',
|
||||||
|
maxWidth: '200px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: c.lastError ? '#ef4444' : 'inherit',
|
||||||
|
opacity: c.lastError ? 1 : 0.4,
|
||||||
|
fontSize: '11px',
|
||||||
|
}} title={c.lastError || ''}>
|
||||||
|
{c.lastError || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{collectors.length === 0 && !error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} style={{ padding: '20px', textAlign: 'center', opacity: 0.5 }}>
|
||||||
|
수집기 데이터 로딩 중...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectorMonitor;
|
||||||
26
frontend/src/services/collectorStatus.ts
Normal file
26
frontend/src/services/collectorStatus.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const API_BASE = '/api/kcg';
|
||||||
|
|
||||||
|
export interface CollectorInfo {
|
||||||
|
name: string;
|
||||||
|
region: string;
|
||||||
|
lastSuccess: string;
|
||||||
|
lastFailure: string;
|
||||||
|
lastCount: number;
|
||||||
|
lastError: string;
|
||||||
|
totalSuccess: number;
|
||||||
|
totalFailure: number;
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectorStatusResponse {
|
||||||
|
collectors: CollectorInfo[];
|
||||||
|
serverTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCollectorStatus(): Promise<CollectorStatusResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/collector-status`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`collector-status ${res.status}`);
|
||||||
|
return res.json() as Promise<CollectorStatusResponse>;
|
||||||
|
}
|
||||||
@ -26,6 +26,14 @@ const KR_BOUNDS = {
|
|||||||
maxLng: 145,
|
maxLng: 145,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Iran / Strait of Hormuz bounding box for signal-batch
|
||||||
|
const IRAN_BOUNDS = {
|
||||||
|
minLat: 20,
|
||||||
|
maxLat: 30,
|
||||||
|
minLng: 48,
|
||||||
|
maxLng: 62,
|
||||||
|
};
|
||||||
|
|
||||||
// S&P API sinceSeconds — currently disabled, will be removed with S&P code
|
// S&P API sinceSeconds — currently disabled, will be removed with S&P code
|
||||||
// const SINCE_SECONDS = 3600;
|
// const SINCE_SECONDS = 3600;
|
||||||
|
|
||||||
@ -321,37 +329,23 @@ export async function fetchTankers(): Promise<Ship[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Main fetch function ═══
|
// ═══ Main fetch function ═══
|
||||||
// Tries S&P Global AIS API first, merges with sample military ships
|
// Tries signal-batch Iran region first, merges with sample military ships
|
||||||
export async function fetchShips(): Promise<Ship[]> {
|
export async function fetchShips(): Promise<Ship[]> {
|
||||||
// Always include sample military/scenario ships (warships have AIS off)
|
|
||||||
const sampleShips = getSampleShips();
|
const sampleShips = getSampleShips();
|
||||||
|
const real = await fetchShipsFromSignalBatchIran();
|
||||||
|
|
||||||
// Try area-based query for all commercial vessels in Middle East
|
if (real.length > 0) {
|
||||||
const areaShips = await fetchShipsFromSPG();
|
console.log(`signal-batch: ${real.length} vessels in Iran/Hormuz region`);
|
||||||
|
// 샘플 군함(military)은 유지 (AIS에 안 잡힘)
|
||||||
if (areaShips.length > 0) {
|
|
||||||
console.log(`S&P AIS API: ${areaShips.length} real vessels in Middle East area`);
|
|
||||||
|
|
||||||
// Keep sample military ships that aren't in AIS data
|
|
||||||
const sampleMilitary = sampleShips.filter(s =>
|
const sampleMilitary = sampleShips.filter(s =>
|
||||||
s.category !== 'civilian' && s.category !== 'cargo' && s.category !== 'tanker' && s.category !== 'unknown'
|
s.category === 'warship' || s.category === 'carrier' || s.category === 'destroyer' || s.category === 'patrol' || s.category === 'submarine'
|
||||||
);
|
);
|
||||||
// Keep sample Korean commercial ships (scenario-specific stranded vessels)
|
const sampleMMSIs = new Set(sampleMilitary.map(s => s.mmsi));
|
||||||
const sampleKorean = sampleShips.filter(s => s.flag === 'KR' && s.category !== 'destroyer');
|
return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sampleMilitary];
|
||||||
|
|
||||||
const sampleMMSIs = new Set([...sampleMilitary, ...sampleKorean].map(s => s.mmsi));
|
|
||||||
const sampleToKeep = [...sampleMilitary, ...sampleKorean];
|
|
||||||
|
|
||||||
// Merge: real AIS ships + sample military/Korean ships (avoid duplicates)
|
|
||||||
const merged = [
|
|
||||||
...areaShips.filter(s => !sampleMMSIs.has(s.mmsi)),
|
|
||||||
...sampleToKeep,
|
|
||||||
];
|
|
||||||
return merged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to sample data only
|
// signal-batch 실패 시 기존 샘플 데이터 사용
|
||||||
console.warn('S&P AIS API returned no data, using sample data');
|
console.warn('signal-batch Iran returned no data, using sample data');
|
||||||
return sampleShips;
|
return sampleShips;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -721,6 +715,36 @@ function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchShipsFromSignalBatchIran(): Promise<Ship[]> {
|
||||||
|
try {
|
||||||
|
const body: RecentPositionDetailRequest = {
|
||||||
|
minutes: 10,
|
||||||
|
coordinates: [
|
||||||
|
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat],
|
||||||
|
[IRAN_BOUNDS.maxLng, IRAN_BOUNDS.minLat],
|
||||||
|
[IRAN_BOUNDS.maxLng, IRAN_BOUNDS.maxLat],
|
||||||
|
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.maxLat],
|
||||||
|
[IRAN_BOUNDS.minLng, IRAN_BOUNDS.minLat],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v1/vessels/recent-positions-detail`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`signal-batch API ${res.status}`);
|
||||||
|
const data: RecentPositionDetailDto[] = await res.json();
|
||||||
|
return data
|
||||||
|
.filter(d => d.lat != null && d.lon != null && d.lat !== 0 && d.lon !== 0)
|
||||||
|
.map(parseSignalBatchVessel);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('signal-batch API (Iran region) failed:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
|
async function fetchShipsFromSignalBatch(): Promise<Ship[]> {
|
||||||
try {
|
try {
|
||||||
const body: RecentPositionDetailRequest = {
|
const body: RecentPositionDetailRequest = {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user