import { Request } from "express"; import { query, pool } from "../database/db"; import logger from "../utils/logger"; export function getClientIp(req: Request): string { const forwarded = req.headers["x-forwarded-for"]; if (forwarded) { const first = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0]; return first.trim(); } const realIp = req.headers["x-real-ip"]; if (realIp) { return Array.isArray(realIp) ? realIp[0] : realIp; } return req.ip || req.socket?.remoteAddress || "unknown"; } export type AuditAction = | "CREATE" | "UPDATE" | "DELETE" | "COPY" | "LOGIN" | "STATUS_CHANGE" | "BATCH_CREATE" | "BATCH_UPDATE" | "BATCH_DELETE"; export type AuditResourceType = | "MENU" | "SCREEN" | "SCREEN_LAYOUT" | "FLOW" | "FLOW_STEP" | "USER" | "ROLE" | "PERMISSION" | "COMPANY" | "CODE_CATEGORY" | "CODE" | "DATA" | "TABLE" | "NUMBERING_RULE" | "BATCH" | "NODE_FLOW"; export interface AuditLogParams { companyCode: string; userId: string; userName?: string; action: AuditAction; resourceType: AuditResourceType; resourceId?: string; resourceName?: string; tableName?: string; summary?: string; changes?: { before?: Record; after?: Record; fields?: string[]; }; ipAddress?: string; requestPath?: string; } export interface AuditLogEntry { id: number; company_code: string; company_name: string | null; user_id: string; user_name: string | null; action: string; resource_type: string; resource_id: string | null; resource_name: string | null; table_name: string | null; summary: string | null; changes: any; ip_address: string | null; request_path: string | null; created_at: string; } export interface AuditLogFilters { companyCode?: string; userId?: string; resourceType?: string; action?: string; tableName?: string; dateFrom?: string; dateTo?: string; search?: string; page?: number; limit?: number; } export interface AuditLogStats { dailyCounts: Array<{ date: string; count: number }>; resourceTypeCounts: Array<{ resource_type: string; count: number }>; actionCounts: Array<{ action: string; count: number }>; topUsers: Array<{ user_id: string; user_name: string; count: number }>; } class AuditLogService { /** * 감사 로그 1건 기록 (fire-and-forget) * 본 작업에 영향을 주지 않도록 에러를 내부에서 처리 */ async log(params: AuditLogParams): Promise { try { logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`); await query( `INSERT INTO system_audit_log (company_code, user_id, user_name, action, resource_type, resource_id, resource_name, table_name, summary, changes, ip_address, request_path) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [ params.companyCode, params.userId, params.userName || null, params.action, params.resourceType, params.resourceId || null, params.resourceName || null, params.tableName || null, params.summary || null, params.changes ? JSON.stringify(params.changes) : null, params.ipAddress || null, params.requestPath || null, ] ); logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`); } catch (error: any) { logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params }); } } /** * 감사 로그 다건 기록 (배치) */ async logBatch(entries: AuditLogParams[]): Promise { if (entries.length === 0) return; try { const values = entries .map( (_, i) => `($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})` ) .join(", "); const params = entries.flatMap((e) => [ e.companyCode, e.userId, e.userName || null, e.action, e.resourceType, e.resourceId || null, e.resourceName || null, e.tableName || null, e.summary || null, e.changes ? JSON.stringify(e.changes) : null, e.ipAddress || null, e.requestPath || null, ]); await query( `INSERT INTO system_audit_log (company_code, user_id, user_name, action, resource_type, resource_id, resource_name, table_name, summary, changes, ip_address, request_path) VALUES ${values}`, params ); } catch (error) { logger.error("감사 로그 배치 기록 실패 (무시됨)", { error }); } } /** * 감사 로그 조회 (페이징, 필터) */ async queryLogs( filters: AuditLogFilters, isSuperAdmin: boolean = false ): Promise<{ data: AuditLogEntry[]; total: number }> { const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (!isSuperAdmin && filters.companyCode) { conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } else if (isSuperAdmin && filters.companyCode) { conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } if (filters.userId) { conditions.push(`sal.user_id = $${paramIndex++}`); params.push(filters.userId); } if (filters.resourceType) { conditions.push(`sal.resource_type = $${paramIndex++}`); params.push(filters.resourceType); } if (filters.action) { conditions.push(`sal.action = $${paramIndex++}`); params.push(filters.action); } if (filters.tableName) { conditions.push(`sal.table_name = $${paramIndex++}`); params.push(filters.tableName); } if (filters.dateFrom) { conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`); params.push(filters.dateFrom); } if (filters.dateTo) { conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`); params.push(filters.dateTo); } if (filters.search) { conditions.push( `(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})` ); params.push(`%${filters.search}%`); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const page = filters.page || 1; const limit = filters.limit || 50; const offset = (page - 1) * limit; const countResult = await query<{ count: string }>( `SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`, params ); const total = parseInt(countResult[0].count, 10); const data = await query( `SELECT sal.*, ci.company_name FROM system_audit_log sal LEFT JOIN company_mng ci ON sal.company_code = ci.company_code ${whereClause} ORDER BY sal.created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...params, limit, offset] ); return { data, total }; } /** * 통계 조회 */ async getStats( companyCode?: string, days: number = 30 ): Promise { const companyFilter = companyCode ? "AND company_code = $1" : ""; const params = companyCode ? [companyCode] : []; const dailyCounts = await query<{ date: string; count: number }>( `SELECT DATE(created_at) as date, COUNT(*)::int as count FROM system_audit_log WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} GROUP BY DATE(created_at) ORDER BY date DESC`, params ); const resourceTypeCounts = await query<{ resource_type: string; count: number; }>( `SELECT resource_type, COUNT(*)::int as count FROM system_audit_log WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} GROUP BY resource_type ORDER BY count DESC`, params ); const actionCounts = await query<{ action: string; count: number }>( `SELECT action, COUNT(*)::int as count FROM system_audit_log WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} GROUP BY action ORDER BY count DESC`, params ); const topUsers = await query<{ user_id: string; user_name: string; count: number; }>( `SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count FROM system_audit_log WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} GROUP BY user_id ORDER BY count DESC LIMIT 10`, params ); return { dailyCounts, resourceTypeCounts, actionCounts, topUsers }; } } export const auditLogService = new AuditLogService();