import { wingPool } from '../db/wingDb.js'; // ============================================================ // AERIAL_MEDIA // ============================================================ interface AerialMediaItem { aerialMediaSn: number; acdntSn: number | null; fileNm: string; orgnlNm: string | null; filePath: string | null; lon: number | null; lat: number | null; locDc: string | null; equipTpCd: string | null; equipNm: string | null; mediaTpCd: string | null; takngDtm: string | null; fileSz: string | null; resolution: string | null; regDtm: string; } interface ListMediaInput { equipType?: string; mediaType?: string; acdntSn?: number; search?: string; } function rowToMedia(r: Record): AerialMediaItem { return { aerialMediaSn: r.aerial_media_sn as number, acdntSn: r.acdnt_sn as number | null, fileNm: r.file_nm as string, orgnlNm: r.orgnl_nm as string | null, filePath: r.file_path as string | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, locDc: r.loc_dc as string | null, equipTpCd: r.equip_tp_cd as string | null, equipNm: r.equip_nm as string | null, mediaTpCd: r.media_tp_cd as string | null, takngDtm: r.takng_dtm as string | null, fileSz: r.file_sz as string | null, resolution: r.resolution as string | null, regDtm: r.reg_dtm as string, }; } export async function getMediaBySn(sn: number): Promise { const { rows } = await wingPool.query( `SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM FROM wing.AERIAL_MEDIA WHERE AERIAL_MEDIA_SN = $1 AND USE_YN = 'Y'`, [sn] ); return rows.length > 0 ? rowToMedia(rows[0]) : null; } export async function fetchOriginalImage(camTy: string, fileId: string): Promise { const res = await fetch(`${IMAGE_ANALYSIS_URL}/get-original-image/${camTy}/${fileId}`, { signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`); const base64 = await res.json() as string; return Buffer.from(base64, 'base64'); } export async function listMedia(input: ListMediaInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: (string | number)[] = []; let idx = 1; if (input.equipType) { conditions.push(`EQUIP_TP_CD = $${idx++}`); params.push(input.equipType); } if (input.mediaType) { conditions.push(`MEDIA_TP_CD = $${idx++}`); params.push(input.mediaType); } if (input.acdntSn) { conditions.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } if (input.search) { conditions.push(`(FILE_NM ILIKE '%' || $${idx} || '%' OR EQUIP_NM ILIKE '%' || $${idx} || '%')`); params.push(input.search); idx++; } const { rows } = await wingPool.query( `SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM FROM AERIAL_MEDIA WHERE ${conditions.join(' AND ')} ORDER BY TAKNG_DTM DESC NULLS LAST`, params ); return rows.map((r: Record) => rowToMedia(r)); } export async function createMedia(input: { acdntSn?: number; fileNm: string; orgnlNm?: string; filePath?: string; lon?: number; lat?: number; locDc?: string; equipTpCd?: string; equipNm?: string; mediaTpCd?: string; takngDtm?: string; fileSz?: string; resolution?: string; }): Promise<{ aerialMediaSn: number }> { const { rows } = await wingPool.query( `INSERT INTO AERIAL_MEDIA ( ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, LON, LAT, GEOM, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION ) VALUES ( $1, $2, $3, $4, $5::float8, $6::float8, CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5, $6), 4326) END, $7, $8, $9, $10, $11, $12, $13 ) RETURNING AERIAL_MEDIA_SN`, [ input.acdntSn || null, input.fileNm, input.orgnlNm || null, input.filePath || null, input.lon || null, input.lat || null, input.locDc || null, input.equipTpCd || null, input.equipNm || null, input.mediaTpCd || null, input.takngDtm || null, input.fileSz || null, input.resolution || null, ] ); return { aerialMediaSn: rows[0].aerial_media_sn }; } // ============================================================ // CCTV_CAMERA // ============================================================ interface CctvCameraItem { cctvSn: number; cameraNm: string; regionNm: string | null; lon: number | null; lat: number | null; locDc: string | null; coordDc: string | null; sttsCd: string; ptzYn: string; sourceNm: string | null; streamUrl: string | null; regDtm: string; } interface ListCctvInput { region?: string; status?: string; } function rowToCctv(r: Record): CctvCameraItem { return { cctvSn: r.cctv_sn as number, cameraNm: r.camera_nm as string, regionNm: r.region_nm as string | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, locDc: r.loc_dc as string | null, coordDc: r.coord_dc as string | null, sttsCd: r.stts_cd as string, ptzYn: r.ptz_yn as string, sourceNm: r.source_nm as string | null, streamUrl: r.stream_url as string | null, regDtm: r.reg_dtm as string, }; } export async function listCctv(input: ListCctvInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: string[] = []; let idx = 1; if (input.region) { conditions.push(`REGION_NM = $${idx++}`); params.push(input.region); } if (input.status) { conditions.push(`STTS_CD = $${idx++}`); params.push(input.status); } const { rows } = await wingPool.query( `SELECT CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL, REG_DTM FROM CCTV_CAMERA WHERE ${conditions.join(' AND ')} ORDER BY REGION_NM, CAMERA_NM`, params ); return rows.map((r: Record) => rowToCctv(r)); } // ============================================================ // SAT_REQUEST // ============================================================ interface SatRequestItem { satReqSn: number; reqCd: string; acdntSn: number | null; lon: number | null; lat: number | null; zoneDc: string | null; coordDc: string | null; zoneAreaKm2: number | null; satNm: string | null; providerNm: string | null; resolution: string | null; purposeDc: string | null; reqstrNm: string | null; reqDtm: string | null; expectedRcvDtm: string | null; sttsCd: string; regDtm: string; } interface ListSatRequestsInput { status?: string; } function rowToSatRequest(r: Record): SatRequestItem { return { satReqSn: r.sat_req_sn as number, reqCd: r.req_cd as string, acdntSn: r.acdnt_sn as number | null, lon: r.lon ? parseFloat(r.lon as string) : null, lat: r.lat ? parseFloat(r.lat as string) : null, zoneDc: r.zone_dc as string | null, coordDc: r.coord_dc as string | null, zoneAreaKm2: r.zone_area_km2 ? parseFloat(r.zone_area_km2 as string) : null, satNm: r.sat_nm as string | null, providerNm: r.provider_nm as string | null, resolution: r.resolution as string | null, purposeDc: r.purpose_dc as string | null, reqstrNm: r.reqstr_nm as string | null, reqDtm: r.req_dtm as string | null, expectedRcvDtm: r.expected_rcv_dtm as string | null, sttsCd: r.stts_cd as string, regDtm: r.reg_dtm as string, }; } export async function listSatRequests(input: ListSatRequestsInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"]; const params: string[] = []; let idx = 1; if (input.status) { conditions.push(`STTS_CD = $${idx++}`); params.push(input.status); } const { rows } = await wingPool.query( `SELECT SAT_REQ_SN, REQ_CD, ACDNT_SN, LON, LAT, ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, RESOLUTION, PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM, STTS_CD, REG_DTM FROM SAT_REQUEST WHERE ${conditions.join(' AND ')} ORDER BY REQ_DTM DESC NULLS LAST`, params ); return rows.map((r: Record) => rowToSatRequest(r)); } export async function createSatRequest(input: { reqCd: string; acdntSn?: number; lon?: number; lat?: number; zoneDc?: string; coordDc?: string; zoneAreaKm2?: number; satNm?: string; providerNm?: string; resolution?: string; purposeDc?: string; reqstrNm?: string; reqDtm?: string; expectedRcvDtm?: string; }): Promise<{ satReqSn: number }> { const { rows } = await wingPool.query( `INSERT INTO SAT_REQUEST ( REQ_CD, ACDNT_SN, LON, LAT, GEOM, ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, RESOLUTION, PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM ) VALUES ( $1, $2, $3, $4, CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($3::float, $4::float), 4326) END, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) RETURNING SAT_REQ_SN`, [ input.reqCd, input.acdntSn || null, input.lon || null, input.lat || null, input.zoneDc || null, input.coordDc || null, input.zoneAreaKm2 || null, input.satNm || null, input.providerNm || null, input.resolution || null, input.purposeDc || null, input.reqstrNm || null, input.reqDtm || null, input.expectedRcvDtm || null, ] ); return { satReqSn: rows[0].sat_req_sn }; } const VALID_SAT_STATUSES = ['PENDING', 'SHOOTING', 'COMPLETED', 'CANCELLED'] as const; type SatStatus = typeof VALID_SAT_STATUSES[number]; export function isValidSatStatus(value: string): value is SatStatus { return (VALID_SAT_STATUSES as readonly string[]).includes(value); } export async function updateSatRequestStatus(sn: number, sttsCd: string): Promise { await wingPool.query( `UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`, [sttsCd, sn] ); } // ============================================================ // OIL INFERENCE (GPU 서버 프록시) // ============================================================ const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:5001'; const IMAGE_ANALYSIS_URL = process.env.IMAGE_ANALYSIS_URL || OIL_INFERENCE_URL; const INFERENCE_TIMEOUT_MS = 10_000; export interface OilInferenceRegion { classId: number; className: string; pixelCount: number; percentage: number; thicknessMm: number; } export interface OilInferenceResult { mask: string; // base64 uint8 array (values 0-4) width: number; height: number; regions: OilInferenceRegion[]; } /** 여러 이미지를 이미지 분석 서버의 /stitch 엔드포인트로 전송해 합성 JPEG를 반환한다. */ export async function stitchImages( files: Express.Multer.File[], fileId: string ): Promise { const form = new FormData(); form.append('fileId', fileId); for (const f of files) { form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname); } const response = await fetch(`${IMAGE_ANALYSIS_URL}/stitch`, { method: 'POST', body: form, signal: AbortSignal.timeout(300_000), }); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`stitch server responded ${response.status}: ${detail}`); } return Buffer.from(await response.arrayBuffer()); } /** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */ export async function requestOilInference(imageBase64: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); try { const response = await fetch(`${OIL_INFERENCE_URL}/inference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: imageBase64 }), signal: controller.signal, }); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`Inference server responded ${response.status}: ${detail}`); } return await response.json() as OilInferenceResult; } finally { clearTimeout(timeout); } } /** GPU 추론 서버 헬스체크 */ export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> { try { const response = await fetch(`${OIL_INFERENCE_URL}/health`, { signal: AbortSignal.timeout(3000), }); if (!response.ok) throw new Error(`status ${response.status}`); return await response.json() as { status: string; device?: string }; } catch { return { status: 'unavailable' }; } }