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 ;
2026-04-07 17:29:03 +09:00
scheduledTime : Date ;
useType : "접속" | "종료" ;
2026-04-07 14:16:26 +09:00
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 : "" ,
} ;
2026-04-07 16:45:52 +09:00
const logDataJson = JSON . stringify ( logData ) ;
2026-03-11 18:34:58 +09:00
const response = await axios . get ( SMART_FACTORY_LOG_URL , {
2026-04-07 16:45:52 +09:00
params : { logData : logDataJson } ,
2026-03-11 18:34:58 +09:00
timeout : 5000 ,
} ) ;
2026-04-07 16:45:52 +09:00
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" ) ;
2026-04-07 10:35:16 +09:00
await saveLog ( {
companyCode : params.companyCode || "" ,
userId : params.userId ,
userName : params.userName ,
useType ,
connectIp : params.remoteAddr ,
2026-04-07 16:45:52 +09:00
sendStatus : isRealSuccess ? "SUCCESS" : "FAIL" ,
2026-04-07 10:35:16 +09:00
responseStatus : response.status ,
2026-04-07 16:45:52 +09:00
errorMessage : isRealSuccess ? null : responseBody ,
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" } ) ;
2026-04-07 18:21:08 +09:00
// 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성)
// 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지
2026-04-07 14:16:26 +09:00
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 ;
2026-04-07 17:29:03 +09:00
daily_count : number ;
2026-04-07 14:16:26 +09:00
} > (
2026-04-07 17:29:03 +09:00
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count FROM smart_factory_schedule WHERE is_active = true"
2026-04-07 14:16:26 +09:00
) ;
if ( schedules . length === 0 ) return ;
// 공휴일 캐시 갱신
await refreshHolidayCache ( ) ;
for ( const schedule of schedules ) {
2026-04-07 17:29:03 +09:00
const { company_code , time_start , time_end , exclude_weekend , exclude_holidays , daily_count } = schedule ;
2026-04-07 14:16:26 +09:00
// 주말 체크
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 ;
}
2026-04-07 17:29:03 +09:00
// 접속/종료 쌍 + 다회 시각 배정
const entries = assignSessionPairs ( attendees , today , time_start , time_end , company_code , daily_count ) ;
2026-04-07 14:16:26 +09:00
dailyPlan . set ( company_code , entries ) ;
2026-04-07 17:29:03 +09:00
const sessionCount = entries . filter ( ( e ) = > e . useType === "접속" ) . length ;
logger . info ( ` 스마트공장 스케줄 ${ company_code } : ${ attendees . length } 명 × 최대 ${ daily_count } 회 = ${ sessionCount } 세션 계획 ( ${ time_start } ~ ${ time_end } ) ` ) ;
2026-04-07 14:16:26 +09:00
}
}
/ * *
* 매 분 실 행 — 현 재 분 에 해 당 하 는 사 용 자 전 송
* /
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 ( ) ;
2026-04-07 18:21:08 +09:00
if ( entryMinute !== currentMinute ) continue ; // 정확히 해당 분에만 전송
2026-04-07 14:16:26 +09:00
// 전송
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 ,
2026-04-07 17:29:03 +09:00
useType : entry.useType ,
2026-04-07 14:16:26 +09:00
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 ;
}
// ─── 내부 함수 ───
2026-04-07 17:29:03 +09:00
/ * *
* 사 용 자 별 접 속 / 종 료 쌍 을 생 성
* dailyCount : 최대 접 속 횟 수 ( 사 용 자 별 1 ~ dailyCount 랜 덤 )
* /
function assignSessionPairs (
2026-04-07 14:16:26 +09:00
users : Array < { user_id : string ; user_name : string } > ,
today : Date ,
timeStart : string ,
timeEnd : string ,
2026-04-07 17:29:03 +09:00
companyCode : string ,
dailyCount : number
2026-04-07 14:16:26 +09:00
) : 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 [ ] ;
2026-04-07 17:29:03 +09:00
const allEntries : ScheduledEntry [ ] = [ ] ;
const maxCount = Math . max ( 1 , Math . min ( 3 , dailyCount ) ) ;
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
for ( const user of users ) {
// 사용자별 1 ~ maxCount 사이 랜덤 횟수
const count = Math . floor ( Math . random ( ) * maxCount ) + 1 ;
// 시간대를 횟수로 균등 분할
const slotSec = Math . floor ( totalSec / count ) ;
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
for ( let i = 0 ; i < count ; i ++ ) {
const slotStart = startSec + slotSec * i ;
const slotEnd = i < count - 1 ? slotStart + slotSec : endSec ;
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
// 접속 시각: 슬롯 전반부에서 랜덤
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분 여유
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
// 종료 시각: 접속 후 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 ) ;
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
// 접속과 종료 시각이 너무 가까우면(2분 미만) 스킵
if ( logoutSec - clampedLoginSec < 120 ) continue ;
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
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 ,
} ) ;
}
}
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
// 시각순 정렬
return allEntries . sort ( ( a , b ) = > a . scheduledTime . getTime ( ) - b . scheduledTime . getTime ( ) ) ;
}
2026-04-07 14:16:26 +09:00
2026-04-07 17:29:03 +09:00
/** 초(하루 내)를 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 ;
2026-04-07 14:16:26 +09:00
}
/** 공휴일 캐시 갱신 */
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 ) ) ;
}