wing-ops/backend/src/aerial/aerialRouter.ts
jeonghyo.k 3946ff6a25 feat(prediction): 이미지 분석 서버 Docker 패키징 + DB 코드 제거
- prediction/image/ FastAPI 서버 Docker 환경 구성
  - Dockerfile: PyTorch 2.1 + CUDA 12.1 기반 GPU 이미지
  - docker-compose.yml: GPU 할당 + 데이터 볼륨 마운트
  - requirements.txt: 서버 의존성 목록
  - .env.example: 환경변수 템플릿
  - DOCKER_USAGE.md: 빌드/실행/API 사용법 문서
  - Dockerfile에 .dockerignore 제외 폴더 mkdir -p 추가
- .gitignore: prediction/image 결과물 및 모델 가중치(.pth) 제외 추가
- dbInsert_csv.py, dbInsert_shp.py 삭제 (미사용 DB 로직)
- api.py: dbInsert import 및 주석 처리된 DB 호출 코드 제거
- aerialRouter.ts: req.params 타입 오류 수정
2026-03-10 18:37:36 +09:00

339 lines
12 KiB
TypeScript

import express from 'express';
import multer from 'multer';
import {
listMedia,
createMedia,
getMediaBySn,
fetchOriginalImage,
listCctv,
listSatRequests,
createSatRequest,
updateSatRequestStatus,
isValidSatStatus,
requestOilInference,
checkInferenceHealth,
stitchImages,
} from './aerialService.js';
import { isValidNumber } from '../middleware/security.js';
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
const router = express.Router();
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
// ============================================================
// 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: '미디어 등록 실패' });
}
});
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', '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 media = await getMediaBySn(sn);
if (!media) {
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
return;
}
// fileId 추출: FILE_NM의 앞 36자가 UUID 형식인지 검증 (이미지 분석 생성 레코드만 다운로드 가능)
const fileId = media.fileNm.substring(0, 36);
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
res.status(404).json({ error: '다운로드 가능한 이미지가 없습니다.' });
return;
}
const buffer = await fetchOriginalImage(media.equipNm, fileId);
const downloadName = media.orgnlNm ?? media.fileNm;
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(downloadName)}`);
res.send(buffer);
} catch (err) {
console.error('[aerial] 이미지 다운로드 오류:', err);
res.status(502).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: '위성 요청 상태 변경 실패' });
}
});
// ============================================================
// 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: '추론 서버 연결 불가' });
}
});
// ============================================================
// STITCH (이미지 합성) 라우트
// ============================================================
// POST /api/aerial/stitch — 여러 이미지를 합성하여 JPEG 반환
router.post(
'/stitch',
requireAuth,
requirePermission('aerial', 'READ'),
stitchUpload.array('files', 6),
async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length < 2) {
res.status(400).json({ error: '이미지를 최소 2장 이상 선택해주세요.' });
return;
}
const fileId = `stitch_${Date.now()}`;
const buffer = await stitchImages(files, fileId);
res.type('image/jpeg').send(buffer);
} 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;