jskim-node #11
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 공휴일 캐시 갱신 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user