2026-03-11 18:34:58 +09:00
// 스마트공장 활용 로그 전송 유틸리티
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
2026-04-07 14:16:26 +09:00
// + 스케줄 기반 자동 전송 엔진
2026-03-11 18:34:58 +09:00
import axios from "axios" ;
2026-04-07 14:16:26 +09:00
import cron from "node-cron" ;
2026-03-11 18:34:58 +09:00
import { logger } from "./logger" ;
2026-04-07 14:16:26 +09:00
import { query , queryOne } from "../database/db" ;
import { encryptionService } from "../services/encryptionService" ;
2026-03-11 18:34:58 +09:00
const SMART_FACTORY_LOG_URL =
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do" ;
2026-04-07 14:16:26 +09:00
// ─── 스케줄 엔진 상태 ───
interface ScheduledEntry {
userId : string ;
userName : string ;
companyCode : string ;
scheduledTime : Date ; // 초 단위까지 배정된 시각
sent : boolean ;
}
// 오늘의 전송 계획 (회사코드 → 사용자 목록)
const dailyPlan : Map < string , ScheduledEntry [ ] > = new Map ( ) ;
// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신)
let holidayCache : Set < string > = new Set ( ) ;
let holidayCacheDate = "" ;
2026-03-11 18:34:58 +09:00
/ * *
2026-04-07 10:35:16 +09:00
* 스 마 트 공 장 활 용 로 그 전 송 + DB 저 장
2026-04-07 14:16:26 +09:00
* logTime이 주 어 지 면 해 당 시 각 을 logDt로 사 용 ( 스 케 줄 전 송 용 )
2026-03-11 18:34:58 +09:00
* /
export async function sendSmartFactoryLog ( params : {
userId : string ;
2026-04-07 10:35:16 +09:00
userName? : string ;
2026-03-11 18:34:58 +09:00
remoteAddr : string ;
useType? : string ;
2026-04-03 11:23:02 +09:00
companyCode? : string ;
2026-04-07 14:16:26 +09:00
logTime? : Date ;
2026-03-11 18:34:58 +09:00
} ) : Promise < void > {
2026-04-07 14:16:26 +09:00
const logTimeToUse = params . logTime || new Date ( ) ;
const logDt = formatDateTime ( logTimeToUse ) ;
2026-04-07 10:35:16 +09:00
const useType = params . useType || "접속" ;
2026-04-07 14:16:26 +09:00
// API 키 조회: DB 우선 → 환경변수 폴백
const apiKey = await getApiKey ( params . companyCode ) ;
2026-03-11 18:34:58 +09:00
if ( ! apiKey ) {
logger . warn (
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
) ;
2026-04-07 10:35:16 +09:00
await saveLog ( {
companyCode : params.companyCode || "" ,
userId : params.userId ,
userName : params.userName ,
useType ,
connectIp : params.remoteAddr ,
sendStatus : "SKIPPED" ,
responseStatus : null ,
errorMessage : "API 키 미설정" ,
2026-04-07 14:16:26 +09:00
logDt : logTimeToUse ,
2026-04-07 10:35:16 +09:00
} ) ;
2026-03-11 18:34:58 +09:00
return ;
}
try {
const logData = {
crtfcKey : apiKey ,
logDt ,
2026-04-07 10:35:16 +09:00
useSe : useType ,
2026-03-11 18:34:58 +09:00
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 ,
} ) ;
2026-04-07 10:35:16 +09:00
await saveLog ( {
companyCode : params.companyCode || "" ,
userId : params.userId ,
userName : params.userName ,
useType ,
connectIp : params.remoteAddr ,
sendStatus : "SUCCESS" ,
responseStatus : response.status ,
errorMessage : null ,
2026-04-07 14:16:26 +09:00
logDt : logTimeToUse ,
2026-04-07 10:35:16 +09:00
} ) ;
2026-03-11 18:34:58 +09:00
} catch ( error ) {
2026-04-07 10:35:16 +09:00
const errorMsg = error instanceof Error ? error.message : String ( error ) ;
2026-03-11 18:34:58 +09:00
logger . error ( "스마트공장 로그 전송 실패" , {
userId : params.userId ,
2026-04-07 10:35:16 +09:00
error : errorMsg ,
} ) ;
await saveLog ( {
companyCode : params.companyCode || "" ,
userId : params.userId ,
userName : params.userName ,
useType ,
connectIp : params.remoteAddr ,
sendStatus : "FAIL" ,
responseStatus : null ,
errorMessage : errorMsg ,
2026-04-07 14:16:26 +09:00
logDt : logTimeToUse ,
2026-04-07 10:35:16 +09:00
} ) ;
}
}
2026-04-07 14:16:26 +09:00
// ─── 스케줄 엔진 ───
/ * *
* 서 버 시 작 시 호 출 — cron 2 개 등 록
* /
export async function initSmartFactoryScheduler ( ) : Promise < void > {
// 매일 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 < void > {
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 < void > {
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 < void > {
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 ) ;
}
}
2026-04-07 10:35:16 +09:00
/** 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 < void > {
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 ,
2026-03-11 18:34:58 +09:00
} ) ;
}
}
/** 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 } ` ;
}
2026-04-07 14:16:26 +09:00
/** 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 < string | undefined > {
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 < void > {
return new Promise ( ( resolve ) = > setTimeout ( resolve , ms ) ) ;
}