From df957be2fe337c65fdaafe1aa0c4c648f26ec9d9 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Mar 2026 08:01:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(metrics):=20REST=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20client=5Fid=20=EC=88=98=EC=A7=91=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JWT 쿠키 파싱을 WebSocketStompConfig.extractClientIdFromRequest() static 메서드로 추출 - GisControllerV2 → GisServiceV2 → enqueueRestMetric에 clientId 전달 추가 - WebSocket 경로는 이미 정상 (htlee@gcsc.co.kr 수집 확인) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gis/controller/GisControllerV2.java | 6 +- .../domain/gis/service/GisServiceV2.java | 7 ++- .../global/config/WebSocketStompConfig.java | 60 ++++++++++--------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java index 495171a..a3476da 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java @@ -191,7 +191,11 @@ public class GisControllerV2 { ) @RequestBody VesselTracksRequest request, HttpServletRequest httpRequest) { - return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest)); + return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest), getClientId(httpRequest)); + } + + private String getClientId(HttpServletRequest request) { + return gc.mda.signal_batch.global.config.WebSocketStompConfig.extractClientIdFromRequest(request); } private String getClientIp(HttpServletRequest request) { diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java index 42ec069..b3d6478 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java @@ -285,7 +285,7 @@ public class GisServiceV2 { /** * 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment) */ - public List getVesselTracksV2(VesselTracksRequest request, String clientIp) { + public List getVesselTracksV2(VesselTracksRequest request, String clientIp, String clientId) { String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8); long startMs = System.currentTimeMillis(); boolean slotAcquired = false; @@ -329,7 +329,7 @@ public class GisServiceV2 { result.size(), request.getVessels().size(), dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip()); - enqueueRestMetric(queryId, request, result, startMs, clientIp); + enqueueRestMetric(queryId, request, result, startMs, clientIp, clientId); return result; @@ -347,7 +347,7 @@ public class GisServiceV2 { } private void enqueueRestMetric(String queryId, VesselTracksRequest request, - List result, long startMs, String clientIp) { + List result, long startMs, String clientIp, String clientId) { try { int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum(); long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40; @@ -367,6 +367,7 @@ public class GisServiceV2 { .elapsedMs(System.currentTimeMillis() - startMs) .status("COMPLETED") .clientIp(clientIp) + .clientId(clientId) .build()); } catch (Exception e) { log.debug("Failed to enqueue REST metric: {}", e.getMessage()); diff --git a/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java b/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java index e8f0c46..419c913 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java @@ -235,42 +235,44 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { return "unknown"; } - /** - * GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출. - * JWT 검증은 nginx auth_request에서 이미 완료 — 여기서는 payload 디코딩만 수행. - */ private String extractEmailFromJwtCookie(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) return null; + return extractClientIdFromRequest(request); + } + } - String token = null; - for (Cookie cookie : cookies) { - if ("GC_SESSION".equals(cookie.getName())) { - token = cookie.getValue(); - break; - } + /** + * GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출 (REST/WebSocket 공용). + * JWT 검증은 nginx auth_request에서 이미 완료 — 여기서는 payload 디코딩만 수행. + */ + public static String extractClientIdFromRequest(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + + String token = null; + for (Cookie cookie : cookies) { + if ("GC_SESSION".equals(cookie.getName())) { + token = cookie.getValue(); + break; } - if (token == null || token.isEmpty()) return null; + } + if (token == null || token.isEmpty()) return null; - try { - // JWT: header.payload.signature — payload만 Base64URL 디코딩 - String[] parts = token.split("\\."); - if (parts.length < 2) return null; + try { + String[] parts = token.split("\\."); + if (parts.length < 2) return null; - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - // 간단한 JSON 파싱 (Jackson 의존 없이): "email":"value" 추출 - int emailIdx = payload.indexOf("\"email\""); - if (emailIdx < 0) return null; + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + int emailIdx = payload.indexOf("\"email\""); + if (emailIdx < 0) return null; - int colonIdx = payload.indexOf(':', emailIdx); - int quoteStart = payload.indexOf('"', colonIdx + 1); - int quoteEnd = payload.indexOf('"', quoteStart + 1); - if (quoteStart < 0 || quoteEnd < 0) return null; + int colonIdx = payload.indexOf(':', emailIdx); + int quoteStart = payload.indexOf('"', colonIdx + 1); + int quoteEnd = payload.indexOf('"', quoteStart + 1); + if (quoteStart < 0 || quoteEnd < 0) return null; - return payload.substring(quoteStart + 1, quoteEnd); - } catch (Exception e) { - return null; - } + return payload.substring(quoteStart + 1, quoteEnd); + } catch (Exception e) { + return null; } } } \ No newline at end of file -- 2.45.2 From 74aace919bb7ee9045c556228aaf5e6cfe242573 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Mar 2026 08:05:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5513fb2..f9d303e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 수정 +- REST API 경로 client_id 수집 누락 수정 — JWT 쿠키 파싱 공용 메서드 추출 + ## [2026-03-27.2] ### 수정 -- 2.45.2 From 8f784de358552e160ddfea088dcc34c87b5814ba Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Mar 2026 07:56:16 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(batch):=20=EB=B9=84=EC=A0=95=EC=83=81?= =?UTF-8?q?=20=EA=B6=A4=EC=A0=81=20=ED=8F=AC=ED=95=A8=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=B6=94=EA=B0=80=20=E2=80=94?= =?UTF-8?q?=20=EA=B0=95=ED=99=94=ED=95=99=EC=8A=B5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A7=91=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPS 스푸핑 등 비정상 운항 패턴의 강화학습 분류기 고도화를 위해, 비정상 궤적을 정상 테이블(5min/hourly/daily)과 캐시(L1/L2)에도 포함 저장하는 설정 플래그 추가. - vessel.batch.track.include-abnormal-in-tracks 플래그 (기본 false) - 5min: isAbnormal 시에도 filteredTracks에 포함 (플래그 true) - Hourly/Daily: correctedTrack null 시에도 originalTrack 포함 (플래그 true) - 비정상 검출 + t_abnormal_tracks 기록은 플래그와 무관하게 항상 유지 - prod 환경 true 설정 (강화학습 데이터 수집) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../batch/job/DailyAggregationStepConfig.java | 7 +++++- .../job/HourlyAggregationStepConfig.java | 6 ++++- .../batch/job/VesselTrackStepConfig.java | 24 ++++++++++++------- .../batch/writer/CompositeTrackWriter.java | 11 ++++++--- src/main/resources/application-prod.yml | 1 + src/main/resources/application.yml | 2 ++ 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/main/java/gc/mda/signal_batch/batch/job/DailyAggregationStepConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/DailyAggregationStepConfig.java index 00ffb8e..e6915f2 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/DailyAggregationStepConfig.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/DailyAggregationStepConfig.java @@ -62,6 +62,9 @@ public class DailyAggregationStepConfig { @Value("${vessel.batch.chunk-size:5000}") private int chunkSize; + @Value("${vessel.batch.track.include-abnormal-in-tracks:false}") + private boolean includeAbnormalInTracks; + @Bean public Step mergeDailyTracksStep() { log.info("Building mergeDailyTracksStep with cache-based in-memory merge"); @@ -110,7 +113,9 @@ public class DailyAggregationStepConfig { return new CompositeTrackWriter( vesselTrackBulkWriter, abnormalTrackWriter, - "daily" + "daily", + null, + includeAbnormalInTracks ); } diff --git a/src/main/java/gc/mda/signal_batch/batch/job/HourlyAggregationStepConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/HourlyAggregationStepConfig.java index 7718d1a..b473611 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/HourlyAggregationStepConfig.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/HourlyAggregationStepConfig.java @@ -69,6 +69,9 @@ public class HourlyAggregationStepConfig { @Value("${vessel.batch.chunk-size:5000}") private int chunkSize; + @Value("${vessel.batch.track.include-abnormal-in-tracks:false}") + private boolean includeAbnormalInTracks; + // ────────────────────────────────────────────── // Step 1: 5분 → 시간 병합 (인메모리 캐시 기반) // ────────────────────────────────────────────── @@ -122,7 +125,8 @@ public class HourlyAggregationStepConfig { vesselTrackBulkWriter, abnormalTrackWriter, "hourly", - hourlyTrackCache + hourlyTrackCache, + includeAbnormalInTracks ); } diff --git a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java index 14e33eb..d43832d 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java @@ -96,6 +96,9 @@ public class VesselTrackStepConfig { @Value("${vessel.batch.chunk-size:1000}") private int chunkSize; + @Value("${vessel.batch.track.include-abnormal-in-tracks:false}") + private boolean includeAbnormalInTracks; + @PostConstruct public void init() { // 5분 Job의 이름을 명시적으로 설정 @@ -203,18 +206,21 @@ public class VesselTrackStepConfig { log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}", abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm()); saveAbnormalTrack(track, abnormalReason); + if (includeAbnormalInTracks) { + filteredTracks.add(track); // 플래그 true → 정상 테이블+캐시에도 포함 + } } else { filteredTracks.add(track); + } - // 정상 궤적의 종료 위치 저장 (캐시 업데이트용) - if (track.getEndPosition() != null) { - currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder() - .mmsi(track.getMmsi()) - .endLon(track.getEndPosition().getLon()) - .endLat(track.getEndPosition().getLat()) - .endTime(track.getEndPosition().getTime()) - .build()); - } + // 궤적의 종료 위치 저장 (캐시 업데이트용) — 비정상 포함 시에도 위치 추적 + if (filteredTracks.contains(track) && track.getEndPosition() != null) { + currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder() + .mmsi(track.getMmsi()) + .endLon(track.getEndPosition().getLon()) + .endLat(track.getEndPosition().getLat()) + .endTime(track.getEndPosition().getTime()) + .build()); } } diff --git a/src/main/java/gc/mda/signal_batch/batch/writer/CompositeTrackWriter.java b/src/main/java/gc/mda/signal_batch/batch/writer/CompositeTrackWriter.java index 1efeca5..74562d8 100644 --- a/src/main/java/gc/mda/signal_batch/batch/writer/CompositeTrackWriter.java +++ b/src/main/java/gc/mda/signal_batch/batch/writer/CompositeTrackWriter.java @@ -25,21 +25,24 @@ public class CompositeTrackWriter implements ItemWriter private final AbnormalTrackWriter abnormalTrackWriter; private final String targetTable; private final HourlyTrackCache hourlyTrackCache; // nullable (daily writer는 미사용) + private final boolean includeAbnormalInTracks; public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter, AbnormalTrackWriter abnormalTrackWriter, String targetTable, - HourlyTrackCache hourlyTrackCache) { + HourlyTrackCache hourlyTrackCache, + boolean includeAbnormalInTracks) { this.vesselTrackBulkWriter = vesselTrackBulkWriter; this.abnormalTrackWriter = abnormalTrackWriter; this.targetTable = targetTable; this.hourlyTrackCache = hourlyTrackCache; + this.includeAbnormalInTracks = includeAbnormalInTracks; } public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter, AbnormalTrackWriter abnormalTrackWriter, String targetTable) { - this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null); + this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null, false); } @BeforeStep @@ -66,9 +69,11 @@ public class CompositeTrackWriter implements ItemWriter abnormalResults.add(result); // 정정된 궤적이 있으면 정상 궤적으로 저장 - // null이면 전체 궤적이 비정상이므로 제외 + // null이면 전체 궤적이 비정상이므로 제외 (플래그 true면 원본 포함) if (result.getCorrectedTrack() != null) { normalTracks.add(result.getCorrectedTrack()); + } else if (includeAbnormalInTracks) { + normalTracks.add(result.getOriginalTrack()); } else { log.debug("비정상 궤적 전체 제외: vessel={}", result.getOriginalTrack().getVesselKey()); diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6c00a95..2552570 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -173,6 +173,7 @@ vessel: # spring 하위가 아닌 최상위 레벨 # 궤적 비정상 검출 설정 track: + include-abnormal-in-tracks: true # 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용) abnormal-detection: large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함 extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 682c7e8..159eb74 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -159,6 +159,8 @@ vessel: page-size: ${BATCH_PAGE_SIZE:10000} partition-size: ${BATCH_PARTITION_SIZE:24} skip-limit: 100 + track: + include-abnormal-in-tracks: false # true: 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용) retry-limit: 3 # Reader 설정 use-cursor-reader: true # Cursor Reader 사용 여부 -- 2.45.2 From 44cd532d525602154f0eb6754220f957ec62d5ad Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Mar 2026 08:06:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index f9d303e..83ce331 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 비정상 궤적 포함 저장 플래그 (`include-abnormal-in-tracks`) — 강화학습 데이터 수집용 + ### 수정 - REST API 경로 client_id 수집 누락 수정 — JWT 쿠키 파싱 공용 메서드 추출 -- 2.45.2 From ce55cdd1155c6df58f983ce7d1638d7c8a4a808e Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Mar 2026 08:12:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 83ce331..e8b172b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-27.3] + ### 추가 - 비정상 궤적 포함 저장 플래그 (`include-abnormal-in-tracks`) — 강화학습 데이터 수집용 -- 2.45.2