로그시스템 개선
This commit is contained in:
406
backend-node/src/controllers/tableHistoryController.ts
Normal file
406
backend-node/src/controllers/tableHistoryController.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 테이블 이력 조회 컨트롤러
|
||||
* 테이블 타입 관리의 {테이블명}_log 테이블과 연동
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class TableHistoryController {
|
||||
/**
|
||||
* 특정 레코드의 변경 이력 조회
|
||||
*/
|
||||
static async getRecordHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, recordId } = req.params;
|
||||
const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query;
|
||||
|
||||
logger.info(`📜 테이블 이력 조회 요청:`, {
|
||||
tableName,
|
||||
recordId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// 로그 테이블명 생성
|
||||
const logTableName = `${tableName}_log`;
|
||||
|
||||
// 동적 WHERE 조건 생성
|
||||
const whereConditions: string[] = [`original_id = $1`];
|
||||
const queryParams: any[] = [recordId];
|
||||
let paramIndex = 2;
|
||||
|
||||
// 작업 유형 필터
|
||||
if (operationType) {
|
||||
whereConditions.push(`operation_type = $${paramIndex}`);
|
||||
queryParams.push(operationType);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 변경자 필터
|
||||
if (changedBy) {
|
||||
whereConditions.push(`changed_by ILIKE $${paramIndex}`);
|
||||
queryParams.push(`%${changedBy}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (startDate) {
|
||||
whereConditions.push(`changed_at >= $${paramIndex}`);
|
||||
queryParams.push(startDate);
|
||||
paramIndex++;
|
||||
}
|
||||
if (endDate) {
|
||||
whereConditions.push(`changed_at <= $${paramIndex}`);
|
||||
queryParams.push(endDate);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// LIMIT과 OFFSET 파라미터 추가
|
||||
queryParams.push(limit);
|
||||
const limitParam = `$${paramIndex}`;
|
||||
paramIndex++;
|
||||
|
||||
queryParams.push(offset);
|
||||
const offsetParam = `$${paramIndex}`;
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
|
||||
// 이력 조회 쿼리
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
operation_type,
|
||||
original_id,
|
||||
changed_column,
|
||||
old_value,
|
||||
new_value,
|
||||
changed_by,
|
||||
changed_at,
|
||||
ip_address,
|
||||
user_agent,
|
||||
full_row_before,
|
||||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
// 전체 카운트 쿼리
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM ${logTableName}
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const [historyRecords, countResult] = await Promise.all([
|
||||
query<any>(historyQuery, queryParams),
|
||||
query<any>(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult[0]?.total || "0", 10);
|
||||
|
||||
logger.info(`✅ 이력 조회 완료: ${historyRecords.length}건 / 전체 ${total}건`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
records: historyRecords,
|
||||
pagination: {
|
||||
total,
|
||||
limit: parseInt(limit as string, 10),
|
||||
offset: parseInt(offset as string, 10),
|
||||
hasMore: parseInt(offset as string, 10) + historyRecords.length < total,
|
||||
},
|
||||
},
|
||||
message: "이력 조회 성공",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 테이블 이력 조회 실패:`, error);
|
||||
|
||||
// 테이블이 존재하지 않는 경우
|
||||
if (error.code === "42P01") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "이력 테이블이 존재하지 않습니다. 테이블 타입 관리에서 이력 관리를 활성화해주세요.",
|
||||
errorCode: "TABLE_NOT_FOUND",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이력 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 테이블 이력 조회 (레코드 ID 없이)
|
||||
*/
|
||||
static async getAllTableHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query;
|
||||
|
||||
logger.info(`📜 전체 테이블 이력 조회 요청:`, {
|
||||
tableName,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
// 로그 테이블명 생성
|
||||
const logTableName = `${tableName}_log`;
|
||||
|
||||
// 동적 WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 작업 유형 필터
|
||||
if (operationType) {
|
||||
whereConditions.push(`operation_type = $${paramIndex}`);
|
||||
queryParams.push(operationType);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 변경자 필터
|
||||
if (changedBy) {
|
||||
whereConditions.push(`changed_by ILIKE $${paramIndex}`);
|
||||
queryParams.push(`%${changedBy}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (startDate) {
|
||||
whereConditions.push(`changed_at >= $${paramIndex}`);
|
||||
queryParams.push(startDate);
|
||||
paramIndex++;
|
||||
}
|
||||
if (endDate) {
|
||||
whereConditions.push(`changed_at <= $${paramIndex}`);
|
||||
queryParams.push(endDate);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// LIMIT과 OFFSET 파라미터 추가
|
||||
queryParams.push(limit);
|
||||
const limitParam = `$${paramIndex}`;
|
||||
paramIndex++;
|
||||
|
||||
queryParams.push(offset);
|
||||
const offsetParam = `$${paramIndex}`;
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 이력 조회 쿼리
|
||||
const historyQuery = `
|
||||
SELECT
|
||||
log_id,
|
||||
operation_type,
|
||||
original_id,
|
||||
changed_column,
|
||||
old_value,
|
||||
new_value,
|
||||
changed_by,
|
||||
changed_at,
|
||||
ip_address,
|
||||
user_agent,
|
||||
full_row_before,
|
||||
full_row_after
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
// 전체 카운트 쿼리
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM ${logTableName}
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const [historyRecords, countResult] = await Promise.all([
|
||||
query<any>(historyQuery, queryParams),
|
||||
query<any>(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외
|
||||
]);
|
||||
|
||||
const total = parseInt(countResult[0]?.total || "0", 10);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
records: historyRecords,
|
||||
pagination: {
|
||||
total,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
hasMore: Number(offset) + Number(limit) < total,
|
||||
},
|
||||
},
|
||||
message: "전체 테이블 이력 조회 성공",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 전체 테이블 이력 조회 실패:`, error);
|
||||
|
||||
if (error.code === "42P01") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "이력 테이블이 존재하지 않습니다.",
|
||||
errorCode: "TABLE_NOT_FOUND",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이력 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 전체 이력 요약 조회
|
||||
*/
|
||||
static async getTableHistorySummary(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const logTableName = `${tableName}_log`;
|
||||
|
||||
const summaryQuery = `
|
||||
SELECT
|
||||
operation_type,
|
||||
COUNT(*) as count,
|
||||
COUNT(DISTINCT original_id) as affected_records,
|
||||
COUNT(DISTINCT changed_by) as unique_users,
|
||||
MIN(changed_at) as first_change,
|
||||
MAX(changed_at) as last_change
|
||||
FROM ${logTableName}
|
||||
GROUP BY operation_type
|
||||
`;
|
||||
|
||||
const summary = await query<any>(summaryQuery);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary,
|
||||
message: "이력 요약 조회 성공",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 테이블 이력 요약 조회 실패:`, error);
|
||||
|
||||
if (error.code === "42P01") {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "이력 테이블이 존재하지 않습니다.",
|
||||
errorCode: "TABLE_NOT_FOUND",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이력 요약 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레코드의 변경 타임라인 조회 (그룹화)
|
||||
*/
|
||||
static async getRecordTimeline(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, recordId } = req.params;
|
||||
const logTableName = `${tableName}_log`;
|
||||
|
||||
// 변경 이벤트별로 그룹화 (동일 시간대 변경을 하나의 이벤트로)
|
||||
const timelineQuery = `
|
||||
WITH grouped_changes AS (
|
||||
SELECT
|
||||
changed_at,
|
||||
changed_by,
|
||||
operation_type,
|
||||
ip_address,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'column', changed_column,
|
||||
'oldValue', old_value,
|
||||
'newValue', new_value
|
||||
) ORDER BY changed_column
|
||||
) as changes,
|
||||
full_row_before,
|
||||
full_row_after
|
||||
FROM ${logTableName}
|
||||
WHERE original_id = $1
|
||||
GROUP BY changed_at, changed_by, operation_type, ip_address, full_row_before, full_row_after
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 100
|
||||
)
|
||||
SELECT * FROM grouped_changes
|
||||
`;
|
||||
|
||||
const timeline = await query<any>(timelineQuery, [recordId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: timeline,
|
||||
message: "타임라인 조회 성공",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 레코드 타임라인 조회 실패:`, error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "타임라인 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이력 테이블 존재 여부 확인
|
||||
*/
|
||||
static async checkHistoryTableExists(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const logTableName = `${tableName}_log`;
|
||||
|
||||
const checkQuery = `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
) as exists
|
||||
`;
|
||||
|
||||
const result = await query<any>(checkQuery, [logTableName]);
|
||||
const exists = result[0]?.exists || false;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
tableName,
|
||||
logTableName,
|
||||
exists,
|
||||
historyEnabled: exists,
|
||||
},
|
||||
message: exists ? "이력 테이블이 존재합니다." : "이력 테이블이 존재하지 않습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ 이력 테이블 존재 여부 확인 실패:`, error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "이력 테이블 확인 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user