CCTV 실시간 영상: - CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생) - 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성) - KHOA 15개 + KBS 6개 실제 해안 CCTV 연동 - Vite dev proxy, 스트림 타입 자동 감지 유틸리티 HNS 분석: - HNS 시나리오 저장/불러오기/재계산 기능 - 물질 DB 검색 및 상세 정보 연동 - 좌표/파라미터 입력 UI 개선 - Python 확산 모델 스크립트 (hns_dispersion.py) 공통: - 3D 지도 토글, 보고서 생성 개선 - useSubMenu 훅, mapUtils 확장 - ESLint set-state-in-effect 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
7.6 KiB
TypeScript
225 lines
7.6 KiB
TypeScript
import express from 'express';
|
|
import {
|
|
listMedia,
|
|
createMedia,
|
|
listCctv,
|
|
listSatRequests,
|
|
createSatRequest,
|
|
updateSatRequestStatus,
|
|
isValidSatStatus,
|
|
} from './aerialService.js';
|
|
import { isValidNumber } from '../middleware/security.js';
|
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// ============================================================
|
|
// AERIAL_MEDIA 라우트
|
|
// ============================================================
|
|
|
|
// GET /api/aerial/media — 미디어 목록
|
|
router.get('/media', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
|
try {
|
|
const { equipType, mediaType, acdntSn, search } = req.query;
|
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
|
|
if (acdntSn && !isValidNumber(acdntSnNum, 1, 999999)) {
|
|
res.status(400).json({ error: '유효하지 않은 사고 번호' });
|
|
return;
|
|
}
|
|
const items = await listMedia({
|
|
equipType: equipType as string | undefined,
|
|
mediaType: mediaType as string | undefined,
|
|
acdntSn: acdntSnNum,
|
|
search: search as string | undefined,
|
|
});
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[aerial] 미디어 목록 오류:', err);
|
|
res.status(500).json({ error: '미디어 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/aerial/media — 미디어 메타 등록
|
|
router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const {
|
|
acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc,
|
|
equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution,
|
|
} = req.body;
|
|
if (!fileNm) {
|
|
res.status(400).json({ error: '파일명은 필수입니다.' });
|
|
return;
|
|
}
|
|
const result = await createMedia({
|
|
acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc,
|
|
equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
console.error('[aerial] 미디어 등록 오류:', err);
|
|
res.status(500).json({ error: '미디어 등록 실패' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// CCTV_CAMERA 라우트
|
|
// ============================================================
|
|
|
|
// GET /api/aerial/cctv — CCTV 목록
|
|
router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
|
try {
|
|
const { region, status } = req.query;
|
|
const items = await listCctv({
|
|
region: region as string | undefined,
|
|
status: status as string | undefined,
|
|
});
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[aerial] CCTV 목록 오류:', err);
|
|
res.status(500).json({ error: 'CCTV 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// CCTV HLS 스트림 프록시 (CORS 우회)
|
|
// ============================================================
|
|
|
|
/** 허용 도메인 목록 */
|
|
const ALLOWED_STREAM_HOSTS = [
|
|
'www.khoa.go.kr',
|
|
'kbsapi.loomex.net',
|
|
];
|
|
|
|
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
|
router.get('/cctv/stream-proxy', async (req, res) => {
|
|
try {
|
|
const targetUrl = req.query.url as string | undefined;
|
|
if (!targetUrl) {
|
|
res.status(400).json({ error: 'url 파라미터가 필요합니다.' });
|
|
return;
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(targetUrl);
|
|
} catch {
|
|
res.status(400).json({ error: '유효하지 않은 URL' });
|
|
return;
|
|
}
|
|
|
|
if (!ALLOWED_STREAM_HOSTS.includes(parsed.hostname)) {
|
|
res.status(403).json({ error: '허용되지 않은 스트림 호스트' });
|
|
return;
|
|
}
|
|
|
|
const upstream = await fetch(targetUrl, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
|
|
});
|
|
|
|
if (!upstream.ok) {
|
|
res.status(upstream.status).json({ error: `스트림 서버 응답: ${upstream.status}` });
|
|
return;
|
|
}
|
|
|
|
const contentType = upstream.headers.get('content-type') || '';
|
|
|
|
// .m3u8 매니페스트: 상대 URL을 프록시 URL로 재작성
|
|
if (targetUrl.includes('.m3u8') || contentType.includes('mpegurl') || contentType.includes('m3u8')) {
|
|
const text = await upstream.text();
|
|
const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1);
|
|
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
|
|
|
|
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
|
|
if (line.startsWith('http://') || line.startsWith('https://')) {
|
|
return `${proxyBase}${encodeURIComponent(line)}`;
|
|
}
|
|
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
|
|
});
|
|
|
|
res.set({
|
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
|
'Cache-Control': 'no-cache',
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
res.send(rewritten);
|
|
return;
|
|
}
|
|
|
|
// .ts 세그먼트 등: 바이너리 스트리밍
|
|
res.set({
|
|
'Content-Type': contentType || 'video/mp2t',
|
|
'Cache-Control': 'no-cache',
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
|
|
const buffer = Buffer.from(await upstream.arrayBuffer());
|
|
res.send(buffer);
|
|
} catch (err) {
|
|
console.error('[aerial] CCTV 스트림 프록시 오류:', err);
|
|
res.status(502).json({ error: '스트림 프록시 실패' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// SAT_REQUEST 라우트
|
|
// ============================================================
|
|
|
|
// GET /api/aerial/satellite — 위성 촬영 요청 목록
|
|
router.get('/satellite', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
|
try {
|
|
const { status } = req.query;
|
|
const items = await listSatRequests({
|
|
status: status as string | undefined,
|
|
});
|
|
res.json(items);
|
|
} catch (err) {
|
|
console.error('[aerial] 위성 요청 목록 오류:', err);
|
|
res.status(500).json({ error: '위성 요청 목록 조회 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/aerial/satellite — 위성 촬영 요청 생성
|
|
router.post('/satellite', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => {
|
|
try {
|
|
const {
|
|
reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2,
|
|
satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm,
|
|
} = req.body;
|
|
if (!reqCd) {
|
|
res.status(400).json({ error: '요청코드는 필수입니다.' });
|
|
return;
|
|
}
|
|
const result = await createSatRequest({
|
|
reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2,
|
|
satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm,
|
|
});
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
console.error('[aerial] 위성 요청 생성 오류:', err);
|
|
res.status(500).json({ error: '위성 요청 생성 실패' });
|
|
}
|
|
});
|
|
|
|
// POST /api/aerial/satellite/:sn/status — 위성 요청 상태 변경
|
|
router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'CREATE'), 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 { sttsCd } = req.body;
|
|
if (!sttsCd || !isValidSatStatus(sttsCd)) {
|
|
res.status(400).json({ error: '유효하지 않은 상태값 (PENDING/SHOOTING/COMPLETED/CANCELLED)' });
|
|
return;
|
|
}
|
|
await updateSatRequestStatus(sn, sttsCd);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('[aerial] 위성 요청 상태 변경 오류:', err);
|
|
res.status(500).json({ error: '위성 요청 상태 변경 실패' });
|
|
}
|
|
});
|
|
|
|
export default router;
|