feat: 메뉴 DB SSOT 구조화 — auth_perm_tree 기반 메뉴·권한·i18n 통합

## 핵심 변경
- 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) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-09 15:54:04 +09:00
부모 1147b96b00
커밋 6fe7a7daf4
26개의 변경된 파일1303개의 추가작업 그리고 332개의 파일을 삭제

파일 보기

@ -4,6 +4,7 @@ import gc.mda.kcg.auth.dto.LoginRequest;
import gc.mda.kcg.auth.dto.UserInfoResponse; import gc.mda.kcg.auth.dto.UserInfoResponse;
import gc.mda.kcg.auth.provider.AuthProvider; import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.menu.MenuConfigService;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@ -25,6 +26,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final JwtService jwtService; private final JwtService jwtService;
private final AppProperties appProperties; private final AppProperties appProperties;
private final MenuConfigService menuConfigService;
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req, public ResponseEntity<?> login(@RequestBody LoginRequest req,
@ -95,7 +97,8 @@ public class AuthController {
u.getUserSttsCd(), u.getUserSttsCd(),
u.getAuthProvider(), u.getAuthProvider(),
info.roles(), info.roles(),
info.permissions() info.permissions(),
menuConfigService.getActiveMenuConfig()
); );
} }

파일 보기

@ -1,5 +1,7 @@
package gc.mda.kcg.auth.dto; package gc.mda.kcg.auth.dto;
import gc.mda.kcg.menu.MenuConfigDto;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -12,5 +14,6 @@ public record UserInfoResponse(
String status, String status,
String authProvider, String authProvider,
List<String> roles, List<String> roles,
Map<String, List<String>> permissions Map<String, List<String>> permissions,
List<MenuConfigDto> menuConfig
) {} ) {}

파일 보기

