feat: enhance audit logging and add company name to audit entries

- Integrated detailed audit logging for update and delete actions in the CommonCodeController and DDLController.
- Added company name retrieval to the audit log entries for better traceability.
- Updated the audit log service to include company name in the log entries.
- Modified the frontend audit log page to display company names alongside company codes for improved clarity.

Made-with: Cursor
This commit is contained in:
kjs
2026-03-12 05:14:27 +09:00
parent fd90e3d761
commit b1e50f2e0a
14 changed files with 462 additions and 142 deletions

View File

@@ -1814,7 +1814,7 @@ export async function toggleMenuStatus(
// 현재 상태 및 회사 코드 조회
const currentMenu = await queryOne<any>(
`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)]
);

View File

@@ -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: "삭제되었습니다",

View File

@@ -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: "코드 삭제 성공",

View File

@@ -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,

View File

@@ -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<string, string> = {
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
};
const partTypeLabel: Record<string, string> = {
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: "테스트 채번 규칙이 삭제되었습니다",

View File

@@ -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<string, any> = {};
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<string, any> = {};
const changedAfter: Record<string, any> = {};
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];

View File

@@ -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<void> {
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<AuditLogEntry>(
`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]
);