Files
vexplor/backend-node/src/utils/smartFactoryLog.ts
kjs 8f228bc7c3 refactor: Update smart factory log scheduling behavior
- Removed immediate plan generation on server start to prevent sending logs for past timestamps.
- Updated the upsertSchedule response message to clarify that the schedule will take effect from the next day at 00:05.

These changes aim to enhance the reliability of the smart factory log scheduling system by ensuring that logs are sent at the correct times and reducing the risk of sending outdated logs.
2026-04-07 18:21:08 +09:00

486 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 스마트공장 활용 로그 전송 유틸리티
// 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<string, ScheduledEntry[]> = new Map();
// 공휴일 캐시 (날짜 문자열 Set, 매일 갱신)
let holidayCache: Set<string> = 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<void> {
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<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" });
// 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성)
// 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지
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;
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<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; // 정확히 해당 분에만 전송
// 전송
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<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);
}
}
/** 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,
});
}
}
/** 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<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));
}