feat(backend): 그룹 폴리곤 API — 목록/상세/히스토리 엔드포인트

- GroupPolygonController: GET /api/vessel-analysis/groups (목록, 상세, 히스토리)
- GroupPolygonService: JdbcTemplate + ST_AsGeoJSON + Caffeine 5분 캐시
- GroupPolygonDto: GeoJSON polygon + members JSONB 응답 구조
- CacheConfig: GROUP_POLYGONS 캐시 키 추가
This commit is contained in:
htlee 2026-03-24 13:32:36 +09:00
부모 2441e3068a
커밋 b0fafca8c9
4개의 변경된 파일212개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -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)

파일 보기

@ -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<Map<String, Object>> getGroups() {
List<GroupPolygonDto> groups = groupPolygonService.getLatestGroups();
return ResponseEntity.ok(Map.of(
"count", groups.size(),
"items", groups
));
}
/**
* 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거)
*/
@GetMapping("/{groupKey}/detail")
public ResponseEntity<GroupPolygonDto> 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<List<GroupPolygonDto>> getGroupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours) {
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
return ResponseEntity.ok(history);
}
}

파일 보기

@ -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<Map<String, Object>> members;
private String color;
}

파일 보기

@ -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<GroupPolygonDto> getLatestGroups() {
Cache cache = cacheManager.getCache(CacheConfig.GROUP_POLYGONS);
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get("data");
if (wrapper != null) {
return (List<GroupPolygonDto>) wrapper.get();
}
}
List<GroupPolygonDto> results = jdbcTemplate.query(LATEST_GROUPS_SQL, this::mapRow);
if (cache != null) {
cache.put("data", results);
}
return results;
}
/**
* 특정 그룹의 최신 상세 정보.
*/
public GroupPolygonDto getGroupDetail(String groupKey) {
List<GroupPolygonDto> results = jdbcTemplate.query(GROUP_DETAIL_SQL, this::mapRow, groupKey);
return results.isEmpty() ? null : results.get(0);
}
/**
* 특정 그룹의 시간별 히스토리.
*/
public List<GroupPolygonDto> 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<Map<String, Object>>() {});
} catch (Exception e) {
log.warn("Failed to parse polygon GeoJSON: {}", e.getMessage());
}
}
List<Map<String, Object>> 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();
}
}