wing-ops/backend/src/aerial/aerialRouter.ts
Nan Kyung Lee 044994bd57 feat(aerial): UP42 위성 패스 조회 + 궤도 지도 표시
- 백엔드: GET /api/aerial/satellite/passes — 한국 주변 위성 패스 시뮬레이션
  UP42 API 연동 준비 (Workspace ID: b9bc92ae, TODO 주석)
  6개 위성 궤도 데이터 (KOMPSAT-3A, Pléiades Neo, Sentinel-1/2, WV-3, SkySat)
- 프론트 API: fetchSatellitePasses() + SatellitePass 인터페이스
- UP42 모달: MapLibre 지도에 위성 궤도 라인 실시간 표시
  한국 영역 AOI 점선 박스 + 궤도별 색상 구분
  위성 클릭 시 해당 궤도 하이라이트 (나머지 투명)
- 패스 타임라인: 통과 시각, 해상도, 앙각, 상승/하강 방향, 긴급도 표시
- 궤도 범례 오버레이 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:11:52 +09:00

537 lines
20 KiB
TypeScript

import express from 'express';
import path from 'path';
import {
listMedia,
createMedia,
listCctv,
listSatRequests,
createSatRequest,
updateSatRequestStatus,
isValidSatStatus,
requestOilInference,
checkInferenceHealth,
listDroneStreams,
startDroneStream,
stopDroneStream,
getHlsDirectory,
} 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 목록 조회 실패' });
}
});
// ============================================================
// KBS 재난안전포탈 CCTV HLS 리졸버
// ============================================================
/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */
const kbsHlsCache = new Map<string, { url: string; ts: number }>();
const KBS_CACHE_TTL = 5 * 60 * 1000;
// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시
router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
try {
const cctvId = req.params.cctvId as string;
if (!/^\d+$/.test(cctvId)) {
res.status(400).json({ error: '유효하지 않은 cctvId' });
return;
}
let m3u8Url: string | null = null;
// 캐시 확인
const cached = kbsHlsCache.get(cctvId);
if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) {
m3u8Url = cached.url;
} else {
// 1단계: KBS 팝업 API에서 loomex API URL 추출
const popupRes = await fetch(
`https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`,
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } },
);
if (!popupRes.ok) {
res.status(502).json({ error: 'KBS 팝업 API 응답 실패' });
return;
}
const popupHtml = await popupRes.text();
const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/);
if (!urlMatch) {
res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' });
return;
}
// 2단계: loomex API에서 실제 m3u8 URL 획득
const loomexRes = await fetch(urlMatch[1], {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!loomexRes.ok) {
res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' });
return;
}
m3u8Url = (await loomexRes.text()).trim();
kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() });
}
// 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성
const upstream = await fetch(m3u8Url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!upstream.ok) {
// 캐시 무효화 후 재시도 유도
kbsHlsCache.delete(cctvId);
res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' });
return;
}
const text = await upstream.text();
const baseUrl = m3u8Url.substring(0, m3u8Url.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);
} catch (err) {
console.error('[aerial] KBS HLS 리졸버 오류:', err);
res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' });
}
});
// ============================================================
// CCTV HLS 스트림 프록시 (CORS 우회)
// ============================================================
/** 허용 도메인 목록 */
const ALLOWED_STREAM_HOSTS = [
'www.khoa.go.kr',
'kbsapi.loomex.net',
'kbscctv-cache.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: '스트림 프록시 실패' });
}
});
// ============================================================
// DRONE STREAM 라우트 (RTSP → HLS)
// ============================================================
// GET /api/aerial/drone/streams — 드론 스트림 목록 + 상태
router.get('/drone/streams', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
const streams = listDroneStreams();
res.json(streams);
} catch (err) {
console.error('[aerial] 드론 스트림 목록 오류:', err);
res.status(500).json({ error: '드론 스트림 목록 조회 실패' });
}
});
// POST /api/aerial/drone/streams/:id/start — 드론 스트림 시작 (RTSP→HLS 변환)
router.post('/drone/streams/:id/start', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = startDroneStream(req.params.id as string);
if (!result.success) {
res.status(400).json({ error: result.error });
return;
}
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 시작 오류:', err);
res.status(500).json({ error: '드론 스트림 시작 실패' });
}
});
// POST /api/aerial/drone/streams/:id/stop — 드론 스트림 중지
router.post('/drone/streams/:id/stop', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const result = stopDroneStream(req.params.id as string);
res.json(result);
} catch (err) {
console.error('[aerial] 드론 스트림 중지 오류:', err);
res.status(500).json({ error: '드론 스트림 중지 실패' });
}
});
// GET /api/aerial/drone/hls/:id/* — HLS 정적 파일 서빙 (.m3u8, .ts)
router.get('/drone/hls/:id/*', async (req, res) => {
try {
const id = req.params.id as string;
const hlsDir = getHlsDirectory(id);
if (!hlsDir) {
res.status(404).json({ error: '스트림을 찾을 수 없습니다' });
return;
}
// wildcard: req.params[0] contains the rest of the path
// Cast through unknown because @types/express v5 types the wildcard key as string[]
const rawParams = req.params as unknown as Record<string, string | string[]>;
const wildcardRaw = rawParams['0'] ?? '';
const wildcardParam = Array.isArray(wildcardRaw) ? wildcardRaw.join('/') : wildcardRaw;
const filePath = path.join(hlsDir, wildcardParam);
// Security: prevent path traversal
if (!filePath.startsWith(hlsDir)) {
res.status(403).json({ error: '접근 거부' });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = ext === '.m3u8' ? 'application/vnd.apple.mpegurl'
: ext === '.ts' ? 'video/mp2t'
: 'application/octet-stream';
res.set({
'Content-Type': contentType,
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.sendFile(filePath);
} catch (err) {
console.error('[aerial] HLS 파일 서빙 오류:', err);
res.status(404).json({ error: 'HLS 파일을 찾을 수 없습니다' });
}
});
// ============================================================
// 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: '위성 요청 상태 변경 실패' });
}
});
// ============================================================
// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도)
// ============================================================
/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */
function generateKoreaSatellitePasses() {
const now = new Date();
const passes = [
{
id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical',
resolution: '0.5m', color: '#a855f7',
startTime: new Date(now.getTime() + 2 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(),
maxElevation: 72, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 },
{ lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 },
{ lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 },
],
},
{
id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical',
resolution: '0.3m', color: '#06b6d4',
startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(),
maxElevation: 65, direction: 'ascending',
orbit: [
{ lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 },
{ lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 },
{ lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 },
],
},
{
id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar',
resolution: '20m', color: '#f59e0b',
startTime: new Date(now.getTime() + 5 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(),
maxElevation: 58, direction: 'descending',
orbit: [
{ lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 },
{ lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 },
{ lat: 31.0, lon: 128.5 },
],
},
{
id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical',
resolution: '0.31m', color: '#3b82f6',
startTime: new Date(now.getTime() + 8 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(),
maxElevation: 80, direction: 'descending',
orbit: [
{ lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 },
{ lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 },
{ lat: 32.0, lon: 126.5 },
],
},
{
id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical',
resolution: '0.5m', color: '#22c55e',
startTime: new Date(now.getTime() + 12 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(),
maxElevation: 55, direction: 'ascending',
orbit: [
{ lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 },
{ lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 },
{ lat: 40.0, lon: 124.0 },
],
},
{
id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical',
resolution: '10m', color: '#ec4899',
startTime: new Date(now.getTime() + 18 * 3600000).toISOString(),
endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(),
maxElevation: 62, direction: 'descending',
orbit: [
{ lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 },
{ lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 },
],
},
];
return passes;
}
// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비)
router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
try {
// TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체
// const token = await getUp42Token()
// const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] })
const passes = generateKoreaSatellitePasses();
res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' });
} catch (err) {
console.error('[aerial] 위성 패스 조회 오류:', err);
res.status(500).json({ error: '위성 패스 조회 실패' });
}
});
// ============================================================
// OIL INFERENCE 라우트
// ============================================================
// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시)
// base64 이미지 전송을 위해 3MB JSON 파서 적용
router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const { image } = req.body;
if (!image || typeof image !== 'string') {
res.status(400).json({ error: 'image (base64) 필드가 필요합니다' });
return;
}
// base64 크기 제한 (약 2MB 이미지)
if (image.length > 3_000_000) {
res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' });
return;
}
const result = await requestOilInference(image);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('abort') || message.includes('timeout')) {
console.error('[aerial] 추론 서버 타임아웃:', message);
res.status(504).json({ error: '추론 서버 응답 시간 초과' });
return;
}
console.error('[aerial] 오일 감지 오류:', err);
res.status(503).json({ error: '추론 서버 연결 불가' });
}
});
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
const health = await checkInferenceHealth();
res.json(health);
});
export default router;