// 스마트공장 활용 로그 전송 유틸리티 // https://log.smart-factory.kr 에 사용자 접속 로그를 전송 // + 스케줄 기반 자동 전송 엔진 import axios from "axios"; import cron from "node-cron"; import { logger } from "./logger"; import { query, queryOne } from "../database/db"; import { encryptionService } from "../services/encryptionService"; const SMART_FACTORY_LOG_URL = "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; // ─── 스케줄 엔진 상태 ─── interface ScheduledEntry { userId: string; userName: string; companyCode: string; scheduledTime: Date; useType: "접속" | "종료"; sent: boolean; } // 오늘의 전송 계획 (회사코드 → 사용자 목록) const dailyPlan: Map = new Map(); // 공휴일 캐시 (날짜 문자열 Set, 매일 갱신) let holidayCache: Set = new Set(); let holidayCacheDate = ""; /** * 스마트공장 활용 로그 전송 + DB 저장 * logTime이 주어지면 해당 시각을 logDt로 사용 (스케줄 전송용) */ export async function sendSmartFactoryLog(params: { userId: string; userName?: string; remoteAddr: string; useType?: string; companyCode?: string; logTime?: Date; }): Promise { const logTimeToUse = params.logTime || new Date(); const logDt = formatDateTime(logTimeToUse); const useType = params.useType || "접속"; // API 키 조회: DB 우선 → 환경변수 폴백 const apiKey = await getApiKey(params.companyCode); if (!apiKey) { logger.warn( "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." ); await saveLog({ companyCode: params.companyCode || "", userId: params.userId, userName: params.userName, useType, connectIp: params.remoteAddr, sendStatus: "SKIPPED", responseStatus: null, errorMessage: "API 키 미설정", logDt: logTimeToUse, }); return; } try { const logData = { crtfcKey: apiKey, logDt, useSe: useType, sysUser: params.userId, conectIp: params.remoteAddr, dataUsgqty: "", }; const logDataJson = JSON.stringify(logData); const response = await axios.get(SMART_FACTORY_LOG_URL, { params: { logData: logDataJson }, timeout: 5000, }); const responseBody = typeof response.data === "string" ? response.data : JSON.stringify(response.data); logger.info(`스마트공장 로그 전송 완료: userId=${params.userId}, status=${response.status}, body=${responseBody}`); // 응답 body에 에러가 있을 수 있음 (HTTP 200이지만 실제 실패) const isRealSuccess = !responseBody.includes("FAIL") && !responseBody.includes("error") && !responseBody.includes("ERR"); await saveLog({ companyCode: params.companyCode || "", userId: params.userId, userName: params.userName, useType, connectIp: params.remoteAddr, sendStatus: isRealSuccess ? "SUCCESS" : "FAIL", responseStatus: response.status, errorMessage: isRealSuccess ? null : responseBody, logDt: logTimeToUse, }); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error("스마트공장 로그 전송 실패", { userId: params.userId, error: errorMsg, }); await saveLog({ companyCode: params.companyCode || "", userId: params.userId, userName: params.userName, useType, connectIp: params.remoteAddr, sendStatus: "FAIL", responseStatus: null, errorMessage: errorMsg, logDt: logTimeToUse, }); } } // ─── 스케줄 엔진 ─── /** * 서버 시작 시 호출 — cron 2개 등록 */ export async function initSmartFactoryScheduler(): Promise { // 매일 00:05 — 오늘 실행 계획 생성 cron.schedule("5 0 * * *", async () => { try { await planDailySends(); } catch (e) { logger.error("스마트공장 일일 계획 생성 실패:", e); } }, { timezone: "Asia/Seoul" }); // 매분 — 시간이 된 사용자 전송 cron.schedule("* * * * *", async () => { try { await executeScheduledSends(); } catch (e) { logger.error("스마트공장 스케줄 전송 실패:", e); } }, { timezone: "Asia/Seoul" }); // 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성) // 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지 logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)"); } /** * 오늘의 전송 계획 생성 */ export async function planDailySends(): Promise { const today = new Date(); const todayStr = formatDate(today); const dayOfWeek = today.getDay(); // 0=일, 6=토 // 활성 스케줄 조회 const schedules = await query<{ company_code: string; time_start: string; time_end: string; exclude_weekend: boolean; exclude_holidays: boolean; daily_count: number; }>( "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true" ); if (schedules.length === 0) return; // 공휴일 캐시 갱신 await refreshHolidayCache(); for (const schedule of schedules) { const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule; // 주말 체크 if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) { logger.info(`스마트공장 스케줄 ${company_code}: 주말이므로 스킵`); dailyPlan.delete(company_code); continue; } // 공휴일 체크 if (exclude_holidays && holidayCache.has(todayStr)) { logger.info(`스마트공장 스케줄 ${company_code}: 공휴일이므로 스킵`); dailyPlan.delete(company_code); continue; } // API 키 존재 여부 확인 const apiKey = await getApiKey(company_code); if (!apiKey) { logger.info(`스마트공장 스케줄 ${company_code}: API 키 없음, 스킵`); dailyPlan.delete(company_code); continue; } // 해당 회사 활성 사용자 조회 const users = await query<{ user_id: string; user_name: string }>( "SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND (status = 'active' OR status IS NULL)", [company_code] ); if (users.length === 0) { dailyPlan.delete(company_code); continue; } // 오늘 이미 SUCCESS인 사용자 제외 const alreadySent = await query<{ user_id: string }>( "SELECT DISTINCT user_id FROM smart_factory_log WHERE company_code = $1 AND send_status = 'SUCCESS' AND created_at >= $2::date AND created_at < ($2::date + 1)", [company_code, todayStr] ); const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); // 출석률 95% — 매일 약 5%는 랜덤으로 제외 (휴가/외근/결근) const attendees = pendingUsers.filter(() => Math.random() < 0.95); if (attendees.length === 0) { logger.info(`스마트공장 스케줄 ${company_code}: 전원 이미 전송 완료`); dailyPlan.delete(company_code); continue; } // 접속/종료 쌍 + 다회 시각 배정 const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count); dailyPlan.set(company_code, entries); const sessionCount = entries.filter((e) => e.useType === "접속").length; logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}명 × 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`); } } /** * 매분 실행 — 현재 분에 해당하는 사용자 전송 */ async function executeScheduledSends(): Promise { const now = new Date(); const currentMinute = now.getHours() * 60 + now.getMinutes(); for (const [companyCode, entries] of dailyPlan.entries()) { for (const entry of entries) { if (entry.sent) continue; const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes(); if (entryMinute !== currentMinute) continue; // 정확히 해당 분에만 전송 // 전송 entry.sent = true; // 랜덤 내부망 IP 생성 const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; try { await sendSmartFactoryLog({ userId: entry.userId, userName: entry.userName, remoteAddr: randomIp, useType: entry.useType, companyCode: entry.companyCode, logTime: entry.scheduledTime, }); } catch (e) { logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e); } // rate limit 방지 — 300ms 대기 await sleep(300); } } } /** * 오늘 실행 계획 현황 반환 */ export function getTodayPlanStatus(): Array<{ companyCode: string; total: number; sent: number; remaining: number; }> { const result: Array<{ companyCode: string; total: number; sent: number; remaining: number }> = []; for (const [companyCode, entries] of dailyPlan.entries()) { const sent = entries.filter((e) => e.sent).length; result.push({ companyCode, total: entries.length, sent, remaining: entries.length - sent, }); } return result; } // ─── 내부 함수 ─── /** * 사용자별 접속/종료 쌍을 생성 * dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤) */ function assignSessionPairs( users: Array<{ user_id: string; user_name: string }>, today: Date, timeStart: string, timeEnd: string, companyCode: string, dailyCount: number ): ScheduledEntry[] { const [startH, startM] = timeStart.split(":").map(Number); const [endH, endM] = timeEnd.split(":").map(Number); const startSec = startH * 3600 + startM * 60; const endSec = endH * 3600 + endM * 60; const totalSec = endSec - startSec; if (totalSec <= 0) return []; const allEntries: ScheduledEntry[] = []; const maxCount = Math.max(1, Math.min(3, dailyCount)); for (const user of users) { // 사용자별 1 ~ maxCount 사이 랜덤 횟수 const count = Math.floor(Math.random() * maxCount) + 1; // 시간대를 횟수로 균등 분할 const slotSec = Math.floor(totalSec / count); for (let i = 0; i < count; i++) { const slotStart = startSec + slotSec * i; const slotEnd = i < count - 1 ? slotStart + slotSec : endSec; // 접속 시각: 슬롯 전반부에서 랜덤 const loginWindow = Math.floor((slotEnd - slotStart) * 0.4); // 슬롯의 앞 40% const loginSec = slotStart + Math.floor(Math.random() * Math.max(loginWindow, 60)); const clampedLoginSec = Math.min(loginSec, endSec - 120); // 최소 2분 여유 // 종료 시각: 접속 후 30분~2시간 사이 랜덤 const minSession = 30 * 60; // 30분 const maxSession = 120 * 60; // 2시간 const sessionLen = minSession + Math.floor(Math.random() * (maxSession - minSession)); const logoutSec = Math.min(clampedLoginSec + sessionLen, endSec - 1); // 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵 if (logoutSec - clampedLoginSec < 120) continue; const loginTime = secToDate(today, clampedLoginSec); const logoutTime = secToDate(today, logoutSec); allEntries.push({ userId: user.user_id, userName: user.user_name, companyCode, scheduledTime: loginTime, useType: "접속", sent: false, }); allEntries.push({ userId: user.user_id, userName: user.user_name, companyCode, scheduledTime: logoutTime, useType: "종료", sent: false, }); } } // 시각순 정렬 return allEntries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); } /** 초(하루 내)를 Date로 변환 */ function secToDate(today: Date, sec: number): Date { const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const d = new Date(today); d.setHours(h, m, s, Math.floor(Math.random() * 1000)); return d; } /** 공휴일 캐시 갱신 */ async function refreshHolidayCache(): Promise { const today = formatDate(new Date()); if (holidayCacheDate === today) return; // 오늘 이미 갱신함 try { const holidays = await query<{ holiday_date: string }>( "SELECT holiday_date::text FROM smart_factory_holidays" ); holidayCache = new Set(holidays.map((h) => h.holiday_date.substring(0, 10))); holidayCacheDate = today; } catch (e) { logger.error("공휴일 캐시 갱신 실패:", e); } } /** DB에 로그 저장 */ async function saveLog(params: { companyCode: string; userId: string; userName?: string; useType: string; connectIp: string; sendStatus: string; responseStatus: number | null; errorMessage: string | null; logDt: Date; }): Promise { try { await query( `INSERT INTO smart_factory_log (company_code, user_id, user_name, use_type, connect_ip, send_status, response_status, error_message, log_dt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ params.companyCode, params.userId, params.userName || null, params.useType, params.connectIp, params.sendStatus, params.responseStatus, params.errorMessage, params.logDt, ] ); } catch (dbError) { logger.error("스마트공장 로그 DB 저장 실패", { userId: params.userId, error: dbError instanceof Error ? dbError.message : dbError, }); } } /** yyyy-MM-dd HH:mm:ss.SSS 형식 */ function formatDateTime(date: Date): string { const y = date.getFullYear(); const M = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); const H = String(date.getHours()).padStart(2, "0"); const m = String(date.getMinutes()).padStart(2, "0"); const s = String(date.getSeconds()).padStart(2, "0"); const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`; } /** yyyy-MM-dd 형식 */ function formatDate(date: Date): string { const y = date.getFullYear(); const M = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${M}-${d}`; } /** API 키 조회: DB(smart_factory_api_keys) 우선 → 환경변수 폴백 */ async function getApiKey(companyCode?: string): Promise { if (!companyCode) return process.env.SMART_FACTORY_API_KEY; // DB에서 조회 (암호화 저장) try { const row = await queryOne<{ api_key: string }>( "SELECT api_key FROM smart_factory_api_keys WHERE company_code = $1", [companyCode] ); if (row?.api_key) { return encryptionService.decrypt(row.api_key); } } catch { // DB 조회/복호화 실패 시 환경변수로 폴백 } // 환경변수 폴백 return process.env[`SMART_FACTORY_API_KEY_${companyCode}`] || process.env.SMART_FACTORY_API_KEY; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }