Files
vexplor_dev/backend-node/src/utils/smartFactoryLog.ts

486 lines
15 KiB
TypeScript
Raw Normal View History

// 스마트공장 활용 로그 전송 유틸리티
// 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));
}