From 6fe7a7daf45bd1d27b444d98d6f7551a3ff83827 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 15:54:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20DB=20SSOT=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94=20=E2=80=94=20auth=5Fperm=5Ftree=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A9=94=EB=89=B4=C2=B7=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=C2=B7i18n=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 핵심 변경 - auth_perm_tree를 메뉴 SSOT로 확장 (V020~V024) - url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort 컬럼 - labels JSONB (다국어: {"ko":"...", "en":"..."}) - 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection, risk-assessment 등) - 권한 트리 = 메뉴 트리 완전 동기화 - 그룹 레벨 권한 → 개별 자식 권한으로 확장 후 그룹 삭제 - 패널 노드 parent_cd를 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외, 역할관리→권한관리) - vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크) ## 백엔드 - MenuConfigService: auth_perm_tree에서 menuConfig DTO 생성 - /api/auth/me 응답에 menuConfig 포함 (로그인 시 프리로드) - @RequirePermission 12곳 수정 (삭제된 그룹명 → 구체적 자식 리소스) - Caffeine 캐시 menuConfig 추가 ## 프론트엔드 - NAV_ENTRIES 하드코딩 제거 → menuStore(Zustand) 동적 렌더링 - PATH_TO_RESOURCE 하드코딩 제거 → DB 기반 longest-match - App.tsx 36개 정적 import/33개 Route → DynamicRoutes + componentRegistry - PermissionsPanel: DB labels JSONB 기반 표시명 + 페이지/패널 아이콘 구분 - DB migration README.md 전면 재작성 (V001~V024, 49테이블, 149인덱스) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/gc/mda/kcg/auth/AuthController.java | 5 +- .../gc/mda/kcg/auth/dto/UserInfoResponse.java | 5 +- .../analysis/VesselAnalysisController.java | 8 +- .../VesselAnalysisProxyController.java | 2 +- .../mda/kcg/domain/stats/StatsController.java | 8 +- .../mda/kcg/master/MasterDataController.java | 8 +- .../gc/mda/kcg/menu/MenuConfigController.java | 21 ++ .../java/gc/mda/kcg/menu/MenuConfigDto.java | 19 ++ .../gc/mda/kcg/menu/MenuConfigService.java | 115 ++++++++ .../java/gc/mda/kcg/permission/PermTree.java | 26 ++ backend/src/main/resources/application.yml | 2 +- .../db/migration/V020__menu_config.sql | 97 +++++++ .../migration/V021__menu_into_perm_tree.sql | 115 ++++++++ .../migration/V022__perm_tree_i18n_labels.sql | 83 ++++++ .../migration/V023__perm_tree_sort_align.sql | 22 ++ .../db/migration/V024__flatten_perm_tree.sql | 78 +++++ database/migration/README.md | 229 +++++++++++++-- frontend/src/app/App.tsx | 153 ++++------ frontend/src/app/auth/AuthContext.tsx | 56 +--- frontend/src/app/componentRegistry.ts | 136 +++++++++ frontend/src/app/iconRegistry.ts | 54 ++++ frontend/src/app/layout/MainLayout.tsx | 270 ++++++++---------- .../src/features/admin/PermissionsPanel.tsx | 32 ++- frontend/src/services/adminApi.ts | 6 + frontend/src/services/authApi.ts | 19 ++ frontend/src/stores/menuStore.ts | 66 +++++ 26 files changed, 1303 insertions(+), 332 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java create mode 100644 backend/src/main/resources/db/migration/V020__menu_config.sql create mode 100644 backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql create mode 100644 backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql create mode 100644 backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql create mode 100644 backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql create mode 100644 frontend/src/app/componentRegistry.ts create mode 100644 frontend/src/app/iconRegistry.ts create mode 100644 frontend/src/stores/menuStore.ts diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java index e02da34..3c92735 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java @@ -4,6 +4,7 @@ import gc.mda.kcg.auth.dto.LoginRequest; import gc.mda.kcg.auth.dto.UserInfoResponse; import gc.mda.kcg.auth.provider.AuthProvider; import gc.mda.kcg.config.AppProperties; +import gc.mda.kcg.menu.MenuConfigService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -25,6 +26,7 @@ public class AuthController { private final AuthService authService; private final JwtService jwtService; private final AppProperties appProperties; + private final MenuConfigService menuConfigService; @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest req, @@ -95,7 +97,8 @@ public class AuthController { u.getUserSttsCd(), u.getAuthProvider(), info.roles(), - info.permissions() + info.permissions(), + menuConfigService.getActiveMenuConfig() ); } diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java index 6e101ea..defed9c 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/UserInfoResponse.java @@ -1,5 +1,7 @@ package gc.mda.kcg.auth.dto; +import gc.mda.kcg.menu.MenuConfigDto; + import java.util.List; import java.util.Map; @@ -12,5 +14,6 @@ public record UserInfoResponse( String status, String authProvider, List roles, - Map> permissions + Map> permissions, + List menuConfig ) {} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index a9f0c79..ae0318b 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -27,7 +27,7 @@ public class VesselAnalysisController { * 기본: 최근 1시간 내 결과. */ @GetMapping("/vessels") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public Page listVessels( @RequestParam(required = false) String mmsi, @RequestParam(required = false) String zoneCode, @@ -48,7 +48,7 @@ public class VesselAnalysisController { * 특정 선박 최신 분석 결과 (features 포함). */ @GetMapping("/vessels/{mmsi}") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); } @@ -57,7 +57,7 @@ public class VesselAnalysisController { * 특정 선박 분석 이력 (기본 24시간). */ @GetMapping("/vessels/{mmsi}/history") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public List getHistory( @PathVariable String mmsi, @RequestParam(defaultValue = "24") int hours @@ -86,7 +86,7 @@ public class VesselAnalysisController { * 환적 의심 목록 (최신 분석, MMSI 중복 제거). */ @GetMapping("/transship") - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public Page listTransshipSuspects( @RequestParam(defaultValue = "1") int hours, @RequestParam(defaultValue = "0") int page, diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java index 3593357..6b55341 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisProxyController.java @@ -29,7 +29,7 @@ public class VesselAnalysisProxyController { private final ParentResolutionRepository resolutionRepository; @GetMapping - @RequirePermission(resource = "detection", operation = "READ") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") public ResponseEntity getVesselAnalysis() { Map data = iranClient.getJson("/api/vessel-analysis"); if (data == null) { diff --git a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java index d506157..e0c4743 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/stats/StatsController.java @@ -27,7 +27,7 @@ public class StatsController { * 실시간 KPI 전체 목록 조회 */ @GetMapping("/kpi") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getKpi() { return kpiRepository.findAll(); } @@ -38,7 +38,7 @@ public class StatsController { * @param to 종료 월 (예: 2026-04) */ @GetMapping("/monthly") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getMonthly( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @@ -52,7 +52,7 @@ public class StatsController { * @param to 종료 날짜 (예: 2026-04-07) */ @GetMapping("/daily") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getDaily( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @@ -65,7 +65,7 @@ public class StatsController { * @param hours 조회 시간 범위 (기본 24시간) */ @GetMapping("/hourly") - @RequirePermission(resource = "statistics", operation = "READ") + @RequirePermission(resource = "statistics:statistics", operation = "READ") public List getHourly( @RequestParam(defaultValue = "24") int hours ) { diff --git a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java index 3352cbf..87279ff 100644 --- a/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java +++ b/backend/src/main/java/gc/mda/kcg/master/MasterDataController.java @@ -79,13 +79,13 @@ public class MasterDataController { // ======================================================================== @GetMapping("/api/patrol-ships") - @RequirePermission(resource = "patrol", operation = "READ") + @RequirePermission(resource = "patrol:patrol-route", operation = "READ") public List listPatrolShips() { return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); } @PatchMapping("/api/patrol-ships/{id}/status") - @RequirePermission(resource = "patrol", operation = "UPDATE") + @RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE") public PatrolShip updatePatrolShipStatus( @PathVariable Long id, @RequestBody PatrolShipStatusRequest request @@ -108,7 +108,7 @@ public class MasterDataController { // ======================================================================== @GetMapping("/api/vessel-permits") - @RequirePermission(resource = "vessel", operation = "READ") + // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터) public Page listVesselPermits( @RequestParam(required = false) String flag, @RequestParam(required = false) String permitStatus, @@ -126,7 +126,7 @@ public class MasterDataController { } @GetMapping("/api/vessel-permits/{mmsi}") - @RequirePermission(resource = "vessel", operation = "READ") + // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터) public VesselPermit getVesselPermit(@PathVariable String mmsi) { return vesselPermitRepository.findByMmsi(mmsi) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java new file mode 100644 index 0000000..d6cea87 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigController.java @@ -0,0 +1,21 @@ +package gc.mda.kcg.menu; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 메뉴 설정 API — auth_perm_tree 기반. + */ +@RestController +@RequiredArgsConstructor +public class MenuConfigController { + + private final MenuConfigService menuConfigService; + + @GetMapping("/api/menu-config") + public List getMenuConfig() { + return menuConfigService.getActiveMenuConfig(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java new file mode 100644 index 0000000..59db30b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigDto.java @@ -0,0 +1,19 @@ +package gc.mda.kcg.menu; + +import java.util.Map; + +public record MenuConfigDto( + String menuCd, + String parentMenuCd, + String menuType, + String urlPath, + String rsrcCd, + String componentKey, + String icon, + String labelKey, + String dividerLabel, + int menuLevel, + int sortOrd, + String useYn, + Map labels +) {} diff --git a/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java new file mode 100644 index 0000000..d464a78 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/menu/MenuConfigService.java @@ -0,0 +1,115 @@ +package gc.mda.kcg.menu; + +import gc.mda.kcg.permission.PermTree; +import gc.mda.kcg.permission.PermTreeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성. + * 디바이더는 nav_sub_group 변경 시점에 동적 삽입. + */ +@Service +@RequiredArgsConstructor +public class MenuConfigService { + + private final PermTreeRepository permTreeRepository; + + // 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd + private static final Set GROUP_NODES = Set.of( + "field-ops", "parent-inference-workflow", "admin" + ); + + @Cacheable("menuConfig") + @Transactional(readOnly = true) + public List getActiveMenuConfig() { + List all = permTreeRepository.findByUseYn("Y"); + List result = new ArrayList<>(); + + // 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL) + List topItems = all.stream() + .filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + for (PermTree n : topItems) { + result.add(toDto(n, null, "ITEM", 0)); + } + + // 2) 그룹 헤더 + 자식 (nav_sort > 0 인 GROUP_NODES) + List groups = all.stream() + .filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + for (PermTree g : groups) { + result.add(toDto(g, null, "GROUP", 0)); + + // 이 그룹의 자식들 + List children = all.stream() + .filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null) + .sorted(Comparator.comparingInt(PermTree::getNavSort)) + .toList(); + + // 디바이더 삽입: nav_sub_group 변경 시점마다 + String currentSubGroup = null; + int dividerSeq = 0; + for (PermTree c : children) { + String sub = c.getNavSubGroup(); + if (sub != null && !sub.equals(currentSubGroup)) { + currentSubGroup = sub; + dividerSeq++; + result.add(new MenuConfigDto( + g.getRsrcCd() + ".div-" + dividerSeq, + g.getRsrcCd(), "DIVIDER", + null, null, null, null, null, + sub, 1, c.getNavSort() - 1, "Y", + Map.of() + )); + } + result.add(toDto(c, g.getRsrcCd(), "ITEM", 1)); + } + } + + // 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL) + List hidden = all.stream() + .filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd())) + .toList(); + for (PermTree h : hidden) { + result.add(new MenuConfigDto( + h.getRsrcCd(), null, "ITEM", + h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(), + h.getIcon(), h.getLabelKey(), null, + 0, 9999, "H", + h.getLabels() != null ? h.getLabels() : Map.of() + )); + } + + return result; + } + + @CacheEvict(value = "menuConfig", allEntries = true) + public void evictCache() { + } + + private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) { + return new MenuConfigDto( + n.getRsrcCd(), + parentMenuCd, + menuType, + n.getUrlPath(), + n.getRsrcCd(), + n.getComponentKey(), + n.getIcon(), + n.getLabelKey(), + n.getNavSubGroup(), + menuLevel, + n.getNavSort(), + n.getUseYn(), + n.getLabels() != null ? n.getLabels() : Map.of() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTree.java b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java index 4c6b194..6d51793 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/PermTree.java +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTree.java @@ -2,6 +2,8 @@ package gc.mda.kcg.permission; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import java.time.OffsetDateTime; @@ -39,6 +41,29 @@ public class PermTree { @Column(name = "use_yn", nullable = false, length = 1) private String useYn; + // ── 메뉴 SSOT 컬럼 (V021) ── + @Column(name = "url_path", length = 200) + private String urlPath; + + @Column(name = "label_key", length = 100) + private String labelKey; + + @Column(name = "component_key", length = 150) + private String componentKey; + + @Column(name = "nav_group", length = 100) + private String navGroup; + + @Column(name = "nav_sub_group", length = 100) + private String navSubGroup; + + @Column(name = "nav_sort", nullable = false) + private Integer navSort; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "labels", columnDefinition = "jsonb") + private java.util.Map labels; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @@ -53,6 +78,7 @@ public class PermTree { if (useYn == null) useYn = "Y"; if (sortOrd == null) sortOrd = 0; if (rsrcLevel == null) rsrcLevel = 0; + if (navSort == null) navSort = 0; } @PreUpdate diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index af78fe1..4951daa 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -33,7 +33,7 @@ spring: cache: type: caffeine - cache-names: permissions,users + cache-names: permissions,users,menuConfig caffeine: spec: maximumSize=1000,expireAfterWrite=10m diff --git a/backend/src/main/resources/db/migration/V020__menu_config.sql b/backend/src/main/resources/db/migration/V020__menu_config.sql new file mode 100644 index 0000000..a6f9699 --- /dev/null +++ b/backend/src/main/resources/db/migration/V020__menu_config.sql @@ -0,0 +1,97 @@ +-- ============================================================================ +-- V020: 메뉴 설정 SSOT 테이블 +-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원 +-- ============================================================================ + +CREATE TABLE kcg.menu_config ( + menu_cd VARCHAR(100) PRIMARY KEY, + parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE, + menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM', + url_path VARCHAR(200), + rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd), + component_key VARCHAR(150), + icon VARCHAR(50), + label_key VARCHAR(100), + divider_label VARCHAR(100), + menu_level INT NOT NULL DEFAULT 0, + sort_ord INT NOT NULL DEFAULT 0, + use_yn VARCHAR(1) NOT NULL DEFAULT 'Y', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동'; +COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)'; +COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL'; +COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL'; +COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성'; + +CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd); +CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord); +CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd); + +-- ============================================================================ +-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건 +-- sort_ord 100 간격 (삽입 여유) +-- ============================================================================ + +-- ─── Top-level ITEM (13개) ───────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100), +('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200), +('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300), +('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400), +('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500), +('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600), +('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700), +('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800), +('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900), +('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000), +('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100), +('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200), +('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300); + +-- ─── GROUP: 현장작전 ─────────────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400), +('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100), +('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200), +('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300), +('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400), +('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500); + +-- ─── GROUP: 모선 워크플로우 ──────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES +('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500), +('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100), +('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200), +('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300); + +-- ─── GROUP: 관리자 ───────────────────────────────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES +('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600), +-- AI 플랫폼 +('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100), +('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200), +('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300), +('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400), +('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500), +-- 시스템 운영 +('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600), +('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700), +('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800), +('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900), +-- 사용자 관리 +('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000), +('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100), +('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200), +('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300), +-- 감사·보안 +('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400), +('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500), +('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600), +('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700); + +-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ─────────────────────────────── +INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES +('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H'); diff --git a/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql b/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql new file mode 100644 index 0000000..04a2565 --- /dev/null +++ b/backend/src/main/resources/db/migration/V021__menu_into_perm_tree.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기 +-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화 +-- ============================================================================ + +-- ────────────────────────────────────────────────────────────────── +-- 1. auth_perm_tree에 메뉴 컬럼 추가 +-- ────────────────────────────────────────────────────────────────── +ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150); +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위) +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹) +ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시) + +-- ────────────────────────────────────────────────────────────────── +-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30) +ON CONFLICT (rsrc_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85) +ON CONFLICT (rsrc_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) +VALUES ('admin:notices', 'admin', '공지사항', 1, 45) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- 신규 노드에 ADMIN 전체 권한 부여 +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd) +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용) +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'statistics:reports', 'READ', 'Y' +FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard'; +UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring'; +UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map'; +UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control'; +UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map'; +UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan'; +UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel'; +UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection'; +UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing'; +UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history'; +UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list'; +UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics'; +UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports'; + +-- ────────────────────────────────────────────────────────────────── +-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops'; +UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow'; +UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin'; + +-- ────────────────────────────────────────────────────────────────── +-- 5. 그룹 자식 — field-ops +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route'; +UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization'; +UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert'; +UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service'; +UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent'; + +-- ────────────────────────────────────────────────────────────────── +-- 6. 그룹 자식 — parent-inference +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review'; +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion'; +UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session'; + +-- ────────────────────────────────────────────────────────────────── +-- 7. 그룹 자식 — admin (서브그룹 포함) +-- ────────────────────────────────────────────────────────────────── +-- AI 플랫폼 +UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model'; +UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops'; +UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops'; +UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant'; +-- 시스템 운영 +UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config'; +UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub'; +UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service'; +-- 사용자 관리 +UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management'; +UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management'; +UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices'; +-- 감사·보안 +UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs'; +UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs'; +UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history'; + +-- ────────────────────────────────────────────────────────────────── +-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail'; + +-- ────────────────────────────────────────────────────────────────── +-- 9. menu_config 테이블 폐기 +-- ────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS kcg.menu_config; diff --git a/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql b/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql new file mode 100644 index 0000000..59565e0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V022__perm_tree_i18n_labels.sql @@ -0,0 +1,83 @@ +-- ============================================================================ +-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT +-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요) +-- ============================================================================ + +ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}'; + +-- ────────────────────────────────────────────────────────────────── +-- 최상위 ITEM +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports'; + +-- ────────────────────────────────────────────────────────────────── +-- 그룹 헤더 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin'; + +-- ────────────────────────────────────────────────────────────────── +-- field-ops 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent'; + +-- ────────────────────────────────────────────────────────────────── +-- parent-inference 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session'; + +-- ────────────────────────────────────────────────────────────────── +-- admin 자식 +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history'; + +-- ────────────────────────────────────────────────────────────────── +-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시) +-- ────────────────────────────────────────────────────────────────── +UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management'; +UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management'; diff --git a/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql b/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql new file mode 100644 index 0000000..37e1118 --- /dev/null +++ b/backend/src/main/resources/db/migration/V023__perm_tree_sort_align.sql @@ -0,0 +1,22 @@ +-- ============================================================================ +-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치 +-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤 +-- ============================================================================ + +-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치 +-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정 +UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100 +UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200 +UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400 +UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600 +UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900 +UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300 +UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100 +UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400 +UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500 +UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600 + +-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치 +UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞 +UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞 +UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞 diff --git a/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql b/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql new file mode 100644 index 0000000..db8f567 --- /dev/null +++ b/backend/src/main/resources/db/migration/V024__flatten_perm_tree.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- V024: 권한 트리 = 메뉴 트리 완전 동기화 +-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치 +-- ============================================================================ + +-- ────────────────────────────────────────────────────────────────── +-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장 +-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn +FROM kcg.auth_perm ap +JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd +WHERE ap.rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +) +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 2. 그룹 권한 행 삭제 +-- ────────────────────────────────────────────────────────────────── +DELETE FROM kcg.auth_perm +WHERE rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +); + +-- ────────────────────────────────────────────────────────────────── +-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행) +-- ────────────────────────────────────────────────────────────────── + +-- 최상위 평탄 아이템: parent_cd → NULL +UPDATE kcg.auth_perm_tree SET parent_cd = NULL +WHERE parent_cd IN ( + 'surveillance','detection','risk-assessment','enforcement','statistics','vessel' +); + +-- patrol 자식 → field-ops 메뉴 그룹으로 이동 +UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops' +WHERE parent_cd = 'patrol'; + +-- ai-operations 자식 → admin 메뉴 그룹으로 이동 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin' +WHERE parent_cd = 'ai-operations'; + +-- ────────────────────────────────────────────────────────────────── +-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후) +-- ────────────────────────────────────────────────────────────────── +DELETE FROM kcg.auth_perm_tree +WHERE rsrc_cd IN ( + 'surveillance','detection','risk-assessment','enforcement', + 'statistics','patrol','ai-operations','vessel' +); + +-- ────────────────────────────────────────────────────────────────── +-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정 +-- ────────────────────────────────────────────────────────────────── + +-- 전역 제외 관리 → 후보 제외 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion' +WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management'; + +-- 역할 관리 → 권한 관리 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management' +WHERE rsrc_cd = 'admin:role-management'; + +-- 메뉴 설정 → 권한 관리 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management' +WHERE rsrc_cd = 'admin:menu-management'; + +-- 어구식별 → 어구 탐지 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection' +WHERE rsrc_cd = 'detection:gear-identification'; + +-- 전재탐지 → 선박상세 페이지 내부 +UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail' +WHERE rsrc_cd = 'vessel:transfer-detection'; diff --git a/database/migration/README.md b/database/migration/README.md index c3df544..1f23bc9 100644 --- a/database/migration/README.md +++ b/database/migration/README.md @@ -1,6 +1,6 @@ # Database Migrations -> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/) +> **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/) > > Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다. > Spring Boot 기동 시 Flyway가 자동으로 적용합니다. @@ -10,37 +10,208 @@ - **User**: `kcg-app` - **Schema**: `kcg` - **Host**: `211.208.115.83:5432` +- **현재 버전**: v022 (2026-04-09) -## 적용된 마이그레이션 (V001~V013) +--- -### Phase 1~8: 인증/권한/감사 (V001~V007) +## 마이그레이션 히스토리 (V001~V022) + +Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다. +V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다. + +### 인증/권한/감사 (V001~V007) | 파일 | 내용 | |---|---| -| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 | -| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 | -| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 | -| `V004__access_logs.sql` | 감사로그/접근이력 | -| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) | -| `V006__demo_accounts.sql` | 데모 계정 5종 | -| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 | +| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting | +| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) | +| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 | +| `V004__access_logs.sql` | auth_audit_log + auth_access_log | +| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions | +| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) | +| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 | -### S1: 마스터 데이터 + Prediction 기반 (V008~V013) +### 마스터 데이터 (V008~V011) | 파일 | 내용 | |---|---| -| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) | -| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) | -| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) | -| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) | -| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 | -| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) | +| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) | +| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) | +| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) | +| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) | + +### Prediction 분석 (V012~V015) + +| 파일 | 내용 | +|---|---| +| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input | +| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics | +| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config | +| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) | + +### 모선 워크플로우 확장 + 기능 추가 (V016~V019) + +| 파일 | 내용 | +|---|---| +| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) | +| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 | +| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 | +| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 | + +### 메뉴 DB SSOT (V020~V022) + +| 파일 | 내용 | +|---|---| +| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) | +| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP | +| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) | + +--- + +## 테이블 목록 (49개, flyway_schema_history 포함) + +### 인증/권한 (8 테이블) + +| 테이블 | PK | 설명 | 주요 컬럼 | +|---|---|---|---| +| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider | +| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) | +| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex | +| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by | +| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** | +| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) | +| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) | +| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider | + +### 감사 (2 테이블) + +| 테이블 | PK | 설명 | 주요 컬럼 | +|---|---|---|---| +| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result | +| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address | + +### 모선 워크플로우 (7 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) | +| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) | +| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) | +| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) | +| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) | +| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) | +| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 | + +### 마스터 데이터 (5 테이블) + +| 테이블 | PK | 설명 | 시드 | +|---|---|---|---| +| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 | +| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 | +| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 | +| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 | +| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 | + +### Prediction 이벤트/통계 (8 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) | +| `vessel_analysis_results_default` | — | 기본 파티션 | +| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) | +| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) | +| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) | +| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) | +| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 | +| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 | + +### Prediction 보조 (7 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) | +| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 | +| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 | +| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 | +| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 | +| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 | +| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) | + +### Prediction 후보 (1 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) | + +### 단속/작전 (3 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) | +| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) | +| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) | + +### AI 모델 (2 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) | +| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) | + +### Fleet (3 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) | +| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) | +| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) | + +### 기타 (2 테이블) + +| 테이블 | PK | 설명 | +|---|---|---| +| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) | +| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) | + +--- + +## 인덱스 현황 (149개) + +주요 패턴: +- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회 +- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열 +- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색 +- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색 +- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만 + +## FK 관계 (21개) + +``` +auth_user ─→ auth_org (org_sn) +auth_user_role ─→ auth_user (user_id), auth_role (role_sn) +auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd) +auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조) +code_master ─→ code_master (parent_id, 자기참조) +zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조) +auth_org ─→ auth_org (upper_org_sn, 자기참조) +enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id) +event_workflow ─→ prediction_events (event_id) +prediction_alerts ─→ prediction_events (event_id) +patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id) +ai_model_metrics ─→ ai_model_versions (model_id) +gear_correlation_scores ─→ correlation_param_models (model_id) +gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id) +fleet_tracking_snapshot ─→ fleet_companies (company_id) +fleet_vessels ─→ fleet_companies (company_id) +vessel_permit_master ─→ fleet_companies (company_id) +``` + +--- ## 실행 방법 ### 최초 1회 - DB/사용자 생성 (관리자 권한 필요) ```sql --- snp 관리자 계정으로 접속 psql -h 211.208.115.83 -U snp -d postgres CREATE DATABASE kcgaidb; @@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run ### 수동 적용 ```bash -cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg +cd backend && ./mvnw flyway:migrate \ + -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \ + -Dflyway.user=kcg-app \ + -Dflyway.password=Kcg2026ai \ + -Dflyway.schemas=kcg ``` ### Checksum 불일치 시 (마이그레이션 파일 수정 후) @@ -70,4 +245,18 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일) ``` ## 신규 마이그레이션 추가 -[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다. +[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다. + +### 메뉴 추가 시 필수 포함 사항 +auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정: +```sql +INSERT INTO kcg.auth_perm_tree( + rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon, + url_path, label_key, component_key, nav_group, nav_sort, + labels +) VALUES ( + 'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles', + '/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400, + '{"ko":"새 기능 서브","en":"New Feature Sub"}' +); +``` diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 6144bf7..8eed79a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,42 +1,13 @@ +import { Suspense, useMemo, lazy } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from '@/app/auth/AuthContext'; import { MainLayout } from '@/app/layout/MainLayout'; import { LoginPage } from '@features/auth'; -/* SFR-01 */ import { AccessControl } from '@features/admin'; -/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin'; -/* SFR-03 */ import { DataHub } from '@features/admin'; -/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations'; -/* SFR-05 */ import { RiskMap } from '@features/risk-assessment'; -/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment'; -/* SFR-07 */ import { PatrolRoute } from '@features/patrol'; -/* SFR-08 */ import { FleetOptimization } from '@features/patrol'; -/* SFR-09 */ import { DarkVesselDetection } from '@features/detection'; -/* SFR-10 */ import { GearDetection } from '@features/detection'; -/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement'; -/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring'; -/* SFR-13 */ import { Statistics } from '@features/statistics'; -/* SFR-14 */ import { ExternalService } from '@features/statistics'; -/* SFR-15 */ import { MobileService } from '@features/field-ops'; -/* SFR-16 */ import { ShipAgent } from '@features/field-ops'; -/* SFR-17 */ import { AIAlert } from '@features/field-ops'; -/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations'; -/* SFR-20 */ import { AIAssistant } from '@features/ai-operations'; -/* SFR-20 LLM운영 */ import { LLMOpsPage } from '@features/ai-operations'; -/* 기존 */ import { Dashboard } from '@features/dashboard'; -import { LiveMapView, MapControl } from '@features/surveillance'; -import { EventList } from '@features/enforcement'; -import { VesselDetail } from '@features/vessel'; -import { ChinaFishing } from '@features/detection'; -import { ReportManagement } from '@features/statistics'; -import { AdminPanel } from '@features/admin'; -// Phase 4: 모선 워크플로우 -import { ParentReview } from '@features/parent-inference/ParentReview'; -import { ParentExclusion } from '@features/parent-inference/ParentExclusion'; -import { LabelSession } from '@features/parent-inference/LabelSession'; -// Phase 4: 관리자 로그 -import { AuditLogs } from '@features/admin/AuditLogs'; -import { AccessLogs } from '@features/admin/AccessLogs'; -import { LoginHistoryView } from '@features/admin/LoginHistoryView'; +import { useMenuStore } from '@stores/menuStore'; +import { COMPONENT_REGISTRY } from '@/app/componentRegistry'; + +// 권한 노드 없는 드릴다운 라우트 (인증만 체크) +const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail }))); /** * 권한 가드. @@ -69,66 +40,66 @@ function ProtectedRoute({ return <>{children}; } +function LoadingFallback() { + return ( +
+
로딩 중...
+
+ ); +} + +/** + * DB menu_config 기반 동적 라우트를 Route 배열로 반환. + * React Router v6는 직계 자식으로 만 허용하므로 컴포넌트가 아닌 함수로 생성. + */ +function useDynamicRoutes() { + const items = useMenuStore((s) => s.items); + const routableItems = useMemo( + () => items.filter((i) => i.menuType === 'ITEM' && i.urlPath), + [items], + ); + + return routableItems.map((item) => { + const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null; + if (!Comp || !item.urlPath) return null; + const path = item.urlPath.replace(/^\//, ''); + return ( + + }> + + + + } + /> + ); + }); +} + +function AppRoutes() { + const dynamicRoutes = useDynamicRoutes(); + + return ( + + } /> + }> + } /> + {dynamicRoutes} + {/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */} + }>} /> + + + ); +} + export default function App() { return ( - - } /> - }> - } /> - {/* SFR-12 대시보드 */} - } /> - } /> - {/* SFR-05~06 위험도·단속계획 */} - } /> - } /> - {/* SFR-09~10 탐지 */} - } /> - } /> - } /> - {/* SFR-07~08 순찰경로 */} - } /> - } /> - {/* SFR-11 이력 */} - } /> - } /> - {/* SFR-15~17 현장 대응 */} - } /> - } /> - } /> - {/* SFR-13~14 통계·외부연계 */} - } /> - } /> - } /> - {/* SFR-04 AI 모델 */} - } /> - {/* SFR-18~20 AI 운영 */} - } /> - } /> - } /> - {/* SFR-03 데이터허브 */} - } /> - {/* SFR-02 환경설정 */} - } /> - } /> - {/* SFR-01 권한·시스템 */} - } /> - } /> - {/* Phase 4: 관리자 로그 */} - } /> - } /> - } /> - {/* Phase 4: 모선 워크플로우 */} - } /> - } /> - } /> - {/* 기존 유지 */} - } /> - } /> - } /> - - + ); diff --git a/frontend/src/app/auth/AuthContext.tsx b/frontend/src/app/auth/AuthContext.tsx index 2428a65..a11a267 100644 --- a/frontend/src/app/auth/AuthContext.tsx +++ b/frontend/src/app/auth/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; +import { useMenuStore } from '@stores/menuStore'; /* * SFR-01: 시스템 로그인 및 권한 관리 @@ -33,47 +34,6 @@ export interface AuthUser { // ─── 세션 타임아웃 (30분) ────────────────── const SESSION_TIMEOUT = 30 * 60 * 1000; -// 경로 → 권한 리소스 매핑 (ProtectedRoute용) -const PATH_TO_RESOURCE: Record = { - '/dashboard': 'dashboard', - '/monitoring': 'monitoring', - '/events': 'surveillance:live-map', - '/map-control': 'surveillance:map-control', - '/dark-vessel': 'detection:dark-vessel', - '/gear-detection': 'detection:gear-detection', - '/china-fishing': 'detection:china-fishing', - '/vessel': 'vessel', - '/risk-map': 'risk-assessment:risk-map', - '/enforcement-plan': 'risk-assessment:enforcement-plan', - '/patrol-route': 'patrol:patrol-route', - '/fleet-optimization': 'patrol:fleet-optimization', - '/enforcement-history': 'enforcement:enforcement-history', - '/event-list': 'enforcement:event-list', - '/mobile-service': 'field-ops:mobile-service', - '/ship-agent': 'field-ops:ship-agent', - '/ai-alert': 'field-ops:ai-alert', - '/ai-assistant': 'ai-operations:ai-assistant', - '/ai-model': 'ai-operations:ai-model', - '/mlops': 'ai-operations:mlops', - '/llm-ops': 'ai-operations:llm-ops', - '/statistics': 'statistics:statistics', - '/external-service': 'statistics:external-service', - '/admin/audit-logs': 'admin:audit-logs', - '/admin/access-logs': 'admin:access-logs', - '/admin/login-history': 'admin:login-history', - '/admin': 'admin', - '/access-control': 'admin:permission-management', - '/system-config': 'admin:system-config', - '/notices': 'admin', - '/reports': 'statistics:statistics', - '/data-hub': 'admin:system-config', - // 모선 워크플로우 - '/parent-inference/review': 'parent-inference-workflow:parent-review', - '/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion', - '/parent-inference/label-session': 'parent-inference-workflow:label-session', - '/parent-inference': 'parent-inference-workflow', -}; - interface AuthContextType { user: AuthUser | null; loading: boolean; @@ -133,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { let alive = true; fetchMe() .then((b) => { - if (alive && b) setUser(backendToAuthUser(b)); + if (alive && b) { + setUser(backendToAuthUser(b)); + if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig); + } }) .finally(() => { if (alive) setLoading(false); @@ -175,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const b = await loginApi(account, password); setUser(backendToAuthUser(b)); + if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig); setLastActivity(Date.now()); } catch (e) { if (e instanceof LoginError) throw e; @@ -187,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { await logoutApi(); } finally { setUser(null); + useMenuStore.getState().clear(); } }, []); @@ -201,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const hasAccess = useCallback( (path: string) => { if (!user) return false; - // 경로의 첫 세그먼트로 매핑 - const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p)); - if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능) - const resource = PATH_TO_RESOURCE[matched]; + // DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체) + const resource = useMenuStore.getState().getResourceForPath(path); + if (!resource) return true; return hasPermission(resource, 'READ'); }, [user, hasPermission], diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts new file mode 100644 index 0000000..7f9bd31 --- /dev/null +++ b/frontend/src/app/componentRegistry.ts @@ -0,0 +1,136 @@ +import { lazy, type ComponentType } from 'react'; + +type LazyComponent = React.LazyExoticComponent>; + +/** + * DB menu_config.component_key → lazy-loaded React 컴포넌트 매핑. + * 메뉴 추가 시 이 레지스트리에 1줄만 추가하면 됨. + */ +export const COMPONENT_REGISTRY: Record = { + // ── 상황판·감시 ── + 'features/dashboard/Dashboard': lazy(() => + import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })), + ), + 'features/monitoring/MonitoringDashboard': lazy(() => + import('@features/monitoring/MonitoringDashboard').then((m) => ({ + default: m.MonitoringDashboard, + })), + ), + 'features/surveillance/LiveMapView': lazy(() => + import('@features/surveillance').then((m) => ({ default: m.LiveMapView })), + ), + 'features/surveillance/MapControl': lazy(() => + import('@features/surveillance').then((m) => ({ default: m.MapControl })), + ), + // ── 위험도·단속 ── + 'features/risk-assessment/RiskMap': lazy(() => + import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })), + ), + 'features/risk-assessment/EnforcementPlan': lazy(() => + import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })), + ), + // ── 탐지 ── + 'features/detection/DarkVesselDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })), + ), + 'features/detection/GearDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.GearDetection })), + ), + 'features/detection/ChinaFishing': lazy(() => + import('@features/detection').then((m) => ({ default: m.ChinaFishing })), + ), + // ── 단속·이벤트 ── + 'features/enforcement/EnforcementHistory': lazy(() => + import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), + ), + 'features/enforcement/EventList': lazy(() => + import('@features/enforcement').then((m) => ({ default: m.EventList })), + ), + // ── 통계 ── + 'features/statistics/Statistics': lazy(() => + import('@features/statistics').then((m) => ({ default: m.Statistics })), + ), + 'features/statistics/ReportManagement': lazy(() => + import('@features/statistics').then((m) => ({ default: m.ReportManagement })), + ), + 'features/statistics/ExternalService': lazy(() => + import('@features/statistics').then((m) => ({ default: m.ExternalService })), + ), + // ── 순찰 ── + 'features/patrol/PatrolRoute': lazy(() => + import('@features/patrol').then((m) => ({ default: m.PatrolRoute })), + ), + 'features/patrol/FleetOptimization': lazy(() => + import('@features/patrol').then((m) => ({ default: m.FleetOptimization })), + ), + // ── 현장작전 ── + 'features/field-ops/AIAlert': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.AIAlert })), + ), + 'features/field-ops/MobileService': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.MobileService })), + ), + 'features/field-ops/ShipAgent': lazy(() => + import('@features/field-ops').then((m) => ({ default: m.ShipAgent })), + ), + // ── AI 운영 ── + 'features/ai-operations/AIModelManagement': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })), + ), + 'features/ai-operations/MLOpsPage': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })), + ), + 'features/ai-operations/LLMOpsPage': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })), + ), + 'features/ai-operations/AIAssistant': lazy(() => + import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })), + ), + // ── 관리 ── + 'features/admin/AdminPanel': lazy(() => + import('@features/admin').then((m) => ({ default: m.AdminPanel })), + ), + 'features/admin/SystemConfig': lazy(() => + import('@features/admin').then((m) => ({ default: m.SystemConfig })), + ), + 'features/admin/DataHub': lazy(() => + import('@features/admin').then((m) => ({ default: m.DataHub })), + ), + 'features/admin/AccessControl': lazy(() => + import('@features/admin').then((m) => ({ default: m.AccessControl })), + ), + 'features/admin/NoticeManagement': lazy(() => + import('@features/admin').then((m) => ({ default: m.NoticeManagement })), + ), + 'features/admin/AuditLogs': lazy(() => + import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })), + ), + 'features/admin/AccessLogs': lazy(() => + import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })), + ), + 'features/admin/LoginHistoryView': lazy(() => + import('@features/admin/LoginHistoryView').then((m) => ({ + default: m.LoginHistoryView, + })), + ), + // ── 모선 워크플로우 ── + 'features/parent-inference/ParentReview': lazy(() => + import('@features/parent-inference/ParentReview').then((m) => ({ + default: m.ParentReview, + })), + ), + 'features/parent-inference/ParentExclusion': lazy(() => + import('@features/parent-inference/ParentExclusion').then((m) => ({ + default: m.ParentExclusion, + })), + ), + 'features/parent-inference/LabelSession': lazy(() => + import('@features/parent-inference/LabelSession').then((m) => ({ + default: m.LabelSession, + })), + ), + // ── 선박 (숨김 라우트) ── + 'features/vessel/VesselDetail': lazy(() => + import('@features/vessel').then((m) => ({ default: m.VesselDetail })), + ), +}; diff --git a/frontend/src/app/iconRegistry.ts b/frontend/src/app/iconRegistry.ts new file mode 100644 index 0000000..b66d9e9 --- /dev/null +++ b/frontend/src/app/iconRegistry.ts @@ -0,0 +1,54 @@ +import { + LayoutDashboard, Activity, Radar, Map, Layers, Shield, + EyeOff, Anchor, Ship, FileText, List, BarChart3, + Navigation, Users, Send, Smartphone, Monitor, + GitBranch, CheckSquare, Ban, Tag, Settings, + Brain, Cpu, MessageSquare, Database, Wifi, Globe, + Fingerprint, Megaphone, ScrollText, History, KeyRound, + type LucideIcon, +} from 'lucide-react'; + +/** + * DB icon 문자열 → Lucide React 컴포넌트 매핑. + * 사이드바에서 사용하는 아이콘만 포함. + */ +const ICON_MAP: Record = { + LayoutDashboard, + Activity, + Radar, + Map, + Layers, + Shield, + EyeOff, + Anchor, + Ship, + FileText, + List, + BarChart3, + Navigation, + Users, + Send, + Smartphone, + Monitor, + GitBranch, + CheckSquare, + Ban, + Tag, + Settings, + Brain, + Cpu, + MessageSquare, + Database, + Wifi, + Globe, + Fingerprint, + Megaphone, + ScrollText, + History, + KeyRound, +}; + +export function resolveIcon(name: string | null): LucideIcon | null { + if (!name) return null; + return ICON_MAP[name] ?? null; +} diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 1ebb4ef..115a90e 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -2,18 +2,16 @@ import { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { - LayoutDashboard, Map, List, Ship, Anchor, Radar, - FileText, Settings, LogOut, ChevronLeft, ChevronRight, - Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, - Download, FileSpreadsheet, Printer, Wifi, Brain, Activity, - Navigation, Users, EyeOff, BarChart3, Globe, - Smartphone, Monitor, Send, Cpu, MessageSquare, - GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound, + LogOut, ChevronLeft, ChevronRight, + Shield, Bell, Search, Clock, Lock, + Download, FileSpreadsheet, Printer, } from 'lucide-react'; -import { useAuth, type UserRole } from '@/app/auth/AuthContext'; +import { useAuth } from '@/app/auth/AuthContext'; import { getRoleColorHex } from '@shared/constants/userRoles'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { useSettingsStore } from '@stores/settingsStore'; +import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore'; +import { resolveIcon } from '@/app/iconRegistry'; /* * SFR-01 반영 사항: @@ -34,82 +32,6 @@ const AUTH_METHOD_LABELS: Record = { sso: 'SSO', }; -interface NavItem { to: string; icon: React.ElementType; labelKey: string; } -interface NavDivider { dividerLabel: string; } -interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavDivider)[]; } -type NavEntry = NavItem | NavGroup; - -const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry; -const isDivider = (item: NavItem | NavDivider): item is NavDivider => 'dividerLabel' in item; - -const NAV_ENTRIES: NavEntry[] = [ - // ── 상황판·감시 ── - { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, - { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, - { to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' }, - { to: '/map-control', icon: Map, labelKey: 'nav.mapControl' }, - // ── 위험도·단속 ── - { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, - { to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' }, - // ── 탐지 ── - { to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' }, - { to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' }, - { to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' }, - // ── 이력·통계 ── - { to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' }, - { to: '/event-list', icon: List, labelKey: 'nav.eventList' }, - { to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' }, - { to: '/reports', icon: FileText, labelKey: 'nav.reports' }, - // ── 함정용 (그룹) ── - { - groupKey: 'group.fieldOps', icon: Ship, - items: [ - { to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' }, - { to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' }, - { to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' }, - { to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' }, - { to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' }, - ], - }, - // ── 모선 워크플로우 (운영자 의사결정, 그룹) ── - { - groupKey: 'group.parentInference', icon: GitBranch, - items: [ - { to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' }, - { to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' }, - { to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' }, - ], - }, - // ── 관리자 (그룹) ── - { - groupKey: 'group.admin', icon: Settings, - items: [ - { dividerLabel: 'AI 플랫폼' }, - { to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' }, - { to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' }, - { to: '/llm-ops', icon: Brain, labelKey: 'nav.llmOps' }, - { to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' }, - { dividerLabel: '시스템 운영' }, - { to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' }, - { to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' }, - { to: '/external-service', icon: Globe, labelKey: 'nav.externalService' }, - { dividerLabel: '사용자 관리' }, - { to: '/admin', icon: Settings, labelKey: 'nav.admin' }, - { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, - { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, - { dividerLabel: '감사·보안' }, - { to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' }, - { to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' }, - { to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' }, - ], - }, -]; - -// getPageLabel용 flat 목록 (divider 제외) -const NAV_ITEMS = NAV_ENTRIES.flatMap(e => - isGroup(e) ? e.items.filter((i): i is NavItem => !isDivider(i)) : [e] -); - function formatRemaining(seconds: number) { const m = Math.floor(seconds / 60); const s = seconds % 60; @@ -124,11 +46,13 @@ export function MainLayout() { const location = useLocation(); const { user, logout, hasAccess, sessionRemaining } = useAuth(); const contentRef = useRef(null); + const { getTopLevelEntries, getChildren } = useMenuStore(); - // getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n) + // getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반) const getPageLabel = (pathname: string): string => { - const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to)); - return item ? t(item.labelKey) : ''; + const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath); + const item = allItems.find((n) => pathname.startsWith(n.urlPath!)); + return item ? getMenuLabel(item, language) : ''; }; // 공통 검색 @@ -259,76 +183,31 @@ export function MainLayout() { )} - {/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */} + {/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}