diff --git a/.gitignore b/.gitignore index 43468a6..a3652b2 100755 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ backend/data/*.db-wal # Large reference data (keep locally, do not commit) _reference/ -scat/ +/scat/ 참고용/ 논문/ diff --git a/CLAUDE.md b/CLAUDE.md index 29f527d..42410c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,10 @@ - **프로젝트 타입**: react-ts (모노레포) - **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 -- **Backend**: Express 4 + better-sqlite3 + TypeScript +- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript +- **DB**: PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) - **상태관리**: Zustand (클라이언트), TanStack Query (서버) -- **지도**: Leaflet, OpenLayers +- **지도**: Leaflet + react-leaflet - **실시간**: Socket.IO ## 빌드/실행 @@ -49,16 +50,27 @@ wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ │ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ └── ... analysis, board, incidents, map, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ ├── utils/ coordinates, geo, sanitize +│ │ ├── data/ layerData.ts (UI 레이어 트리) +│ │ └── mock/ vesselMockData, backtrackMockData +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 (오버레이, hooks, services) +│ ├── incidents/ 사건/사고 관리 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT 조사 +│ └── admin/ 관리자 (사용자/권한/메뉴/설정) ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -68,15 +80,23 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) -├── database/ SQL 초기화 스크립트 -├── docs/ 개발 문서 (README, 가이드, 변경이력) +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 +│ ├── init.sql wing DB 초기 스키마 +│ ├── auth_init.sql wing_auth DB 초기 스키마 +│ └── migration/ 마이그레이션 (001_layer, 002_hns_substance) +├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) ``` +### Path Alias +- `@common/*` → `src/common/*` (공통 모듈) +- `@tabs/*` → `src/tabs/*` (탭 패키지) + ## 팀 컨벤션 `.claude/rules/` 디렉토리 참조: - `team-policy.md` — 보안/품질 정책 diff --git a/README.md b/README.md index 7832a3e..aab89dc 100644 --- a/README.md +++ b/README.md @@ -90,17 +90,26 @@ cd frontend && npm install && npm run dev # localhost:5173 wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 각 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ └── ... analysis, board, incidents, map, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand 상태 (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 +│ ├── hns/ HNS 분석 +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ ├── incidents/ 사건/사고 +│ ├── board/ 게시판 +│ ├── reports/ 보고서 +│ ├── assets/ 자산 관리 +│ ├── scat/ Pre-SCAT +│ └── admin/ 관리자 ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -110,10 +119,11 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) -├── database/ SQL 초기화 스크립트 +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 ├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) @@ -126,12 +136,12 @@ wing/ | 영역 | 기술 | |------|------| | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet, OpenLayers | +| 지도 | Leaflet + react-leaflet | | 실시간 | Socket.IO | | 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | | CI/CD | Gitea Actions | --- diff --git a/backend/package-lock.json b/backend/package-lock.json index 761c796..a3d5138 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "dependencies": { "bcrypt": "^6.0.0", - "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", @@ -22,7 +21,6 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", - "@types/better-sqlite3": "^7.6.12", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", @@ -513,16 +511,6 @@ "@types/node": "*" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -773,17 +761,6 @@ "node": ">= 18" } }, - "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -793,26 +770,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -873,30 +830,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -941,12 +874,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1077,30 +1004,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1120,15 +1023,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -1191,15 +1085,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1287,15 +1172,6 @@ "node": ">= 0.6" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1404,12 +1280,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1489,12 +1359,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1598,12 +1462,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1729,38 +1587,12 @@ "node": ">= 14" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -1978,18 +1810,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -2005,15 +1825,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2023,24 +1834,12 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2050,18 +1849,6 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -2153,15 +1940,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2336,32 +2114,6 @@ "node": ">=0.10.0" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2375,16 +2127,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2436,35 +2178,6 @@ "node": ">=0.10.0" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2693,51 +2406,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2756,15 +2424,6 @@ "node": ">= 0.8" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2861,43 +2520,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2927,18 +2549,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2982,12 +2592,6 @@ "node": ">= 0.8" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3121,12 +2725,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 36aaee1..1e578cb 100755 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,6 @@ }, "dependencies": { "bcrypt": "^6.0.0", - "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.3.1", @@ -23,7 +22,6 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", - "@types/better-sqlite3": "^7.6.12", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts index 4193f54..0a2fc47 100644 --- a/backend/src/auth/authMiddleware.ts +++ b/backend/src/auth/authMiddleware.ts @@ -1,11 +1,13 @@ import type { Request, Response, NextFunction } from 'express' import { verifyToken, getTokenFromCookie } from './jwtProvider.js' import type { JwtPayload } from './jwtProvider.js' +import { getUserInfo } from './authService.js' declare global { namespace Express { interface Request { user?: JwtPayload + resolvedPermissions?: Record } } } @@ -43,3 +45,43 @@ export function requireRole(...roles: string[]) { next() } } + +/** + * 리소스 + 오퍼레이션 기반 권한 검사 미들웨어. + * + * OPER_CD는 HTTP Method가 아닌 비즈니스 의미로 결정한다. + * 오퍼레이션 미지정 시 기본 'READ'. + * + * 사용 예: + * router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) + * router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) + * router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) + * router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) + */ +export function requirePermission(resource: string, operation: string = 'READ') { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ error: '인증이 필요합니다.' }) + return + } + + try { + // req에 캐싱된 permissions 재사용 (요청당 1회만 DB 조회) + if (!req.resolvedPermissions) { + const userInfo = await getUserInfo(req.user.sub) + req.resolvedPermissions = userInfo.permissions + } + + const allowedOps = req.resolvedPermissions[resource] + if (allowedOps && allowedOps.includes(operation)) { + next() + return + } + + res.status(403).json({ error: '접근 권한이 없습니다.' }) + } catch (err) { + console.error('[auth] 권한 확인 오류:', err) + res.status(500).json({ error: '권한 확인 중 오류가 발생했습니다.' }) + } + } +} diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts index 1be0467..0d607b7 100644 --- a/backend/src/auth/authService.ts +++ b/backend/src/auth/authService.ts @@ -2,6 +2,8 @@ import bcrypt from 'bcrypt' import { authPool } from '../db/authDb.js' import { signToken, setTokenCookie } from './jwtProvider.js' import type { Response } from 'express' +import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js' +import { getPermTreeNodes } from '../roles/roleService.js' const MAX_FAIL_COUNT = 5 const SALT_ROUNDS = 10 @@ -24,7 +26,7 @@ interface AuthUserInfo { rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] - permissions: string[] + permissions: Record } export async function login( @@ -127,9 +129,9 @@ export async function getUserInfo(userId: string): Promise { const row = userResult.rows[0] - // 역할 조회 + // 역할 조회 (ROLE_SN + ROLE_CD) const rolesResult = await authPool.query( - `SELECT r.ROLE_CD as role_cd + `SELECT r.ROLE_SN as role_sn, r.ROLE_CD as role_cd FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`, @@ -137,17 +139,63 @@ export async function getUserInfo(userId: string): Promise { ) const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd) + const roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn) - // 권한 조회 (역할 기반) - const permsResult = await authPool.query( - `SELECT DISTINCT p.RSRC_CD as rsrc_cd - FROM AUTH_PERM p - JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN - WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, - [userId] - ) + // 트리 기반 resolved permissions (리소스 × 오퍼레이션) + let permissions: Record + try { + const treeNodes = await getPermTreeNodes() - const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd) + if (treeNodes.length > 0) { + // AUTH_PERM_TREE가 존재 → 트리 기반 resolve + const explicitPermsResult = await authPool.query( + `SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn + FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`, + [roleSns] + ) + + const explicitPermsPerRole = new Map>() + for (const sn of roleSns) { + explicitPermsPerRole.set(sn, new Map()) + } + for (const p of explicitPermsResult.rows) { + const roleMap = explicitPermsPerRole.get(p.role_sn) + if (roleMap) { + const key = makePermKey(p.rsrc_cd, p.oper_cd) + roleMap.set(key, p.grant_yn === 'Y') + } + } + + const granted = resolvePermissions(treeNodes, explicitPermsPerRole) + permissions = grantedSetToRecord(granted) + } else { + // AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback + const permsResult = await authPool.query( + `SELECT DISTINCT p.RSRC_CD as rsrc_cd + FROM AUTH_PERM p + JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN + WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, + [userId] + ) + permissions = {} + for (const p of permsResult.rows) { + permissions[p.rsrc_cd] = ['READ'] + } + } + } catch { + // AUTH_PERM_TREE 테이블 미존재 시 fallback + const permsResult = await authPool.query( + `SELECT DISTINCT p.RSRC_CD as rsrc_cd + FROM AUTH_PERM p + JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN + WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, + [userId] + ) + permissions = {} + for (const p of permsResult.rows) { + permissions[p.rsrc_cd] = ['READ'] + } + } return { id: row.user_id, diff --git a/backend/src/board/boardRouter.ts b/backend/src/board/boardRouter.ts new file mode 100644 index 0000000..e854c5c --- /dev/null +++ b/backend/src/board/boardRouter.ts @@ -0,0 +1,137 @@ +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js' + +const router = Router() + +// 카테고리 → 리소스 매핑 +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} + +// ============================================================ +// GET /api/board — 게시글 목록 +// ============================================================ +router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.query + const result = await listPosts({ + categoryCd: categoryCd as string | undefined, + search: search as string | undefined, + page: page ? parseInt(page as string, 10) : undefined, + size: size ? parseInt(size as string, 10) : undefined, + }) + res.json(result) + } catch (err) { + console.error('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// GET /api/board/:sn — 게시글 상세 +// ============================================================ +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + const post = await getPost(sn) + res.json(post) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 상세 조회 오류:', err) + res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한) +// ============================================================ +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + const result = await createPost({ + categoryCd, + title, + content, + authorId: req.user!.sub, + pinnedYn, + }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 작성 오류:', err) + res.status(500).json({ error: '게시글 작성 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서) +// ============================================================ +router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + + const { title, content, pinnedYn } = req.body + await updatePost(sn, { title, content, pinnedYn }, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 수정 오류:', err) + res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증) +// ============================================================ +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 게시글 번호입니다.' }) + return + } + + await deletePost(sn, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/board/boardService.ts b/backend/src/board/boardService.ts new file mode 100644 index 0000000..1b554a7 --- /dev/null +++ b/backend/src/board/boardService.ts @@ -0,0 +1,243 @@ +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface PostDetail extends PostListItem { + content: string | null + mdfcnDtm: string | null +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} + +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +interface UpdatePostInput { + title?: string + content?: string + pinnedYn?: string +} + +// ============================================================ +// CRUD 함수 +// ============================================================ + +const VALID_CATEGORIES = ['NOTICE', 'DATA', 'QNA', 'MANUAL'] + +export async function listPosts(input: ListPostsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1 + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 + const offset = (page - 1) * size + + let whereClause = `WHERE bp.USE_YN = 'Y'` + const params: (string | number)[] = [] + let paramIdx = 1 + + if (input.categoryCd) { + whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` + params.push(input.categoryCd) + } + + if (input.search) { + whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${input.search}%`) + paramIdx++ + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 목록 (상단고정 우선, 등록일 내림차순) + const listParams = [...params, size, offset] + const listResult = await wingPool.query( + `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, + bp.AUTHOR_ID as author_id, u.USER_NM as author_name, + bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, + bp.REG_DTM as reg_dtm + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause} + ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams + ) + + const items: PostListItem[] = listResult.rows.map((r: Record) => ({ + sn: r.sn as number, + categoryCd: r.category_cd as string, + title: r.title as string, + authorId: r.author_id as string, + authorName: r.author_name as string, + viewCnt: r.view_cnt as number, + pinnedYn: r.pinned_yn as string, + regDtm: r.reg_dtm as string, + })) + + return { items, totalCount, page, size } +} + +export async function getPost(postSn: number): Promise { + // 조회수 증가 + 상세 조회 (단일 쿼리) + const result = await wingPool.query( + `UPDATE BOARD_POST SET VIEW_CNT = VIEW_CNT + 1 + WHERE POST_SN = $1 AND USE_YN = 'Y' + RETURNING POST_SN as sn, CATEGORY_CD as category_cd, TITLE as title, + CONTENT as content, AUTHOR_ID as author_id, + VIEW_CNT as view_cnt, PINNED_YN as pinned_yn, + REG_DTM as reg_dtm, MDFCN_DTM as mdfcn_dtm`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + + // 작성자명 조회 + const authorResult = await wingPool.query( + 'SELECT USER_NM as name FROM AUTH_USER WHERE USER_ID = $1', + [row.author_id] + ) + + return { + sn: row.sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: authorResult.rows[0]?.name || '알 수 없음', + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} + +export async function createPost(input: CreatePostInput): Promise<{ sn: number }> { + if (!VALID_CATEGORIES.includes(input.categoryCd)) { + throw new AuthError('유효하지 않은 카테고리입니다.', 400) + } + + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400) + } + + const result = await wingPool.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) + VALUES ($1, $2, $3, $4, $5) + RETURNING POST_SN as sn`, + [input.categoryCd, input.title.trim(), input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { sn: result.rows[0].sn } +} + +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string +): Promise { + // 게시글 존재 + 작성자 확인 + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`) + params.push(input.title.trim()) + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`) + params.push(input.content) + } + if (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} + +export async function deletePost(postSn: number, requesterId: string): Promise { + // 게시글 존재 + 작성자 확인 + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) + } + + // 논리 삭제 + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} diff --git a/backend/src/db/authDb.ts b/backend/src/db/authDb.ts index d82613c..68d06d9 100644 --- a/backend/src/db/authDb.ts +++ b/backend/src/db/authDb.ts @@ -1,33 +1,13 @@ -import pg from 'pg' +// ============================================================ +// 하위 호환: authPool → wingPool re-export +// DB 통합으로 wing_auth DB가 wing DB의 auth 스키마로 이전됨. +// 기존 코드에서 authPool을 import하는 곳에서 에러 없이 동작하도록 유지. +// 신규 코드는 wingDb.ts의 wingPool을 직접 import할 것. +// ============================================================ +import { wingPool, testWingDbConnection } from './wingDb.js' -const { Pool } = pg - -const authPool = new Pool({ - host: process.env.AUTH_DB_HOST || 'localhost', - port: Number(process.env.AUTH_DB_PORT) || 5432, - database: process.env.AUTH_DB_NAME || 'wing_auth', - user: process.env.AUTH_DB_USER || 'wing_auth', - password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026', - max: 10, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, -}) - -authPool.on('error', (err) => { - console.error('[authDb] 예기치 않은 연결 오류:', err.message) -}) +export const authPool = wingPool export async function testAuthDbConnection(): Promise { - try { - const client = await authPool.connect() - await client.query('SELECT 1') - client.release() - console.log('[authDb] wing_auth 데이터베이스 연결 성공') - return true - } catch (err) { - console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message) - return false - } + return testWingDbConnection() } - -export { authPool } diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts deleted file mode 100755 index de1109c..0000000 --- a/backend/src/db/database.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Database from 'better-sqlite3' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -const dbPath = join(__dirname, '../../data/layers.db') - -export const db = new Database(dbPath) - -// 데이터베이스 초기화 -export function initDatabase() { - db.exec(` - CREATE TABLE IF NOT EXISTS layers ( - cmn_cd TEXT PRIMARY KEY, - up_cmn_cd TEXT, - cmn_cd_full_nm TEXT NOT NULL, - cmn_cd_nm TEXT NOT NULL, - cmn_cd_level INTEGER NOT NULL, - clnm TEXT, - FOREIGN KEY (up_cmn_cd) REFERENCES layers(cmn_cd) - ); - - CREATE INDEX IF NOT EXISTS idx_up_cmn_cd ON layers(up_cmn_cd); - CREATE INDEX IF NOT EXISTS idx_cmn_cd_level ON layers(cmn_cd_level); - `) -} - -export default db diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 1a27bf1..1beff4f 100755 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -1,91 +1,106 @@ +import 'dotenv/config' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' -import db, { initDatabase } from './database.js' +import { wingPool } from './wingDb.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) async function seedDatabase() { - console.log('데이터베이스 초기화 중...') - initDatabase() + console.log('wing DB 레이어 시드 시작...') - // 기존 데이터 삭제 - db.exec('DELETE FROM layers') + const client = await wingPool.connect() - // CSV 파일 읽기 - const csvPath = path.join(__dirname, '../../../LayerList.csv') - const csvContent = fs.readFileSync(csvPath, 'utf-8') - - // CSV 파싱 - const lines = csvContent.split('\n') - const headers = lines[0].split(',').map(h => h.replace(/"/g, '').trim()) - - const insert = db.prepare(` - INSERT INTO layers (cmn_cd, up_cmn_cd, cmn_cd_full_nm, cmn_cd_nm, cmn_cd_level, clnm) - VALUES (?, ?, ?, ?, ?, ?) - `) + try { + // CSV 파일 읽기 + const csvPath = path.join(__dirname, '../../../_reference/LayerList.csv') + const csvContent = fs.readFileSync(csvPath, 'utf-8') - const insertMany = db.transaction((rows: any[]) => { - for (const row of rows) { - insert.run(row) - } - }) + // CSV 파싱 + const lines = csvContent.split('\n') - const rows = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim() - if (!line) continue + const rows: (string | number | null)[][] = [] - // CSV 파싱 (쉼표로 구분, 따옴표 처리) - const values = [] - let current = '' - let inQuotes = false + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue - for (let j = 0; j < line.length; j++) { - const char = line[j] - - if (char === '"') { - inQuotes = !inQuotes - } else if (char === ',' && !inQuotes) { - values.push(current.trim()) - current = '' - } else { - current += char + const values: string[] = [] + let current = '' + let inQuotes = false + + for (let j = 0; j < line.length; j++) { + const char = line[j] + if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + values.push(current.trim()) + current = '' + } else { + current += char + } + } + values.push(current.trim()) + + const row = values.map(v => { + if (v === 'NULL' || v === '') return null + return v.replace(/"/g, '') + }) + + if (row.length >= 6) { + rows.push([ + row[0], // LAYER_CD + row[1], // UP_LAYER_CD + row[2], // LAYER_FULL_NM + row[3], // LAYER_NM + parseInt(row[4] || '0', 10), // LAYER_LEVEL + row[5], // WMS_LAYER_NM + ]) } } - values.push(current.trim()) - // NULL 값 처리 - const row = values.map(v => { - if (v === 'NULL' || v === '') return null - return v.replace(/"/g, '') - }) + console.log(`${rows.length}개의 레이어 데이터 삽입 중...`) - if (row.length >= 6) { - rows.push([ - row[0], // cmn_cd - row[1], // up_cmn_cd - row[2], // cmn_cd_full_nm - row[3], // cmn_cd_nm - parseInt(row[4] || '0'), // cmn_cd_level - row[5], // clnm - ]) + await client.query('BEGIN') + + // 기존 데이터 삭제 + await client.query('DELETE FROM LAYER') + + // FK 제약 때문에 상위 레이어(낮은 레벨)부터 삽입 + const sortedRows = rows.sort((a, b) => (a[4] as number) - (b[4] as number)) + + for (const row of sortedRows) { + await client.query( + `INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (LAYER_CD) DO UPDATE SET + UP_LAYER_CD = EXCLUDED.UP_LAYER_CD, + LAYER_FULL_NM = EXCLUDED.LAYER_FULL_NM, + LAYER_NM = EXCLUDED.LAYER_NM, + LAYER_LEVEL = EXCLUDED.LAYER_LEVEL, + WMS_LAYER_NM = EXCLUDED.WMS_LAYER_NM`, + row + ) } + + await client.query('COMMIT') + + // 결과 확인 + const { rows: countResult } = await client.query('SELECT COUNT(*) as count FROM LAYER') + console.log(`시드 완료! 총 ${countResult[0].count}개의 레이어가 저장되었습니다.`) + } catch (err) { + await client.query('ROLLBACK') + console.error('시드 실패:', err) + throw err + } finally { + client.release() + await wingPool.end() } - - console.log(`${rows.length}개의 레이어 데이터 삽입 중...`) - insertMany(rows) - - console.log('시드 완료!') - - // 결과 확인 - const count = db.prepare('SELECT COUNT(*) as count FROM layers').get() as { count: number } - console.log(`총 ${count.count}개의 레이어가 저장되었습니다.`) - - db.close() } -seedDatabase().catch(console.error) +seedDatabase().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/backend/src/db/seedHns.ts b/backend/src/db/seedHns.ts new file mode 100644 index 0000000..a89aee7 --- /dev/null +++ b/backend/src/db/seedHns.ts @@ -0,0 +1,63 @@ +import 'dotenv/config' +import { wingPool } from './wingDb.js' + +// 프론트엔드 정적 데이터를 직접 import (tsx로 실행) +import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js' + +async function seedHnsSubstances() { + console.log('HNS 물질정보 시드 시작...') + console.log(`총 ${HNS_SEARCH_DB.length}종 물질 데이터 삽입 예정`) + + const client = await wingPool.connect() + + try { + await client.query('BEGIN') + + // 기존 데이터 삭제 + await client.query('DELETE FROM HNS_SUBSTANCE') + + let inserted = 0 + + for (const s of HNS_SEARCH_DB) { + // 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장 + const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s + + await client.query( + `INSERT INTO HNS_SUBSTANCE (SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (SBST_SN) DO UPDATE SET + ABBREVIATION = EXCLUDED.ABBREVIATION, + NM_KR = EXCLUDED.NM_KR, + NM_EN = EXCLUDED.NM_EN, + UN_NO = EXCLUDED.UN_NO, + CAS_NO = EXCLUDED.CAS_NO, + SEBC = EXCLUDED.SEBC, + DATA = EXCLUDED.DATA`, + [s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)] + ) + + inserted++ + if (inserted % 100 === 0) { + console.log(` ${inserted}/${HNS_SEARCH_DB.length}건 삽입 완료...`) + } + } + + await client.query('COMMIT') + + // 결과 확인 + const { rows } = await client.query('SELECT COUNT(*) as count FROM HNS_SUBSTANCE') + console.log(`시드 완료! 총 ${rows[0].count}종의 HNS 물질이 저장되었습니다.`) + } catch (err) { + await client.query('ROLLBACK') + console.error('HNS 시드 실패:', err) + throw err + } finally { + client.release() + await wingPool.end() + } +} + +seedHnsSubstances().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/backend/src/db/wingDb.ts b/backend/src/db/wingDb.ts new file mode 100644 index 0000000..5e257d4 --- /dev/null +++ b/backend/src/db/wingDb.ts @@ -0,0 +1,44 @@ +import pg from 'pg' + +const { Pool } = pg + +// ============================================================ +// wing DB 통합 Pool (wing 스키마 + auth 스키마) +// - wing 스키마: 운영 데이터 (LAYER, BOARD_POST 등) +// - auth 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +// - public 스키마: PostGIS 시스템 테이블만 유지 +// ============================================================ +const wingPool = new Pool({ + host: process.env.DB_HOST || process.env.WING_DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || process.env.WING_DB_PORT) || 5432, + database: process.env.DB_NAME || process.env.WING_DB_NAME || 'wing', + user: process.env.DB_USER || process.env.WING_DB_USER || 'wing', + password: process.env.DB_PASSWORD || process.env.WING_DB_PASSWORD || 'Wing2026', + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}) + +// 연결 시 search_path 자동 설정 (public 미사용) +wingPool.on('connect', (client) => { + client.query('SET search_path = wing, auth, public') +}) + +wingPool.on('error', (err) => { + console.error('[db] 예기치 않은 연결 오류:', err.message) +}) + +export async function testWingDbConnection(): Promise { + try { + const client = await wingPool.connect() + await client.query('SELECT 1') + client.release() + console.log('[db] wing 데이터베이스 연결 성공 (wing + auth 스키마)') + return true + } catch (err) { + console.warn('[db] wing 데이터베이스 연결 실패:', (err as Error).message) + return false + } +} + +export { wingPool } diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts new file mode 100644 index 0000000..68aff33 --- /dev/null +++ b/backend/src/hns/hnsRouter.ts @@ -0,0 +1,53 @@ +import express from 'express' +import { searchSubstances, getSubstanceById } from './hnsService.js' +import { isValidNumber } from '../middleware/security.js' + +const router = express.Router() + +// HNS 물질 검색 +router.get('/', async (req, res) => { + try { + const q = req.query.q as string | undefined + const type = req.query.type as string | undefined + const sebc = req.query.sebc as string | undefined + const page = parseInt(req.query.page as string, 10) || 1 + const limit = parseInt(req.query.limit as string, 10) || 50 + + if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) { + return res.status(400).json({ + error: '유효하지 않은 페이지네이션', + message: 'page는 1~10000, limit은 1~100 범위여야 합니다.', + }) + } + + const validTypes = ['abbreviation', 'nameKr', 'nameEn', 'casNumber', 'unNumber', 'cargoCode'] + const searchType = type && validTypes.includes(type) + ? type as 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode' + : undefined + + const result = await searchSubstances({ q, type: searchType, sebc, page, limit }) + res.json(result) + } catch { + res.status(500).json({ error: 'HNS 물질 검색 실패' }) + } +}) + +// HNS 물질 상세 조회 +router.get('/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10) + if (!isValidNumber(id, 1, 999999)) { + return res.status(400).json({ error: '유효하지 않은 물질 ID' }) + } + + const substance = await getSubstanceById(id) + if (!substance) { + return res.status(404).json({ error: '물질을 찾을 수 없습니다' }) + } + res.json(substance) + } catch { + res.status(500).json({ error: 'HNS 물질 조회 실패' }) + } +}) + +export default router diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts new file mode 100644 index 0000000..5e5fe11 --- /dev/null +++ b/backend/src/hns/hnsService.ts @@ -0,0 +1,110 @@ +import { wingPool } from '../db/wingDb.js' + +interface HnsSearchParams { + q?: string + type?: 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode' + sebc?: string + page?: number + limit?: number +} + +export async function searchSubstances(params: HnsSearchParams) { + const { q, type = 'nameKr', sebc, page = 1, limit = 50 } = params + const conditions: string[] = ["USE_YN = 'Y'"] + const values: (string | number)[] = [] + let paramIdx = 1 + + if (q && q.trim()) { + const keyword = q.trim() + switch (type) { + case 'abbreviation': + conditions.push(`ABBREVIATION ILIKE $${paramIdx}`) + values.push(`%${keyword}%`) + break + case 'nameKr': + conditions.push(`NM_KR ILIKE $${paramIdx}`) + values.push(`%${keyword}%`) + break + case 'nameEn': + conditions.push(`NM_EN ILIKE $${paramIdx}`) + values.push(`%${keyword}%`) + break + case 'casNumber': + conditions.push(`CAS_NO ILIKE $${paramIdx}`) + values.push(`%${keyword}%`) + break + case 'unNumber': + conditions.push(`UN_NO = $${paramIdx}`) + values.push(keyword) + break + case 'cargoCode': + conditions.push(`DATA->'cargoCodes' @> $${paramIdx}::jsonb`) + values.push(JSON.stringify([{ code: keyword }])) + break + default: + conditions.push(`(NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx} OR ABBREVIATION ILIKE $${paramIdx})`) + values.push(`%${keyword}%`) + } + paramIdx++ + } + + if (sebc && sebc.trim()) { + conditions.push(`SEBC ILIKE $${paramIdx}`) + values.push(`%${sebc.trim()}%`) + paramIdx++ + } + + const where = conditions.join(' AND ') + const offset = (page - 1) * limit + + const countQuery = `SELECT COUNT(*) as total FROM HNS_SUBSTANCE WHERE ${where}` + const dataQuery = ` + SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA + FROM HNS_SUBSTANCE + WHERE ${where} + ORDER BY SBST_SN + LIMIT $${paramIdx} OFFSET $${paramIdx + 1} + ` + + const [countResult, dataResult] = await Promise.all([ + wingPool.query(countQuery, values), + wingPool.query(dataQuery, [...values, limit, offset]), + ]) + + return { + total: parseInt(countResult.rows[0].total, 10), + page, + limit, + items: dataResult.rows.map(row => ({ + id: row.sbst_sn, + abbreviation: row.abbreviation, + nameKr: row.nm_kr, + nameEn: row.nm_en, + unNumber: row.un_no, + casNumber: row.cas_no, + sebc: row.sebc, + ...row.data, + })), + } +} + +export async function getSubstanceById(id: number) { + const { rows } = await wingPool.query( + `SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA + FROM HNS_SUBSTANCE WHERE SBST_SN = $1`, + [id] + ) + if (rows.length === 0) return null + + const row = rows[0] + return { + id: row.sbst_sn, + abbreviation: row.abbreviation, + nameKr: row.nm_kr, + nameEn: row.nm_en, + unNumber: row.un_no, + casNumber: row.cas_no, + sebc: row.sebc, + ...row.data, + } +} diff --git a/backend/src/roles/permResolver.ts b/backend/src/roles/permResolver.ts new file mode 100644 index 0000000..52dadc7 --- /dev/null +++ b/backend/src/roles/permResolver.ts @@ -0,0 +1,197 @@ +/** + * 트리 구조 기반 권한 해석(Resolution) 유틸. + * + * 2차원 모델: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) + * + * 규칙: + * 1. 부모 리소스의 READ가 N → 자식의 모든 오퍼레이션 강제 N + * 2. 해당 (RSRC_CD, OPER_CD) 명시적 레코드 있으면 → 그 값 사용 + * 3. 명시적 레코드 없으면 → 부모의 같은 OPER_CD 상속 + * 4. 최상위까지 없으면 → 기본 N (거부) + * + * 키 형식: "rsrcCode::operCd" (더블콜론으로 리소스와 오퍼레이션 구분) + */ + +export const OPERATIONS = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const +export type OperationCode = (typeof OPERATIONS)[number] + +export interface PermTreeNode { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number +} + +/** 리소스::오퍼레이션 키 생성 */ +export function makePermKey(rsrcCode: string, operCd: string): string { + return `${rsrcCode}::${operCd}` +} + +/** 키에서 리소스 코드와 오퍼레이션 코드 분리 */ +export function parsePermKey(key: string): { rsrcCode: string; operCd: string } { + const idx = key.indexOf('::') + return { + rsrcCode: key.substring(0, idx), + operCd: key.substring(idx + 2), + } +} + +/** + * 트리 노드 + 역할별 명시적 권한 → granted된 "rsrc::oper" Set 반환. + * 다중 역할: 역할별 resolve 후 OR (하나라도 Y면 Y). + */ +export function resolvePermissions( + treeNodes: PermTreeNode[], + explicitPermsPerRole: Map>, +): Set { + const granted = new Set() + + const nodeMap = new Map() + for (const node of treeNodes) { + nodeMap.set(node.code, node) + } + + for (const [, explicitPerms] of explicitPermsPerRole) { + const roleResolved = resolveForSingleRole(treeNodes, nodeMap, explicitPerms) + for (const key of roleResolved) { + granted.add(key) + } + } + + return granted +} + +/** + * 단일 역할에 대한 권한 해석. + */ +function resolveForSingleRole( + treeNodes: PermTreeNode[], + nodeMap: Map, + explicitPerms: Map, +): Set { + const effective = new Map() + + // 레벨 순(0→1→2→...)으로 처리하여 부모 → 자식 순서 보장 + const sorted = [...treeNodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder) + + for (const node of sorted) { + // READ 먼저 resolve (CUD는 READ 결과에 의존) + resolveNodeOper(node, 'READ', explicitPerms, effective) + + // CUD resolve + for (const oper of OPERATIONS) { + if (oper === 'READ') continue + resolveNodeOper(node, oper, explicitPerms, effective) + } + } + + const granted = new Set() + for (const [key, value] of effective) { + if (value) granted.add(key) + } + return granted +} + +/** + * 개별 노드 × 오퍼레이션의 effective 값 계산. + */ +function resolveNodeOper( + node: PermTreeNode, + operCd: string, + explicitPerms: Map, + effective: Map, +): void { + const key = makePermKey(node.code, operCd) + if (effective.has(key)) return + + const explicit = explicitPerms.get(key) + + if (node.parentCode === null) { + // 최상위: 명시적 값 또는 기본 거부 + effective.set(key, explicit ?? false) + return + } + + // 부모의 READ 확인 (접근 게이트) + const parentReadKey = makePermKey(node.parentCode, 'READ') + const parentReadEffective = effective.get(parentReadKey) + + if (parentReadEffective === false) { + // 부모 READ 차단 → 모든 오퍼레이션 강제 차단 + effective.set(key, false) + return + } + + // 명시적 값 있으면 사용 + if (explicit !== undefined) { + effective.set(key, explicit) + return + } + + // 부모의 같은 오퍼레이션 상속 + const parentOperKey = makePermKey(node.parentCode, operCd) + const parentOperEffective = effective.get(parentOperKey) + effective.set(key, parentOperEffective ?? false) +} + +/** + * resolved Set → Record 변환 (API 반환용). + */ +export function grantedSetToRecord(granted: Set): Record { + const result: Record = {} + for (const key of granted) { + const { rsrcCode, operCd } = parsePermKey(key) + if (!result[rsrcCode]) result[rsrcCode] = [] + result[rsrcCode].push(operCd) + } + return result +} + +/** + * 플랫 노드 배열 → 트리 구조 변환 (프론트엔드 UI용). + */ +export interface PermTreeResponse { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number + children: PermTreeResponse[] +} + +export function buildPermTree(nodes: PermTreeNode[]): PermTreeResponse[] { + const nodeMap = new Map() + const roots: PermTreeResponse[] = [] + + const sorted = [...nodes].sort((a, b) => a.level - b.level || a.sortOrder - b.sortOrder) + + for (const node of sorted) { + const treeNode: PermTreeResponse = { + code: node.code, + parentCode: node.parentCode, + name: node.name, + description: node.description, + icon: node.icon, + level: node.level, + sortOrder: node.sortOrder, + children: [], + } + nodeMap.set(node.code, treeNode) + + if (node.parentCode === null) { + roots.push(treeNode) + } else { + const parent = nodeMap.get(node.parentCode) + if (parent) { + parent.children.push(treeNode) + } + } + } + + return roots +} diff --git a/backend/src/roles/roleRouter.ts b/backend/src/roles/roleRouter.ts index 0f59ef3..3706dad 100644 --- a/backend/src/roles/roleRouter.ts +++ b/backend/src/roles/roleRouter.ts @@ -1,13 +1,24 @@ import { Router } from 'express' import { requireAuth, requireRole } from '../auth/authMiddleware.js' import { AuthError } from '../auth/authService.js' -import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault } from './roleService.js' +import { listRolesWithPermissions, createRole, updateRole, deleteRole, updatePermissions, updateRoleDefault, getPermTree } from './roleService.js' const router = Router() router.use(requireAuth) router.use(requireRole('ADMIN')) +// GET /api/roles/perm-tree — 권한 트리 구조 조회 +router.get('/perm-tree', async (_req, res) => { + try { + const tree = await getPermTree() + res.json(tree) + } catch (err) { + console.error('[roles] 권한 트리 조회 오류:', err) + res.status(500).json({ error: '권한 트리 조회 중 오류가 발생했습니다.' }) + } +}) + // GET /api/roles router.get('/', async (_req, res) => { try { @@ -76,6 +87,7 @@ router.delete('/:id', async (req, res) => { }) // PUT /api/roles/:id/permissions +// 요청: { permissions: [{ resourceCode, operationCode, granted }] } router.put('/:id/permissions', async (req, res) => { try { const roleSn = Number(req.params.id) @@ -86,6 +98,13 @@ router.put('/:id/permissions', async (req, res) => { return } + for (const p of permissions) { + if (!p.resourceCode || !p.operationCode || typeof p.granted !== 'boolean') { + res.status(400).json({ error: '각 권한에는 resourceCode, operationCode, granted가 필요합니다.' }) + return + } + } + await updatePermissions(roleSn, permissions) res.json({ success: true }) } catch (err) { diff --git a/backend/src/roles/roleService.ts b/backend/src/roles/roleService.ts index b3b0862..b78aeb1 100644 --- a/backend/src/roles/roleService.ts +++ b/backend/src/roles/roleService.ts @@ -1,13 +1,34 @@ import { authPool } from '../db/authDb.js' import { AuthError } from '../auth/authService.js' - -const PERM_RESOURCE_CODES = [ - 'prediction', 'hns', 'rescue', 'reports', 'aerial', - 'assets', 'scat', 'incidents', 'board', 'weather', 'admin', -] as const +import { type PermTreeNode, buildPermTree, type PermTreeResponse } from './permResolver.js' const PROTECTED_ROLE_CODES = ['ADMIN'] +/** AUTH_PERM_TREE에서 level 0 리소스 코드를 동적 조회 */ +async function getTopLevelResourceCodes(): Promise { + const result = await authPool.query( + `SELECT RSRC_CD FROM AUTH_PERM_TREE WHERE RSRC_LEVEL = 0 AND USE_YN = 'Y' ORDER BY SORT_ORD` + ) + return result.rows.map((r: { rsrc_cd: string }) => r.rsrc_cd) +} + +/** AUTH_PERM_TREE 전체 노드 조회 */ +export async function getPermTreeNodes(): Promise { + const result = await authPool.query( + `SELECT RSRC_CD as code, PARENT_CD as "parentCode", RSRC_NM as name, + RSRC_DESC as description, ICON as icon, RSRC_LEVEL as level, SORT_ORD as "sortOrder" + FROM AUTH_PERM_TREE WHERE USE_YN = 'Y' + ORDER BY RSRC_LEVEL, SORT_ORD` + ) + return result.rows +} + +/** 트리 구조로 변환하여 반환 (프론트엔드 UI용) */ +export async function getPermTree(): Promise { + const nodes = await getPermTreeNodes() + return buildPermTree(nodes) +} + interface RoleWithPermissions { sn: number code: string @@ -17,6 +38,7 @@ interface RoleWithPermissions { permissions: Array<{ sn: number resourceCode: string + operationCode: string granted: boolean }> } @@ -42,8 +64,8 @@ export async function listRolesWithPermissions(): Promise for (const row of rolesResult.rows) { const permsResult = await authPool.query( - `SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted - FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`, + `SELECT PERM_SN as sn, RSRC_CD as resource_code, OPER_CD as operation_code, GRANT_YN as granted + FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD, OPER_CD`, [row.sn] ) @@ -53,9 +75,12 @@ export async function listRolesWithPermissions(): Promise name: row.name, description: row.description, isDefault: row.is_default === 'Y', - permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({ + permissions: permsResult.rows.map((p: { + sn: number; resource_code: string; operation_code: string; granted: string + }) => ({ sn: p.sn, resourceCode: p.resource_code, + operationCode: p.operation_code, granted: p.granted === 'Y', })), }) @@ -94,17 +119,20 @@ export async function createRole(input: CreateRoleInput): Promise ({ + permissions: permsResult.rows.map((p: { + sn: number; resource_code: string; operation_code: string; granted: string + }) => ({ sn: p.sn, resourceCode: p.resource_code, + operationCode: p.operation_code, granted: p.granted === 'Y', })), } @@ -177,23 +208,23 @@ export async function deleteRole(roleSn: number): Promise { export async function updatePermissions( roleSn: number, - permissions: Array<{ resourceCode: string; granted: boolean }> + permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }> ): Promise { for (const perm of permissions) { const existing = await authPool.query( - 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2', - [roleSn, perm.resourceCode] + 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2 AND OPER_CD = $3', + [roleSn, perm.resourceCode, perm.operationCode] ) if (existing.rows.length > 0) { await authPool.query( - 'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3', - [perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode] + 'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3 AND OPER_CD = $4', + [perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode, perm.operationCode] ) } else { await authPool.query( - 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)', - [roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N'] + 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES ($1, $2, $3, $4)', + [roleSn, perm.resourceCode, perm.operationCode, perm.granted ? 'Y' : 'N'] ) } } diff --git a/backend/src/routes/layers.ts b/backend/src/routes/layers.ts index ee50daf..d5ad1cb 100755 --- a/backend/src/routes/layers.ts +++ b/backend/src/routes/layers.ts @@ -1,5 +1,5 @@ import express from 'express' -import db from '../db/database.js' +import { wingPool } from '../db/wingDb.js' import { enrichLayerWithMetadata } from '../utils/layerIcons.js' import { sanitizeParams, @@ -19,14 +19,26 @@ interface Layer { clnm: string | null } +// DB 컬럼 → API 응답 컬럼 매핑 (프론트엔드 호환성 유지) +const LAYER_COLUMNS = ` + LAYER_CD AS cmn_cd, + UP_LAYER_CD AS up_cmn_cd, + LAYER_FULL_NM AS cmn_cd_full_nm, + LAYER_NM AS cmn_cd_nm, + LAYER_LEVEL AS cmn_cd_level, + WMS_LAYER_NM AS clnm +`.trim() + // 모든 라우트에 파라미터 살균 적용 router.use(sanitizeParams) // 모든 레이어 조회 -router.get('/', (_req, res) => { +router.get('/', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레이어 조회 실패' }) @@ -34,17 +46,19 @@ router.get('/', (_req, res) => { }) // 계층 구조로 변환된 레이어 트리 조회 -router.get('/tree/all', (_req, res) => { +router.get('/tree/all', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) - const layerMap = new Map() + const layerMap = new Map() enrichedLayers.forEach(layer => { layerMap.set(layer.cmn_cd, { ...layer, children: [] }) }) - const rootLayers: any[] = [] + const rootLayers: (Layer & { children: Layer[] })[] = [] enrichedLayers.forEach(layer => { const layerNode = layerMap.get(layer.cmn_cd)! if (layer.up_cmn_cd === null) { @@ -64,10 +78,12 @@ router.get('/tree/all', (_req, res) => { }) // WMS 레이어만 조회 -router.get('/wms/all', (_req, res) => { +router.get('/wms/all', async (_req, res) => { try { - const layers = db.prepare('SELECT * FROM layers WHERE clnm IS NOT NULL ORDER BY cmn_cd').all() as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD` + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: 'WMS 레이어 조회 실패' }) @@ -75,11 +91,10 @@ router.get('/wms/all', (_req, res) => { }) // 특정 레벨의 레이어만 조회 -router.get('/level/:level', (req, res) => { +router.get('/level/:level', async (req, res) => { try { const level = parseInt(req.params.level, 10) - // 입력 검증: 레벨은 1~10 범위의 정수 if (!isValidNumber(level, 1, 10)) { return res.status(400).json({ error: '유효하지 않은 레벨값', @@ -87,9 +102,11 @@ router.get('/level/:level', (req, res) => { }) } - // 파라미터화된 쿼리 사용 (SQL 인젝션 방지) - const layers = db.prepare('SELECT * FROM layers WHERE cmn_cd_level = ? ORDER BY cmn_cd').all(level) as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + [level] + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '레벨별 레이어 조회 실패' }) @@ -97,11 +114,10 @@ router.get('/level/:level', (req, res) => { }) // 특정 부모의 자식 레이어 조회 -router.get('/children/:parentId', (req, res) => { +router.get('/children/:parentId', async (req, res) => { try { const parentId = req.params.parentId - // 입력 검증: 코드 형식 확인 (영숫자, 언더스코어, 하이픈만 허용) if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) { return res.status(400).json({ error: '유효하지 않은 부모 ID', @@ -110,8 +126,11 @@ router.get('/children/:parentId', (req, res) => { } const sanitizedId = sanitizeString(parentId) - const layers = db.prepare('SELECT * FROM layers WHERE up_cmn_cd = ? ORDER BY cmn_cd').all(sanitizedId) as Layer[] - const enrichedLayers = layers.map(enrichLayerWithMetadata) + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`, + [sanitizedId] + ) + const enrichedLayers = rows.map(enrichLayerWithMetadata) res.json(enrichedLayers) } catch { res.status(500).json({ error: '자식 레이어 조회 실패' }) @@ -119,11 +138,10 @@ router.get('/children/:parentId', (req, res) => { }) // 특정 레이어 조회 -router.get('/:id', (req, res) => { +router.get('/:id', async (req, res) => { try { const id = req.params.id - // 입력 검증: ID 형식 확인 if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) { return res.status(400).json({ error: '유효하지 않은 레이어 ID', @@ -132,11 +150,14 @@ router.get('/:id', (req, res) => { } const sanitizedId = sanitizeString(id) - const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined - if (!layer) { + const { rows } = await wingPool.query( + `SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`, + [sanitizedId] + ) + if (rows.length === 0) { return res.status(404).json({ error: '레이어를 찾을 수 없습니다' }) } - const enrichedLayer = enrichLayerWithMetadata(layer) + const enrichedLayer = enrichLayerWithMetadata(rows[0]) res.json(enrichedLayer) } catch { res.status(500).json({ error: '레이어 조회 실패' }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 8d312c9..21bb1ba 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,8 +4,7 @@ import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' import cookieParser from 'cookie-parser' -import { initDatabase } from './db/database.js' -import { testAuthDbConnection } from './db/authDb.js' +import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' import authRouter from './auth/authRouter.js' @@ -14,6 +13,8 @@ import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' import menuRouter from './menus/menuRouter.js' import auditRouter from './audit/auditRouter.js' +import boardRouter from './board/boardRouter.js' +import hnsRouter from './hns/hnsRouter.js' import { sanitizeBody, sanitizeQuery, @@ -48,6 +49,7 @@ app.use(helmet({ } }, crossOriginEmbedderPolicy: false, // API 서버이므로 비활성 + crossOriginResourcePolicy: { policy: 'cross-origin' }, // sendBeacon cross-origin 허용 })) // 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지) @@ -113,11 +115,6 @@ app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT })) app.use(sanitizeBody) app.use(sanitizeQuery) -// ============================================================ -// 데이터베이스 초기화 -// ============================================================ -initDatabase() - // ============================================================ // 라우트 // ============================================================ @@ -140,8 +137,10 @@ app.use('/api/menus', menuRouter) app.use('/api/audit', auditRouter) // API 라우트 — 업무 +app.use('/api/board', boardRouter) app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) +app.use('/api/hns', hnsRouter) // 헬스 체크 app.get('/health', (_req, res) => { @@ -176,13 +175,14 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres // ============================================================ app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) - const connected = await testAuthDbConnection() + + // wing DB 연결 확인 (wing + auth 스키마 통합) + const connected = await testWingDbConnection() if (connected) { // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) try { - const { authPool } = await import('./db/authDb.js') - await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) - console.log('[migration] SETTING_VAL → TEXT 변환 완료') + const { wingPool } = await import('./db/wingDb.js') + await wingPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) } catch { // 이미 TEXT이거나 권한 없으면 무시 } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index e8c99b6..c11d906 100755 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/db/seedHns.ts"] } diff --git a/database/auth_init.sql b/database/auth_init.sql index d55729b..b99b723 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -134,18 +134,21 @@ CREATE TABLE AUTH_PERM ( PERM_SN SERIAL NOT NULL, ROLE_SN INTEGER NOT NULL, RSRC_CD VARCHAR(50) NOT NULL, + OPER_CD VARCHAR(20) NOT NULL, GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y', REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN), CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE, - CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD), - CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')) + CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD), + CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')), + CONSTRAINT CK_AUTH_PERM_OPER CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT')) ); COMMENT ON TABLE AUTH_PERM IS '역할별권한'; COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번'; COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번'; COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)'; +COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)'; COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)'; COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시'; @@ -239,6 +242,7 @@ CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) W CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL; CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN); CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD); +CREATE INDEX IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD); CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID); CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM); CREATE INDEX IDX_AUDIT_LOG_USER ON AUTH_AUDIT_LOG (USER_ID); @@ -257,36 +261,65 @@ INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES -- ============================================================ --- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스) +-- 11. 초기 데이터: 역할별 권한 (리소스 × 오퍼레이션 매트릭스) +-- OPER_CD: READ(조회), CREATE(생성), UPDATE(수정), DELETE(삭제) -- ============================================================ --- ADMIN (ROLE_SN=1): 모든 탭 접근 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'), -(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'), -(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'), -(1, 'weather', 'Y'), (1, 'admin', 'Y'); +-- ADMIN (ROLE_SN=1): 모든 탭 × 모든 오퍼레이션 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(1, 'prediction', 'READ', 'Y'), (1, 'prediction', 'CREATE', 'Y'), (1, 'prediction', 'UPDATE', 'Y'), (1, 'prediction', 'DELETE', 'Y'), +(1, 'hns', 'READ', 'Y'), (1, 'hns', 'CREATE', 'Y'), (1, 'hns', 'UPDATE', 'Y'), (1, 'hns', 'DELETE', 'Y'), +(1, 'rescue', 'READ', 'Y'), (1, 'rescue', 'CREATE', 'Y'), (1, 'rescue', 'UPDATE', 'Y'), (1, 'rescue', 'DELETE', 'Y'), +(1, 'reports', 'READ', 'Y'), (1, 'reports', 'CREATE', 'Y'), (1, 'reports', 'UPDATE', 'Y'), (1, 'reports', 'DELETE', 'Y'), +(1, 'aerial', 'READ', 'Y'), (1, 'aerial', 'CREATE', 'Y'), (1, 'aerial', 'UPDATE', 'Y'), (1, 'aerial', 'DELETE', 'Y'), +(1, 'assets', 'READ', 'Y'), (1, 'assets', 'CREATE', 'Y'), (1, 'assets', 'UPDATE', 'Y'), (1, 'assets', 'DELETE', 'Y'), +(1, 'scat', 'READ', 'Y'), (1, 'scat', 'CREATE', 'Y'), (1, 'scat', 'UPDATE', 'Y'), (1, 'scat', 'DELETE', 'Y'), +(1, 'incidents', 'READ', 'Y'), (1, 'incidents', 'CREATE', 'Y'), (1, 'incidents', 'UPDATE', 'Y'), (1, 'incidents', 'DELETE', 'Y'), +(1, 'board', 'READ', 'Y'), (1, 'board', 'CREATE', 'Y'), (1, 'board', 'UPDATE', 'Y'), (1, 'board', 'DELETE', 'Y'), +(1, 'weather', 'READ', 'Y'), (1, 'weather', 'CREATE', 'Y'), (1, 'weather', 'UPDATE', 'Y'), (1, 'weather', 'DELETE', 'Y'), +(1, 'admin', 'READ', 'Y'), (1, 'admin', 'CREATE', 'Y'), (1, 'admin', 'UPDATE', 'Y'), (1, 'admin', 'DELETE', 'Y'); --- MANAGER (ROLE_SN=2): admin 탭 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'), -(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'), -(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'), -(2, 'weather', 'Y'), (2, 'admin', 'N'); +-- MANAGER (ROLE_SN=2): admin 탭 제외, RCUD 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(2, 'prediction', 'READ', 'Y'), (2, 'prediction', 'CREATE', 'Y'), (2, 'prediction', 'UPDATE', 'Y'), (2, 'prediction', 'DELETE', 'Y'), +(2, 'hns', 'READ', 'Y'), (2, 'hns', 'CREATE', 'Y'), (2, 'hns', 'UPDATE', 'Y'), (2, 'hns', 'DELETE', 'Y'), +(2, 'rescue', 'READ', 'Y'), (2, 'rescue', 'CREATE', 'Y'), (2, 'rescue', 'UPDATE', 'Y'), (2, 'rescue', 'DELETE', 'Y'), +(2, 'reports', 'READ', 'Y'), (2, 'reports', 'CREATE', 'Y'), (2, 'reports', 'UPDATE', 'Y'), (2, 'reports', 'DELETE', 'Y'), +(2, 'aerial', 'READ', 'Y'), (2, 'aerial', 'CREATE', 'Y'), (2, 'aerial', 'UPDATE', 'Y'), (2, 'aerial', 'DELETE', 'Y'), +(2, 'assets', 'READ', 'Y'), (2, 'assets', 'CREATE', 'Y'), (2, 'assets', 'UPDATE', 'Y'), (2, 'assets', 'DELETE', 'Y'), +(2, 'scat', 'READ', 'Y'), (2, 'scat', 'CREATE', 'Y'), (2, 'scat', 'UPDATE', 'Y'), (2, 'scat', 'DELETE', 'Y'), +(2, 'incidents', 'READ', 'Y'), (2, 'incidents', 'CREATE', 'Y'), (2, 'incidents', 'UPDATE', 'Y'), (2, 'incidents', 'DELETE', 'Y'), +(2, 'board', 'READ', 'Y'), (2, 'board', 'CREATE', 'Y'), (2, 'board', 'UPDATE', 'Y'), (2, 'board', 'DELETE', 'Y'), +(2, 'weather', 'READ', 'Y'), (2, 'weather', 'CREATE', 'Y'), (2, 'weather', 'UPDATE', 'Y'), (2, 'weather', 'DELETE', 'Y'), +(2, 'admin', 'READ', 'N'); --- USER (ROLE_SN=3): assets, admin 탭 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'), -(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'), -(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'), -(3, 'weather', 'Y'), (3, 'admin', 'N'); +-- USER (ROLE_SN=3): assets/admin 제외, 허용 탭은 READ/CREATE/UPDATE, DELETE 없음 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(3, 'prediction', 'READ', 'Y'), (3, 'prediction', 'CREATE', 'Y'), (3, 'prediction', 'UPDATE', 'Y'), +(3, 'hns', 'READ', 'Y'), (3, 'hns', 'CREATE', 'Y'), (3, 'hns', 'UPDATE', 'Y'), +(3, 'rescue', 'READ', 'Y'), (3, 'rescue', 'CREATE', 'Y'), (3, 'rescue', 'UPDATE', 'Y'), +(3, 'reports', 'READ', 'Y'), (3, 'reports', 'CREATE', 'Y'), (3, 'reports', 'UPDATE', 'Y'), +(3, 'aerial', 'READ', 'Y'), (3, 'aerial', 'CREATE', 'Y'), (3, 'aerial', 'UPDATE', 'Y'), +(3, 'assets', 'READ', 'N'), +(3, 'scat', 'READ', 'Y'), (3, 'scat', 'CREATE', 'Y'), (3, 'scat', 'UPDATE', 'Y'), +(3, 'incidents', 'READ', 'Y'), (3, 'incidents', 'CREATE', 'Y'), (3, 'incidents', 'UPDATE', 'Y'), +(3, 'board', 'READ', 'Y'), (3, 'board', 'CREATE', 'Y'), (3, 'board', 'UPDATE', 'Y'), +(3, 'weather', 'READ', 'Y'), +(3, 'admin', 'READ', 'N'); --- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외 -INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES -(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'), -(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'), -(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'), -(4, 'weather', 'Y'), (4, 'admin', 'N'); +-- VIEWER (ROLE_SN=4): 제한적 탭의 READ만 허용 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) VALUES +(4, 'prediction', 'READ', 'Y'), +(4, 'hns', 'READ', 'Y'), +(4, 'rescue', 'READ', 'Y'), +(4, 'reports', 'READ', 'N'), +(4, 'aerial', 'READ', 'Y'), +(4, 'assets', 'READ', 'N'), +(4, 'scat', 'READ', 'N'), +(4, 'incidents', 'READ', 'Y'), +(4, 'board', 'READ', 'Y'), +(4, 'weather', 'READ', 'Y'), +(4, 'admin', 'READ', 'N'); -- ============================================================ diff --git a/database/migration/001_layer_table.sql b/database/migration/001_layer_table.sql new file mode 100644 index 0000000..1d43b13 --- /dev/null +++ b/database/migration/001_layer_table.sql @@ -0,0 +1,36 @@ +-- ================================================================ +-- 001: LAYER 테이블 생성 (SQLite layers.db → PostgreSQL wing DB 마이그레이션) +-- ================================================================ +-- 기존 SQLite layers 테이블의 데이터를 PostgreSQL wing DB로 이전 +-- 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 네이밍 적용 +-- ================================================================ + +CREATE TABLE IF NOT EXISTS LAYER ( + LAYER_CD VARCHAR(50) NOT NULL, -- 레이어코드 (기존 cmn_cd) + UP_LAYER_CD VARCHAR(50), -- 상위레이어코드 (기존 up_cmn_cd) + LAYER_FULL_NM VARCHAR(200) NOT NULL, -- 레이어전체명 (기존 cmn_cd_full_nm) + LAYER_NM VARCHAR(100) NOT NULL, -- 레이어명 (기존 cmn_cd_nm) + LAYER_LEVEL INTEGER NOT NULL, -- 레이어레벨 (기존 cmn_cd_level) + WMS_LAYER_NM VARCHAR(100), -- WMS레이어명 (기존 clnm) + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부 + SORT_ORD INTEGER DEFAULT 0, -- 정렬순서 + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 + CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD), + CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD), + CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N')) +); + +COMMENT ON TABLE LAYER IS '레이어'; +COMMENT ON COLUMN LAYER.LAYER_CD IS '레이어코드 (예: LYR001001)'; +COMMENT ON COLUMN LAYER.UP_LAYER_CD IS '상위레이어코드 (상위 레이어 참조)'; +COMMENT ON COLUMN LAYER.LAYER_FULL_NM IS '레이어전체명 (계층 경로 포함 전체 명칭)'; +COMMENT ON COLUMN LAYER.LAYER_NM IS '레이어명 (표시용 짧은 명칭)'; +COMMENT ON COLUMN LAYER.LAYER_LEVEL IS '레이어레벨 (1:최상위, 2:중분류, 3:소분류 ...)'; +COMMENT ON COLUMN LAYER.WMS_LAYER_NM IS 'WMS레이어명 (GeoServer WMS 레이어 식별자)'; +COMMENT ON COLUMN LAYER.USE_YN IS '사용여부 (Y:사용, N:미사용)'; +COMMENT ON COLUMN LAYER.SORT_ORD IS '정렬순서'; +COMMENT ON COLUMN LAYER.REG_DTM IS '등록일시'; + +CREATE INDEX IF NOT EXISTS IDX_LAYER_UP ON LAYER(UP_LAYER_CD); +CREATE INDEX IF NOT EXISTS IDX_LAYER_LEVEL ON LAYER(LAYER_LEVEL); +CREATE INDEX IF NOT EXISTS IDX_LAYER_USE ON LAYER(USE_YN); diff --git a/database/migration/002_hns_substance.sql b/database/migration/002_hns_substance.sql new file mode 100644 index 0000000..d60b5cc --- /dev/null +++ b/database/migration/002_hns_substance.sql @@ -0,0 +1,46 @@ +-- ================================================================ +-- 002: HNS 물질정보 테이블 (프론트엔드 정적 데이터 → DB 이전) +-- ================================================================ +-- 검색용 컬럼 + 상세 데이터 JSONB 구조 +-- pg_trgm 인덱스로 한글/영문 물질명 검색 지원 +-- ================================================================ + +CREATE TABLE IF NOT EXISTS HNS_SUBSTANCE ( + SBST_SN SERIAL NOT NULL, -- 물질순번 + ABBREVIATION VARCHAR(50), -- 약자/제품명 + NM_KR VARCHAR(200) NOT NULL, -- 국문명 + NM_EN VARCHAR(200), -- 영문명 + UN_NO VARCHAR(10), -- UN번호 + CAS_NO VARCHAR(20), -- CAS번호 + SEBC VARCHAR(50), -- SEBC 거동분류 + DATA JSONB NOT NULL, -- 전체 상세 데이터 + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부 + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 + CONSTRAINT PK_HNS_SUBSTANCE PRIMARY KEY (SBST_SN), + CONSTRAINT CK_HNS_SBST_USE CHECK (USE_YN IN ('Y', 'N')) +); + +COMMENT ON TABLE HNS_SUBSTANCE IS 'HNS물질정보 (1,316종)'; +COMMENT ON COLUMN HNS_SUBSTANCE.SBST_SN IS '물질순번'; +COMMENT ON COLUMN HNS_SUBSTANCE.ABBREVIATION IS '약자/제품명 (화물적부도 코드)'; +COMMENT ON COLUMN HNS_SUBSTANCE.NM_KR IS '국문명'; +COMMENT ON COLUMN HNS_SUBSTANCE.NM_EN IS '영문명'; +COMMENT ON COLUMN HNS_SUBSTANCE.UN_NO IS 'UN번호 (위험물 식별번호)'; +COMMENT ON COLUMN HNS_SUBSTANCE.CAS_NO IS 'CAS번호 (화학물질등록번호)'; +COMMENT ON COLUMN HNS_SUBSTANCE.SEBC IS 'SEBC 거동분류 (G/GD/E/ED/FE/FED/F/FD/D/S/SD)'; +COMMENT ON COLUMN HNS_SUBSTANCE.DATA IS '전체 상세 데이터 (JSONB)'; +COMMENT ON COLUMN HNS_SUBSTANCE.USE_YN IS '사용여부 (Y:사용, N:미사용)'; +COMMENT ON COLUMN HNS_SUBSTANCE.REG_DTM IS '등록일시'; + +-- 텍스트 검색 인덱스 (pg_trgm) +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_KR ON HNS_SUBSTANCE USING GIN(NM_KR gin_trgm_ops); +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_EN ON HNS_SUBSTANCE USING GIN(NM_EN gin_trgm_ops); +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_ABBR ON HNS_SUBSTANCE USING GIN(ABBREVIATION gin_trgm_ops); + +-- 코드 검색 인덱스 +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_UN ON HNS_SUBSTANCE(UN_NO); +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_CAS ON HNS_SUBSTANCE(CAS_NO); +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_SEBC ON HNS_SUBSTANCE(SEBC); + +-- JSONB 내 cargoCodes 검색용 인덱스 +CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_DATA ON HNS_SUBSTANCE USING GIN(DATA jsonb_path_ops); diff --git a/database/migration/003_perm_tree.sql b/database/migration/003_perm_tree.sql new file mode 100644 index 0000000..2688735 --- /dev/null +++ b/database/migration/003_perm_tree.sql @@ -0,0 +1,108 @@ +-- ============================================================ +-- AUTH_PERM_TREE: 트리 구조 기반 리소스(메뉴) 권한 정의 +-- 부모-자식 관계로 N-depth 서브탭 권한 제어 지원 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS AUTH_PERM_TREE ( + RSRC_CD VARCHAR(50) NOT NULL, -- 콜론 구분 경로: 'prediction', 'aerial:media' + PARENT_CD VARCHAR(50), -- NULL이면 최상위 탭 + RSRC_NM VARCHAR(100) NOT NULL, -- 표시명 + RSRC_DESC VARCHAR(200), -- 설명 (NULL 허용) + ICON VARCHAR(20), -- 아이콘 (NULL 허용, 선택 옵션) + RSRC_LEVEL SMALLINT NOT NULL DEFAULT 0, -- depth (0=탭, 1=서브탭, 2+) + SORT_ORD SMALLINT NOT NULL DEFAULT 0, -- 형제 노드 간 정렬 + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + CONSTRAINT PK_PERM_TREE PRIMARY KEY (RSRC_CD), + CONSTRAINT FK_PERM_TREE_PARENT FOREIGN KEY (PARENT_CD) + REFERENCES AUTH_PERM_TREE(RSRC_CD) +); + +CREATE INDEX IF NOT EXISTS IDX_PERM_TREE_PARENT ON AUTH_PERM_TREE(PARENT_CD); + +-- ============================================================ +-- 초기 데이터 +-- ============================================================ + +-- Level 0: 메인 탭 (11개) +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_DESC, RSRC_LEVEL, SORT_ORD) VALUES + ('prediction', NULL, '유출유 확산예측', '확산 예측 실행 및 결과 조회', 0, 1), + ('hns', NULL, 'HNS·대기확산', '대기확산 분석 실행 및 조회', 0, 2), + ('rescue', NULL, '긴급구난', '구난 예측 실행 및 조회', 0, 3), + ('reports', NULL, '보고자료', '사고 보고서 작성 및 조회', 0, 4), + ('aerial', NULL, '항공탐색', '항공 탐색 데이터 조회', 0, 5), + ('assets', NULL, '방제자산 관리', '방제 장비 및 자산 관리', 0, 6), + ('scat', NULL, '해안평가', 'SCAT 조사 실행 및 조회', 0, 7), + ('board', NULL, '게시판', '자료실 및 공지사항 조회', 0, 8), + ('weather', NULL, '기상정보', '기상 및 해상 정보 조회', 0, 9), + ('incidents', NULL, '통합조회', '사고 상세 정보 조회', 0, 10), + ('admin', NULL, '관리', '사용자 및 권한 관리', 0, 11) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: prediction 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('prediction:analysis', 'prediction', '확산분석', 1, 1), + ('prediction:list', 'prediction', '분석 목록', 1, 2), + ('prediction:theory', 'prediction', '확산모델 이론', 1, 3), + ('prediction:boom-theory', 'prediction', '오일펜스 배치 이론', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: hns 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('hns:analysis', 'hns', '대기확산 분석', 1, 1), + ('hns:list', 'hns', '분석 목록', 1, 2), + ('hns:scenario', 'hns', '시나리오 관리', 1, 3), + ('hns:manual', 'hns', 'HNS 대응매뉴얼', 1, 4), + ('hns:theory', 'hns', '확산모델 이론', 1, 5), + ('hns:substance', 'hns', 'HNS 물질정보', 1, 6) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: rescue 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('rescue:rescue', 'rescue', '긴급구난예측', 1, 1), + ('rescue:list', 'rescue', '긴급구난 목록', 1, 2), + ('rescue:scenario', 'rescue', '시나리오 관리', 1, 3), + ('rescue:theory', 'rescue', '긴급구난모델 이론', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: reports 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('reports:report-list', 'reports', '보고서 목록', 1, 1), + ('reports:template', 'reports', '표준보고서 템플릿', 1, 2), + ('reports:generate', 'reports', '보고서 생성', 1, 3) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: aerial 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('aerial:media', 'aerial', '영상사진관리', 1, 1), + ('aerial:analysis', 'aerial', '유출유면적분석', 1, 2), + ('aerial:realtime', 'aerial', '실시간드론', 1, 3), + ('aerial:sensor', 'aerial', '오염/선박3D분석', 1, 4), + ('aerial:satellite', 'aerial', '위성요청', 1, 5), + ('aerial:cctv', 'aerial', 'CCTV 조회', 1, 6), + ('aerial:theory', 'aerial', '항공탐색 이론', 1, 7) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: assets 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('assets:management', 'assets', '자산 관리', 1, 1), + ('assets:upload', 'assets', '자산 현행화', 1, 2), + ('assets:theory', 'assets', '방제자원 이론', 1, 3), + ('assets:insurance', 'assets', '선박 보험정보', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: board 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('board:all', 'board', '전체', 1, 1), + ('board:notice', 'board', '공지사항', 1, 2), + ('board:data', 'board', '자료실', 1, 3), + ('board:qna', 'board', 'Q&A', 1, 4), + ('board:manual', 'board', '해경매뉴얼', 1, 5) +ON CONFLICT (RSRC_CD) DO NOTHING; + +-- Level 1: admin 하위 +INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) VALUES + ('admin:users', 'admin', '사용자 관리', 1, 1), + ('admin:permissions', 'admin', '권한 관리', 1, 2), + ('admin:menus', 'admin', '메뉴 관리', 1, 3), + ('admin:settings', 'admin', '시스템 설정', 1, 4) +ON CONFLICT (RSRC_CD) DO NOTHING; diff --git a/database/migration/004_oper_cd.sql b/database/migration/004_oper_cd.sql new file mode 100644 index 0000000..a352a58 --- /dev/null +++ b/database/migration/004_oper_cd.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- 마이그레이션 004: AUTH_PERM에 OPER_CD 컬럼 추가 +-- 리소스 단일 권한 → 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델 +-- ============================================================ + +-- Step 1: OPER_CD 컬럼 추가 (기존 레코드는 'READ'로 설정) +ALTER TABLE AUTH_PERM ADD COLUMN IF NOT EXISTS OPER_CD VARCHAR(20) NOT NULL DEFAULT 'READ'; +COMMENT ON COLUMN AUTH_PERM.OPER_CD IS '오퍼레이션코드 (READ, CREATE, UPDATE, DELETE, MANAGE, EXPORT)'; + +-- Step 2: UNIQUE 제약 변경 (ROLE_SN, RSRC_CD) → (ROLE_SN, RSRC_CD, OPER_CD) +-- INSERT 전에 변경해야 CUD 레코드 삽입 시 충돌 없음 +ALTER TABLE AUTH_PERM DROP CONSTRAINT IF EXISTS UK_AUTH_PERM; +ALTER TABLE AUTH_PERM ADD CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD, OPER_CD); + +-- Step 3: 기존 GRANT_YN='Y' 레코드를 CREATE/UPDATE/DELETE로 확장 +-- (기존에 허용된 리소스는 RCUD 모두 허용하여 동작 보존) +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'CREATE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'UPDATE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) +SELECT ROLE_SN, RSRC_CD, 'DELETE', GRANT_YN +FROM AUTH_PERM WHERE OPER_CD = 'READ' AND GRANT_YN = 'Y' +ON CONFLICT DO NOTHING; + +-- Step 3-1: VIEWER(조회 전용) 역할의 CUD 레코드 제거 +-- VIEWER는 READ만 허용, CUD 확장은 의미 없음 +DELETE FROM AUTH_PERM +WHERE ROLE_SN = (SELECT ROLE_SN FROM AUTH_ROLE WHERE ROLE_CD = 'VIEWER') + AND OPER_CD != 'READ'; + +-- Step 4: 기본값 제거 (신규 레코드는 반드시 OPER_CD 명시) +ALTER TABLE AUTH_PERM ALTER COLUMN OPER_CD DROP DEFAULT; + +-- Step 5: CHECK 제약 추가 (확장 가능: MANAGE, EXPORT 포함) +DO $$ BEGIN + ALTER TABLE AUTH_PERM ADD CONSTRAINT CK_AUTH_PERM_OPER + CHECK (OPER_CD IN ('READ','CREATE','UPDATE','DELETE','MANAGE','EXPORT')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Step 6: 인덱스 +CREATE INDEX IF NOT EXISTS IDX_AUTH_PERM_OPER ON AUTH_PERM (OPER_CD); + +-- 검증 +SELECT ROLE_SN, OPER_CD, COUNT(*), STRING_AGG(GRANT_YN, '') as grants +FROM AUTH_PERM +GROUP BY ROLE_SN, OPER_CD +ORDER BY ROLE_SN, OPER_CD; diff --git a/database/migration/005_db_consolidation.sql b/database/migration/005_db_consolidation.sql new file mode 100644 index 0000000..61262a5 --- /dev/null +++ b/database/migration/005_db_consolidation.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- 마이그레이션 005: DB 통합 (wing + wing_auth → wing 단일 DB) +-- +-- 스키마 구조: +-- wing — 운영 데이터 (LAYER, BOARD_POST, HNS_SUBSTANCE 등) +-- auth — 인증/인가 데이터 (AUTH_USER, AUTH_ROLE 등) +-- public — PostGIS 시스템 테이블만 유지 (spatial_ref_sys) +-- +-- 실행 순서: +-- 1. 이 SQL을 wing DB에서 실행 (스키마 생성 + 테이블 이동) +-- 2. wing_auth DB 덤프 → auth 스키마로 복원 (별도 쉘) +-- 3. search_path 설정 +-- ============================================================ + +-- Step 1: 명시적 스키마 생성 +CREATE SCHEMA IF NOT EXISTS wing; +CREATE SCHEMA IF NOT EXISTS auth; + +-- Step 2: 기존 public 운영 테이블을 wing 스키마로 이동 +-- (PostGIS 시스템 테이블 spatial_ref_sys, topology는 public에 유지) +ALTER TABLE IF EXISTS public.layer SET SCHEMA wing; +ALTER TABLE IF EXISTS public.hns_substance SET SCHEMA wing; + +-- Step 3: 기본 search_path 설정 (DB 레벨) +-- wing 사용자가 스키마 접두사 없이 양쪽 테이블 접근 가능 +ALTER DATABASE wing SET search_path = wing, auth; + +-- Step 4: wing 사용자에게 auth 스키마 권한 부여 +-- (wing_auth 데이터 복원 후 적용) +GRANT USAGE ON SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO wing; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO wing; + +-- Step 5: wing 스키마 기본 권한 +GRANT ALL PRIVILEGES ON SCHEMA wing TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON TABLES TO wing; +ALTER DEFAULT PRIVILEGES IN SCHEMA wing GRANT ALL ON SEQUENCES TO wing; + +-- 검증 +SELECT schemaname, tablename +FROM pg_tables +WHERE schemaname IN ('wing', 'auth') +ORDER BY schemaname, tablename; diff --git a/database/migration/006_board.sql b/database/migration/006_board.sql new file mode 100644 index 0000000..efc6e08 --- /dev/null +++ b/database/migration/006_board.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- 마이그레이션 006: 게시판 (BOARD_POST) +-- wing 스키마에 생성, auth.AUTH_USER FK 참조 +-- ============================================================ + +-- Step 1: 게시판 테이블 +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; +COMMENT ON COLUMN BOARD_POST.PINNED_YN IS '상단고정 여부'; +COMMENT ON COLUMN BOARD_POST.USE_YN IS '사용여부 (N=논리삭제)'; + +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); + +-- Step 2: 초기 데이터 (기존 프론트엔드 mockPosts 이전) +-- admin 사용자 ID 조회 +DO $$ +DECLARE + v_admin_id UUID; +BEGIN + SELECT USER_ID INTO v_admin_id FROM auth.AUTH_USER WHERE USER_ACNT = 'admin' LIMIT 1; + + IF v_admin_id IS NOT NULL THEN + INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, VIEW_CNT, PINNED_YN, REG_DTM) VALUES + ('NOTICE', '시스템 업데이트 안내', '시스템 업데이트 관련 안내사항입니다.', v_admin_id, 245, 'Y', '2025-02-15'::timestamptz), + ('NOTICE', '2025년 방제 교육 일정 안내', '2025년도 방제 교육 일정을 안내합니다.', v_admin_id, 189, 'Y', '2025-02-14'::timestamptz), + ('DATA', '방제 매뉴얼 업데이트 (2025년 개정판)', '2025년 개정판 방제 매뉴얼입니다.', v_admin_id, 423, 'N', '2025-02-10'::timestamptz), + ('QNA', 'HNS 대기확산 분석 결과 해석 문의', 'HNS 분석 결과 해석 방법을 문의합니다.', v_admin_id, 156, 'N', '2025-02-08'::timestamptz), + ('DATA', '2024년 유류오염사고 통계 자료', '2024년도 유류오염사고 통계 자료를 공유합니다.', v_admin_id, 312, 'N', '2025-02-05'::timestamptz), + ('QNA', '유출유 확산 예측 알고리즘 선택 기준', '확산 예측 시 알고리즘 선택 기준을 문의합니다.', v_admin_id, 267, 'N', '2025-02-03'::timestamptz), + ('DATA', '해양오염 방제 장비 운용 가이드', '방제 장비 운용 가이드 문서입니다.', v_admin_id, 534, 'N', '2025-01-28'::timestamptz), + ('QNA', 'SCAT 조사 방법 관련 질문', 'SCAT 현장 조사 방법에 대해 질문합니다.', v_admin_id, 198, 'N', '2025-01-25'::timestamptz), + ('DATA', 'HNS 물질 안전보건자료 (MSDS) 모음', 'HNS 물질별 MSDS 자료 모음입니다.', v_admin_id, 645, 'N', '2025-01-20'::timestamptz), + ('QNA', '항공촬영 드론 운용 시 주의사항', '드론 운용 시 주의할 점을 문의합니다.', v_admin_id, 221, 'N', '2025-01-15'::timestamptz) + ON CONFLICT DO NOTHING; + END IF; +END $$; + +-- 검증 +SELECT POST_SN, CATEGORY_CD, TITLE, VIEW_CNT, PINNED_YN FROM BOARD_POST ORDER BY POST_SN; diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index e4f2b25..561f4ca 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -10,21 +10,79 @@ ### 개요 JWT 기반 세션 인증. HttpOnly 쿠키(`WING_SESSION`)로 토큰을 관리하며, 프론트엔드에서는 Zustand `authStore`로 상태를 관리합니다. +### 권한 모델: 리소스 × 오퍼레이션 (RBAC) + +**2차원 권한 모델**: 리소스 트리(상속) × 오퍼레이션(RCUD, 플랫) + +``` +AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) +├── prediction READ = 조회/열람 +│ ├── prediction:analysis CREATE = 생성 +│ ├── prediction:list UPDATE = 수정 +│ └── prediction:theory DELETE = 삭제 +├── board +│ ├── board:notice +│ └── board:data +└── admin + ├── admin:users + └── admin:permissions +``` + +#### 오퍼레이션 코드 + +| OPER_CD | 설명 | 비고 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | +| `MANAGE` | 관리 | 관리자 설정 (확장용) | +| `EXPORT` | 내보내기 | 다운로드/출력 (확장용) | + +#### 상속 규칙 + +1. 부모 리소스의 **READ**가 N → 자식의 **모든 오퍼레이션** 강제 N (접근 자체 차단) +2. 해당 `(RSRC_CD, OPER_CD)` 명시적 레코드 있으면 → 그 값 사용 +3. 명시적 레코드 없으면 → 부모의 **같은 OPER_CD** 상속 +4. 최상위까지 없으면 → 기본 N (거부) + +``` +예시: board (READ:Y, CREATE:Y, UPDATE:Y, DELETE:N) +└── board:notice + ├── READ: 상속 Y (부모 READ Y) + ├── CREATE: 상속 Y (부모 CREATE Y) + ├── UPDATE: 명시적 N (override 가능) + └── DELETE: 상속 N (부모 DELETE N) +``` + +#### 키 구분자 +- 리소스 내부 경로: `:` (board:notice) +- 리소스-오퍼레이션 결합 (내부용): `::` (board:notice::READ) + ### 백엔드 -#### 미들웨어 적용 +#### 미들웨어 + ```typescript -// backend/src/auth/authMiddleware.ts -import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { requireAuth, requireRole, requirePermission } from '../auth/authMiddleware.js' // 인증만 필요한 라우트 router.use(requireAuth) -// 특정 역할 필요 +// 역할 기반 (관리 API용) router.use(requireRole('ADMIN')) -router.use(requireRole('ADMIN', 'MANAGER')) + +// 리소스×오퍼레이션 기반 (일반 비즈니스 API용) +router.post('/notice/list', requirePermission('board:notice', 'READ'), handler) +router.post('/notice/create', requirePermission('board:notice', 'CREATE'), handler) +router.post('/notice/update', requirePermission('board:notice', 'UPDATE'), handler) +router.post('/notice/delete', requirePermission('board:notice', 'DELETE'), handler) ``` +`requirePermission`은 요청당 1회만 DB 조회하고 `req.resolvedPermissions`에 캐싱합니다. + #### JWT 페이로드 (req.user) `requireAuth` 통과 후 `req.user`에 담기는 정보: ```typescript @@ -36,25 +94,21 @@ interface JwtPayload { } ``` -#### 라우터 패턴 +#### 라우터 패턴 (CRUD 구조) ```typescript // backend/src/[모듈]/[모듈]Router.ts import { Router } from 'express' -import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' const router = Router() router.use(requireAuth) -router.get('/', async (req, res) => { - try { - const userId = req.user!.sub - // 비즈니스 로직... - res.json(result) - } catch (err) { - console.error('[모듈] 오류:', err) - res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) - } -}) +// 리소스별 CRUD 엔드포인트 +router.post('/list', requirePermission('module:sub', 'READ'), listHandler) +router.post('/detail', requirePermission('module:sub', 'READ'), detailHandler) +router.post('/create', requirePermission('module:sub', 'CREATE'), createHandler) +router.post('/update', requirePermission('module:sub', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('module:sub', 'DELETE'), deleteHandler) export default router ``` @@ -63,32 +117,36 @@ export default router #### authStore (Zustand) ```typescript -// frontend/src/store/authStore.ts -import { useAuthStore } from '../store/authStore' +import { useAuthStore } from '@common/store/authStore' -// 컴포넌트 내에서 사용 const { user, isAuthenticated, hasPermission, logout } = useAuthStore() // 사용자 정보 user?.id // UUID user?.name // 이름 user?.roles // ['ADMIN', 'USER'] +user?.permissions // { 'prediction': ['READ','CREATE','UPDATE','DELETE'], ... } -// 권한 확인 (탭 ID 기준) -hasPermission('prediction') // true/false -hasPermission('admin') // true/false +// 권한 확인 (리소스 × 오퍼레이션) +hasPermission('prediction') // READ 확인 (기본값) +hasPermission('prediction', 'READ') // 명시적 READ 확인 +hasPermission('board:notice', 'CREATE') // 공지사항 생성 권한 +hasPermission('board:notice', 'DELETE') // 공지사항 삭제 권한 + +// 하위 호환: operation 생략 시 'READ' 기본값 +hasPermission('admin') // === hasPermission('admin', 'READ') ``` #### API 클라이언트 ```typescript -// frontend/src/services/api.ts -import { api } from './api' +import { api } from '@common/services/api' // withCredentials: true 설정으로 JWT 쿠키 자동 포함 -const response = await api.get('/your-endpoint') -const response = await api.post('/your-endpoint', data) +const response = await api.post('/your-endpoint/list', params) +const response = await api.post('/your-endpoint/create', data) // 401 응답 시 자동 로그아웃 처리 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) ``` --- @@ -103,13 +161,15 @@ const response = await api.post('/your-endpoint', data) ```typescript // frontend/src/App.tsx (자동 적용, 수정 불필요) +import { API_BASE_URL } from '@common/services/api' + useEffect(() => { if (!isAuthenticated) return const blob = new Blob( [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], { type: 'text/plain' } ) - navigator.sendBeacon('/api/audit/log', blob) + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) }, [activeMainTab, isAuthenticated]) ``` @@ -117,12 +177,13 @@ useEffect(() => { 특정 작업에 대해 명시적으로 감사 로그를 기록하려면: ```typescript -// 프론트엔드에서 sendBeacon 사용 +import { API_BASE_URL } from '@common/services/api' + const blob = new Blob( [JSON.stringify({ action: 'ADMIN_ACTION', detail: '사용자 승인' })], { type: 'text/plain' } ) -navigator.sendBeacon('/api/audit/log', blob) +navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) ``` ### 감사 로그 테이블 구조 (AUTH_AUDIT_LOG) @@ -275,30 +336,82 @@ const mutation = useMutation({ --- -## 6. 백엔드 모듈 추가 절차 +## 6. 백엔드 API CRUD 규칙 + +> 상세 가이드 + 게시판 실전 튜토리얼: **[CRUD-API-GUIDE.md](./CRUD-API-GUIDE.md)** 참조 + +### HTTP Method 정책 (보안 가이드 준수) +- 보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +- GET은 단순 조회 중 민감하지 않은 경우에만 허용 (필요 시 POST로 전환). +- PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다. + +### 오퍼레이션 기반 권한 미들웨어 +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. +`requirePermission` 미들웨어에 명시적으로 오퍼레이션을 지정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `/resource/list` | READ | `requirePermission(resource, 'READ')` | +| `/resource/detail` | READ | `requirePermission(resource, 'READ')` | +| `/resource/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `/resource/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `/resource/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +### 라우터 작성 예시 + +```typescript +// backend/src/board/noticeRouter.ts +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +const router = Router() +router.use(requireAuth) + +// 조회 +router.post('/list', requirePermission('board:notice', 'READ'), listHandler) +router.post('/detail', requirePermission('board:notice', 'READ'), detailHandler) + +// 생성/수정/삭제 +router.post('/create', requirePermission('board:notice', 'CREATE'), createHandler) +router.post('/update', requirePermission('board:notice', 'UPDATE'), updateHandler) +router.post('/delete', requirePermission('board:notice', 'DELETE'), deleteHandler) + +export default router +``` + +### 관리 API (예외) +사용자/역할/설정 등 관리 API는 `requireRole('ADMIN')` 유지: +```typescript +router.use(requireAuth) +router.use(requireRole('ADMIN')) +``` + +--- + +## 7. 백엔드 모듈 추가 절차 새 백엔드 모듈을 추가할 때: 1. `backend/src/[모듈명]/` 디렉토리 생성 2. `[모듈명]Service.ts` — 비즈니스 로직 (DB 쿼리) -3. `[모듈명]Router.ts` — Express 라우터 (입력 검증, 에러 처리) +3. `[모듈명]Router.ts` — Express 라우터 (CRUD 엔드포인트 + requirePermission) 4. `backend/src/server.ts`에 라우터 등록: ```typescript import newRouter from './[모듈명]/[모듈명]Router.js' app.use('/api/[경로]', newRouter) ``` 5. DB 테이블 필요 시 `database/auth_init.sql`에 DDL 추가 +6. 리소스 코드를 `AUTH_PERM_TREE`에 등록 (마이그레이션 SQL) ### DB 접근 ```typescript -// PostgreSQL (인증 DB) -import { authPool } from '../db/authDb.js' -const result = await authPool.query('SELECT * FROM TABLE WHERE id = $1', [id]) +// PostgreSQL — wing DB (운영 데이터: 레이어, 사고, 예측 등) +import { wingPool } from '../db/wingDb.js' +const result = await wingPool.query('SELECT * FROM LAYER WHERE LAYER_CD = $1', [id]) -// SQLite (레이어 DB) -import { getDb } from '../db/database.js' -const db = getDb() -const rows = db.prepare('SELECT * FROM table').all() +// PostgreSQL — wing_auth DB (인증 데이터: 사용자, 역할, 권한 등) +import { authPool } from '../db/authDb.js' +const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1', [id]) ``` --- @@ -307,20 +420,30 @@ const rows = db.prepare('SELECT * FROM table').all() ``` frontend/src/ -├── services/api.ts Axios 인스턴스 + 인터셉터 -├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API -├── store/authStore.ts 인증 상태 (Zustand) -├── store/menuStore.ts 메뉴 상태 (Zustand) -└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 +├── common/ +│ ├── services/api.ts Axios 인스턴스 + API_BASE_URL + 인터셉터 +│ ├── services/authApi.ts 인증/사용자/역할/설정/메뉴/감사로그 API +│ ├── store/authStore.ts 인증 상태 + hasPermission (Zustand) +│ ├── store/menuStore.ts 메뉴 상태 (Zustand) +│ └── hooks/ useSubMenu, useFeatureTracking 등 +├── tabs/ 탭별 패키지 (11개) +└── App.tsx 탭 라우팅 + 감사 로그 자동 기록 backend/src/ -├── auth/ 인증 (JWT, OAuth, 미들웨어) +├── auth/ 인증 (JWT, OAuth, 미들웨어, requirePermission) ├── users/ 사용자 관리 -├── roles/ 역할/권한 관리 +├── roles/ 역할/권한 관리 (permResolver, roleService) ├── settings/ 시스템 설정 ├── menus/ 메뉴 설정 ├── audit/ 감사 로그 -├── db/ DB 연결 (authDb, database) +├── db/ DB 연결 (authDb, wingDb) ├── middleware/ 보안 미들웨어 └── server.ts Express 진입점 + 라우터 등록 + +database/ +├── auth_init.sql 인증 DB DDL + 초기 데이터 +├── init.sql 운영 DB DDL +└── migration/ 마이그레이션 스크립트 + ├── 003_perm_tree.sql 리소스 트리 (AUTH_PERM_TREE) + └── 004_oper_cd.sql 오퍼레이션 코드 (OPER_CD) 추가 ``` diff --git a/docs/CRUD-API-GUIDE.md b/docs/CRUD-API-GUIDE.md new file mode 100644 index 0000000..95c46e4 --- /dev/null +++ b/docs/CRUD-API-GUIDE.md @@ -0,0 +1,1433 @@ +# RBAC 기반 CRUD API 개발 가이드 + +새 CRUD API를 추가할 때 따라야 할 표준 가이드. +Phase 5 RBAC 체계(리소스 x 오퍼레이션 2차원 모델)를 기반으로 한다. + +**DB 구조**: wing DB 단일 DB, 스키마 분리 +- `wing` 스키마: 운영 데이터 (BOARD_POST, LAYER 등) +- `auth` 스키마: 인증/인가 데이터 (AUTH_USER, AUTH_ROLE, AUTH_PERM 등) +- `public` 스키마: PostGIS 시스템 테이블만 유지 (사용 금지) + +--- + +## Part 1: 범용 가이드 + +### 1. 개요 + +이 문서는 WING-OPS의 **모든 탭 개발자**가 새 CRUD API를 만들 때 참조하는 표준이다. + +- 백엔드: Express Router + Service 2-Layer +- 권한: `requirePermission(resource, operation)` 미들웨어 +- DB: PostgreSQL (`wingPool` 단일 Pool, `search_path = wing, auth, public`) +- 프론트: Axios + `hasPermission()` 조건부 렌더링 + +각 섹션에 복사해서 바로 사용할 수 있는 실제 코드 스니펫을 포함한다. + +--- + +### 2. 아키텍처 + +#### 3-Layer 구조 + +``` +클라이언트 (React) + ↓ Axios (withCredentials: true, JWT 쿠키 자동 포함) +Router (Express) ← requireAuth → requirePermission + ↓ +Service ← 비즈니스 로직, DB 쿼리 + ↓ +DB (pg Pool) ← wingPool (search_path = wing, auth) +``` + +#### 디렉토리 구조 + +``` +backend/src/{domain}/ +├── {domain}Router.ts ← Express 라우터 (엔드포인트 + 미들웨어) +└── {domain}Service.ts ← 비즈니스 로직 (쿼리, 인터페이스) +``` + +#### DB Pool + +```typescript +// backend/src/db/wingDb.ts +import { wingPool } from '../db/wingDb.js' + +// wingPool은 연결 시 search_path = wing, auth, public 자동 설정 +// → 스키마 접두사 없이 wing.BOARD_POST, auth.AUTH_USER 모두 접근 가능 +``` + +> **주의**: `authPool`은 하위 호환용 re-export이다. 신규 코드는 반드시 `wingPool`을 직접 import할 것. + +```typescript +// backend/src/db/authDb.ts (하위 호환 — 신규 코드에서 사용 금지) +import { wingPool } from './wingDb.js' +export const authPool = wingPool // 같은 Pool +``` + +--- + +### 3. 권한 모델 빠른 요약 + +#### 2차원 모델: 리소스 트리 x 오퍼레이션 + +``` +AUTH_PERM 테이블: (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + +리소스 트리 (AUTH_PERM_TREE) 오퍼레이션 (플랫) +├── board READ = 조회/열람 +│ ├── board:notice CREATE = 생성 +│ ├── board:data UPDATE = 수정 +│ └── board:qna DELETE = 삭제 +├── prediction +│ ├── prediction:analysis +│ └── prediction:list +└── admin + ├── admin:users + └── admin:permissions +``` + +#### 리소스 코드 + +`AUTH_PERM_TREE` 테이블에 등록된 코드. 콜론(`:`)으로 계층 구분. + +| 형식 | 예시 | 설명 | +|------|------|------| +| `{탭}` | `board` | 메인 탭 (level 0) | +| `{탭}:{서브}` | `board:notice` | 서브 리소스 (level 1) | + +#### 오퍼레이션 + +| OPER_CD | 설명 | 용도 | +|---------|------|------| +| `READ` | 조회/열람 | 목록, 상세 조회 | +| `CREATE` | 생성 | 새 데이터 등록 | +| `UPDATE` | 수정 | 기존 데이터 변경 | +| `DELETE` | 삭제 | 데이터 삭제 | + +#### 백엔드: requirePermission + +```typescript +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' + +// requirePermission(리소스코드, 오퍼레이션코드) +// 오퍼레이션 생략 시 기본값 'READ' +router.post('/list', requirePermission('board:notice', 'READ'), handler) +router.post('/create', requirePermission('board:notice', 'CREATE'), handler) +``` + +`requirePermission`은 **요청당 1회**만 DB를 조회하고 `req.resolvedPermissions`에 캐싱한다. 한 요청에서 여러 번 호출해도 성능 문제 없다. + +#### 프론트엔드: hasPermission + +```typescript +import { useAuthStore } from '@common/store/authStore' + +const { hasPermission } = useAuthStore() + +hasPermission('board:notice') // READ 확인 (기본값) +hasPermission('board:notice', 'CREATE') // 생성 권한 확인 +hasPermission('board:notice', 'UPDATE') // 수정 권한 확인 +hasPermission('board:notice', 'DELETE') // 삭제 권한 확인 +``` + +#### 상속 규칙 + +``` +규칙 1: 부모 READ=N → 자식의 모든 오퍼레이션 강제 N +규칙 2: 명시적 레코드 있으면 → 그 값 사용 +규칙 3: 명시적 레코드 없으면 → 부모의 같은 오퍼레이션 상속 +규칙 4: 최상위까지 없으면 → 기본 N (거부) +``` + +--- + +### 4. DB 설계 규칙 + +#### 스키마 선택 + +| 데이터 성격 | 스키마 | 예시 | +|-------------|--------|------| +| 운영 데이터 | `wing` | BOARD_POST, LAYER, HNS_SUBSTANCE | +| 인증/인가 | `auth` | AUTH_USER, AUTH_ROLE, AUTH_PERM | + +> `search_path = wing, auth, public` 설정으로 스키마 접두사 없이 접근 가능. +> 단, 다른 스키마 테이블을 FK로 참조할 때는 `auth.AUTH_USER(USER_ID)` 처럼 명시한다. + +#### 네이밍 규칙 + +| 항목 | 규칙 | 예시 | +|------|------|------| +| 테이블명 | UPPER_SNAKE_CASE | `BOARD_POST`, `HNS_SUBSTANCE` | +| 컬럼명 | UPPER_SNAKE_CASE | `POST_SN`, `CATEGORY_CD`, `REG_DTM` | +| PK | `{접두어}_SN` (SERIAL) 또는 `{접두어}_ID` (UUID) | `POST_SN`, `USER_ID` | +| FK 컬럼 | 참조 테이블의 PK 컬럼명 그대로 사용 | `AUTHOR_ID` (→ AUTH_USER.USER_ID) | +| 코드성 컬럼 | `{의미}_CD` | `CATEGORY_CD`, `OPER_CD` | +| 여부 컬럼 | `{의미}_YN` (CHAR(1), 'Y'/'N') | `USE_YN`, `PINNED_YN` | +| 일시 컬럼 | `{의미}_DTM` (TIMESTAMPTZ) | `REG_DTM`, `MDFCN_DTM` | + +#### 공통 컬럼 패턴 + +모든 운영 테이블에 포함하는 표준 컬럼: + +```sql +USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 논리삭제 (Y=활성, N=삭제) +REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시 +MDFCN_DTM TIMESTAMPTZ, -- 수정일시 +``` + +#### DDL 작성 예시 + +```sql +-- database/migration/NNN_description.sql + +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + -- FK: 다른 스키마 참조 시 스키마 명시 + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + + -- CHECK: 코드성 컬럼에 허용값 명시 + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +-- COMMENT: 테이블/컬럼 설명 +COMMENT ON TABLE BOARD_POST IS '게시판 게시글'; +COMMENT ON COLUMN BOARD_POST.CATEGORY_CD IS '카테고리: NOTICE=공지, DATA=자료실, QNA=Q&A, MANUAL=해경매뉴얼'; + +-- INDEX: 검색/필터 대상, FK 컬럼 +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +#### 마이그레이션 파일 규칙 + +- 경로: `database/migration/NNN_description.sql` +- 번호: 기존 파일 다음 번호 (001, 003, 004, 005, 006, ...) +- 모든 DDL에 `IF NOT EXISTS` / `IF EXISTS` 사용 (재실행 안전) +- 파일 끝에 검증 SELECT 포함 + +--- + +### 5. Service 레이어 패턴 + +#### 인터페이스 정의 + +Service 파일 상단에 반환 타입과 입력 타입을 정의한다. + +```typescript +// backend/src/{domain}/{domain}Service.ts + +import { wingPool } from '../db/wingDb.js' +import { AuthError } from '../auth/authService.js' + +// 목록/상세 조회 반환 타입 +interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +// 생성 입력 타입 +interface CreatePostInput { + categoryCd: string + title: string + content?: string + authorId: string + pinnedYn?: string +} + +// 수정 입력 타입 (모든 필드 optional — 부분 업데이트) +interface UpdatePostInput { + title?: string + content?: string + categoryCd?: string + pinnedYn?: string +} + +// 페이징 응답 타입 +interface PagedResult { + items: T[] + totalCount: number + page: number + size: number +} +``` + +#### wingPool 사용 + +```typescript +import { wingPool } from '../db/wingDb.js' + +// 단순 조회 +const result = await wingPool.query( + 'SELECT * FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = $2', + [postSn, 'Y'] +) + +// Parameterized Query — 반드시 $1, $2, ... 사용 (SQL Injection 방지) +// 문자열 결합으로 쿼리를 만들지 않는다 +``` + +#### 동적 WHERE 빌드 패턴 (필터, 검색) + +```typescript +export async function listPosts( + categoryCd?: string, + search?: string, + page: number = 1, + size: number = 20, +): Promise> { + // 동적 WHERE 조건 + const conditions: string[] = ["p.USE_YN = 'Y'"] + const params: (string | number)[] = [] + let paramIdx = 1 + + if (categoryCd) { + conditions.push(`p.CATEGORY_CD = $${paramIdx++}`) + params.push(categoryCd) + } + + if (search) { + conditions.push(`(p.TITLE ILIKE $${paramIdx} OR p.CONTENT ILIKE $${paramIdx})`) + params.push(`%${search}%`) + paramIdx++ + } + + const whereClause = conditions.join(' AND ') + + // totalCount 조회 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST p WHERE ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 페이징 데이터 조회 + const offset = (page - 1) * size + const dataParams = [...params, size, offset] + + const dataResult = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE ${whereClause} + ORDER BY p.PINNED_YN DESC, p.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, + dataParams + ) + + const items: PostItem[] = dataResult.rows.map((row) => ({ + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + })) + + return { items, totalCount, page, size } +} +``` + +#### 상세 조회 + +```typescript +export async function getPost(postSn: number): Promise { + const result = await wingPool.query( + `SELECT p.POST_SN as post_sn, p.CATEGORY_CD as category_cd, + p.TITLE as title, p.CONTENT as content, + p.AUTHOR_ID as author_id, u.USER_NM as author_name, + p.VIEW_CNT as view_cnt, p.PINNED_YN as pinned_yn, + p.USE_YN as use_yn, p.REG_DTM as reg_dtm, p.MDFCN_DTM as mdfcn_dtm + FROM BOARD_POST p + LEFT JOIN AUTH_USER u ON p.AUTHOR_ID = u.USER_ID + WHERE p.POST_SN = $1 AND p.USE_YN = 'Y'`, + [postSn] + ) + + if (result.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + return { + postSn: row.post_sn, + categoryCd: row.category_cd, + title: row.title, + content: row.content, + authorId: row.author_id, + authorName: row.author_name, + viewCnt: row.view_cnt, + pinnedYn: row.pinned_yn, + useYn: row.use_yn, + regDtm: row.reg_dtm, + mdfcnDtm: row.mdfcn_dtm, + } +} +``` + +#### 생성 + +```typescript +export async function createPost(input: CreatePostInput): Promise<{ postSn: number }> { + const result = await wingPool.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID, PINNED_YN) + VALUES ($1, $2, $3, $4, $5) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content || null, input.authorId, input.pinnedYn || 'N'] + ) + + return { postSn: result.rows[0].post_sn } +} +``` + +#### 동적 SET 빌드 패턴 (부분 업데이트) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string, +): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // 동적 SET 빌드 + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.title !== undefined) { + sets.push(`TITLE = $${idx++}`) + params.push(input.title) + } + if (input.content !== undefined) { + sets.push(`CONTENT = $${idx++}`) + params.push(input.content) + } + if (input.categoryCd !== undefined) { + sets.push(`CATEGORY_CD = $${idx++}`) + params.push(input.categoryCd) + } + if (input.pinnedYn !== undefined) { + sets.push(`PINNED_YN = $${idx++}`) + params.push(input.pinnedYn) + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + // MDFCN_DTM 자동 갱신 + sets.push('MDFCN_DTM = NOW()') + params.push(postSn) + + await wingPool.query( + `UPDATE BOARD_POST SET ${sets.join(', ')} WHERE POST_SN = $${idx}`, + params + ) +} +``` + +#### 삭제 (논리삭제) + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 + const existing = await wingPool.query( + "SELECT AUTHOR_ID FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'", + [postSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 삭제할 수 있습니다.', 403) + } + + // 논리삭제: USE_YN = 'N' + await wingPool.query( + "UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1", + [postSn] + ) +} +``` + +#### 트랜잭션 패턴 + +여러 테이블을 동시에 변경해야 할 때: + +```typescript +export async function createPostWithAttachments( + input: CreatePostInput, + attachments: AttachmentInput[], +): Promise<{ postSn: number }> { + const client = await wingPool.connect() + + try { + await client.query('BEGIN') + + // 게시글 생성 + const postResult = await client.query( + `INSERT INTO BOARD_POST (CATEGORY_CD, TITLE, CONTENT, AUTHOR_ID) + VALUES ($1, $2, $3, $4) + RETURNING POST_SN as post_sn`, + [input.categoryCd, input.title, input.content, input.authorId] + ) + const postSn = postResult.rows[0].post_sn + + // 첨부파일 생성 + for (const att of attachments) { + await client.query( + `INSERT INTO BOARD_ATTACH (POST_SN, FILE_NM, FILE_PATH, FILE_SIZE) + VALUES ($1, $2, $3, $4)`, + [postSn, att.fileName, att.filePath, att.fileSize] + ) + } + + await client.query('COMMIT') + return { postSn } + } catch (err) { + await client.query('ROLLBACK') + throw err + } finally { + client.release() + } +} +``` + +#### 에러 처리 + +```typescript +import { AuthError } from '../auth/authService.js' + +// AuthError: status 코드와 메시지를 포함하는 커스텀 에러 +// Router에서 instanceof 체크로 적절한 HTTP 응답을 반환 + +throw new AuthError('게시글을 찾을 수 없습니다.', 404) +throw new AuthError('권한이 없습니다.', 403) +throw new AuthError('필수 항목이 누락되었습니다.', 400) +throw new AuthError('이미 존재하는 데이터입니다.', 409) +``` + +`AuthError` 클래스 정의 (`backend/src/auth/authService.ts`): + +```typescript +export class AuthError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + this.name = 'AuthError' + } +} +``` + +--- + +### 6. Router 레이어 패턴 + +#### 미들웨어 체인 + +``` +requireAuth → requirePermission(resource, operation) → 핸들러 +``` + +- `requireAuth`: JWT 쿠키 검증, `req.user`에 페이로드 세팅 +- `requirePermission`: 리소스 x 오퍼레이션 권한 확인 + +#### CRUD 엔드포인트 표준 + +보안 취약점 점검 가이드에 따라 **POST 메서드를 기본**으로 사용한다. +OPER_CD는 HTTP Method가 아닌 **비즈니스 의미**로 결정한다. + +| URL 패턴 | OPER_CD | 미들웨어 | +|----------|---------|----------| +| `POST /api/{domain}/list` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/detail` | READ | `requirePermission(resource, 'READ')` | +| `POST /api/{domain}/create` | CREATE | `requirePermission(resource, 'CREATE')` | +| `POST /api/{domain}/update` | UPDATE | `requirePermission(resource, 'UPDATE')` | +| `POST /api/{domain}/delete` | DELETE | `requirePermission(resource, 'DELETE')` | + +#### 전체 Router 예시 + +```typescript +// backend/src/board/boardRouter.ts + +import { Router } from 'express' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { + listPosts, + getPost, + createPost, + updatePost, + deletePost, +} from './boardService.js' + +const router = Router() + +// 모든 엔드포인트에 인증 필수 +router.use(requireAuth) + +// 목록 조회 +router.post('/list', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { categoryCd, search, page, size } = req.body + const result = await listPosts(categoryCd, search, page, size) + res.json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 목록 조회 오류:', err) + res.status(500).json({ error: '게시글 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// 상세 조회 +router.post('/detail', requirePermission('board:notice', 'READ'), async (req, res) => { + try { + const { postSn } = req.body + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + const post = await getPost(postSn) + res.json(post) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 상세 조회 오류:', err) + res.status(500).json({ error: '게시글 조회 중 오류가 발생했습니다.' }) + } +}) + +// 생성 +router.post('/create', requirePermission('board:notice', 'CREATE'), async (req, res) => { + try { + const { categoryCd, title, content, pinnedYn } = req.body + + // 필수 필드 검증 + if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + + // req.user!.sub = 현재 로그인 사용자 UUID + const result = await createPost({ + categoryCd, + title, + content, + authorId: req.user!.sub, + pinnedYn, + }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 생성 오류:', err) + res.status(500).json({ error: '게시글 생성 중 오류가 발생했습니다.' }) + } +}) + +// 수정 +router.post('/update', requirePermission('board:notice', 'UPDATE'), async (req, res) => { + try { + const { postSn, title, content, categoryCd, pinnedYn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await updatePost(postSn, { title, content, categoryCd, pinnedYn }, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 수정 오류:', err) + res.status(500).json({ error: '게시글 수정 중 오류가 발생했습니다.' }) + } +}) + +// 삭제 +router.post('/delete', requirePermission('board:notice', 'DELETE'), async (req, res) => { + try { + const { postSn } = req.body + + if (!postSn) { + res.status(400).json({ error: '게시글 번호는 필수입니다.' }) + return + } + + await deletePost(postSn, req.user!.sub) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[board] 삭제 오류:', err) + res.status(500).json({ error: '게시글 삭제 중 오류가 발생했습니다.' }) + } +}) + +export default router +``` + +#### 입력 검증 패턴 + +핸들러 내부에서 필수 필드를 직접 체크한다. + +```typescript +// 필수 필드 검증 +if (!categoryCd || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return +} + +// 배열 타입 검증 +if (!Array.isArray(roleSns)) { + res.status(400).json({ error: '역할 목록이 필요합니다.' }) + return +} + +// 길이 검증 +if (!password || password.length < 4) { + res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' }) + return +} +``` + +#### 에러 응답 패턴 + +모든 핸들러에서 동일한 에러 처리 구조를 사용한다. + +```typescript +try { + // 비즈니스 로직 +} catch (err) { + // 1. AuthError → 해당 status + message + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + // 2. 예상치 못한 에러 → 500 + 일반 메시지 (내부 정보 노출 방지) + console.error('[domain] 작업 오류:', err) + res.status(500).json({ error: '처리 중 오류가 발생했습니다.' }) +} +``` + +#### server.ts 등록 + +```typescript +// backend/src/server.ts + +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +#### req.user 구조 (JWT 페이로드) + +`requireAuth` 통과 후 `req.user`에 담기는 정보: + +```typescript +interface JwtPayload { + sub: string // 사용자 UUID (USER_ID) + acnt: string // 계정명 (USER_ACNT) + name: string // 사용자명 (USER_NM) + roles: string[] // 역할 코드 목록 ['ADMIN', 'MANAGER', 'USER', 'VIEWER'] +} + +// 사용 예시 +const userId = req.user!.sub // 현재 사용자 UUID +const userName = req.user!.name // 현재 사용자 이름 +const isAdmin = req.user!.roles.includes('ADMIN') +``` + +--- + +### 7. 프론트엔드 연동 패턴 + +#### API 서비스 파일 + +탭별로 `services/` 디렉토리에 API 함수를 분리한다. + +```typescript +// frontend/src/tabs/board/services/boardApi.ts + +import { api } from '@common/services/api' + +// 타입 정의 +export interface PostItem { + postSn: number + categoryCd: string + title: string + content: string | null + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + useYn: string + regDtm: string + mdfcnDtm: string | null +} + +export interface PostListResult { + items: PostItem[] + totalCount: number + page: number + size: number +} + +// 목록 조회 +export async function fetchPosts(params: { + categoryCd?: string + search?: string + page?: number + size?: number +}): Promise { + const response = await api.post('/board/list', params) + return response.data +} + +// 상세 조회 +export async function fetchPost(postSn: number): Promise { + const response = await api.post('/board/detail', { postSn }) + return response.data +} + +// 생성 +export async function createPostApi(data: { + categoryCd: string + title: string + content?: string + pinnedYn?: string +}): Promise<{ postSn: number }> { + const response = await api.post<{ postSn: number }>('/board/create', data) + return response.data +} + +// 수정 +export async function updatePostApi( + postSn: number, + data: { title?: string; content?: string; categoryCd?: string; pinnedYn?: string }, +): Promise { + await api.post('/board/update', { postSn, ...data }) +} + +// 삭제 +export async function deletePostApi(postSn: number): Promise { + await api.post('/board/delete', { postSn }) +} +``` + +#### Axios 인스턴스 + +```typescript +// frontend/src/common/services/api.ts (이미 설정됨, 수정 불필요) + +import axios from 'axios' + +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api', + withCredentials: true, // JWT 쿠키 자동 포함 + timeout: 30000, // 30초 타임아웃 +}) + +// 401 응답 시 자동 로그아웃 (인터셉터) +// 403 응답 시 권한 부족 (requirePermission 미들웨어) +``` + +#### 권한 기반 UI 분기 + +```tsx +// frontend/src/tabs/board/components/PostList.tsx + +import { useAuthStore } from '@common/store/authStore' + +const PostList = () => { + const { hasPermission } = useAuthStore() + + return ( +
+

게시판

+ + {/* CREATE 권한이 있을 때만 글쓰기 버튼 표시 */} + {hasPermission('board:notice', 'CREATE') && ( + + )} + + {/* 목록 렌더링 */} + {posts.map((post) => ( +
+ {post.title} + + {/* UPDATE 권한 + 본인 글일 때만 수정 버튼 */} + {hasPermission('board:notice', 'UPDATE') && post.authorId === user?.id && ( + + )} + + {/* DELETE 권한 + 본인 글일 때만 삭제 버튼 */} + {hasPermission('board:notice', 'DELETE') && post.authorId === user?.id && ( + + )} +
+ ))} + + {/* 페이징 */} + +
+ ) +} +``` + +#### TanStack Query 연동 (권장) + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { fetchPosts, createPostApi, deletePostApi } from '../services/boardApi' + +// 목록 조회 +const { data, isLoading } = useQuery({ + queryKey: ['posts', categoryCd, search, page], + queryFn: () => fetchPosts({ categoryCd, search, page, size: 20 }), +}) + +// 생성 +const queryClient = useQueryClient() +const createMutation = useMutation({ + mutationFn: createPostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) + +// 삭제 +const deleteMutation = useMutation({ + mutationFn: deletePostApi, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, +}) +``` + +--- + +### 8. 권한 상속 실전 시나리오 + +`AUTH_PERM_TREE`와 `AUTH_PERM`의 상속 규칙이 실제로 어떻게 동작하는지 4가지 시나리오로 설명한다. + +#### 시나리오 1: 부모 허용 → 자식 상속 + +``` +AUTH_PERM: + ADMIN 역할 — board READ=Y, CREATE=Y, UPDATE=Y, DELETE=Y + +결과: + board:notice READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + board:notice CREATE → 명시적 레코드 없음 → 부모(board) CREATE=Y 상속 → Y + board:data READ → 명시적 레코드 없음 → 부모(board) READ=Y 상속 → Y + +→ 부모에게 권한을 주면 모든 자식이 자동으로 같은 권한을 상속한다. +``` + +#### 시나리오 2: 명시적 거부 (Override) + +``` +AUTH_PERM: + MANAGER 역할 — board READ=Y, CREATE=Y + board:notice CREATE=N (명시적) + +결과: + board:notice READ → 부모 상속 Y + board:notice CREATE → 명시적 N → N (공지 작성 불가) + board:data CREATE → 부모 상속 Y (자료실은 작성 가능) + +→ 자식에 명시적 레코드가 있으면 부모 상속보다 우선한다. +``` + +#### 시나리오 3: 부모 접근 차단 → 자식 전체 차단 + +``` +AUTH_PERM: + VIEWER 역할 — board READ=N + +결과: + board:notice READ → 부모 READ=N → 강제 N (규칙 1) + board:notice CREATE → 부모 READ=N → 강제 N (규칙 1) + board:data READ → 부모 READ=N → 강제 N (규칙 1) + +→ 부모의 READ가 N이면 자식의 모든 오퍼레이션이 강제 차단된다. + 자식에 명시적 Y가 있어도 무시된다. +``` + +#### 시나리오 4: 서브리소스 개별 허용 + +``` +AUTH_PERM: + USER 역할 — board READ=Y, CREATE=N + board:qna CREATE=Y (명시적) + +결과: + board:notice CREATE → 부모 상속 N (공지 작성 불가) + board:data CREATE → 부모 상속 N (자료실 작성 불가) + board:qna CREATE → 명시적 Y → Y (Q&A는 작성 가능) + +→ 부모에서 CUD를 기본 차단하고, 특정 서브리소스만 허용하는 패턴. +``` + +#### 내부 키 형식 + +permResolver에서 리소스와 오퍼레이션을 결합할 때 더블콜론(`::`)을 사용한다. + +``` +리소스 내부 경로: board:notice (싱글콜론) +리소스-오퍼레이션 결합: board:notice::READ (더블콜론, 내부 전용) +``` + +```typescript +// backend/src/roles/permResolver.ts +export function makePermKey(rsrcCode: string, operCd: string): string { + return `${rsrcCode}::${operCd}` +} +``` + +--- + +### 9. 새 CRUD API 추가 체크리스트 + +새 도메인의 CRUD API를 추가할 때 아래 순서대로 진행한다. + +#### 백엔드 + +- [ ] `database/migration/NNN_{domain}.sql` 작성 (DDL + 초기 데이터) + - 테이블 생성 (IF NOT EXISTS) + - FK, CHECK 제약, 인덱스 + - COMMENT + - 검증 SELECT +- [ ] DB 마이그레이션 실행 (`psql`로 직접 실행) +- [ ] `backend/src/{domain}/{domain}Service.ts` 작성 + - 인터페이스 정의 (Item, CreateInput, UpdateInput) + - CRUD 함수 (list, get, create, update, delete) + - wingPool import, AuthError import + - 동적 WHERE/SET 빌드, 소유자 검증 +- [ ] `backend/src/{domain}/{domain}Router.ts` 작성 + - requireAuth + requirePermission 미들웨어 + - POST /list, /detail, /create, /update, /delete + - 입력 검증, AuthError 분기, 500 에러 처리 +- [ ] `backend/src/server.ts`에 라우터 등록 + ```typescript + import boardRouter from './board/boardRouter.js' + app.use('/api/board', boardRouter) + ``` +- [ ] 빌드 확인: `cd backend && npm run build` + +#### 권한 등록 (필요 시) + +- [ ] `AUTH_PERM_TREE`에 리소스 등록 (마이그레이션 SQL) + ```sql + INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD) + VALUES ('board:notice', 'board', '공지사항', 1, 2) + ON CONFLICT (RSRC_CD) DO NOTHING; + ``` +- [ ] `AUTH_PERM`에 역할별 권한 초기값 추가 (마이그레이션 SQL) + ```sql + -- ADMIN: 모든 오퍼레이션 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', op.cd, 'Y' + FROM AUTH_ROLE r, (VALUES ('READ'),('CREATE'),('UPDATE'),('DELETE')) AS op(cd) + WHERE r.ROLE_CD = 'ADMIN' + ON CONFLICT DO NOTHING; + + -- VIEWER: READ만 허용 + INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, GRANT_YN) + SELECT r.ROLE_SN, 'board:notice', 'READ', 'Y' + FROM AUTH_ROLE r + WHERE r.ROLE_CD = 'VIEWER' + ON CONFLICT DO NOTHING; + ``` + +#### 프론트엔드 + +- [ ] `frontend/src/tabs/{domain}/services/{domain}Api.ts` 작성 + - 타입 정의 (interface) + - CRUD API 함수 (api.post 사용) +- [ ] 프론트 컴포넌트에서 mock 데이터 → API 호출로 전환 +- [ ] `hasPermission()` 조건부 렌더링 적용 + - CREATE 권한 → 글쓰기 버튼 + - UPDATE 권한 → 수정 버튼 + - DELETE 권한 → 삭제 버튼 +- [ ] 빌드 확인: `cd frontend && npx tsc --noEmit` + +--- + +## Part 2: 게시판 실전 튜토리얼 + +게시판(Board) CRUD API를 처음부터 끝까지 구현한 실전 예제. +Part 1의 규칙을 실제로 어떻게 적용하는지 단계별로 설명한다. + +--- + +### Step 1: DB 테이블 설계 + +**파일**: `database/migration/006_board.sql` + +```sql +CREATE TABLE IF NOT EXISTS BOARD_POST ( + POST_SN SERIAL PRIMARY KEY, + CATEGORY_CD VARCHAR(20) NOT NULL, + TITLE VARCHAR(200) NOT NULL, + CONTENT TEXT, + AUTHOR_ID UUID NOT NULL, + VIEW_CNT INTEGER NOT NULL DEFAULT 0, + PINNED_YN CHAR(1) NOT NULL DEFAULT 'N', + USE_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + + CONSTRAINT FK_BOARD_AUTHOR FOREIGN KEY (AUTHOR_ID) + REFERENCES auth.AUTH_USER(USER_ID), + CONSTRAINT CK_BOARD_CATEGORY + CHECK (CATEGORY_CD IN ('NOTICE','DATA','QNA','MANUAL')), + CONSTRAINT CK_BOARD_PINNED CHECK (PINNED_YN IN ('Y','N')), + CONSTRAINT CK_BOARD_USE CHECK (USE_YN IN ('Y','N')) +); + +CREATE INDEX IF NOT EXISTS IDX_BOARD_CATEGORY ON BOARD_POST(CATEGORY_CD); +CREATE INDEX IF NOT EXISTS IDX_BOARD_AUTHOR ON BOARD_POST(AUTHOR_ID); +CREATE INDEX IF NOT EXISTS IDX_BOARD_REG_DTM ON BOARD_POST(REG_DTM DESC); +``` + +**설계 포인트**: +- `wing` 스키마에 생성 (search_path 덕분에 쿼리에서 스키마 접두사 불필요) +- `AUTHOR_ID`는 `auth.AUTH_USER(USER_ID)`를 cross-schema FK 참조 +- `USE_YN`으로 논리 삭제 (물리 삭제 대신 `'N'`으로 변경) +- `CATEGORY_CD` CHECK 제약으로 유효값 강제 + +#### 카테고리 ↔ 리소스 매핑 + +| CATEGORY_CD | AUTH_PERM_TREE 리소스 | 정책 | +|---|---|---| +| `NOTICE` | `board:notice` | ADMIN/MANAGER만 CUD | +| `DATA` | `board:data` | MANAGER 이상 CUD | +| `QNA` | `board:qna` | 인증 사용자 CUD (본인 글만 UD) | +| `MANUAL` | `board:manual` | ADMIN만 CUD | + +--- + +### Step 2: Service 구현 + +**파일**: `backend/src/board/boardService.ts` + +#### 인터페이스 정의 + +```typescript +interface PostListItem { + sn: number + categoryCd: string + title: string + authorId: string + authorName: string + viewCnt: number + pinnedYn: string + regDtm: string +} + +interface ListPostsInput { + categoryCd?: string + search?: string + page?: number + size?: number +} + +interface ListPostsResult { + items: PostListItem[] + totalCount: number + page: number + size: number +} +``` + +#### 목록 조회 (페이징 + 필터 + 검색) + +```typescript +export async function listPosts(input: ListPostsInput): Promise { + const page = input.page && input.page > 0 ? input.page : 1 + const size = input.size && input.size > 0 ? Math.min(input.size, 100) : 20 + const offset = (page - 1) * size + + let whereClause = `WHERE bp.USE_YN = 'Y'` + const params: (string | number)[] = [] + let paramIdx = 1 + + if (input.categoryCd) { + whereClause += ` AND bp.CATEGORY_CD = $${paramIdx++}` + params.push(input.categoryCd) + } + + if (input.search) { + whereClause += ` AND (bp.TITLE ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${input.search}%`) + paramIdx++ + } + + // 전체 건수 + const countResult = await wingPool.query( + `SELECT COUNT(*) as cnt FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID ${whereClause}`, + params + ) + const totalCount = parseInt(countResult.rows[0].cnt, 10) + + // 목록 (상단고정 우선 → 등록일 내림차순) + const listParams = [...params, size, offset] + const listResult = await wingPool.query( + `SELECT bp.POST_SN as sn, bp.CATEGORY_CD as category_cd, bp.TITLE as title, + bp.AUTHOR_ID as author_id, u.USER_NM as author_name, + bp.VIEW_CNT as view_cnt, bp.PINNED_YN as pinned_yn, bp.REG_DTM as reg_dtm + FROM BOARD_POST bp + JOIN AUTH_USER u ON bp.AUTHOR_ID = u.USER_ID + ${whereClause} + ORDER BY bp.PINNED_YN DESC, bp.REG_DTM DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx}`, + listParams + ) + // ... 결과 매핑 후 return +} +``` + +**핵심**: `JOIN AUTH_USER`로 cross-schema JOIN 수행 (작성자명 표시). 이것이 DB 통합의 핵심 이점. + +#### 소유자 검증 패턴 (수정/삭제) + +```typescript +export async function updatePost( + postSn: number, + input: UpdatePostInput, + requesterId: string // ← req.user.sub (JWT에서 추출) +): Promise { + const existing = await wingPool.query( + `SELECT AUTHOR_ID as author_id FROM BOARD_POST WHERE POST_SN = $1 AND USE_YN = 'Y'`, + [postSn] + ) + + if (existing.rows.length === 0) { + throw new AuthError('게시글을 찾을 수 없습니다.', 404) + } + + // 본인 글만 수정 가능 + if (existing.rows[0].author_id !== requesterId) { + throw new AuthError('본인의 게시글만 수정할 수 있습니다.', 403) + } + + // ... 동적 SET 빌드 + UPDATE +} +``` + +#### 논리 삭제 + +```typescript +export async function deletePost(postSn: number, requesterId: string): Promise { + // 소유자 검증 (위와 동일) + await wingPool.query( + `UPDATE BOARD_POST SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE POST_SN = $1`, + [postSn] + ) +} +``` + +--- + +### Step 3: Router 구현 + +**파일**: `backend/src/board/boardRouter.ts` + +#### 카테고리별 동적 리소스 결정 + +```typescript +const CATEGORY_RESOURCE: Record = { + NOTICE: 'board:notice', + DATA: 'board:data', + QNA: 'board:qna', + MANUAL: 'board:manual', +} +``` + +#### 엔드포인트별 requirePermission 적용 + +```typescript +// 목록/상세: 부모 리소스 'board' READ +router.get('/', requireAuth, requirePermission('board', 'READ'), listHandler) +router.get('/:sn', requireAuth, requirePermission('board', 'READ'), getHandler) + +// 작성: 카테고리별 서브리소스 CREATE (핵심!) +router.post('/', requireAuth, async (req, res, next) => { + const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' + requirePermission(resource, 'CREATE')(req, res, next) +}, createHandler) + +// 수정/삭제: 부모 리소스 권한 + 서비스에서 소유자 검증 +router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), updateHandler) +router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), deleteHandler) +``` + +**카테고리별 작성 권한의 원리**: +- POST `/api/board` 요청 시 body에 `categoryCd`가 포함 +- 미들웨어에서 `CATEGORY_RESOURCE[categoryCd]`로 서브리소스 결정 +- `board:notice` CREATE 권한이 없는 사용자는 공지 작성 불가 +- `board:qna` CREATE 권한이 있으면 Q&A는 작성 가능 + +--- + +### Step 4: server.ts 등록 + +```typescript +import boardRouter from './board/boardRouter.js' + +// API 라우트 — 업무 +app.use('/api/board', boardRouter) +``` + +--- + +### Step 5: 프론트엔드 연동 + +#### API 서비스 + +**파일**: `frontend/src/tabs/board/services/boardApi.ts` + +```typescript +import { api } from '@common/services/api'; + +export interface BoardPostItem { + sn: number; + categoryCd: string; + title: string; + authorId: string; + authorName: string; + viewCnt: number; + pinnedYn: string; + regDtm: string; +} + +export interface BoardListResponse { + items: BoardPostItem[]; + totalCount: number; + page: number; + size: number; +} + +export async function fetchBoardPosts(params?: BoardListParams): Promise { + const response = await api.get('/board', { params }); + return response.data; +} + +export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn: number }> { + const response = await api.post<{ sn: number }>('/board', input); + return response.data; +} +``` + +#### 권한 기반 UI 분기 + +**파일**: `frontend/src/tabs/board/components/BoardListTable.tsx` + +```tsx +import { useAuthStore } from '@common/store/authStore'; + +const hasPermission = useAuthStore((s) => s.hasPermission); + +// 카테고리별 서브리소스 CREATE 권한 확인 +const canWrite = selectedCategory + ? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE') + : hasPermission('board', 'CREATE'); + +// 글쓰기 버튼 조건부 렌더링 +{canWrite && ( + +)} +``` + +--- + +### Step 6: 권한 시나리오 테스트 + +| 시나리오 | 역할 | 요청 | 결과 | +|---|---|---|---| +| ADMIN이 공지 작성 | ADMIN | POST `/api/board` `{categoryCd:"NOTICE"}` | 201 Created | +| USER가 공지 작성 | USER | POST `/api/board` `{categoryCd:"NOTICE"}` | 403 (board:notice CREATE 없음) | +| USER가 Q&A 작성 | USER | POST `/api/board` `{categoryCd:"QNA"}` | 201 (board:qna CREATE 있음) | +| VIEWER가 Q&A 작성 | VIEWER | POST `/api/board` `{categoryCd:"QNA"}` | 403 (board:qna CREATE 없음) | +| USER가 본인 글 수정 | USER | PUT `/api/board/11` (본인 글) | 200 | +| USER가 타인 글 수정 | USER | PUT `/api/board/1` (타인 글) | 403 (소유자 검증 실패) | +| ADMIN이 목록 조회 | ADMIN | GET `/api/board` | 200 (board READ 있음) | + +--- + +### 관련 파일 전체 목록 + +| 위치 | 파일 | 설명 | +|---|---|---| +| DB | `database/migration/006_board.sql` | DDL + 초기 데이터 | +| 백엔드 | `backend/src/board/boardService.ts` | CRUD 비즈니스 로직 | +| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission | +| 백엔드 | `backend/src/server.ts` | boardRouter 등록 | +| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 | +| 프론트 | `frontend/src/tabs/board/components/BoardListTable.tsx` | 목록 UI (API 연동) | diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md index a9d68eb..e9bfb70 100644 --- a/docs/MENU-TAB-GUIDE.md +++ b/docs/MENU-TAB-GUIDE.md @@ -22,18 +22,19 @@ Frontend: menuStore.ts → TopBar.tsx (탭 렌더링) | 순서 | 파일 | 작업 | 필수 | |------|------|------|------| -| 1 | `frontend/src/components/views/XxxView.tsx` | 뷰 컴포넌트 생성 | O | -| 2 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | -| 3 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | -| 4 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | -| 5 | 관리자 UI | 메뉴 관리에서 활성화 | O | +| 1 | `frontend/src/tabs/{탭명}/components/XxxView.tsx` | 뷰 컴포넌트 생성 | O | +| 2 | `frontend/src/tabs/{탭명}/index.ts` | re-export 생성 | O | +| 3 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O | +| 4 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O | +| 5 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O | +| 6 | 관리자 UI | 메뉴 관리에서 활성화 | O | ## Step 1: 뷰 컴포넌트 생성 -`frontend/src/components/views/` 에 새 뷰 컴포넌트를 생성합니다. +`frontend/src/tabs/{탭명}/components/` 에 새 뷰 컴포넌트를 생성합니다. ```tsx -// frontend/src/components/views/MonitoringView.tsx +// frontend/src/tabs/monitoring/components/MonitoringView.tsx export function MonitoringView() { return ( @@ -47,7 +48,14 @@ export function MonitoringView() { } ``` -기존 뷰 컴포넌트(`OilSpillView`, `WeatherView` 등)의 레이아웃 패턴을 참고하세요. +`index.ts`에서 re-export합니다: +```tsx +// frontend/src/tabs/monitoring/index.ts +export { MonitoringView } from './components/MonitoringView' +``` + +기존 탭(`@tabs/prediction`, `@tabs/weather` 등)의 레이아웃 패턴을 참고하세요. +공통 모듈은 `@common/` alias로 import합니다. ## Step 2: App.tsx 탭 등록 @@ -68,7 +76,7 @@ export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin' ### 2-2. 뷰 컴포넌트 import ```tsx -import { MonitoringView } from './components/views/MonitoringView' +import { MonitoringView } from '@tabs/monitoring' ``` ### 2-3. renderView switch에 case 추가 diff --git a/docs/README.md b/docs/README.md index f077b30..53f349e 100755 --- a/docs/README.md +++ b/docs/README.md @@ -34,12 +34,12 @@ claude | 영역 | 기술 | |------|------| | Frontend | React 19, Vite 7, TypeScript 5.9, Tailwind CSS 3 | -| Backend | Express 4, TypeScript, better-sqlite3 (레이어), pg (인증) | +| Backend | Express 4, TypeScript, PostgreSQL (pg) | | 상태 관리 | Zustand (클라이언트), TanStack Query (서버) | -| 지도 | Leaflet, OpenLayers | +| 지도 | Leaflet + react-leaflet | | 실시간 | Socket.IO | | 인증 | JWT (HttpOnly Cookie), Google OAuth | -| DB | PostgreSQL 16 + PostGIS (운영 DB 직접 연결), SQLite | +| DB | PostgreSQL 16 + PostGIS (wing 운영DB + wing_auth 인증DB) | | CI/CD | Gitea Actions | --- @@ -50,18 +50,21 @@ claude wing/ ├── frontend/ React 19 + Vite + TypeScript + Tailwind │ └── src/ -│ ├── App.tsx 메인 (탭 라우팅, 감사 로그 자동 기록) -│ ├── components/ UI 컴포넌트 -│ │ ├── auth/ 로그인 페이지 -│ │ ├── views/ 각 탭별 페이지 뷰 (11개) -│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel -│ │ ├── map/ 지도 관련 -│ │ └── ... analysis, board, incidents, weather 등 -│ ├── hooks/ 커스텀 훅 -│ ├── services/ API 서비스 (api, authApi, weatherApi 등) -│ ├── store/ Zustand 상태 (authStore, menuStore) -│ ├── types/ 타입 정의 -│ └── utils/ 유틸리티 +│ ├── App.tsx 메인 (탭 라우팅, 감사 로그) +│ ├── common/ 공통 모듈 (@common/ alias) +│ │ ├── components/ auth/, layer/, layout/, map/, ui/ +│ │ ├── hooks/ useLayers, useSubMenu +│ │ ├── services/ api.ts, authApi.ts, layerService.ts +│ │ ├── store/ authStore, menuStore (Zustand) +│ │ ├── types/ backtrack, boomLine, hns, navigation +│ │ └── utils/ coordinates, geo, sanitize +│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) +│ ├── prediction/ 확산 예측 (OilSpillView, LeftPanel 등) +│ ├── hns/ HNS 분석 (HNSView, HNSSubstanceView 등) +│ ├── rescue/ 구조 시나리오 +│ ├── aerial/ 항공 방제 +│ ├── weather/ 해양 기상 +│ └── ... incidents, board, reports, assets, scat, admin ├── backend/ Express + TypeScript │ └── src/ │ ├── server.ts 진입점 + 라우터 등록 @@ -71,10 +74,11 @@ wing/ │ ├── settings/ 시스템 설정 │ ├── menus/ 메뉴 설정 │ ├── audit/ 감사 로그 +│ ├── hns/ HNS 물질 검색 API │ ├── routes/ 레이어, 시뮬레이션 │ ├── middleware/ 보안 (입력 살균, rate-limit) -│ └── db/ DB 연결 (PostgreSQL, SQLite) -├── database/ SQL 초기화 스크립트 +│ └── db/ DB 연결 (wingDb, authDb), seed +├── database/ SQL 스크립트 + 마이그레이션 ├── docs/ 개발 문서 ├── .claude/ 팀 워크플로우 (rules, skills, scripts) └── .githooks/ Git hooks (pre-commit, commit-msg) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5e0d34..bc22c88 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,6 @@ "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", - "ol": "^10.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -1148,12 +1147,6 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", - "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", - "license": "MIT" - }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1650,12 +1643,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@types/rbush": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", - "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1966,16 +1953,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@zarrita/storage": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz", - "integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==", - "license": "MIT", - "dependencies": { - "reference-spec-reader": "^0.2.0", - "unzipit": "1.4.3" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2483,12 +2460,6 @@ "node": ">= 0.4" } }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2897,12 +2868,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3051,25 +3016,6 @@ "node": ">=6.9.0" } }, - "node_modules/geotiff": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz", - "integrity": "sha512-yRoDQDYxWYiB421p0cbxJvdy79OlQW+rxDI9GDbIUeWCAh6YAZ0vlTKF448EAiEuuUpBsNaegd2flavF0p+kvw==", - "license": "MIT", - "dependencies": { - "@petamoriken/float16": "^3.4.7", - "lerc": "^3.0.0", - "pako": "^2.0.4", - "parse-headers": "^2.0.2", - "quick-lru": "^6.1.1", - "web-worker": "^1.5.0", - "xml-utils": "^1.10.2", - "zstddec": "^0.2.0" - }, - "engines": { - "node": ">=10.19" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3410,12 +3356,6 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, - "node_modules/lerc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", - "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3633,15 +3573,6 @@ "node": ">=0.10.0" } }, - "node_modules/numcodecs": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", - "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", - "license": "MIT", - "dependencies": { - "fflate": "^0.8.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3662,24 +3593,6 @@ "node": ">= 6" } }, - "node_modules/ol": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/ol/-/ol-10.8.0.tgz", - "integrity": "sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/rbush": "4.0.0", - "earcut": "^3.0.0", - "geotiff": "^3.0.2", - "pbf": "4.0.1", - "rbush": "^4.0.0", - "zarrita": "^0.6.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/openlayers" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3730,12 +3643,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3749,12 +3656,6 @@ "node": ">=6" } }, - "node_modules/parse-headers": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", - "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3782,18 +3683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4007,12 +3896,6 @@ "node": ">= 0.8.0" } }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4050,33 +3933,6 @@ ], "license": "MIT" }, - "node_modules/quick-lru": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", - "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC" - }, - "node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "dependencies": { - "quickselect": "^3.0.0" - } - }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4158,12 +4014,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/reference-spec-reader": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", - "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4195,15 +4045,6 @@ "node": ">=4" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4598,18 +4439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unzipit": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", - "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", - "license": "MIT", - "dependencies": { - "uzip-module": "^1.0.2" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4658,12 +4487,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uzip-module": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", - "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", - "license": "MIT" - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4739,12 +4562,6 @@ } } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4792,12 +4609,6 @@ } } }, - "node_modules/xml-utils": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", - "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", - "license": "CC0-1.0" - }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -4826,16 +4637,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zarrita": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.6.1.tgz", - "integrity": "sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw==", - "license": "MIT", - "dependencies": { - "@zarrita/storage": "^0.1.4", - "numcodecs": "^0.3.2" - } - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -4859,12 +4660,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/zstddec": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", - "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", - "license": "MIT AND BSD-3-Clause" - }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index c659318..aa8625f 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,6 @@ "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", - "ol": "^10.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de541a9..7059d68 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,22 @@ import { useState, useEffect } from 'react' import { GoogleOAuthProvider } from '@react-oauth/google' -import { MainLayout } from './components/layout/MainLayout' -import { LoginPage } from './components/auth/LoginPage' -import { registerMainTabSwitcher } from './hooks/useSubMenu' -import { useAuthStore } from './store/authStore' -import { useMenuStore } from './store/menuStore' -import { OilSpillView } from './components/views/OilSpillView' -import { ReportsView } from './components/views/ReportsView' -import { HNSView } from './components/views/HNSView' -import { AerialView } from './components/views/AerialView' -import { AssetsView } from './components/views/AssetsView' -import { BoardView } from './components/views/BoardView' -import { WeatherView } from './components/views/WeatherView' -import { IncidentsView } from './components/views/IncidentsView' -import { AdminView } from './components/views/AdminView' -import { PreScatView } from './components/views/PreScatView' -import { RescueView } from './components/views/RescueView' - -export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' +import type { MainTab } from '@common/types/navigation' +import { MainLayout } from '@common/components/layout/MainLayout' +import { LoginPage } from '@common/components/auth/LoginPage' +import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' +import { useAuthStore } from '@common/store/authStore' +import { useMenuStore } from '@common/store/menuStore' +import { OilSpillView } from '@tabs/prediction' +import { ReportsView } from '@tabs/reports' +import { HNSView } from '@tabs/hns' +import { AerialView } from '@tabs/aerial' +import { AssetsView } from '@tabs/assets' +import { BoardView } from '@tabs/board' +import { WeatherView } from '@tabs/weather' +import { IncidentsView } from '@tabs/incidents' +import { AdminView } from '@tabs/admin' +import { PreScatView } from '@tabs/scat' +import { RescueView } from '@tabs/rescue' const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' @@ -47,7 +46,8 @@ function App() { [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], { type: 'text/plain' } ) - navigator.sendBeacon('/api/audit/log', blob) + const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' + navigator.sendBeacon(`${apiBase}/audit/log`, blob) }, [activeMainTab, isAuthenticated]) // 세션 확인 중 스플래시 diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/common/components/auth/LoginPage.tsx similarity index 100% rename from frontend/src/components/auth/LoginPage.tsx rename to frontend/src/common/components/auth/LoginPage.tsx diff --git a/frontend/src/components/layer/LayerTree.tsx b/frontend/src/common/components/layer/LayerTree.tsx similarity index 99% rename from frontend/src/components/layer/LayerTree.tsx rename to frontend/src/common/components/layer/LayerTree.tsx index df2a736..b4f2195 100755 --- a/frontend/src/components/layer/LayerTree.tsx +++ b/frontend/src/common/components/layer/LayerTree.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import type { Layer } from '../../data/layerDatabase' +import type { Layer } from '@common/services/layerService' const PRESET_COLORS = [ '#ef4444','#f97316','#eab308','#22c55e','#06b6d4', diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/common/components/layout/MainLayout.tsx similarity index 93% rename from frontend/src/components/layout/MainLayout.tsx rename to frontend/src/common/components/layout/MainLayout.tsx index 68f7c50..adddd5f 100755 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/common/components/layout/MainLayout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { TopBar } from './TopBar' import { SubMenuBar } from './SubMenuBar' diff --git a/frontend/src/components/layout/SubMenuBar.tsx b/frontend/src/common/components/layout/SubMenuBar.tsx similarity index 95% rename from frontend/src/components/layout/SubMenuBar.tsx rename to frontend/src/common/components/layout/SubMenuBar.tsx index 9abd458..98a39da 100755 --- a/frontend/src/components/layout/SubMenuBar.tsx +++ b/frontend/src/common/components/layout/SubMenuBar.tsx @@ -1,4 +1,4 @@ -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { useSubMenu } from '../../hooks/useSubMenu' interface SubMenuBarProps { diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx similarity index 99% rename from frontend/src/components/layout/TopBar.tsx rename to frontend/src/common/components/layout/TopBar.tsx index c5f4a8d..3133575 100755 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useMemo } from 'react' -import type { MainTab } from '../../App' +import type { MainTab } from '../../types/navigation' import { useAuthStore } from '../../store/authStore' import { useMenuStore } from '../../store/menuStore' diff --git a/frontend/src/components/map/BacktrackReplayBar.tsx b/frontend/src/common/components/map/BacktrackReplayBar.tsx similarity index 98% rename from frontend/src/components/map/BacktrackReplayBar.tsx rename to frontend/src/common/components/map/BacktrackReplayBar.tsx index ec46ddd..fdab7fe 100755 --- a/frontend/src/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/common/components/map/BacktrackReplayBar.tsx @@ -1,4 +1,4 @@ -import type { ReplayShip, CollisionEvent } from '../../types/backtrack' +import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' interface BacktrackReplayBarProps { isPlaying: boolean diff --git a/frontend/src/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx similarity index 99% rename from frontend/src/components/map/BacktrackReplayOverlay.tsx rename to frontend/src/common/components/map/BacktrackReplayOverlay.tsx index 503d088..c2a6c51 100755 --- a/frontend/src/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet' import L from 'leaflet' -import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '../../types/backtrack' +import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' interface BacktrackReplayOverlayProps { replayShips: ReplayShip[] diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx similarity index 98% rename from frontend/src/components/map/MapView.tsx rename to frontend/src/common/components/map/MapView.tsx index 0d81357..fce41a6 100755 --- a/frontend/src/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -2,11 +2,11 @@ import { useState, useMemo, useEffect } from 'react' import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet' import 'leaflet/dist/leaflet.css' import L from 'leaflet' -import { layerDatabase } from '../../data/layerDatabase' -import { decimalToDMS } from '../../utils/coordinates' -import type { PredictionModel } from '../views/OilSpillView' -import type { BoomLine, BoomLineCoord } from '../../types/boomLine' -import type { ReplayShip, CollisionEvent } from '../../types/backtrack' +import { layerDatabase } from '@common/services/layerService' +import { decimalToDMS } from '@common/utils/coordinates' +import type { PredictionModel } from '@tabs/prediction/components/OilSpillView' +import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' +import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' // Fix Leaflet default icon issue diff --git a/frontend/src/components/ui/ComboBox.tsx b/frontend/src/common/components/ui/ComboBox.tsx similarity index 100% rename from frontend/src/components/ui/ComboBox.tsx rename to frontend/src/common/components/ui/ComboBox.tsx diff --git a/frontend/src/common/constants/featureIds.ts b/frontend/src/common/constants/featureIds.ts new file mode 100644 index 0000000..9fee631 --- /dev/null +++ b/frontend/src/common/constants/featureIds.ts @@ -0,0 +1,72 @@ +/** + * FEATURE_ID 레지스트리 + * + * 서브탭 단위의 기능 식별자. AUTH_PERM.RSRC_CD 및 감사로그 ACTION_DTL과 동기화. + * 형식: '{메인탭}:{서브탭}' + */ +export const FEATURE_IDS = { + // prediction + 'prediction:analysis': '확산 분석', + 'prediction:list': '시뮬레이션 목록', + 'prediction:theory': '확산 이론', + 'prediction:boom-theory': '오일펜스 배치 이론', + + // hns + 'hns:analysis': 'HNS 분석', + 'hns:list': 'HNS 시뮬레이션 목록', + 'hns:scenario': 'HNS 시나리오', + 'hns:manual': 'HNS 매뉴얼', + 'hns:theory': 'HNS 이론', + 'hns:substance': 'HNS 물질정보', + + // rescue + 'rescue:rescue': '구난 메인', + 'rescue:list': '구난 목록', + 'rescue:scenario': '구난 시나리오', + 'rescue:theory': '구난 이론', + + // aerial + 'aerial:media': '영상 관리', + 'aerial:analysis': '유출 면적 분석', + 'aerial:realtime': '실시간 드론', + 'aerial:sensor': '센서 분석', + 'aerial:satellite': '위성 요청', + 'aerial:cctv': 'CCTV 모니터링', + 'aerial:theory': '항공탐색 이론', + + // reports + 'reports:report-list': '보고서 목록', + 'reports:template': '보고서 템플릿', + 'reports:generate': '보고서 생성', + + // board + 'board:all': '전체 게시판', + 'board:notice': '공지사항', + 'board:data': '자료실', + 'board:qna': '질의응답', + 'board:manual': '매뉴얼', + + // assets + 'assets:management': '자산 관리', + 'assets:upload': '자산 현행화', + 'assets:theory': '방제자원 이론', + 'assets:insurance': '선박 보험정보', + + // scat + 'scat:survey': 'SCAT 조사', + + // weather + 'weather:current': '현재 기상', + 'weather:forecast': '기상 예보', + + // incidents + 'incidents:list': '사고 목록', + + // admin + 'admin:users': '사용자 관리', + 'admin:permissions': '권한 매트릭스', + 'admin:menus': '메뉴 관리', + 'admin:settings': '시스템 설정', +} as const; + +export type FeatureId = keyof typeof FEATURE_IDS; diff --git a/frontend/src/data/layerData.ts b/frontend/src/common/data/layerData.ts similarity index 100% rename from frontend/src/data/layerData.ts rename to frontend/src/common/data/layerData.ts diff --git a/frontend/src/common/hooks/useFeatureTracking.ts b/frontend/src/common/hooks/useFeatureTracking.ts new file mode 100644 index 0000000..2aaf674 --- /dev/null +++ b/frontend/src/common/hooks/useFeatureTracking.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useAuthStore } from '@common/store/authStore'; +import { API_BASE_URL } from '@common/services/api'; + +/** + * 서브탭 진입 시 감사 로그를 기록하는 훅. + * App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다. + * + * N-depth 지원: 콜론 구분 경로 (예: 'aerial:media', 'admin:users', 'a:b:c:d') + * + * @param featureId - 콜론 구분 리소스 경로 + */ +export function useFeatureTracking(featureId: string) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated || !featureId) return; + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], + { type: 'text/plain' }, + ); + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob); + }, [featureId, isAuthenticated]); +} diff --git a/frontend/src/hooks/useLayers.ts b/frontend/src/common/hooks/useLayers.ts similarity index 93% rename from frontend/src/hooks/useLayers.ts rename to frontend/src/common/hooks/useLayers.ts index 6c0ceb7..62654a2 100755 --- a/frontend/src/hooks/useLayers.ts +++ b/frontend/src/common/hooks/useLayers.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api' -import type { Layer } from '../data/layerDatabase' +import type { Layer } from '@common/services/layerService' // 모든 레이어 조회 훅 export function useLayers() { diff --git a/frontend/src/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts similarity index 82% rename from frontend/src/hooks/useSubMenu.ts rename to frontend/src/common/hooks/useSubMenu.ts index ce3b04a..dafecae 100755 --- a/frontend/src/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react' -import type { MainTab } from '../App' +import type { MainTab } from '../types/navigation' +import { useAuthStore } from '@common/store/authStore' +import { API_BASE_URL } from '@common/services/api' interface SubMenuItem { id: string @@ -91,6 +93,8 @@ function subscribe(listener: () => void) { export function useSubMenu(mainTab: MainTab) { const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const hasPermission = useAuthStore((s) => s.hasPermission) useEffect(() => { const unsubscribe = subscribe(() => { @@ -103,10 +107,27 @@ export function useSubMenu(mainTab: MainTab) { setSubTab(mainTab, subTab) } + // 권한 기반 서브메뉴 필터링 + const rawConfig = subMenuConfigs[mainTab] + const filteredConfig = rawConfig?.filter(item => + hasPermission(`${mainTab}:${item.id}`) + ) ?? null + + // 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로) + useEffect(() => { + if (!isAuthenticated || !activeSubTab) return + const resourcePath = `${mainTab}:${activeSubTab}` + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })], + { type: 'text/plain' }, + ) + navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob) + }, [mainTab, activeSubTab, isAuthenticated]) + return { activeSubTab, setActiveSubTab, - subMenuConfig: subMenuConfigs[mainTab] + subMenuConfig: filteredConfig, } } diff --git a/frontend/src/data/backtrackMockData.ts b/frontend/src/common/mock/backtrackMockData.ts similarity index 98% rename from frontend/src/data/backtrackMockData.ts rename to frontend/src/common/mock/backtrackMockData.ts index b494e7e..668cd3f 100755 --- a/frontend/src/data/backtrackMockData.ts +++ b/frontend/src/common/mock/backtrackMockData.ts @@ -1,4 +1,4 @@ -import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '../types/backtrack' +import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack' export const MOCK_CONDITIONS: BacktrackConditions = { estimatedSpillTime: '02-10 06:30', diff --git a/frontend/src/data/vesselMockData.ts b/frontend/src/common/mock/vesselMockData.ts similarity index 100% rename from frontend/src/data/vesselMockData.ts rename to frontend/src/common/mock/vesselMockData.ts diff --git a/frontend/src/services/api.ts b/frontend/src/common/services/api.ts similarity index 97% rename from frontend/src/services/api.ts rename to frontend/src/common/services/api.ts index 904a372..6d99764 100755 --- a/frontend/src/services/api.ts +++ b/frontend/src/common/services/api.ts @@ -1,6 +1,6 @@ import axios from 'axios' -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' +export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' export const api = axios.create({ baseURL: API_BASE_URL, diff --git a/frontend/src/services/authApi.ts b/frontend/src/common/services/authApi.ts similarity index 92% rename from frontend/src/services/authApi.ts rename to frontend/src/common/services/authApi.ts index 5f58a77..9fccd40 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/common/services/authApi.ts @@ -7,7 +7,7 @@ export interface AuthUser { rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] - permissions: string[] + permissions: Record } interface LoginResponse { @@ -117,6 +117,7 @@ export interface RoleWithPermissions { permissions: Array<{ sn: number resourceCode: string + operationCode: string granted: boolean }> } @@ -126,9 +127,26 @@ export async function fetchRoles(): Promise { return response.data } +// 권한 트리 구조 API +export interface PermTreeNode { + code: string + parentCode: string | null + name: string + description: string | null + icon: string | null + level: number + sortOrder: number + children: PermTreeNode[] +} + +export async function fetchPermTree(): Promise { + const response = await api.get('/roles/perm-tree') + return response.data +} + export async function updatePermissionsApi( roleSn: number, - permissions: Array<{ resourceCode: string; granted: boolean }> + permissions: Array<{ resourceCode: string; operationCode: string; granted: boolean }> ): Promise { await api.put(`/roles/${roleSn}/permissions`, { permissions }) } diff --git a/frontend/src/data/layerDatabase.ts b/frontend/src/common/services/layerService.ts similarity index 96% rename from frontend/src/data/layerDatabase.ts rename to frontend/src/common/services/layerService.ts index 503dbd1..848c0e5 100755 --- a/frontend/src/data/layerDatabase.ts +++ b/frontend/src/common/services/layerService.ts @@ -1,5 +1,5 @@ // 레이어 데이터베이스 - API에서 가져옴 -import { fetchAllLayers } from '../services/api' +import { fetchAllLayers } from '@common/services/api' export interface Layer { id: string diff --git a/frontend/src/store/authStore.ts b/frontend/src/common/store/authStore.ts similarity index 90% rename from frontend/src/store/authStore.ts rename to frontend/src/common/store/authStore.ts index 299cdc6..f47a474 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/common/store/authStore.ts @@ -12,7 +12,7 @@ interface AuthState { googleLogin: (credential: string) => Promise logout: () => Promise checkSession: () => Promise - hasPermission: (resource: string) => boolean + hasPermission: (resource: string, operation?: string) => boolean clearError: () => void } @@ -70,10 +70,12 @@ export const useAuthStore = create((set, get) => ({ } }, - hasPermission: (resource: string) => { + hasPermission: (resource: string, operation?: string) => { const { user } = get() if (!user) return false - return user.permissions.includes(resource) + const ops = user.permissions[resource] + if (!ops) return false + return ops.includes(operation ?? 'READ') }, clearError: () => set({ error: null, pendingMessage: null }), diff --git a/frontend/src/store/menuStore.ts b/frontend/src/common/store/menuStore.ts similarity index 100% rename from frontend/src/store/menuStore.ts rename to frontend/src/common/store/menuStore.ts diff --git a/frontend/src/types/backtrack.ts b/frontend/src/common/types/backtrack.ts similarity index 100% rename from frontend/src/types/backtrack.ts rename to frontend/src/common/types/backtrack.ts diff --git a/frontend/src/types/boomLine.ts b/frontend/src/common/types/boomLine.ts similarity index 100% rename from frontend/src/types/boomLine.ts rename to frontend/src/common/types/boomLine.ts diff --git a/frontend/src/common/types/hns.ts b/frontend/src/common/types/hns.ts new file mode 100644 index 0000000..70348e0 --- /dev/null +++ b/frontend/src/common/types/hns.ts @@ -0,0 +1,67 @@ +/* HNS 물질 검색 데이터 타입 */ + +export interface HNSSearchSubstance { + id: number + abbreviation: string // 약자/제품명 (화물적부도 코드) + nameKr: string // 국문명 + nameEn: string // 영문명 + synonymsEn: string // 영문 동의어 + synonymsKr: string // 국문 동의어/용도 + unNumber: string // UN번호 + casNumber: string // CAS번호 + transportMethod: string // 운송방법 + sebc: string // SEBC 거동분류 + /* 물리·화학적 특성 */ + usage: string + state: string + color: string + odor: string + flashPoint: string + autoIgnition: string + boilingPoint: string + density: string // 비중 (물=1) + solubility: string + vaporPressure: string + vaporDensity: string // 증기밀도 (공기=1) + explosionRange: string // 폭발범위 + /* 위험등급·농도기준 */ + nfpa: { health: number; fire: number; reactivity: number; special: string } + hazardClass: string + ergNumber: string + idlh: string + aegl2: string + erpg2: string + /* 방제거리 */ + responseDistanceFire: string + responseDistanceSpillDay: string + responseDistanceSpillNight: string + marineResponse: string + /* PPE */ + ppeClose: string + ppeFar: string + /* MSDS 요약 */ + msds: { + hazard: string + firstAid: string + fireFighting: string + spillResponse: string + exposure: string + regulation: string + } + /* IBC CODE */ + ibcHazard: string + ibcShipType: string + ibcTankType: string + ibcDetection: string + ibcFireFighting: string + ibcMinRequirement: string + /* EmS */ + emsCode: string + emsFire: string + emsSpill: string + emsFirstAid: string + /* 화물적부도 코드 */ + cargoCodes: Array<{ code: string; name: string; company: string; source: string }> + /* 항구별 반입 */ + portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }> +} diff --git a/frontend/src/common/types/navigation.ts b/frontend/src/common/types/navigation.ts new file mode 100644 index 0000000..a3c3c57 --- /dev/null +++ b/frontend/src/common/types/navigation.ts @@ -0,0 +1 @@ +export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'; diff --git a/frontend/src/utils/coordinates.ts b/frontend/src/common/utils/coordinates.ts similarity index 100% rename from frontend/src/utils/coordinates.ts rename to frontend/src/common/utils/coordinates.ts diff --git a/frontend/src/utils/geo.ts b/frontend/src/common/utils/geo.ts similarity index 100% rename from frontend/src/utils/geo.ts rename to frontend/src/common/utils/geo.ts diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/common/utils/sanitize.ts similarity index 100% rename from frontend/src/utils/sanitize.ts rename to frontend/src/common/utils/sanitize.ts diff --git a/frontend/src/components/board/BoardListTable.tsx b/frontend/src/components/board/BoardListTable.tsx deleted file mode 100755 index f186bdd..0000000 --- a/frontend/src/components/board/BoardListTable.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useState } from 'react' - -interface BoardPost { - id: number - category: string - title: string - author: string - date: string - views: number - isNotice?: boolean -} - -interface BoardListTableProps { - onPostClick: (id: number) => void - onWriteClick: () => void -} - -const mockPosts: BoardPost[] = [ - { - id: 1, - category: '공지사항', - title: '시스템 업데이트 안내', - author: '관리자', - date: '2025-02-15', - views: 245, - isNotice: true - }, - { - id: 2, - category: '공지사항', - title: '2025년 방제 교육 일정 안내', - author: '관리자', - date: '2025-02-14', - views: 189, - isNotice: true - }, - { - id: 3, - category: '자료실', - title: '방제 매뉴얼 업데이트 (2025년 개정판)', - author: '김철수', - date: '2025-02-10', - views: 423 - }, - { - id: 4, - category: 'Q&A', - title: 'HNS 대기확산 분석 결과 해석 문의', - author: '이영희', - date: '2025-02-08', - views: 156 - }, - { - id: 5, - category: '자료실', - title: '2024년 유류오염사고 통계 자료', - author: '박민수', - date: '2025-02-05', - views: 312 - }, - { - id: 6, - category: 'Q&A', - title: '유출유 확산 예측 알고리즘 선택 기준', - author: '정수진', - date: '2025-02-03', - views: 267 - }, - { - id: 7, - category: '자료실', - title: '해양오염 방제 장비 운용 가이드', - author: '최동현', - date: '2025-01-28', - views: 534 - }, - { - id: 8, - category: 'Q&A', - title: 'SCAT 조사 방법 관련 질문', - author: '강지은', - date: '2025-01-25', - views: 198 - }, - { - id: 9, - category: '자료실', - title: 'HNS 물질 안전보건자료 (MSDS) 모음', - author: '윤성호', - date: '2025-01-20', - views: 645 - }, - { - id: 10, - category: 'Q&A', - title: '항공촬영 드론 운용 시 주의사항', - author: '송미래', - date: '2025-01-15', - views: 221 - } -] - -export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) { - const [searchTerm, setSearchTerm] = useState('') - const [selectedCategory, setSelectedCategory] = useState('전체') - - const categories = ['전체', '공지사항', '자료실', 'Q&A'] - - const filteredPosts = mockPosts.filter((post) => { - const matchesCategory = selectedCategory === '전체' || post.category === selectedCategory - const matchesSearch = - post.title.toLowerCase().includes(searchTerm.toLowerCase()) || - post.author.toLowerCase().includes(searchTerm.toLowerCase()) - return matchesCategory && matchesSearch - }) - - return ( -
- {/* Header with Search and Write Button */} -
-
- {/* Category Filters */} -
- {categories.map((category) => ( - - ))} -
-
- -
- {/* Search Input */} - setSearchTerm(e.target.value)} - className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64" - /> - - {/* Write Button */} - -
-
- - {/* Board List Table */} -
- - - - - - - - - - - - - {filteredPosts.map((post) => ( - onPostClick(post.id)} - className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors" - > - - - - - - - - ))} - -
번호분류제목작성자작성일조회수
- {post.isNotice ? ( - - 공지 - - ) : ( - post.id - )} - - - {post.category} - - - - {post.title} - - {post.author}{post.date}{post.views}
- - {filteredPosts.length === 0 && ( -
-

검색 결과가 없습니다.

-
- )} -
- - {/* Pagination */} -
- - - - - -
-
- ) -} diff --git a/frontend/src/components/layout/LeftPanel.tsx b/frontend/src/components/layout/LeftPanel.tsx deleted file mode 100755 index e9eb1c1..0000000 --- a/frontend/src/components/layout/LeftPanel.tsx +++ /dev/null @@ -1,1237 +0,0 @@ -import { useState, useMemo } from 'react' -import { LayerTree } from '../layer/LayerTree' -import { useLayerTree } from '../../hooks/useLayers' -import { layerData } from '../../data/layerData' -import type { LayerNode } from '../../data/layerData' -import type { Layer } from '../../data/layerDatabase' -import { decimalToDMS } from '../../utils/coordinates' -import { ComboBox } from '../ui/ComboBox' -import { ALL_MODELS } from '../views/OilSpillView' -import type { PredictionModel } from '../views/OilSpillView' -import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '../../types/boomLine' -import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '../../utils/geo' -import type { Analysis } from '../analysis/AnalysisListTable' - -interface LeftPanelProps { - selectedAnalysis?: Analysis | null - enabledLayers: Set - onToggleLayer: (layerId: string, enabled: boolean) => void - incidentCoord: { lon: number; lat: number } - onCoordChange: (coord: { lon: number; lat: number }) => void - onMapSelectClick: () => void - onRunSimulation: () => void - isRunningSimulation: boolean - selectedModels: Set - onModelsChange: (models: Set) => void - predictionTime: number - onPredictionTimeChange: (time: number) => void - spillType: string - onSpillTypeChange: (type: string) => void - oilType: string - onOilTypeChange: (type: string) => void - spillAmount: number - onSpillAmountChange: (amount: number) => void - // 오일펜스 배치 관련 - boomLines: BoomLine[] - onBoomLinesChange: (lines: BoomLine[]) => void - oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> - algorithmSettings: AlgorithmSettings - onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void - isDrawingBoom: boolean - onDrawingBoomChange: (drawing: boolean) => void - drawingPoints: BoomLineCoord[] - onDrawingPointsChange: (points: BoomLineCoord[]) => void - containmentResult: ContainmentResult | null - onContainmentResultChange: (result: ContainmentResult | null) => void - // 레이어 스타일 - layerOpacity: number - onLayerOpacityChange: (val: number) => void - layerBrightness: number - onLayerBrightnessChange: (val: number) => void -} - -export function LeftPanel({ - selectedAnalysis, - enabledLayers, - onToggleLayer, - incidentCoord, - onCoordChange, - onMapSelectClick, - onRunSimulation, - isRunningSimulation, - selectedModels, - onModelsChange, - predictionTime, - onPredictionTimeChange, - spillType, - onSpillTypeChange, - oilType, - onOilTypeChange, - spillAmount, - onSpillAmountChange, - boomLines, - onBoomLinesChange, - oilTrajectory, - algorithmSettings, - onAlgorithmSettingsChange, - isDrawingBoom, - onDrawingBoomChange, - drawingPoints, - onDrawingPointsChange, - containmentResult, - onContainmentResultChange, - layerOpacity, - onLayerOpacityChange, - layerBrightness, - onLayerBrightnessChange, -}: LeftPanelProps) { - const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct') - const [uploadedImage, setUploadedImage] = useState(null) - const [uploadedFileName, setUploadedFileName] = useState('') - const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation') - const [expandedSections, setExpandedSections] = useState({ - predictionInput: true, - incident: false, - impactResources: false, - infoLayer: true, - oilBoom: false, - }) - - // API에서 레이어 트리 데이터 가져오기 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { data: layerTree, isLoading, error } = useLayerTree() - - const [layerColors, setLayerColors] = useState>({}) - - // 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백) - const staticLayers = useMemo(() => { - const convert = (node: LayerNode): Layer => ({ - id: node.code, - parentId: node.parentCode, - name: node.name, - fullName: node.fullName, - level: node.level, - wmsLayer: node.layerName, - icon: node.icon, - count: node.count, - children: node.children?.map(convert), - }) - return layerData.map(convert) - }, []) - - // API 데이터 우선, 실패 시 정적 데이터 폴백 - const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers - - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections(prev => ({ - ...prev, - [section]: !prev[section] - })) - } - - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - setUploadedFileName(file.name) - const reader = new FileReader() - reader.onload = (event) => { - setUploadedImage(event.target?.result as string) - } - reader.readAsDataURL(file) - } - } - - const removeUploadedImage = () => { - setUploadedImage(null) - setUploadedFileName('') - } - - return ( -
- {/* Scrollable Content */} -
- {/* Prediction Input Section */} -
-
toggleSection('predictionInput')} - className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" - > -

