jskim-node #11

Merged
jskim merged 3 commits from jskim-node into main 2026-04-07 08:29:25 +00:00
6 changed files with 108 additions and 138 deletions

View File

@@ -8,7 +8,6 @@ import { logger } from "../utils/logger";
import { encryptionService } from "../services/encryptionService";
import {
sendSmartFactoryLog,
runScheduleNow,
getTodayPlanStatus,
planDailySends,
} from "../utils/smartFactoryLog";
@@ -255,7 +254,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: "회사코드는 필수입니다." });
@@ -263,11 +262,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,
@@ -275,6 +274,7 @@ export const upsertSchedule = async (
timeEnd || "17:30",
excludeWeekend ?? true,
excludeHolidays ?? true,
Math.max(1, Math.min(3, dailyCount || 1)),
]
);
@@ -308,23 +308,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
*/

View File

@@ -38,7 +38,6 @@ import {
getSchedules,
upsertSchedule,
deleteSchedule,
runScheduleNowHandler,
getTodayPlanHandler,
getHolidays,
addHoliday,
@@ -110,7 +109,7 @@ 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);

View File

@@ -16,7 +16,8 @@ interface ScheduledEntry {
userId: string;
userName: string;
companyCode: string;
scheduledTime: Date; // 초 단위까지 배정된 시각
scheduledTime: Date;
useType: "접속" | "종료";
sent: boolean;
}
@@ -165,8 +166,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;
@@ -175,7 +177,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)) {
@@ -227,11 +229,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})`);
}
}
@@ -263,7 +266,7 @@ async function executeScheduledSends(): Promise<void> {
userId: entry.userId,
userName: entry.userName,
remoteAddr: randomIp,
useType: "접속",
useType: entry.useType,
companyCode: entry.companyCode,
logTime: entry.scheduledTime,
});
@@ -277,71 +280,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 };
}
/**
* 오늘 실행 계획 현황 반환
@@ -367,13 +305,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);
@@ -383,49 +325,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;
}
/** 공휴일 캐시 갱신 */

View File

@@ -104,6 +104,7 @@ export default function SmartFactoryLogPage() {
timeEnd: "17:30",
excludeWeekend: true,
excludeHolidays: true,
dailyCount: 1,
});
const [apiKeys, setApiKeys] = useState<ApiKeyEntry[]>([]);
const [editingKey, setEditingKey] = useState({ companyCode: "", apiKey: "" });
@@ -232,7 +233,7 @@ export default function SmartFactoryLogPage() {
await upsertSchedule({
companyCode: s.company_code, isActive: !s.is_active,
timeStart: s.time_start, timeEnd: s.time_end,
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays,
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, dailyCount: s.daily_count || 1,
});
fetchSchedules();
} catch (e) { console.error("스케줄 토글 실패:", e); }
@@ -549,7 +550,7 @@ export default function SmartFactoryLogPage() {
</CardTitle>
<Button size="sm" onClick={() => {
setEditingSchedule({ companyCode: "", isActive: true, timeStart: "08:30", timeEnd: "17:30", excludeWeekend: true, excludeHolidays: true });
setEditingSchedule({ companyCode: "", isActive: true, timeStart: "08:30", timeEnd: "17:30", excludeWeekend: true, excludeHolidays: true, dailyCount: 1 });
setScheduleError(""); setScheduleDialogOpen(true);
}}>
<Plus className="h-4 w-4 mr-1" />
@@ -568,6 +569,7 @@ export default function SmartFactoryLogPage() {
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[90px]"> </TableHead>
<TableHead className="w-[90px]"> </TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
@@ -581,6 +583,7 @@ export default function SmartFactoryLogPage() {
</TableCell>
<TableCell>{s.exclude_weekend ? <Badge variant="secondary">Y</Badge> : <span className="text-muted-foreground text-xs">N</span>}</TableCell>
<TableCell>{s.exclude_holidays ? <Badge variant="secondary">Y</Badge> : <span className="text-muted-foreground text-xs">N</span>}</TableCell>
<TableCell className="text-sm text-center">{s.daily_count || 1}</TableCell>
<TableCell><Switch checked={s.is_active} onCheckedChange={() => handleToggleSchedule(s)} /></TableCell>
<TableCell>
<div className="flex items-center gap-1">
@@ -589,7 +592,7 @@ export default function SmartFactoryLogPage() {
setEditingSchedule({
companyCode: s.company_code, isActive: s.is_active,
timeStart: s.time_start, timeEnd: s.time_end,
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays,
excludeWeekend: s.exclude_weekend, excludeHolidays: s.exclude_holidays, dailyCount: s.daily_count || 1,
});
setScheduleError(""); setScheduleDialogOpen(true);
}}>
@@ -959,6 +962,20 @@ export default function SmartFactoryLogPage() {
<label className="text-sm"> </label>
<Switch checked={editingSchedule.excludeHolidays} onCheckedChange={(v) => setEditingSchedule((p) => ({ ...p, excludeHolidays: v }))} />
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm"> </label>
<p className="text-xs text-muted-foreground"> / </p>
</div>
<Select value={String(editingSchedule.dailyCount)} onValueChange={(v) => setEditingSchedule((p) => ({ ...p, dailyCount: parseInt(v, 10) }))}>
<SelectTrigger className="w-[80px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{scheduleError && (
<p className="text-sm text-destructive px-6">{scheduleError}</p>

View File

@@ -79,6 +79,7 @@ export interface SmartFactorySchedule {
time_end: string;
exclude_weekend: boolean;
exclude_holidays: boolean;
daily_count: number;
created_at: string;
updated_at: string;
}
@@ -109,6 +110,7 @@ export async function upsertSchedule(params: {
timeEnd: string;
excludeWeekend: boolean;
excludeHolidays: boolean;
dailyCount: number;
}): Promise<{ success: boolean; message: string }> {
const response = await apiClient.post("/admin/smart-factory-log/schedules", params);
return response.data;

View File

@@ -31,9 +31,17 @@ const nextConfig = {
];
},
// 개발 환경에서 CORS 처리
// 캐시 및 CORS 헤더
async headers() {
return [
// HTML 페이지: 배포 후 즉시 반영되도록 캐시 금지
{
source: "/((?!_next/static|_next/image|favicon.ico).*)",
headers: [
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
],
},
// API CORS
{
source: "/api/:path*",
headers: [