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 new file mode 100644 index 0000000..ae0318b --- /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:dark-vessel", 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:dark-vessel", operation = "READ") + public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { + return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); + } + + /** + * 특정 선박 분석 이력 (기본 24시간). + */ + @GetMapping("/vessels/{mmsi}/history") + @RequirePermission(resource = "detection:dark-vessel", 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:dark-vessel", 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/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/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/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/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/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/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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4ae4e7b..485073c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,59 @@ ## [Unreleased] +## [2026-04-09.2] + +### 추가 +- **워크플로우 연결 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 등) + +- **메뉴 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 파라미터 추가 + +### 수정 +- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리") +- system-flow 08-frontend.json 누락 노드 14개 추가 + +### 문서 +- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en) + ## [2026-04-09] ### 추가 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 8159ce6..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,74 +32,6 @@ const AUTH_METHOD_LABELS: Record = { sso: 'SSO', }; -interface NavItem { to: string; icon: React.ElementType; labelKey: string; } -interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; } -type NavEntry = NavItem | NavGroup; - -const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry; - -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: '/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: [ - { 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' }, - { to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' }, - { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, - { to: '/admin', icon: Settings, labelKey: 'nav.admin' }, - { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, - { 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 목록 -const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]); - function formatRemaining(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds % 60; @@ -116,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) : ''; }; // 공통 검색 @@ -251,64 +183,31 @@ export function MainLayout() { )} - {/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */} + {/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}