@ -27,7 +27,7 @@ public class VesselAnalysisController {
* 기본: 최근 1시간 결과. * 기본: 최근 1시간 결과.
*/ */
@GetMapping("/vessels") @GetMapping("/vessels")
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listVessels( public Page<VesselAnalysisResponse> listVessels(
@RequestParam(required = false) String mmsi, @RequestParam(required = false) String mmsi,
@RequestParam(required = false) String zoneCode, @RequestParam(required = false) String zoneCode,
@ -48,7 +48,7 @@ public class VesselAnalysisController {
* 특정 선박 최신 분석 결과 (features 포함). * 특정 선박 최신 분석 결과 (features 포함).
*/ */
@GetMapping("/vessels/{mmsi}") @GetMapping("/vessels/{mmsi}")
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
} }
@ -57,7 +57,7 @@ public class VesselAnalysisController {
* 특정 선박 분석 이력 (기본 24시간). * 특정 선박 분석 이력 (기본 24시간).
*/ */
@GetMapping("/vessels/{mmsi}/history") @GetMapping("/vessels/{mmsi}/history")
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public List<VesselAnalysisResponse> getHistory( public List<VesselAnalysisResponse> getHistory(
@PathVariable String mmsi, @PathVariable String mmsi,
@RequestParam(defaultValue = "24") int hours @RequestParam(defaultValue = "24") int hours
@ -86,7 +86,7 @@ public class VesselAnalysisController {
* 환적 의심 목록 (최신 분석, MMSI 중복 제거). * 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/ */
@GetMapping("/transship") @GetMapping("/transship")
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listTransshipSuspects( public Page<VesselAnalysisResponse> listTransshipSuspects(
@RequestParam(defaultValue = "1") int hours, @RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,

파일 보기

@ -29,7 +29,7 @@ public class VesselAnalysisProxyController {
private final ParentResolutionRepository resolutionRepository; private final ParentResolutionRepository resolutionRepository;
@GetMapping @GetMapping
@RequirePermission(resource = "detection", operation = "READ") @RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() { public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis"); Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) { if (data == null) {

파일 보기

@ -27,7 +27,7 @@ public class StatsController {
* 실시간 KPI 전체 목록 조회 * 실시간 KPI 전체 목록 조회
*/ */
@GetMapping("/kpi") @GetMapping("/kpi")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionKpi> getKpi() { public List<PredictionKpi> getKpi() {
return kpiRepository.findAll(); return kpiRepository.findAll();
} }
@ -38,7 +38,7 @@ public class StatsController {
* @param to 종료 (: 2026-04) * @param to 종료 (: 2026-04)
*/ */
@GetMapping("/monthly") @GetMapping("/monthly")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsMonthly> getMonthly( public List<PredictionStatsMonthly> getMonthly(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -52,7 +52,7 @@ public class StatsController {
* @param to 종료 날짜 (: 2026-04-07) * @param to 종료 날짜 (: 2026-04-07)
*/ */
@GetMapping("/daily") @GetMapping("/daily")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsDaily> getDaily( public List<PredictionStatsDaily> getDaily(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -65,7 +65,7 @@ public class StatsController {
* @param hours 조회 시간 범위 (기본 24시간) * @param hours 조회 시간 범위 (기본 24시간)
*/ */
@GetMapping("/hourly") @GetMapping("/hourly")
@RequirePermission(resource = "statistics", operation = "READ") @RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsHourly> getHourly( public List<PredictionStatsHourly> getHourly(
@RequestParam(defaultValue = "24") int hours @RequestParam(defaultValue = "24") int hours
) { ) {

파일 보기

@ -79,13 +79,13 @@ public class MasterDataController {
// ======================================================================== // ========================================================================
@GetMapping("/api/patrol-ships") @GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol", operation = "READ") @RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() { public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode(); return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
} }
@PatchMapping("/api/patrol-ships/{id}/status") @PatchMapping("/api/patrol-ships/{id}/status")
@RequirePermission(resource = "patrol", operation = "UPDATE") @RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE")
public PatrolShip updatePatrolShipStatus( public PatrolShip updatePatrolShipStatus(
@PathVariable Long id, @PathVariable Long id,
@RequestBody PatrolShipStatusRequest request @RequestBody PatrolShipStatusRequest request
@ -108,7 +108,7 @@ public class MasterDataController {
// ======================================================================== // ========================================================================
@GetMapping("/api/vessel-permits") @GetMapping("/api/vessel-permits")
@RequirePermission(resource = "vessel", operation = "READ") // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits( public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag, @RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus, @RequestParam(required = false) String permitStatus,
@ -126,7 +126,7 @@ public class MasterDataController {
} }
@GetMapping("/api/vessel-permits/{mmsi}") @GetMapping("/api/vessel-permits/{mmsi}")
@RequirePermission(resource = "vessel", operation = "READ") // 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) { public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi) return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,

파일 보기

@ -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<MenuConfigDto> getMenuConfig() {
return menuConfigService.getActiveMenuConfig();
}
}

파일 보기

@ -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<String, String> labels
) {}

파일 보기

@ -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<String> GROUP_NODES = Set.of(
"field-ops", "parent-inference-workflow", "admin"
);
@Cacheable("menuConfig")
@Transactional(readOnly = true)
public List<MenuConfigDto> getActiveMenuConfig() {
List<PermTree> all = permTreeRepository.findByUseYn("Y");
List<MenuConfigDto> result = new ArrayList<>();
// 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL)
List<PermTree> 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<PermTree> 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<PermTree> 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<PermTree> 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()
);
}
}

파일 보기

@ -2,6 +2,8 @@ package gc.mda.kcg.permission;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@ -39,6 +41,29 @@ public class PermTree {
@Column(name = "use_yn", nullable = false, length = 1) @Column(name = "use_yn", nullable = false, length = 1)
private String useYn; 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<String, String> labels;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@ -53,6 +78,7 @@ public class PermTree {
if (useYn == null) useYn = "Y"; if (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0; if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0; if (rsrcLevel == null) rsrcLevel = 0;
if (navSort == null) navSort = 0;
} }
@PreUpdate @PreUpdate

파일 보기

@ -33,7 +33,7 @@ spring:
cache: cache:
type: caffeine type: caffeine
cache-names: permissions,users cache-names: permissions,users,menuConfig
caffeine: caffeine:
spec: maximumSize=1000,expireAfterWrite=10m spec: maximumSize=1000,expireAfterWrite=10m

파일 보기

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

파일 보기

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

파일 보기

@ -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';

파일 보기

@ -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 바로 앞

파일 보기

@ -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';

파일 보기

@ -1,6 +1,6 @@
# Database Migrations # 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 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다. > Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
@ -10,37 +10,208 @@
- **User**: `kcg-app` - **User**: `kcg-app`
- **Schema**: `kcg` - **Schema**: `kcg`
- **Host**: `211.208.115.83:5432` - **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` | 인증/조직/역할/사용자-역할/로그인 이력 | | `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 | | `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 | | `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
| `V004__access_logs.sql` | 감사로그/접근이력 | | `V004__access_logs.sql` | auth_audit_log + auth_access_log |
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) | | `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
| `V006__demo_accounts.sql` | 데모 계정 5종 | | `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 | | `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
### S1: 마스터 데이터 + Prediction 기반 (V008~V013) ### 마스터 데이터 (V008~V011)
| 파일 | 내용 | | 파일 | 내용 |
|---|---| |---|---|
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) | | `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) | | `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) | | `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) | | `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) | ### 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/사용자 생성 (관리자 권한 필요) ### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
```sql ```sql
-- snp 관리자 계정으로 접속
psql -h 211.208.115.83 -U snp -d postgres psql -h 211.208.115.83 -U snp -d postgres
CREATE DATABASE kcgaidb; CREATE DATABASE kcgaidb;
@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run
### 수동 적용 ### 수동 적용
```bash ```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 불일치 시 (마이그레이션 파일 수정 후) ### 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"}'
);
```

파일 보기

@ -1,42 +1,13 @@
import { Suspense, useMemo, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/app/auth/AuthContext'; import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
import { MainLayout } from '@/app/layout/MainLayout'; import { MainLayout } from '@/app/layout/MainLayout';
import { LoginPage } from '@features/auth'; import { LoginPage } from '@features/auth';
/* SFR-01 */ import { AccessControl } from '@features/admin'; import { useMenuStore } from '@stores/menuStore';
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin'; import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
/* SFR-03 */ import { DataHub } from '@features/admin';
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations'; // 권한 노드 없는 드릴다운 라우트 (인증만 체크)
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment'; const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
/* 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';
/** /**
* . * .
@ -69,66 +40,66 @@ function ProtectedRoute({
return <>{children}</>; return <>{children}</>;
} }
function LoadingFallback() {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-sm text-hint"> ...</div>
</div>
);
}
/**
* DB menu_config Route .
* React Router v6는 <Routes> <Route> .
*/
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 (
<Route
key={item.menuCd}
path={path}
element={
<ProtectedRoute resource={item.rsrcCd ?? undefined}>
<Suspense fallback={<LoadingFallback />}>
<Comp />
</Suspense>
</ProtectedRoute>
}
/>
);
});
}
function AppRoutes() {
const dynamicRoutes = useDynamicRoutes();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{dynamicRoutes}
{/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */}
<Route path="vessel/:id" element={<Suspense fallback={<LoadingFallback />}><VesselDetail /></Suspense>} />
</Route>
</Routes>
);
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Routes> <AppRoutes />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{/* SFR-12 대시보드 */}
<Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
<Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
{/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
<Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
{/* SFR-07~08 순찰경로 */}
<Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
<Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
{/* SFR-11 이력 */}
<Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
<Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
{/* SFR-15~17 현장 대응 */}
<Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
<Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
<Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
{/* SFR-13~14 통계·외부연계 */}
<Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
<Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
<Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
{/* SFR-04 AI 모델 */}
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
{/* SFR-18~20 AI 운영 */}
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
<Route path="llm-ops" element={<ProtectedRoute resource="ai-operations:llm-ops"><LLMOpsPage /></ProtectedRoute>} />
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
{/* SFR-03 데이터허브 */}
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
{/* SFR-02 환경설정 */}
<Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
<Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
{/* SFR-01 권한·시스템 */}
<Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
<Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
{/* Phase 4: 관리자 로그 */}
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
{/* Phase 4: 모선 워크플로우 */}
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
{/* 기존 유지 */}
<Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
<Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
<Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
</Route>
</Routes>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
); );

파일 보기

@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi'; import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore';
/* /*
* SFR-01: 시스템 * SFR-01: 시스템
@ -33,47 +34,6 @@ export interface AuthUser {
// ─── 세션 타임아웃 (30분) ────────────────── // ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000; const SESSION_TIMEOUT = 30 * 60 * 1000;
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
const PATH_TO_RESOURCE: Record<string, string> = {
'/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 { interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
loading: boolean; loading: boolean;
@ -133,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let alive = true; let alive = true;
fetchMe() fetchMe()
.then((b) => { .then((b) => {
if (alive && b) setUser(backendToAuthUser(b)); if (alive && b) {
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
}
}) })
.finally(() => { .finally(() => {
if (alive) setLoading(false); if (alive) setLoading(false);
@ -175,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const b = await loginApi(account, password); const b = await loginApi(account, password);
setUser(backendToAuthUser(b)); setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
setLastActivity(Date.now()); setLastActivity(Date.now());
} catch (e) { } catch (e) {
if (e instanceof LoginError) throw e; if (e instanceof LoginError) throw e;
@ -187,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await logoutApi(); await logoutApi();
} finally { } finally {
setUser(null); setUser(null);
useMenuStore.getState().clear();
} }
}, []); }, []);
@ -201,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const hasAccess = useCallback( const hasAccess = useCallback(
(path: string) => { (path: string) => {
if (!user) return false; if (!user) return false;
// 경로의 첫 세그먼트로 매핑 // DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p)); const resource = useMenuStore.getState().getResourceForPath(path);
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능) if (!resource) return true;
const resource = PATH_TO_RESOURCE[matched];
return hasPermission(resource, 'READ'); return hasPermission(resource, 'READ');
}, },
[user, hasPermission], [user, hasPermission],

파일 보기

@ -0,0 +1,136 @@
import { lazy, type ComponentType } from 'react';
type LazyComponent = React.LazyExoticComponent<ComponentType<unknown>>;
/**
* DB menu_config.component_key lazy-loaded React .
* 1 .
*/
export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
// ── 상황판·감시 ──
'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 })),
),
};

파일 보기

@ -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<string, LucideIcon> = {
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;
}

파일 보기

@ -2,18 +2,16 @@ import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { import {
LayoutDashboard, Map, List, Ship, Anchor, Radar, LogOut, ChevronLeft, ChevronRight,
FileText, Settings, LogOut, ChevronLeft, ChevronRight, Shield, Bell, Search, Clock, Lock,
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, Download, FileSpreadsheet, Printer,
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext'; import { useAuth } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles'; import { getRoleColorHex } from '@shared/constants/userRoles';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore'; import { useSettingsStore } from '@stores/settingsStore';
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
import { resolveIcon } from '@/app/iconRegistry';
/* /*
* SFR-01 : * SFR-01 :
@ -34,82 +32,6 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
sso: 'SSO', 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) { function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;
@ -124,11 +46,13 @@ export function MainLayout() {
const location = useLocation(); const location = useLocation();
const { user, logout, hasAccess, sessionRemaining } = useAuth(); const { user, logout, hasAccess, sessionRemaining } = useAuth();
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const { getTopLevelEntries, getChildren } = useMenuStore();
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n) // getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
const getPageLabel = (pathname: string): string => { const getPageLabel = (pathname: string): string => {
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to)); const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
return item ? t(item.labelKey) : ''; const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
return item ? getMenuLabel(item, language) : '';
}; };
// 공통 검색 // 공통 검색
@ -259,76 +183,31 @@ export function MainLayout() {
</div> </div>
)} )}
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */} {/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5"> <nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{NAV_ENTRIES.map((entry) => { {getTopLevelEntries().map((entry) => {
if (isGroup(entry)) { if (entry.menuType === 'GROUP') {
// 그룹 내 RBAC 필터링 (divider는 유지)
const navItems = entry.items.filter((item): item is NavItem => !isDivider(item));
const accessibleItems = navItems.filter((item) => hasAccess(item.to));
if (accessibleItems.length === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = accessibleItems.some((item) => location.pathname.startsWith(item.to));
return ( return (
<div key={entry.groupKey}> <GroupMenu
{/* 그룹 헤더 */} key={entry.menuCd}
<button group={entry}
onClick={() => toggleGroup(entry.groupKey)} children={getChildren(entry.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${ collapsed={collapsed}
isAnyActive || openGroups.has(entry.groupKey) hasAccess={hasAccess}
? 'text-foreground bg-surface-overlay' openGroups={openGroups}
: 'text-hint hover:bg-surface-overlay hover:text-label' toggleGroup={toggleGroup}
}`} location={location}
> language={language}
<GroupIcon className="w-4 h-4 shrink-0" /> />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 그룹 하위 메뉴 */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{entry.items.map((item, idx) => {
if (isDivider(item)) {
if (collapsed) return null;
return (
<div key={`div-${idx}`} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{item.dividerLabel}</span>
</div>
);
}
if (!hasAccess(item.to)) return null;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
<item.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
</NavLink>
);
})}
</div>
)}
</div>
); );
} }
// 일반 메뉴 아이템 // 일반 ITEM
if (!hasAccess(entry.to)) return null; if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
const Icon = resolveIcon(entry.icon);
return ( return (
<NavLink <NavLink
key={entry.to} key={entry.menuCd}
to={entry.to} to={entry.urlPath}
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${ `flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
isActive isActive
@ -337,8 +216,8 @@ export function MainLayout() {
}` }`
} }
> >
<entry.icon className="w-4 h-4 shrink-0" /> {Icon && <Icon className="w-4 h-4 shrink-0" />}
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>} {!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
</NavLink> </NavLink>
); );
})} })}
@ -523,3 +402,88 @@ export function MainLayout() {
</div> </div>
); );
} }
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
function GroupMenu({
group,
children,
collapsed,
hasAccess,
openGroups,
toggleGroup,
location,
language,
}: {
group: MenuConfigItem;
children: MenuConfigItem[];
collapsed: boolean;
hasAccess: (path: string) => boolean;
openGroups: Set<string>;
toggleGroup: (name: string) => void;
location: { pathname: string };
language: string;
}) {
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
if (accessibleItems.length === 0) return null;
const GroupIcon = resolveIcon(group.icon);
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
return (
<div>
<button
type="button"
onClick={() => toggleGroup(group.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
{getMenuLabel(group, language)}
</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</>
)}
</button>
{isOpen && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{children.map((child) => {
if (child.menuType === 'DIVIDER') {
if (collapsed) return null;
return (
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
</div>
);
}
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
const ChildIcon = resolveIcon(child.icon);
return (
<NavLink
key={child.menuCd}
to={child.urlPath}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
{!collapsed && (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react'; import { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
import { import {
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown, Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
ExternalLink, Layers,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card'; import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge'; import { Badge } from '@shared/components/ui/badge';
@ -14,6 +15,7 @@ import {
type Operation, type TreeNode, type PermRow, type Operation, type TreeNode, type PermRow,
} from '@/lib/permission/permResolver'; } from '@/lib/permission/permResolver';
import { useAuth } from '@/app/auth/AuthContext'; import { useAuth } from '@/app/auth/AuthContext';
import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker'; import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi'; import { updateRole as apiUpdateRole } from '@/services/adminApi';
@ -100,7 +102,7 @@ export function PermissionsPanel() {
setDraftPerms(m); setDraftPerms(m);
}, [selectedRole]); }, [selectedRole]);
// 트리 → 트리 인덱싱 (parent → children) // 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
const childrenMap = useMemo(() => { const childrenMap = useMemo(() => {
const m = new Map<string | null, PermTreeNode[]>(); const m = new Map<string | null, PermTreeNode[]>();
for (const n of tree) { for (const n of tree) {
@ -109,6 +111,14 @@ export function PermissionsPanel() {
arr.push(n); arr.push(n);
m.set(n.parentCd, arr); m.set(n.parentCd, arr);
} }
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
for (const [, arr] of m.entries()) {
arr.sort((a, b) => {
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
return aSort - bSort;
});
}
return m; return m;
}, [tree]); }, [tree]);
@ -280,9 +290,13 @@ export function PermissionsPanel() {
const hasChildren = children.length > 0; const hasChildren = children.length > 0;
const isExpanded = expanded.has(node.rsrcCd); const isExpanded = expanded.has(node.rsrcCd);
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
const lang = useSettingsStore.getState().language;
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
return ( return (
<> <Fragment key={node.rsrcCd}>
<tr key={node.rsrcCd} className="border-t border-border hover:bg-surface-overlay/30"> <tr className="border-t border-border hover:bg-surface-overlay/30">
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}> <td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{hasChildren ? ( {hasChildren ? (
@ -291,8 +305,14 @@ export function PermissionsPanel() {
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />} {isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button> </button>
) : <span className="w-4" />} ) : <span className="w-4" />}
<span className="text-[11px] text-heading font-medium">{node.rsrcNm}</span> {/* 페이지/패널 구분 아이콘 */}
{node.urlPath
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
}
<span className="text-[11px] text-heading font-medium">{displayName}</span>
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span> <span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
</div> </div>
</td> </td>
{OPERATIONS.map((op) => { {OPERATIONS.map((op) => {
@ -324,7 +344,7 @@ export function PermissionsPanel() {
})} })}
</tr> </tr>
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))} {isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
</> </Fragment>
); );
}; };

파일 보기

@ -61,6 +61,12 @@ export interface PermTreeNode {
rsrcLevel: number; rsrcLevel: number;
sortOrd: number; sortOrd: number;
useYn: string; useYn: string;
/** V021: 메뉴 SSOT */
labelKey: string | null;
urlPath: string | null;
navSort: number;
/** V022: DB i18n JSONB {"ko":"...", "en":"..."} */
labels: Record<string, string>;
} }
export interface RoleWithPermissions { export interface RoleWithPermissions {

파일 보기

@ -6,6 +6,23 @@
const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface MenuConfigItem {
menuCd: string;
parentMenuCd: string | null;
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
urlPath: string | null;
rsrcCd: string | null;
componentKey: string | null;
icon: string | null;
labelKey: string | null;
dividerLabel: string | null;
menuLevel: number;
sortOrd: number;
useYn: string;
/** DB i18n SSOT: {"ko":"종합 상황판","en":"Dashboard"} */
labels: Record<string, string>;
}
export interface BackendUser { export interface BackendUser {
id: string; id: string;
account: string; account: string;
@ -17,6 +34,8 @@ export interface BackendUser {
roles: string[]; roles: string[];
/** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */ /** rsrcCd → operCd[] (READ/CREATE/UPDATE/DELETE/EXPORT) */
permissions: Record<string, string[]>; permissions: Record<string, string[]>;
/** DB 메뉴 설정 SSOT */
menuConfig: MenuConfigItem[];
} }
export class LoginError extends Error { export class LoginError extends Error {

파일 보기

@ -0,0 +1,66 @@
import { create } from 'zustand';
export interface MenuConfigItem {
menuCd: string;
parentMenuCd: string | null;
menuType: 'ITEM' | 'GROUP' | 'DIVIDER';
urlPath: string | null;
rsrcCd: string | null;
componentKey: string | null;
icon: string | null;
labelKey: string | null;
dividerLabel: string | null;
menuLevel: number;
sortOrd: number;
useYn: string;
labels: Record<string, string>;
}
/** DB labels에서 현재 언어의 라벨을 반환 */
export function getMenuLabel(item: MenuConfigItem, lang: string): string {
return item.labels?.[lang] || item.labels?.ko || item.menuCd;
}
interface MenuStore {
items: MenuConfigItem[];
loaded: boolean;
setMenuConfig: (items: MenuConfigItem[]) => void;
clear: () => void;
/** path → rsrcCd (longest-match, PATH_TO_RESOURCE 대체) */
getResourceForPath: (path: string) => string | undefined;
/** 최상위 항목 (menu_level=0, 사이드바 표시용) */
getTopLevelEntries: () => MenuConfigItem[];
/** 그룹 하위 항목 */
getChildren: (parentMenuCd: string) => MenuConfigItem[];
/** 라우팅 가능 항목 (ITEM + urlPath 보유) */
getRoutableItems: () => MenuConfigItem[];
}
export const useMenuStore = create<MenuStore>((set, get) => ({
items: [],
loaded: false,
setMenuConfig: (items) => set({ items, loaded: true }),
clear: () => set({ items: [], loaded: false }),
getResourceForPath: (path) => {
const { items } = get();
// longest-match: 가장 구체적인 경로 우선 (삽입 순서 의존 버그 해결)
const candidates = items.filter((i) => i.urlPath && i.rsrcCd);
candidates.sort((a, b) => b.urlPath!.length - a.urlPath!.length);
const match = candidates.find((i) => path.startsWith(i.urlPath!));
return match?.rsrcCd ?? undefined;
},
getTopLevelEntries: () =>
get().items.filter(
(i) => i.menuLevel === 0 && i.parentMenuCd === null && i.useYn !== 'H',
),
getChildren: (parentMenuCd) =>
get().items.filter((i) => i.parentMenuCd === parentMenuCd),
getRoutableItems: () =>
get().items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
}));