diff --git a/sql/chnprmship-cache-diag.sql b/sql/chnprmship-cache-diag.sql new file mode 100644 index 0000000..716f1cd --- /dev/null +++ b/sql/chnprmship-cache-diag.sql @@ -0,0 +1,149 @@ +-- ============================================================ +-- ChnPrmShip 캐시 검증 진단 쿼리 +-- 대상: t_std_snp_data.ais_target (일별 파티션) +-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증 +-- ============================================================ + +-- ============================================================ +-- 0. 대상 MMSI 임시 테이블 생성 +-- ============================================================ +CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY); + +-- psql에서 실행: +-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt' + + +-- ============================================================ +-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부 +-- ============================================================ +SELECT + (SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi, + COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d, + (SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d, + ROUND(COUNT(DISTINCT a.mmsi) * 100.0 + / NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'; + + +-- ============================================================ +-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치 +-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작) +-- ============================================================ +SELECT COUNT(*) AS cached_count, + MIN(message_timestamp) AS oldest_cached, + MAX(message_timestamp) AS newest_cached, + NOW() - MAX(message_timestamp) AS newest_age +FROM ( + SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp + FROM t_std_snp_data.ais_target a + JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi + WHERE a.message_timestamp >= NOW() - INTERVAL '2 days' + ORDER BY a.mmsi, a.message_timestamp DESC +) latest; + + +-- ============================================================ +-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건) +-- ============================================================ +SELECT DISTINCT ON (a.mmsi) + a.mmsi, + a.message_timestamp, + a.name, + a.vessel_type, + a.lat, + a.lon, + a.sog, + a.cog, + a.heading, + NOW() - a.message_timestamp AS data_age +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '2 days' +ORDER BY a.mmsi, a.message_timestamp DESC +LIMIT 30; + + +-- ============================================================ +-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박) +-- ============================================================ +SELECT t.mmsi AS missing_mmsi +FROM tmp_chn_mmsi t +LEFT JOIN ( + SELECT DISTINCT mmsi + FROM t_std_snp_data.ais_target + WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi) + AND message_timestamp >= NOW() - INTERVAL '2 days' +) a ON t.mmsi = a.mmsi +WHERE a.mmsi IS NULL +ORDER BY t.mmsi; + + +-- ============================================================ +-- 5. 시간대별 분포 (2일 기준 세부 확인) +-- ============================================================ +SELECT + '6시간 이내' AS time_range, + COUNT(DISTINCT mmsi) AS distinct_mmsi +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours' + +UNION ALL +SELECT '12시간 이내', COUNT(DISTINCT mmsi) +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours' + +UNION ALL +SELECT '1일 이내', COUNT(DISTINCT mmsi) +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '1 day' + +UNION ALL +SELECT '2일 이내', COUNT(DISTINCT mmsi) +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +WHERE a.message_timestamp >= NOW() - INTERVAL '2 days' + +UNION ALL +SELECT '전체(무제한)', COUNT(DISTINCT mmsi) +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi; + + +-- ============================================================ +-- 6. 파티션별 대상 데이터 분포 +-- ============================================================ +SELECT + tableoid::regclass AS partition_name, + COUNT(*) AS row_count, + COUNT(DISTINCT mmsi) AS distinct_mmsi, + MIN(message_timestamp) AS min_ts, + MAX(message_timestamp) AS max_ts +FROM t_std_snp_data.ais_target a +JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi +GROUP BY tableoid::regclass +ORDER BY max_ts DESC; + + +-- ============================================================ +-- 7. 전체 ais_target 파티션 현황 +-- ============================================================ +SELECT + c.relname AS partition_name, + pg_size_pretty(pg_relation_size(c.oid)) AS table_size, + s.n_live_tup AS estimated_rows +FROM pg_inherits i +JOIN pg_class c ON c.oid = i.inhrelid +JOIN pg_stat_user_tables s ON s.relid = c.oid +WHERE i.inhparent = 't_std_snp_data.ais_target'::regclass +ORDER BY c.relname DESC; + + +-- ============================================================ +-- 정리 +-- ============================================================ +DROP TABLE IF EXISTS tmp_chn_mmsi; diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java index afc1505..20233b0 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepository.java @@ -26,6 +26,14 @@ public interface AisTargetRepository { */ List findLatestByMmsiIn(List mmsiList); + /** + * 여러 MMSI의 최신 위치 조회 (시간 범위 필터) + * + * @param mmsiList 대상 MMSI 목록 + * @param since 이 시점 이후 데이터만 조회 + */ + List findLatestByMmsiInSince(List mmsiList, OffsetDateTime since); + /** * 시간 범위 내 특정 MMSI의 항적 조회 */ diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java index a32d2dc..5f9a570 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java @@ -130,7 +130,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { private final RowMapper rowMapper = (rs, rowNum) -> AisTargetEntity.builder() .mmsi(rs.getLong("mmsi")) .messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp"))) - .imo(rs.getObject("imo", Long.class)) + .imo(toLong(rs, "imo")) .name(rs.getString("name")) .callsign(rs.getString("callsign")) .vesselType(rs.getString("vessel_type")) @@ -140,45 +140,45 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { .heading(rs.getObject("heading", Double.class)) .sog(rs.getObject("sog", Double.class)) .cog(rs.getObject("cog", Double.class)) - .rot(rs.getObject("rot", Integer.class)) - .length(rs.getObject("length", Integer.class)) - .width(rs.getObject("width", Integer.class)) + .rot(toInt(rs, "rot")) + .length(toInt(rs, "length")) + .width(toInt(rs, "width")) .draught(rs.getObject("draught", Double.class)) - .lengthBow(rs.getObject("length_bow", Integer.class)) - .lengthStern(rs.getObject("length_stern", Integer.class)) - .widthPort(rs.getObject("width_port", Integer.class)) - .widthStarboard(rs.getObject("width_starboard", Integer.class)) + .lengthBow(toInt(rs, "length_bow")) + .lengthStern(toInt(rs, "length_stern")) + .widthPort(toInt(rs, "width_port")) + .widthStarboard(toInt(rs, "width_starboard")) .destination(rs.getString("destination")) .eta(toOffsetDateTime(rs.getTimestamp("eta"))) .status(rs.getString("status")) .ageMinutes(rs.getObject("age_minutes", Double.class)) - .positionAccuracy(rs.getObject("position_accuracy", Integer.class)) - .timestampUtc(rs.getObject("timestamp_utc", Integer.class)) - .repeatIndicator(rs.getObject("repeat_indicator", Integer.class)) - .raimFlag(rs.getObject("raim_flag", Integer.class)) - .radioStatus(rs.getObject("radio_status", Integer.class)) - .regional(rs.getObject("regional", Integer.class)) - .regional2(rs.getObject("regional2", Integer.class)) - .spare(rs.getObject("spare", Integer.class)) - .spare2(rs.getObject("spare2", Integer.class)) - .aisVersion(rs.getObject("ais_version", Integer.class)) - .positionFixType(rs.getObject("position_fix_type", Integer.class)) - .dte(rs.getObject("dte", Integer.class)) - .bandFlag(rs.getObject("band_flag", Integer.class)) + .positionAccuracy(toInt(rs, "position_accuracy")) + .timestampUtc(toInt(rs, "timestamp_utc")) + .repeatIndicator(toInt(rs, "repeat_indicator")) + .raimFlag(toInt(rs, "raim_flag")) + .radioStatus(toInt(rs, "radio_status")) + .regional(toInt(rs, "regional")) + .regional2(toInt(rs, "regional2")) + .spare(toInt(rs, "spare")) + .spare2(toInt(rs, "spare2")) + .aisVersion(toInt(rs, "ais_version")) + .positionFixType(toInt(rs, "position_fix_type")) + .dte(toInt(rs, "dte")) + .bandFlag(toInt(rs, "band_flag")) .receivedDate(toOffsetDateTime(rs.getTimestamp("received_date"))) .collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at"))) - .tonnesCargo(rs.getObject("tonnes_cargo", Integer.class)) - .inSTS(rs.getObject("in_sts", Integer.class)) + .tonnesCargo(toInt(rs, "tonnes_cargo")) + .inSTS(toInt(rs, "in_sts")) .onBerth(rs.getObject("on_berth", Boolean.class)) - .dwt(rs.getObject("dwt", Integer.class)) + .dwt(toInt(rs, "dwt")) .anomalous(rs.getString("anomalous")) - .destinationPortID(rs.getObject("destination_port_id", Integer.class)) + .destinationPortID(toInt(rs, "destination_port_id")) .destinationTidied(rs.getString("destination_tidied")) .destinationUNLOCODE(rs.getString("destination_unlocode")) .imoVerified(rs.getString("imo_verified")) .lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received"))) - .lpcCode(rs.getObject("lpc_code", Integer.class)) - .messageType(rs.getObject("message_type", Integer.class)) + .lpcCode(toInt(rs, "lpc_code")) + .messageType(toInt(rs, "message_type")) .source(rs.getString("source")) .stationId(rs.getString("station_id")) .zoneId(rs.getObject("zone_id", Double.class)) @@ -223,6 +223,24 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray); } + @Override + public List findLatestByMmsiInSince(List mmsiList, OffsetDateTime since) { + if (mmsiList == null || mmsiList.isEmpty()) { + return List.of(); + } + + String sql = """ + SELECT DISTINCT ON (mmsi) * + FROM %s + WHERE mmsi = ANY(?) + AND message_timestamp >= ? + ORDER BY mmsi, message_timestamp DESC + """.formatted(tableName); + + Long[] mmsiArray = mmsiList.toArray(new Long[0]); + return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since)); + } + @Override public List findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) { String sql = """ @@ -359,6 +377,23 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { // ==================== Helper Methods ==================== + /** + * int8(bigint) → Integer 안전 변환 + * PostgreSQL JDBC 드라이버는 int8 → Integer 자동 변환을 지원하지 않아 + * getObject("col", Integer.class) 사용 시 오류 발생. Number로 읽어서 변환. + */ + private Integer toInt(ResultSet rs, String column) throws SQLException { + Object val = rs.getObject(column); + if (val == null) return null; + return ((Number) val).intValue(); + } + + private Long toLong(ResultSet rs, String column) throws SQLException { + Object val = rs.getObject(column); + if (val == null) return null; + return ((Number) val).longValue(); + } + private Timestamp toTimestamp(OffsetDateTime odt) { return odt != null ? Timestamp.from(odt.toInstant()) : null; } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java index 3883008..0c4db30 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java @@ -3,6 +3,7 @@ package com.snp.batch.jobs.aistarget.batch.writer; import com.snp.batch.common.batch.writer.BaseWriter; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; +import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager; import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier; import com.snp.batch.jobs.aistarget.classifier.SignalKindCode; import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer; @@ -19,7 +20,8 @@ import java.util.List; * 1. ClassType 분류 (Core20 캐시 기반 A/B 분류) * 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드) * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함) - * 4. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만) + * 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터) + * 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만) * * 참고: * - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행 @@ -34,15 +36,18 @@ public class AisTargetDataWriter extends BaseWriter { private final AisClassTypeClassifier classTypeClassifier; @Nullable private final AisTargetKafkaProducer kafkaProducer; + private final ChnPrmShipCacheManager chnPrmShipCacheManager; public AisTargetDataWriter( AisTargetCacheManager cacheManager, AisClassTypeClassifier classTypeClassifier, - @Nullable AisTargetKafkaProducer kafkaProducer) { + @Nullable AisTargetKafkaProducer kafkaProducer, + ChnPrmShipCacheManager chnPrmShipCacheManager) { super("AisTarget"); this.cacheManager = cacheManager; this.classTypeClassifier = classTypeClassifier; this.kafkaProducer = kafkaProducer; + this.chnPrmShipCacheManager = chnPrmShipCacheManager; } @Override @@ -65,7 +70,10 @@ public class AisTargetDataWriter extends BaseWriter { log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", items.size(), cacheManager.size()); - // 4. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만) + // 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터) + chnPrmShipCacheManager.putIfTarget(items); + + // 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만) if (kafkaProducer == null) { log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵"); return; diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java new file mode 100644 index 0000000..6f2e373 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java @@ -0,0 +1,131 @@ +package com.snp.batch.jobs.aistarget.chnprmship; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 중국 허가선박 전용 캐시 + * + * - 대상 MMSI(~1,400척)만 별도 관리 + * - TTL: expireAfterWrite (마지막 put 이후 N일 경과 시 만료) + * - 순수 캐시 조회 전용 (DB fallback 없음) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChnPrmShipCacheManager { + + private final ChnPrmShipProperties properties; + private Cache cache; + + @PostConstruct + public void init() { + this.cache = Caffeine.newBuilder() + .maximumSize(properties.getMaxSize()) + .expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS) + .recordStats() + .build(); + + log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}", + properties.getTtlDays(), properties.getMaxSize()); + } + + /** + * 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장 + * + * @param items 전체 AIS Target 데이터 (배치 수집 결과) + * @return 저장된 건수 + */ + public int putIfTarget(List items) { + if (items == null || items.isEmpty()) { + return 0; + } + + int updated = 0; + for (AisTargetEntity item : items) { + if (!properties.isTarget(item.getMmsi())) { + continue; + } + + AisTargetEntity existing = cache.getIfPresent(item.getMmsi()); + if (existing == null || isNewer(item, existing)) { + cache.put(item.getMmsi(), item); + updated++; + } + } + + if (updated > 0) { + log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}", + items.size(), updated, cache.estimatedSize()); + } + return updated; + } + + /** + * 시간 범위 내 캐시 데이터 조회 + * + * @param minutes 조회 범위 (분) + * @return 시간 범위 내 데이터 목록 + */ + public List getByTimeRange(int minutes) { + OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes); + + return cache.asMap().values().stream() + .filter(entity -> entity.getMessageTimestamp() != null) + .filter(entity -> entity.getMessageTimestamp().isAfter(threshold)) + .collect(Collectors.toList()); + } + + /** + * 워밍업용 직접 저장 (시간 비교 없이 저장) + */ + public void putAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + for (AisTargetEntity entity : entities) { + if (entity != null && entity.getMmsi() != null) { + cache.put(entity.getMmsi(), entity); + } + } + } + + public long size() { + return cache.estimatedSize(); + } + + public Map getStats() { + var stats = cache.stats(); + return Map.of( + "estimatedSize", cache.estimatedSize(), + "maxSize", properties.getMaxSize(), + "ttlDays", properties.getTtlDays(), + "targetMmsiCount", properties.getMmsiSet().size(), + "hitCount", stats.hitCount(), + "missCount", stats.missCount(), + "hitRate", String.format("%.2f%%", stats.hitRate() * 100) + ); + } + + private boolean isNewer(AisTargetEntity candidate, AisTargetEntity existing) { + if (candidate.getMessageTimestamp() == null) { + return false; + } + if (existing.getMessageTimestamp() == null) { + return true; + } + return candidate.getMessageTimestamp().isAfter(existing.getMessageTimestamp()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java new file mode 100644 index 0000000..2155237 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheWarmer.java @@ -0,0 +1,79 @@ +package com.snp.batch.jobs.aistarget.chnprmship; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; +import com.snp.batch.jobs.aistarget.classifier.SignalKindCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +/** + * 기동 시 ChnPrmShip 캐시 워밍업 + * + * DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다. + * 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChnPrmShipCacheWarmer implements ApplicationRunner { + + private static final int DB_QUERY_CHUNK_SIZE = 500; + + private final ChnPrmShipProperties properties; + private final ChnPrmShipCacheManager cacheManager; + private final AisTargetRepository aisTargetRepository; + + @Override + public void run(ApplicationArguments args) { + if (!properties.isWarmupEnabled()) { + log.info("ChnPrmShip 캐시 워밍업 비활성화"); + return; + } + + if (properties.getMmsiSet().isEmpty()) { + log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다"); + return; + } + + OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC) + .minusDays(properties.getWarmupDays()); + + log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})", + properties.getMmsiSet().size(), properties.getWarmupDays(), since); + long startTime = System.currentTimeMillis(); + + List mmsiList = new ArrayList<>(properties.getMmsiSet()); + int totalLoaded = 0; + + for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) { + List chunk = mmsiList.subList(i, + Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size())); + + List fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since); + + // signalKindCode 치환 (DB 데이터는 치환이 안 되어 있을 수 있음) + fromDb.forEach(entity -> { + if (entity.getSignalKindCode() == null) { + SignalKindCode kindCode = SignalKindCode.resolve( + entity.getVesselType(), entity.getExtraInfo()); + entity.setSignalKindCode(kindCode.getCode()); + } + }); + + cacheManager.putAll(fromDb); + totalLoaded += fromDb.size(); + } + + long elapsed = System.currentTimeMillis() - startTime; + log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms", + properties.getMmsiSet().size(), totalLoaded, elapsed); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java new file mode 100644 index 0000000..b66b0ed --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipProperties.java @@ -0,0 +1,82 @@ +package com.snp.batch.jobs.aistarget.chnprmship; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 중국 허가선박(ChnPrmShip) 설정 + * + * 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다. + */ +@Slf4j +@Getter +@Setter +@ConfigurationProperties(prefix = "app.batch.chnprmship") +public class ChnPrmShipProperties { + + /** + * MMSI 목록 리소스 경로 + */ + private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt"; + + /** + * 캐시 TTL (일) + * - 마지막 put() 이후 이 기간이 지나면 만료 + */ + private int ttlDays = 2; + + /** + * 최대 캐시 크기 + */ + private int maxSize = 2000; + + /** + * 기동 시 DB 워밍업 활성화 여부 + */ + private boolean warmupEnabled = true; + + /** + * DB 워밍업 조회 범위 (일) + */ + private int warmupDays = 2; + + /** + * 로딩된 대상 MMSI 집합 + */ + private Set mmsiSet = Collections.emptySet(); + + @PostConstruct + public void init() { + try { + Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + mmsiSet = reader.lines() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .map(Long::parseLong) + .collect(Collectors.toUnmodifiableSet()); + } + log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath); + } catch (Exception e) { + log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage()); + mmsiSet = Collections.emptySet(); + } + } + + public boolean isTarget(Long mmsi) { + return mmsi != null && mmsiSet.contains(mmsi); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java index 9389945..f9f0728 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/kafka/AisTargetKafkaProducer.java @@ -27,11 +27,8 @@ import java.util.concurrent.atomic.AtomicInteger; */ @Slf4j @Component -@ConditionalOnProperty( - name = "app.batch.ais-target.kafka.enabled", - havingValue = "true" -) @RequiredArgsConstructor +@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true") public class AisTargetKafkaProducer { private final KafkaTemplate kafkaTemplate; diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java index 7c98f09..05bf36a 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java @@ -38,6 +38,42 @@ public class AisTargetController { private final AisTargetService aisTargetService; + // ==================== 중국 허가선박 전용 ==================== + + @Operation( + summary = "중국 허가선박 위치 조회", + description = """ + 중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다. + + - 순수 캐시 조회 (DB fallback 없음) + - 캐시에 없으면 빈 배열 반환 + - 응답 구조는 /search와 동일 + """ + ) + @GetMapping("/chnprmship") + public ResponseEntity>> getChnPrmShip( + @Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880") + @RequestParam(defaultValue = "2880") Integer minutes) { + + log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes); + + List result = aisTargetService.findChnPrmShip(minutes); + return ResponseEntity.ok(ApiResponse.success( + "ChnPrmShip 조회 완료: " + result.size() + " 건", + result + )); + } + + @Operation( + summary = "중국 허가선박 캐시 통계", + description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다" + ) + @GetMapping("/chnprmship/stats") + public ResponseEntity>> getChnPrmShipStats() { + Map stats = aisTargetService.getChnPrmShipCacheStats(); + return ResponseEntity.ok(ApiResponse.success(stats)); + } + // ==================== 단건 조회 ==================== @Operation( diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java index a7e7fe2..045f20d 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java @@ -5,6 +5,7 @@ import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil; import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil; +import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager; import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest; import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto; import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; @@ -38,6 +39,7 @@ public class AisTargetService { private final AisTargetCacheManager cacheManager; private final SpatialFilterUtil spatialFilterUtil; private final AisTargetFilterUtil filterUtil; + private final ChnPrmShipCacheManager chnPrmShipCacheManager; private static final String SOURCE_CACHE = "cache"; private static final String SOURCE_DB = "db"; @@ -360,6 +362,36 @@ public class AisTargetService { .collect(Collectors.toList()); } + // ==================== 중국 허가선박 전용 조회 ==================== + + /** + * 중국 허가선박 전용 캐시 조회 (DB fallback 없음) + * + * @param minutes 조회 범위 (분) + * @return 시간 범위 내 대상 선박 목록 + */ + public List findChnPrmShip(int minutes) { + log.debug("ChnPrmShip 조회 - minutes: {}", minutes); + + long startTime = System.currentTimeMillis(); + + List entities = chnPrmShipCacheManager.getByTimeRange(minutes); + + long elapsed = System.currentTimeMillis() - startTime; + log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed); + + return entities.stream() + .map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE)) + .collect(Collectors.toList()); + } + + /** + * ChnPrmShip 캐시 통계 조회 + */ + public Map getChnPrmShipCacheStats() { + return chnPrmShipCacheManager.getStats(); + } + // ==================== 캐시 관리 ==================== /** diff --git a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java index ffe5d9f..113e675 100644 --- a/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java +++ b/src/main/java/com/snp/batch/jobs/aistargetdbsync/batch/tasklet/AisTargetDbSyncTasklet.java @@ -3,7 +3,6 @@ package com.snp.batch.jobs.aistargetdbsync.batch.tasklet; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; @@ -12,53 +11,69 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.time.Instant; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; /** * AIS Target DB Sync Tasklet * - * 스케줄: 매 15분 (0 0/15 * * * ?) - * * 동작: - * - Caffeine 캐시에서 최근 N분 이내 데이터 조회 + * - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회 * - MMSI별 최신 위치 1건씩 DB에 UPSERT * - 캐시의 모든 컬럼 정보를 그대로 DB에 저장 * + * 시간 범위 결정 전략: + * - 첫 실행 또는 마지막 실행 정보 없음 → fallback(time-range-minutes) 사용 + * - 이후 실행 → 마지막 성공 시각 기준으로 경과 시간 자동 계산 + * - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화) + * * 참고: * - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL) - * - DB 저장은 15분 주기로 수행하여 볼륨 절감 * - 기존 aisTargetImportJob은 캐시 업데이트만 수행 */ @Slf4j @Component -@RequiredArgsConstructor public class AisTargetDbSyncTasklet implements Tasklet { private final AisTargetCacheManager cacheManager; private final AisTargetRepository aisTargetRepository; + private final int fallbackMinutes; /** - * DB 동기화 시 조회할 캐시 데이터 시간 범위 (분) - * 기본값: 15분 (스케줄 주기와 동일) + * 마지막 성공 시각 (JVM 내 유지, 재기동 시 fallback 사용) */ - @Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") - private int timeRangeMinutes; + private final AtomicReference lastSuccessTime = new AtomicReference<>(); + + public AisTargetDbSyncTasklet( + AisTargetCacheManager cacheManager, + AisTargetRepository aisTargetRepository, + @Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) { + this.cacheManager = cacheManager; + this.aisTargetRepository = aisTargetRepository; + this.fallbackMinutes = fallbackMinutes; + } @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + Instant now = Instant.now(); + int rangeMinutes = resolveRangeMinutes(now); + log.info("========================================"); log.info("AIS Target DB Sync 시작"); - log.info("조회 범위: 최근 {}분", timeRangeMinutes); + log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes, + lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback"); log.info("현재 캐시 크기: {}", cacheManager.size()); log.info("========================================"); long startTime = System.currentTimeMillis(); - // 1. 캐시에서 최근 N분 이내 데이터 조회 - List entities = cacheManager.getByTimeRange(timeRangeMinutes); + // 1. 캐시에서 시간 범위 내 데이터 조회 + List entities = cacheManager.getByTimeRange(rangeMinutes); if (entities.isEmpty()) { - log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes); + log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes); + lastSuccessTime.set(now); return RepeatStatus.FINISHED; } @@ -69,6 +84,9 @@ public class AisTargetDbSyncTasklet implements Tasklet { long elapsed = System.currentTimeMillis() - startTime; + // 성공 시각 기록 + lastSuccessTime.set(now); + log.info("========================================"); log.info("AIS Target DB Sync 완료"); log.info("저장 건수: {} 건", entities.size()); @@ -80,4 +98,24 @@ public class AisTargetDbSyncTasklet implements Tasklet { return RepeatStatus.FINISHED; } + + private static final int MAX_RANGE_MINUTES = 60; + + /** + * 조회 범위(분) 결정 + * - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분) + * - 없으면: fallback 값 사용 + * - 오래 중단 후 재가동 시에도 최대 60분으로 제한하여 과부하 방지 + */ + private int resolveRangeMinutes(Instant now) { + Instant last = lastSuccessTime.get(); + if (last == null) { + return Math.min(fallbackMinutes, MAX_RANGE_MINUTES); + } + + long elapsedMinutes = java.time.Duration.between(last, now).toMinutes(); + // 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분 + int range = (int) Math.max(elapsedMinutes + 1, 1); + return Math.min(range, MAX_RANGE_MINUTES); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef74351..5d6992c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -185,6 +185,14 @@ app: ttl-minutes: 120 # 캐시 TTL (분) - 2시간 max-size: 300000 # 최대 캐시 크기 - 30만 건 + # 중국 허가선박 전용 캐시 설정 + chnprmship: + mmsi-resource-path: classpath:chnprmship-mmsi.txt + ttl-days: 2 + max-size: 2000 + warmup-enabled: true + warmup-days: 2 + # ClassType 분류 설정 class-type: refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) diff --git a/src/main/resources/chnprmship-mmsi.txt b/src/main/resources/chnprmship-mmsi.txt new file mode 100644 index 0000000..5086ddc --- /dev/null +++ b/src/main/resources/chnprmship-mmsi.txt @@ -0,0 +1,1402 @@ +100895843 +100915113 +150201583 +186544332 +200005740 +200026355 +210105014 +210800202 +214100000 +261088888 +313443397 +314425141 +320709591 +332154938 +333545559 +365226688 +379824585 +400108800 +400123354 +400702597 +410210118 +411225585 +411256658 +412000996 +412001266 +412002674 +412005279 +412005557 +412005999 +412014688 +412015316 +412020019 +412026089 +412026099 +412026399 +412036999 +412053898 +412054958 +412055125 +412056987 +412085668 +412113500 +412121483 +412135789 +412167777 +412200193 +412200194 +412200217 +412200377 +412200384 +412200394 +412200404 +412200414 +412200432 +412200437 +412200527 +412200528 +412200561 +412200776 +412200805 +412200812 +412200813 +412200849 +412200853 +412200877 +412200879 +412201174 +412201239 +412202172 +412202321 +412202322 +412202326 +412202327 +412202356 +412202374 +412202375 +412202377 +412202384 +412202385 +412202388 +412202413 +412202414 +412202499 +412202736 +412202741 +412202782 +412202783 +412202796 +412202797 +412202802 +412202803 +412202888 +412202969 +412202974 +412203032 +412203062 +412203388 +412203608 +412204051 +412204069 +412204155 +412204201 +412205349 +412205351 +412205422 +412205461 +412205462 +412205602 +412205603 +412205629 +412205631 +412205632 +412205647 +412205648 +412205651 +412205697 +412205699 +412205742 +412205743 +412207019 +412207076 +412207077 +412207078 +412207079 +412207463 +412207465 +412208071 +412208072 +412208081 +412208082 +412208116 +412208162 +412208166 +412208213 +412208281 +412208282 +412209061 +412210017 +412210018 +412210019 +412210021 +412210022 +412210023 +412210024 +412210025 +412210026 +412210043 +412210044 +412210048 +412210049 +412210051 +412210054 +412210056 +412210109 +412210111 +412210112 +412210113 +412210115 +412210117 +412210118 +412210121 +412210123 +412210124 +412210126 +412210127 +412210131 +412210132 +412210134 +412210135 +412210136 +412210138 +412210139 +412210142 +412210154 +412210156 +412210158 +412210161 +412210162 +412210163 +412210165 +412210246 +412210258 +412210259 +412210261 +412210273 +412210297 +412210312 +412210313 +412210314 +412210315 +412210316 +412210329 +412210331 +412210332 +412210442 +412210463 +412210466 +412210467 +412210469 +412210471 +412210472 +412210473 +412210474 +412210475 +412210477 +412210478 +412210479 +412210484 +412210487 +412210489 +412210491 +412210517 +412210518 +412210519 +412210527 +412210822 +412210871 +412210938 +412211121 +412211161 +412212504 +412212655 +412212934 +412213298 +412213299 +412213351 +412213369 +412213373 +412213374 +412213375 +412213381 +412213382 +412213383 +412213384 +412213386 +412213401 +412213403 +412213405 +412213454 +412213455 +412213457 +412213478 +412213486 +412213487 +412213488 +412213495 +412213514 +412213520 +412213521 +412213522 +412213576 +412213624 +412213626 +412213663 +412213692 +412213702 +412213708 +412213769 +412213772 +412213773 +412213774 +412213775 +412213777 +412213778 +412213779 +412214808 +412214872 +412214873 +412215031 +412215139 +412217300 +412217304 +412217305 +412217678 +412218936 +412218937 +412219066 +412219067 +412219955 +412219956 +412219986 +412221489 +412221493 +412223022 +412223024 +412223032 +412223033 +412223050 +412225088 +412225282 +412225388 +412225502 +412225509 +412225512 +412225518 +412225525 +412225585 +412225591 +412225616 +412225734 +412225738 +412225743 +412225754 +412225766 +412225773 +412225788 +412225793 +412225795 +412225797 +412225802 +412225809 +412225814 +412225835 +412225841 +412225844 +412225854 +412225863 +412225925 +412225927 +412225936 +412225938 +412225948 +412225951 +412225952 +412225954 +412225959 +412225962 +412226004 +412226023 +412226057 +412226059 +412226087 +412226088 +412226089 +412226092 +412226094 +412226095 +412226107 +412226108 +412226109 +412226114 +412226115 +412226129 +412226151 +412226153 +412226205 +412226209 +412226318 +412226319 +412226321 +412226324 +412226388 +412229246 +412231777 +412251119 +412255855 +412256658 +412256789 +412258598 +412258777 +412265777 +412265888 +412280063 +412280237 +412280376 +412280377 +412280739 +412280741 +412280841 +412280842 +412284608 +412285646 +412286361 +412286362 +412286368 +412286369 +412286529 +412286540 +412286655 +412286661 +412286662 +412286666 +412286668 +412286669 +412286671 +412286672 +412286673 +412286674 +412286675 +412286677 +412286682 +412286684 +412286685 +412286686 +412286687 +412286688 +412286715 +412287545 +412287668 +412287669 +412287708 +412287709 +412287711 +412287712 +412287713 +412287748 +412287752 +412287753 +412287756 +412287757 +412287766 +412287767 +412287771 +412287772 +412287773 +412287774 +412287775 +412287776 +412287782 +412287783 +412287784 +412287804 +412287805 +412287812 +412287822 +412287824 +412287844 +412287861 +412287874 +412287877 +412287878 +412287918 +412289281 +412296865 +412300005 +412300006 +412300011 +412300012 +412300013 +412300026 +412300028 +412300029 +412300031 +412300032 +412300033 +412300034 +412300035 +412300036 +412300037 +412300038 +412300042 +412300043 +412300044 +412300046 +412300053 +412300054 +412300055 +412300056 +412300062 +412300064 +412300065 +412300066 +412300068 +412300069 +412300071 +412300084 +412300087 +412300146 +412300189 +412300233 +412300249 +412300292 +412300307 +412300332 +412300346 +412300504 +412300517 +412300817 +412301005 +412301006 +412301041 +412301063 +412301088 +412304086 +412304899 +412305328 +412305988 +412306396 +412306399 +412306663 +412306788 +412306887 +412308689 +412309679 +412311132 +412313345 +412314158 +412317827 +412319975 +412320009 +412320018 +412320035 +412320043 +412320045 +412320069 +412320091 +412320092 +412320093 +412320094 +412320122 +412320123 +412320151 +412320162 +412320163 +412320166 +412320167 +412320168 +412320257 +412320258 +412320274 +412320279 +412320315 +412320358 +412320393 +412320394 +412320404 +412320413 +412320414 +412320475 +412320476 +412320491 +412320492 +412320501 +412320511 +412320529 +412320599 +412320601 +412320625 +412320626 +412320646 +412320647 +412320706 +412320745 +412320746 +412320783 +412320784 +412320789 +412320805 +412320836 +412320837 +412320959 +412320961 +412320962 +412320963 +412321053 +412321054 +412321115 +412321116 +412321312 +412321339 +412321341 +412321346 +412321372 +412321373 +412321387 +412321516 +412321517 +412321624 +412321686 +412321718 +412321719 +412321797 +412321802 +412321865 +412322075 +412322114 +412322145 +412322148 +412322149 +412322174 +412322175 +412324015 +412324761 +412324808 +412325033 +412325034 +412325055 +412325056 +412325218 +412325219 +412325222 +412325223 +412325249 +412325251 +412325257 +412325279 +412325304 +412325386 +412325443 +412325533 +412325813 +412325936 +412326016 +412326017 +412326817 +412326835 +412326836 +412327066 +412327646 +412327647 +412327672 +412327673 +412327735 +412327736 +412327749 +412327751 +412327752 +412327753 +412327771 +412327772 +412327819 +412327821 +412327824 +412327825 +412327844 +412327845 +412327846 +412327847 +412327865 +412327866 +412327867 +412327868 +412327890 +412327897 +412327898 +412327908 +412327922 +412327923 +412327926 +412327927 +412327928 +412327929 +412327933 +412327934 +412327944 +412327945 +412327974 +412328111 +412328112 +412328113 +412328114 +412328115 +412328116 +412328285 +412328286 +412328287 +412328288 +412328294 +412328295 +412328301 +412328302 +412328304 +412328345 +412328346 +412328366 +412328372 +412328373 +412328384 +412328385 +412328386 +412328409 +412328411 +412328443 +412328444 +412328466 +412328467 +412328501 +412328502 +412328657 +412328658 +412328814 +412328815 +412328835 +412328836 +412328847 +412328848 +412328878 +412328894 +412328895 +412328897 +412328898 +412328905 +412328906 +412328907 +412328908 +412328923 +412328924 +412328934 +412328935 +412328936 +412328937 +412328942 +412328943 +412328944 +412328945 +412328965 +412328966 +412328989 +412328991 +412328996 +412328997 +412329001 +412329002 +412329006 +412329007 +412329078 +412329089 +412329091 +412329095 +412329096 +412329117 +412329134 +412329135 +412329148 +412329149 +412329173 +412329174 +412329176 +412329177 +412329183 +412329184 +412329211 +412329212 +412329215 +412329216 +412329245 +412329246 +412329289 +412329291 +412329316 +412329317 +412329321 +412329322 +412329323 +412329324 +412329374 +412329375 +412329396 +412329397 +412329398 +412329399 +412329489 +412329491 +412329492 +412329493 +412329551 +412329552 +412329614 +412329615 +412329759 +412329761 +412329782 +412329786 +412329788 +412329789 +412329803 +412329804 +412329808 +412329809 +412329817 +412329831 +412329832 +412329833 +412329847 +412329848 +412329892 +412329893 +412329901 +412329902 +412329916 +412329917 +412329919 +412329921 +412329924 +412329925 +412329926 +412329927 +412329934 +412329935 +412329941 +412329977 +412329982 +412329983 +412329986 +412329987 +412329988 +412329995 +412329996 +412330022 +412330023 +412330024 +412330027 +412330028 +412330476 +412330477 +412330503 +412330504 +412330505 +412330506 +412330522 +412330523 +412330524 +412330525 +412330545 +412330546 +412330554 +412330555 +412330558 +412330559 +412330569 +412330572 +412330573 +412330574 +412330575 +412330576 +412330577 +412330578 +412330579 +412330588 +412330589 +412330594 +412330595 +412330635 +412330636 +412330657 +412330862 +412330886 +412330887 +412330888 +412330889 +412330911 +412330912 +412331194 +412331195 +412331196 +412331197 +412331198 +412331199 +412331206 +412331207 +412331396 +412331397 +412331528 +412331529 +412331535 +412331847 +412332398 +412332808 +412333324 +412333325 +412333326 +412333327 +412333342 +412333343 +412333531 +412333532 +412333541 +412333550 +412333945 +412333946 +412334006 +412334007 +412334014 +412334015 +412334019 +412334027 +412334058 +412336074 +412336093 +412336094 +412336095 +412336102 +412336111 +412336116 +412336117 +412336118 +412336123 +412336129 +412336131 +412336132 +412336196 +412336606 +412336607 +412336612 +412336613 +412336623 +412336624 +412336637 +412336638 +412337325 +412337348 +412337349 +412337424 +412337644 +412337645 +412345621 +412350017 +412350047 +412350049 +412350058 +412350059 +412350112 +412350165 +412350338 +412352301 +412352381 +412352422 +412352436 +412352649 +412353058 +412353373 +412353857 +412353858 +412353886 +412355071 +412355141 +412356251 +412357799 +412358545 +412358882 +412358995 +412359066 +412359077 +412364135 +412364283 +412364303 +412364358 +412364513 +412364738 +412364783 +412364837 +412364947 +412365095 +412365194 +412365289 +412365328 +412365331 +412365335 +412365639 +412365939 +412366336 +412366358 +412366665 +412366669 +412366912 +412368875 +412368885 +412368902 +412368966 +412375283 +412386668 +412386669 +412410001 +412410009 +412410746 +412410747 +412411528 +412411605 +412411647 +412411909 +412413895 +412414342 +412414345 +412414423 +412414436 +412414538 +412414744 +412415482 +412415513 +412416104 +412416132 +412416207 +412416235 +412416249 +412416268 +412416269 +412416292 +412416296 +412416307 +412416308 +412416338 +412416367 +412416391 +412416394 +412416406 +412416448 +412416508 +412416535 +412416554 +412416557 +412416584 +412416591 +412416592 +412416595 +412416642 +412416699 +412416837 +412416842 +412416872 +412416875 +412416898 +412416927 +412416949 +412416981 +412417008 +412417106 +412417115 +412417151 +412417182 +412417188 +412417222 +412417247 +412417248 +412417287 +412417288 +412417295 +412417311 +412417334 +412417335 +412417338 +412417352 +412417365 +412417368 +412417412 +412417413 +412417483 +412417509 +412417556 +412417692 +412417712 +412417741 +412417785 +412417807 +412417825 +412417838 +412417851 +412417917 +412417954 +412417957 +412417977 +412417981 +412418011 +412418017 +412418018 +412418056 +412418082 +412418101 +412418158 +412418171 +412418185 +412418246 +412418319 +412418387 +412418401 +412418478 +412418488 +412418507 +412418511 +412418513 +412418515 +412418567 +412418568 +412418586 +412418629 +412418633 +412418679 +412418696 +412418698 +412418774 +412418785 +412418793 +412418795 +412418803 +412418814 +412418816 +412418833 +412418834 +412418872 +412418873 +412418874 +412418887 +412418918 +412418933 +412418941 +412418942 +412418952 +412418999 +412419018 +412419024 +412419064 +412419114 +412419132 +412419203 +412419233 +412419262 +412419264 +412419265 +412419266 +412419276 +412419324 +412419342 +412419345 +412419348 +412419406 +412419407 +412419455 +412419488 +412419495 +412419502 +412419506 +412419507 +412419509 +412419531 +412419536 +412419541 +412419544 +412419545 +412419549 +412419553 +412419564 +412419569 +412419585 +412419587 +412419638 +412419641 +412419642 +412419667 +412419668 +412419688 +412419689 +412419701 +412419702 +412419703 +412419704 +412419706 +412419709 +412425002 +412431008 +412431029 +412431033 +412431041 +412431058 +412431063 +412431066 +412431071 +412431084 +412431087 +412431120 +412431124 +412431129 +412431141 +412431151 +412431173 +412431222 +412431262 +412431263 +412431266 +412431291 +412431396 +412431491 +412431494 +412431743 +412431805 +412431809 +412431892 +412431911 +412431913 +412431914 +412431915 +412431963 +412431964 +412431966 +412431985 +412435125 +412435126 +412435127 +412435142 +412435309 +412435356 +412435386 +412435387 +412435595 +412435596 +412435784 +412435813 +412436079 +412436271 +412436329 +412436519 +412436521 +412436627 +412436631 +412436701 +412436710 +412436841 +412436874 +412436963 +412436969 +412436992 +412437006 +412437026 +412437037 +412437045 +412437054 +412437055 +412437071 +412437072 +412437079 +412437085 +412437095 +412437113 +412437118 +412437119 +412437166 +412437215 +412437418 +412437419 +412437626 +412437627 +412437633 +412437635 +412437659 +412437718 +412437817 +412437818 +412437821 +412437822 +412437988 +412437989 +412438043 +412438044 +412438045 +412438065 +412438066 +412438146 +412438235 +412438236 +412438646 +412438647 +412438696 +412438697 +412438868 +412438869 +412438873 +412438955 +412438996 +412438997 +412439055 +412439056 +412439111 +412439112 +412439139 +412439143 +412439145 +412439146 +412439252 +412439356 +412439357 +412443647 +412452265 +412456855 +412468166 +412471879 +412475803 +412476361 +412476457 +412479103 +412479385 +412480093 +412494141 +412494148 +412494156 +412494172 +412515088 +412526198 +412526798 +412532666 +412545687 +412556356 +412556357 +412556889 +412570001 +412577688 +412585665 +412588681 +412588778 +412653456 +412665478 +412685177 +412752470 +412798948 +412800888 +412852443 +412865966 +412879798 +412885120 +412886580 +412900240 +412952867 +412958588 +413000229 +413000769 +413004860 +413005116 +413027088 +413035319 +413089562 +413111322 +413122960 +413127608 +413155153 +413215238 +413216847 +413226089 +413245082 +413255506 +413255555 +413256667 +413296865 +413300026 +413300221 +413315088 +413320282 +413327929 +413335198 +413350165 +413357867 +413361808 +413388589 +413457866 +413464232 +413466077 +413520688 +413578254 +413593750 +413699592 +414328943 +415005666 +415051026 +415107777 +415108888 +415109607 +415140625 +415214102 +415232125 +415261386 +415506055 +415628585 +415782000 +415836666 +415901572 +415936288 +417758521 +420439112 +421233456 +422226789 +422277709 +423678955 +423789666 +425556789 +441235678 +512325936 +550026918 +558888888 +586358882 +586418965 +600950945 +688816888 +688826755 +688826968 +712210938 +712330656 +712888888 +789999999 +800004551 +800029774 +800044382 +800052359 +888888838 +888888877 +888888988 +888898888 +900000173 +900020650 +905106399 +905106699 +905108588 +926002285 +926002997 +926004879 +926009286 +926012291