diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index f2274a0..703e5e5 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -21,6 +21,7 @@ public class CacheConfig { public static final String SEISMIC = "seismic"; public static final String PRESSURE = "pressure"; public static final String VESSEL_ANALYSIS = "vessel-analysis"; + public static final String GROUP_POLYGONS = "group-polygons"; @Bean public CacheManager cacheManager() { @@ -29,7 +30,8 @@ public class CacheConfig { OSINT_IRAN, OSINT_KOREA, SATELLITES, SEISMIC, PRESSURE, - VESSEL_ANALYSIS + VESSEL_ANALYSIS, + GROUP_POLYGONS ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java new file mode 100644 index 0000000..97b9e9e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.fleet; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vessel-analysis/groups") +@RequiredArgsConstructor +public class GroupPolygonController { + + private final GroupPolygonService groupPolygonService; + + /** + * 전체 그룹 폴리곤 목록 (최신 스냅샷, 5분 캐시) + */ + @GetMapping + public ResponseEntity> getGroups() { + List groups = groupPolygonService.getLatestGroups(); + return ResponseEntity.ok(Map.of( + "count", groups.size(), + "items", groups + )); + } + + /** + * 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거) + */ + @GetMapping("/{groupKey}/detail") + public ResponseEntity getGroupDetail(@PathVariable String groupKey) { + GroupPolygonDto detail = groupPolygonService.getGroupDetail(groupKey); + if (detail == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(detail); + } + + /** + * 특정 그룹 히스토리 (시간별 폴리곤 변화) + */ + @GetMapping("/{groupKey}/history") + public ResponseEntity> getGroupHistory( + @PathVariable String groupKey, + @RequestParam(defaultValue = "24") int hours) { + List history = groupPolygonService.getGroupHistory(groupKey, hours); + return ResponseEntity.ok(history); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java new file mode 100644 index 0000000..dd2fa28 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.fleet; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupPolygonDto { + private String groupType; + private String groupKey; + private String groupLabel; + private String snapshotTime; + private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON) + private double centerLat; + private double centerLon; + private double areaSqNm; + private int memberCount; + private String zoneId; + private String zoneName; + private List> members; + private String color; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java new file mode 100644 index 0000000..eb9f289 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -0,0 +1,131 @@ +package gc.mda.kcg.domain.fleet; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GroupPolygonService { + + private final JdbcTemplate jdbcTemplate; + private final CacheManager cacheManager; + private final ObjectMapper objectMapper; + + private static final String LATEST_GROUPS_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) + ORDER BY group_type, member_count DESC + """; + + private static final String GROUP_DETAIL_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE group_key = ? + ORDER BY snapshot_time DESC + LIMIT 1 + """; + + private static final String GROUP_HISTORY_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL) + ORDER BY snapshot_time DESC + """; + + /** + * 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시). + */ + @SuppressWarnings("unchecked") + public List getLatestGroups() { + Cache cache = cacheManager.getCache(CacheConfig.GROUP_POLYGONS); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + + List results = jdbcTemplate.query(LATEST_GROUPS_SQL, this::mapRow); + + if (cache != null) { + cache.put("data", results); + } + return results; + } + + /** + * 특정 그룹의 최신 상세 정보. + */ + public GroupPolygonDto getGroupDetail(String groupKey) { + List results = jdbcTemplate.query(GROUP_DETAIL_SQL, this::mapRow, groupKey); + return results.isEmpty() ? null : results.get(0); + } + + /** + * 특정 그룹의 시간별 히스토리. + */ + public List getGroupHistory(String groupKey, int hours) { + return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours)); + } + + private GroupPolygonDto mapRow(ResultSet rs, int rowNum) throws SQLException { + Object polygonObj = null; + String polygonJson = rs.getString("polygon_geojson"); + if (polygonJson != null) { + try { + polygonObj = objectMapper.readValue(polygonJson, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse polygon GeoJSON: {}", e.getMessage()); + } + } + + List> members = List.of(); + String membersJson = rs.getString("members"); + if (membersJson != null) { + try { + members = objectMapper.readValue(membersJson, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to parse members JSON: {}", e.getMessage()); + } + } + + return GroupPolygonDto.builder() + .groupType(rs.getString("group_type")) + .groupKey(rs.getString("group_key")) + .groupLabel(rs.getString("group_label")) + .snapshotTime(rs.getString("snapshot_time")) + .polygon(polygonObj) + .centerLat(rs.getDouble("center_lat")) + .centerLon(rs.getDouble("center_lon")) + .areaSqNm(rs.getDouble("area_sq_nm")) + .memberCount(rs.getInt("member_count")) + .zoneId(rs.getString("zone_id")) + .zoneName(rs.getString("zone_name")) + .members(members) + .color(rs.getString("color")) + .build(); + } +}