feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management. - Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made. - Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks. - Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes. - This integration improves traceability and accountability for data modifications within the application.
This commit is contained in:
296
backend-node/src/services/auditLogService.ts
Normal file
296
backend-node/src/services/auditLogService.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { query, pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
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";
|
||||
|
||||
export interface AuditLogParams {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
action: AuditAction;
|
||||
resourceType: AuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
tableName?: string;
|
||||
summary?: string;
|
||||
changes?: {
|
||||
before?: Record<string, any>;
|
||||
after?: Record<string, any>;
|
||||
fields?: string[];
|
||||
};
|
||||
ipAddress?: string;
|
||||
requestPath?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
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<void> {
|
||||
try {
|
||||
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,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 로그 다건 기록 (배치)
|
||||
*/
|
||||
async logBatch(entries: AuditLogParams[]): Promise<void> {
|
||||
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(`company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`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})`
|
||||
);
|
||||
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 ${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
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStats(
|
||||
companyCode?: string,
|
||||
days: number = 30
|
||||
): Promise<AuditLogStats> {
|
||||
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();
|
||||
@@ -405,69 +405,169 @@ class CategoryTreeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 하위 카테고리 값 ID 재귀 수집
|
||||
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||
*/
|
||||
private async collectAllChildValueIds(
|
||||
private async checkCategoryValueInUse(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<number[]> {
|
||||
value: CategoryValue
|
||||
): Promise<{ inUse: boolean; count: number }> {
|
||||
const pool = getPool();
|
||||
|
||||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
||||
const query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2 OR cv.company_code = '*'
|
||||
)
|
||||
SELECT value_id FROM category_tree
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [valueId, companyCode]);
|
||||
return result.rows.map(row => row.value_id);
|
||||
|
||||
try {
|
||||
const tableExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
if (!tableExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const columnExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
|
||||
) AS exists`,
|
||||
[value.tableName, value.columnName]
|
||||
);
|
||||
|
||||
if (!columnExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const hasCompanyCode = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
let countQuery: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode.rows[0].exists && companyCode !== "*") {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE company_code = $1
|
||||
AND ($2 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $2)
|
||||
`;
|
||||
params = [companyCode, value.valueCode];
|
||||
} else {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE $1 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $1
|
||||
`;
|
||||
params = [value.valueCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(countQuery, params);
|
||||
const count = parseInt(result.rows[0].count);
|
||||
|
||||
return { inUse: count > 0, count };
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용)", {
|
||||
error: err.message,
|
||||
tableName: value.tableName,
|
||||
columnName: value.columnName,
|
||||
});
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
async checkCanDelete(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<{ canDelete: boolean; reason?: string }> {
|
||||
const pool = getPool();
|
||||
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return { canDelete: false, reason: "카테고리 값을 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `이 카테고리 값(${value.valueLabel})은 ${usageCheck.count}건의 데이터에서 사용 중이므로 삭제할 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { canDelete: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (자식 존재 및 사용 중 검증 후 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 1. 모든 하위 카테고리 ID 수집
|
||||
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
|
||||
const allValueIds = [valueId, ...childValueIds];
|
||||
|
||||
logger.info("삭제 대상 카테고리 값 수집 완료", {
|
||||
valueId,
|
||||
childCount: childValueIds.length,
|
||||
totalCount: allValueIds.length,
|
||||
});
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
|
||||
const reversedIds = [...allValueIds].reverse();
|
||||
|
||||
for (const id of reversedIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, id]
|
||||
// 1. 자식 카테고리 존재 여부 확인
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
throw new Error(
|
||||
`VALIDATION:하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", {
|
||||
valueId,
|
||||
deletedCount: allValueIds.length,
|
||||
deletedChildCount: childValueIds.length,
|
||||
});
|
||||
|
||||
// 2. 실제 데이터에서 사용 중인지 확인
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
throw new Error(
|
||||
`VALIDATION:이 카테고리 값(${value.valueLabel})은 ${value.tableName} 테이블에서 ${usageCheck.count}건의 데이터가 사용 중이므로 삭제할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 삭제
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, valueId]
|
||||
);
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", { valueId, valueLabel: value.valueLabel });
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
if (!err.message.startsWith("VALIDATION:")) {
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,7 +765,7 @@ export class EntityJoinService {
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
// 1. 테이블의 기본 컬럼 정보 조회
|
||||
// 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함)
|
||||
const columns = await query<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -775,7 +775,7 @@ export class EntityJoinService {
|
||||
data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
@@ -403,6 +403,38 @@ export class ScreenManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 메인 테이블명만 업데이트
|
||||
*/
|
||||
async updateScreenTableName(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
userCompanyCode: string,
|
||||
): Promise<void> {
|
||||
const existingResult = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (existingResult.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
if (
|
||||
userCompanyCode !== "*" &&
|
||||
existingResult[0].company_code !== userCompanyCode
|
||||
) {
|
||||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
|
||||
[tableName, screenId],
|
||||
);
|
||||
|
||||
console.log(`화면 테이블명 업데이트 완료: screenId=${screenId}, tableName=${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user