442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
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<string, unknown>): 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<AerialMediaItem | null> {
|
|
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<Buffer> {
|
|
const res = await fetch(`${IMAGE_API_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<AerialMediaItem[]> {
|
|
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<string, unknown>) => 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<string, unknown>): 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<CctvCameraItem[]> {
|
|
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<string, unknown>) => 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<string, unknown>): 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<SatRequestItem[]> {
|
|
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<string, unknown>) => 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<void> {
|
|
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 IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
|
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<Buffer> {
|
|
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_API_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<OilInferenceResult> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(`${IMAGE_API_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(`${IMAGE_API_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' };
|
|
}
|
|
}
|