This commit is contained in:
SeongHyun Kim
2026-04-08 10:19:55 +09:00
54 changed files with 34351 additions and 9099 deletions
@@ -7,9 +7,8 @@ import { query, queryOne } from "../database/db";
import { logger } from "../utils/logger";
import { encryptionService } from "../services/encryptionService";
import {
runScheduleNow,
sendSmartFactoryLog,
getTodayPlanStatus,
planDailySends,
} from "../utils/smartFactoryLog";
/**
@@ -254,7 +253,7 @@ export const upsertSchedule = async (
res: Response
): Promise<void> => {
try {
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays } = req.body;
const { companyCode, isActive, timeStart, timeEnd, excludeWeekend, excludeHolidays, dailyCount } = req.body;
if (!companyCode) {
res.status(400).json({ success: false, message: "회사코드는 필수입니다." });
@@ -262,11 +261,11 @@ export const upsertSchedule = async (
}
await query(
`INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`INSERT INTO smart_factory_schedule (company_code, is_active, time_start, time_end, exclude_weekend, exclude_holidays, daily_count, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (company_code) DO UPDATE SET
is_active = $2, time_start = $3, time_end = $4,
exclude_weekend = $5, exclude_holidays = $6, updated_at = NOW()`,
exclude_weekend = $5, exclude_holidays = $6, daily_count = $7, updated_at = NOW()`,
[
companyCode,
isActive ?? false,
@@ -274,13 +273,12 @@ export const upsertSchedule = async (
timeEnd || "17:30",
excludeWeekend ?? true,
excludeHolidays ?? true,
Math.max(1, Math.min(3, dailyCount || 1)),
]
);
// 스케줄 변경 시 오늘 계획 재생성
await planDailySends();
res.json({ success: true, message: "스케줄이 저장되었습니다." });
// 계획은 매일 00:05에만 생성 (즉시 재생성하면 지난 시각 소급 전송 위험)
res.json({ success: true, message: "스케줄이 저장되었습니다. 내일 00:05부터 적용됩니다." });
} catch (error) {
logger.error("스케줄 저장 실패:", error);
res.status(500).json({ success: false, message: "스케줄 저장 실패" });
@@ -307,23 +305,6 @@ export const deleteSchedule = async (
/**
* POST /api/admin/smart-factory-log/schedules/:companyCode/run-now
*/
export const runScheduleNowHandler = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
const result = await runScheduleNow(companyCode);
res.json({ success: true, data: result });
} catch (error) {
logger.error("즉시 실행 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "즉시 실행 실패",
});
}
};
/**
* GET /api/admin/smart-factory-log/schedules/today-plan
*/
@@ -502,3 +483,92 @@ export const deleteApiKey = async (
res.status(500).json({ success: false, message: "API 키 삭제 실패" });
}
};
// ─── 즉시 전송 ───
/**
* GET /api/admin/smart-factory-log/users/:companyCode
* 회사별 사용자 목록 조회 (즉시 전송 대상 선택용)
*/
export const getCompanyUsers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
const users = await query<any>(
`SELECT user_id, user_name, dept_name
FROM user_info
WHERE company_code = $1 AND (status = 'active' OR status IS NULL)
ORDER BY user_name`,
[companyCode]
);
res.json({ success: true, data: users });
} catch (error) {
logger.error("사용자 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "사용자 목록 조회 실패" });
}
};
/**
* POST /api/admin/smart-factory-log/send-now
* 선택한 사용자 즉시 전송
* body: { companyCode, userIds: string[], timeStart?, timeEnd? }
*/
export const sendNow = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode, userIds } = req.body;
logger.info(`=== 즉시 전송 API 호출 === companyCode=${companyCode}, userIds=${JSON.stringify(userIds)}`);
if (!companyCode || !userIds || userIds.length === 0) {
res.status(400).json({ success: false, message: "회사코드와 사용자를 선택해주세요." });
return;
}
// 사용자 정보 조회
const users = await query<{ user_id: string; user_name: string }>(
`SELECT user_id, user_name FROM user_info WHERE company_code = $1 AND user_id = ANY($2)`,
[companyCode, userIds]
);
logger.info(`즉시 전송 대상: ${users.length}명 (조회된 사용자: ${users.map(u => u.user_id).join(", ")})`);
// 현재 시간으로 즉시 전송
let success = 0;
let fail = 0;
const remoteAddr = req.ip || "127.0.0.1";
for (const user of users) {
try {
logger.info(`즉시 전송 시작: ${user.user_id}`);
await sendSmartFactoryLog({
userId: user.user_id,
userName: user.user_name,
remoteAddr,
useType: "접속",
companyCode,
});
success++;
logger.info(`즉시 전송 성공: ${user.user_id}`);
} catch (e) {
fail++;
logger.error(`즉시 전송 실패: ${user.user_id}`, e);
}
}
res.json({
success: true,
data: { total: users.length, success, fail },
message: `${success}명 전송 완료${fail > 0 ? `, ${fail}명 실패` : ""}`,
});
} catch (error) {
logger.error("즉시 전송 실패:", error);
res.status(500).json({ success: false, message: "즉시 전송 실패" });
}
};
+7 -2
View File
@@ -38,7 +38,6 @@ import {
getSchedules,
upsertSchedule,
deleteSchedule,
runScheduleNowHandler,
getTodayPlanHandler,
getHolidays,
addHoliday,
@@ -46,6 +45,8 @@ import {
getApiKeys,
saveApiKey,
deleteApiKey,
getCompanyUsers,
sendNow,
} from "../controllers/smartFactoryLogController";
import { authenticateToken } from "../middleware/authMiddleware";
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
@@ -108,13 +109,17 @@ router.get("/smart-factory-log/schedules", requireSuperAdmin, getSchedules);
router.get("/smart-factory-log/schedules/today-plan", requireSuperAdmin, getTodayPlanHandler);
router.post("/smart-factory-log/schedules", requireSuperAdmin, upsertSchedule);
router.delete("/smart-factory-log/schedules/:companyCode", requireSuperAdmin, deleteSchedule);
router.post("/smart-factory-log/schedules/:companyCode/run-now", requireSuperAdmin, runScheduleNowHandler);
// 스마트공장 공휴일 관리 (최고관리자 전용)
router.get("/smart-factory-log/holidays", requireSuperAdmin, getHolidays);
router.post("/smart-factory-log/holidays", requireSuperAdmin, addHoliday);
router.delete("/smart-factory-log/holidays/:id", requireSuperAdmin, deleteHoliday);
// 스마트공장 즉시 전송 (최고관리자 전용)
router.get("/smart-factory-log/users/:companyCode", requireSuperAdmin, getCompanyUsers);
router.post("/smart-factory-log/send-now", requireSuperAdmin, sendNow);
// 스마트공장 API 키 관리 (최고관리자 전용)
router.get("/smart-factory-log/api-keys", requireSuperAdmin, getApiKeys);
router.post("/smart-factory-log/api-keys", requireSuperAdmin, saveApiKey);
+84 -125
View File
@@ -16,7 +16,8 @@ interface ScheduledEntry {
userId: string;
userName: string;
companyCode: string;
scheduledTime: Date; // 초 단위까지 배정된 시각
scheduledTime: Date;
useType: "접속" | "종료";
sent: boolean;
}
@@ -74,17 +75,19 @@ export async function sendSmartFactoryLog(params: {
dataUsgqty: "",
};
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
const logDataJson = JSON.stringify(logData);
const response = await axios.get(SMART_FACTORY_LOG_URL, {
params: { logData: encodedLogData },
params: { logData: logDataJson },
timeout: 5000,
});
logger.info("스마트공장 로그 전송 완료", {
userId: params.userId,
status: response.status,
});
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 || "",
@@ -92,9 +95,9 @@ export async function sendSmartFactoryLog(params: {
userName: params.userName,
useType,
connectIp: params.remoteAddr,
sendStatus: "SUCCESS",
sendStatus: isRealSuccess ? "SUCCESS" : "FAIL",
responseStatus: response.status,
errorMessage: null,
errorMessage: isRealSuccess ? null : responseBody,
logDt: logTimeToUse,
});
} catch (error) {
@@ -142,9 +145,8 @@ export async function initSmartFactoryScheduler(): Promise<void> {
}
}, { timezone: "Asia/Seoul" });
// 서버 시작 시 오늘 계획이 아직 없으면 바로 생성
await planDailySends();
// 서버 시작 시에는 계획 생성하지 않음 (00:05 cron에서만 생성)
// 서버 재시작 시 이미 지난 시각의 로그가 한꺼번에 전송되는 것 방지
logger.info("스마트공장 로그 스케줄러 초기화 완료 (매일 00:05 계획 생성, 매분 전송 실행)");
}
@@ -163,8 +165,9 @@ export async function planDailySends(): Promise<void> {
time_end: string;
exclude_weekend: boolean;
exclude_holidays: boolean;
daily_count: number;
}>(
"SELECT company_code, time_start, time_end, exclude_weekend, exclude_holidays FROM smart_factory_schedule WHERE is_active = true"
"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;
@@ -173,7 +176,7 @@ export async function planDailySends(): Promise<void> {
await refreshHolidayCache();
for (const schedule of schedules) {
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays } = schedule;
const { company_code, time_start, time_end, exclude_weekend, exclude_holidays, daily_count } = schedule;
// 주말 체크
if (exclude_weekend && (dayOfWeek === 0 || dayOfWeek === 6)) {
@@ -225,11 +228,12 @@ export async function planDailySends(): Promise<void> {
continue;
}
// 랜덤 시각 배정 (초 단위)
const entries = assignRandomTimes(attendees, today, time_start, time_end, company_code);
// 접속/종료 쌍 + 다회 시각 배정
const entries = assignSessionPairs(attendees, today, time_start, time_end, company_code, daily_count);
dailyPlan.set(company_code, entries);
logger.info(`스마트공장 스케줄 ${company_code}: ${entries.length}/${pendingUsers.length}명 계획 생성 (${time_start}~${time_end})`);
const sessionCount = entries.filter((e) => e.useType === "접속").length;
logger.info(`스마트공장 스케줄 ${company_code}: ${attendees.length}× 최대${daily_count}회 = ${sessionCount}세션 계획 (${time_start}~${time_end})`);
}
}
@@ -245,10 +249,7 @@ async function executeScheduledSends(): Promise<void> {
if (entry.sent) continue;
const entryMinute = entry.scheduledTime.getHours() * 60 + entry.scheduledTime.getMinutes();
if (entryMinute > currentMinute) continue; // 아직 안 됨
if (entryMinute < currentMinute) {
// 이미 지난 분인데 못 보낸 것 — 보냄
}
if (entryMinute !== currentMinute) continue; // 정확히 해당 분에만 전송
// 전송
entry.sent = true;
@@ -261,7 +262,7 @@ async function executeScheduledSends(): Promise<void> {
userId: entry.userId,
userName: entry.userName,
remoteAddr: randomIp,
useType: "접속",
useType: entry.useType,
companyCode: entry.companyCode,
logTime: entry.scheduledTime,
});
@@ -275,71 +276,6 @@ async function executeScheduledSends(): Promise<void> {
}
}
/**
* 수동 즉시 실행 (관리자 테스트용)
*/
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 };
}
/**
* 오늘 실행 계획 현황 반환
@@ -365,13 +301,17 @@ export function getTodayPlanStatus(): Array<{
// ─── 내부 함수 ───
/** 시간 범위 내에서 사용자들에게 랜덤 시각(초 단위) 배정 */
function assignRandomTimes(
/**
* 사용자별 접속/종료 쌍을 생성
* dailyCount: 최대 접속 횟수 (사용자별 1~dailyCount 랜덤)
*/
function assignSessionPairs(
users: Array<{ user_id: string; user_name: string }>,
today: Date,
timeStart: string,
timeEnd: string,
companyCode: string
companyCode: string,
dailyCount: number
): ScheduledEntry[] {
const [startH, startM] = timeStart.split(":").map(Number);
const [endH, endM] = timeEnd.split(":").map(Number);
@@ -381,49 +321,68 @@ function assignRandomTimes(
if (totalSec <= 0) return [];
const slotSize = totalSec / users.length;
const allEntries: ScheduledEntry[] = [];
const maxCount = Math.max(1, Math.min(3, dailyCount));
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);
for (const user of users) {
// 사용자별 1 ~ maxCount 사이 랜덤 횟수
const count = Math.floor(Math.random() * maxCount) + 1;
// 시간대를 횟수로 균등 분할
const slotSec = Math.floor(totalSec / count);
const h = Math.floor(assignedSec / 3600);
const m = Math.floor((assignedSec % 3600) / 60);
const s = assignedSec % 60;
for (let i = 0; i < count; i++) {
const slotStart = startSec + slotSec * i;
const slotEnd = i < count - 1 ? slotStart + slotSec : endSec;
const scheduledTime = new Date(today);
scheduledTime.setHours(h, m, s, Math.floor(Math.random() * 1000));
// 접속 시각: 슬롯 전반부에서 랜덤
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분 여유
return {
userId: user.user_id,
userName: user.user_name,
companyCode,
scheduledTime,
sent: false,
};
});
// 종료 시각: 접속 후 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 entries.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime());
return allEntries.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;
/** 초(하루 내)를 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;
}
/** 공휴일 캐시 갱신 */