- 예측정보 입력 -

- - {expandedSections.predictionInput ? '▼' : '▶'} - -
- - {expandedSections.predictionInput && ( -
- {/* Input Mode Selection */} -
- - -
- - {/* Direct Input Mode */} - {inputMode === 'direct' && ( - <> - - - - )} - - {/* Image Upload Mode */} - {inputMode === 'upload' && ( - <> - - {}} - options={[ - { value: '', label: '여수 유조선 충돌 (INC-0042)' }, - { value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' } - ]} - placeholder="사고 선택" - /> - - {/* Upload Success Message */} - {uploadedImage && ( -
- - 내 이미지가 업로드됨 -
- )} - - {/* File Upload Area */} - {!uploadedImage ? ( - - ) : ( -
- 📄 {uploadedFileName || 'example_plot_0.gif'} - -
- )} - - {/* Dropdowns */} -
- {}} - options={[ - { value: '', label: '유출회사' }, - { value: 'company1', label: '회사A' }, - { value: 'company2', label: '회사B' } - ]} - placeholder="유출회사" - /> - {}} - options={[ - { value: '', label: '예상시각' }, - { value: '09:00', label: '09:00' }, - { value: '12:00', label: '12:00' } - ]} - placeholder="예상시각" - /> -
- - )} - - {/* Coordinates + Map Button */} -
-
- { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value }) - }} - placeholder="위도°" - /> - { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value }) - }} - placeholder="경도°" - /> - -
- {/* 도분초 표시 */} - {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( -
- {decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)} -
- )} -
- - {/* Oil Type + Oil Kind */} -
- - -
- - {/* Volume + Unit + Duration */} -
- onSpillAmountChange(parseInt(e.target.value) || 0)} - /> - {}} - options={[ - { value: 'kL', label: 'kL' }, - { value: 'ton', label: 'Ton' }, - { value: 'barrel', label: '배럴' } - ]} - /> - onPredictionTimeChange(parseInt(v))} - options={[ - { value: '6', label: '6시간' }, - { value: '12', label: '12시간' }, - { value: '24', label: '24시간' }, - { value: '48', label: '48시간' }, - { value: '72', label: '72시간' } - ]} - /> -
- - {/* Image Analysis Note (Upload Mode Only) */} - {inputMode === 'upload' && uploadedImage && ( -
- 📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요. -
- )} - - {/* Divider */} -
- - {/* Model Selection (다중 선택) */} -
- {([ - { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, - { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, - { id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' }, - ] as const).map(m => ( -
{ - const next = new Set(selectedModels) - if (next.has(m.id)) { - next.delete(m.id) - } else { - next.add(m.id) - } - onModelsChange(next) - }} - style={{ cursor: 'pointer' }} - > - - {m.id} -
- ))} -
{ - if (selectedModels.size === ALL_MODELS.length) { - onModelsChange(new Set(['KOSPS'])) - } else { - onModelsChange(new Set(ALL_MODELS)) - } - }} - style={{ cursor: 'pointer' }} - > - - 앙상블 -
-
- - {/* Run Button */} - -
- )} -
- - {/* Incident Section */} -
-
toggleSection('incident')} - className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" - > -

