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:
kjs
2026-03-04 13:49:08 +09:00
parent f04d224b09
commit b4d5367e2b
26 changed files with 2620 additions and 140 deletions

View 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();

View File

@@ -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;
}
}

View File

@@ -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]
);

View File

@@ -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}`);
}
/**
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
*/