From 5c804aa38fbc2f8cf4c0a938c5eaaab2410bc389 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 10:43:53 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(backend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=201=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V018 마이그레이션: prediction_events.features JSONB 컬럼 추가 - VesselAnalysis 직접 조회 API 5개 신설 (/api/analysis/*) - vessels 목록 (필터: mmsi, zone, riskLevel, isDark) - vessels/{mmsi} 최신 분석 (features 포함) - vessels/{mmsi}/history 분석 이력 - dark 베셀 목록 (MMSI 중복 제거) - transship 의심 목록 - PredictionEvent entity에 features JSONB 필드 추가 - EnforcementController vesselMmsi 필터 파라미터 추가 - event_generator.py INSERT에 features 컬럼 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../analysis/VesselAnalysisController.java | 99 +++++++++++++ .../analysis/VesselAnalysisRepository.java | 60 ++++++++ .../analysis/VesselAnalysisResponse.java | 84 +++++++++++ .../domain/analysis/VesselAnalysisResult.java | 135 ++++++++++++++++++ .../analysis/VesselAnalysisService.java | 83 +++++++++++ .../enforcement/EnforcementController.java | 3 +- .../enforcement/EnforcementService.java | 5 +- .../EnforcementRecordRepository.java | 1 + .../mda/kcg/domain/event/PredictionEvent.java | 5 + .../V018__prediction_event_features.sql | 11 ++ prediction/output/event_generator.py | 8 +- 11 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java create mode 100644 backend/src/main/resources/db/migration/V018__prediction_event_features.sql diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java new file mode 100644 index 0000000..a9f0c79 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -0,0 +1,99 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * vessel_analysis_results 직접 조회 API. + * prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공. + * 기존 iran proxy와 별도 경로 (/api/analysis/*). + */ +@RestController +@RequestMapping("/api/analysis") +@RequiredArgsConstructor +public class VesselAnalysisController { + + private final VesselAnalysisService service; + + /** + * 분석 결과 목록 조회 (필터 + 페이징). + * 기본: 최근 1시간 내 결과. + */ + @GetMapping("/vessels") + @RequirePermission(resource = "detection", operation = "READ") + public Page listVessels( + @RequestParam(required = false) String mmsi, + @RequestParam(required = false) String zoneCode, + @RequestParam(required = false) String riskLevel, + @RequestParam(required = false) Boolean isDark, + @RequestParam(defaultValue = "1") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return service.getAnalysisResults( + mmsi, zoneCode, riskLevel, isDark, after, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt")) + ).map(VesselAnalysisResponse::from); + } + + /** + * 특정 선박 최신 분석 결과 (features 포함). + */ + @GetMapping("/vessels/{mmsi}") + @RequirePermission(resource = "detection", operation = "READ") + public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { + return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); + } + + /** + * 특정 선박 분석 이력 (기본 24시간). + */ + @GetMapping("/vessels/{mmsi}/history") + @RequirePermission(resource = "detection", operation = "READ") + public List getHistory( + @PathVariable String mmsi, + @RequestParam(defaultValue = "24") int hours + ) { + return service.getHistory(mmsi, hours).stream() + .map(VesselAnalysisResponse::from) + .toList(); + } + + /** + * 다크 베셀 목록 (최신 분석, MMSI 중복 제거). + */ + @GetMapping("/dark") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") + public Page listDarkVessels( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.getDarkVessels(hours, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore")) + ).map(VesselAnalysisResponse::from); + } + + /** + * 환적 의심 목록 (최신 분석, MMSI 중복 제거). + */ + @GetMapping("/transship") + @RequirePermission(resource = "detection", operation = "READ") + public Page listTransshipSuspects( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.getTransshipSuspects(hours, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore")) + ).map(VesselAnalysisResponse::from); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java new file mode 100644 index 0000000..4b54e48 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java @@ -0,0 +1,60 @@ +package gc.mda.kcg.domain.analysis; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/** + * vessel_analysis_results 읽기 전용 Repository. + */ +public interface VesselAnalysisRepository + extends JpaRepository, JpaSpecificationExecutor { + + /** + * 특정 선박의 최신 분석 결과. + */ + Optional findTopByMmsiOrderByAnalyzedAtDesc(String mmsi); + + /** + * 특정 선박의 분석 이력 (시간 범위). + */ + List findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc( + String mmsi, OffsetDateTime after); + + /** + * 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거). + */ + @Query(""" + SELECT v FROM VesselAnalysisResult v + WHERE v.isDark = true AND v.analyzedAt > :after + AND v.analyzedAt = ( + SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2 + WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after + ) + ORDER BY v.riskScore DESC + """) + Page findLatestDarkVessels( + @Param("after") OffsetDateTime after, Pageable pageable); + + /** + * 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거). + */ + @Query(""" + SELECT v FROM VesselAnalysisResult v + WHERE v.transshipSuspect = true AND v.analyzedAt > :after + AND v.analyzedAt = ( + SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2 + WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after + ) + ORDER BY v.riskScore DESC + """) + Page findLatestTransshipSuspects( + @Param("after") OffsetDateTime after, Pageable pageable); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java new file mode 100644 index 0000000..c7ec691 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java @@ -0,0 +1,84 @@ +package gc.mda.kcg.domain.analysis; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * vessel_analysis_results 응답 DTO. + * 프론트엔드에서 필요한 핵심 필드만 포함. + */ +public record VesselAnalysisResponse( + Long id, + String mmsi, + OffsetDateTime analyzedAt, + // 분류 + String vesselType, + BigDecimal confidence, + BigDecimal fishingPct, + String season, + // 위치 + Double lat, + Double lon, + String zoneCode, + BigDecimal distToBaselineNm, + // 행동 + String activityState, + // 위협 + Boolean isDark, + Integer gapDurationMin, + String darkPattern, + BigDecimal spoofingScore, + Integer speedJumpCount, + // 환적 + Boolean transshipSuspect, + String transshipPairMmsi, + Integer transshipDurationMin, + // 선단 + Integer fleetClusterId, + String fleetRole, + Boolean fleetIsLeader, + // 위험도 + Integer riskScore, + String riskLevel, + // 확장 + String gearCode, + String gearJudgment, + String permitStatus, + // features + Map features +) { + public static VesselAnalysisResponse from(VesselAnalysisResult e) { + return new VesselAnalysisResponse( + e.getId(), + e.getMmsi(), + e.getAnalyzedAt(), + e.getVesselType(), + e.getConfidence(), + e.getFishingPct(), + e.getSeason(), + e.getLat(), + e.getLon(), + e.getZoneCode(), + e.getDistToBaselineNm(), + e.getActivityState(), + e.getIsDark(), + e.getGapDurationMin(), + e.getDarkPattern(), + e.getSpoofingScore(), + e.getSpeedJumpCount(), + e.getTransshipSuspect(), + e.getTransshipPairMmsi(), + e.getTransshipDurationMin(), + e.getFleetClusterId(), + e.getFleetRole(), + e.getFleetIsLeader(), + e.getRiskScore(), + e.getRiskLevel(), + e.getGearCode(), + e.getGearJudgment(), + e.getPermitStatus(), + e.getFeatures() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java new file mode 100644 index 0000000..cedc132 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -0,0 +1,135 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * vessel_analysis_results 읽기 전용 Entity. + * prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행. + * + * DB PK는 (id, analyzed_at) 복합키(파티션)이지만, + * BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑. + */ +@Entity +@Table(name = "vessel_analysis_results", schema = "kcg") +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VesselAnalysisResult { + + @Id + private Long id; + + @Column(name = "mmsi", nullable = false, length = 20) + private String mmsi; + + @Column(name = "analyzed_at", nullable = false) + private OffsetDateTime analyzedAt; + + // 분류 + @Column(name = "vessel_type", length = 30) + private String vesselType; + + @Column(name = "confidence", precision = 5, scale = 4) + private BigDecimal confidence; + + @Column(name = "fishing_pct", precision = 5, scale = 4) + private BigDecimal fishingPct; + + @Column(name = "cluster_id") + private Integer clusterId; + + @Column(name = "season", length = 20) + private String season; + + // 위치 + @Column(name = "lat") + private Double lat; + + @Column(name = "lon") + private Double lon; + + @Column(name = "zone_code", length = 30) + private String zoneCode; + + @Column(name = "dist_to_baseline_nm", precision = 8, scale = 2) + private BigDecimal distToBaselineNm; + + // 행동 분석 + @Column(name = "activity_state", length = 20) + private String activityState; + + @Column(name = "ucaf_score", precision = 5, scale = 4) + private BigDecimal ucafScore; + + @Column(name = "ucft_score", precision = 5, scale = 4) + private BigDecimal ucftScore; + + // 위협 탐지 + @Column(name = "is_dark") + private Boolean isDark; + + @Column(name = "gap_duration_min") + private Integer gapDurationMin; + + @Column(name = "dark_pattern", length = 30) + private String darkPattern; + + @Column(name = "spoofing_score", precision = 5, scale = 4) + private BigDecimal spoofingScore; + + @Column(name = "bd09_offset_m", precision = 8, scale = 2) + private BigDecimal bd09OffsetM; + + @Column(name = "speed_jump_count") + private Integer speedJumpCount; + + // 환적 + @Column(name = "transship_suspect") + private Boolean transshipSuspect; + + @Column(name = "transship_pair_mmsi", length = 20) + private String transshipPairMmsi; + + @Column(name = "transship_duration_min") + private Integer transshipDurationMin; + + // 선단 + @Column(name = "fleet_cluster_id") + private Integer fleetClusterId; + + @Column(name = "fleet_role", length = 20) + private String fleetRole; + + @Column(name = "fleet_is_leader") + private Boolean fleetIsLeader; + + // 위험도 + @Column(name = "risk_score") + private Integer riskScore; + + @Column(name = "risk_level", length = 20) + private String riskLevel; + + // 확장 + @Column(name = "gear_code", length = 20) + private String gearCode; + + @Column(name = "gear_judgment", length = 30) + private String gearJudgment; + + @Column(name = "permit_status", length = 20) + private String permitStatus; + + // features JSONB + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "features", columnDefinition = "jsonb") + private Map features; + + @Column(name = "created_at") + private OffsetDateTime createdAt; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java new file mode 100644 index 0000000..79a6555 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -0,0 +1,83 @@ +package gc.mda.kcg.domain.analysis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * vessel_analysis_results 직접 조회 서비스. + * prediction이 write한 분석 결과를 프론트엔드에 제공. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VesselAnalysisService { + + private final VesselAnalysisRepository repository; + + /** + * 분석 결과 목록 조회 (동적 필터). + */ + public Page getAnalysisResults( + String mmsi, String zoneCode, String riskLevel, Boolean isDark, + OffsetDateTime after, Pageable pageable + ) { + Specification spec = Specification.where(null); + + if (after != null) { + spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after)); + } + if (mmsi != null && !mmsi.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi)); + } + if (zoneCode != null && !zoneCode.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode)); + } + if (riskLevel != null && !riskLevel.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel)); + } + if (isDark != null && isDark) { + spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark"))); + } + + return repository.findAll(spec, pageable); + } + + /** + * 특정 선박 최신 분석 결과. + */ + public VesselAnalysisResult getLatestByMmsi(String mmsi) { + return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi) + .orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi)); + } + + /** + * 특정 선박 분석 이력 (시간 범위). + */ + public List getHistory(String mmsi, int hours) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after); + } + + /** + * 다크 베셀 목록 (최신 분석, MMSI 중복 제거). + */ + public Page getDarkVessels(int hours, Pageable pageable) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findLatestDarkVessels(after, pageable); + } + + /** + * 환적 의심 목록 (최신 분석, MMSI 중복 제거). + */ + public Page getTransshipSuspects(int hours, Pageable pageable) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findLatestTransshipSuspects(after, pageable); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java index dd26dfc..92fefb8 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java @@ -32,9 +32,10 @@ public class EnforcementController { @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") public Page listRecords( @RequestParam(required = false) String violationType, + @RequestParam(required = false) String vesselMmsi, Pageable pageable ) { - return service.listRecords(violationType, pageable); + return service.listRecords(violationType, vesselMmsi, pageable); } /** diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java index 27464be..cf40432 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java @@ -32,7 +32,10 @@ public class EnforcementService { // 단속 이력 // ======================================================================== - public Page listRecords(String violationType, Pageable pageable) { + public Page listRecords(String violationType, String vesselMmsi, Pageable pageable) { + if (vesselMmsi != null && !vesselMmsi.isBlank()) { + return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable); + } if (violationType != null && !violationType.isBlank()) { return recordRepository.findByViolationType(violationType, pageable); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java index 749259e..541e34a 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java @@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface EnforcementRecordRepository extends JpaRepository { Page findAllByOrderByEnforcedAtDesc(Pageable pageable); Page findByViolationType(String violationType, Pageable pageable); + Page findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java index cbef145..0292096 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java @@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes; import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.Map; import java.util.UUID; /** @@ -93,6 +94,10 @@ public class PredictionEvent { @Column(name = "dedup_key", length = 200) private String dedupKey; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "features", columnDefinition = "jsonb") + private Map features; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; diff --git a/backend/src/main/resources/db/migration/V018__prediction_event_features.sql b/backend/src/main/resources/db/migration/V018__prediction_event_features.sql new file mode 100644 index 0000000..9ec3322 --- /dev/null +++ b/backend/src/main/resources/db/migration/V018__prediction_event_features.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V018: prediction_events에 features JSONB 컬럼 추가 +-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을 +-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다. +-- ============================================================ + +ALTER TABLE kcg.prediction_events + ADD COLUMN IF NOT EXISTS features JSONB; + +COMMENT ON COLUMN kcg.prediction_events.features IS + '분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)'; diff --git a/prediction/output/event_generator.py b/prediction/output/event_generator.py index e1ad2d4..3b1f5d0 100644 --- a/prediction/output/event_generator.py +++ b/prediction/output/event_generator.py @@ -6,6 +6,7 @@ dedup: 동일 mmsi + category + 윈도우 내 중복 방지. """ import hashlib +import json import logging from datetime import datetime, timedelta, timezone from typing import Optional @@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict: event_uid = _make_event_uid(now, seq) seq += 1 + # features 추출: 이벤트에 연관된 핵심 특성만 저장 + raw_features = result.get('features') + features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None + events_to_insert.append(( event_uid, now, # occurred_at @@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict: result.get('confidence') or result.get('risk_score', 0) / 100.0, 'NEW', # status dedup_key, + features_json, )) generated += 1 # break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성 @@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict: f"""INSERT INTO {EVENTS_TABLE} (event_uid, occurred_at, level, category, title, detail, vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn, - source_type, source_ref_id, ai_confidence, status, dedup_key) + source_type, source_ref_id, ai_confidence, status, dedup_key, features) VALUES %s ON CONFLICT (event_uid) DO NOTHING""", events_to_insert, -- 2.45.2 From 0679c04bfe9068d6d6b8c812969bfff5b842f2ab Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 10:50:31 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=202=20?= =?UTF-8?q?=E2=80=94=20EventList=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20+=20MMSI=20=EB=A7=81=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventList 인라인 액션 버튼 4종 추가 (확인/선박상세/단속등록/오탐) - 확인(ACK): NEW 상태 이벤트만 활성, ackEvent API 연동 - 선박 상세: /vessel/{mmsi} 네비게이션 - 단속 등록: createEnforcementRecord API → 이벤트 RESOLVED 자동 전환 - 오탐 처리: updateEventStatus(FALSE_POSITIVE) 연동 - MMSI → VesselDetail 링크 3개 화면 적용 - EventList: MMSI 컬럼 클릭 → /vessel/{mmsi} - DarkVesselDetection: MMSI 컬럼 클릭 → /vessel/{mmsi} - EnforcementHistory: 대상 선박 컬럼 클릭 → /vessel/{mmsi} - PredictionEvent 타입에 features 필드 추가 (dark_tier, transship_score 등) - analysisApi.ts 서비스 신규 생성 (직접 조회 API 5개 연동) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../detection/DarkVesselDetection.tsx | 12 +- .../enforcement/EnforcementHistory.tsx | 27 ++++- .../src/features/enforcement/EventList.tsx | 109 ++++++++++++++++- frontend/src/services/analysisApi.ts | 113 ++++++++++++++++++ frontend/src/services/event.ts | 9 ++ 5 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 frontend/src/services/analysisApi.ts diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index ccd8568..508dae9 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; @@ -70,13 +71,22 @@ export function DarkVesselDetection() { const { t } = useTranslation('detection'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); + const navigate = useNavigate(); const cols: DataColumn[] = useMemo(() => [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, - { key: 'mmsi', label: 'MMSI', width: '100px', render: v => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => { + const mmsi = v as string; + return ( + + ); + } }, { key: 'flag', label: '국적', width: '50px' }, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, diff --git a/frontend/src/features/enforcement/EnforcementHistory.tsx b/frontend/src/features/enforcement/EnforcementHistory.tsx index 79838e9..95fc534 100644 --- a/frontend/src/features/enforcement/EnforcementHistory.tsx +++ b/frontend/src/features/enforcement/EnforcementHistory.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer, PageHeader } from '@shared/components/layout'; @@ -18,6 +19,7 @@ interface Record { date: string; zone: string; vessel: string; + mmsi: string; violation: string; action: string; aiMatch: string; @@ -29,7 +31,8 @@ export function EnforcementHistory() { const { t } = useTranslation('enforcement'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); - const { records, loading, error, load } = useEnforcementStore(); + const navigate = useNavigate(); + const { records, rawRecords, loading, error, load } = useEnforcementStore(); const cols: DataColumn[] = useMemo(() => [ { @@ -55,9 +58,20 @@ export function EnforcementHistory() { key: 'vessel', label: '대상 선박', sortable: true, - render: (v) => ( - {v as string} - ), + render: (_v, row) => { + const mmsi = row.mmsi; + const vessel = row.vessel as string; + if (mmsi && mmsi !== '-') { + return ( + + ); + } + return {vessel}; + }, }, { key: 'violation', @@ -119,7 +133,10 @@ export function EnforcementHistory() { load(); }, [load]); - const DATA: Record[] = records as Record[]; + const DATA: Record[] = records.map((r, idx) => ({ + ...r, + mmsi: rawRecords[idx]?.vesselMmsi ?? '-', + })) as Record[]; return ( diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 45e9dde..785a872 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Badge } from '@shared/components/ui/badge'; import { Button } from '@shared/components/ui/button'; @@ -9,8 +10,11 @@ import { FileUpload } from '@shared/components/common/FileUpload'; import { AlertTriangle, Eye, Anchor, Radar, Crosshair, Filter, Upload, X, Loader2, + CheckCircle, Ship, Shield, Ban, } from 'lucide-react'; import { useEventStore } from '@stores/eventStore'; +import { ackEvent, updateEventStatus } from '@/services/event'; +import { createEnforcementRecord } from '@/services/enforcement'; import { formatDateTime } from '@shared/utils/dateFormat'; import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses'; @@ -27,6 +31,7 @@ type AlertLevel = AlertLevelType; interface EventRow { id: string; + _eventId: number; time: string; level: AlertLevel; type: string; @@ -45,14 +50,55 @@ export function EventList() { const { t } = useTranslation('enforcement'); const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); + const navigate = useNavigate(); const { events: storeEvents, + rawEvents, stats, loading, error, load, loadStats, } = useEventStore(); + const [actionLoading, setActionLoading] = useState(null); + + const handleAck = useCallback(async (eventId: number) => { + setActionLoading(eventId); + try { + await ackEvent(eventId); + load({ level: '' }); + } finally { + setActionLoading(null); + } + }, [load]); + + const handleFalsePositive = useCallback(async (eventId: number) => { + setActionLoading(eventId); + try { + await updateEventStatus(eventId, 'FALSE_POSITIVE', '오탐 처리'); + load({ level: '' }); + } finally { + setActionLoading(null); + } + }, [load]); + + const handleCreateEnforcement = useCallback(async (row: EventRow) => { + setActionLoading(row._eventId); + try { + await createEnforcementRecord({ + eventId: row._eventId, + enforcedAt: new Date().toISOString(), + vesselMmsi: row.mmsi !== '-' ? row.mmsi : undefined, + vesselName: row.vesselName !== '-' ? row.vesselName : undefined, + zoneCode: row.area !== '-' ? row.area : undefined, + violationType: row.type, + action: 'PATROL_DISPATCH', + }); + load({ level: '' }); + } finally { + setActionLoading(null); + } + }, [load]); const columns: DataColumn[] = useMemo(() => [ { @@ -83,12 +129,24 @@ export function EventList() { render: (val) => {val as string}, }, { key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px', - render: (val) => {val as string}, + render: (_val, row) => { + const mmsi = row.mmsi; + if (!mmsi || mmsi === '-') return -; + return ( + + ); + }, }, { key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true }, { key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' }, { - key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true, + key: 'status', label: '처리상태', minWidth: '70px', maxWidth: '100px', sortable: true, render: (val) => { const s = val as string; return ( @@ -98,8 +156,46 @@ export function EventList() { ); }, }, - { key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' }, - ], [tc, lang]); + { + key: '_eventId', label: '액션', minWidth: '120px', maxWidth: '180px', + render: (_val, row) => { + const eid = row._eventId; + const isNew = row.status === 'NEW'; + const isActionable = row.status !== 'RESOLVED' && row.status !== 'FALSE_POSITIVE'; + const busy = actionLoading === eid; + return ( +
+ {isNew && ( + + )} + + {isActionable && ( + <> + + + + )} +
+ ); + }, + }, + ], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]); const [levelFilter, setLevelFilter] = useState(''); const [showUpload, setShowUpload] = useState(false); @@ -114,9 +210,10 @@ export function EventList() { fetchData(); }, [fetchData]); - // store events -> EventRow 변환 - const EVENTS: EventRow[] = storeEvents.map((e) => ({ + // store events -> EventRow 변환 (rawEvents에서 numeric id 참조) + const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({ id: e.id, + _eventId: rawEvents[idx]?.id ?? 0, time: e.time, level: e.level as AlertLevel, type: e.type, diff --git a/frontend/src/services/analysisApi.ts b/frontend/src/services/analysisApi.ts new file mode 100644 index 0000000..18691a3 --- /dev/null +++ b/frontend/src/services/analysisApi.ts @@ -0,0 +1,113 @@ +/** + * vessel_analysis_results 직접 조회 API 서비스. + * 백엔드 /api/analysis/* 엔드포인트 연동. + */ + +const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; + +export interface VesselAnalysis { + id: number; + mmsi: string; + analyzedAt: string; + vesselType: string | null; + confidence: number | null; + fishingPct: number | null; + season: string | null; + lat: number | null; + lon: number | null; + zoneCode: string | null; + distToBaselineNm: number | null; + activityState: string | null; + isDark: boolean | null; + gapDurationMin: number | null; + darkPattern: string | null; + spoofingScore: number | null; + speedJumpCount: number | null; + transshipSuspect: boolean | null; + transshipPairMmsi: string | null; + transshipDurationMin: number | null; + fleetClusterId: number | null; + fleetRole: string | null; + fleetIsLeader: boolean | null; + riskScore: number | null; + riskLevel: string | null; + gearCode: string | null; + gearJudgment: string | null; + permitStatus: string | null; + features: Record | null; +} + +export interface AnalysisPageResponse { + content: VesselAnalysis[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +/** 분석 결과 목록 조회 */ +export async function getAnalysisVessels(params?: { + mmsi?: string; + zoneCode?: string; + riskLevel?: string; + isDark?: boolean; + hours?: number; + page?: number; + size?: number; +}): Promise { + const query = new URLSearchParams(); + if (params?.mmsi) query.set('mmsi', params.mmsi); + if (params?.zoneCode) query.set('zoneCode', params.zoneCode); + if (params?.riskLevel) query.set('riskLevel', params.riskLevel); + if (params?.isDark != null) query.set('isDark', String(params.isDark)); + query.set('hours', String(params?.hours ?? 1)); + query.set('page', String(params?.page ?? 0)); + query.set('size', String(params?.size ?? 50)); + const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +/** 특정 선박 최신 분석 결과 */ +export async function getAnalysisLatest(mmsi: string): Promise { + const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +/** 특정 선박 분석 이력 */ +export async function getAnalysisHistory(mmsi: string, hours = 24): Promise { + const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +/** 다크 베셀 목록 */ +export async function getDarkVessels(params?: { + hours?: number; + page?: number; + size?: number; +}): Promise { + const query = new URLSearchParams(); + query.set('hours', String(params?.hours ?? 1)); + query.set('page', String(params?.page ?? 0)); + query.set('size', String(params?.size ?? 50)); + const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +/** 환적 의심 목록 */ +export async function getTransshipSuspects(params?: { + hours?: number; + page?: number; + size?: number; +}): Promise { + const query = new URLSearchParams(); + query.set('hours', String(params?.hours ?? 1)); + query.set('page', String(params?.page ?? 0)); + query.set('size', String(params?.size ?? 50)); + const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} diff --git a/frontend/src/services/event.ts b/frontend/src/services/event.ts index a406b8f..d11dcda 100644 --- a/frontend/src/services/event.ts +++ b/frontend/src/services/event.ts @@ -30,6 +30,15 @@ export interface PredictionEvent { resolvedAt: string | null; resolutionNote: string | null; createdAt: string; + features?: { + dark_suspicion_score?: number; + dark_tier?: string; + dark_patterns?: string[]; + dark_history_7d?: number; + transship_tier?: string; + transship_score?: number; + [key: string]: unknown; + } | null; } export interface EventPageResponse { -- 2.45.2 From 1940caf73b2cb9bc679009d88758ad00ec52e3ef Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 11:02:46 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=203=20?= =?UTF-8?q?=E2=80=94=20VesselDetail=20=EA=B0=95=ED=99=94=20+=20DarkVessel?= =?UTF-8?q?=20prediction=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VesselDetail: - iran proxy → prediction 직접 API 전환 (getAnalysisLatest/getAnalysisHistory) - dark 패턴 시각화: dark_tier Badge, 의심점수 바, dark_patterns 태그, 7일 반복 횟수 - 환적 의심 분석 섹션 추가 (transship_tier, transship_score) - 24h AIS 수신 이력 타임라인 그래프 (시간대별 수신/소실 막대) - 단속 이력 탭 신설 (GET /api/enforcement/records?vesselMmsi) - 지도 중심좌표를 분석 결과의 lat/lon으로 자동 설정 - 위험도 점수 표시 0~100 직접 사용 (iran proxy의 0~1 변환 제거) DarkVesselDetection: - iran proxy → getDarkVessels() 직접 API 전환 - derivePattern() 제거 → features.dark_tier/dark_suspicion_score/dark_patterns 직접 표시 - tier 기반 KPI 카드 (CRITICAL/HIGH/WATCH) + 클릭 필터 - 의심 점수 내림차순 정렬 (가장 의심스러운 순) - tier별 필터 셀렉트 추가 - 지도 범례: tier 기반 색상 enforcement.ts: getEnforcementRecords에 vesselMmsi 파라미터 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../detection/DarkVesselDetection.tsx | 277 ++++++++------- frontend/src/features/vessel/VesselDetail.tsx | 317 ++++++++++++------ frontend/src/services/enforcement.ts | 2 + 3 files changed, 369 insertions(+), 227 deletions(-) diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 508dae9..815779f 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -3,44 +3,33 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Select } from '@shared/components/ui/select'; import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react'; +import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; -import { - fetchVesselAnalysis, - filterDarkVessels, - type VesselAnalysisItem, -} from '@/services/vesselAnalysisApi'; +import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; -import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns'; -import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses'; +import { getRiskIntent } from '@shared/constants/statusIntent'; import { useSettingsStore } from '@stores/settingsStore'; -/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ +/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */ -interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; } - -const GAP_FULL_BLOCK_MIN = 1440; -const GAP_LONG_LOSS_MIN = 60; -const SPOOFING_THRESHOLD = 0.7; - -function derivePattern(item: VesselAnalysisItem): string { - const { gapDurationMin } = item.algorithms.darkVessel; - const { spoofingScore } = item.algorithms.gpsSpoofing; - if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단'; - if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심'; - if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실'; - return '신호 간헐송출'; -} - -function deriveStatus(item: VesselAnalysisItem): string { - const { score } = item.algorithms.riskScore; - if (score >= 80) return '추적중'; - if (score >= 50) return '감시중'; - if (score >= 30) return '확인중'; - return '정상'; +interface Suspect { + id: string; + mmsi: string; + name: string; + flag: string; + darkTier: string; + darkScore: number; + darkPatterns: string; + risk: number; + gap: number; + lastAIS: string; + lat: number; + lng: number; + [key: string]: unknown; } function deriveFlag(mmsi: string): string { @@ -49,21 +38,32 @@ function deriveFlag(mmsi: string): string { return '미상'; } -function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect { - const risk = item.algorithms.riskScore.score; - const status = deriveStatus(item); +const TIER_HEX: Record = { + CRITICAL: '#ef4444', + HIGH: '#f97316', + WATCH: '#eab308', + NONE: '#6b7280', +}; + +function mapToSuspect(v: VesselAnalysis, idx: number): Suspect { + const feat = v.features ?? {}; + const darkTier = (feat.dark_tier as string) ?? 'NONE'; + const darkScore = (feat.dark_suspicion_score as number) ?? 0; + const patterns = (feat.dark_patterns as string[]) ?? []; + return { id: `DV-${String(idx + 1).padStart(3, '0')}`, - mmsi: item.mmsi, - name: item.classification.vesselType || item.mmsi, - flag: deriveFlag(item.mmsi), - pattern: derivePattern(item), - risk, - lastAIS: formatDateTime(item.timestamp), - status, - label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-', - lat: 0, - lng: 0, + mmsi: v.mmsi, + name: v.vesselType || v.mmsi, + flag: deriveFlag(v.mmsi), + darkTier, + darkScore, + darkPatterns: patterns.join(', ') || '-', + risk: v.riskScore ?? 0, + gap: v.gapDurationMin ?? 0, + lastAIS: formatDateTime(v.analyzedAt), + lat: v.lat ?? 0, + lng: v.lon ?? 0, }; } @@ -73,32 +73,51 @@ export function DarkVesselDetection() { const lang = useSettingsStore((s) => s.language); const navigate = useNavigate(); - const cols: DataColumn[] = useMemo(() => [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, - render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, - { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, - { key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => { - const mmsi = v as string; - return ( - - ); - } }, - { key: 'flag', label: '국적', width: '50px' }, - { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, - render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, - { key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => {v as string} }, - { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => {getVesselSurveillanceLabel(v as string, tc, lang)} }, - { key: 'label', label: '라벨', width: '60px', align: 'center', - render: v => { const l = v as string; return l === '-' ? : {l}; } }, - ], [tc, lang]); + const [tierFilter, setTierFilter] = useState(''); - const [darkItems, setDarkItems] = useState([]); - const [serviceAvailable, setServiceAvailable] = useState(true); + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', + render: (v) => {v as string} }, + { key: 'darkTier', label: '등급', width: '80px', sortable: true, + render: (v) => { + const tier = v as string; + return {tier}; + } }, + { key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true, + render: (v) => { + const n = v as number; + return = 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}; + } }, + { key: 'name', label: '선박 유형', sortable: true, + render: (v) => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', + render: (v) => { + const mmsi = v as string; + return ( + + ); + } }, + { key: 'flag', label: '국적', width: '50px' }, + { key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true, + render: (v) => { + const min = v as number; + return {min > 0 ? `${min}분` : '-'}; + } }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: (v) => { + const n = v as number; + return = 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; + } }, + { key: 'darkPatterns', label: '의심 패턴', minWidth: '120px', + render: (v) => {v as string} }, + { key: 'lastAIS', label: '분석시각', width: '90px', + render: (v) => {v as string} }, + ], [tc, lang, navigate]); + + const [rawData, setRawData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -106,12 +125,10 @@ export function DarkVesselDetection() { setLoading(true); setError(''); try { - const res = await fetchVesselAnalysis(); - setServiceAvailable(res.serviceAvailable); - setDarkItems(filterDarkVessels(res.items)); + const res = await getDarkVessels({ hours: 1, size: 500 }); + setRawData(res.content); } catch (e: unknown) { setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); - setServiceAvailable(false); } finally { setLoading(false); } @@ -119,15 +136,25 @@ export function DarkVesselDetection() { useEffect(() => { loadData(); }, [loadData]); - const DATA: Suspect[] = useMemo( - () => darkItems.map((item, i) => mapItemToSuspect(item, i)), - [darkItems], - ); + const DATA: Suspect[] = useMemo(() => { + let items = rawData.map((v, i) => mapToSuspect(v, i)); + if (tierFilter) { + items = items.filter((d) => d.darkTier === tierFilter); + } + // 의심 점수 내림차순 정렬 + return items.sort((a, b) => b.darkScore - a.darkScore); + }, [rawData, tierFilter]); - const avgRisk = useMemo( - () => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0, - [DATA], - ); + // KPI 카운트 + const tierCounts = useMemo(() => { + const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE'); + return { + total: all.length, + CRITICAL: all.filter((t) => t === 'CRITICAL').length, + HIGH: all.filter((t) => t === 'HIGH').length, + WATCH: all.filter((t) => t === 'WATCH').length, + }; + }, [rawData]); const mapRef = useRef(null); @@ -135,21 +162,18 @@ export function DarkVesselDetection() { ...STATIC_LAYERS, createRadiusLayer( 'dv-radius', - DATA.filter(d => d.risk > 80).map(d => ({ - lat: d.lat, - lng: d.lng, - radius: 10000, - color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', + DATA.filter((d) => d.darkScore >= 70).map((d) => ({ + lat: d.lat, lng: d.lng, radius: 10000, + color: TIER_HEX[d.darkTier] || '#ef4444', })), 0.08, ), createMarkerLayer( 'dv-markers', - DATA.map(d => ({ - lat: d.lat, - lng: d.lng, - color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', - radius: d.risk > 80 ? 1200 : 800, + DATA.filter((d) => d.lat !== 0).map((d) => ({ + lat: d.lat, lng: d.lng, + color: TIER_HEX[d.darkTier] || '#6b7280', + radius: d.darkScore >= 70 ? 1200 : 800, label: `${d.id} ${d.name}`, } as MarkerData)), ), @@ -164,15 +188,20 @@ export function DarkVesselDetection() { iconColor="text-red-400" title={t('darkVessel.title')} description={t('darkVessel.desc')} + actions={ +
+ + +
+ } /> - {!serviceAvailable && ( -
- - iran 분석 서비스 미연결 - 실시간 Dark Vessel 데이터를 불러올 수 없습니다 -
- )} - {error &&
에러: {error}
} {loading && ( @@ -181,49 +210,51 @@ export function DarkVesselDetection() { )} + {/* KPI — tier 기반 */}
{[ - { l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle }, - { l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff }, - { l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio }, - { l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag }, - ].map(k => ( -
- {k.v}{k.l} + { l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' }, + { l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' }, + { l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' }, + { l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' }, + ].map((k) => ( +
setTierFilter(k.filter)} + className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${ + tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border' + }`}> + + {k.v} + {k.l}
))}
- + {/* 탐지 위치 지도 */} - {/* 범례 */} + {/* 범례 — tier 기반 */}
-
탐지 패턴
+
Dark Tier
- {(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => { - const meta = getDarkVesselPatternMeta(p); - if (!meta) return null; - return ( -
-
- {meta.fallback.ko} -
- ); - })} -
-
-
EEZ
-
NLL
+ {(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => ( +
+
+ {tier} +
+ ))}
- {DATA.filter(d => d.risk > 80).length}척 - 고위험 Dark Vessel 탐지 + {tierCounts.CRITICAL}척 + CRITICAL Dark Vessel
diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index b97d198..cd76f31 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -6,16 +6,15 @@ import { Search, Ship, AlertTriangle, Radar, MapPin, Printer, Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain, - Loader2, WifiOff, ShieldAlert, + Loader2, ShieldAlert, Shield, EyeOff, FileText, } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map'; -import { - fetchVesselAnalysis, - type VesselAnalysisItem, -} from '@/services/vesselAnalysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; +import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi'; +import { getEnforcementRecords, type EnforcementRecord } from '@/services/enforcement'; import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { getRiskIntent } from '@shared/constants/statusIntent'; import { useSettingsStore } from '@stores/settingsStore'; import { useTranslation } from 'react-i18next'; @@ -57,27 +56,66 @@ const RIGHT_TOOLS = [ { icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' }, ]; +// ─── 24h AIS 수신 막대 ─────────────── +function AisTimeline({ history }: { history: VesselAnalysis[] }) { + // 최근 24시간을 1시간 단위 슬롯으로 분할 + const now = Date.now(); + const slots = Array.from({ length: 24 }, (_, i) => { + const slotStart = now - (24 - i) * 3600_000; + const slotEnd = slotStart + 3600_000; + const hasData = history.some((h) => { + const t = new Date(h.analyzedAt).getTime(); + return t >= slotStart && t < slotEnd; + }); + return { hour: new Date(slotStart).getHours(), hasData }; + }); + const received = slots.filter((s) => s.hasData).length; + + return ( +
+
+ 24h AIS 수신 이력 + {received}/24h ({Math.round(received / 24 * 100)}%) +
+
+ {slots.map((s, i) => ( +
+ ))} +
+
+ -24h + 현재 +
+
+ ); +} + // ─── 메인 컴포넌트 ──────────────────── export function VesselDetail() { const { id: mmsiParam } = useParams<{ id: string }>(); // 데이터 상태 - const [vessel, setVessel] = useState(null); + const [analysis, setAnalysis] = useState(null); + const [history, setHistory] = useState([]); const [permit, setPermit] = useState(null); const [events, setEvents] = useState([]); - const [serviceAvailable, setServiceAvailable] = useState(true); + const [enforcements, setEnforcements] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // 검색 상태 (검색 패널용) + // 검색 상태 const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? ''); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const mapRef = useRef(null); - // 데이터 로드 + // 데이터 로드 — prediction 직접 API useEffect(() => { if (!mmsiParam) { setLoading(false); @@ -92,27 +130,21 @@ export function VesselDetail() { setError(null); try { - const [analysisRes, permitRes, eventsRes] = await Promise.all([ - fetchVesselAnalysis().catch(() => null), + const [analysisRes, historyRes, permitRes, eventsRes, enfRes] = await Promise.all([ + getAnalysisLatest(mmsiParam).catch(() => null), + getAnalysisHistory(mmsiParam, 24).catch(() => []), fetchVesselPermit(mmsiParam), getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), + getEnforcementRecords({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null), ]); if (cancelled) return; - if (!analysisRes) { - setServiceAvailable(false); - setPermit(permitRes); - setEvents(eventsRes?.content ?? []); - setLoading(false); - return; - } - - setServiceAvailable(analysisRes.serviceAvailable); - const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null; - setVessel(found); + setAnalysis(analysisRes); + setHistory(historyRes); setPermit(permitRes); setEvents(eventsRes?.content ?? []); + setEnforcements(enfRes?.content ?? []); } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : '데이터 로드 실패'); @@ -127,24 +159,17 @@ export function VesselDetail() { }, [mmsiParam]); // 지도 레이어 - const buildLayers = useCallback(() => { - const layers = [ - ...STATIC_LAYERS, - createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ - name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, - })), 80000, 0.05), - ...DEPTH_CONTOURS.map((contour, i) => - createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { - color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], - }) - ), - ]; - - // 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가 - // vessel-analysis에는 좌표가 없으므로 마커 생략 - - return layers; - }, []); + const buildLayers = useCallback(() => [ + ...STATIC_LAYERS, + createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({ + name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000, + })), 80000, 0.05), + ...DEPTH_CONTOURS.map((contour, i) => + createPolylineLayer(`depth-${i}`, contour.points as [number, number][], { + color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4], + }) + ), + ], []); useMapLayers(mapRef, buildLayers, []); @@ -152,11 +177,20 @@ export function VesselDetail() { const { t: tc } = useTranslation('common'); const lang = useSettingsStore((s) => s.language); - // 위험도 점수 바 - const riskScore = vessel?.algorithms.riskScore.score ?? 0; - const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel; + // 위험도 + const riskScore = analysis?.riskScore ?? 0; + const riskLevel = (analysis?.riskLevel ?? 'LOW') as AlertLevel; const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW; + // features 추출 + const features = analysis?.features ?? {}; + const darkTier = features.dark_tier as string | undefined; + const darkScore = features.dark_suspicion_score as number | undefined; + const darkPatterns = features.dark_patterns as string[] | undefined; + const darkHistory7d = features.dark_history_7d as number | undefined; + const transshipTier = features.transship_tier as string | undefined; + const transshipScore = features.transship_score as number | undefined; + return ( @@ -201,16 +235,6 @@ export function VesselDetail() {
)} - {!serviceAvailable && !loading && !error && ( -
-
- - 분석 서비스 오프라인 -
-

iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.

-
- )} - {/* 선박 정보 */} {!loading && !error && (
@@ -223,17 +247,17 @@ export function VesselDetail() {
{[ ['MMSI', mmsiParam ?? '-'], - ['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'], + ['선박 유형', analysis?.vesselType ?? permit?.vesselType ?? '-'], ['국적', permit?.flagCountry ?? '-'], ['선명', permit?.vesselName ?? '-'], ['선명(중문)', permit?.vesselNameCn ?? '-'], ['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'], ['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'], ['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'], - ['구역', vessel?.algorithms.location.zone ?? '-'], - ['기선거리', vessel?.algorithms.location.distToBaselineNm != null - ? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'], - ['시즌', vessel?.classification.season ?? '-'], + ['구역', analysis?.zoneCode ?? '-'], + ['기선거리', analysis?.distToBaselineNm != null + ? `${Number(analysis.distToBaselineNm).toFixed(1)}nm` : '-'], + ['시즌', analysis?.season ?? '-'], ].map(([k, v], i) => (
{k} @@ -268,8 +292,8 @@ export function VesselDetail() {
)} - {/* AI 분석 결과 */} - {vessel && ( + {/* AI 분석 결과 — prediction 직접 데이터 */} + {analysis && (
@@ -286,14 +310,14 @@ export function VesselDetail() {
- {Math.round(riskScore * 100)} + {riskScore} /100
@@ -301,21 +325,17 @@ export function VesselDetail() { {/* 알고리즘 상세 */}
{[ - ['활동 상태', vessel.algorithms.activity.state], - ['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)], - ['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)], - ['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'], - ['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0 - ? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'], - ['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)], - ['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`], - ['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`], - ['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`], - ['선단 역할', vessel.algorithms.fleetRole.role], - ['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'], - ['환적 상대', vessel.algorithms.transship.pairMmsi || '-'], - ['환적 시간', vessel.algorithms.transship.durationMin > 0 - ? `${vessel.algorithms.transship.durationMin}분` : '-'], + ['활동 상태', analysis.activityState ?? '-'], + ['다크베셀', analysis.isDark ? '예 (의심)' : '아니오'], + ['AIS 공백', analysis.gapDurationMin != null && analysis.gapDurationMin > 0 + ? `${analysis.gapDurationMin}분` : '-'], + ['스푸핑 점수', analysis.spoofingScore != null ? Number(analysis.spoofingScore).toFixed(2) : '-'], + ['속도 점프', analysis.speedJumpCount != null ? `${analysis.speedJumpCount}회` : '-'], + ['선단 역할', analysis.fleetRole ?? '-'], + ['환적 의심', analysis.transshipSuspect ? '예' : '아니오'], + ['환적 상대', analysis.transshipPairMmsi || '-'], + ['환적 시간', analysis.transshipDurationMin != null && analysis.transshipDurationMin > 0 + ? `${analysis.transshipDurationMin}분` : '-'], ].map(([k, v], i) => (
{k} @@ -329,8 +349,68 @@ export function VesselDetail() {
)} + {/* Dark 패턴 시각화 — features 기반 */} + {analysis?.isDark && darkTier && ( +
+
+ + Dark Vessel 분석 +
+
+ {/* Dark tier + score */} +
+ {darkTier} + {darkScore ?? 0}점 + {darkHistory7d != null && darkHistory7d > 0 && ( + 7일간 {darkHistory7d}회 반복 + )} +
+ {/* 의심 점수 바 */} +
+
= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308', + }} + /> +
+ {/* Dark 패턴 태그 */} + {darkPatterns && darkPatterns.length > 0 && ( +
+ {darkPatterns.map((p) => ( + {p} + ))} +
+ )} +
+
+ )} + + {/* 환적 분석 — features 기반 */} + {analysis?.transshipSuspect && transshipTier && ( +
+
+ + 환적 의심 분석 +
+
+ {transshipTier} + {transshipScore ?? 0}점 + 상대: {analysis.transshipPairMmsi ?? '-'} +
+
+ )} + + {/* 24h AIS 수신 이력 */} + {history.length > 0 && ( +
+ +
+ )} + {/* 관련 이벤트 이력 */} -
+
관련 이벤트 이력 @@ -340,27 +420,53 @@ export function VesselDetail() {
관련 이벤트가 없습니다.
) : (
- {events.map((evt) => { - return ( -
-
- - {getAlertLevelLabel(evt.level, tc, lang)} - - {evt.title} - - {evt.status} - -
-
- {evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''} -
- {evt.detail && ( -
{evt.detail}
- )} + {events.map((evt) => ( +
+
+ + {getAlertLevelLabel(evt.level, tc, lang)} + + {evt.title} + + {evt.status} +
- ); - })} +
+ {formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''} +
+
+ ))} +
+ )} +
+ + {/* 단속 이력 */} +
+
+ + 단속 이력 + {enforcements.length}건 +
+ {enforcements.length === 0 ? ( +
단속 이력이 없습니다.
+ ) : ( +
+ {enforcements.map((enf) => ( +
+
+ {enf.enfUid} + + {enf.violationType ?? '단속'} + + + {enf.result ?? '-'} + +
+
+ {formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''} +
+
+ ))}
)}
@@ -376,7 +482,7 @@ export function VesselDetail() {
MMSI: {mmsiParam} - {vessel && ( + {analysis && ( 위험도: {getAlertLevelLabel(riskLevel, tc, lang)} @@ -387,8 +493,11 @@ export function VesselDetail() { @@ -397,15 +506,15 @@ export function VesselDetail() { 위도 - 34.5000 + {analysis?.lat?.toFixed(4) ?? '-'} 경도 - 126.5000 + {analysis?.lon?.toFixed(4) ?? '-'} - UTC + KST {formatDateTime(new Date())}
diff --git a/frontend/src/services/enforcement.ts b/frontend/src/services/enforcement.ts index 16475cd..a84b6ec 100644 --- a/frontend/src/services/enforcement.ts +++ b/frontend/src/services/enforcement.ts @@ -81,11 +81,13 @@ export interface EnforcementPlan { export async function getEnforcementRecords(params?: { violationType?: string; + vesselMmsi?: string; page?: number; size?: number; }): Promise> { const query = new URLSearchParams(); if (params?.violationType) query.set('violationType', params.violationType); + if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi); query.set('page', String(params?.page ?? 0)); query.set('size', String(params?.size ?? 20)); const res = await fetch(`${API_BASE}/enforcement/records?${query}`, { -- 2.45.2 From 6887a2b4fca188803b3b1721cbd9c0c56b42c1f4 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 11:32:03 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=204=20?= =?UTF-8?q?=E2=80=94=20Enforcement=20=EC=97=B0=EA=B3=84=20+=20admin=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=EA=B7=B8=EB=A3=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnforcementHistory: - eventId 역추적 컬럼 추가 (#{eventId} 클릭 → EventList 이동) - Record 인터페이스에 eventId 필드 추가 EnforcementPlan: - 미배정 CRITICAL 이벤트 패널 신설 (NEW 상태 CRITICAL 이벤트 표시) - getEvents(level=CRITICAL, status=NEW) 연동 MainLayout: - admin 메뉴 4개 서브그룹 분리 (AI 플랫폼/시스템 운영/사용자 관리/감사·보안) - NavDivider 타입 도입으로 그룹 내 소제목 라벨 렌더링 - 기존 RBAC 필터링 + collapsed 모드 호환 유지 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/app/layout/MainLayout.tsx | 44 ++++++++++++++----- .../enforcement/EnforcementHistory.tsx | 19 ++++++++ .../risk-assessment/EnforcementPlan.tsx | 44 +++++++++++++++++-- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 8159ce6..bb3559d 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -35,10 +35,12 @@ const AUTH_METHOD_LABELS: Record = { }; interface NavItem { to: string; icon: React.ElementType; labelKey: string; } -interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; } +interface NavDivider { dividerLabel: string; } +interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavDivider)[]; } type NavEntry = NavItem | NavGroup; const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry; +const isDivider = (item: NavItem | NavDivider): item is NavDivider => 'dividerLabel' in item; const NAV_ENTRIES: NavEntry[] = [ // ── 상황판·감시 ── @@ -82,16 +84,20 @@ const NAV_ENTRIES: NavEntry[] = [ { groupKey: 'group.admin', icon: Settings, items: [ + { dividerLabel: 'AI 플랫폼' }, { to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' }, { to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' }, { to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' }, { to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' }, - { to: '/external-service', icon: Globe, labelKey: 'nav.externalService' }, - { to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' }, + { dividerLabel: '시스템 운영' }, { to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' }, - { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, + { to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' }, + { to: '/external-service', icon: Globe, labelKey: 'nav.externalService' }, + { dividerLabel: '사용자 관리' }, { to: '/admin', icon: Settings, labelKey: 'nav.admin' }, { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, + { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, + { dividerLabel: '감사·보안' }, { to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' }, { to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' }, { to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' }, @@ -99,8 +105,10 @@ const NAV_ENTRIES: NavEntry[] = [ }, ]; -// getPageLabel용 flat 목록 -const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]); +// getPageLabel용 flat 목록 (divider 제외) +const NAV_ITEMS = NAV_ENTRIES.flatMap(e => + isGroup(e) ? e.items.filter((i): i is NavItem => !isDivider(i)) : [e] +); function formatRemaining(seconds: number) { const m = Math.floor(seconds / 60); @@ -255,11 +263,12 @@ export function MainLayout() {
))}
+ {/* 미배정 CRITICAL 이벤트 */} + {criticalEvents.length > 0 && ( + + +
+ + 미배정 CRITICAL 이벤트 + {criticalEvents.length}건 +
+
+ {criticalEvents.map((evt) => ( +
+ + {getAlertLevelLabel(evt.level, tc, lang)} + + {evt.title} + {formatDateTime(evt.occurredAt)} + {evt.vesselMmsi ?? '-'} +
+ ))} +
+
+
+ )} +
경보 임계값 설정
-- 2.45.2 From e401c07dd3e92f44cde547e8209b6d3e485f499c Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 11:43:18 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=205=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EB=8F=99=EA=B0=B1=EC=8B=A0=20+=20?= =?UTF-8?q?=EB=AA=A8=EC=84=A0=EC=B6=94=EB=A1=A0=20=EC=97=B0=EA=B2=B0=20+?= =?UTF-8?q?=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 갱신 (30초, 깜박임 없음): - eventStore: silentRefresh() 메서드 추가 (loading 상태 미변경, 데이터만 교체) - EventList: 30초 인터벌로 silentRefresh + loadStats 호출 - DarkVesselDetection: 30초 인터벌로 getDarkVessels silent 갱신 모선추론 자동 연결: - ParentReview CONFIRM → createLabelSession 자동 호출 (학습 데이터 수집 시작) - ParentReview REJECT → excludeForGroup 자동 호출 (잘못된 후보 재추론 방지) - 자동 연결 실패 시 리뷰 자체는 유지 (catch 무시) i18n (ko/en): - darkTier: CRITICAL/HIGH/WATCH/NONE 라벨 - transshipTier: CRITICAL/HIGH/WATCH 라벨 - adminSubGroup: AI 플랫폼/시스템 운영/사용자 관리/감사·보안 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../detection/DarkVesselDetection.tsx | 11 +++++++++ .../src/features/enforcement/EventList.tsx | 11 +++++++++ .../parent-inference/ParentReview.tsx | 24 +++++++++++++++++++ frontend/src/lib/i18n/locales/en/common.json | 17 +++++++++++++ frontend/src/lib/i18n/locales/ko/common.json | 17 +++++++++++++ frontend/src/stores/eventStore.ts | 19 +++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 815779f..7d716c9 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -136,6 +136,17 @@ export function DarkVesselDetection() { useEffect(() => { loadData(); }, [loadData]); + // 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체) + useEffect(() => { + const timer = setInterval(async () => { + try { + const res = await getDarkVessels({ hours: 1, size: 500 }); + setRawData(res.content); + } catch { /* silent */ } + }, 30_000); + return () => clearInterval(timer); + }, []); + const DATA: Suspect[] = useMemo(() => { let items = rawData.map((v, i) => mapToSuspect(v, i)); if (tierFilter) { diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 785a872..3aa1a4b 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -58,6 +58,7 @@ export function EventList() { loading, error, load, + silentRefresh, loadStats, } = useEventStore(); const [actionLoading, setActionLoading] = useState(null); @@ -210,6 +211,16 @@ export function EventList() { fetchData(); }, [fetchData]); + // 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용) + useEffect(() => { + const params = levelFilter ? { level: levelFilter } : undefined; + const timer = setInterval(() => { + silentRefresh(params); + loadStats(); + }, 30_000); + return () => clearInterval(timer); + }, [levelFilter, silentRefresh, loadStats]); + // store events -> EventRow 변환 (rawEvents에서 numeric id 참조) const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({ id: e.id, diff --git a/frontend/src/features/parent-inference/ParentReview.tsx b/frontend/src/features/parent-inference/ParentReview.tsx index 72c73f5..b8df06c 100644 --- a/frontend/src/features/parent-inference/ParentReview.tsx +++ b/frontend/src/features/parent-inference/ParentReview.tsx @@ -9,6 +9,8 @@ import { useAuth } from '@/app/auth/AuthContext'; import { fetchReviewList, reviewParent, + createLabelSession, + excludeForGroup, type ParentResolution, } from '@/services/parentInferenceApi'; import { formatDateTime } from '@shared/utils/dateFormat'; @@ -70,6 +72,28 @@ export function ParentReview() { selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined, comment: `${action} via UI`, }); + + // CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작) + if (action === 'CONFIRM') { + const mmsi = selectedMmsi || item.selectedParentMmsi; + if (mmsi) { + await createLabelSession(item.groupKey, item.subClusterId, { + labelParentMmsi: mmsi, + }).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ }); + } + } + + // REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지) + if (action === 'REJECT') { + const mmsi = item.selectedParentMmsi; + if (mmsi) { + await excludeForGroup(item.groupKey, item.subClusterId, { + excludedMmsi: mmsi, + reason: '운영자 거부', + }).catch(() => { /* Exclusion 실패는 무시 */ }); + } + } + await load(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'unknown'; diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index f3dbc55..6685d58 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -132,6 +132,23 @@ "INTERMITTENT": "Intermittent", "SPEED_ANOMALY": "Speed Anomaly" }, + "darkTier": { + "CRITICAL": "Intentional Loss (Critical)", + "HIGH": "Suspicious Loss", + "WATCH": "Under Watch", + "NONE": "Normal" + }, + "transshipTier": { + "CRITICAL": "Confirmed Transship", + "HIGH": "Suspected Transship", + "WATCH": "Under Watch" + }, + "adminSubGroup": { + "aiPlatform": "AI Platform", + "systemOps": "System Operations", + "userMgmt": "User Management", + "auditSecurity": "Audit & Security" + }, "userAccountStatus": { "ACTIVE": "Active", "PENDING": "Pending", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 9e68ca4..9539842 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -132,6 +132,23 @@ "INTERMITTENT": "신호 간헐송출", "SPEED_ANOMALY": "속도 이상" }, + "darkTier": { + "CRITICAL": "고의 소실 (위험)", + "HIGH": "의심 소실", + "WATCH": "관찰 대상", + "NONE": "정상" + }, + "transshipTier": { + "CRITICAL": "환적 확실", + "HIGH": "환적 의심", + "WATCH": "관찰 대상" + }, + "adminSubGroup": { + "aiPlatform": "AI 플랫폼", + "systemOps": "시스템 운영", + "userMgmt": "사용자 관리", + "auditSecurity": "감사·보안" + }, "userAccountStatus": { "ACTIVE": "활성", "PENDING": "승인 대기", diff --git a/frontend/src/stores/eventStore.ts b/frontend/src/stores/eventStore.ts index 8c85589..206b2e0 100644 --- a/frontend/src/stores/eventStore.ts +++ b/frontend/src/stores/eventStore.ts @@ -29,6 +29,8 @@ interface EventStore { loaded: boolean; /** API 호출 */ load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; + /** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */ + silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; loadStats: () => Promise; filterByLevel: (level: string | null) => LegacyEventRecord[]; } @@ -68,6 +70,23 @@ export const useEventStore = create((set, get) => ({ } }, + silentRefresh: async (params) => { + try { + const res = await getEvents(params); + const legacy = res.content.map(toLegacyEvent); + set({ + rawEvents: res.content, + events: legacy, + totalElements: res.totalElements, + totalPages: res.totalPages, + currentPage: res.number, + pageSize: res.size, + }); + } catch { + // silent: 에러 무시 — 다음 갱신에서 재시도 + } + }, + loadStats: async () => { try { const stats = await getEventStats(); -- 2.45.2 From d08d614b5fb52ae450c214e6ff678930a1ff373a Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 12:05:35 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix(frontend):=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=95=B4=EC=86=8C=20+=20system-flow=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20+=20V019=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=ED=8A=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /map-control labelKey nav.riskMap → nav.mapControl (위험도 지도 중복 해소) - i18n nav.mapControl 키 추가 (ko: 해역 관리, en: Map Control) - V019 마이그레이션: ai-operations:llm-ops 권한 트리 항목 추가 (PR #22 누락분) - system-flow 08-frontend.json: 누락 노드 14개 추가 - ui.map_control, ui.risk_map, ui.patrol_route, ui.fleet_optimization - ui.report_management, ui.external_service - ui.ai_model, ui.mlops, ui.llm_ops - ui.mobile_service, ui.ship_agent - ui.admin_panel, ui.permissions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../db/migration/V019__llm_ops_perm.sql | 16 ++ frontend/src/app/layout/MainLayout.tsx | 2 +- frontend/src/flow/manifest/08-frontend.json | 143 ++++++++++++++++++ frontend/src/lib/i18n/locales/en/common.json | 1 + frontend/src/lib/i18n/locales/ko/common.json | 1 + 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/resources/db/migration/V019__llm_ops_perm.sql diff --git a/backend/src/main/resources/db/migration/V019__llm_ops_perm.sql b/backend/src/main/resources/db/migration/V019__llm_ops_perm.sql new file mode 100644 index 0000000..a589b08 --- /dev/null +++ b/backend/src/main/resources/db/migration/V019__llm_ops_perm.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- V019: LLM 운영 페이지 권한 트리 항목 추가 +-- PR #22에서 추가된 /llm-ops 페이지에 대응하는 권한 리소스 +-- ============================================================ + +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('ai-operations:llm-ops', 'ai-operations', 'LLM 운영', 1, 35) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- ADMIN 역할에 자동 부여 +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'ai-operations:llm-ops', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index bb3559d..1ebb4ef 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -47,7 +47,7 @@ const NAV_ENTRIES: NavEntry[] = [ { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, { to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' }, - { to: '/map-control', icon: Map, labelKey: 'nav.riskMap' }, + { to: '/map-control', icon: Map, labelKey: 'nav.mapControl' }, // ── 위험도·단속 ── { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, { to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' }, diff --git a/frontend/src/flow/manifest/08-frontend.json b/frontend/src/flow/manifest/08-frontend.json index 3b6d315..3112d89 100644 --- a/frontend/src/flow/manifest/08-frontend.json +++ b/frontend/src/flow/manifest/08-frontend.json @@ -153,6 +153,50 @@ "status": "implemented", "file": "frontend/src/features/parent-inference/LabelSession.tsx" }, + { + "id": "ui.map_control", + "label": "해역 관리", + "shortDescription": "해역 구역 설정/관리 화면", + "stage": "UI", + "menu": "감시", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/surveillance/MapControl.tsx" + }, + { + "id": "ui.risk_map", + "label": "위험도 지도", + "shortDescription": "해역별 위험도 히트맵", + "stage": "UI", + "menu": "위험평가", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/risk-assessment/RiskMap.tsx" + }, + { + "id": "ui.patrol_route", + "label": "순찰경로 추천", + "shortDescription": "AI 기반 순찰 경로 최적화", + "stage": "UI", + "menu": "순찰", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/patrol/PatrolRoute.tsx" + }, + { + "id": "ui.fleet_optimization", + "label": "다함정 최적화", + "shortDescription": "다수 함정 배치 최적화", + "stage": "UI", + "menu": "순찰", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/patrol/FleetOptimization.tsx" + }, { "id": "ui.statistics", "label": "통계", @@ -164,6 +208,28 @@ "status": "implemented", "file": "frontend/src/features/statistics/Statistics.tsx" }, + { + "id": "ui.report_management", + "label": "보고서 관리", + "shortDescription": "보고서 생성/조회", + "stage": "UI", + "menu": "통계", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/statistics/ReportManagement.tsx" + }, + { + "id": "ui.external_service", + "label": "외부 서비스", + "shortDescription": "외부 연동 서비스 설정", + "stage": "UI", + "menu": "통계", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/statistics/ExternalService.tsx" + }, { "id": "ui.ai_alert", "label": "현장 AI 경보", @@ -185,5 +251,82 @@ "trigger": "on_demand", "status": "implemented", "file": "frontend/src/features/ai-operations/AIAssistant.tsx" + }, + { + "id": "ui.ai_model", + "label": "AI 모델관리", + "shortDescription": "AI 모델 배포/모니터링", + "stage": "UI", + "menu": "AI", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/ai-operations/AIModelManagement.tsx" + }, + { + "id": "ui.mlops", + "label": "MLOps", + "shortDescription": "ML 파이프라인 운영", + "stage": "UI", + "menu": "AI", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/ai-operations/MLOpsPage.tsx" + }, + { + "id": "ui.llm_ops", + "label": "LLM 운영", + "shortDescription": "LLM 모델 운영 관리", + "stage": "UI", + "menu": "AI", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/ai-operations/LLMOpsPage.tsx" + }, + { + "id": "ui.mobile_service", + "label": "모바일 서비스", + "shortDescription": "현장 모바일 서비스", + "stage": "UI", + "menu": "현장", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/field-ops/MobileService.tsx" + }, + { + "id": "ui.ship_agent", + "label": "함정 Agent", + "shortDescription": "함정 단말 에이전트", + "stage": "UI", + "menu": "현장", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/field-ops/ShipAgent.tsx" + }, + { + "id": "ui.admin_panel", + "label": "시스템 관리", + "shortDescription": "사용자/역할/권한 관리", + "stage": "UI", + "menu": "관리", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/admin/AdminPanel.tsx" + }, + { + "id": "ui.permissions", + "label": "권한 관리", + "shortDescription": "RBAC 트리 권한 매트릭스", + "stage": "UI", + "menu": "관리", + "kind": "ui", + "trigger": "on_demand", + "status": "implemented", + "file": "frontend/src/features/admin/AccessControl.tsx" } ] diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index 6685d58..0fb1bfd 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -3,6 +3,7 @@ "dashboard": "Dashboard", "monitoring": "Alert Monitor", "riskMap": "Risk Map", + "mapControl": "Map Control", "enforcementPlan": "Enforcement Plan", "darkVessel": "Dark Vessel", "gearDetection": "Gear Detection", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 9539842..d646cca 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -3,6 +3,7 @@ "dashboard": "종합 상황판", "monitoring": "경보 현황판", "riskMap": "위험도 지도", + "mapControl": "해역 관리", "enforcementPlan": "단속 계획", "darkVessel": "다크베셀 탐지", "gearDetection": "어구 탐지", -- 2.45.2 From 1147b96b004fc4e0cfe8c61021f6af03e1fe2119 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 12:17:17 +0900 Subject: [PATCH 07/10] =?UTF-8?q?docs:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EB=85=B8=ED=8A=B8=20[Unreleased]=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4ae4e7b..473277e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,40 @@ ## [Unreleased] +### 추가 +- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현 + - **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회 + - vessels 목록 (필터: mmsi, zone, riskLevel, isDark) + - vessels/{mmsi} 최신 분석 (features JSONB 포함) + - vessels/{mmsi}/history 분석 이력 (24h) + - dark 베셀 목록 (MMSI 중복 제거) + - transship 의심 목록 + - **EventList 인라인 액션 4종** — 확인(ACK)/선박상세/단속등록/오탐 처리 + - **MMSI → VesselDetail 링크** — EventList, DarkVessel, EnforcementHistory 3개 화면 + - **VesselDetail 전면 개편** — prediction 직접 API 전환, dark 패턴 시각화(tier/score/patterns), 환적 분석, 24h AIS 수신 타임라인, 단속 이력 탭 + - **DarkVesselDetection prediction 전환** — iran proxy 제거, tier 기반 KPI/필터/정렬 + - **EnforcementHistory eventId 역추적** — 단속→이벤트 역링크 + - **EnforcementPlan 미배정 CRITICAL 이벤트 패널** — NEW 상태 CRITICAL 이벤트 표시 + - **모선추론 자동 연결** — CONFIRM→LabelSession, REJECT→Exclusion 자동 호출 + - **30초 자동 갱신** — EventList, DarkVessel (silentRefresh 패턴, 깜박임 없음) +- **admin 메뉴 4개 서브그룹** — AI 플랫폼/시스템 운영/사용자 관리/감사·보안 +- **V018 마이그레이션** — prediction_events.features JSONB 컬럼 +- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목 +- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동) +- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등) + +### 변경 +- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장) +- **EnforcementController** vesselMmsi 필터 파라미터 추가 +- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가 + +### 수정 +- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리") +- system-flow 08-frontend.json 누락 노드 14개 추가 + +### 문서 +- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en) + ## [2026-04-09] ### 추가 -- 2.45.2 From 6fe7a7daf45bd1d27b444d98d6f7551a3ff83827 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 15:54:04 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20DB=20SSOT=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94=20=E2=80=94=20auth=5Fperm=5Ftree=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A9=94=EB=89=B4=C2=B7=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=C2=B7i18n=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 핵심 변경 - auth_perm_tree를 메뉴 SSOT로 확장 (V020~V024) - url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort 컬럼 - labels JSONB (다국어: {"ko":"...", "en":"..."}) - 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection, risk-assessment 등) - 권한 트리 = 메뉴 트리 완전 동기화 - 그룹 레벨 권한 → 개별 자식 권한으로 확장 후 그룹 삭제 - 패널 노드 parent_cd를 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외, 역할관리→권한관리) - vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크) ## 백엔드 - MenuConfigService: auth_perm_tree에서 menuConfig DTO 생성 - /api/auth/me 응답에 menuConfig 포함 (로그인 시 프리로드) - @RequirePermission 12곳 수정 (삭제된 그룹명 → 구체적 자식 리소스) - Caffeine 캐시 menuConfig 추가 ## 프론트엔드 - NAV_ENTRIES 하드코딩 제거 → menuStore(Zustand) 동적 렌더링 - PATH_TO_RESOURCE 하드코딩 제거 → DB 기반 longest-match - App.tsx 36개 정적 import/33개 Route → DynamicRoutes + componentRegistry - PermissionsPanel: DB labels JSONB 기반 표시명 + 페이지/패널 아이콘 구분 - DB migration README.md 전면 재작성 (V001~V024, 49테이블, 149인덱스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/gc/mda/kcg/auth/AuthController.java | 5 +- .../gc/mda/kcg/auth/dto/UserInfoResponse.java | 5 +- .../analysis/VesselAnalysisController.java | 8 +- .../VesselAnalysisProxyController.java | 2 +- .../mda/kcg/domain/stats/StatsController.java | 8 +- .../mda/kcg/master/MasterDataController.java | 8 +- .../gc/mda/kcg/menu/MenuConfigController.java | 21 ++ .../java/gc/mda/kcg/menu/MenuConfigDto.java | 19 ++ .../gc/mda/kcg/menu/MenuConfigService.java | 115 ++++++++ .../java/gc/mda/kcg/permission/PermTree.java | 26 ++ backend/src/main/resources/application.yml | 2 +- .../db/migration/V020__menu_config.sql | 97 +++++++ .../migration/V021__menu_into_perm_tree.sql | 115 ++++++++ .../migration/V022__perm_tree_i18n_labels.sql | 83 ++++++ .../migration/V023__perm_tree_sort_align.sql | 22 ++ .../db/migration/V024__flatten_perm_tree.sql | 78 +++++ database/migration/README.md | 229 +++++++++++++-- frontend/src/app/App.tsx | 153 ++++------ frontend/src/app/auth/AuthContext.tsx | 56 +--- frontend/src/app/componentRegistry.ts | 136 +++++++++ frontend/src/app/iconRegistry.ts | 54 ++++ frontend/src/app/layout/MainLayout.tsx | 270 ++++++++---------- .../src/features/admin/PermissionsPanel.tsx | 32 ++- frontend/src/services/adminApi.ts | 6 + frontend/src/services/authApi.ts | 19 ++ frontend/src/stores/menuStore.ts | 66 +++++ 26 files changed, 1303 insertions(+), 332 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java create mode 100644 backend/src/main/resources/db/migration/V020__menu_config.sql create mode 100644 backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql create mode 100644 backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql create mode 100644 backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql create mode 100644 backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql create mode 100644 frontend/src/app/componentRegistry.ts create mode 100644 frontend/src/app/iconRegistry.ts create mode 100644 frontend/src/stores/menuStore.ts diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java index e02da34..3c92735 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java @@ -4,6 +4,7 @@ import gc.mda.kcg.auth.dto.LoginRequest; import gc.mda.kcg.auth.dto.UserInfoResponse; import gc.mda.kcg.auth.provider.AuthProvider; import gc.mda.kcg.config.AppProperties; +import gc.mda.kcg.menu.MenuConfigService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -25,6 +26,7 @@ public class AuthController { private final AuthService authService; private final JwtService jwtService; private final AppProperties appProperties; + private final MenuConfigService menuConfigService; @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest req, @@ -95,7 +97,8 @@ public class AuthController { u.getUserSttsCd(), u.getAuthProvider(), info.roles(), - info.permissions() + info.permissions(), + menuConfigService.getActiveMenuConfig() ); } diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java index 6e101ea..defed9c 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java @@ -1,5 +1,7 @@ package gc.mda.kcg.auth.dto; +import gc.mda.kcg.menu.MenuConfigDto; + import java.util.List; import java.util.Map; @@ -12,5 +14,6 @@ public record UserInfoResponse( String status, String authProvider, List roles, - Map> permissions + Map> permissions, + List menuConfig ) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index a9f0c79..ae0318b 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -27,7 +27,7 @@ public class VesselAnalysisController { * 기본: 최근 1시간 내 결과. */ @GetMapping("/vessels") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public Page listVessels( @RequestParam(required = false) String mmsi, @RequestParam(required = false) String zoneCode, @@ -48,7 +48,7 @@ public class VesselAnalysisController { * 특정 선박 최신 분석 결과 (features 포함). */ @GetMapping("/vessels/{mmsi}") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); } @@ -57,7 +57,7 @@ public class VesselAnalysisController { * 특정 선박 분석 이력 (기본 24시간). */ @GetMapping("/vessels/{mmsi}/history") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public List getHistory( @PathVariable String mmsi, @RequestParam(defaultValue = "24") int hours @@ -86,7 +86,7 @@ public class VesselAnalysisController { * 환적 의심 목록 (최신 분석, MMSI 중복 제거). */ @GetMapping("/transship") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public Page listTransshipSuspects( @RequestParam(defaultValue = "1") int hours, @RequestParam(defaultValue = "0") int page, diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java index 3593357..6b55341 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -29,7 +29,7 @@ public class VesselAnalysisProxyController { private final ParentResolutionRepository resolutionRepository; @GetMapping - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public ResponseEntity getVesselAnalysis() { Map data = iranClient.getJson("/api/vessel-analysis"); if (data == null) { diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java index d506157..e0c4743 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java @@ -27,7 +27,7 @@ public class StatsController { * 실시간 KPI 전체 목록 조회 */ @GetMapping("/kpi") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getKpi() { return kpiRepository.findAll(); } @@ -38,7 +38,7 @@ public class StatsController { * @param to 종료 월 (예: 2026-04) */ @GetMapping("/monthly") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getMonthly( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @@ -52,7 +52,7 @@ public class StatsController { * @param to 종료 날짜 (예: 2026-04-07) */ @GetMapping("/daily") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getDaily( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @@ -65,7 +65,7 @@ public class StatsController { * @param hours 조회 시간 범위 (기본 24시간) */ @GetMapping("/hourly") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getHourly( @RequestParam(defaultValue = "24") int hours ) { diff --git a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java index 3352cbf..87279ff 100644 --- a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java +++ b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java @@ -79,13 +79,13 @@ public class MasterDataController { // ======================================================================== @GetMapping("/api/patrol-ships") - @RequirePermission(resource = "patrol", operation = "READ") + @RequirePermission(resource = "patrol:patrol-route", operation = "READ") public List listPatrolShips() { return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); } @PatchMapping("/api/patrol-ships/{id}/status") - @RequirePermission(resource = "patrol", operation = "UPDATE") + @RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE") public PatrolShip updatePatrolShipStatus( @PathVariable Long id, @RequestBody PatrolShipStatusRequest request @@ -108,7 +108,7 @@ public class MasterDataController { // ======================================================================== @GetMapping("/api/vessel-permits") - @RequirePermission(resource = "vessel", operation = "READ") + // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터) public Page listVesselPermits( @RequestParam(required = false) String flag, @RequestParam(required = false) String permitStatus, @@ -126,7 +126,7 @@ public class MasterDataController { } @GetMapping("/api/vessel-permits/{mmsi}") - @RequirePermission(resource = "vessel", operation = "READ") + // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터) public VesselPermit getVesselPermit(@PathVariable String mmsi) { return vesselPermitRepository.findByMmsi(mmsi) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java new file mode 100644 index 0000000..d6cea87 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java @@ -0,0 +1,21 @@ +package gc.mda.kcg.menu; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 메뉴 설정 API — auth_perm_tree 기반. + */ +@RestController +@RequiredArgsConstructor +public class MenuConfigController { + + private final MenuConfigService menuConfigService; + + @GetMapping("/api/menu-config") + public List getMenuConfig() { + return menuConfigService.getActiveMenuConfig(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java new file mode 100644 index 0000000..59db30b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java @@ -0,0 +1,19 @@ +package gc.mda.kcg.menu; + +import java.util.Map; + +public record MenuConfigDto( + String menuCd, + String parentMenuCd, + String menuType, + String urlPath, + String rsrcCd, + String componentKey, + String icon, + String labelKey, + String dividerLabel, + int menuLevel, + int sortOrd, + String useYn, + Map labels +) {} diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java new file mode 100644 index 0000000..d464a78 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java @@ -0,0 +1,115 @@ +package gc.mda.kcg.menu; + +import gc.mda.kcg.permission.PermTree; +import gc.mda.kcg.permission.PermTreeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성. + * 디바이더는 nav_sub_group 변경 시점에 동적 삽입. + */ +@Service +@RequiredArgsConstructor +public class MenuConfigService { + + private final PermTreeRepository permTreeRepository; + + // 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd + private static final Set GROUP_NODES = Set.of( + "field-ops", "parent-inference-workflow", "admin" + ); + + @Cacheable("menuConfig") + @Transactional(readOnly = true) + public List getActiveMenuConfig() { + List all = permTreeRepository.findByUseYn("Y"); + List result = new ArrayList<>(); + + // 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL) + List topItems = all.stream() + .filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + for (PermTree n : topItems) { + result.add(toDto(n, null, "ITEM", 0)); + } + + // 2) 그룹 헤더 + 자식 (nav_sort > 0 인 GROUP_NODES) + List groups = all.stream() + .filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + for (PermTree g : groups) { + result.add(toDto(g, null, "GROUP", 0)); + + // 이 그룹의 자식들 + List children = all.stream() + .filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + + // 디바이더 삽입: nav_sub_group 변경 시점마다 + String currentSubGroup = null; + int dividerSeq = 0; + for (PermTree c : children) { + String sub = c.getNavSubGroup(); + if (sub != null && !sub.equals(currentSubGroup)) { + currentSubGroup = sub; + dividerSeq++; + result.add(new MenuConfigDto( + g.getRsrcCd() + ".div-" + dividerSeq, + g.getRsrcCd(), "DIVIDER", + null, null, null, null, null, + sub, 1, c.getNavSort() - 1, "Y", + Map.of() + )); + } + result.add(toDto(c, g.getRsrcCd(), "ITEM", 1)); + } + } + + // 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL) + List hidden = all.stream() + .filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd())) + .toList(); + for (PermTree h : hidden) { + result.add(new MenuConfigDto( + h.getRsrcCd(), null, "ITEM", + h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(), + h.getIcon(), h.getLabelKey(), null, + 0, 9999, "H", + h.getLabels() != null ? h.getLabels() : Map.of() + )); + } + + return result; + } + + @CacheEvict(value = "menuConfig", allEntries = true) + public void evictCache() { + } + + private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) { + return new MenuConfigDto( + n.getRsrcCd(), + parentMenuCd, + menuType, + n.getUrlPath(), + n.getRsrcCd(), + n.getComponentKey(), + n.getIcon(), + n.getLabelKey(), + n.getNavSubGroup(), + menuLevel, + n.getNavSort(), + n.getUseYn(), + n.getLabels() != null ? n.getLabels() : Map.of() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTree.java b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java index 4c6b194..6d51793 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/PermTree.java +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java @@ -2,6 +2,8 @@ package gc.mda.kcg.permission; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.OffsetDateTime; @@ -39,6 +41,29 @@ public class PermTree { @Column(name = "use_yn", nullable = false, length = 1) private String useYn; + // ── 메뉴 SSOT 컬럼 (V021) ── + @Column(name = "url_path", length = 200) + private String urlPath; + + @Column(name = "label_key", length = 100) + private String labelKey; + + @Column(name = "component_key", length = 150) + private String componentKey; + + @Column(name = "nav_group", length = 100) + private String navGroup; + + @Column(name = "nav_sub_group", length = 100) + private String navSubGroup; + + @Column(name = "nav_sort", nullable = false) + private Integer navSort; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "labels", columnDefinition = "jsonb") + private java.util.Map labels; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @@ -53,6 +78,7 @@ public class PermTree { if (useYn == null) useYn = "Y"; if (sortOrd == null) sortOrd = 0; if (rsrcLevel == null) rsrcLevel = 0; + if (navSort == null) navSort = 0; } @PreUpdate diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index af78fe1..4951daa 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -33,7 +33,7 @@ spring: cache: type: caffeine - cache-names: permissions,users + cache-names: permissions,users,menuConfig caffeine: spec: maximumSize=1000,expireAfterWrite=10m diff --git a/backend/src/main/resources/db/migration/V020__menu_config.sql b/backend/src/main/resources/db/migration/V020__menu_config.sql new file mode 100644 index 0000000..a6f9699 --- /dev/null +++ b/backend/src/main/resources/db/migration/V020__menu_config.sql @@ -0,0 +1,97 @@ +-- ============================================================================ +-- V020: 메뉴 설정 SSOT 테이블 +-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원 +-- ============================================================================ + +CREATE TABLE kcg.menu_config ( + menu_cd VARCHAR(100) PRIMARY KEY, + parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE, + menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM', + url_path VARCHAR(200), + rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd), + component_key VARCHAR(150), + icon VARCHAR(50), + label_key VARCHAR(100), + divider_label VARCHAR(100), + menu_level INT NOT NULL DEFAULT 0, + sort_ord INT NOT NULL DEFAULT 0, + use_yn VARCHAR(1) NOT NULL DEFAULT 'Y', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동'; +COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)'; +COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL'; +COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL'; +COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성'; + +CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd); +CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord); +CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd); + +-- ============================================================================ +-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건 +-- sort_ord 100 간격 (삽입 여유) +-- ============================================================================ + +-- ─── Top-level ITEM (13개) ───────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100), +('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200), +('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300), +('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400), +('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500), +('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600), +('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700), +('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800), +('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900), +('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000), +('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100), +('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200), +('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300); + +-- ─── GROUP: 현장작전 ─────────────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400), +('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100), +('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200), +('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300), +('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400), +('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500); + +-- ─── GROUP: 모선 워크플로우 ──────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500), +('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100), +('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200), +('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300); + +-- ─── GROUP: 관리자 ───────────────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES +('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600), +-- AI 플랫폼 +('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100), +('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200), +('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300), +('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400), +('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500), +-- 시스템 운영 +('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600), +('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700), +('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800), +('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900), +-- 사용자 관리 +('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000), +('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100), +('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200), +('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300), +-- 감사·보안 +('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400), +('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500), +('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600), +('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700); + +-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ─────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES +('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H'); diff --git a/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql b/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql new file mode 100644 index 0000000..04a2565 --- /dev/null +++ b/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기 +-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화 +-- ============================================================================ + +-- ────────────────────────────────────────────────────────────────── +-- 1. auth_perm_tree에 메뉴 컬럼 추가 +-- ────────────────────────────────────────────────────────────────── +ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위) +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹) +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시) + +-- ────────────────────────────────────────────────────────────────── +-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30) +ON CONFLICT (rsrc_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85) +ON CONFLICT (rsrc_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:notices', 'admin', '공지사항', 1, 45) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- 신규 노드에 ADMIN 전체 권한 부여 +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd) +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용) +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'statistics:reports', 'READ', 'Y' +FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard'; +UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring'; +UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map'; +UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control'; +UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map'; +UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan'; +UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel'; +UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection'; +UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing'; +UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history'; +UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list'; +UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics'; +UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports'; + +-- ────────────────────────────────────────────────────────────────── +-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops'; +UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow'; +UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin'; + +-- ────────────────────────────────────────────────────────────────── +-- 5. 그룹 자식 — field-ops +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route'; +UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization'; +UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert'; +UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service'; +UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent'; + +-- ────────────────────────────────────────────────────────────────── +-- 6. 그룹 자식 — parent-inference +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review'; +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion'; +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session'; + +-- ────────────────────────────────────────────────────────────────── +-- 7. 그룹 자식 — admin (서브그룹 포함) +-- ────────────────────────────────────────────────────────────────── +-- AI 플랫폼 +UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model'; +UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops'; +UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops'; +UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant'; +-- 시스템 운영 +UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config'; +UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub'; +UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service'; +-- 사용자 관리 +UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management'; +UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management'; +UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices'; +-- 감사·보안 +UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs'; +UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs'; +UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history'; + +-- ────────────────────────────────────────────────────────────────── +-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail'; + +-- ────────────────────────────────────────────────────────────────── +-- 9. menu_config 테이블 폐기 +-- ────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS kcg.menu_config; diff --git a/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql b/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql new file mode 100644 index 0000000..59565e0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql @@ -0,0 +1,83 @@ +-- ============================================================================ +-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT +-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요) +-- ============================================================================ + +ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}'; + +-- ────────────────────────────────────────────────────────────────── +-- 최상위 ITEM +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports'; + +-- ────────────────────────────────────────────────────────────────── +-- 그룹 헤더 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin'; + +-- ────────────────────────────────────────────────────────────────── +-- field-ops 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent'; + +-- ────────────────────────────────────────────────────────────────── +-- parent-inference 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session'; + +-- ────────────────────────────────────────────────────────────────── +-- admin 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history'; + +-- ────────────────────────────────────────────────────────────────── +-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management'; diff --git a/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql b/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql new file mode 100644 index 0000000..37e1118 --- /dev/null +++ b/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql @@ -0,0 +1,22 @@ +-- ============================================================================ +-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치 +-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤 +-- ============================================================================ + +-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치 +-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정 +UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100 +UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200 +UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400 +UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600 +UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900 +UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300 +UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100 +UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400 +UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500 +UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600 + +-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치 +UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞 +UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞 +UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞 diff --git a/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql b/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql new file mode 100644 index 0000000..db8f567 --- /dev/null +++ b/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- V024: 권한 트리 = 메뉴 트리 완전 동기화 +-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치 +-- ============================================================================ + +-- ────────────────────────────────────────────────────────────────── +-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장 +-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn +FROM kcg.auth_perm ap +JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd +WHERE ap.rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +) +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 2. 그룹 권한 행 삭제 +-- ────────────────────────────────────────────────────────────────── +DELETE FROM kcg.auth_perm +WHERE rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +); + +-- ────────────────────────────────────────────────────────────────── +-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행) +-- ────────────────────────────────────────────────────────────────── + +-- 최상위 평탄 아이템: parent_cd → NULL +UPDATE kcg.auth_perm_tree SET parent_cd = NULL +WHERE parent_cd IN ( + 'surveillance','detection','risk-assessment','enforcement','statistics','vessel' +); + +-- patrol 자식 → field-ops 메뉴 그룹으로 이동 +UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops' +WHERE parent_cd = 'patrol'; + +-- ai-operations 자식 → admin 메뉴 그룹으로 이동 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin' +WHERE parent_cd = 'ai-operations'; + +-- ────────────────────────────────────────────────────────────────── +-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후) +-- ────────────────────────────────────────────────────────────────── +DELETE FROM kcg.auth_perm_tree +WHERE rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +); + +-- ────────────────────────────────────────────────────────────────── +-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정 +-- ────────────────────────────────────────────────────────────────── + +-- 전역 제외 관리 → 후보 제외 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion' +WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management'; + +-- 역할 관리 → 권한 관리 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management' +WHERE rsrc_cd = 'admin:role-management'; + +-- 메뉴 설정 → 권한 관리 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management' +WHERE rsrc_cd = 'admin:menu-management'; + +-- 어구식별 → 어구 탐지 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection' +WHERE rsrc_cd = 'detection:gear-identification'; + +-- 전재탐지 → 선박상세 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail' +WHERE rsrc_cd = 'vessel:transfer-detection'; diff --git a/database/migration/README.md b/database/migration/README.md index c3df544..1f23bc9 100644 --- a/database/migration/README.md +++ b/database/migration/README.md @@ -1,6 +1,6 @@ # Database Migrations -> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/) +> **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/) > > Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다. > Spring Boot 기동 시 Flyway가 자동으로 적용합니다. @@ -10,37 +10,208 @@ - **User**: `kcg-app` - **Schema**: `kcg` - **Host**: `211.208.115.83:5432` +- **현재 버전**: v022 (2026-04-09) -## 적용된 마이그레이션 (V001~V013) +--- -### Phase 1~8: 인증/권한/감사 (V001~V007) +## 마이그레이션 히스토리 (V001~V022) + +Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다. +V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다. + +### 인증/권한/감사 (V001~V007) | 파일 | 내용 | |---|---| -| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 | -| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 | -| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 | -| `V004__access_logs.sql` | 감사로그/접근이력 | -| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) | -| `V006__demo_accounts.sql` | 데모 계정 5종 | -| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 | +| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting | +| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) | +| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 | +| `V004__access_logs.sql` | auth_audit_log + auth_access_log | +| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions | +| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) | +| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 | -### S1: 마스터 데이터 + Prediction 기반 (V008~V013) +### 마스터 데이터 (V008~V011) | 파일 | 내용 | |---|---| -| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) | -| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) | -| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) | -| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) | -| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 | -| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) | +| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) | +| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) | +| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) | +| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) | + +### Prediction 분석 (V012~V015) + +| 파일 | 내용 | +|---|---| +| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input | +| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics | +| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config | +| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) | + +### 모선 워크플로우 확장 + 기능 추가 (V016~V019) + +| 파일 | 내용 | +|---|---| +| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) | +| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 | +| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 | +| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 | + +### 메뉴 DB SSOT (V020~V022) + +| 파일 | 내용 | +|---|---| +| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) | +| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP | +| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) | + +--- + +## 테이블 목록 (49개, flyway_schema_history 포함) + +### 인증/권한 (8 테이블) + +| 테이블 | PK | 설명 | 주요 컬럼 | +|---|---|---|---| +| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider | +| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) | +| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex | +| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by | +| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** | +| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) | +| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) | +| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider | + +### 감사 (2 테이블) + +| 테이블 | PK | 설명 | 주요 컬럼 | +|---|---|---|---| +| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result | +| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address | + +### 모선 워크플로우 (7 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) | +| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) | +| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) | +| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) | +| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) | +| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) | +| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 | + +### 마스터 데이터 (5 테이블) + +| 테이블 | PK | 설명 | 시드 | +|---|---|---|---| +| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 | +| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 | +| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 | +| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 | +| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 | + +### Prediction 이벤트/통계 (8 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) | +| `vessel_analysis_results_default` | — | 기본 파티션 | +| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) | +| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) | +| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) | +| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) | +| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 | +| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 | + +### Prediction 보조 (7 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) | +| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 | +| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 | +| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 | +| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 | +| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 | +| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) | + +### Prediction 후보 (1 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) | + +### 단속/작전 (3 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) | +| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) | +| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) | + +### AI 모델 (2 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) | +| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) | + +### Fleet (3 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) | +| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) | +| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) | + +### 기타 (2 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) | +| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) | + +--- + +## 인덱스 현황 (149개) + +주요 패턴: +- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회 +- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열 +- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색 +- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색 +- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만 + +## FK 관계 (21개) + +``` +auth_user ─→ auth_org (org_sn) +auth_user_role ─→ auth_user (user_id), auth_role (role_sn) +auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd) +auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조) +code_master ─→ code_master (parent_id, 자기참조) +zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조) +auth_org ─→ auth_org (upper_org_sn, 자기참조) +enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id) +event_workflow ─→ prediction_events (event_id) +prediction_alerts ─→ prediction_events (event_id) +patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id) +ai_model_metrics ─→ ai_model_versions (model_id) +gear_correlation_scores ─→ correlation_param_models (model_id) +gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id) +fleet_tracking_snapshot ─→ fleet_companies (company_id) +fleet_vessels ─→ fleet_companies (company_id) +vessel_permit_master ─→ fleet_companies (company_id) +``` + +--- ## 실행 방법 ### 최초 1회 - DB/사용자 생성 (관리자 권한 필요) ```sql --- snp 관리자 계정으로 접속 psql -h 211.208.115.83 -U snp -d postgres CREATE DATABASE kcgaidb; @@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run ### 수동 적용 ```bash -cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg +cd backend && ./mvnw flyway:migrate \ + -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \ + -Dflyway.user=kcg-app \ + -Dflyway.password=Kcg2026ai \ + -Dflyway.schemas=kcg ``` ### Checksum 불일치 시 (마이그레이션 파일 수정 후) @@ -70,4 +245,18 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일) ``` ## 신규 마이그레이션 추가 -[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다. +[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다. + +### 메뉴 추가 시 필수 포함 사항 +auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정: +```sql +INSERT INTO kcg.auth_perm_tree( + rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon, + url_path, label_key, component_key, nav_group, nav_sort, + labels +) VALUES ( + 'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles', + '/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400, + '{"ko":"새 기능 서브","en":"New Feature Sub"}' +); +``` diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 6144bf7..8eed79a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,42 +1,13 @@ +import { Suspense, useMemo, lazy } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from '@/app/auth/AuthContext'; import { MainLayout } from '@/app/layout/MainLayout'; import { LoginPage } from '@features/auth'; -/* SFR-01 */ import { AccessControl } from '@features/admin'; -/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin'; -/* SFR-03 */ import { DataHub } from '@features/admin'; -/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations'; -/* SFR-05 */ import { RiskMap } from '@features/risk-assessment'; -/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment'; -/* SFR-07 */ import { PatrolRoute } from '@features/patrol'; -/* SFR-08 */ import { FleetOptimization } from '@features/patrol'; -/* SFR-09 */ import { DarkVesselDetection } from '@features/detection'; -/* SFR-10 */ import { GearDetection } from '@features/detection'; -/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement'; -/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring'; -/* SFR-13 */ import { Statistics } from '@features/statistics'; -/* SFR-14 */ import { ExternalService } from '@features/statistics'; -/* SFR-15 */ import { MobileService } from '@features/field-ops'; -/* SFR-16 */ import { ShipAgent } from '@features/field-ops'; -/* SFR-17 */ import { AIAlert } from '@features/field-ops'; -/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations'; -/* SFR-20 */ import { AIAssistant } from '@features/ai-operations'; -/* SFR-20 LLM운영 */ import { LLMOpsPage } from '@features/ai-operations'; -/* 기존 */ import { Dashboard } from '@features/dashboard'; -import { LiveMapView, MapControl } from '@features/surveillance'; -import { EventList } from '@features/enforcement'; -import { VesselDetail } from '@features/vessel'; -import { ChinaFishing } from '@features/detection'; -import { ReportManagement } from '@features/statistics'; -import { AdminPanel } from '@features/admin'; -// Phase 4: 모선 워크플로우 -import { ParentReview } from '@features/parent-inference/ParentReview'; -import { ParentExclusion } from '@features/parent-inference/ParentExclusion'; -import { LabelSession } from '@features/parent-inference/LabelSession'; -// Phase 4: 관리자 로그 -import { AuditLogs } from '@features/admin/AuditLogs'; -import { AccessLogs } from '@features/admin/AccessLogs'; -import { LoginHistoryView } from '@features/admin/LoginHistoryView'; +import { useMenuStore } from '@stores/menuStore'; +import { COMPONENT_REGISTRY } from '@/app/componentRegistry'; + +// 권한 노드 없는 드릴다운 라우트 (인증만 체크) +const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail }))); /** * 권한 가드. @@ -69,66 +40,66 @@ function ProtectedRoute({ return <>{children}; } +function LoadingFallback() { + return ( +
+
로딩 중...
+
+ ); +} + +/** + * DB menu_config 기반 동적 라우트를 Route 배열로 반환. + * React Router v6는 직계 자식으로 만 허용하므로 컴포넌트가 아닌 함수로 생성. + */ +function useDynamicRoutes() { + const items = useMenuStore((s) => s.items); + const routableItems = useMemo( + () => items.filter((i) => i.menuType === 'ITEM' && i.urlPath), + [items], + ); + + return routableItems.map((item) => { + const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null; + if (!Comp || !item.urlPath) return null; + const path = item.urlPath.replace(/^\//, ''); + return ( + + }> + + + + } + /> + ); + }); +} + +function AppRoutes() { + const dynamicRoutes = useDynamicRoutes(); + + return ( + + } /> + }> + } /> + {dynamicRoutes} + {/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */} + }>} /> + + + ); +} + export default function App() { return ( - - } /> - }> - } /> - {/* SFR-12 대시보드 */} - } /> - } /> - {/* SFR-05~06 위험도·단속계획 */} - } /> - } /> - {/* SFR-09~10 탐지 */} - } /> - } /> - } /> - {/* SFR-07~08 순찰경로 */} - } /> - } /> - {/* SFR-11 이력 */} - } /> - } /> - {/* SFR-15~17 현장 대응 */} - } /> - } /> - } /> - {/* SFR-13~14 통계·외부연계 */} - } /> - } /> - } /> - {/* SFR-04 AI 모델 */} - } /> - {/* SFR-18~20 AI 운영 */} - } /> - } /> - } /> - {/* SFR-03 데이터허브 */} - } /> - {/* SFR-02 환경설정 */} - } /> - } /> - {/* SFR-01 권한·시스템 */} - } /> - } /> - {/* Phase 4: 관리자 로그 */} - } /> - } /> - } /> - {/* Phase 4: 모선 워크플로우 */} - } /> - } /> - } /> - {/* 기존 유지 */} - } /> - } /> - } /> - - + ); diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx index 2428a65..a11a267 100644 --- a/frontend/src/app/auth/AuthContext.tsx +++ b/frontend/src/app/auth/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; +import { useMenuStore } from '@stores/menuStore'; /* * SFR-01: 시스템 로그인 및 권한 관리 @@ -33,47 +34,6 @@ export interface AuthUser { // ─── 세션 타임아웃 (30분) ────────────────── const SESSION_TIMEOUT = 30 * 60 * 1000; -// 경로 → 권한 리소스 매핑 (ProtectedRoute용) -const PATH_TO_RESOURCE: Record = { - '/dashboard': 'dashboard', - '/monitoring': 'monitoring', - '/events': 'surveillance:live-map', - '/map-control': 'surveillance:map-control', - '/dark-vessel': 'detection:dark-vessel', - '/gear-detection': 'detection:gear-detection', - '/china-fishing': 'detection:china-fishing', - '/vessel': 'vessel', - '/risk-map': 'risk-assessment:risk-map', - '/enforcement-plan': 'risk-assessment:enforcement-plan', - '/patrol-route': 'patrol:patrol-route', - '/fleet-optimization': 'patrol:fleet-optimization', - '/enforcement-history': 'enforcement:enforcement-history', - '/event-list': 'enforcement:event-list', - '/mobile-service': 'field-ops:mobile-service', - '/ship-agent': 'field-ops:ship-agent', - '/ai-alert': 'field-ops:ai-alert', - '/ai-assistant': 'ai-operations:ai-assistant', - '/ai-model': 'ai-operations:ai-model', - '/mlops': 'ai-operations:mlops', - '/llm-ops': 'ai-operations:llm-ops', - '/statistics': 'statistics:statistics', - '/external-service': 'statistics:external-service', - '/admin/audit-logs': 'admin:audit-logs', - '/admin/access-logs': 'admin:access-logs', - '/admin/login-history': 'admin:login-history', - '/admin': 'admin', - '/access-control': 'admin:permission-management', - '/system-config': 'admin:system-config', - '/notices': 'admin', - '/reports': 'statistics:statistics', - '/data-hub': 'admin:system-config', - // 모선 워크플로우 - '/parent-inference/review': 'parent-inference-workflow:parent-review', - '/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion', - '/parent-inference/label-session': 'parent-inference-workflow:label-session', - '/parent-inference': 'parent-inference-workflow', -}; - interface AuthContextType { user: AuthUser | null; loading: boolean; @@ -133,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { let alive = true; fetchMe() .then((b) => { - if (alive && b) setUser(backendToAuthUser(b)); + if (alive && b) { + setUser(backendToAuthUser(b)); + if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig); + } }) .finally(() => { if (alive) setLoading(false); @@ -175,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const b = await loginApi(account, password); setUser(backendToAuthUser(b)); + if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig); setLastActivity(Date.now()); } catch (e) { if (e instanceof LoginError) throw e; @@ -187,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { await logoutApi(); } finally { setUser(null); + useMenuStore.getState().clear(); } }, []); @@ -201,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const hasAccess = useCallback( (path: string) => { if (!user) return false; - // 경로의 첫 세그먼트로 매핑 - const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p)); - if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능) - const resource = PATH_TO_RESOURCE[matched]; + // DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체) + const resource = useMenuStore.getState().getResourceForPath(path); + if (!resource) return true; return hasPermission(resource, 'READ'); }, [user, hasPermission], diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts new file mode 100644 index 0000000..7f9bd31 --- /dev/null +++ b/frontend/src/app/componentRegistry.ts @@ -0,0 +1,136 @@ +import { lazy, type ComponentType } from 'react'; + +type LazyComponent = React.LazyExoticComponent>; + +/** + * DB menu_config.component_key → lazy-loaded React 컴포넌트 매핑. + * 메뉴 추가 시 이 레지스트리에 1줄만 추가하면 됨. + */ +export const COMPONENT_REGISTRY: Record = { + // ── 상황판·감시 ── + 'features/dashboard/Dashboard': lazy(() => + import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })), + ), + 'features/monitoring/MonitoringDashboard': lazy(() => + import('@features/monitoring/MonitoringDashboard').then((m) => ({ + default: m.MonitoringDashboard, + })), + ), + 'features/surveillance/LiveMapView': lazy(() => + import('@features/surveillance').then((m) => ({ default: m.LiveMapView })), + ), + 'features/surveillance/MapControl': lazy(() => + import('@features/surveillance').then((m) => ({ default: m.MapControl })), + ), + // ── 위험도·단속 ── + 'features/risk-assessment/RiskMap': lazy(() => + import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })), + ), + 'features/risk-assessment/EnforcementPlan': lazy(() => + import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })), + ), + // ── 탐지 ── + 'features/detection/DarkVesselDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })), + ), + 'features/detection/GearDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.GearDetection })), + ), + 'features/detection/ChinaFishing': lazy(() => + import('@features/detection').then((m) => ({ default: m.ChinaFishing })), + ), + // ── 단속·이벤트 ── + 'features/enforcement/EnforcementHistory': lazy(() => + import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), + ), + 'features/enforcement/EventList': lazy(() => + import('@features/enforcement').then((m) => ({ default: m.EventList })), + ), + // ── 통계 ── + 'features/statistics/Statistics': lazy(() => + import('@features/statistics').then((m) => ({ default: m.Statistics })), + ), + 'features/statistics/ReportManagement': lazy(() => + import('@features/statistics').then((m) => ({ default: m.ReportManagement })), + ), + 'features/statistics/ExternalService': lazy(() => + import('@features/statistics').then((m) => ({ default: m.ExternalService })), + ), + // ── 순찰 ── + 'features/patrol/PatrolRoute': lazy(() => + import('@features/patrol').then((m) => ({ default: m.PatrolRoute })), + ), + 'features/patrol/FleetOptimization': lazy(() => + import('@features/patrol').then((m) => ({ default: m.FleetOptimization })), + ), + // ── 현장작전 ── + 'features/field-ops/AIAlert': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.AIAlert })), + ), + 'features/field-ops/MobileService': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.MobileService })), + ), + 'features/field-ops/ShipAgent': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.ShipAgent })), + ), + // ── AI 운영 ── + 'features/ai-operations/AIModelManagement': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })), + ), + 'features/ai-operations/MLOpsPage': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })), + ), + 'features/ai-operations/LLMOpsPage': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })), + ), + 'features/ai-operations/AIAssistant': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })), + ), + // ── 관리 ── + 'features/admin/AdminPanel': lazy(() => + import('@features/admin').then((m) => ({ default: m.AdminPanel })), + ), + 'features/admin/SystemConfig': lazy(() => + import('@features/admin').then((m) => ({ default: m.SystemConfig })), + ), + 'features/admin/DataHub': lazy(() => + import('@features/admin').then((m) => ({ default: m.DataHub })), + ), + 'features/admin/AccessControl': lazy(() => + import('@features/admin').then((m) => ({ default: m.AccessControl })), + ), + 'features/admin/NoticeManagement': lazy(() => + import('@features/admin').then((m) => ({ default: m.NoticeManagement })), + ), + 'features/admin/AuditLogs': lazy(() => + import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })), + ), + 'features/admin/AccessLogs': lazy(() => + import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })), + ), + 'features/admin/LoginHistoryView': lazy(() => + import('@features/admin/LoginHistoryView').then((m) => ({ + default: m.LoginHistoryView, + })), + ), + // ── 모선 워크플로우 ── + 'features/parent-inference/ParentReview': lazy(() => + import('@features/parent-inference/ParentReview').then((m) => ({ + default: m.ParentReview, + })), + ), + 'features/parent-inference/ParentExclusion': lazy(() => + import('@features/parent-inference/ParentExclusion').then((m) => ({ + default: m.ParentExclusion, + })), + ), + 'features/parent-inference/LabelSession': lazy(() => + import('@features/parent-inference/LabelSession').then((m) => ({ + default: m.LabelSession, + })), + ), + // ── 선박 (숨김 라우트) ── + 'features/vessel/VesselDetail': lazy(() => + import('@features/vessel').then((m) => ({ default: m.VesselDetail })), + ), +}; diff --git a/frontend/src/app/iconRegistry.ts b/frontend/src/app/iconRegistry.ts new file mode 100644 index 0000000..b66d9e9 --- /dev/null +++ b/frontend/src/app/iconRegistry.ts @@ -0,0 +1,54 @@ +import { + LayoutDashboard, Activity, Radar, Map, Layers, Shield, + EyeOff, Anchor, Ship, FileText, List, BarChart3, + Navigation, Users, Send, Smartphone, Monitor, + GitBranch, CheckSquare, Ban, Tag, Settings, + Brain, Cpu, MessageSquare, Database, Wifi, Globe, + Fingerprint, Megaphone, ScrollText, History, KeyRound, + type LucideIcon, +} from 'lucide-react'; + +/** + * DB icon 문자열 → Lucide React 컴포넌트 매핑. + * 사이드바에서 사용하는 아이콘만 포함. + */ +const ICON_MAP: Record = { + LayoutDashboard, + Activity, + Radar, + Map, + Layers, + Shield, + EyeOff, + Anchor, + Ship, + FileText, + List, + BarChart3, + Navigation, + Users, + Send, + Smartphone, + Monitor, + GitBranch, + CheckSquare, + Ban, + Tag, + Settings, + Brain, + Cpu, + MessageSquare, + Database, + Wifi, + Globe, + Fingerprint, + Megaphone, + ScrollText, + History, + KeyRound, +}; + +export function resolveIcon(name: string | null): LucideIcon | null { + if (!name) return null; + return ICON_MAP[name] ?? null; +} diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 1ebb4ef..115a90e 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -2,18 +2,16 @@ import { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { - LayoutDashboard, Map, List, Ship, Anchor, Radar, - FileText, Settings, LogOut, ChevronLeft, ChevronRight, - Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, - Download, FileSpreadsheet, Printer, Wifi, Brain, Activity, - Navigation, Users, EyeOff, BarChart3, Globe, - Smartphone, Monitor, Send, Cpu, MessageSquare, - GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound, + LogOut, ChevronLeft, ChevronRight, + Shield, Bell, Search, Clock, Lock, + Download, FileSpreadsheet, Printer, } from 'lucide-react'; -import { useAuth, type UserRole } from '@/app/auth/AuthContext'; +import { useAuth } from '@/app/auth/AuthContext'; import { getRoleColorHex } from '@shared/constants/userRoles'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { useSettingsStore } from '@stores/settingsStore'; +import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore'; +import { resolveIcon } from '@/app/iconRegistry'; /* * SFR-01 반영 사항: @@ -34,82 +32,6 @@ const AUTH_METHOD_LABELS: Record = { sso: 'SSO', }; -interface NavItem { to: string; icon: React.ElementType; labelKey: string; } -interface NavDivider { dividerLabel: string; } -interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavDivider)[]; } -type NavEntry = NavItem | NavGroup; - -const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry; -const isDivider = (item: NavItem | NavDivider): item is NavDivider => 'dividerLabel' in item; - -const NAV_ENTRIES: NavEntry[] = [ - // ── 상황판·감시 ── - { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, - { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, - { to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' }, - { to: '/map-control', icon: Map, labelKey: 'nav.mapControl' }, - // ── 위험도·단속 ── - { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, - { to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' }, - // ── 탐지 ── - { to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' }, - { to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' }, - { to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' }, - // ── 이력·통계 ── - { to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' }, - { to: '/event-list', icon: List, labelKey: 'nav.eventList' }, - { to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' }, - { to: '/reports', icon: FileText, labelKey: 'nav.reports' }, - // ── 함정용 (그룹) ── - { - groupKey: 'group.fieldOps', icon: Ship, - items: [ - { to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' }, - { to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' }, - { to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' }, - { to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' }, - { to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' }, - ], - }, - // ── 모선 워크플로우 (운영자 의사결정, 그룹) ── - { - groupKey: 'group.parentInference', icon: GitBranch, - items: [ - { to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' }, - { to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' }, - { to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' }, - ], - }, - // ── 관리자 (그룹) ── - { - groupKey: 'group.admin', icon: Settings, - items: [ - { dividerLabel: 'AI 플랫폼' }, - { to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' }, - { to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' }, - { to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' }, - { to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' }, - { dividerLabel: '시스템 운영' }, - { to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' }, - { to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' }, - { to: '/external-service', icon: Globe, labelKey: 'nav.externalService' }, - { dividerLabel: '사용자 관리' }, - { to: '/admin', icon: Settings, labelKey: 'nav.admin' }, - { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, - { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, - { dividerLabel: '감사·보안' }, - { to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' }, - { to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' }, - { to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' }, - ], - }, -]; - -// getPageLabel용 flat 목록 (divider 제외) -const NAV_ITEMS = NAV_ENTRIES.flatMap(e => - isGroup(e) ? e.items.filter((i): i is NavItem => !isDivider(i)) : [e] -); - function formatRemaining(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds % 60; @@ -124,11 +46,13 @@ export function MainLayout() { const location = useLocation(); const { user, logout, hasAccess, sessionRemaining } = useAuth(); const contentRef = useRef(null); + const { getTopLevelEntries, getChildren } = useMenuStore(); - // getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n) + // getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반) const getPageLabel = (pathname: string): string => { - const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to)); - return item ? t(item.labelKey) : ''; + const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath); + const item = allItems.find((n) => pathname.startsWith(n.urlPath!)); + return item ? getMenuLabel(item, language) : ''; }; // 공통 검색 @@ -259,76 +183,31 @@ export function MainLayout() {
)} - {/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */} + {/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
); } + +/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */ +function GroupMenu({ + group, + children, + collapsed, + hasAccess, + openGroups, + toggleGroup, + location, + language, +}: { + group: MenuConfigItem; + children: MenuConfigItem[]; + collapsed: boolean; + hasAccess: (path: string) => boolean; + openGroups: Set; + toggleGroup: (name: string) => void; + location: { pathname: string }; + language: string; +}) { + const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath); + const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!)); + if (accessibleItems.length === 0) return null; + + const GroupIcon = resolveIcon(group.icon); + const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!)); + const isOpen = openGroups.has(group.menuCd) || isAnyActive; + + return ( +
+ + {isOpen && ( +
+ {children.map((child) => { + if (child.menuType === 'DIVIDER') { + if (collapsed) return null; + return ( +
+ {child.dividerLabel} +
+ ); + } + if (!child.urlPath || !hasAccess(child.urlPath)) return null; + const ChildIcon = resolveIcon(child.icon); + return ( + + `flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${ + isActive + ? 'bg-blue-600/15 text-blue-400 border border-blue-500/20' + : 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent' + }` + } + > + {ChildIcon && } + {!collapsed && ( + {getMenuLabel(child, language)} + )} + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/features/admin/PermissionsPanel.tsx b/frontend/src/features/admin/PermissionsPanel.tsx index 4976f62..d0f04be 100644 --- a/frontend/src/features/admin/PermissionsPanel.tsx +++ b/frontend/src/features/admin/PermissionsPanel.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { Fragment, useEffect, useState, useCallback, useMemo } from 'react'; import { Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown, + ExternalLink, Layers, } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; @@ -14,6 +15,7 @@ import { type Operation, type TreeNode, type PermRow, } from '@/lib/permission/permResolver'; import { useAuth } from '@/app/auth/AuthContext'; +import { useSettingsStore } from '@stores/settingsStore'; import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; import { ColorPicker } from '@shared/components/common/ColorPicker'; import { updateRole as apiUpdateRole } from '@/services/adminApi'; @@ -100,7 +102,7 @@ export function PermissionsPanel() { setDraftPerms(m); }, [selectedRole]); - // 트리 → 트리 인덱싱 (parent → children) + // 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치) const childrenMap = useMemo(() => { const m = new Map(); for (const n of tree) { @@ -109,6 +111,14 @@ export function PermissionsPanel() { arr.push(n); m.set(n.parentCd, arr); } + // nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치 + for (const [, arr] of m.entries()) { + arr.sort((a, b) => { + const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd; + const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd; + return aSort - bSort; + }); + } return m; }, [tree]); @@ -280,9 +290,13 @@ export function PermissionsPanel() { const hasChildren = children.length > 0; const isExpanded = expanded.has(node.rsrcCd); + // DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백 + const lang = useSettingsStore.getState().language; + const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm; + return ( - <> - + +
{hasChildren ? ( @@ -291,8 +305,14 @@ export function PermissionsPanel() { {isExpanded ? : } ) : } - {node.rsrcNm} + {/* 페이지/패널 구분 아이콘 */} + {node.urlPath + ? + : depth > 0 ? : null + } + {displayName} ({node.rsrcCd}) + {node.urlPath && {node.urlPath}}
{OPERATIONS.map((op) => { @@ -324,7 +344,7 @@ export function PermissionsPanel() { })} {isExpanded && children.map((c) => renderTreeRow(c, depth + 1))} - +
); }; diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 2951b30..ad130a5 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -61,6 +61,12 @@ export interface PermTreeNode { rsrcLevel: number; sortOrd: number; useYn: string; + /** V021: 메뉴 SSOT */ + labelKey: string | null; + urlPath: string | null; + navSort: number; + /** V022: DB i18n JSONB {"ko":"...", "en":"..."} */ + labels: Record; } export interface RoleWithPermissions { diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts index 7035b2a..b43a2fe 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/services/authApi.ts @@ -6,6 +6,23 @@ const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; +export interface MenuConfigItem { + menuCd: string; + parentMenuCd: string | null; + menuType: 'ITEM' | 'GROUP' | 'DIVIDER'; + urlPath: string | null; + rsrcCd: string | null; + componentKey: string | null; + icon: string | null; + labelKey: string | null; + dividerLabel: string | null; + menuLevel: number; + sortOrd: number; + useYn: string; + /** DB i18n SSOT: {"ko":"종합 상황판","en":"Dashboard"} */ + labels: Record; +} + export interface BackendUser { id: string; account: string; @@ -17,6 +34,8 @@ export interface BackendUser { roles: string[]; /** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */ permissions: Record; + /** DB 메뉴 설정 SSOT */ + menuConfig: MenuConfigItem[]; } export class LoginError extends Error { diff --git a/frontend/src/stores/menuStore.ts b/frontend/src/stores/menuStore.ts new file mode 100644 index 0000000..3215f96 --- /dev/null +++ b/frontend/src/stores/menuStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; + +export interface MenuConfigItem { + menuCd: string; + parentMenuCd: string | null; + menuType: 'ITEM' | 'GROUP' | 'DIVIDER'; + urlPath: string | null; + rsrcCd: string | null; + componentKey: string | null; + icon: string | null; + labelKey: string | null; + dividerLabel: string | null; + menuLevel: number; + sortOrd: number; + useYn: string; + labels: Record; +} + +/** DB labels에서 현재 언어의 라벨을 반환 */ +export function getMenuLabel(item: MenuConfigItem, lang: string): string { + return item.labels?.[lang] || item.labels?.ko || item.menuCd; +} + +interface MenuStore { + items: MenuConfigItem[]; + loaded: boolean; + setMenuConfig: (items: MenuConfigItem[]) => void; + clear: () => void; + + /** path → rsrcCd (longest-match, PATH_TO_RESOURCE 대체) */ + getResourceForPath: (path: string) => string | undefined; + /** 최상위 항목 (menu_level=0, 사이드바 표시용) */ + getTopLevelEntries: () => MenuConfigItem[]; + /** 그룹 하위 항목 */ + getChildren: (parentMenuCd: string) => MenuConfigItem[]; + /** 라우팅 가능 항목 (ITEM + urlPath 보유) */ + getRoutableItems: () => MenuConfigItem[]; +} + +export const useMenuStore = create((set, get) => ({ + items: [], + loaded: false, + + setMenuConfig: (items) => set({ items, loaded: true }), + clear: () => set({ items: [], loaded: false }), + + getResourceForPath: (path) => { + const { items } = get(); + // longest-match: 가장 구체적인 경로 우선 (삽입 순서 의존 버그 해결) + const candidates = items.filter((i) => i.urlPath && i.rsrcCd); + candidates.sort((a, b) => b.urlPath!.length - a.urlPath!.length); + const match = candidates.find((i) => path.startsWith(i.urlPath!)); + return match?.rsrcCd ?? undefined; + }, + + getTopLevelEntries: () => + get().items.filter( + (i) => i.menuLevel === 0 && i.parentMenuCd === null && i.useYn !== 'H', + ), + + getChildren: (parentMenuCd) => + get().items.filter((i) => i.parentMenuCd === parentMenuCd), + + getRoutableItems: () => + get().items.filter((i) => i.menuType === 'ITEM' && i.urlPath), +})); -- 2.45.2 From a498cfdbe7bb1d3db5c277b2f23fed722efb1804 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 16:02:55 +0900 Subject: [PATCH 09/10] =?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[Unreleased]=20=EB=A9=94=EB=89=B4=20DB=20S?= =?UTF-8?q?SOT=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 473277e..3bb1efc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -26,8 +26,25 @@ - **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동) - **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등) +- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합 + - auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort) + - labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT + - 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection 등) → 권한 트리 = 메뉴 트리 완전 동기화 + - 패널 노드 parent_cd 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외) + - vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크) + - 공유 리소스 분리: statistics:reports, admin:data-hub, admin:notices 독립 노드 생성 + - V020~V024 마이그레이션 5건 +- **프론트엔드 동적 메뉴/라우팅** — DB 기반 자동 구성 + - menuStore(Zustand) + componentRegistry(lazy loading) + iconRegistry + - NAV_ENTRIES/PATH_TO_RESOURCE 하드코딩 제거 + - App.tsx DynamicRoutes: DB menuConfig에서 Route 자동 생성 + - MainLayout: DB menuConfig에서 사이드바 자동 렌더링 +- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬 +- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화 + ### 변경 - **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장) +- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스 - **EnforcementController** vesselMmsi 필터 파라미터 추가 - **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가 -- 2.45.2 From a08071edce8c91a2c6a03c72d8d7c564ac4d82ec Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 16:04:10 +0900 Subject: [PATCH 10/10] =?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-04-09.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3bb1efc..485073c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-09.2] + ### 추가 - **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현 - **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회 -- 2.45.2