- 사고정보 -

- - {expandedSections.incident ? '▼' : '▶'} - -
- - {expandedSections.incident && ( -
- {/* Status Badge */} -
- - 진행중 -
- - {/* Info Grid */} -
-
- 사고코드 - {selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'} -
-
- 사고명 - {selectedAnalysis?.name || '씨프린스호'} -
-
- 사고일시 - {selectedAnalysis?.occurredAt || '2025-02-10 06:30'} -
-
- 유종 - {selectedAnalysis?.oilType || 'BUNKER_C'} -
-
- 유출량 - {selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'} -
-
- 담당자 - {selectedAnalysis?.analyst || '남해청, 방재과'} -
-
- 위치 - {selectedAnalysis?.location || '여수 돌산 남방 5NM'} -
-
-
- )} -
- - {/* Impact Resources Section */} -
-
toggleSection('impactResources')} - className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" - > -

- 영향 민감자원 -

- - {expandedSections.impactResources ? '▼' : '▶'} - -
- - {expandedSections.impactResources && ( -
-

영향받는 민감자원 목록

-
- )} -
- - {/* Info Layer Section */} -
-
-

toggleSection('infoLayer')} - className="text-[13px] font-bold text-text-1 font-korean cursor-pointer" - > - 📂 정보 레이어 -

