diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 6f0997a9..7cc4b29d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1814,7 +1814,7 @@ export async function toggleMenuStatus( // 현재 상태 및 회사 코드 조회 const currentMenu = await queryOne( - `SELECT objid, status, company_code FROM menu_info WHERE objid = $1`, + `SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, [Number(menuId)] ); diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index 54b93ee4..98d74fa4 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from "express"; import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; import { logger } from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../services/auditLogService"; const router = Router(); @@ -16,6 +17,7 @@ router.use(authenticateToken); interface AuthenticatedRequest extends Request { user?: { userId: string; + userName: string; companyCode: string; }; } @@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy); + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "CREATE", + resourceType: "CODE_CATEGORY", + resourceId: String(value.valueId), + resourceName: input.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`, + changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon const companyCode = req.user?.companyCode || "*"; const updatedBy = req.user?.userId; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy); if (!value) { @@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "UPDATE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: value.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`, + changes: { + before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined, + after: input, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res const { valueId } = req.params; const companyCode = req.user?.companyCode || "*"; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId)); if (!success) { @@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "DELETE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: beforeValue?.valueLabel || valueId, + tableName: "category_values", + summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`, + changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "삭제되었습니다", diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index a9bd0755..a67ba44e 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -396,6 +396,20 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "UPDATE", + resourceType: "CODE", + resourceId: codeValue, + resourceName: codeData.codeName || codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 수정`, + changes: { after: codeData }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: code, @@ -440,6 +454,19 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + action: "DELETE", + resourceType: "CODE", + resourceId: codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 삭제`, + changes: { before: { categoryCode, codeValue } }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "코드 삭제 성공", diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 631b6360..00baf75d 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -438,6 +438,19 @@ export class DDLController { ); if (result.success) { + auditLogService.log({ + companyCode: userCompanyCode || "", + userId, + action: "DELETE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName, + summary: `테이블 "${tableName}" 삭제`, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: result.message, diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index a3887ab8..9d05a1b7 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -193,6 +193,7 @@ router.post( auditLogService.log({ companyCode, userId, + userName: req.user?.userName, action: "CREATE", resourceType: "NUMBERING_RULE", resourceId: String(newRule.ruleId), @@ -243,6 +244,7 @@ router.put( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "UPDATE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -285,6 +287,7 @@ router.delete( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "DELETE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -521,6 +524,56 @@ router.post( companyCode, userId ); + + const isUpdate = !!ruleConfig.ruleId; + + const resetPeriodLabel: Record = { + none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별", + }; + const partTypeLabel: Record = { + sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조", + }; + const partsDescription = (ruleConfig.parts || []) + .sort((a: any, b: any) => (a.order || 0) - (b.order || 0)) + .map((p: any) => { + const type = partTypeLabel[p.partType] || p.partType; + if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`; + if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`; + if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`; + if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`; + if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`; + return type; + }) + .join(` ${ruleConfig.separator || "-"} `); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: isUpdate ? "UPDATE" : "CREATE", + resourceType: "NUMBERING_RULE", + resourceId: String(savedRule.ruleId), + resourceName: ruleConfig.ruleName, + tableName: "numbering_rules", + summary: isUpdate + ? `채번 규칙 "${ruleConfig.ruleName}" 수정` + : `채번 규칙 "${ruleConfig.ruleName}" 생성`, + changes: { + after: { + 규칙명: ruleConfig.ruleName, + 적용테이블: ruleConfig.tableName || "(미지정)", + 적용컬럼: ruleConfig.columnName || "(미지정)", + 구분자: ruleConfig.separator || "-", + 리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함", + 적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역", + 코드구성: partsDescription || "(파트 없음)", + 파트수: (ruleConfig.parts || []).length, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: savedRule }); } catch (error: any) { logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); @@ -535,10 +588,25 @@ router.delete( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { ruleId } = req.params; try { await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: "DELETE", + resourceType: "NUMBERING_RULE", + resourceId: ruleId, + tableName: "numbering_rules", + summary: `채번 규칙(ID:${ruleId}) 삭제`, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다", diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0ab73e09..5c53094f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -963,6 +963,15 @@ export async function addTableData( logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); + const systemFields = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); + const auditData: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (!systemFields.has(k)) auditData[k] = v; + } + auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", @@ -973,7 +982,7 @@ export async function addTableData( resourceName: tableName, tableName, summary: `${tableName} 데이터 추가`, - changes: { after: data }, + changes: { after: auditData }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1096,10 +1105,14 @@ export async function editTableData( return; } - // 변경된 필드만 추출 + const systemFieldsForEdit = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); const changedBefore: Record = {}; const changedAfter: Record = {}; for (const key of Object.keys(updatedData)) { + if (systemFieldsForEdit.has(key)) continue; if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { changedBefore[key] = originalData[key]; changedAfter[key] = updatedData[key]; diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 9ac3e35e..c86a71fd 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -66,6 +66,7 @@ export interface AuditLogParams { export interface AuditLogEntry { id: number; company_code: string; + company_name: string | null; user_id: string; user_name: string | null; action: string; @@ -107,6 +108,7 @@ class AuditLogService { */ 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, @@ -128,8 +130,9 @@ class AuditLogService { params.requestPath || null, ] ); - } catch (error) { - logger.error("감사 로그 기록 실패 (무시됨)", { error, params }); + logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`); + } catch (error: any) { + logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params }); } } @@ -186,40 +189,40 @@ class AuditLogService { let paramIndex = 1; if (!isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } else if (isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } if (filters.userId) { - conditions.push(`user_id = $${paramIndex++}`); + conditions.push(`sal.user_id = $${paramIndex++}`); params.push(filters.userId); } if (filters.resourceType) { - conditions.push(`resource_type = $${paramIndex++}`); + conditions.push(`sal.resource_type = $${paramIndex++}`); params.push(filters.resourceType); } if (filters.action) { - conditions.push(`action = $${paramIndex++}`); + conditions.push(`sal.action = $${paramIndex++}`); params.push(filters.action); } if (filters.tableName) { - conditions.push(`table_name = $${paramIndex++}`); + conditions.push(`sal.table_name = $${paramIndex++}`); params.push(filters.tableName); } if (filters.dateFrom) { - conditions.push(`created_at >= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`); params.push(filters.dateFrom); } if (filters.dateTo) { - conditions.push(`created_at <= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`); params.push(filters.dateTo); } if (filters.search) { conditions.push( - `(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})` + `(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})` ); params.push(`%${filters.search}%`); paramIndex++; @@ -233,14 +236,17 @@ class AuditLogService { const offset = (page - 1) * limit; const countResult = await query<{ count: string }>( - `SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`, + `SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`, params ); const total = parseInt(countResult[0].count, 10); const data = await query( - `SELECT * FROM system_audit_log ${whereClause} - ORDER BY created_at DESC + `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] ); diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 8fbe5e95..747d4640 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record< NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, - PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" }, TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" }, NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" }, - BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" }, }; const ACTION_CONFIG: Record = { @@ -817,7 +815,7 @@ export default function AuditLogPage() { {entry.company_code && entry.company_code !== "*" && ( - [{entry.company_code}] + [{entry.company_name || entry.company_code}] )} @@ -862,9 +860,11 @@ export default function AuditLogPage() {
-

{selectedEntry.company_code}

+

+ {selectedEntry.company_name || selectedEntry.company_code} +