407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
/**
|
|
* 테이블 이력 조회 컨트롤러
|
|
* 테이블 타입 관리의 {테이블명}_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 ");
|
|
|
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
|
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 log_id 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 ")}` : "";
|
|
|
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
|
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 log_id 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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|