297 lines
8.0 KiB
TypeScript
297 lines
8.0 KiB
TypeScript
|
|
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();
|