import express from 'express'; import multer from 'multer'; import { listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn, } 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 } = req.query; const items = await listAnalyses({ search: search as string | 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 — 최신 OpenDrift 결과 조회 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 result = await getAnalysisTrajectory(acdntSn); 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/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 result = await analyzeImageFile(req.file.buffer, req.file.originalname); 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;