-
- - - toggleSection('infoLayer')} - className="text-[10px] text-text-3 cursor-pointer" - > - {expandedSections.infoLayer ? '▼' : '▶'} - -
-
- - {expandedSections.infoLayer && ( -
- {isLoading && effectiveLayers.length === 0 ? ( -

레이어 로딩 중...

- ) : effectiveLayers.length === 0 ? ( -

레이어 데이터가 없습니다.

- ) : ( - setLayerColors(prev => ({ ...prev, [id]: color }))} - /> - )} - - {/* 레이어 스타일 조절 */} -
-
레이어 스타일
-
- 투명도 - onLayerOpacityChange(Number(e.target.value))} - /> - {layerOpacity}% -
-
- 밝기 - onLayerBrightnessChange(Number(e.target.value))} - /> - {layerBrightness}% -
-
-
- )} -
- - {/* Oil Boom Placement Guide Section */} -
-
toggleSection('oilBoom')} - className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" - > -

- 🛡 오일펜스 배치 가이드 -

- - {expandedSections.oilBoom ? '▼' : '▶'} - -
- - {expandedSections.oilBoom && ( -
- - {/* Tab Buttons + Reset */} -
- {[ - { id: 'ai' as const, label: 'AI 자동 추천' }, - { id: 'manual' as const, label: '수동 배치' }, - { id: 'simulation' as const, label: '시뮬레이션' } - ].map(tab => ( - - ))} - -
- - {/* Key Metrics (동적) */} -
- {[ - { value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' }, - { value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' }, - { value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' } - ].map((metric, idx) => ( -
-
- {metric.value} -
-
- {metric.label} -
-
- ))} -
- - {/* ===== AI 자동 추천 탭 ===== */} - {boomPlacementTab === 'ai' && ( - <> -
-
- 0 ? 'var(--green)' : 'var(--t3)' }} /> - 0 ? 'var(--green)' : 'var(--t3)', fontFamily: 'var(--fK)' }}> - {oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'} - -
- -

- 확산 예측 기반 최적 배치안 -

- -

- {oilTrajectory.length > 0 - ? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.' - : '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.' - } -

- - -
- - {/* 알고리즘 설정 */} -
-

- 📊 배치 알고리즘 설정 -

-
- {[ - { label: '해류 직교 보정', key: 'currentOrthogonalCorrection' as const, unit: '°', value: algorithmSettings.currentOrthogonalCorrection }, - { label: '안전 마진 (도달시간)', key: 'safetyMarginMinutes' as const, unit: '분', value: algorithmSettings.safetyMarginMinutes }, - { label: '최소 차단 효율', key: 'minContainmentEfficiency' as const, unit: '%', value: algorithmSettings.minContainmentEfficiency }, - { label: '파고 보정 계수', key: 'waveHeightCorrectionFactor' as const, unit: 'x', value: algorithmSettings.waveHeightCorrectionFactor }, - ].map((setting) => ( -
- ● {setting.label} -
- { - const val = parseFloat(e.target.value) || 0 - onAlgorithmSettingsChange({ ...algorithmSettings, [setting.key]: val }) - }} - className="boom-setting-input" - step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1} - /> - {setting.unit} -
-
- ))} -
-
- - )} - - {/* ===== 수동 배치 탭 ===== */} - {boomPlacementTab === 'manual' && ( - <> - {/* 드로잉 컨트롤 */} -
- {!isDrawingBoom ? ( - - ) : ( - <> - - - - )} -
- - {/* 드로잉 실시간 정보 */} - {isDrawingBoom && drawingPoints.length > 0 && ( -
- 포인트: {drawingPoints.length} - 길이: {computePolylineLength(drawingPoints).toFixed(0)}m - {drawingPoints.length >= 2 && ( - 방위각: {computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}° - )} -
- )} - - {/* 배치된 라인 목록 */} - {boomLines.length === 0 ? ( -

- 배치된 오일펜스 라인이 없습니다. -

- ) : ( - boomLines.map((line, idx) => ( -
-
- { - const updated = [...boomLines] - updated[idx] = { ...updated[idx], name: e.target.value } - onBoomLinesChange(updated) - }} - style={{ - flex: 1, fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)', - background: 'transparent', border: 'none', color: 'var(--t1)', outline: 'none' - }} - /> - -
-
-
- 길이 -
{line.length.toFixed(0)}m
-
-
- 각도 -
{line.angle.toFixed(0)}°
-
-
- 우선순위 - -
-
-
- )) - )} - - )} - - {/* ===== 시뮬레이션 탭 ===== */} - {boomPlacementTab === 'simulation' && ( - <> - {/* 전제조건 체크 */} -
-
- 0 ? 'var(--green)' : 'var(--red)' }} /> - 0 ? 'var(--green)' : 'var(--t3)' }}> - 확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'} - -
-
- 0 ? 'var(--green)' : 'var(--red)' }} /> - 0 ? 'var(--green)' : 'var(--t3)' }}> - 오일펜스 라인 {boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'} - -
-
- - {/* 실행 버튼 */} - - - {/* 시뮬레이션 결과 */} - {containmentResult && containmentResult.totalParticles > 0 && ( -
- {/* 전체 효율 */} -
-
- {containmentResult.overallEfficiency}% -
-
- 전체 차단 효율 -
-
- - {/* 차단/통과 카운트 */} -
-
-
- {containmentResult.blockedParticles} -
-
차단 입자
-
-
-
- {containmentResult.passedParticles} -
-
통과 입자
-
-
- - {/* 효율 바 */} -
-
= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)' - }} /> -
- - {/* 라인별 분석 */} -
-

- 라인별 차단 분석 -

- {containmentResult.perLineResults.map((r) => ( -
- {r.boomLineName} - = 50 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fM)', marginLeft: '8px' }}> - {r.blocked}차단 / {r.efficiency}% - -
- ))} -
-
- )} - - )} - - {/* 배치된 방어선 카드 (AI/수동 공통 표시) */} - {boomPlacementTab !== 'simulation' && boomLines.length > 0 && boomPlacementTab === 'ai' && ( - <> - {boomLines.map((line, idx) => { - const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)' - const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통' - return ( -
-
- - 🛡 {idx + 1}차 방어선 ({line.type}) - - - {priorityLabel} - -
-
-
- 길이 -
- {line.length.toFixed(0)}m -
-
-
- 각도 -
- {line.angle.toFixed(0)}° -
-
-
-
- = 80 ? 'var(--green)' : 'var(--orange)' }} /> - = 80 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fK)' }}> - 차단 효율 {line.efficiency}% - -
-
- ) - })} - - )} - -
- )} -
-
-
- ) -} diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx deleted file mode 100755 index a0d7bc6..0000000 --- a/frontend/src/components/views/AdminView.tsx +++ /dev/null @@ -1,1306 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' -import data from '@emoji-mart/data' -import EmojiPicker from '@emoji-mart/react' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - type DragEndEvent, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { - fetchUsers, - fetchRoles, - updatePermissionsApi, - updateUserApi, - updateRoleDefaultApi, - approveUserApi, - rejectUserApi, - assignRolesApi, - createRoleApi, - updateRoleApi, - deleteRoleApi, - fetchRegistrationSettings, - updateRegistrationSettingsApi, - fetchOAuthSettings, - updateOAuthSettingsApi, - fetchMenuConfig, - updateMenuConfigApi, - type UserListItem, - type RoleWithPermissions, - type RegistrationSettings, - type OAuthSettings, - type MenuConfigItem, -} from '../../services/authApi' -import { useMenuStore } from '../../store/menuStore' - -const DEFAULT_ROLE_COLORS: Record = { - ADMIN: 'var(--red)', - MANAGER: 'var(--orange)', - USER: 'var(--cyan)', - VIEWER: 'var(--t3)', -} - -const CUSTOM_ROLE_COLORS = [ - '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', -] - -function getRoleColor(code: string, index: number): string { - return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] -} - -const statusLabels: Record = { - PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, - ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, - LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, - INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, - REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, -} - - -// ─── 사용자 관리 패널 ───────────────────────────────────────── -function UsersPanel() { - const [searchTerm, setSearchTerm] = useState('') - const [statusFilter, setStatusFilter] = useState('') - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) - const [allRoles, setAllRoles] = useState([]) - const [roleEditUserId, setRoleEditUserId] = useState(null) - const [selectedRoleSns, setSelectedRoleSns] = useState([]) - const roleDropdownRef = useRef(null) - - const loadUsers = useCallback(async () => { - setLoading(true) - try { - const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) - setUsers(data) - } catch (err) { - console.error('사용자 목록 조회 실패:', err) - } finally { - setLoading(false) - } - }, [searchTerm, statusFilter]) - - useEffect(() => { - loadUsers() - }, [loadUsers]) - - useEffect(() => { - fetchRoles().then(setAllRoles).catch(console.error) - }, []) - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { - setRoleEditUserId(null) - } - } - if (roleEditUserId) { - document.addEventListener('mousedown', handleClickOutside) - } - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [roleEditUserId]) - - const handleUnlock = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('계정 잠금 해제 실패:', err) - } - } - - const handleApprove = async (userId: string) => { - try { - await approveUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 승인 실패:', err) - } - } - - const handleReject = async (userId: string) => { - try { - await rejectUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 거절 실패:', err) - } - } - - const handleDeactivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'INACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 비활성화 실패:', err) - } - } - - const handleActivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 활성화 실패:', err) - } - } - - const handleOpenRoleEdit = (user: UserListItem) => { - setRoleEditUserId(user.id) - setSelectedRoleSns(user.roleSns || []) - } - - const toggleRoleSelection = (roleSn: number) => { - setSelectedRoleSns(prev => - prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn] - ) - } - - const handleSaveRoles = async (userId: string) => { - try { - await assignRolesApi(userId, selectedRoleSns) - await loadUsers() - setRoleEditUserId(null) - } catch (err) { - console.error('역할 할당 실패:', err) - } - } - - const formatDate = (dateStr: string | null) => { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleString('ko-KR', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }) - } - - const pendingCount = users.filter(u => u.status === 'PENDING').length - - return ( -
-
-
-
-

사용자 관리

-

총 {users.length}명

-
- {pendingCount > 0 && ( - - 승인대기 {pendingCount}명 - - )} -
-
- - setSearchTerm(e.target.value)} - className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> - -
-
- -
- {loading ? ( -
불러오는 중...
- ) : ( - - - - - - - - - - - - - - - {users.map((user) => { - const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE - return ( - - - - - - - - - - - ) - })} - -
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} -
-
handleOpenRoleEdit(user)} - title="클릭하여 역할 변경" - > - {user.roles.length > 0 ? user.roles.map((roleCode, idx) => { - const color = getRoleColor(roleCode, idx) - const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode - return ( - - {roleName} - - ) - }) : ( - 역할 없음 - )} - - - -
- {roleEditUserId === user.id && ( -
-
역할 선택
- {allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} -
- - -
-
- )} -
-
- {user.oauthProvider ? ( - - - Google - - ) : ( - - - ID/PW - - )} - - - - {statusInfo.label} - - {formatDate(user.lastLogin)} -
- {user.status === 'PENDING' && ( - <> - - - - )} - {user.status === 'LOCKED' && ( - - )} - {user.status === 'ACTIVE' && ( - - )} - {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( - - )} -
-
- )} -
-
- ) -} - -// ─── 권한 관리 패널 ───────────────────────────────────────── -const PERM_RESOURCES = [ - { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, - { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, - { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, - { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, - { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, - { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, - { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, - { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, - { id: 'board', label: '게시판', desc: '게시판 접근' }, - { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, - { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, -] - -function PermissionsPanel() { - const [roles, setRoles] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [dirty, setDirty] = useState(false) - const [showCreateForm, setShowCreateForm] = useState(false) - const [newRoleCode, setNewRoleCode] = useState('') - const [newRoleName, setNewRoleName] = useState('') - const [newRoleDesc, setNewRoleDesc] = useState('') - const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState('') - const [editingRoleSn, setEditingRoleSn] = useState(null) - const [editRoleName, setEditRoleName] = useState('') - - useEffect(() => { - loadRoles() - }, []) - - const loadRoles = async () => { - setLoading(true) - try { - const data = await fetchRoles() - setRoles(data) - setDirty(false) - } catch (err) { - console.error('역할 목록 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const getPermGranted = (roleSn: number, resourceCode: string): boolean => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return false - const perm = role.permissions.find(p => p.resourceCode === resourceCode) - return perm?.granted ?? false - } - - const togglePerm = (roleSn: number, resourceCode: string) => { - setRoles(prev => prev.map(role => { - if (role.sn !== roleSn) return role - const perms = role.permissions.map(p => - p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p - ) - if (!perms.find(p => p.resourceCode === resourceCode)) { - perms.push({ sn: 0, resourceCode, granted: true }) - } - return { ...role, permissions: perms } - })) - setDirty(true) - } - - const toggleDefault = async (roleSn: number) => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return - const newValue = !role.isDefault - try { - await updateRoleDefaultApi(roleSn, newValue) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, isDefault: newValue } : r - )) - } catch (err) { - console.error('기본 역할 변경 실패:', err) - } - } - - const handleSave = async () => { - setSaving(true) - try { - for (const role of roles) { - const permissions = PERM_RESOURCES.map(r => ({ - resourceCode: r.id, - granted: getPermGranted(role.sn, r.id), - })) - await updatePermissionsApi(role.sn, permissions) - } - setDirty(false) - } catch (err) { - console.error('권한 저장 실패:', err) - } finally { - setSaving(false) - } - } - - const handleCreateRole = async () => { - setCreating(true) - setCreateError('') - try { - await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined }) - await loadRoles() - setShowCreateForm(false) - setNewRoleCode('') - setNewRoleName('') - setNewRoleDesc('') - } catch (err) { - const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.' - setCreateError(message) - } finally { - setCreating(false) - } - } - - const handleDeleteRole = async (roleSn: number, roleName: string) => { - if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) { - return - } - try { - await deleteRoleApi(roleSn) - await loadRoles() - } catch (err) { - console.error('역할 삭제 실패:', err) - } - } - - const handleStartEditName = (role: RoleWithPermissions) => { - setEditingRoleSn(role.sn) - setEditRoleName(role.name) - } - - const handleSaveRoleName = async (roleSn: number) => { - if (!editRoleName.trim()) return - try { - await updateRoleApi(roleSn, { name: editRoleName.trim() }) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r - )) - setEditingRoleSn(null) - } catch (err) { - console.error('역할 이름 수정 실패:', err) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-
-

사용자 권한 관리

-

역할별 메뉴 접근 권한을 설정합니다

-
-
- - -
-
- -
- - - - - {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} - - - - {PERM_RESOURCES.map((perm) => ( - - - {roles.map(role => ( - - ))} - - ))} - -
기능 -
- {editingRoleSn === role.sn ? ( - setEditRoleName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveRoleName(role.sn) - if (e.key === 'Escape') setEditingRoleSn(null) - }} - onBlur={() => handleSaveRoleName(role.sn)} - autoFocus - className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" - /> - ) : ( - handleStartEditName(role)} - title="클릭하여 이름 수정" - > - {role.name} - - )} - {role.code !== 'ADMIN' && ( - - )} -
-
{role.code}
- -
-
{perm.label}
-
{perm.desc}
-
- -
-
- - {/* 역할 생성 모달 */} - {showCreateForm && ( -
-
-
-

새 역할 추가

-
-
-
- - setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} - placeholder="CUSTOM_ROLE" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> -

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

-
-
- - setNewRoleName(e.target.value)} - placeholder="사용자 정의 역할" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
-
- - setNewRoleDesc(e.target.value)} - placeholder="역할에 대한 설명" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
- {createError && ( -
- {createError} -
- )} -
-
- - -
-
-
- )} -
- ) -} - -// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── -interface SortableMenuItemProps { - menu: MenuConfigItem - idx: number - totalCount: number - isEditing: boolean - emojiPickerId: string | null - emojiPickerRef: React.RefObject - onToggle: (id: string) => void - onMove: (idx: number, direction: -1 | 1) => void - onEditStart: (id: string) => void - onEditEnd: () => void - onEmojiPickerToggle: (id: string | null) => void - onLabelChange: (id: string, value: string) => void - onEmojiSelect: (emoji: { native: string }) => void -} - -function SortableMenuItem({ - menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, - onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, -}: SortableMenuItemProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: menu.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - zIndex: isDragging ? 50 : undefined, - } - - return ( -
-
- - {idx + 1} - {isEditing ? ( - <> -
- - {emojiPickerId === menu.id && ( -
- -
- )} -
-
- onLabelChange(menu.id, e.target.value)} - className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" - /> -
{menu.id}
-
- - - ) : ( - <> - {menu.icon} -
-
- {menu.label} -
-
{menu.id}
-
- - - )} -
-
- -
- - -
-
-
- ) -} - -// ─── 메뉴 관리 패널 ───────────────────────────────────────── -function MenusPanel() { - const [menus, setMenus] = useState([]) - const [originalMenus, setOriginalMenus] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [editingId, setEditingId] = useState(null) - const [emojiPickerId, setEmojiPickerId] = useState(null) - const [activeId, setActiveId] = useState(null) - const emojiPickerRef = useRef(null) - const { setMenuConfig } = useMenuStore() - - const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ) - - const loadMenus = useCallback(async () => { - setLoading(true) - try { - const config = await fetchMenuConfig() - setMenus(config) - setOriginalMenus(config) - } catch (err) { - console.error('메뉴 설정 조회 실패:', err) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadMenus() - }, [loadMenus]) - - useEffect(() => { - if (!emojiPickerId) return - const handler = (e: MouseEvent) => { - if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { - setEmojiPickerId(null) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [emojiPickerId]) - - const toggleMenu = (id: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) - } - - const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) - } - - const handleEmojiSelect = (emoji: { native: string }) => { - if (emojiPickerId) { - updateMenuField(emojiPickerId, 'icon', emoji.native) - setEmojiPickerId(null) - } - } - - const moveMenu = (idx: number, direction: -1 | 1) => { - const targetIdx = idx + direction - if (targetIdx < 0 || targetIdx >= menus.length) return - setMenus(prev => { - const arr = [...prev] - ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] - return arr.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - setActiveId(null) - if (!over || active.id === over.id) return - setMenus(prev => { - const oldIndex = prev.findIndex(m => m.id === active.id) - const newIndex = prev.findIndex(m => m.id === over.id) - const reordered = arrayMove(prev, oldIndex, newIndex) - return reordered.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleSave = async () => { - setSaving(true) - try { - const updated = await updateMenuConfigApi(menus) - setMenus(updated) - setOriginalMenus(updated) - setMenuConfig(updated) - } catch (err) { - console.error('메뉴 설정 저장 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return ( -
-
메뉴 설정을 불러오는 중...
-
- ) - } - - const activeMenu = activeId ? menus.find(m => m.id === activeId) : null - - return ( -
-
-
-

메뉴 관리

-

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

-
- -
- -
- setActiveId(event.active.id as string)} - onDragEnd={handleDragEnd} - > - m.id)} strategy={verticalListSortingStrategy}> -
- {menus.map((menu, idx) => ( - { setEditingId(null); setEmojiPickerId(null) }} - onEmojiPickerToggle={setEmojiPickerId} - onLabelChange={(id, value) => updateMenuField(id, 'label', value)} - onEmojiSelect={handleEmojiSelect} - /> - ))} -
-
- - {activeMenu ? ( -
- - {activeMenu.icon} - {activeMenu.label} -
- ) : null} -
-
-
-
- ) -} - -// ─── 시스템 설정 패널 ──────────────────────────────────────── -function SettingsPanel() { - const [settings, setSettings] = useState(null) - const [oauthSettings, setOauthSettings] = useState(null) - const [oauthDomainInput, setOauthDomainInput] = useState('') - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [savingOAuth, setSavingOAuth] = useState(false) - - useEffect(() => { - loadSettings() - }, []) - - const loadSettings = async () => { - setLoading(true) - try { - const [regData, oauthData] = await Promise.all([ - fetchRegistrationSettings(), - fetchOAuthSettings(), - ]) - setSettings(regData) - setOauthSettings(oauthData) - setOauthDomainInput(oauthData.autoApproveDomains) - } catch (err) { - console.error('설정 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const handleToggle = async (key: keyof RegistrationSettings) => { - if (!settings) return - const newValue = !settings[key] - setSaving(true) - try { - const updated = await updateRegistrationSettingsApi({ [key]: newValue }) - setSettings(updated) - } catch (err) { - console.error('설정 변경 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-

