- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출 - 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가 - prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel) - HNS 분석 생성 시 acdntSn 연결 지원 - GSC 사고 목록 응답에 acdntSn 노출 - 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import express from 'express';
|
|
import multer from 'multer';
|
|
import {
|
|
listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt,
|
|
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
|
|
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
|
|
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
|
|
getOilSpillSummary,
|
|
} from './predictionService.js';
|
|
import { analyzeImageFile } from './imageAnalyzeService.js';
|
|
import { isValidNumber } from '../middleware/security.js';
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
|
|
|
const router = express.Router();
|
|
|
|
// GET /api/prediction/analyses — 분석 목록
|
|
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const { search, acdntSn } = req.query;
|
|
const items = await listAnalyses({
|
|
search: search as string | undefined,
|
|
acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined,
|
|
});
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[prediction] 분석 목록 오류:', err);
|
|
res.status(500).json({ error: '분석 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn — 분석 상세
|
|
router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const detail = await getAnalysisDetail(acdntSn);
|
|
if (!detail) {
|
|
res.status(404).json({ error: '분석을 찾을 수 없습니다' });
|
|
return;
|
|
}
|
|
res.json(detail);
|
|
} catch (err) {
|
|
console.error('[prediction] 분석 상세 오류:', err);
|
|
res.status(500).json({ error: '분석 상세 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능)
|
|
router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
|
const result = await getAnalysisTrajectory(acdntSn, predRunSn);
|
|
if (!result) {
|
|
res.json({ trajectory: null, summary: null });
|
|
return;
|
|
}
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] trajectory 조회 오류:', err);
|
|
res.status(500).json({ error: 'trajectory 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
|
|
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
|
|
const result = await getOilSpillSummary(acdntSn, predRunSn);
|
|
if (!result) {
|
|
res.json({ primary: null, byModel: {} });
|
|
return;
|
|
}
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] oil-summary 조회 오류:', err);
|
|
res.status(500).json({ error: 'oil-summary 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
|
|
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const result = await getSensitiveResourcesByAcdntSn(acdntSn);
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 민감자원 조회 오류:', err);
|
|
res.status(500).json({ error: '민감자원 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/sensitive-resources/geojson — 예측 영역 내 민감자원 GeoJSON
|
|
router.get('/analyses/:acdntSn/sensitive-resources/geojson', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const result = await getSensitiveResourcesGeoJsonByAcdntSn(acdntSn);
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 민감자원 GeoJSON 조회 오류:', err);
|
|
res.status(500).json({ error: '민감자원 GeoJSON 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/spread-particles — 예측 확산 파티클 GeoJSON
|
|
router.get('/analyses/:acdntSn/spread-particles', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const result = await getPredictionParticlesGeojsonByAcdntSn(acdntSn);
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 확산 파티클 GeoJSON 조회 오류:', err);
|
|
res.status(500).json({ error: '확산 파티클 GeoJSON 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/analyses/:acdntSn/sensitivity-evaluation — 통합민감도 평가 GeoJSON
|
|
router.get('/analyses/:acdntSn/sensitivity-evaluation', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const result = await getSensitivityEvaluationGeojsonByAcdntSn(acdntSn);
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 통합민감도 평가 GeoJSON 조회 오류:', err);
|
|
res.status(500).json({ error: '통합민감도 평가 GeoJSON 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/backtrack — 사고별 역추적 목록
|
|
router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.query.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const items = await listBacktracksByAcdnt(acdntSn);
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[prediction] 역추적 목록 오류:', err);
|
|
res.status(500).json({ error: '역추적 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/backtrack/:sn — 역추적 상세
|
|
router.get('/backtrack/:sn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const sn = parseInt(req.params.sn as string, 10);
|
|
if (!isValidNumber(sn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 역추적 번호' });
|
|
return;
|
|
}
|
|
const item = await getBacktrack(sn);
|
|
if (!item) {
|
|
res.status(404).json({ error: '역추적 결과를 찾을 수 없습니다' });
|
|
return;
|
|
}
|
|
res.json(item);
|
|
} catch (err) {
|
|
console.error('[prediction] 역추적 상세 오류:', err);
|
|
res.status(500).json({ error: '역추적 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/prediction/backtrack — 역추적 생성
|
|
router.post('/backtrack', requireAuth, requirePermission('prediction', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = req.body;
|
|
if (!acdntSn || !lat || !lon) {
|
|
res.status(400).json({ error: '사고번호, 위도, 경도는 필수입니다' });
|
|
return;
|
|
}
|
|
const result = await createBacktrack({ acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm });
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 역추적 생성 오류:', err);
|
|
res.status(500).json({ error: '역추적 생성 실패' });
|
|
}
|
|
});
|
|
|
|
// GET /api/prediction/boom/:acdntSn — 오일펜스 목록
|
|
router.get('/boom/:acdntSn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
|
try {
|
|
const acdntSn = parseInt(req.params.acdntSn as string, 10);
|
|
if (!isValidNumber(acdntSn, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const items = await listBoomLines(acdntSn);
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[prediction] 오일펜스 목록 오류:', err);
|
|
res.status(500).json({ error: '오일펜스 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/prediction/boom — 오일펜스 저장
|
|
router.post('/boom', requireAuth, requirePermission('prediction', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const { acdntSn, boomNm, priorityOrd, geojson, lengthM, efficiencyPct } = req.body;
|
|
if (!acdntSn || !boomNm || !geojson) {
|
|
res.status(400).json({ error: '사고번호, 이름, GeoJSON은 필수입니다' });
|
|
return;
|
|
}
|
|
const result = await saveBoomLine({ acdntSn, boomNm, priorityOrd, geojson, lengthM, efficiencyPct });
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
console.error('[prediction] 오일펜스 저장 오류:', err);
|
|
res.status(500).json({ error: '오일펜스 저장 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/prediction/image-analyze — 이미지 업로드 분석
|
|
router.post(
|
|
'/image-analyze',
|
|
requireAuth,
|
|
requirePermission('prediction', 'CREATE'),
|
|
upload.single('image'),
|
|
async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
res.status(400).json({ error: '이미지 파일이 필요합니다' });
|
|
return;
|
|
}
|
|
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
|
|
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === 'GPS_NOT_FOUND') {
|
|
res.status(422).json({ error: 'GPS_NOT_FOUND', message: 'GPS 정보가 없는 이미지입니다' });
|
|
return;
|
|
}
|
|
if (code === 'TIMEOUT') {
|
|
res.status(504).json({ error: 'TIMEOUT', message: '이미지 분석 서버 응답 시간 초과' });
|
|
return;
|
|
}
|
|
}
|
|
console.error('[prediction] 이미지 분석 오류:', err);
|
|
res.status(500).json({ error: '이미지 분석 실패' });
|
|
}
|
|
}
|
|
);
|
|
|
|
export default router;
|