로그시스템 개선

This commit is contained in:
kjs
2025-10-27 11:11:08 +09:00
parent f14d9ee66c
commit 5fdefffd26
12 changed files with 1588 additions and 93 deletions

View File

@@ -62,6 +62,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -218,6 +219,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@@ -245,12 +247,19 @@ app.listen(PORT, HOST, async () => {
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 대시보드 마이그레이션 실행
// 데이터베이스 마이그레이션 실행
try {
const { runDashboardMigration } = await import("./database/runMigration");
const {
runDashboardMigration,
runTableHistoryActionMigration,
runDtgManagementLogMigration,
} = await import("./database/runMigration");
await runDashboardMigration();
await runTableHistoryActionMigration();
await runDtgManagementLogMigration();
} catch (error) {
logger.error(` 대시보드 마이그레이션 실패:`, error);
logger.error(`❌ 마이그레이션 실패:`, error);
}
// 배치 스케줄러 초기화
@@ -279,17 +288,18 @@ app.listen(PORT, HOST, async () => {
const { mailSentHistoryService } = await import(
"./services/mailSentHistoryService"
);
cron.schedule("0 2 * * *", async () => {
try {
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails();
const deletedCount =
await mailSentHistoryService.cleanupOldDeletedMails();
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
} catch (error) {
logger.error("❌ 메일 자동 삭제 실패:", error);
}
});
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);

View 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,
});
}
}
}

View File

@@ -1,4 +1,6 @@
import { PostgreSQLService } from './PostgreSQLService';
import { PostgreSQLService } from "./PostgreSQLService";
import fs from "fs";
import path from "path";
/**
* 데이터베이스 마이그레이션 실행
@@ -6,21 +8,21 @@ import { PostgreSQLService } from './PostgreSQLService';
*/
export async function runDashboardMigration() {
try {
console.log('🔄 대시보드 마이그레이션 시작...');
console.log("🔄 대시보드 마이그레이션 시작...");
// custom_title 컬럼 추가
await PostgreSQLService.query(`
ALTER TABLE dashboard_elements
ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255)
`);
console.log('✅ custom_title 컬럼 추가 완료');
console.log("✅ custom_title 컬럼 추가 완료");
// show_header 컬럼 추가
await PostgreSQLService.query(`
ALTER TABLE dashboard_elements
ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true
`);
console.log('✅ show_header 컬럼 추가 완료');
console.log("✅ show_header 컬럼 추가 완료");
// 기존 데이터 업데이트
await PostgreSQLService.query(`
@@ -28,15 +30,83 @@ export async function runDashboardMigration() {
SET show_header = true
WHERE show_header IS NULL
`);
console.log('✅ 기존 데이터 업데이트 완료');
console.log("✅ 기존 데이터 업데이트 완료");
console.log('✅ 대시보드 마이그레이션 완료!');
console.log("✅ 대시보드 마이그레이션 완료!");
} catch (error) {
console.error('❌ 대시보드 마이그레이션 실패:', error);
console.error("❌ 대시보드 마이그레이션 실패:", error);
// 이미 컬럼이 있는 경우는 무시
if (error instanceof Error && error.message.includes('already exists')) {
console.log(' 컬럼이 이미 존재합니다.');
if (error instanceof Error && error.message.includes("already exists")) {
console.log(" 컬럼이 이미 존재합니다.");
}
}
}
/**
* 테이블 이력 보기 버튼 액션 마이그레이션
*/
export async function runTableHistoryActionMigration() {
try {
console.log("🔄 테이블 이력 보기 액션 마이그레이션 시작...");
// SQL 파일 읽기
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/024_add_table_history_view_action.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
// SQL 실행
await PostgreSQLService.query(sqlContent);
console.log("✅ 테이블 이력 보기 액션 마이그레이션 완료!");
} catch (error) {
console.error("❌ 테이블 이력 보기 액션 마이그레이션 실패:", error);
// 이미 액션이 있는 경우는 무시
if (
error instanceof Error &&
error.message.includes("duplicate key value")
) {
console.log(" 액션이 이미 존재합니다.");
}
}
}
/**
* DTG Management 테이블 이력 시스템 마이그레이션
*/
export async function runDtgManagementLogMigration() {
try {
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
// SQL 파일 읽기
const sqlFilePath = path.join(
__dirname,
"../../db/migrations/025_create_dtg_management_log.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
// SQL 실행
await PostgreSQLService.query(sqlContent);
console.log("✅ DTG Management 이력 테이블 마이그레이션 완료!");
} catch (error) {
console.error("❌ DTG Management 이력 테이블 마이그레이션 실패:", error);
// 이미 테이블이 있는 경우는 무시
if (error instanceof Error && error.message.includes("already exists")) {
console.log(" 이력 테이블이 이미 존재합니다.");
}
}
}

View File

@@ -0,0 +1,35 @@
/**
* 테이블 이력 조회 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { TableHistoryController } from "../controllers/tableHistoryController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 이력 테이블 존재 여부 확인
router.get("/:tableName/check", TableHistoryController.checkHistoryTableExists);
// 테이블 전체 이력 요약
router.get(
"/:tableName/summary",
TableHistoryController.getTableHistorySummary
);
// 전체 테이블 이력 조회 (레코드 ID 없이)
router.get("/:tableName/all", TableHistoryController.getAllTableHistory);
// 특정 레코드의 타임라인
router.get(
"/:tableName/:recordId/timeline",
TableHistoryController.getRecordTimeline
);
// 특정 레코드의 변경 이력 (상세)
router.get("/:tableName/:recordId", TableHistoryController.getRecordHistory);
export default router;