diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1c6a9d9e..33d072d4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -449,6 +449,7 @@ async function initializeServices() { runApprovalSystemMigration, runUserMailAccountsMigration, runMessengerMigration, + runSmartFactoryLogMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -457,6 +458,7 @@ async function initializeServices() { await runApprovalSystemMigration(); await runUserMailAccountsMigration(); await runMessengerMigration(); + await runSmartFactoryLogMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 4b40ce6e..4dcdc275 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -87,6 +87,7 @@ export class AuthController { // 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함) sendSmartFactoryLog({ userId: userInfo.userId, + userName: userInfo.userName, remoteAddr, useType: "접속", companyCode: userInfo.companyCode, diff --git a/backend-node/src/controllers/smartFactoryLogController.ts b/backend-node/src/controllers/smartFactoryLogController.ts new file mode 100644 index 00000000..ed4b353c --- /dev/null +++ b/backend-node/src/controllers/smartFactoryLogController.ts @@ -0,0 +1,218 @@ +// 스마트공장 활용 로그 조회 컨트롤러 +// 최고관리자(*) 전용 — 회사별 필터링 가능 + +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/permissionMiddleware"; +import { query, queryOne } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * GET /api/admin/smart-factory-log + * 스마트공장 로그 목록 조회 + */ +export const getSmartFactoryLogs = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { + companyCode, + userId, + sendStatus, + dateFrom, + dateTo, + search, + page = "1", + limit = "50", + } = req.query; + + const whereConditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 회사 필터 + if (companyCode && companyCode !== "all") { + whereConditions.push(`sfl.company_code = $${paramIndex}`); + queryParams.push(companyCode); + paramIndex++; + } + + // 사용자 필터 + if (userId && (userId as string).trim()) { + whereConditions.push(`sfl.user_id ILIKE $${paramIndex}`); + queryParams.push(`%${(userId as string).trim()}%`); + paramIndex++; + } + + // 전송 상태 필터 + if (sendStatus && sendStatus !== "all") { + whereConditions.push(`sfl.send_status = $${paramIndex}`); + queryParams.push(sendStatus); + paramIndex++; + } + + // 날짜 범위 필터 + if (dateFrom) { + whereConditions.push(`sfl.created_at >= $${paramIndex}`); + queryParams.push(dateFrom); + paramIndex++; + } + if (dateTo) { + whereConditions.push(`sfl.created_at < ($${paramIndex}::date + 1)`); + queryParams.push(dateTo); + paramIndex++; + } + + // 통합 검색 + if (search && (search as string).trim()) { + whereConditions.push( + `(sfl.user_id ILIKE $${paramIndex} OR sfl.user_name ILIKE $${paramIndex} OR sfl.connect_ip ILIKE $${paramIndex} OR sfl.error_message ILIKE $${paramIndex})` + ); + queryParams.push(`%${(search as string).trim()}%`); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + // 총 개수 + const countResult = await queryOne<{ total: string }>( + `SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`, + queryParams + ); + const total = parseInt(countResult?.total || "0", 10); + + // 페이지네이션 + const pageNum = Math.max(1, parseInt(page as string, 10)); + const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10))); + const offset = (pageNum - 1) * limitNum; + + // 데이터 조회 (회사명 JOIN) + const logs = await query( + `SELECT sfl.*, cm.company_name + FROM smart_factory_log sfl + LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code + ${whereClause} + ORDER BY sfl.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...queryParams, limitNum, offset] + ); + + res.status(200).json({ + success: true, + data: logs, + total, + page: pageNum, + limit: limitNum, + }); + } catch (error) { + logger.error("스마트공장 로그 조회 실패:", error); + res.status(500).json({ + success: false, + message: "스마트공장 로그 조회 중 오류가 발생했습니다.", + error: { + code: "SERVER_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }); + } +}; + +/** + * GET /api/admin/smart-factory-log/stats + * 스마트공장 로그 통계 (회사별 요약) + */ +export const getSmartFactoryLogStats = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, days = "30" } = req.query; + const daysNum = parseInt(days as string, 10) || 30; + + const whereConditions: string[] = [ + `sfl.created_at >= NOW() - INTERVAL '${daysNum} days'`, + ]; + const queryParams: any[] = []; + let paramIndex = 1; + + if (companyCode && companyCode !== "all") { + whereConditions.push(`sfl.company_code = $${paramIndex}`); + queryParams.push(companyCode); + paramIndex++; + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + + // 상태별 건수 + const statusCounts = await query<{ send_status: string; count: string }>( + `SELECT send_status, COUNT(*) as count + FROM smart_factory_log sfl + ${whereClause} + GROUP BY send_status`, + queryParams + ); + + // 회사별 건수 + const companyCounts = await query<{ + company_code: string; + company_name: string; + count: string; + }>( + `SELECT sfl.company_code, COALESCE(cm.company_name, sfl.company_code) as company_name, COUNT(*) as count + FROM smart_factory_log sfl + LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code + ${whereClause} + GROUP BY sfl.company_code, cm.company_name + ORDER BY count DESC`, + queryParams + ); + + // 일별 추이 + const dailyCounts = await query<{ date: string; count: string }>( + `SELECT DATE(sfl.created_at) as date, COUNT(*) as count + FROM smart_factory_log sfl + ${whereClause} + GROUP BY DATE(sfl.created_at) + ORDER BY date DESC + LIMIT ${daysNum}`, + queryParams + ); + + // 전체 건수 + const totalResult = await queryOne<{ total: string }>( + `SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`, + queryParams + ); + + res.status(200).json({ + success: true, + data: { + total: parseInt(totalResult?.total || "0", 10), + statusCounts: statusCounts.map((r) => ({ + status: r.send_status, + count: parseInt(r.count, 10), + })), + companyCounts: companyCounts.map((r) => ({ + companyCode: r.company_code, + companyName: r.company_name, + count: parseInt(r.count, 10), + })), + dailyCounts: dailyCounts.map((r) => ({ + date: r.date, + count: parseInt(r.count, 10), + })), + }, + }); + } catch (error) { + logger.error("스마트공장 로그 통계 조회 실패:", error); + res.status(500).json({ + success: false, + message: "통계 조회 중 오류가 발생했습니다.", + error: { + code: "SERVER_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }); + } +}; diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 73523b92..f915d962 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -170,6 +170,35 @@ export async function runMessengerMigration() { } } +/** + * 스마트공장 활용 로그 테이블 마이그레이션 + */ +export async function runSmartFactoryLogMigration() { + try { + console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/200_create_smart_factory_log.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 스마트공장 로그 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 스마트공장 로그 테이블 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + export async function runDtgManagementLogMigration() { try { console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 3a173cbe..d02aac7e 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -32,6 +32,10 @@ import { setUserLocale, getTableSchema, // 테이블 스키마 조회 } from "../controllers/adminController"; +import { + getSmartFactoryLogs, + getSmartFactoryLogStats, +} from "../controllers/smartFactoryLogController"; import { authenticateToken } from "../middleware/authMiddleware"; import { requireSuperAdmin } from "../middleware/permissionMiddleware"; @@ -84,4 +88,8 @@ router.post("/user-locale", setUserLocale); // 테이블 스키마 API (엑셀 업로드 컬럼 매핑용) router.get("/tables/:tableName/schema", getTableSchema); +// 스마트공장 활용 로그 API (최고관리자 전용) +router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs); +router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats); + export default router; diff --git a/backend-node/src/utils/smartFactoryLog.ts b/backend-node/src/utils/smartFactoryLog.ts index 18c5b573..e4fd89f0 100644 --- a/backend-node/src/utils/smartFactoryLog.ts +++ b/backend-node/src/utils/smartFactoryLog.ts @@ -3,20 +3,26 @@ import axios from "axios"; import { logger } from "./logger"; +import { query } from "../database/db"; const SMART_FACTORY_LOG_URL = "https://log.smart-factory.kr/apisvc/sendLogDataJSON.do"; /** - * 스마트공장 활용 로그 전송 + * 스마트공장 활용 로그 전송 + DB 저장 * 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음 */ export async function sendSmartFactoryLog(params: { userId: string; + userName?: string; remoteAddr: string; useType?: string; companyCode?: string; }): Promise { + const now = new Date(); + const logDt = formatDateTime(now); + const useType = params.useType || "접속"; + // 회사별 키 우선 조회, 없으면 공통 키 폴백 const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`]) || process.env.SMART_FACTORY_API_KEY; @@ -25,17 +31,26 @@ export async function sendSmartFactoryLog(params: { logger.warn( "SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다." ); + // SKIPPED 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "SKIPPED", + responseStatus: null, + errorMessage: "API 키 미설정", + logDt: now, + }); return; } try { - const now = new Date(); - const logDt = formatDateTime(now); - const logData = { crtfcKey: apiKey, logDt, - useSe: params.useType || "접속", + useSe: useType, sysUser: params.userId, conectIp: params.remoteAddr, dataUsgqty: "", @@ -52,11 +67,76 @@ export async function sendSmartFactoryLog(params: { userId: params.userId, status: response.status, }); + + // SUCCESS 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "SUCCESS", + responseStatus: response.status, + errorMessage: null, + logDt: now, + }); } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); // 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록 logger.error("스마트공장 로그 전송 실패", { userId: params.userId, - error: error instanceof Error ? error.message : error, + error: errorMsg, + }); + + // FAIL 상태로 DB 기록 + await saveLog({ + companyCode: params.companyCode || "", + userId: params.userId, + userName: params.userName, + useType, + connectIp: params.remoteAddr, + sendStatus: "FAIL", + responseStatus: null, + errorMessage: errorMsg, + logDt: now, + }); + } +} + +/** 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 { + 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) { + // DB 저장 실패해도 로그인 프로세스에 영향 없도록 + logger.error("스마트공장 로그 DB 저장 실패", { + userId: params.userId, + error: dbError instanceof Error ? dbError.message : dbError, }); } } diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 755475ff..8ab60253 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -23,8 +23,11 @@ services: - KMA_API_KEY=${KMA_API_KEY} - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} - - SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-} + - SMART_FACTORY_API_KEY_COMPANY_7=${SMART_FACTORY_API_KEY_COMPANY_7:-} + - SMART_FACTORY_API_KEY_COMPANY_8=${SMART_FACTORY_API_KEY_COMPANY_8:-} - SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-} + - SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-} + - SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] diff --git a/frontend/app/(main)/admin/smart-factory-log/page.tsx b/frontend/app/(main)/admin/smart-factory-log/page.tsx new file mode 100644 index 00000000..73a93daf --- /dev/null +++ b/frontend/app/(main)/admin/smart-factory-log/page.tsx @@ -0,0 +1,478 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Search, + RefreshCw, + ChevronLeft, + ChevronRight, + Factory, + CheckCircle2, + XCircle, + MinusCircle, + Building2, + Activity, + Users, +} from "lucide-react"; +import { + getSmartFactoryLogs, + getSmartFactoryLogStats, + SmartFactoryLogEntry, + SmartFactoryLogFilters, + SmartFactoryLogStats, +} from "@/lib/api/smartFactoryLog"; +import { getCompanyList } from "@/lib/api/company"; +import { useAuth } from "@/hooks/useAuth"; +import { Company } from "@/types/company"; + +const STATUS_CONFIG = { + SUCCESS: { label: "성공", variant: "success" as const, icon: CheckCircle2 }, + FAIL: { label: "실패", variant: "destructive" as const, icon: XCircle }, + SKIPPED: { label: "건너뜀", variant: "secondary" as const, icon: MinusCircle }, +}; + +export default function SmartFactoryLogPage() { + const { user } = useAuth(); + const [logs, setLogs] = useState([]); + const [stats, setStats] = useState(null); + const [companies, setCompanies] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + + const [filters, setFilters] = useState({ + companyCode: "", + sendStatus: "", + search: "", + dateFrom: "", + dateTo: "", + page: 1, + limit: 50, + }); + + // 회사 목록 로드 + useEffect(() => { + getCompanyList() + .then(setCompanies) + .catch(() => {}); + }, []); + + // 로그 데이터 로드 + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const [logRes, statsRes] = await Promise.all([ + getSmartFactoryLogs(filters), + getSmartFactoryLogStats( + filters.companyCode || undefined, + 30 + ), + ]); + if (logRes.success) { + setLogs(logRes.data); + setTotal(logRes.total); + } + if (statsRes.success) { + setStats(statsRes.data); + } + } catch (error) { + console.error("스마트공장 로그 조회 실패:", error); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + const totalPages = Math.ceil(total / (filters.limit || 50)); + + const handleFilterChange = (key: keyof SmartFactoryLogFilters, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value, page: 1 })); + }; + + const formatDate = (dateStr: string) => { + if (!dateStr) return "-"; + const d = new Date(dateStr); + return d.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + // 최고관리자가 아니면 접근 불가 안내 + if (user && user.userType !== "SUPER_ADMIN" && user.companyCode !== "*") { + return ( +
+

최고 관리자만 접근할 수 있습니다.

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

+ + 스마트공장 활용 로그 +

+

+ 로그인 시 log.smart-factory.kr로 전송된 로그 기록 +

+
+ +
+ + {/* 통계 카드 */} + {stats && ( +
+ + +
+
+ +
+
+

최근 30일 전체

+

{stats.total.toLocaleString()}건

+
+
+
+
+ + +
+
+ +
+
+

전송 성공

+

+ {(stats.statusCounts.find((s) => s.status === "SUCCESS")?.count || 0).toLocaleString()}건 +

+
+
+
+
+ + +
+
+ +
+
+

전송 실패

+

+ {(stats.statusCounts.find((s) => s.status === "FAIL")?.count || 0).toLocaleString()}건 +

+
+
+
+
+ + +
+
+ +
+
+

활용 회사

+

+ {stats.companyCounts.length}개사 +

+
+
+
+
+
+ )} + + {/* 회사별 현황 */} + {stats && stats.companyCounts.length > 0 && ( + + + + + 회사별 전송 현황 (최근 30일) + + + +
+ {stats.companyCounts.map((c) => ( + + ))} +
+
+
+ )} + + {/* 필터 */} + + +
+
+ +
+ + handleFilterChange("search", e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + handleFilterChange("dateFrom", e.target.value)} + /> +
+ +
+ + handleFilterChange("dateTo", e.target.value)} + /> +
+
+
+
+ + {/* 테이블 */} + + +
+ + 전송 기록 ({total.toLocaleString()}건) + +
+ +
+
+
+ +
+ + + + No + 회사 + 사용자 + 유형 + 접속 IP + 상태 + 응답 + 에러 메시지 + 전송 시각 + + + + {loading ? ( + + + 로딩 중... + + + ) : logs.length === 0 ? ( + + + 로그가 없습니다. + + + ) : ( + logs.map((log, idx) => { + const statusConf = STATUS_CONFIG[log.send_status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.FAIL; + const StatusIcon = statusConf.icon; + return ( + + + {total - ((filters.page || 1) - 1) * (filters.limit || 50) - idx} + + + {log.company_name || log.company_code} + + +
+ {log.user_name || "-"} + + {log.user_id} + +
+
+ {log.use_type} + {log.connect_ip} + + + + {statusConf.label} + + + + {log.response_status || "-"} + + + {log.error_message || "-"} + + {formatDate(log.created_at)} +
+ ); + }) + )} +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+

+ {((filters.page || 1) - 1) * (filters.limit || 50) + 1}~ + {Math.min((filters.page || 1) * (filters.limit || 50), total)} / {total.toLocaleString()}건 +

+
+ + + {filters.page || 1} / {totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/lib/api/smartFactoryLog.ts b/frontend/lib/api/smartFactoryLog.ts new file mode 100644 index 00000000..cd70b94f --- /dev/null +++ b/frontend/lib/api/smartFactoryLog.ts @@ -0,0 +1,69 @@ +import { apiClient } from "./client"; + +export interface SmartFactoryLogEntry { + id: number; + company_code: string; + company_name: string | null; + user_id: string; + user_name: string | null; + use_type: string; + connect_ip: string; + send_status: "SUCCESS" | "FAIL" | "SKIPPED"; + response_status: number | null; + error_message: string | null; + log_dt: string; + created_at: string; +} + +export interface SmartFactoryLogFilters { + companyCode?: string; + userId?: string; + sendStatus?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface SmartFactoryLogStats { + total: number; + statusCounts: Array<{ status: string; count: number }>; + companyCounts: Array<{ companyCode: string; companyName: string; count: number }>; + dailyCounts: Array<{ date: string; count: number }>; +} + +export async function getSmartFactoryLogs( + filters: SmartFactoryLogFilters +): Promise<{ + success: boolean; + data: SmartFactoryLogEntry[]; + total: number; + page: number; + limit: number; +}> { + const params = new URLSearchParams(); + if (filters.companyCode) params.append("companyCode", filters.companyCode); + if (filters.userId) params.append("userId", filters.userId); + if (filters.sendStatus) params.append("sendStatus", filters.sendStatus); + if (filters.dateFrom) params.append("dateFrom", filters.dateFrom); + if (filters.dateTo) params.append("dateTo", filters.dateTo); + if (filters.search) params.append("search", filters.search); + if (filters.page) params.append("page", String(filters.page)); + if (filters.limit) params.append("limit", String(filters.limit)); + + const response = await apiClient.get(`/admin/smart-factory-log?${params.toString()}`); + return response.data; +} + +export async function getSmartFactoryLogStats( + companyCode?: string, + days?: number +): Promise<{ success: boolean; data: SmartFactoryLogStats }> { + const params = new URLSearchParams(); + if (companyCode) params.append("companyCode", companyCode); + if (days) params.append("days", String(days)); + + const response = await apiClient.get(`/admin/smart-factory-log/stats?${params.toString()}`); + return response.data; +}