시스템 설정

-

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

-
- -
-
- {/* 사용자 등록 설정 */} -
-
-

사용자 등록 설정

-

신규 사용자 등록 시 적용되는 정책을 설정합니다

-
- -
- {/* 자동 승인 */} -
-
-
자동 승인
-

- 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. - 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. -

-
- -
- - {/* 기본 역할 자동 할당 */} -
-
-
기본 역할 자동 할당
-

- 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. - 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. -

-
- -
-
-
- - {/* OAuth 설정 */} -
-
-

Google OAuth 설정

-

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

-
-
-
-
자동 승인 도메인
-

- 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. - 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. - 여러 도메인은 쉼표(,)로 구분합니다. -

-
- setOauthDomainInput(e.target.value)} - placeholder="gcsc.co.kr, example.com" - className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> - -
-
- {oauthSettings?.autoApproveDomains && ( -
- {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( - - @{domain} - - ))} -
- )} -
-
- - {/* 현재 설정 상태 요약 */} -
-
-

설정 상태 요약

-
-
-
-
- - - 신규 사용자 등록 시{' '} - {settings?.autoApprove ? ( - 즉시 활성화 - ) : ( - 관리자 승인 필요 - )} - -
-
- - - 기본 역할 자동 할당{' '} - {settings?.defaultRole ? ( - 활성 - ) : ( - 비활성 - )} - -
-
- - - Google OAuth 자동 승인 도메인{' '} - {oauthSettings?.autoApproveDomains ? ( - {oauthSettings.autoApproveDomains} - ) : ( - 미설정 - )} - -
-
-
-
-
-
-
- ) -} - -// ─── AdminView ──────────────────────────────────────────── -export function AdminView() { - const { activeSubTab } = useSubMenu('admin') - - return ( -
-
- {activeSubTab === 'users' && } - {activeSubTab === 'permissions' && } - {activeSubTab === 'menus' && } - {activeSubTab === 'settings' && } -
-
- ) -} diff --git a/frontend/src/components/views/AerialView.tsx b/frontend/src/components/views/AerialView.tsx deleted file mode 100755 index 249cb70..0000000 --- a/frontend/src/components/views/AerialView.tsx +++ /dev/null @@ -1,2526 +0,0 @@ -import { useState, useRef, useEffect } from 'react' -import { useSubMenu } from '../../hooks/useSubMenu' -import { AerialTheoryView } from '../analysis/AerialTheoryView' - -type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor' - -// ── Mock Data ── - -interface MediaFile { - id: number - incident: string - location: string - filename: string - equipment: string - equipType: 'drone' | 'plane' | 'satellite' - mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학' - datetime: string - size: string - resolution: string -} - -const mediaFiles: MediaFile[] = [ - { id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' }, - { id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' }, - { id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' }, - { id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' }, - { id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' }, - { id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' }, - { id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' }, - { id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' }, - { id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' }, - { id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' }, - { id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' }, - { id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' }, - { id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' }, - { id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' }, - { id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' }, -] - -const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰' - -const equipTagCls = (t: string) => - t === 'drone' - ? 'bg-[rgba(59,130,246,0.12)] text-primary-blue' - : t === 'plane' - ? 'bg-[rgba(34,197,94,0.12)] text-status-green' - : 'bg-[rgba(168,85,247,0.12)] text-primary-purple' - -const mediaTagCls = (t: string) => - t === '영상' - ? 'bg-[rgba(239,68,68,0.12)] text-status-red' - : 'bg-[rgba(234,179,8,0.12)] text-status-yellow' - -// ── Tab 0: 영상·사진 관리 ── - -const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( - -) - -function MediaManagementTab() { - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [equipFilter, setEquipFilter] = useState('all') - const [typeFilter, setTypeFilter] = useState>(new Set()) - const [searchTerm, setSearchTerm] = useState('') - const [sortBy, setSortBy] = useState('latest') - const [showUpload, setShowUpload] = useState(false) - const modalRef = useRef(null) - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - setShowUpload(false) - } - } - if (showUpload) document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [showUpload]) - - const filtered = mediaFiles.filter(f => { - if (equipFilter !== 'all' && f.equipType !== equipFilter) return false - if (typeFilter.size > 0) { - const isPhoto = !['영상'].includes(f.mediaType) - const isVideo = f.mediaType === '영상' - if (typeFilter.has('photo') && !isPhoto) return false - if (typeFilter.has('video') && !isVideo) return false - } - if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false - return true - }) - - const sorted = [...filtered].sort((a, b) => { - if (sortBy === 'name') return a.filename.localeCompare(b.filename) - if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size) - return b.datetime.localeCompare(a.datetime) - }) - - const toggleId = (id: number) => { - setSelectedIds(prev => { - const next = new Set(prev) - if (next.has(id)) { next.delete(id) } else { next.add(id) } - return next - }) - } - - const toggleAll = () => { - if (selectedIds.size === sorted.length) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(sorted.map(f => f.id))) - } - } - - const toggleTypeFilter = (t: string) => { - setTypeFilter(prev => { - const next = new Set(prev) - if (next.has(t)) { next.delete(t) } else { next.add(t) } - return next - }) - } - - const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length - const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length - const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length - - return ( -
- {/* Filters */} -
-
- 촬영 장비: - setEquipFilter('all')} /> - setEquipFilter('drone')} /> - setEquipFilter('plane')} /> - setEquipFilter('satellite')} /> - - 유형: - toggleTypeFilter('photo')} /> - toggleTypeFilter('video')} /> -
-
- setSearchTerm(e.target.value)} - className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan" - /> - -
-
- - {/* Summary Stats */} -
- {[ - { icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' }, - { icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' }, - { icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' }, - { icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' }, - { icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' }, - ].map((s, i) => ( -
- {s.icon} -
-
{s.value}
-
{s.label}
-
-
- ))} -
- - {/* File Table */} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map(f => ( - toggleId(f.id)} - className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ - selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : '' - }`} - > - - - - - - - - - - - - - ))} - -
- 0} - onChange={toggleAll} - className="accent-primary-blue" - /> - - 사고명위치파일명장비유형촬영일시용량해상도📥
e.stopPropagation()}> - toggleId(f.id)} - className="accent-primary-blue" - /> - {equipIcon(f.equipType)}{f.incident}{f.location}{f.filename} - - {f.equipment} - - - - {f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} - - {f.datetime}{f.size}{f.resolution} e.stopPropagation()}> - -
-
-
- - {/* Bottom Actions */} -
-
- 선택된 파일: {selectedIds.size}건 -
-
- - - -
-
- - {/* Upload Modal */} - {showUpload && ( -
-
-
- 📤 영상·사진 업로드 - -
-
-
📁
-
파일을 드래그하거나 클릭하여 업로드
-
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
-
-
- - -
-
- - -
-
- -