// 스마트공장 활용 로그 전송 유틸리티 // 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; // 초 단위까지 배정된 시각 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 encodedLogData = encodeURIComponent(JSON.stringify(logData)); const response = await axios.get(SMART_FACTORY_LOG_URL, { params: { logData: encodedLogData }, timeout: 5000, }); logger.info("스마트공장 로그 전송 완료", { userId: params.userId, status: response.status, }); await saveLog({ companyCode: params.companyCode || "", userId: params.userId, userName: params.userName, useType, connectIp: params.remoteAddr, sendStatus: "SUCCESS", responseStatus: response.status, errorMessage: null, 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" }); // 서버 시작 시 오늘 계획이 아직 없으면 바로 생성 await planDailySends(); 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; }>( "SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays 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 } = 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 = assignRandomTimes(attendees, today, time_start, time_end, company_code); dailyPlan.set(company_code, entries); logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${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; // 아직 안 됨 if (entryMinute < currentMinute) { // 이미 지난 분인데 못 보낸 것 — 보냄 } // 전송 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: "접속", companyCode: entry.companyCode, logTime: entry.scheduledTime, }); } catch (e) { logger.error(`스마트공장 스케줄 전송 실패: ${entry.userId}`, e); } // rate limit 방지 — 300ms 대기 await sleep(300); } } } /** * 수동 즉시 실행 (관리자 테스트용) */ export async function runScheduleNow(companyCode: string): Promise<{ total: number; sent: number; skipped: number }> { const schedule = await query<{ time_start: string; time_end: string; }>( "SELECT time_start, time_end FROM smart_factory_schedule WHERE company_code = $1 AND is_active = true", [companyCode] ); if (schedule.length === 0) { throw new Error("활성 스케줄이 없습니다."); } // API 키 확인 const apiKey = await getApiKey(companyCode); if (!apiKey) { throw new Error("API 키가 설정되지 않았습니다. API 키 관리에서 먼저 등록해주세요."); } const { time_start, time_end } = schedule[0]; const today = new Date(); // 사용자 조회 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)", [companyCode] ); // 오늘 이미 전송된 사용자 제외 const todayStr = formatDate(today); 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)", [companyCode, todayStr] ); const alreadySentSet = new Set(alreadySent.map((r) => r.user_id)); const pendingUsers = users.filter((u) => !alreadySentSet.has(u.user_id)); let sent = 0; for (const user of pendingUsers) { // 시간 범위 내 랜덤 시각 생성 const randomTime = generateRandomTime(today, time_start, time_end); const randomIp = `192.168.0.${Math.floor(Math.random() * 254) + 1}`; try { await sendSmartFactoryLog({ userId: user.user_id, userName: user.user_name, remoteAddr: randomIp, useType: "접속", companyCode, logTime: randomTime, }); sent++; } catch (e) { logger.error(`스마트공장 즉시 전송 실패: ${user.user_id}`, e); } await sleep(300); } return { total: users.length, sent, skipped: alreadySentSet.size }; } /** * 오늘 실행 계획 현황 반환 */ 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; } // ─── 내부 함수 ─── /** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */ function assignRandomTimes( users: Array<{ user_id: string; user_name: string }>, today: Date, timeStart: string, timeEnd: string, companyCode: string ): 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 slotSize = totalSec / users.length; const entries: ScheduledEntry[] = users.map((user, idx) => { // 각 슬롯 내에서 랜덤 오프셋 (초 단위) const slotStart = startSec + Math.floor(slotSize * idx); const randomOffset = Math.floor(Math.random() * slotSize); const assignedSec = Math.min(slotStart + randomOffset, endSec - 1); const h = Math.floor(assignedSec / 3600); const m = Math.floor((assignedSec % 3600) / 60); const s = assignedSec % 60; const scheduledTime = new Date(today); scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000)); return { userId: user.user_id, userName: user.user_name, companyCode, scheduledTime, sent: false, }; }); // 시각순 정렬 return entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()); } /** 단일 랜덤 시각 생성 (즉시 실행용) */ function generateRandomTime(today: Date, timeStart: string, timeEnd: string): Date { 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 randomSec = startSec + Math.floor(Math.random() * (endSec - startSec)); const h = Math.floor(randomSec / 3600); const m = Math.floor((randomSec % 3600) / 60); const s = randomSec % 60; const time = new Date(today); time.setHours(h, m, s, Math.floor(Math.random() * 1000)); return time; } /** 공휴일 캐시 갱신 */ 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)); }