chore: 프로젝트 초기 구성
- frontend: React 19 + Vite 7 + Leaflet + Tailwind + Zustand - backend: Express + better-sqlite3 + TypeScript - database: PostgreSQL 초기화 스크립트 - .gitignore: 대용량 참고자료(scat, 참고용) 및 바이너리 파일 제외 - .env.example: API 키 템플릿 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
30
.claude/settings.local.json
Executable file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tailwindcss init:*)",
|
||||
"Bash(/Users/nankyunglee/Documents/wing/LayerList_New.csv:*)",
|
||||
"Bash(/Users/nankyunglee/Documents/wing/backend/src/utils/layerIcons.ts:*)",
|
||||
"Bash(/Users/nankyunglee/Documents/wing/transform_layers.js:*)",
|
||||
"Bash(__NEW_LINE_5781bf96384c4503__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
|
||||
"Bash(__NEW_LINE_2dfc1a230be907fa__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
|
||||
"Bash(npm run db:seed:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(npm start)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(curl:*)",
|
||||
"WebFetch(domain:www.khoa.go.kr)",
|
||||
"WebFetch(domain:opendrift.github.io)",
|
||||
"WebFetch(domain:www.windy.com)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(npx vite build:*)",
|
||||
"Bash(pdftotext:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
66
.gitignore
vendored
Executable file
@ -0,0 +1,66 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# Environment variables (use .env.example as template)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Database files
|
||||
backend/data/*.db
|
||||
backend/data/*.db-shm
|
||||
backend/data/*.db-wal
|
||||
|
||||
# Large reference data (keep locally, do not commit)
|
||||
_reference/
|
||||
scat/
|
||||
참고용/
|
||||
논문/
|
||||
|
||||
# Binary / archive files
|
||||
*.pdf
|
||||
*.xlsx
|
||||
*.xlsm
|
||||
*.hwpx
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.gif
|
||||
|
||||
# HTML mockup / report files
|
||||
방제과개선안_*.html
|
||||
meris_*.html
|
||||
|
||||
# Source archive (regenerate from repo)
|
||||
wing-frontend-src.zip
|
||||
wing_source_*.tar.gz
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# HNS manual images (large binary)
|
||||
frontend/public/hns-manual/pages/
|
||||
frontend/public/hns-manual/images/
|
||||
|
||||
# Claude Code (team workflow tracked, override global gitignore)
|
||||
!.claude/
|
||||
|
||||
# Lock files (keep for reproducible builds)
|
||||
!frontend/package-lock.json
|
||||
!backend/package-lock.json
|
||||
30
README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# WING-OPS
|
||||
|
||||
해양 방제 운영 지원 시스템
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
frontend/ React 19 + Vite 7 + TypeScript + Tailwind
|
||||
backend/ Express + better-sqlite3 + TypeScript
|
||||
database/ SQL 초기화 스크립트
|
||||
docs/ 설치 가이드, 문서
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# backend
|
||||
cd backend && npm install && npm run dev
|
||||
```
|
||||
|
||||
## 환경 설정
|
||||
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
cp backend/.env.example backend/.env
|
||||
# .env 파일에 API 키 입력
|
||||
```
|
||||
5
backend/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# Database
|
||||
DATABASE_URL="file:./data/wing.db"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
198
backend/API.md
Executable file
@ -0,0 +1,198 @@
|
||||
# Backend API 문서
|
||||
|
||||
## 시뮬레이션 API
|
||||
|
||||
### POST /api/simulation/run
|
||||
|
||||
오일 확산 시뮬레이션을 실행합니다.
|
||||
|
||||
#### 요청
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "OpenDrift", // "KOSPS" | "POSEIDON" | "OpenDrift" | "앙상블"
|
||||
"lat": 34.7312, // 사고 위도
|
||||
"lon": 127.6845, // 사고 경도
|
||||
"duration_hours": 48, // 예측 시간 (시간)
|
||||
"oil_type": "벙커C유", // 유종
|
||||
"spill_amount": 100, // 유출량 (kL)
|
||||
"spill_type": "연속" // 유출 형태
|
||||
}
|
||||
```
|
||||
|
||||
#### 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"model": "OpenDrift",
|
||||
"parameters": {
|
||||
"lat": 34.7312,
|
||||
"lon": 127.6845,
|
||||
"duration_hours": 48,
|
||||
"oil_type": "벙커C유",
|
||||
"spill_amount": 100,
|
||||
"spill_type": "연속"
|
||||
},
|
||||
"trajectory": [
|
||||
{
|
||||
"lat": 34.7312,
|
||||
"lon": 127.6845,
|
||||
"time": 0,
|
||||
"particle": 0
|
||||
},
|
||||
// ... more points
|
||||
],
|
||||
"metadata": {
|
||||
"particle_count": 20,
|
||||
"time_steps": 49,
|
||||
"generated_at": "2026-02-16T16:45:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 오류 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Missing required parameters",
|
||||
"required": ["model", "lat", "lon", "duration_hours"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/simulation/status/:jobId
|
||||
|
||||
시뮬레이션 작업 상태를 확인합니다 (향후 비동기 작업용).
|
||||
|
||||
#### 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "abc123",
|
||||
"status": "completed", // "pending" | "running" | "completed" | "failed"
|
||||
"progress": 100, // 0-100
|
||||
"message": "Simulation completed"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenDrift 통합 계획
|
||||
|
||||
### 1단계: 환경 설정
|
||||
|
||||
```bash
|
||||
# Python 가상 환경 생성
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
|
||||
# OpenDrift 설치
|
||||
pip install opendrift
|
||||
|
||||
# 필요한 기상/해양 데이터 라이브러리
|
||||
pip install netCDF4 xarray
|
||||
```
|
||||
|
||||
### 2단계: 데이터 소스 연동
|
||||
|
||||
OpenDrift는 다음 데이터 소스가 필요합니다:
|
||||
|
||||
- **바람 데이터**: WindSpeed, WindDirection
|
||||
- 소스: ERA5, ECMWF, NCEP 등
|
||||
- 형식: NetCDF (CF-compliant)
|
||||
|
||||
- **해류 데이터**: CurrentSpeed, CurrentDirection
|
||||
- 소스: CMEMS, HYCOM, RTOFS 등
|
||||
- 형식: NetCDF
|
||||
|
||||
- **파고 데이터**: WaveHeight (선택)
|
||||
- 소스: WaveWatch III
|
||||
|
||||
### 3단계: TypeScript에서 Python 호출
|
||||
|
||||
```typescript
|
||||
// backend/src/routes/simulation.ts
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
function runOpenDriftPython(params: SimulationRequest): Promise<ParticlePoint[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const python = spawn('python3', [
|
||||
'./src/utils/opendrift_runner.py',
|
||||
JSON.stringify(params)
|
||||
])
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
console.error('OpenDrift stderr:', data.toString())
|
||||
})
|
||||
|
||||
python.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
const result = JSON.parse(stdout)
|
||||
resolve(result.trajectory)
|
||||
} else {
|
||||
reject(new Error(`OpenDrift failed with code ${code}: ${stderr}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4단계: 비동기 작업 큐 (선택)
|
||||
|
||||
장시간 시뮬레이션의 경우 작업 큐 사용:
|
||||
|
||||
```bash
|
||||
npm install bull redis
|
||||
```
|
||||
|
||||
```typescript
|
||||
import Queue from 'bull'
|
||||
|
||||
const simulationQueue = new Queue('simulation', {
|
||||
redis: { host: 'localhost', port: 6379 }
|
||||
})
|
||||
|
||||
simulationQueue.process(async (job) => {
|
||||
const result = await runOpenDriftPython(job.data)
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 레이어 API
|
||||
|
||||
### GET /api/layers/tree/all
|
||||
|
||||
전체 레이어 트리 구조를 반환합니다.
|
||||
|
||||
#### 응답
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "해도",
|
||||
"name": "해도",
|
||||
"type": "group",
|
||||
"children": [
|
||||
{
|
||||
"id": "기본해도",
|
||||
"name": "기본해도",
|
||||
"type": "layer",
|
||||
"wmsLayer": "khoa:base_chart"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
2072
backend/package-lock.json
generated
Executable file
27
backend/package.json
Executable file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/helmet": "^0.0.48",
|
||||
"@types/node": "^22.13.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
14
backend/prisma.config.ts
Executable file
@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
14
backend/prisma/schema.prisma
Executable file
@ -0,0 +1,14 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
30
backend/src/db/database.ts
Executable file
@ -0,0 +1,30 @@
|
||||
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
|
||||
91
backend/src/db/seed.ts
Executable file
@ -0,0 +1,91 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import db, { initDatabase } from './database.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
async function seedDatabase() {
|
||||
console.log('데이터베이스 초기화 중...')
|
||||
initDatabase()
|
||||
|
||||
// 기존 데이터 삭제
|
||||
db.exec('DELETE FROM layers')
|
||||
|
||||
// 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 (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const insertMany = db.transaction((rows: any[]) => {
|
||||
for (const row of rows) {
|
||||
insert.run(row)
|
||||
}
|
||||
})
|
||||
|
||||
const rows = []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
// CSV 파싱 (쉼표로 구분, 따옴표 처리)
|
||||
const values = []
|
||||
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())
|
||||
|
||||
// NULL 값 처리
|
||||
const row = values.map(v => {
|
||||
if (v === 'NULL' || v === '') return null
|
||||
return v.replace(/"/g, '')
|
||||
})
|
||||
|
||||
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
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
166
backend/src/middleware/security.ts
Executable file
@ -0,0 +1,166 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
|
||||
/**
|
||||
* 입력값 살균(sanitize) 유틸리티
|
||||
* SQL 인젝션 및 XSS 공격에 사용되는 위험 문자를 제거/이스케이프
|
||||
*/
|
||||
|
||||
// HTML 특수문자 이스케이프 (XSS 방지)
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// SQL 인젝션에 사용되는 위험 패턴 탐지
|
||||
const SQL_INJECTION_PATTERNS = [
|
||||
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE|UNION|TRUNCATE|GRANT|REVOKE)\b)/i,
|
||||
/(--|#|\/\*|\*\/)/, // SQL 주석
|
||||
/(\b(OR|AND)\b\s+\d+\s*=\s*\d+)/i, // OR 1=1, AND 1=1
|
||||
/(;[\s]*$)/, // SQL 종료자
|
||||
/(\bxp_\w+)/i, // SQL Server xp_ 프로시저
|
||||
/(\bsp_\w+)/i, // SQL Server sp_ 프로시저
|
||||
]
|
||||
|
||||
// XSS 공격에 사용되는 위험 패턴 탐지
|
||||
const XSS_PATTERNS = [
|
||||
/<script\b[^>]*>/i,
|
||||
/javascript\s*:/i,
|
||||
/on\w+\s*=/i, // onclick=, onerror= 등
|
||||
/eval\s*\(/i,
|
||||
/expression\s*\(/i,
|
||||
/vbscript\s*:/i,
|
||||
/data\s*:\s*text\/html/i,
|
||||
]
|
||||
|
||||
// 문자열에서 위험 패턴 검사
|
||||
export function containsSqlInjection(input: string): boolean {
|
||||
return SQL_INJECTION_PATTERNS.some(pattern => pattern.test(input))
|
||||
}
|
||||
|
||||
export function containsXss(input: string): boolean {
|
||||
return XSS_PATTERNS.some(pattern => pattern.test(input))
|
||||
}
|
||||
|
||||
// 문자열 살균: 위험 HTML 태그와 특수문자 제거
|
||||
export function sanitizeString(input: string): string {
|
||||
return input
|
||||
.replace(/<[^>]*>/g, '') // HTML 태그 제거
|
||||
.replace(/[<>"'`;]/g, '') // 위험 특수문자 제거
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 숫자 검증
|
||||
export function isValidNumber(value: unknown, min?: number, max?: number): value is number {
|
||||
if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) return false
|
||||
if (min !== undefined && value < min) return false
|
||||
if (max !== undefined && value > max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 위도 검증 (-90 ~ 90)
|
||||
export function isValidLatitude(lat: unknown): lat is number {
|
||||
return isValidNumber(lat, -90, 90)
|
||||
}
|
||||
|
||||
// 경도 검증 (-180 ~ 180)
|
||||
export function isValidLongitude(lon: unknown): lon is number {
|
||||
return isValidNumber(lon, -180, 180)
|
||||
}
|
||||
|
||||
// 허용된 값 목록 검증 (화이트리스트)
|
||||
export function isAllowedValue<T>(value: T, allowedValues: T[]): boolean {
|
||||
return allowedValues.includes(value)
|
||||
}
|
||||
|
||||
// 문자열 길이 검증
|
||||
export function isValidStringLength(str: string, maxLength: number): boolean {
|
||||
return typeof str === 'string' && str.length <= maxLength
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 본문(body) 살균 미들웨어
|
||||
* 모든 문자열 필드에서 XSS/SQL 인젝션 패턴을 검사
|
||||
*/
|
||||
export function sanitizeBody(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
if (typeof value === 'string') {
|
||||
// XSS 패턴 검사
|
||||
if (containsXss(value)) {
|
||||
res.status(400).json({
|
||||
error: '유효하지 않은 입력값',
|
||||
field: key,
|
||||
message: '허용되지 않는 문자가 포함되어 있습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
// SQL 인젝션 패턴 검사
|
||||
if (containsSqlInjection(value)) {
|
||||
res.status(400).json({
|
||||
error: '유효하지 않은 입력값',
|
||||
field: key,
|
||||
message: '허용되지 않는 문자가 포함되어 있습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 파라미터(params) 살균 미들웨어
|
||||
* URL 파라미터에서 위험 문자 검사
|
||||
*/
|
||||
export function sanitizeParams(req: Request, res: Response, next: NextFunction): void {
|
||||
for (const [key, value] of Object.entries(req.params)) {
|
||||
if (typeof value === 'string') {
|
||||
if (containsXss(value) || containsSqlInjection(value)) {
|
||||
res.status(400).json({
|
||||
error: '유효하지 않은 파라미터',
|
||||
field: key,
|
||||
message: '허용되지 않는 문자가 포함되어 있습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 쿼리(query) 살균 미들웨어
|
||||
*/
|
||||
export function sanitizeQuery(req: Request, res: Response, next: NextFunction): void {
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string') {
|
||||
if (containsXss(value) || containsSqlInjection(value)) {
|
||||
res.status(400).json({
|
||||
error: '유효하지 않은 쿼리 파라미터',
|
||||
field: key,
|
||||
message: '허용되지 않는 문자가 포함되어 있습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 본문 크기 제한 (기본 100kb)
|
||||
*/
|
||||
export const BODY_SIZE_LIMIT = '100kb'
|
||||
|
||||
/**
|
||||
* 응답 헤더에서 서버 정보 제거
|
||||
*/
|
||||
export function removeServerInfo(req: Request, res: Response, next: NextFunction): void {
|
||||
res.removeHeader('X-Powered-By')
|
||||
next()
|
||||
}
|
||||
146
backend/src/routes/layers.ts
Executable file
@ -0,0 +1,146 @@
|
||||
import express from 'express'
|
||||
import db from '../db/database.js'
|
||||
import { enrichLayerWithMetadata } from '../utils/layerIcons.js'
|
||||
import {
|
||||
sanitizeParams,
|
||||
sanitizeString,
|
||||
isValidNumber,
|
||||
isValidStringLength,
|
||||
} from '../middleware/security.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
interface Layer {
|
||||
cmn_cd: string
|
||||
up_cmn_cd: string | null
|
||||
cmn_cd_full_nm: string
|
||||
cmn_cd_nm: string
|
||||
cmn_cd_level: number
|
||||
clnm: string | null
|
||||
}
|
||||
|
||||
// 모든 라우트에 파라미터 살균 적용
|
||||
router.use(sanitizeParams)
|
||||
|
||||
// 모든 레이어 조회
|
||||
router.get('/', (_req, res) => {
|
||||
try {
|
||||
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 계층 구조로 변환된 레이어 트리 조회
|
||||
router.get('/tree/all', (_req, res) => {
|
||||
try {
|
||||
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
|
||||
const enrichedLayers = layers.map(enrichLayerWithMetadata)
|
||||
|
||||
const layerMap = new Map<string, any>()
|
||||
enrichedLayers.forEach(layer => {
|
||||
layerMap.set(layer.cmn_cd, { ...layer, children: [] })
|
||||
})
|
||||
|
||||
const rootLayers: any[] = []
|
||||
enrichedLayers.forEach(layer => {
|
||||
const layerNode = layerMap.get(layer.cmn_cd)!
|
||||
if (layer.up_cmn_cd === null) {
|
||||
rootLayers.push(layerNode)
|
||||
} else {
|
||||
const parent = layerMap.get(layer.up_cmn_cd)
|
||||
if (parent) {
|
||||
parent.children.push(layerNode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
res.json(rootLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 트리 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// WMS 레이어만 조회
|
||||
router.get('/wms/all', (_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)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: 'WMS 레이어 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 특정 레벨의 레이어만 조회
|
||||
router.get('/level/:level', (req, res) => {
|
||||
try {
|
||||
const level = parseInt(req.params.level, 10)
|
||||
|
||||
// 입력 검증: 레벨은 1~10 범위의 정수
|
||||
if (!isValidNumber(level, 1, 10)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 레벨값',
|
||||
message: '레벨은 1~10 범위의 정수여야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
// 파라미터화된 쿼리 사용 (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)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레벨별 레이어 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 특정 부모의 자식 레이어 조회
|
||||
router.get('/children/:parentId', (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',
|
||||
message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.'
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
res.json(enrichedLayers)
|
||||
} catch {
|
||||
res.status(500).json({ error: '자식 레이어 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 특정 레이어 조회
|
||||
router.get('/:id', (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',
|
||||
message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const sanitizedId = sanitizeString(id)
|
||||
const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined
|
||||
if (!layer) {
|
||||
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||
}
|
||||
const enrichedLayer = enrichLayerWithMetadata(layer)
|
||||
res.json(enrichedLayer)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
227
backend/src/routes/simulation.ts
Executable file
@ -0,0 +1,227 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import {
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
isValidNumber,
|
||||
isAllowedValue,
|
||||
isValidStringLength,
|
||||
escapeHtml,
|
||||
} from '../middleware/security.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 허용된 모델 목록 (화이트리스트)
|
||||
const ALLOWED_MODELS = ['KOSPS', 'POSEIDON', 'OpenDrift', '앙상블'] as const
|
||||
type AllowedModel = typeof ALLOWED_MODELS[number]
|
||||
|
||||
// 허용된 유종 목록
|
||||
const ALLOWED_OIL_TYPES = ['원유', '벙커C유', '경유', '휘발유', '등유', '윤활유', '기타'] as const
|
||||
|
||||
// 허용된 유출 유형 목록
|
||||
const ALLOWED_SPILL_TYPES = ['연속유출', '순간유출'] as const
|
||||
|
||||
interface ParticlePoint {
|
||||
lat: number
|
||||
lon: number
|
||||
time: number
|
||||
particle: number
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/simulation/run
|
||||
* 오일 확산 시뮬레이션 실행
|
||||
*
|
||||
* 보안 조치:
|
||||
* - 화이트리스트 기반 모델명 검증
|
||||
* - 좌표 범위 검증 (위도 -90~90, 경도 -180~180)
|
||||
* - 숫자 범위 검증 (duration, spill_amount)
|
||||
* - 문자열 길이 제한
|
||||
*/
|
||||
router.post('/run', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { model, lat, lon, duration_hours, oil_type, spill_amount, spill_type } = req.body
|
||||
|
||||
// 1. 필수 파라미터 존재 검증
|
||||
if (model === undefined || lat === undefined || lon === undefined || duration_hours === undefined) {
|
||||
return res.status(400).json({
|
||||
error: '필수 파라미터 누락',
|
||||
required: ['model', 'lat', 'lon', 'duration_hours']
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 모델명 화이트리스트 검증
|
||||
if (!isAllowedValue(model, [...ALLOWED_MODELS])) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 모델',
|
||||
message: `허용된 모델: ${ALLOWED_MODELS.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 위도/경도 범위 검증
|
||||
if (!isValidLatitude(lat)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 위도',
|
||||
message: '위도는 -90 ~ 90 범위의 숫자여야 합니다.'
|
||||
})
|
||||
}
|
||||
if (!isValidLongitude(lon)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 경도',
|
||||
message: '경도는 -180 ~ 180 범위의 숫자여야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 예측 시간 범위 검증 (1~720시간 = 최대 30일)
|
||||
if (!isValidNumber(duration_hours, 1, 720)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 예측 시간',
|
||||
message: '예측 시간은 1~720 범위의 숫자여야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 선택적 파라미터 검증
|
||||
if (oil_type !== undefined) {
|
||||
if (typeof oil_type !== 'string' || !isValidStringLength(oil_type, 50)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유종' })
|
||||
}
|
||||
}
|
||||
|
||||
if (spill_amount !== undefined) {
|
||||
if (!isValidNumber(spill_amount, 0, 1000000)) {
|
||||
return res.status(400).json({
|
||||
error: '유효하지 않은 유출량',
|
||||
message: '유출량은 0~1,000,000 범위의 숫자여야 합니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (spill_type !== undefined) {
|
||||
if (typeof spill_type !== 'string' || !isValidStringLength(spill_type, 50)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 유출 유형' })
|
||||
}
|
||||
}
|
||||
|
||||
// 검증 완료 - 시뮬레이션 실행
|
||||
const trajectory = generateDemoTrajectory(
|
||||
lat,
|
||||
lon,
|
||||
duration_hours,
|
||||
model,
|
||||
20
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
model: escapeHtml(String(model)),
|
||||
parameters: {
|
||||
lat,
|
||||
lon,
|
||||
duration_hours,
|
||||
oil_type: oil_type ? escapeHtml(String(oil_type)) : undefined,
|
||||
spill_amount,
|
||||
spill_type: spill_type ? escapeHtml(String(spill_type)) : undefined,
|
||||
},
|
||||
trajectory,
|
||||
metadata: {
|
||||
particle_count: 20,
|
||||
time_steps: duration_hours + 1,
|
||||
generated_at: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 내부 오류 메시지 노출 방지
|
||||
res.status(500).json({
|
||||
error: '시뮬레이션 실행 실패',
|
||||
message: '서버 내부 오류가 발생했습니다.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 데모 궤적 데이터 생성
|
||||
*/
|
||||
function generateDemoTrajectory(
|
||||
startLat: number,
|
||||
startLon: number,
|
||||
hours: number,
|
||||
model: string,
|
||||
particleCount: number
|
||||
): ParticlePoint[] {
|
||||
const trajectory: ParticlePoint[] = []
|
||||
|
||||
const modelFactors: Record<string, number> = {
|
||||
'KOSPS': 0.004,
|
||||
'POSEIDON': 0.006,
|
||||
'OpenDrift': 0.005,
|
||||
'앙상블': 0.0055
|
||||
}
|
||||
const spreadFactor = modelFactors[model] || 0.005
|
||||
|
||||
const windSpeed = 5.5
|
||||
const windDirection = 135
|
||||
const currentSpeed = 0.55
|
||||
const currentDirection = 120
|
||||
const waveHeight = 2.2
|
||||
|
||||
const windRadians = (windDirection * Math.PI) / 180
|
||||
const currentRadians = (currentDirection * Math.PI) / 180
|
||||
|
||||
const windWeight = 0.03
|
||||
const currentWeight = 0.07
|
||||
|
||||
const mainDriftLat =
|
||||
Math.sin(windRadians) * windSpeed * windWeight +
|
||||
Math.sin(currentRadians) * currentSpeed * currentWeight
|
||||
|
||||
const mainDriftLon =
|
||||
Math.cos(windRadians) * windSpeed * windWeight +
|
||||
Math.cos(currentRadians) * currentSpeed * currentWeight
|
||||
|
||||
const dispersal = waveHeight * 0.001
|
||||
|
||||
for (let p = 0; p < particleCount; p++) {
|
||||
const initialSpread = 0.001
|
||||
const randomAngle = Math.random() * Math.PI * 2
|
||||
let particleLat = startLat + Math.sin(randomAngle) * initialSpread * Math.random()
|
||||
let particleLon = startLon + Math.cos(randomAngle) * initialSpread * Math.random()
|
||||
|
||||
for (let h = 0; h <= hours; h++) {
|
||||
const mainMovementLat = mainDriftLat * h * 0.01
|
||||
const mainMovementLon = mainDriftLon * h * 0.01
|
||||
|
||||
const turbulence = Math.sin(h * 0.3 + p * 0.5) * dispersal * h
|
||||
const turbulenceAngle = (h * 0.2 + p * 0.7) * Math.PI
|
||||
|
||||
trajectory.push({
|
||||
lat: particleLat + mainMovementLat + Math.sin(turbulenceAngle) * turbulence,
|
||||
lon: particleLon + mainMovementLon + Math.cos(turbulenceAngle) * turbulence,
|
||||
time: h,
|
||||
particle: p
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return trajectory
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/simulation/status/:jobId
|
||||
* 시뮬레이션 작업 상태 확인
|
||||
*/
|
||||
router.get('/status/:jobId', async (req: Request, res: Response) => {
|
||||
const { jobId } = req.params
|
||||
|
||||
// jobId 형식 검증 (영숫자, 하이픈만 허용)
|
||||
if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) {
|
||||
return res.status(400).json({ error: '유효하지 않은 작업 ID' })
|
||||
}
|
||||
|
||||
res.json({
|
||||
jobId: escapeHtml(jobId),
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
message: 'Simulation completed'
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
157
backend/src/server.ts
Executable file
@ -0,0 +1,157 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { initDatabase } from './db/database.js'
|
||||
import layersRouter from './routes/layers.js'
|
||||
import simulationRouter from './routes/simulation.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
removeServerInfo,
|
||||
BODY_SIZE_LIMIT
|
||||
} from './middleware/security.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
// ============================================================
|
||||
// 보안 미들웨어
|
||||
// ============================================================
|
||||
|
||||
// 1. Helmet: HTTP 보안 헤더 설정
|
||||
// - X-Content-Type-Options: nosniff (MIME 스니핑 방지)
|
||||
// - X-Frame-Options: DENY (클릭재킹 방지)
|
||||
// - X-XSS-Protection: 1 (브라우저 XSS 필터)
|
||||
// - Strict-Transport-Security (HTTPS 강제)
|
||||
// - Content-Security-Policy (컨텐츠 보안 정책)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'", "http://localhost:*", "https://*.data.go.kr", "https://*.khoa.go.kr"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
|
||||
}))
|
||||
|
||||
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
|
||||
app.use(removeServerInfo)
|
||||
app.disable('x-powered-by')
|
||||
|
||||
// 3. CORS: 허용된 출처만 접근 가능
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:5174',
|
||||
'http://localhost:3000',
|
||||
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// 서버-to-서버 요청 (origin 없음) 또는 허용된 출처
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true)
|
||||
} else {
|
||||
callback(new Error('CORS 정책에 의해 차단되었습니다.'))
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
maxAge: 86400, // preflight 캐시 24시간
|
||||
}))
|
||||
|
||||
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15분
|
||||
max: 200, // IP당 최대 200요청
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
error: '요청 횟수 초과',
|
||||
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
||||
}
|
||||
})
|
||||
|
||||
const simulationLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1분
|
||||
max: 10, // IP당 최대 10요청 (시뮬레이션은 비용이 큰 작업)
|
||||
message: {
|
||||
error: '시뮬레이션 요청 횟수 초과',
|
||||
message: '시뮬레이션 요청은 1분당 10회로 제한됩니다.'
|
||||
}
|
||||
})
|
||||
|
||||
app.use(generalLimiter)
|
||||
|
||||
// 5. JSON 본문 파서 (크기 제한 적용)
|
||||
app.use(express.json({ limit: BODY_SIZE_LIMIT }))
|
||||
app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
|
||||
|
||||
// 6. 입력값 살균 미들웨어
|
||||
app.use(sanitizeBody)
|
||||
app.use(sanitizeQuery)
|
||||
|
||||
// ============================================================
|
||||
// 데이터베이스 초기화
|
||||
// ============================================================
|
||||
initDatabase()
|
||||
|
||||
// ============================================================
|
||||
// 라우트
|
||||
// ============================================================
|
||||
|
||||
// 루트 경로
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'WING Backend API',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
// API 라우트
|
||||
app.use('/api/layers', layersRouter)
|
||||
app.use('/api/simulation', simulationLimiter, simulationRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 404 처리
|
||||
// ============================================================
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ error: '요청한 리소스를 찾을 수 없습니다.' })
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 전역 에러 핸들러 (내부 오류 메시지 노출 방지)
|
||||
// ============================================================
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
// 운영 환경에서는 내부 오류 메시지를 노출하지 않음
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
if (isDev) {
|
||||
console.error('서버 오류:', err.message)
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: '서버 내부 오류가 발생했습니다.',
|
||||
...(isDev && { detail: err.message })
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 서버 시작
|
||||
// ============================================================
|
||||
app.listen(PORT, () => {
|
||||
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
|
||||
})
|
||||
90
backend/src/utils/layerIcons.ts
Executable file
@ -0,0 +1,90 @@
|
||||
// 레이어 아이콘 및 카운트 매핑
|
||||
export interface LayerMetadata {
|
||||
icon?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
const layerMetadataMap: Record<string, LayerMetadata> = {
|
||||
// 최상위 레이어 (Level 1)
|
||||
'LYR001001': { icon: '🐟', count: 17129 }, // 어장정보 -> 해양생물자원
|
||||
'LYR001002': { icon: '🌊', count: 3947 }, // 민감자원
|
||||
'LYR001003': { icon: '📊' }, // 민감도평가
|
||||
'LYR001004': { icon: '⚓' }, // 해경관할구역
|
||||
'LYR001005': { icon: '🚓' }, // 해경서소
|
||||
'LYR001006': { icon: '🚫' }, // 금지구역
|
||||
'LYR001007': { icon: '🛡' }, // 북방한계선
|
||||
|
||||
// Level 2 - 민감자원 하위
|
||||
'LYR001002001': { icon: '🌿' }, // 환경생태
|
||||
'LYR001002002': { icon: '💰' }, // 사회경제
|
||||
|
||||
// Level 3 - 환경생태 하위
|
||||
'LYR001002001001': { icon: '🌊' }, // 블루카본
|
||||
'LYR001002001002': { icon: '🏖' }, // ESI
|
||||
'LYR001002001003': { icon: '🐾' }, // 생물종
|
||||
'LYR001002001004': { icon: '🏡' }, // 서식지
|
||||
'LYR001002001005': { icon: '🛡' }, // 보호지역
|
||||
|
||||
// Level 4 - 블루카본 하위
|
||||
'LYR001002001001001': { icon: '🪨' }, // 갯벌
|
||||
'LYR001002001001002': { icon: '🌿' }, // 염생식물
|
||||
'LYR001002001001003': { icon: '📍' }, // 염생식물조사정점
|
||||
|
||||
// Level 4 - 생물종 하위
|
||||
'LYR001002001003001': { icon: '🐋' }, // 포유류
|
||||
'LYR001002001003002': { icon: '🐦' }, // 조류
|
||||
'LYR001002001003003': { icon: '🐢' }, // 양서파충류
|
||||
'LYR001002001003004': { icon: '🦀' }, // 저서생물
|
||||
|
||||
// Level 4 - 서식지 하위
|
||||
'LYR001002001004001': { icon: '🪨' }, // 갯벌
|
||||
'LYR001002001004002': { icon: '🌿' }, // 해조류서식지
|
||||
'LYR001002001004003': { icon: '🐟' }, // 바다목장
|
||||
'LYR001002001004004': { icon: '🌳' }, // 바다숲
|
||||
'LYR001002001004005': { icon: '🪸' }, // 산호류서식지
|
||||
'LYR001002001004006': { icon: '🪸' }, // 인공어초
|
||||
'LYR001002001004007': { icon: '🌿' }, // 해초류서식지
|
||||
|
||||
// Level 3 - 사회경제 하위
|
||||
'LYR001002002001': { icon: '🎣' }, // 수산자원
|
||||
'LYR001002002002': { icon: '🏖' }, // 관광자원
|
||||
'LYR001002002003': { icon: '🏭' }, // 산업자원
|
||||
|
||||
// Level 4 - 관광자원 하위
|
||||
'LYR001002002002001': { icon: '🏖' }, // 해수욕장
|
||||
'LYR001002002002002': { icon: '🎣' }, // 낚시터
|
||||
'LYR001002002002003': { icon: '⛵' }, // 마리나항
|
||||
'LYR001002002002004': { icon: '🐟' }, // 수산시장
|
||||
|
||||
// Level 5 - 낚시터 하위
|
||||
'LYR001002002002002001': { icon: '🪨' }, // 갯바위낚시
|
||||
'LYR001002002002002002': { icon: '🚤' }, // 선상낚시
|
||||
'LYR001002002002002003': { icon: '🎣' }, // 유어장
|
||||
|
||||
// Level 4 - 산업자원 하위
|
||||
'LYR001002002003001': { icon: '💧' }, // 해수취수시설
|
||||
'LYR001002002003002': { icon: '⚓' }, // 항만및어항
|
||||
|
||||
// Level 5 - 해수취수시설 하위
|
||||
'LYR001002002003001001': { icon: '⚡' }, // LNG
|
||||
'LYR001002002003001002': { icon: '🔌' }, // 발전소
|
||||
'LYR001002002003001003': { icon: '🏭' }, // 임해공단
|
||||
|
||||
// Level 5 - 항만및어항 하위
|
||||
'LYR001002002003002001': { icon: '⚓' }, // 국가어항
|
||||
'LYR001002002003002002': { icon: '🚢' }, // 무역항
|
||||
'LYR001002002003002003': { icon: '⛵' }, // 연안항
|
||||
'LYR001002002003002004': { icon: '🎣' }, // 지방어항
|
||||
}
|
||||
|
||||
export function getLayerMetadata(layerId: string): LayerMetadata {
|
||||
return layerMetadataMap[layerId] || {}
|
||||
}
|
||||
|
||||
export function enrichLayerWithMetadata(layer: any) {
|
||||
const metadata = getLayerMetadata(layer.cmn_cd)
|
||||
return {
|
||||
...layer,
|
||||
...metadata,
|
||||
}
|
||||
}
|
||||
132
backend/src/utils/opendrift_runner.py
Executable file
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenDrift 시뮬레이션 실행 스크립트
|
||||
TODO: 실제 OpenDrift 라이브러리 연동 구현
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def run_opendrift_simulation(params):
|
||||
"""
|
||||
OpenDrift 시뮬레이션 실행
|
||||
|
||||
Args:
|
||||
params (dict): 시뮬레이션 파라미터
|
||||
- lat: 시작 위도
|
||||
- lon: 시작 경도
|
||||
- duration_hours: 예측 시간 (시간)
|
||||
- oil_type: 유종
|
||||
- spill_amount: 유출량
|
||||
- spill_type: 유출 형태
|
||||
|
||||
Returns:
|
||||
dict: 시뮬레이션 결과 (입자 궤적 데이터)
|
||||
"""
|
||||
|
||||
# TODO: 실제 OpenDrift 구현
|
||||
# from opendrift.readers import reader_netCDF_CF_generic
|
||||
# from opendrift.models.oceandrift import OceanDrift
|
||||
|
||||
# 현재는 데모 응답 반환
|
||||
print(f"OpenDrift 시뮬레이션 시작: {params}", file=sys.stderr)
|
||||
|
||||
# 입자 수와 시간 스텝
|
||||
particle_count = 1000
|
||||
time_steps = params['duration_hours']
|
||||
|
||||
# 기상/해양 데이터 로드 (TODO: 실제 데이터 소스 연동)
|
||||
# - 바람 데이터 (WindSpeed, WindDirection)
|
||||
# - 해류 데이터 (CurrentSpeed, CurrentDirection)
|
||||
# - 파고 데이터 (WaveHeight)
|
||||
|
||||
# OpenDrift 모델 설정
|
||||
# o = OceanDrift(loglevel=20)
|
||||
#
|
||||
# # 환경 데이터 추가
|
||||
# reader_wind = reader_netCDF_CF_generic.Reader(wind_file)
|
||||
# reader_current = reader_netCDF_CF_generic.Reader(current_file)
|
||||
# o.add_reader([reader_wind, reader_current])
|
||||
#
|
||||
# # 초기 입자 시드
|
||||
# o.seed_elements(
|
||||
# lon=params['lon'],
|
||||
# lat=params['lat'],
|
||||
# number=particle_count,
|
||||
# time=datetime.now()
|
||||
# )
|
||||
#
|
||||
# # 시뮬레이션 실행
|
||||
# o.run(
|
||||
# duration=timedelta(hours=params['duration_hours']),
|
||||
# time_step=3600 # 1시간 간격
|
||||
# )
|
||||
#
|
||||
# # 결과 추출
|
||||
# trajectory = []
|
||||
# for i in range(particle_count):
|
||||
# lons, lats, times = o.get_trajectory(i)
|
||||
# for j, (lon, lat, time) in enumerate(zip(lons, lats, times)):
|
||||
# trajectory.append({
|
||||
# 'lat': float(lat),
|
||||
# 'lon': float(lon),
|
||||
# 'time': j,
|
||||
# 'particle': i
|
||||
# })
|
||||
|
||||
# 데모 응답 (실제 구현 시 제거)
|
||||
trajectory = generate_demo_trajectory(
|
||||
params['lat'],
|
||||
params['lon'],
|
||||
params['duration_hours'],
|
||||
particle_count
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'trajectory': trajectory,
|
||||
'metadata': {
|
||||
'particle_count': particle_count,
|
||||
'time_steps': time_steps,
|
||||
'model': 'OpenDrift',
|
||||
'generated_at': datetime.now().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def generate_demo_trajectory(start_lat, start_lon, hours, particle_count):
|
||||
"""데모 궤적 생성 (실제 구현 시 제거)"""
|
||||
import math
|
||||
|
||||
trajectory = []
|
||||
spread_factor = 0.005
|
||||
|
||||
for p in range(particle_count):
|
||||
angle = (2 * math.pi * p) / particle_count
|
||||
for h in range(hours + 1):
|
||||
distance = h * spread_factor
|
||||
drift = math.sin(h * 0.2) * 0.002
|
||||
trajectory.append({
|
||||
'lat': start_lat + distance * math.cos(angle) + drift,
|
||||
'lon': start_lon + distance * math.sin(angle) + drift,
|
||||
'time': h,
|
||||
'particle': p
|
||||
})
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 표준 입력으로 파라미터 받기
|
||||
if len(sys.argv) > 1:
|
||||
params_json = sys.argv[1]
|
||||
params = json.loads(params_json)
|
||||
else:
|
||||
params = json.loads(sys.stdin.read())
|
||||
|
||||
# 시뮬레이션 실행
|
||||
result = run_opendrift_simulation(params)
|
||||
|
||||
# 결과 출력 (JSON)
|
||||
print(json.dumps(result))
|
||||
17
backend/tsconfig.json
Executable file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
802
database/init.sql
Executable file
@ -0,0 +1,802 @@
|
||||
-- ================================================================
|
||||
-- WING 해양환경 위기대응 통합시스템 데이터베이스
|
||||
-- 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 기준 적용
|
||||
-- PostgreSQL 16 + PostGIS 3.4
|
||||
-- ================================================================
|
||||
--
|
||||
-- [표준화 적용 원칙]
|
||||
-- 1. 표준단어: 한글명 → 영문약어명 매핑 (별도 사전 참조)
|
||||
-- 2. 표준도메인: 분류명 + 데이터타입 + 길이 규칙
|
||||
-- 3. 표준용어: 컬럼 물리명 = 영문약어명 조합 (언더스코어 구분, 30자 이내)
|
||||
-- 4. 저장형식: 날짜 YYYYMMDD(C8), 일시 TIMESTAMP, 여부 Y/N(C1)
|
||||
-- 5. 표준코드: 공통코드 테이블로 관리
|
||||
--
|
||||
-- ================================================================
|
||||
-- 표준단어 사전 (WING 도메인)
|
||||
-- ================================================================
|
||||
-- | 한글명 | 영문약어명 | 영문전체명 |
|
||||
-- |-------------|----------|----------------------|
|
||||
-- | 사고 | ACDNT | Accident |
|
||||
-- | 활성 | ACTV | Active |
|
||||
-- | 알고리즘 | ALGO | Algorithm |
|
||||
-- | 시작 | BGNG | Beginning |
|
||||
-- | 오일펜스 | BOOM | Boom |
|
||||
-- | 코드 | CD | Code |
|
||||
-- | 공통 | CMN | Common |
|
||||
-- | 완료 | CMPL | Completion |
|
||||
-- | 내용 | CN | Content |
|
||||
-- | 해안 | CST | Coast |
|
||||
-- | 설명 | DC | Description |
|
||||
-- | 배치 | DPLY | Deployment |
|
||||
-- | 일시 | DTM | DateTime |
|
||||
-- | 효율 | EFCNC | Efficiency |
|
||||
-- | 오류 | ERR | Error |
|
||||
-- | 실행 | EXEC | Execution |
|
||||
-- | 예측 | FCST | Forecast |
|
||||
-- | 지오메트리 | GEOM | Geometry |
|
||||
-- | 그룹 | GRP | Group |
|
||||
-- | 시간 | HR | Hour |
|
||||
-- | 아이디 | ID | Identifier |
|
||||
-- | 정보 | INFO | Information |
|
||||
-- | 길이 | LEN | Length |
|
||||
-- | 라인 | LINE | Line |
|
||||
-- | 위치 | LOC | Location |
|
||||
-- | 미터 | M | Meter |
|
||||
-- | 수정 | MDFCN | Modification |
|
||||
-- | 메시지 | MSG | Message |
|
||||
-- | 명 | NM | Name |
|
||||
-- | 번호 | NO | Number |
|
||||
-- | 발생 | OCCRN | Occurrence |
|
||||
-- | 유종 | OILTP | OilType |
|
||||
-- | 조직 | ORG | Organization |
|
||||
-- | 비밀번호 | PSWD | Password |
|
||||
-- | 퍼센트 | PCT | Percent |
|
||||
-- | 사진 | PHOTO | Photo |
|
||||
-- | 예측 | PRED | Prediction |
|
||||
-- | 우선순위 | PRIORT | Priority |
|
||||
-- | 양 | QTY | Quantity |
|
||||
-- | 등록 | REG | Registration |
|
||||
-- | 등록자 | RGTR | Registrant |
|
||||
-- | 직급 | RNKP | RankPosition |
|
||||
-- | 역할 | ROLE | Role |
|
||||
-- | 보고 | RPT | Report |
|
||||
-- | 책임 | RSPNS | Responsible |
|
||||
-- | 결과 | RSLT | Result |
|
||||
-- | SCAT | SCAT | SCAT |
|
||||
-- | 초 | SEC | Second |
|
||||
-- | 구간 | SECT | Section |
|
||||
-- | 순번 | SN | SequenceNumber |
|
||||
-- | 유출 | SPIL | Spill |
|
||||
-- | 조사 | SRVY | Survey |
|
||||
-- | 상태 | STTS | Status |
|
||||
-- | 심각도 | SVRT | Severity |
|
||||
-- | 촬영 | TAKNG | Taking |
|
||||
-- | 유형 | TP | Type |
|
||||
-- | 단위 | UNIT | Unit |
|
||||
-- | 상위 | UPPER | Upper |
|
||||
-- | 사용자 | USER | User |
|
||||
-- | 계정 | ACNT | Account |
|
||||
-- | 여부 | YN | YesNo |
|
||||
-- | 일자 | YMD | YearMonthDay |
|
||||
-- | 구역 | ZONE | Zone |
|
||||
-- | 민감도 | SNSTVT | Sensitivity |
|
||||
-- | 오염 | POLUT | Pollution |
|
||||
-- | 정렬 | SORT | Sort |
|
||||
-- | 순서 | ORD | Order |
|
||||
-- | 사용 | USE | Use |
|
||||
-- | 해시 | HASH | Hash |
|
||||
-- | 약칭 | ABBR | Abbreviation |
|
||||
-- | 데이터 | DATA | Data |
|
||||
-- | 소요 | REQD | Required |
|
||||
-- | 경로 | PATH | Path |
|
||||
-- | 파일 | FILE | File |
|
||||
-- | 비고 | RMK | Remark |
|
||||
-- | 관할 | JRSD | Jurisdiction |
|
||||
-- | 기관 | INST | Institution |
|
||||
-- | 위도 | LAT | Latitude |
|
||||
-- | 경도 | LON | Longitude |
|
||||
-- ================================================================
|
||||
-- 표준도메인 정의
|
||||
-- ================================================================
|
||||
-- | 도메인명 | 데이터타입 | 설명 |
|
||||
-- |-----------------|-------------------|------------------------|
|
||||
-- | 순번N10 | SERIAL/INTEGER | 순차번호 10자리 |
|
||||
-- | 아이디V36 | UUID | UUID 식별자 36자리 |
|
||||
-- | 코드V10 | VARCHAR(10) | 코드 10자리 이내 |
|
||||
-- | 코드V20 | VARCHAR(20) | 코드 20자리 이내 |
|
||||
-- | 코드V50 | VARCHAR(50) | 코드 50자리 이내 |
|
||||
-- | 명칭V20 | VARCHAR(20) | 명칭 20자리 이내 |
|
||||
-- | 명칭V30 | VARCHAR(30) | 명칭 30자리 이내 |
|
||||
-- | 명칭V50 | VARCHAR(50) | 명칭 50자리 이내 |
|
||||
-- | 명칭V100 | VARCHAR(100) | 명칭 100자리 이내 |
|
||||
-- | 명칭V200 | VARCHAR(200) | 명칭 200자리 이내 |
|
||||
-- | 내용V255 | VARCHAR(255) | 내용 255자리 이내 |
|
||||
-- | 내용V500 | VARCHAR(500) | 내용 500자리 이내 |
|
||||
-- | 내용TEXT | TEXT | 대용량 텍스트 |
|
||||
-- | 수량N5,2 | NUMERIC(5,2) | 수량 소수점2자리 |
|
||||
-- | 수량N10 | INTEGER | 정수 수량 |
|
||||
-- | 수량N10,2 | NUMERIC(10,2) | 수량 소수점2자리 |
|
||||
-- | 수량N12,2 | NUMERIC(12,2) | 수량 소수점2자리 |
|
||||
-- | 여부C1 | CHAR(1) | Y/N 여부 |
|
||||
-- | 연월일C8 | CHAR(8) | YYYYMMDD |
|
||||
-- | 연월일시분초D | TIMESTAMPTZ | 타임스탬프(시간대포함) |
|
||||
-- | 경도N13,10 | NUMERIC(13,10) | 경도 소수점10자리 |
|
||||
-- | 위도N12,10 | NUMERIC(12,10) | 위도 소수점10자리 |
|
||||
-- | 지오메트리PNT | GEOMETRY(Point) | 포인트 공간정보 |
|
||||
-- | 지오메트리LINE | GEOMETRY(LineStr) | 라인 공간정보 |
|
||||
-- | JSON | JSONB | JSON 바이너리 |
|
||||
-- | 정렬순서N5 | INTEGER | 정렬순서 5자리 |
|
||||
-- ================================================================
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 확장 설치
|
||||
-- ============================================================
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS postgis_topology;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 공통코드 관리
|
||||
-- ============================================================
|
||||
|
||||
-- 공통코드그룹 (CMN_CD_GRP)
|
||||
-- 시스템 전체에서 사용되는 코드를 그룹 단위로 관리
|
||||
CREATE TABLE CMN_CD_GRP (
|
||||
CMN_CD_GRP_ID VARCHAR(20) NOT NULL, -- 공통코드그룹아이디
|
||||
CMN_CD_GRP_NM VARCHAR(100) NOT NULL, -- 공통코드그룹명
|
||||
GRP_DC VARCHAR(500), -- 그룹설명
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_CMN_CD_GRP PRIMARY KEY (CMN_CD_GRP_ID),
|
||||
CONSTRAINT CK_CMN_CD_GRP_USE_YN CHECK (USE_YN IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE CMN_CD_GRP IS '공통코드그룹';
|
||||
COMMENT ON COLUMN CMN_CD_GRP.CMN_CD_GRP_ID IS '공통코드그룹아이디';
|
||||
COMMENT ON COLUMN CMN_CD_GRP.CMN_CD_GRP_NM IS '공통코드그룹명';
|
||||
COMMENT ON COLUMN CMN_CD_GRP.GRP_DC IS '그룹설명';
|
||||
COMMENT ON COLUMN CMN_CD_GRP.USE_YN IS '사용여부 (Y:사용, N:미사용)';
|
||||
COMMENT ON COLUMN CMN_CD_GRP.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- 공통코드 (CMN_CD)
|
||||
-- 각 그룹 내 개별 코드값을 관리
|
||||
CREATE TABLE CMN_CD (
|
||||
CMN_CD_GRP_ID VARCHAR(20) NOT NULL, -- 공통코드그룹아이디
|
||||
CMN_CD VARCHAR(20) NOT NULL, -- 공통코드
|
||||
CMN_CD_NM VARCHAR(100) NOT NULL, -- 공통코드명
|
||||
CD_DC VARCHAR(500), -- 코드설명
|
||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_CMN_CD PRIMARY KEY (CMN_CD_GRP_ID, CMN_CD),
|
||||
CONSTRAINT FK_CMN_CD_GRP FOREIGN KEY (CMN_CD_GRP_ID) REFERENCES CMN_CD_GRP(CMN_CD_GRP_ID),
|
||||
CONSTRAINT CK_CMN_CD_USE_YN CHECK (USE_YN IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE CMN_CD IS '공통코드';
|
||||
COMMENT ON COLUMN CMN_CD.CMN_CD_GRP_ID IS '공통코드그룹아이디';
|
||||
COMMENT ON COLUMN CMN_CD.CMN_CD IS '공통코드';
|
||||
COMMENT ON COLUMN CMN_CD.CMN_CD_NM IS '공통코드명';
|
||||
COMMENT ON COLUMN CMN_CD.CD_DC IS '코드설명';
|
||||
COMMENT ON COLUMN CMN_CD.SORT_ORD IS '정렬순서';
|
||||
COMMENT ON COLUMN CMN_CD.USE_YN IS '사용여부 (Y:사용, N:미사용)';
|
||||
COMMENT ON COLUMN CMN_CD.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 조직 (ORG)
|
||||
-- ============================================================
|
||||
CREATE TABLE ORG (
|
||||
ORG_SN SERIAL NOT NULL, -- 조직순번
|
||||
ORG_NM VARCHAR(100) NOT NULL, -- 조직명
|
||||
ORG_ABBR_NM VARCHAR(20) NOT NULL, -- 조직약칭명
|
||||
ORG_TP_CD VARCHAR(20) NOT NULL, -- 조직유형코드
|
||||
UPPER_ORG_SN INTEGER, -- 상위조직순번
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_ORG PRIMARY KEY (ORG_SN),
|
||||
CONSTRAINT FK_ORG_UPPER FOREIGN KEY (UPPER_ORG_SN) REFERENCES ORG(ORG_SN)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ORG IS '조직';
|
||||
COMMENT ON COLUMN ORG.ORG_SN IS '조직순번';
|
||||
COMMENT ON COLUMN ORG.ORG_NM IS '조직명 (기관 전체 명칭)';
|
||||
COMMENT ON COLUMN ORG.ORG_ABBR_NM IS '조직약칭명 (기관 약칭)';
|
||||
COMMENT ON COLUMN ORG.ORG_TP_CD IS '조직유형코드 (ORG_TP: headquarters, regional, station, agency)';
|
||||
COMMENT ON COLUMN ORG.UPPER_ORG_SN IS '상위조직순번 (상위 기관 참조)';
|
||||
COMMENT ON COLUMN ORG.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 사용자 (USER_INFO)
|
||||
-- ※ USER는 PostgreSQL 예약어이므로 USER_INFO 사용
|
||||
-- ============================================================
|
||||
CREATE TABLE USER_INFO (
|
||||
USER_ID UUID NOT NULL DEFAULT uuid_generate_v4(), -- 사용자아이디
|
||||
USER_ACNT VARCHAR(50) NOT NULL, -- 사용자계정
|
||||
PSWD_HASH VARCHAR(255) NOT NULL, -- 비밀번호해시
|
||||
USER_NM VARCHAR(50) NOT NULL, -- 사용자명
|
||||
RNKP_NM VARCHAR(30), -- 직급명
|
||||
ORG_SN INTEGER, -- 조직순번
|
||||
ROLE_CD VARCHAR(20) NOT NULL DEFAULT 'USER', -- 역할코드
|
||||
ACTV_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 활성여부
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_USER_INFO PRIMARY KEY (USER_ID),
|
||||
CONSTRAINT UK_USER_ACNT UNIQUE (USER_ACNT),
|
||||
CONSTRAINT FK_USER_ORG FOREIGN KEY (ORG_SN) REFERENCES ORG(ORG_SN),
|
||||
CONSTRAINT CK_USER_ROLE_CD CHECK (ROLE_CD IN ('ADMIN','MANAGER','USER')),
|
||||
CONSTRAINT CK_USER_ACTV_YN CHECK (ACTV_YN IN ('Y','N'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE USER_INFO IS '사용자정보';
|
||||
COMMENT ON COLUMN USER_INFO.USER_ID IS '사용자아이디 (UUID)';
|
||||
COMMENT ON COLUMN USER_INFO.USER_ACNT IS '사용자계정 (로그인 ID)';
|
||||
COMMENT ON COLUMN USER_INFO.PSWD_HASH IS '비밀번호해시 (bcrypt 해시값)';
|
||||
COMMENT ON COLUMN USER_INFO.USER_NM IS '사용자명';
|
||||
COMMENT ON COLUMN USER_INFO.RNKP_NM IS '직급명';
|
||||
COMMENT ON COLUMN USER_INFO.ORG_SN IS '조직순번 (소속 조직)';
|
||||
COMMENT ON COLUMN USER_INFO.ROLE_CD IS '역할코드 (ADMIN, MANAGER, USER)';
|
||||
COMMENT ON COLUMN USER_INFO.ACTV_YN IS '활성여부 (Y:활성, N:비활성)';
|
||||
COMMENT ON COLUMN USER_INFO.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 사고 (ACDNT)
|
||||
-- ============================================================
|
||||
CREATE TABLE ACDNT (
|
||||
ACDNT_SN SERIAL NOT NULL, -- 사고순번
|
||||
ACDNT_CD VARCHAR(20) NOT NULL, -- 사고코드
|
||||
ACDNT_NM VARCHAR(200) NOT NULL, -- 사고명
|
||||
ACDNT_TP_CD VARCHAR(50) NOT NULL, -- 사고유형코드
|
||||
ACDNT_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- 사고상태코드
|
||||
LOC_GEOM GEOMETRY(Point, 4326) NOT NULL, -- 위치지오메트리
|
||||
LOC_DC VARCHAR(200), -- 위치설명
|
||||
OCCRN_DTM TIMESTAMPTZ NOT NULL, -- 발생일시
|
||||
RPT_DTM TIMESTAMPTZ, -- 보고일시
|
||||
RSPNS_ORG_SN INTEGER, -- 책임조직순번
|
||||
SVRT_CD VARCHAR(10), -- 심각도코드
|
||||
RGTR_ID UUID, -- 등록자아이디
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
|
||||
CONSTRAINT PK_ACDNT PRIMARY KEY (ACDNT_SN),
|
||||
CONSTRAINT UK_ACDNT_CD UNIQUE (ACDNT_CD),
|
||||
CONSTRAINT FK_ACDNT_ORG FOREIGN KEY (RSPNS_ORG_SN) REFERENCES ORG(ORG_SN),
|
||||
CONSTRAINT FK_ACDNT_RGTR FOREIGN KEY (RGTR_ID) REFERENCES USER_INFO(USER_ID),
|
||||
CONSTRAINT CK_ACDNT_STTS CHECK (ACDNT_STTS_CD IN ('ACTIVE','MONITORING','CLOSED')),
|
||||
CONSTRAINT CK_ACDNT_SVRT CHECK (SVRT_CD IN ('DANGER','ALERT','CAUTION','INTEREST'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ACDNT IS '사고';
|
||||
COMMENT ON COLUMN ACDNT.ACDNT_SN IS '사고순번';
|
||||
COMMENT ON COLUMN ACDNT.ACDNT_CD IS '사고코드 (예: INC-2025-0042)';
|
||||
COMMENT ON COLUMN ACDNT.ACDNT_NM IS '사고명';
|
||||
COMMENT ON COLUMN ACDNT.ACDNT_TP_CD IS '사고유형코드 (ACDNT_TP: 유조선충돌, 좌초 등)';
|
||||
COMMENT ON COLUMN ACDNT.ACDNT_STTS_CD IS '사고상태코드 (ACTIVE:진행, MONITORING:감시, CLOSED:종료)';
|
||||
COMMENT ON COLUMN ACDNT.LOC_GEOM IS '위치지오메트리 (EPSG:4326 Point)';
|
||||
COMMENT ON COLUMN ACDNT.LOC_DC IS '위치설명 (텍스트 위치 표기)';
|
||||
COMMENT ON COLUMN ACDNT.OCCRN_DTM IS '발생일시';
|
||||
COMMENT ON COLUMN ACDNT.RPT_DTM IS '보고일시';
|
||||
COMMENT ON COLUMN ACDNT.RSPNS_ORG_SN IS '책임조직순번 (담당 기관)';
|
||||
COMMENT ON COLUMN ACDNT.SVRT_CD IS '심각도코드 (DANGER:위험, ALERT:경계, CAUTION:주의, INTEREST:관심)';
|
||||
COMMENT ON COLUMN ACDNT.RGTR_ID IS '등록자아이디';
|
||||
COMMENT ON COLUMN ACDNT.REG_DTM IS '등록일시';
|
||||
COMMENT ON COLUMN ACDNT.MDFCN_DTM IS '수정일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 유출정보 (SPIL_DATA)
|
||||
-- ============================================================
|
||||
CREATE TABLE SPIL_DATA (
|
||||
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
|
||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
||||
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
|
||||
SPIL_QTY NUMERIC(12,2), -- 유출량
|
||||
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
|
||||
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
|
||||
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
|
||||
FCST_HR INTEGER, -- 예측시간
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
|
||||
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE SPIL_DATA IS '유출정보';
|
||||
COMMENT ON COLUMN SPIL_DATA.SPIL_DATA_SN IS '유출정보순번';
|
||||
COMMENT ON COLUMN SPIL_DATA.ACDNT_SN IS '사고순번 (사고 참조)';
|
||||
COMMENT ON COLUMN SPIL_DATA.OIL_TP_CD IS '유종코드 (OIL_TP: 벙커C유, 경유, 원유 등)';
|
||||
COMMENT ON COLUMN SPIL_DATA.SPIL_QTY IS '유출량';
|
||||
COMMENT ON COLUMN SPIL_DATA.SPIL_UNIT_CD IS '유출단위코드 (KL:킬로리터, L:리터, BBL:배럴)';
|
||||
COMMENT ON COLUMN SPIL_DATA.SPIL_TP_CD IS '유출유형코드 (SPIL_TP: 연속유출, 순간유출 등)';
|
||||
COMMENT ON COLUMN SPIL_DATA.SPIL_LOC_GEOM IS '유출위치지오메트리 (EPSG:4326 Point)';
|
||||
COMMENT ON COLUMN SPIL_DATA.FCST_HR IS '예측시간 (시간 단위)';
|
||||
COMMENT ON COLUMN SPIL_DATA.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 예측실행 (PRED_EXEC)
|
||||
-- ============================================================
|
||||
CREATE TABLE PRED_EXEC (
|
||||
PRED_EXEC_SN SERIAL NOT NULL, -- 예측실행순번
|
||||
SPIL_DATA_SN INTEGER NOT NULL, -- 유출정보순번
|
||||
ALGO_CD VARCHAR(20) NOT NULL, -- 알고리즘코드
|
||||
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- 실행상태코드
|
||||
BGNG_DTM TIMESTAMPTZ, -- 시작일시
|
||||
CMPL_DTM TIMESTAMPTZ, -- 완료일시
|
||||
REQD_SEC INTEGER, -- 소요시간초
|
||||
RSLT_DATA JSONB, -- 결과데이터
|
||||
ERR_MSG TEXT, -- 오류메시지
|
||||
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
|
||||
CONSTRAINT FK_PRED_SPIL FOREIGN KEY (SPIL_DATA_SN) REFERENCES SPIL_DATA(SPIL_DATA_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE PRED_EXEC IS '예측실행';
|
||||
COMMENT ON COLUMN PRED_EXEC.PRED_EXEC_SN IS '예측실행순번';
|
||||
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (유출정보 참조)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR 등)';
|
||||
COMMENT ON COLUMN PRED_EXEC.EXEC_STTS_CD IS '실행상태코드 (PENDING:대기, RUNNING:실행중, COMPLETED:완료, FAILED:실패)';
|
||||
COMMENT ON COLUMN PRED_EXEC.BGNG_DTM IS '시작일시';
|
||||
COMMENT ON COLUMN PRED_EXEC.CMPL_DTM IS '완료일시';
|
||||
COMMENT ON COLUMN PRED_EXEC.REQD_SEC IS '소요시간초 (실행 소요 시간, 초 단위)';
|
||||
COMMENT ON COLUMN PRED_EXEC.RSLT_DATA IS '결과데이터 (JSON 형식 예측 결과)';
|
||||
COMMENT ON COLUMN PRED_EXEC.ERR_MSG IS '오류메시지';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 오일펜스배치 (BOOM_LINE)
|
||||
-- ============================================================
|
||||
CREATE TABLE BOOM_LINE (
|
||||
BOOM_SN SERIAL NOT NULL, -- 오일펜스순번
|
||||
ACDNT_SN INTEGER NOT NULL, -- 사고순번
|
||||
BOOM_NM VARCHAR(100) NOT NULL, -- 오일펜스명
|
||||
PRIORT_CD VARCHAR(20), -- 우선순위코드
|
||||
LEN_M NUMERIC(10,2), -- 길이미터
|
||||
LINE_GEOM GEOMETRY(LineString, 4326), -- 라인지오메트리
|
||||
DPLY_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PLANNED', -- 배치상태코드
|
||||
EFCNC_PCT NUMERIC(5,2), -- 효율퍼센트
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
|
||||
CONSTRAINT PK_BOOM_LINE PRIMARY KEY (BOOM_SN),
|
||||
CONSTRAINT FK_BOOM_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE,
|
||||
CONSTRAINT CK_BOOM_PRIORT CHECK (PRIORT_CD IN ('CRITICAL','HIGH','MEDIUM','LOW')),
|
||||
CONSTRAINT CK_BOOM_STTS CHECK (DPLY_STTS_CD IN ('PLANNED','DEPLOYING','DEPLOYED','REMOVED'))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE BOOM_LINE IS '오일펜스배치';
|
||||
COMMENT ON COLUMN BOOM_LINE.BOOM_SN IS '오일펜스순번';
|
||||
COMMENT ON COLUMN BOOM_LINE.ACDNT_SN IS '사고순번 (사고 참조)';
|
||||
COMMENT ON COLUMN BOOM_LINE.BOOM_NM IS '오일펜스명';
|
||||
COMMENT ON COLUMN BOOM_LINE.PRIORT_CD IS '우선순위코드 (CRITICAL:긴급, HIGH:높음, MEDIUM:보통, LOW:낮음)';
|
||||
COMMENT ON COLUMN BOOM_LINE.LEN_M IS '길이미터 (오일펜스 총 길이, 미터)';
|
||||
COMMENT ON COLUMN BOOM_LINE.LINE_GEOM IS '라인지오메트리 (EPSG:4326 LineString)';
|
||||
COMMENT ON COLUMN BOOM_LINE.DPLY_STTS_CD IS '배치상태코드 (PLANNED:계획, DEPLOYING:배치중, DEPLOYED:배치완료, REMOVED:회수)';
|
||||
COMMENT ON COLUMN BOOM_LINE.EFCNC_PCT IS '효율퍼센트 (방제 효율, %)';
|
||||
COMMENT ON COLUMN BOOM_LINE.REG_DTM IS '등록일시';
|
||||
COMMENT ON COLUMN BOOM_LINE.MDFCN_DTM IS '수정일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 8. 해안조사구역 (CST_SRVY_ZONE)
|
||||
-- ============================================================
|
||||
CREATE TABLE CST_SRVY_ZONE (
|
||||
CST_SRVY_ZONE_SN SERIAL NOT NULL, -- 해안조사구역순번
|
||||
ZONE_CD VARCHAR(20) NOT NULL, -- 구역코드
|
||||
ZONE_NM VARCHAR(100) NOT NULL, -- 구역명
|
||||
JRSD_ORG_SN INTEGER, -- 관할조직순번
|
||||
ZONE_DC VARCHAR(500), -- 구역설명
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_CST_SRVY_ZONE PRIMARY KEY (CST_SRVY_ZONE_SN),
|
||||
CONSTRAINT UK_ZONE_CD UNIQUE (ZONE_CD),
|
||||
CONSTRAINT FK_ZONE_ORG FOREIGN KEY (JRSD_ORG_SN) REFERENCES ORG(ORG_SN)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE CST_SRVY_ZONE IS '해안조사구역';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.CST_SRVY_ZONE_SN IS '해안조사구역순번';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_CD IS '구역코드 (예: JJAW, JJAN 등)';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_NM IS '구역명 (예: 제주시 서부, 서귀포시 동부 등)';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.JRSD_ORG_SN IS '관할조직순번 (관할 해경 기관)';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_DC IS '구역설명';
|
||||
COMMENT ON COLUMN CST_SRVY_ZONE.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 9. 해안구간 (CST_SECT)
|
||||
-- ============================================================
|
||||
CREATE TABLE CST_SECT (
|
||||
CST_SECT_SN SERIAL NOT NULL, -- 해안구간순번
|
||||
CST_SRVY_ZONE_SN INTEGER NOT NULL, -- 해안조사구역순번
|
||||
SECT_CD VARCHAR(20) NOT NULL, -- 구간코드
|
||||
SECT_NM VARCHAR(100), -- 구간명
|
||||
CST_TP_CD VARCHAR(20), -- 해안유형코드
|
||||
LEN_M NUMERIC(10,2), -- 길이미터
|
||||
SNSTVT_CD VARCHAR(10), -- 민감도코드
|
||||
SRVY_STTS_CD VARCHAR(20) DEFAULT 'PENDING', -- 조사상태코드
|
||||
BGNG_LAT NUMERIC(12,10), -- 시작위도
|
||||
BGNG_LON NUMERIC(13,10), -- 시작경도
|
||||
END_LAT NUMERIC(12,10), -- 종료위도
|
||||
END_LON NUMERIC(13,10), -- 종료경도
|
||||
BGNG_LOC_GEOM GEOMETRY(Point, 4326), -- 시작위치지오메트리
|
||||
END_LOC_GEOM GEOMETRY(Point, 4326), -- 종료위치지오메트리
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
|
||||
CONSTRAINT PK_CST_SECT PRIMARY KEY (CST_SECT_SN),
|
||||
CONSTRAINT UK_SECT_CD UNIQUE (SECT_CD),
|
||||
CONSTRAINT FK_SECT_ZONE FOREIGN KEY (CST_SRVY_ZONE_SN) REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE CST_SECT IS '해안구간';
|
||||
COMMENT ON COLUMN CST_SECT.CST_SECT_SN IS '해안구간순번';
|
||||
COMMENT ON COLUMN CST_SECT.CST_SRVY_ZONE_SN IS '해안조사구역순번 (구역 참조)';
|
||||
COMMENT ON COLUMN CST_SECT.SECT_CD IS '구간코드 (예: JJAW-1, JJAW-2 등)';
|
||||
COMMENT ON COLUMN CST_SECT.SECT_NM IS '구간명';
|
||||
COMMENT ON COLUMN CST_SECT.CST_TP_CD IS '해안유형코드 (CST_TP: 암반, 자갈, 모래, 혼합 등)';
|
||||
COMMENT ON COLUMN CST_SECT.LEN_M IS '길이미터 (해안구간 길이, 미터)';
|
||||
COMMENT ON COLUMN CST_SECT.SNSTVT_CD IS '민감도코드 (SNSTVT: HIGH, MEDIUM, LOW)';
|
||||
COMMENT ON COLUMN CST_SECT.SRVY_STTS_CD IS '조사상태코드 (PENDING:미조사, PROGRESS:진행중, COMPLETED:완료)';
|
||||
COMMENT ON COLUMN CST_SECT.BGNG_LAT IS '시작위도';
|
||||
COMMENT ON COLUMN CST_SECT.BGNG_LON IS '시작경도';
|
||||
COMMENT ON COLUMN CST_SECT.END_LAT IS '종료위도';
|
||||
COMMENT ON COLUMN CST_SECT.END_LON IS '종료경도';
|
||||
COMMENT ON COLUMN CST_SECT.BGNG_LOC_GEOM IS '시작위치지오메트리 (EPSG:4326 Point)';
|
||||
COMMENT ON COLUMN CST_SECT.END_LOC_GEOM IS '종료위치지오메트리 (EPSG:4326 Point)';
|
||||
COMMENT ON COLUMN CST_SECT.REG_DTM IS '등록일시';
|
||||
COMMENT ON COLUMN CST_SECT.MDFCN_DTM IS '수정일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 10. SCAT조사 (SCAT_SRVY)
|
||||
-- ============================================================
|
||||
CREATE TABLE SCAT_SRVY (
|
||||
SCAT_SRVY_SN SERIAL NOT NULL, -- SCAT조사순번
|
||||
CST_SECT_SN INTEGER NOT NULL, -- 해안구간순번
|
||||
ACDNT_SN INTEGER, -- 사고순번
|
||||
SRVY_YMD CHAR(8), -- 조사일자 (YYYYMMDD)
|
||||
SRVY_TP_CD VARCHAR(20), -- 조사유형코드
|
||||
SRVY_PHASE_CD VARCHAR(20), -- 조사단계코드
|
||||
POLUT_CD VARCHAR(20), -- 오염도코드
|
||||
SRVYR_ID UUID, -- 조사자아이디
|
||||
RMK VARCHAR(500), -- 비고
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_SCAT_SRVY PRIMARY KEY (SCAT_SRVY_SN),
|
||||
CONSTRAINT FK_SCAT_SECT FOREIGN KEY (CST_SECT_SN) REFERENCES CST_SECT(CST_SECT_SN),
|
||||
CONSTRAINT FK_SCAT_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN),
|
||||
CONSTRAINT FK_SCAT_SRVYR FOREIGN KEY (SRVYR_ID) REFERENCES USER_INFO(USER_ID)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE SCAT_SRVY IS 'SCAT조사';
|
||||
COMMENT ON COLUMN SCAT_SRVY.SCAT_SRVY_SN IS 'SCAT조사순번';
|
||||
COMMENT ON COLUMN SCAT_SRVY.CST_SECT_SN IS '해안구간순번 (해안구간 참조)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.ACDNT_SN IS '사고순번 (관련 사고 참조)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.SRVY_YMD IS '조사일자 (YYYYMMDD 저장형식)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.SRVY_TP_CD IS '조사유형코드 (SRVY_TP: Pre-SCAT, SCAT, Post-SCAT)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.SRVY_PHASE_CD IS '조사단계코드 (SRVY_PHASE: 1차, 2차, 3차 등)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.POLUT_CD IS '오염도코드 (POLUT: HEAVY, MODERATE, LIGHT, CLEAN)';
|
||||
COMMENT ON COLUMN SCAT_SRVY.SRVYR_ID IS '조사자아이디';
|
||||
COMMENT ON COLUMN SCAT_SRVY.RMK IS '비고';
|
||||
COMMENT ON COLUMN SCAT_SRVY.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 11. SCAT조사사진 (SCAT_SRVY_PHOTO)
|
||||
-- ============================================================
|
||||
CREATE TABLE SCAT_SRVY_PHOTO (
|
||||
SCAT_PHOTO_SN SERIAL NOT NULL, -- SCAT조사사진순번
|
||||
SCAT_SRVY_SN INTEGER NOT NULL, -- SCAT조사순번
|
||||
PHOTO_FILE_PATH VARCHAR(500) NOT NULL, -- 사진파일경로
|
||||
PHOTO_DC VARCHAR(200), -- 사진설명
|
||||
TAKNG_DTM TIMESTAMPTZ, -- 촬영일시
|
||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_SCAT_PHOTO PRIMARY KEY (SCAT_PHOTO_SN),
|
||||
CONSTRAINT FK_PHOTO_SRVY FOREIGN KEY (SCAT_SRVY_SN) REFERENCES SCAT_SRVY(SCAT_SRVY_SN) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE SCAT_SRVY_PHOTO IS 'SCAT조사사진';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SCAT_PHOTO_SN IS 'SCAT조사사진순번';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SCAT_SRVY_SN IS 'SCAT조사순번 (SCAT조사 참조)';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.PHOTO_FILE_PATH IS '사진파일경로';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.PHOTO_DC IS '사진설명';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.TAKNG_DTM IS '촬영일시';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SORT_ORD IS '정렬순서';
|
||||
COMMENT ON COLUMN SCAT_SRVY_PHOTO.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 12. HNS물질정보 (HNS_SBST)
|
||||
-- ============================================================
|
||||
CREATE TABLE HNS_SBST (
|
||||
HNS_SBST_SN SERIAL NOT NULL, -- HNS물질순번
|
||||
SBST_NM VARCHAR(200) NOT NULL, -- 물질명
|
||||
SBST_ENG_NM VARCHAR(200), -- 물질영문명
|
||||
CAS_NO VARCHAR(20), -- CAS번호
|
||||
UN_NO VARCHAR(10), -- UN번호
|
||||
RISK_GRD_CD VARCHAR(10), -- 위험등급코드
|
||||
PHYS_STATE_CD VARCHAR(20), -- 물리상태코드
|
||||
DNST NUMERIC(10,4), -- 밀도
|
||||
VAPOR_PRSR NUMERIC(12,4), -- 증기압
|
||||
WATER_SLBLT_CD VARCHAR(20), -- 수용성코드
|
||||
TOXICITY_CD VARCHAR(20), -- 독성코드
|
||||
SBST_DC TEXT, -- 물질설명
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
CONSTRAINT PK_HNS_SBST PRIMARY KEY (HNS_SBST_SN)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE HNS_SBST IS 'HNS물질정보';
|
||||
COMMENT ON COLUMN HNS_SBST.HNS_SBST_SN IS 'HNS물질순번';
|
||||
COMMENT ON COLUMN HNS_SBST.SBST_NM IS '물질명 (한글명)';
|
||||
COMMENT ON COLUMN HNS_SBST.SBST_ENG_NM IS '물질영문명';
|
||||
COMMENT ON COLUMN HNS_SBST.CAS_NO IS 'CAS번호 (화학물질등록번호)';
|
||||
COMMENT ON COLUMN HNS_SBST.UN_NO IS 'UN번호 (위험물 식별번호)';
|
||||
COMMENT ON COLUMN HNS_SBST.RISK_GRD_CD IS '위험등급코드';
|
||||
COMMENT ON COLUMN HNS_SBST.PHYS_STATE_CD IS '물리상태코드 (SOLID, LIQUID, GAS)';
|
||||
COMMENT ON COLUMN HNS_SBST.DNST IS '밀도 (g/cm³)';
|
||||
COMMENT ON COLUMN HNS_SBST.VAPOR_PRSR IS '증기압 (mmHg)';
|
||||
COMMENT ON COLUMN HNS_SBST.WATER_SLBLT_CD IS '수용성코드 (SOLUBLE, PARTIAL, INSOLUBLE)';
|
||||
COMMENT ON COLUMN HNS_SBST.TOXICITY_CD IS '독성코드 (HIGH, MEDIUM, LOW)';
|
||||
COMMENT ON COLUMN HNS_SBST.SBST_DC IS '물질설명';
|
||||
COMMENT ON COLUMN HNS_SBST.REG_DTM IS '등록일시';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 13. 인덱스
|
||||
-- ============================================================
|
||||
|
||||
-- 공간 인덱스 (GIST)
|
||||
CREATE INDEX IDX_ACDNT_LOC_GEOM ON ACDNT USING GIST(LOC_GEOM);
|
||||
CREATE INDEX IDX_SPIL_LOC_GEOM ON SPIL_DATA USING GIST(SPIL_LOC_GEOM);
|
||||
CREATE INDEX IDX_BOOM_LINE_GEOM ON BOOM_LINE USING GIST(LINE_GEOM);
|
||||
CREATE INDEX IDX_SECT_BGNG_GEOM ON CST_SECT USING GIST(BGNG_LOC_GEOM);
|
||||
CREATE INDEX IDX_SECT_END_GEOM ON CST_SECT USING GIST(END_LOC_GEOM);
|
||||
|
||||
-- 상태 코드 인덱스
|
||||
CREATE INDEX IDX_ACDNT_STTS ON ACDNT (ACDNT_STTS_CD);
|
||||
CREATE INDEX IDX_PRED_STTS ON PRED_EXEC (EXEC_STTS_CD);
|
||||
CREATE INDEX IDX_BOOM_STTS ON BOOM_LINE (DPLY_STTS_CD);
|
||||
CREATE INDEX IDX_SECT_SRVY_STTS ON CST_SECT (SRVY_STTS_CD);
|
||||
|
||||
-- 외래키 인덱스
|
||||
CREATE INDEX IDX_USER_ORG_SN ON USER_INFO (ORG_SN);
|
||||
CREATE INDEX IDX_ACDNT_RSPNS_ORG ON ACDNT (RSPNS_ORG_SN);
|
||||
CREATE INDEX IDX_SPIL_ACDNT_SN ON SPIL_DATA (ACDNT_SN);
|
||||
CREATE INDEX IDX_PRED_SPIL_SN ON PRED_EXEC (SPIL_DATA_SN);
|
||||
CREATE INDEX IDX_BOOM_ACDNT_SN ON BOOM_LINE (ACDNT_SN);
|
||||
CREATE INDEX IDX_SECT_ZONE_SN ON CST_SECT (CST_SRVY_ZONE_SN);
|
||||
CREATE INDEX IDX_SCAT_SECT_SN ON SCAT_SRVY (CST_SECT_SN);
|
||||
CREATE INDEX IDX_SCAT_ACDNT_SN ON SCAT_SRVY (ACDNT_SN);
|
||||
CREATE INDEX IDX_PHOTO_SRVY_SN ON SCAT_SRVY_PHOTO (SCAT_SRVY_SN);
|
||||
|
||||
-- 코드 테이블 인덱스
|
||||
CREATE INDEX IDX_CMN_CD_GRP_USE ON CMN_CD_GRP (USE_YN);
|
||||
CREATE INDEX IDX_CMN_CD_USE ON CMN_CD (USE_YN);
|
||||
|
||||
-- 텍스트 검색 인덱스
|
||||
CREATE INDEX IDX_ACDNT_NM_TRGM ON ACDNT USING GIN(ACDNT_NM gin_trgm_ops);
|
||||
CREATE INDEX IDX_HNS_SBST_NM_TRGM ON HNS_SBST USING GIN(SBST_NM gin_trgm_ops);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 14. 공통코드 데이터
|
||||
-- ============================================================
|
||||
|
||||
-- 조직유형 (ORG_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('ORG_TP', '조직유형', '조직의 유형을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('ORG_TP', 'HEADQUARTERS', '본청', 1),
|
||||
('ORG_TP', 'REGIONAL', '지방청', 2),
|
||||
('ORG_TP', 'STATION', '해양경찰서', 3),
|
||||
('ORG_TP', 'AGENCY', '유관기관', 4);
|
||||
|
||||
-- 사용자역할 (USER_ROLE)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('USER_ROLE', '사용자역할', '사용자의 시스템 역할을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('USER_ROLE', 'ADMIN', '관리자', 1),
|
||||
('USER_ROLE', 'MANAGER', '운영자', 2),
|
||||
('USER_ROLE', 'USER', '일반사용자', 3);
|
||||
|
||||
-- 사고유형 (ACDNT_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('ACDNT_TP', '사고유형', '해양사고의 유형을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('ACDNT_TP', 'COLLISION', '충돌', 1),
|
||||
('ACDNT_TP', 'GROUNDING', '좌초', 2),
|
||||
('ACDNT_TP', 'SINKING', '침몰', 3),
|
||||
('ACDNT_TP', 'LEAK', '누출', 4),
|
||||
('ACDNT_TP', 'EXPLOSION', '폭발', 5),
|
||||
('ACDNT_TP', 'ETC', '기타', 99);
|
||||
|
||||
-- 사고상태 (ACDNT_STTS)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('ACDNT_STTS', '사고상태', '사고의 진행 상태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('ACDNT_STTS', 'ACTIVE', '진행중', 1),
|
||||
('ACDNT_STTS', 'MONITORING', '감시중', 2),
|
||||
('ACDNT_STTS', 'CLOSED', '종료', 3);
|
||||
|
||||
-- 심각도 (SVRT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SVRT', '심각도', '사고의 심각도를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SVRT', 'DANGER', '위험', 1),
|
||||
('SVRT', 'ALERT', '경계', 2),
|
||||
('SVRT', 'CAUTION', '주의', 3),
|
||||
('SVRT', 'INTEREST', '관심', 4);
|
||||
|
||||
-- 유종 (OIL_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('OIL_TP', '유종', '유출유의 종류를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('OIL_TP', 'CRUDE', '원유', 1),
|
||||
('OIL_TP', 'BUNKER_C', '벙커C유', 2),
|
||||
('OIL_TP', 'DIESEL', '경유', 3),
|
||||
('OIL_TP', 'GASOLINE', '휘발유', 4),
|
||||
('OIL_TP', 'KEROSENE', '등유', 5),
|
||||
('OIL_TP', 'LUBE', '윤활유', 6),
|
||||
('OIL_TP', 'ETC', '기타', 99);
|
||||
|
||||
-- 유출단위 (SPIL_UNIT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SPIL_UNIT', '유출단위', '유출량의 측정 단위를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SPIL_UNIT', 'KL', '킬로리터', 1),
|
||||
('SPIL_UNIT', 'L', '리터', 2),
|
||||
('SPIL_UNIT', 'BBL', '배럴', 3),
|
||||
('SPIL_UNIT', 'TON', '톤', 4);
|
||||
|
||||
-- 유출유형 (SPIL_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SPIL_TP', '유출유형', '유출의 형태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SPIL_TP', 'CONTINUOUS', '연속유출', 1),
|
||||
('SPIL_TP', 'INSTANTANEOUS','순간유출', 2);
|
||||
|
||||
-- 예측알고리즘 (ALGO)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('ALGO', '예측알고리즘', '유출 확산 예측에 사용되는 알고리즘 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('ALGO', 'GNOME', 'GNOME 모델', 1),
|
||||
('ALGO', 'OSCAR', 'OSCAR 모델', 2),
|
||||
('ALGO', 'MOHID', 'MOHID 모델', 3);
|
||||
|
||||
-- 실행상태 (EXEC_STTS)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('EXEC_STTS', '실행상태', '예측 실행의 상태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('EXEC_STTS', 'PENDING', '대기', 1),
|
||||
('EXEC_STTS', 'RUNNING', '실행중', 2),
|
||||
('EXEC_STTS', 'COMPLETED', '완료', 3),
|
||||
('EXEC_STTS', 'FAILED', '실패', 4);
|
||||
|
||||
-- 오일펜스우선순위 (BOOM_PRIORT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('BOOM_PRIORT', '오일펜스우선순위', '오일펜스 배치 우선순위를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('BOOM_PRIORT', 'CRITICAL', '긴급', 1),
|
||||
('BOOM_PRIORT', 'HIGH', '높음', 2),
|
||||
('BOOM_PRIORT', 'MEDIUM', '보통', 3),
|
||||
('BOOM_PRIORT', 'LOW', '낮음', 4);
|
||||
|
||||
-- 오일펜스배치상태 (DPLY_STTS)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('DPLY_STTS', '배치상태', '오일펜스의 배치 상태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('DPLY_STTS', 'PLANNED', '계획', 1),
|
||||
('DPLY_STTS', 'DEPLOYING', '배치중', 2),
|
||||
('DPLY_STTS', 'DEPLOYED', '배치완료', 3),
|
||||
('DPLY_STTS', 'REMOVED', '회수', 4);
|
||||
|
||||
-- 해안유형 (CST_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('CST_TP', '해안유형', '해안의 지형 유형을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('CST_TP', 'ROCK', '암반해안', 1),
|
||||
('CST_TP', 'GRAVEL', '자갈해안', 2),
|
||||
('CST_TP', 'SAND', '모래해안', 3),
|
||||
('CST_TP', 'MUD', '갯벌', 4),
|
||||
('CST_TP', 'MIXED', '혼합해안', 5),
|
||||
('CST_TP', 'SEAWALL', '인공구조물', 6),
|
||||
('CST_TP', 'TETRAPOD', '테트라포드', 7);
|
||||
|
||||
-- 민감도 (SNSTVT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SNSTVT', '민감도', '해안구간의 환경 민감도를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SNSTVT', 'HIGH', '고', 1),
|
||||
('SNSTVT', 'MEDIUM', '중', 2),
|
||||
('SNSTVT', 'LOW', '저', 3);
|
||||
|
||||
-- 조사상태 (SRVY_STTS)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SRVY_STTS', '조사상태', '해안구간 조사의 진행 상태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SRVY_STTS', 'PENDING', '미조사', 1),
|
||||
('SRVY_STTS', 'PROGRESS', '진행중', 2),
|
||||
('SRVY_STTS', 'COMPLETED', '완료', 3);
|
||||
|
||||
-- 조사유형 (SRVY_TP)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SRVY_TP', '조사유형', 'SCAT 조사의 유형을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SRVY_TP', 'PRE_SCAT', 'Pre-SCAT', 1),
|
||||
('SRVY_TP', 'SCAT', 'SCAT', 2),
|
||||
('SRVY_TP', 'POST_SCAT', 'Post-SCAT', 3);
|
||||
|
||||
-- 조사단계 (SRVY_PHASE)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('SRVY_PHASE', '조사단계', 'SCAT 조사의 단계를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('SRVY_PHASE', 'PHASE_1', '1차 조사', 1),
|
||||
('SRVY_PHASE', 'PHASE_2', '2차 조사', 2),
|
||||
('SRVY_PHASE', 'PHASE_3', '3차 조사', 3);
|
||||
|
||||
-- 오염도 (POLUT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('POLUT', '오염도', '해안구간의 오염 정도를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('POLUT', 'HEAVY', '심각', 1),
|
||||
('POLUT', 'MODERATE', '보통', 2),
|
||||
('POLUT', 'LIGHT', '경미', 3),
|
||||
('POLUT', 'CLEAN', '깨끗', 4);
|
||||
|
||||
-- 물리상태 (PHYS_STATE)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('PHYS_STATE', '물리상태', '화학물질의 물리적 상태를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('PHYS_STATE', 'SOLID', '고체', 1),
|
||||
('PHYS_STATE', 'LIQUID', '액체', 2),
|
||||
('PHYS_STATE', 'GAS', '기체', 3);
|
||||
|
||||
-- 수용성 (WATER_SLBLT)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('WATER_SLBLT', '수용성', '화학물질의 수용성 정도를 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('WATER_SLBLT', 'SOLUBLE', '수용성', 1),
|
||||
('WATER_SLBLT', 'PARTIAL', '부분용해', 2),
|
||||
('WATER_SLBLT', 'INSOLUBLE', '불용성', 3);
|
||||
|
||||
-- 독성 (TOXICITY)
|
||||
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
|
||||
('TOXICITY', '독성', '화학물질의 독성 수준을 구분하는 코드');
|
||||
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
|
||||
('TOXICITY', 'HIGH', '고독성', 1),
|
||||
('TOXICITY', 'MEDIUM', '중독성', 2),
|
||||
('TOXICITY', 'LOW', '저독성', 3);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 15. 샘플 데이터
|
||||
-- ============================================================
|
||||
|
||||
-- 조직
|
||||
INSERT INTO ORG (ORG_NM, ORG_ABBR_NM, ORG_TP_CD) VALUES
|
||||
('해양경찰청', '해경청', 'HEADQUARTERS'),
|
||||
('남해지방해양경찰청', '남해청', 'REGIONAL'),
|
||||
('제주지방해양경찰청', '제주청', 'REGIONAL'),
|
||||
('여수해양경찰서', '여수서', 'STATION'),
|
||||
('서귀포해양경찰서', '서귀포서', 'STATION'),
|
||||
('제주해양경찰서', '제주서', 'STATION');
|
||||
|
||||
-- 상위조직 설정
|
||||
UPDATE ORG SET UPPER_ORG_SN = 1 WHERE ORG_SN IN (2, 3);
|
||||
UPDATE ORG SET UPPER_ORG_SN = 2 WHERE ORG_SN = 4;
|
||||
UPDATE ORG SET UPPER_ORG_SN = 3 WHERE ORG_SN IN (5, 6);
|
||||
|
||||
-- 관리자 (비밀번호: admin1234)
|
||||
INSERT INTO USER_INFO (USER_ACNT, PSWD_HASH, USER_NM, RNKP_NM, ORG_SN, ROLE_CD) VALUES
|
||||
('admin', '$2b$10$rN3qJ9qZ5yH.KfxYvYvW0.VxJ8K5vN8qJ9qZ5yH.KfxYvYvW0.VxJ8', '관리자', '경정', 1, 'ADMIN');
|
||||
|
||||
-- 샘플 사고 (여수 앞바다)
|
||||
INSERT INTO ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, LOC_GEOM, LOC_DC, OCCRN_DTM, RSPNS_ORG_SN, SVRT_CD) VALUES
|
||||
('INC-2025-0042', '여수 앞바다 유조선 충돌', 'COLLISION',
|
||||
ST_SetSRID(ST_MakePoint(127.8, 34.5), 4326),
|
||||
'여수 돌산 남방 5NM', '2025-02-10 06:30:00+09', 4, 'DANGER');
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 데이터베이스 코멘트
|
||||
-- ============================================================
|
||||
COMMENT ON DATABASE wing IS 'WING 해양환경 위기대응 통합시스템 - 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 적용';
|
||||
39
docker-compose.yml
Executable file
@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
container_name: wing-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: wing
|
||||
POSTGRES_USER: wing_admin
|
||||
POSTGRES_PASSWORD: wing_secure_2026
|
||||
TZ: Asia/Seoul
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- wing_data:/var/lib/postgresql/data
|
||||
- ./database/database_init.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wing_admin -d wing"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: wing-pgadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@wing.kr
|
||||
PGADMIN_DEFAULT_PASSWORD: admin1234
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
wing_data:
|
||||
driver: local
|
||||
186
docs/INSTALL_GUIDE.md
Executable file
@ -0,0 +1,186 @@
|
||||
# WING 해양환경 위기대응 통합시스템 - 설치 매뉴얼
|
||||
|
||||
## 1. 필수 소프트웨어
|
||||
|
||||
| 소프트웨어 | 최소 버전 | 용도 | 다운로드 |
|
||||
|-----------|----------|------|---------|
|
||||
| Node.js | v20 이상 (권장 v25) | 프론트엔드/백엔드 실행 | https://nodejs.org |
|
||||
| npm | v10 이상 | 패키지 관리 | Node.js에 포함 |
|
||||
| Docker Desktop | v4.0 이상 | PostgreSQL + pgAdmin 실행 | https://www.docker.com/products/docker-desktop |
|
||||
|
||||
> **오프라인 환경**: 인터넷이 안 되는 망에서는 `node_modules`가 포함된 압축 파일을 사용하세요 (아래 "오프라인 설치" 참고).
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 구조
|
||||
|
||||
```
|
||||
wing/
|
||||
├── frontend/ # React + Vite 프론트엔드 (포트 5173)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI 컴포넌트
|
||||
│ │ ├── data/ # 정적 데이터
|
||||
│ │ ├── hooks/ # 커스텀 훅
|
||||
│ │ ├── types/ # TypeScript 타입 정의
|
||||
│ │ ├── utils/ # 유틸리티 함수
|
||||
│ │ └── store/ # 상태관리
|
||||
│ └── package.json
|
||||
├── backend/ # Express 백엔드 API (포트 3001)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API 라우트
|
||||
│ │ ├── middleware/ # 미들웨어 (보안 등)
|
||||
│ │ ├── db/ # DB 시드 데이터
|
||||
│ │ └── server.ts # 서버 엔트리
|
||||
│ └── package.json
|
||||
├── database/ # DB 초기화 SQL
|
||||
│ ├── database_init.sql
|
||||
│ └── init.sql
|
||||
├── docker-compose.yml # PostgreSQL + pgAdmin 컨테이너
|
||||
└── INSTALL_GUIDE.md # 이 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 온라인 설치 (인터넷 가능한 환경)
|
||||
|
||||
### 3-1. 의존성 설치
|
||||
|
||||
```bash
|
||||
# 프론트엔드
|
||||
cd wing/frontend
|
||||
npm install
|
||||
|
||||
# 백엔드
|
||||
cd ../backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3-2. 데이터베이스 실행 (Docker)
|
||||
|
||||
```bash
|
||||
cd wing
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
실행 확인:
|
||||
- PostgreSQL: `localhost:5432` (ID: `wing_admin` / PW: `wing_secure_2026`)
|
||||
- pgAdmin: `http://localhost:5050` (ID: `admin@wing.kr` / PW: `admin1234`)
|
||||
|
||||
### 3-3. 백엔드 실행
|
||||
|
||||
```bash
|
||||
cd wing/backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
→ `http://localhost:3001` 에서 API 서버 시작
|
||||
|
||||
### 3-4. 프론트엔드 실행
|
||||
|
||||
```bash
|
||||
cd wing/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
→ `http://localhost:5173` 에서 웹 앱 시작
|
||||
|
||||
---
|
||||
|
||||
## 4. 오프라인 설치 (폐쇄망/다른 망)
|
||||
|
||||
인터넷이 안 되는 환경에서는 `npm install`이 불가능합니다.
|
||||
이 경우 **node_modules 포함 압축 파일**을 사용하세요.
|
||||
|
||||
### 4-1. 압축 해제
|
||||
|
||||
```bash
|
||||
# wing_full.tar.gz 파일을 작업 폴더에 복사한 뒤:
|
||||
tar -xzf wing_full.tar.gz
|
||||
```
|
||||
|
||||
### 4-2. Node.js 설치
|
||||
|
||||
대상 PC에 Node.js가 없으면 오프라인 설치 파일(.msi 또는 .pkg)을 미리 준비하여 설치합니다.
|
||||
|
||||
- Windows: `node-v25.x.x-x64.msi`
|
||||
- macOS: `node-v25.x.x.pkg`
|
||||
|
||||
### 4-3. Docker 이미지 (선택)
|
||||
|
||||
DB를 Docker로 실행하려면 이미지를 미리 저장해서 가져갑니다.
|
||||
|
||||
**원래 PC에서 내보내기:**
|
||||
```bash
|
||||
docker pull postgis/postgis:16-3.4
|
||||
docker pull dpage/pgadmin4:latest
|
||||
docker save postgis/postgis:16-3.4 dpage/pgadmin4:latest -o wing_docker_images.tar
|
||||
```
|
||||
|
||||
**대상 PC에서 불러오기:**
|
||||
```bash
|
||||
docker load -i wing_docker_images.tar
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> Docker 없이도 PostgreSQL을 직접 설치하여 `database/database_init.sql`을 실행하면 됩니다.
|
||||
|
||||
### 4-4. 실행
|
||||
|
||||
node_modules가 이미 포함되어 있으므로 바로 실행 가능합니다.
|
||||
|
||||
```bash
|
||||
# 터미널 1 - 백엔드
|
||||
cd wing/backend
|
||||
npm run dev
|
||||
|
||||
# 터미널 2 - 프론트엔드
|
||||
cd wing/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 접속 정보 요약
|
||||
|
||||
| 서비스 | URL | 비고 |
|
||||
|--------|-----|------|
|
||||
| 프론트엔드 (WING) | http://localhost:5173 | Vite dev server |
|
||||
| 백엔드 API | http://localhost:3001 | Express |
|
||||
| PostgreSQL | localhost:5432 | DB: `wing`, User: `wing_admin` |
|
||||
| pgAdmin | http://localhost:5050 | Email: `admin@wing.kr` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 주요 명령어
|
||||
|
||||
```bash
|
||||
# 프론트엔드 빌드 (배포용)
|
||||
cd frontend && npm run build # dist/ 폴더에 정적 파일 생성
|
||||
|
||||
# 백엔드 빌드
|
||||
cd backend && npm run build # dist/ 폴더에 JS 생성
|
||||
|
||||
# DB 시드 데이터 입력
|
||||
cd backend && npm run db:seed
|
||||
|
||||
# TypeScript 타입 체크
|
||||
cd frontend && npx tsc --noEmit
|
||||
|
||||
# Docker 중지
|
||||
docker compose down
|
||||
|
||||
# Docker 데이터 포함 삭제
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 트러블슈팅
|
||||
|
||||
| 증상 | 해결 |
|
||||
|------|------|
|
||||
| `npm run dev` 실행 시 포트 충돌 | `lsof -i :5173` 또는 `lsof -i :3001`로 확인 후 프로세스 종료 |
|
||||
| `EACCES` 권한 오류 | `sudo chown -R $(whoami) wing/` |
|
||||
| Docker 컨테이너 안 뜸 | `docker compose logs` 로 로그 확인 |
|
||||
| 프론트엔드에서 API 호출 실패 | 백엔드(`localhost:3001`)가 실행 중인지 확인 |
|
||||
| `MODULE_NOT_FOUND` 오류 | `npm install` 재실행 (온라인) 또는 node_modules 포함 압축본 사용 |
|
||||
153
docs/README.md
Executable file
@ -0,0 +1,153 @@
|
||||
# Wing - 해양 레이어 관리 시스템
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
wing/
|
||||
├── backend/ # Express + SQLite 백엔드
|
||||
│ ├── src/
|
||||
│ │ ├── db/ # 데이터베이스 설정 및 시드
|
||||
│ │ ├── routes/ # API 라우트
|
||||
│ │ └── server.ts # 메인 서버
|
||||
│ └── data/ # SQLite 데이터베이스 파일
|
||||
├── frontend/ # React + Vite 프론트엔드
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── data/ # 레이어 데이터 타입
|
||||
│ ├── hooks/ # React Query 훅
|
||||
│ └── services/ # API 클라이언트
|
||||
└── LayerList.csv # 원본 레이어 데이터
|
||||
```
|
||||
|
||||
## 설치 및 실행
|
||||
|
||||
### 1. 백엔드 설정
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 데이터베이스 시드 (CSV를 DB로 변환)
|
||||
npm run db:seed
|
||||
|
||||
# 개발 서버 실행
|
||||
npm run dev
|
||||
```
|
||||
|
||||
백엔드 서버는 `http://localhost:3001`에서 실행됩니다.
|
||||
|
||||
### 2. 프론트엔드 설정
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 의존성 설치 (아직 안 했다면)
|
||||
npm install
|
||||
|
||||
# 개발 서버 실행
|
||||
npm run dev
|
||||
```
|
||||
|
||||
프론트엔드는 `http://localhost:5173`에서 실행됩니다.
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 레이어 관리
|
||||
|
||||
- `GET /api/layers` - 모든 레이어 조회
|
||||
- `GET /api/layers/:id` - 특정 레이어 조회
|
||||
- `GET /api/layers/tree/all` - 계층 구조 레이어 트리 조회
|
||||
- `GET /api/layers/wms/all` - WMS 레이어만 조회
|
||||
- `GET /api/layers/level/:level` - 특정 레벨의 레이어 조회
|
||||
- `GET /api/layers/children/:parentId` - 특정 부모의 자식 레이어 조회
|
||||
|
||||
## 데이터베이스 스키마
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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, -- 레벨 (1-7)
|
||||
clnm TEXT, -- WMS 레이어 이름
|
||||
FOREIGN KEY (up_cmn_cd) REFERENCES layers(cmn_cd)
|
||||
);
|
||||
```
|
||||
|
||||
## 프론트엔드에서 사용하기
|
||||
|
||||
### React Query 훅 사용
|
||||
|
||||
```typescript
|
||||
import { useLayers, useWMSLayers } from './hooks/useLayers'
|
||||
|
||||
function MyComponent() {
|
||||
const { data: layers, isLoading, error } = useLayers()
|
||||
const { data: wmsLayers } = useWMSLayers()
|
||||
|
||||
if (isLoading) return <div>로딩 중...</div>
|
||||
if (error) return <div>오류: {error.message}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{layers?.map(layer => (
|
||||
<div key={layer.id}>{layer.name}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### API 직접 호출
|
||||
|
||||
```typescript
|
||||
import { fetchAllLayers, fetchWMSLayers } from './services/api'
|
||||
|
||||
async function loadLayers() {
|
||||
const layers = await fetchAllLayers()
|
||||
const wmsLayers = await fetchWMSLayers()
|
||||
console.log(layers, wmsLayers)
|
||||
}
|
||||
```
|
||||
|
||||
## CSV 데이터 업데이트
|
||||
|
||||
LayerList.csv 파일을 수정한 후:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
이렇게 하면 데이터베이스가 새 CSV 데이터로 업데이트됩니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### 백엔드
|
||||
- Node.js + TypeScript
|
||||
- Express.js
|
||||
- Better-SQLite3
|
||||
|
||||
### 프론트엔드
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Vite
|
||||
- React Query (TanStack Query)
|
||||
- Axios
|
||||
- Leaflet (지도)
|
||||
- Tailwind CSS
|
||||
|
||||
## 환경 변수
|
||||
|
||||
### 프론트엔드 (.env)
|
||||
```
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
```
|
||||
|
||||
### 백엔드
|
||||
```
|
||||
PORT=3001 # 기본값
|
||||
```
|
||||
7
frontend/.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
|
||||
# 기상청 / 국립해양조사원 API Key (https://www.data.go.kr)
|
||||
VITE_DATA_GO_KR_API_KEY=your_api_key_here
|
||||
|
||||
# AIS 선박 위치 API Key
|
||||
VITE_AIS_API_KEY=your_api_key_here
|
||||
73
frontend/README.md
Executable file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Executable file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
frontend/index.html
Executable file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4816
frontend/package-lock.json
generated
Executable file
42
frontend/package.json
Executable file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.5",
|
||||
"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",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Executable file
@ -0,0 +1,6 @@
|
||||
import tailwindcss from 'tailwindcss'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
|
||||
export default {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
}
|
||||
561
frontend/public/hns-manual/chapters.json
Executable file
596
frontend/public/hns-manual/image-manifest.json
Executable file
@ -0,0 +1,596 @@
|
||||
{
|
||||
"1": [
|
||||
"p001_img01.jpg",
|
||||
"p001_img02.jpg",
|
||||
"p001_img03.jpg",
|
||||
"p001_img04.jpg",
|
||||
"p001_img05.jpg",
|
||||
"p001_img06.jpg"
|
||||
],
|
||||
"2": [
|
||||
"p002_img02.jpg",
|
||||
"p002_img03.jpg"
|
||||
],
|
||||
"5": [
|
||||
"p005_img01.jpg",
|
||||
"p005_img02.jpg",
|
||||
"p005_img03.jpg"
|
||||
],
|
||||
"17": [
|
||||
"p017_img01.jpg",
|
||||
"p017_img02.jpg",
|
||||
"p017_img03.jpg",
|
||||
"p017_img04.jpg",
|
||||
"p017_img05.jpg",
|
||||
"p017_img06.jpg",
|
||||
"p017_img10.jpg",
|
||||
"p017_img12.jpg",
|
||||
"p017_img13.jpg",
|
||||
"p017_img14.jpg"
|
||||
],
|
||||
"19": [
|
||||
"p019_img01.jpg",
|
||||
"p019_img02.jpg"
|
||||
],
|
||||
"21": [
|
||||
"p021_img01.jpg"
|
||||
],
|
||||
"22": [
|
||||
"p022_img02.jpg",
|
||||
"p022_img03.jpg",
|
||||
"p022_img04.jpg",
|
||||
"p022_img07.jpg",
|
||||
"p022_img08.jpg",
|
||||
"p022_img10.jpg",
|
||||
"p022_img11.jpg",
|
||||
"p022_img12.jpg",
|
||||
"p022_img13.jpg",
|
||||
"p022_img16.jpg",
|
||||
"p022_img17.jpg"
|
||||
],
|
||||
"23": [
|
||||
"p023_img01.jpg"
|
||||
],
|
||||
"24": [
|
||||
"p024_img01.jpg"
|
||||
],
|
||||
"25": [
|
||||
"p025_img01.jpg",
|
||||
"p025_img04.jpg",
|
||||
"p025_img07.jpg",
|
||||
"p025_img08.jpg",
|
||||
"p025_img11.jpg",
|
||||
"p025_img12.jpg",
|
||||
"p025_img14.jpg"
|
||||
],
|
||||
"26": [
|
||||
"p026_img01.jpg"
|
||||
],
|
||||
"27": [
|
||||
"p027_img01.jpg",
|
||||
"p027_img03.jpg",
|
||||
"p027_img04.jpg",
|
||||
"p027_img05.jpg",
|
||||
"p027_img06.jpg",
|
||||
"p027_img07.jpg",
|
||||
"p027_img08.jpg",
|
||||
"p027_img09.jpg",
|
||||
"p027_img10.jpg",
|
||||
"p027_img12.jpg",
|
||||
"p027_img14.jpg",
|
||||
"p027_img17.jpg",
|
||||
"p027_img18.jpg",
|
||||
"p027_img19.jpg"
|
||||
],
|
||||
"28": [
|
||||
"p028_img01.jpg"
|
||||
],
|
||||
"30": [
|
||||
"p030_img01.jpg",
|
||||
"p030_img02.jpg"
|
||||
],
|
||||
"32": [
|
||||
"p032_img01.jpg",
|
||||
"p032_img02.jpg"
|
||||
],
|
||||
"33": [
|
||||
"p033_img01.jpg"
|
||||
],
|
||||
"34": [
|
||||
"p034_img01.jpg",
|
||||
"p034_img02.jpg",
|
||||
"p034_img06.jpg",
|
||||
"p034_img10.jpg",
|
||||
"p034_img12.jpg",
|
||||
"p034_img13.jpg",
|
||||
"p034_img16.jpg",
|
||||
"p034_img17.jpg"
|
||||
],
|
||||
"35": [
|
||||
"p035_img02.jpg",
|
||||
"p035_img03.jpg",
|
||||
"p035_img04.jpg"
|
||||
],
|
||||
"36": [
|
||||
"p036_img03.jpg"
|
||||
],
|
||||
"37": [
|
||||
"p037_img01.jpg",
|
||||
"p037_img02.jpg"
|
||||
],
|
||||
"41": [
|
||||
"p041_img01.jpg"
|
||||
],
|
||||
"43": [
|
||||
"p043_img01.jpg"
|
||||
],
|
||||
"45": [
|
||||
"p045_img01.jpg",
|
||||
"p045_img02.jpg",
|
||||
"p045_img03.jpg"
|
||||
],
|
||||
"46": [
|
||||
"p046_img01.jpg"
|
||||
],
|
||||
"47": [
|
||||
"p047_img01.jpg"
|
||||
],
|
||||
"48": [
|
||||
"p048_img01.jpg"
|
||||
],
|
||||
"49": [
|
||||
"p049_img01.jpg"
|
||||
],
|
||||
"51": [
|
||||
"p051_img01.jpg"
|
||||
],
|
||||
"53": [
|
||||
"p053_img01.jpg"
|
||||
],
|
||||
"56": [
|
||||
"p056_img01.jpg"
|
||||
],
|
||||
"60": [
|
||||
"p060_img01.jpg"
|
||||
],
|
||||
"61": [
|
||||
"p061_img01.jpg"
|
||||
],
|
||||
"62": [
|
||||
"p062_img01.jpg"
|
||||
],
|
||||
"63": [
|
||||
"p063_img01.jpg"
|
||||
],
|
||||
"65": [
|
||||
"p065_img01.jpg"
|
||||
],
|
||||
"66": [
|
||||
"p066_img01.jpg",
|
||||
"p066_img02.jpg"
|
||||
],
|
||||
"69": [
|
||||
"p069_img01.jpg",
|
||||
"p069_img02.jpg"
|
||||
],
|
||||
"74": [
|
||||
"p074_img01.jpg",
|
||||
"p074_img02.jpg",
|
||||
"p074_img05.jpg"
|
||||
],
|
||||
"78": [
|
||||
"p078_img01.jpg"
|
||||
],
|
||||
"79": [
|
||||
"p079_img01.jpg"
|
||||
],
|
||||
"80": [
|
||||
"p080_img01.jpg"
|
||||
],
|
||||
"82": [
|
||||
"p082_img01.jpg",
|
||||
"p082_img02.jpg"
|
||||
],
|
||||
"84": [
|
||||
"p084_img01.jpg"
|
||||
],
|
||||
"85": [
|
||||
"p085_img01.jpg"
|
||||
],
|
||||
"88": [
|
||||
"p088_img01.jpg",
|
||||
"p088_img02.jpg"
|
||||
],
|
||||
"90": [
|
||||
"p090_img01.jpg"
|
||||
],
|
||||
"92": [
|
||||
"p092_img01.jpg"
|
||||
],
|
||||
"93": [
|
||||
"p093_img01.jpg"
|
||||
],
|
||||
"99": [
|
||||
"p099_img01.jpg"
|
||||
],
|
||||
"100": [
|
||||
"p100_img01.jpg"
|
||||
],
|
||||
"102": [
|
||||
"p102_img01.jpg"
|
||||
],
|
||||
"108": [
|
||||
"p108_img03.jpg",
|
||||
"p108_img07.jpg",
|
||||
"p108_img09.jpg"
|
||||
],
|
||||
"110": [
|
||||
"p110_img01.jpg"
|
||||
],
|
||||
"111": [
|
||||
"p111_img01.jpg"
|
||||
],
|
||||
"114": [
|
||||
"p114_img01.jpg"
|
||||
],
|
||||
"117": [
|
||||
"p117_img01.jpg"
|
||||
],
|
||||
"121": [
|
||||
"p121_img01.jpg",
|
||||
"p121_img02.jpg"
|
||||
],
|
||||
"122": [
|
||||
"p122_img01.jpg"
|
||||
],
|
||||
"127": [
|
||||
"p127_img01.jpg"
|
||||
],
|
||||
"129": [
|
||||
"p129_img01.jpg"
|
||||
],
|
||||
"130": [
|
||||
"p130_img01.jpg"
|
||||
],
|
||||
"133": [
|
||||
"p133_img01.jpg",
|
||||
"p133_img02.jpg",
|
||||
"p133_img10.jpg",
|
||||
"p133_img12.jpg",
|
||||
"p133_img19.jpg",
|
||||
"p133_img20.jpg"
|
||||
],
|
||||
"134": [
|
||||
"p134_img01.jpg",
|
||||
"p134_img02.jpg",
|
||||
"p134_img03.jpg",
|
||||
"p134_img04.jpg",
|
||||
"p134_img05.jpg",
|
||||
"p134_img06.jpg"
|
||||
],
|
||||
"135": [
|
||||
"p135_img01.jpg"
|
||||
],
|
||||
"136": [
|
||||
"p136_img01.jpg"
|
||||
],
|
||||
"140": [
|
||||
"p140_img02.jpg"
|
||||
],
|
||||
"141": [
|
||||
"p141_img01.jpg"
|
||||
],
|
||||
"143": [
|
||||
"p143_img03.jpg",
|
||||
"p143_img04.jpg",
|
||||
"p143_img07.jpg"
|
||||
],
|
||||
"144": [
|
||||
"p144_img01.jpg"
|
||||
],
|
||||
"150": [
|
||||
"p150_img01.jpg"
|
||||
],
|
||||
"151": [
|
||||
"p151_img01.jpg"
|
||||
],
|
||||
"155": [
|
||||
"p155_img01.jpg"
|
||||
],
|
||||
"156": [
|
||||
"p156_img01.jpg",
|
||||
"p156_img02.jpg",
|
||||
"p156_img03.jpg",
|
||||
"p156_img04.jpg",
|
||||
"p156_img05.jpg"
|
||||
],
|
||||
"158": [
|
||||
"p158_img01.jpg"
|
||||
],
|
||||
"160": [
|
||||
"p160_img01.jpg",
|
||||
"p160_img02.jpg"
|
||||
],
|
||||
"161": [
|
||||
"p161_img01.jpg",
|
||||
"p161_img02.jpg"
|
||||
],
|
||||
"170": [
|
||||
"p170_img05.jpg"
|
||||
],
|
||||
"180": [
|
||||
"p180_img01.jpg",
|
||||
"p180_img03.jpg"
|
||||
],
|
||||
"181": [
|
||||
"p181_img05.jpg",
|
||||
"p181_img06.jpg"
|
||||
],
|
||||
"190": [
|
||||
"p190_img01.jpg"
|
||||
],
|
||||
"192": [
|
||||
"p192_img01.jpg",
|
||||
"p192_img02.jpg"
|
||||
],
|
||||
"195": [
|
||||
"p195_img01.jpg"
|
||||
],
|
||||
"196": [
|
||||
"p196_img01.jpg",
|
||||
"p196_img02.jpg",
|
||||
"p196_img03.jpg"
|
||||
],
|
||||
"198": [
|
||||
"p198_img01.jpg"
|
||||
],
|
||||
"200": [
|
||||
"p200_img01.jpg"
|
||||
],
|
||||
"202": [
|
||||
"p202_img01.jpg"
|
||||
],
|
||||
"204": [
|
||||
"p204_img01.jpg"
|
||||
],
|
||||
"206": [
|
||||
"p206_img01.jpg"
|
||||
],
|
||||
"207": [
|
||||
"p207_img01.jpg"
|
||||
],
|
||||
"208": [
|
||||
"p208_img01.jpg"
|
||||
],
|
||||
"209": [
|
||||
"p209_img01.jpg",
|
||||
"p209_img02.jpg"
|
||||
],
|
||||
"210": [
|
||||
"p210_img01.jpg"
|
||||
],
|
||||
"212": [
|
||||
"p212_img01.jpg",
|
||||
"p212_img02.jpg",
|
||||
"p212_img03.jpg",
|
||||
"p212_img04.jpg"
|
||||
],
|
||||
"213": [
|
||||
"p213_img01.jpg"
|
||||
],
|
||||
"214": [
|
||||
"p214_img01.jpg",
|
||||
"p214_img02.jpg",
|
||||
"p214_img03.jpg",
|
||||
"p214_img04.jpg"
|
||||
],
|
||||
"217": [
|
||||
"p217_img01.jpg"
|
||||
],
|
||||
"219": [
|
||||
"p219_img01.jpg",
|
||||
"p219_img02.jpg",
|
||||
"p219_img03.jpg",
|
||||
"p219_img04.jpg"
|
||||
],
|
||||
"226": [
|
||||
"p226_img01.jpg",
|
||||
"p226_img02.jpg"
|
||||
],
|
||||
"227": [
|
||||
"p227_img01.jpg"
|
||||
],
|
||||
"228": [
|
||||
"p228_img02.jpg"
|
||||
],
|
||||
"229": [
|
||||
"p229_img01.jpg"
|
||||
],
|
||||
"230": [
|
||||
"p230_img01.jpg"
|
||||
],
|
||||
"231": [
|
||||
"p231_img09.jpg"
|
||||
],
|
||||
"236": [
|
||||
"p236_img03.jpg"
|
||||
],
|
||||
"237": [
|
||||
"p237_img01.jpg",
|
||||
"p237_img02.jpg"
|
||||
],
|
||||
"238": [
|
||||
"p238_img01.jpg",
|
||||
"p238_img02.jpg",
|
||||
"p238_img03.jpg",
|
||||
"p238_img04.jpg",
|
||||
"p238_img05.jpg",
|
||||
"p238_img06.jpg"
|
||||
],
|
||||
"239": [
|
||||
"p239_img01.jpg",
|
||||
"p239_img02.jpg",
|
||||
"p239_img03.jpg"
|
||||
],
|
||||
"242": [
|
||||
"p242_img02.jpg",
|
||||
"p242_img03.jpg",
|
||||
"p242_img04.jpg"
|
||||
],
|
||||
"244": [
|
||||
"p244_img01.jpg"
|
||||
],
|
||||
"245": [
|
||||
"p245_img01.jpg"
|
||||
],
|
||||
"248": [
|
||||
"p248_img01.jpg",
|
||||
"p248_img02.jpg",
|
||||
"p248_img03.jpg",
|
||||
"p248_img04.jpg",
|
||||
"p248_img05.jpg",
|
||||
"p248_img08.jpg",
|
||||
"p248_img09.jpg",
|
||||
"p248_img10.jpg",
|
||||
"p248_img11.jpg",
|
||||
"p248_img12.jpg",
|
||||
"p248_img13.jpg",
|
||||
"p248_img14.jpg"
|
||||
],
|
||||
"249": [
|
||||
"p249_img01.jpg"
|
||||
],
|
||||
"250": [
|
||||
"p250_img01.jpg"
|
||||
],
|
||||
"254": [
|
||||
"p254_img01.jpg"
|
||||
],
|
||||
"257": [
|
||||
"p257_img01.jpg",
|
||||
"p257_img02.jpg",
|
||||
"p257_img03.jpg",
|
||||
"p257_img04.jpg"
|
||||
],
|
||||
"259": [
|
||||
"p259_img01.jpg"
|
||||
],
|
||||
"262": [
|
||||
"p262_img01.jpg"
|
||||
],
|
||||
"263": [
|
||||
"p263_img04.jpg"
|
||||
],
|
||||
"264": [
|
||||
"p264_img01.jpg",
|
||||
"p264_img02.jpg"
|
||||
],
|
||||
"266": [
|
||||
"p266_img01.jpg",
|
||||
"p266_img02.jpg"
|
||||
],
|
||||
"267": [
|
||||
"p267_img03.jpg",
|
||||
"p267_img04.jpg",
|
||||
"p267_img05.jpg"
|
||||
],
|
||||
"268": [
|
||||
"p268_img01.jpg"
|
||||
],
|
||||
"272": [
|
||||
"p272_img01.jpg",
|
||||
"p272_img02.jpg",
|
||||
"p272_img03.jpg",
|
||||
"p272_img04.jpg"
|
||||
],
|
||||
"273": [
|
||||
"p273_img01.jpg"
|
||||
],
|
||||
"274": [
|
||||
"p274_img01.jpg"
|
||||
],
|
||||
"275": [
|
||||
"p275_img01.jpg",
|
||||
"p275_img02.jpg"
|
||||
],
|
||||
"276": [
|
||||
"p276_img01.jpg",
|
||||
"p276_img02.jpg"
|
||||
],
|
||||
"278": [
|
||||
"p278_img01.jpg",
|
||||
"p278_img02.jpg",
|
||||
"p278_img03.jpg"
|
||||
],
|
||||
"279": [
|
||||
"p279_img01.jpg"
|
||||
],
|
||||
"280": [
|
||||
"p280_img01.jpg"
|
||||
],
|
||||
"281": [
|
||||
"p281_img01.jpg"
|
||||
],
|
||||
"283": [
|
||||
"p283_img01.jpg",
|
||||
"p283_img02.jpg",
|
||||
"p283_img03.jpg",
|
||||
"p283_img04.jpg",
|
||||
"p283_img05.jpg"
|
||||
],
|
||||
"286": [
|
||||
"p286_img01.jpg",
|
||||
"p286_img02.jpg"
|
||||
],
|
||||
"287": [
|
||||
"p287_img01.jpg",
|
||||
"p287_img02.jpg",
|
||||
"p287_img03.jpg",
|
||||
"p287_img04.jpg"
|
||||
],
|
||||
"290": [
|
||||
"p290_img01.jpg",
|
||||
"p290_img02.jpg",
|
||||
"p290_img03.jpg"
|
||||
],
|
||||
"293": [
|
||||
"p293_img03.jpg"
|
||||
],
|
||||
"294": [
|
||||
"p294_img01.jpg",
|
||||
"p294_img02.jpg",
|
||||
"p294_img03.jpg",
|
||||
"p294_img04.jpg"
|
||||
],
|
||||
"298": [
|
||||
"p298_img01.jpg",
|
||||
"p298_img02.jpg"
|
||||
],
|
||||
"306": [
|
||||
"p306_img01.jpg",
|
||||
"p306_img02.jpg"
|
||||
],
|
||||
"307": [
|
||||
"p307_img03.jpg"
|
||||
],
|
||||
"309": [
|
||||
"p309_img01.jpg",
|
||||
"p309_img02.jpg"
|
||||
],
|
||||
"312": [
|
||||
"p312_img01.jpg"
|
||||
],
|
||||
"314": [
|
||||
"p314_img01.jpg"
|
||||
],
|
||||
"315": [
|
||||
"p315_img01.jpg"
|
||||
],
|
||||
"316": [
|
||||
"p316_img01.jpg"
|
||||
],
|
||||
"337": [
|
||||
"p337_img01.jpg",
|
||||
"p337_img02.jpg"
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/scat-photos/JJAW-1-1.png
Executable file
|
After Width: | Height: | 크기: 113 KiB |
BIN
frontend/public/scat-photos/JJAW-10-1.png
Executable file
|
After Width: | Height: | 크기: 294 KiB |
BIN
frontend/public/scat-photos/JJAW-11-1.png
Executable file
|
After Width: | Height: | 크기: 422 KiB |
BIN
frontend/public/scat-photos/JJAW-12-1.png
Executable file
|
After Width: | Height: | 크기: 384 KiB |
BIN
frontend/public/scat-photos/JJAW-13-1.png
Executable file
|
After Width: | Height: | 크기: 67 KiB |
BIN
frontend/public/scat-photos/JJAW-14-1.png
Executable file
|
After Width: | Height: | 크기: 64 KiB |
BIN
frontend/public/scat-photos/JJAW-15-1.png
Executable file
|
After Width: | Height: | 크기: 20 KiB |
BIN
frontend/public/scat-photos/JJAW-16-1.png
Executable file
|
After Width: | Height: | 크기: 77 KiB |
BIN
frontend/public/scat-photos/JJAW-17-1.png
Executable file
|
After Width: | Height: | 크기: 23 KiB |
BIN
frontend/public/scat-photos/JJAW-18-1.png
Executable file
|
After Width: | Height: | 크기: 85 KiB |
BIN
frontend/public/scat-photos/JJAW-19-1.png
Executable file
|
After Width: | Height: | 크기: 101 KiB |
BIN
frontend/public/scat-photos/JJAW-2-1.png
Executable file
|
After Width: | Height: | 크기: 32 KiB |
BIN
frontend/public/scat-photos/JJAW-20-1.png
Executable file
|
After Width: | Height: | 크기: 11 KiB |
BIN
frontend/public/scat-photos/JJAW-21-1.png
Executable file
|
After Width: | Height: | 크기: 24 KiB |
BIN
frontend/public/scat-photos/JJAW-22-1.png
Executable file
|
After Width: | Height: | 크기: 31 KiB |
BIN
frontend/public/scat-photos/JJAW-23-1.png
Executable file
|
After Width: | Height: | 크기: 195 KiB |
BIN
frontend/public/scat-photos/JJAW-24-1.png
Executable file
|
After Width: | Height: | 크기: 432 KiB |
BIN
frontend/public/scat-photos/JJAW-25-1.png
Executable file
|
After Width: | Height: | 크기: 351 KiB |
BIN
frontend/public/scat-photos/JJAW-26-1.png
Executable file
|
After Width: | Height: | 크기: 476 KiB |
BIN
frontend/public/scat-photos/JJAW-27-1.png
Executable file
|
After Width: | Height: | 크기: 313 KiB |
BIN
frontend/public/scat-photos/JJAW-28-1.png
Executable file
|
After Width: | Height: | 크기: 309 KiB |
BIN
frontend/public/scat-photos/JJAW-29-1.png
Executable file
|
After Width: | Height: | 크기: 447 KiB |
BIN
frontend/public/scat-photos/JJAW-3-1.png
Executable file
|
After Width: | Height: | 크기: 332 KiB |
BIN
frontend/public/scat-photos/JJAW-30-1.png
Executable file
|
After Width: | Height: | 크기: 343 KiB |
BIN
frontend/public/scat-photos/JJAW-31-1.png
Executable file
|
After Width: | Height: | 크기: 420 KiB |
BIN
frontend/public/scat-photos/JJAW-32-1.png
Executable file
|
After Width: | Height: | 크기: 388 KiB |
BIN
frontend/public/scat-photos/JJAW-33-1.png
Executable file
|
After Width: | Height: | 크기: 186 KiB |
BIN
frontend/public/scat-photos/JJAW-34-1.png
Executable file
|
After Width: | Height: | 크기: 301 KiB |
BIN
frontend/public/scat-photos/JJAW-35-1.png
Executable file
|
After Width: | Height: | 크기: 196 KiB |
BIN
frontend/public/scat-photos/JJAW-36-1.png
Executable file
|
After Width: | Height: | 크기: 286 KiB |
BIN
frontend/public/scat-photos/JJAW-37-1.png
Executable file
|
After Width: | Height: | 크기: 200 KiB |
BIN
frontend/public/scat-photos/JJAW-38-1.png
Executable file
|
After Width: | Height: | 크기: 334 KiB |
BIN
frontend/public/scat-photos/JJAW-39-1.png
Executable file
|
After Width: | Height: | 크기: 515 KiB |
BIN
frontend/public/scat-photos/JJAW-4-1.png
Executable file
|
After Width: | Height: | 크기: 331 KiB |
BIN
frontend/public/scat-photos/JJAW-40-1.png
Executable file
|
After Width: | Height: | 크기: 498 KiB |
BIN
frontend/public/scat-photos/JJAW-41-1.png
Executable file
|
After Width: | Height: | 크기: 388 KiB |
BIN
frontend/public/scat-photos/JJAW-42-1.png
Executable file
|
After Width: | Height: | 크기: 430 KiB |
BIN
frontend/public/scat-photos/JJAW-43-1.png
Executable file
|
After Width: | Height: | 크기: 445 KiB |
BIN
frontend/public/scat-photos/JJAW-44-1.png
Executable file
|
After Width: | Height: | 크기: 425 KiB |
BIN
frontend/public/scat-photos/JJAW-45-1.png
Executable file
|
After Width: | Height: | 크기: 401 KiB |
BIN
frontend/public/scat-photos/JJAW-46-1.png
Executable file
|
After Width: | Height: | 크기: 201 KiB |
BIN
frontend/public/scat-photos/JJAW-47-1.png
Executable file
|
After Width: | Height: | 크기: 428 KiB |
BIN
frontend/public/scat-photos/JJAW-48-1.png
Executable file
|
After Width: | Height: | 크기: 310 KiB |
BIN
frontend/public/scat-photos/JJAW-49-1.png
Executable file
|
After Width: | Height: | 크기: 373 KiB |
BIN
frontend/public/scat-photos/JJAW-5-1.png
Executable file
|
After Width: | Height: | 크기: 298 KiB |
BIN
frontend/public/scat-photos/JJAW-50-1.png
Executable file
|
After Width: | Height: | 크기: 364 KiB |
BIN
frontend/public/scat-photos/JJAW-51-1.png
Executable file
|
After Width: | Height: | 크기: 345 KiB |
BIN
frontend/public/scat-photos/JJAW-52-1.png
Executable file
|
After Width: | Height: | 크기: 229 KiB |
BIN
frontend/public/scat-photos/JJAW-53-1.png
Executable file
|
After Width: | Height: | 크기: 166 KiB |
BIN
frontend/public/scat-photos/JJAW-54-1.png
Executable file
|
After Width: | Height: | 크기: 516 KiB |
BIN
frontend/public/scat-photos/JJAW-55-1.png
Executable file
|
After Width: | Height: | 크기: 515 KiB |
BIN
frontend/public/scat-photos/JJAW-56-1.png
Executable file
|
After Width: | Height: | 크기: 274 KiB |
BIN
frontend/public/scat-photos/JJAW-57-1.png
Executable file
|
After Width: | Height: | 크기: 347 KiB |
BIN
frontend/public/scat-photos/JJAW-58-1.png
Executable file
|
After Width: | Height: | 크기: 402 KiB |
BIN
frontend/public/scat-photos/JJAW-59-1.png
Executable file
|
After Width: | Height: | 크기: 437 KiB |
BIN
frontend/public/scat-photos/JJAW-6-1.png
Executable file
|
After Width: | Height: | 크기: 267 KiB |
BIN
frontend/public/scat-photos/JJAW-60-1.png
Executable file
|
After Width: | Height: | 크기: 332 KiB |
BIN
frontend/public/scat-photos/JJAW-61-1.png
Executable file
|
After Width: | Height: | 크기: 397 KiB |
BIN
frontend/public/scat-photos/JJAW-62-1.png
Executable file
|
After Width: | Height: | 크기: 368 KiB |
BIN
frontend/public/scat-photos/JJAW-63-1.png
Executable file
|
After Width: | Height: | 크기: 393 KiB |
BIN
frontend/public/scat-photos/JJAW-64-1.png
Executable file
|
After Width: | Height: | 크기: 451 KiB |
BIN
frontend/public/scat-photos/JJAW-65-1.png
Executable file
|
After Width: | Height: | 크기: 469 KiB |
BIN
frontend/public/scat-photos/JJAW-66-1.png
Executable file
|
After Width: | Height: | 크기: 478 KiB |
BIN
frontend/public/scat-photos/JJAW-67-1.png
Executable file
|
After Width: | Height: | 크기: 526 KiB |
BIN
frontend/public/scat-photos/JJAW-68-1.png
Executable file
|
After Width: | Height: | 크기: 451 KiB |
BIN
frontend/public/scat-photos/JJAW-69-1.png
Executable file
|
After Width: | Height: | 크기: 439 KiB |
BIN
frontend/public/scat-photos/JJAW-7-1.png
Executable file
|
After Width: | Height: | 크기: 357 KiB |
BIN
frontend/public/scat-photos/JJAW-70-1.png
Executable file
|
After Width: | Height: | 크기: 394 KiB |
BIN
frontend/public/scat-photos/JJAW-71-1.png
Executable file
|
After Width: | Height: | 크기: 301 KiB |