diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index aac86625..d7b2bd74 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1048,3 +1048,268 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +// ======================================== +// ๐ŸŽฏ ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ API +// ======================================== + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ +export async function createLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { pkColumn } = req.body; + const userId = req.user?.userId; + + logger.info(`=== ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ์ž‘: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { + const response: ApiResponse = { + success: false, + message: "PK ์ปฌ๋Ÿผ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_PK_COLUMN", + details: "PK ์ปฌ๋Ÿผ๋ช…๊ณผ ๋ฐ์ดํ„ฐ ํƒ€์ž…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.createLogTable(tableName, pkColumn, userId); + + logger.info(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ: ${tableName}_log`); + + const response: ApiResponse = { + success: true, + message: "๋กœ๊ทธ ํ…Œ์ด๋ธ”์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + success: false, + message: "๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: { + code: "LOG_TABLE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ + */ +export async function getLogConfig( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + logger.info(`=== ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const logConfig = await tableManagementService.getLogConfig(tableName); + + const response: ApiResponse = { + success: true, + message: "๋กœ๊ทธ ์„ค์ •์„ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค.", + data: logConfig, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ์„ค์ • ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + success: false, + message: "๋กœ๊ทธ ์„ค์ • ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: { + code: "LOG_CONFIG_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + */ +export async function getLogData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 20, + operationType, + startDate, + endDate, + changedBy, + originalId, + } = req.query; + + logger.info(`=== ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const result = await tableManagementService.getLogData(tableName, { + page: parseInt(page as string), + size: parseInt(size as string), + operationType: operationType as string, + startDate: startDate as string, + endDate: endDate as string, + changedBy: changedBy as string, + originalId: originalId as string, + }); + + logger.info( + `๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: ${tableName}_log, ${result.total}๊ฑด` + ); + + const response: ApiResponse = { + success: true, + message: "๋กœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + success: false, + message: "๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: { + code: "LOG_DATA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” + */ +export async function toggleLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { isActive } = req.body; + + logger.info(`=== ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€: ${tableName}, isActive: ${isActive} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + if (isActive === undefined || isActive === null) { + const response: ApiResponse = { + success: false, + message: "isActive ๊ฐ’์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_IS_ACTIVE", + details: "isActive ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.toggleLogTable( + tableName, + isActive === "Y" || isActive === true + ); + + logger.info( + `๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์™„๋ฃŒ: ${tableName}, isActive: ${isActive}` + ); + + const response: ApiResponse = { + success: true, + message: `๋กœ๊ทธ ๊ธฐ๋Šฅ์ด ${isActive ? "ํ™œ์„ฑํ™”" : "๋น„ํ™œ์„ฑํ™”"}๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + success: false, + message: "๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: { + code: "LOG_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index c0b35b94..5e5ddf38 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -18,6 +18,10 @@ import { checkTableExists, getColumnWebTypes, checkDatabaseConnection, + createLogTable, + getLogConfig, + getLogData, + toggleLogTable, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData); */ router.delete("/tables/:tableName/delete", deleteTableData); +// ======================================== +// ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ API +// ======================================== + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + * POST /api/table-management/tables/:tableName/log + */ +router.post("/tables/:tableName/log", createLogTable); + +/** + * ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ + * GET /api/table-management/tables/:tableName/log/config + */ +router.get("/tables/:tableName/log/config", getLogConfig); + +/** + * ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + * GET /api/table-management/tables/:tableName/log + */ +router.get("/tables/:tableName/log", getLogData); + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” + * POST /api/table-management/tables/:tableName/log/toggle + */ +router.post("/tables/:tableName/log/toggle", toggleLogTable); + export default router; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 83f3a696..10de1e73 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3118,4 +3118,410 @@ export class TableManagementService { // ๊ธฐ๋ณธ๊ฐ’ return "text"; } + + // ======================================== + // ๐ŸŽฏ ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ + // ======================================== + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + userId?: string + ): Promise { + try { + const logTableName = `${tableName}_log`; + const triggerFuncName = `${tableName}_log_trigger_func`; + const triggerName = `${tableName}_audit_trigger`; + + logger.info(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ์ž‘: ${logTableName}`); + + // ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ + const logTableDDL = this.generateLogTableDDL( + logTableName, + tableName, + pkColumn.columnName, + pkColumn.dataType + ); + + // ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ DDL ์ƒ์„ฑ + const triggerFuncDDL = this.generateTriggerFunctionDDL( + triggerFuncName, + logTableName, + tableName, + pkColumn.columnName + ); + + // ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ + const triggerDDL = this.generateTriggerDDL( + triggerName, + tableName, + triggerFuncName + ); + + // ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์‹คํ–‰ + await transaction(async (client) => { + // 1. ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + await client.query(logTableDDL); + logger.info(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ: ${logTableName}`); + + // 2. ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ + await client.query(triggerFuncDDL); + logger.info(`ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ ์™„๋ฃŒ: ${triggerFuncName}`); + + // 3. ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ + await client.query(triggerDDL); + logger.info(`ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ ์™„๋ฃŒ: ${triggerName}`); + + // 4. ๋กœ๊ทธ ์„ค์ • ์ €์žฅ + await client.query( + `INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, created_by + ) VALUES ($1, $2, $3, $4, $5)`, + [tableName, logTableName, triggerName, triggerFuncName, userId] + ); + logger.info(`๋กœ๊ทธ ์„ค์ • ์ €์žฅ ์™„๋ฃŒ: ${tableName}`); + }); + + logger.info(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ: ${logTableName}`); + } catch (error) { + logger.error(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ: ${tableName}`, error); + throw new Error( + `๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ + */ + private generateLogTableDDL( + logTableName: string, + originalTableName: string, + pkColumnName: string, + pkDataType: string + ): string { + return ` + CREATE TABLE ${logTableName} ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id VARCHAR(100), + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB + ); + + CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id); + CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at); + CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type); + + COMMENT ON TABLE ${logTableName} IS '${originalTableName} ํ…Œ์ด๋ธ” ๋ณ€๊ฒฝ ์ด๋ ฅ'; + COMMENT ON COLUMN ${logTableName}.operation_type IS '์ž‘์—… ์œ ํ˜• (INSERT/UPDATE/DELETE)'; + COMMENT ON COLUMN ${logTableName}.original_id IS '์›๋ณธ ํ…Œ์ด๋ธ” PK ๊ฐ’'; + COMMENT ON COLUMN ${logTableName}.changed_column IS '๋ณ€๊ฒฝ๋œ ์ปฌ๋Ÿผ๋ช…'; + COMMENT ON COLUMN ${logTableName}.old_value IS '๋ณ€๊ฒฝ ์ „ ๊ฐ’'; + COMMENT ON COLUMN ${logTableName}.new_value IS '๋ณ€๊ฒฝ ํ›„ ๊ฐ’'; + COMMENT ON COLUMN ${logTableName}.changed_by IS '๋ณ€๊ฒฝ์ž ID'; + COMMENT ON COLUMN ${logTableName}.changed_at IS '๋ณ€๊ฒฝ ์‹œ๊ฐ'; + COMMENT ON COLUMN ${logTableName}.ip_address IS '๋ณ€๊ฒฝ ์š”์ฒญ IP'; + COMMENT ON COLUMN ${logTableName}.full_row_before IS '๋ณ€๊ฒฝ ์ „ ์ „์ฒด ํ–‰ (JSON)'; + COMMENT ON COLUMN ${logTableName}.full_row_after IS '๋ณ€๊ฒฝ ํ›„ ์ „์ฒด ํ–‰ (JSON)'; + `; + } + + /** + * ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ DDL ์ƒ์„ฑ + */ + private generateTriggerFunctionDDL( + funcName: string, + logTableName: string, + originalTableName: string, + pkColumnName: string + ): string { + return ` + CREATE OR REPLACE FUNCTION ${funcName}() + RETURNS TRIGGER AS $$ + DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); + BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb; + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${originalTableName}' + AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)', + '${pkColumnName}' + ) + USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb; + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb; + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + `; + } + + /** + * ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ + */ + private generateTriggerDDL( + triggerName: string, + tableName: string, + funcName: string + ): string { + return ` + CREATE TRIGGER ${triggerName} + AFTER INSERT OR UPDATE OR DELETE ON ${tableName} + FOR EACH ROW EXECUTE FUNCTION ${funcName}(); + `; + } + + /** + * ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ + */ + async getLogConfig(tableName: string): Promise<{ + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + isActive: string; + createdAt: Date; + createdBy: string; + } | null> { + try { + logger.info(`๋กœ๊ทธ ์„ค์ • ์กฐํšŒ: ${tableName}`); + + const result = await queryOne<{ + original_table_name: string; + log_table_name: string; + trigger_name: string; + trigger_function_name: string; + is_active: string; + created_at: Date; + created_by: string; + }>( + `SELECT + original_table_name, log_table_name, trigger_name, + trigger_function_name, is_active, created_at, created_by + FROM table_log_config + WHERE original_table_name = $1`, + [tableName] + ); + + if (!result) { + return null; + } + + return { + originalTableName: result.original_table_name, + logTableName: result.log_table_name, + triggerName: result.trigger_name, + triggerFunctionName: result.trigger_function_name, + isActive: result.is_active, + createdAt: result.created_at, + createdBy: result.created_by, + }; + } catch (error) { + logger.error(`๋กœ๊ทธ ์„ค์ • ์กฐํšŒ ์‹คํŒจ: ${tableName}`, error); + throw error; + } + } + + /** + * ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + */ + async getLogData( + tableName: string, + options: { + page: number; + size: number; + operationType?: string; + startDate?: string; + endDate?: string; + changedBy?: string; + originalId?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const logTableName = `${tableName}_log`; + const offset = (options.page - 1) * options.size; + + logger.info(`๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ: ${logTableName}`, options); + + // WHERE ์กฐ๊ฑด ๊ตฌ์„ฑ + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (options.operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + values.push(options.operationType); + paramIndex++; + } + + if (options.startDate) { + whereConditions.push(`changed_at >= $${paramIndex}::timestamp`); + values.push(options.startDate); + paramIndex++; + } + + if (options.endDate) { + whereConditions.push(`changed_at <= $${paramIndex}::timestamp`); + values.push(options.endDate); + paramIndex++; + } + + if (options.changedBy) { + whereConditions.push(`changed_by = $${paramIndex}`); + values.push(options.changedBy); + paramIndex++; + } + + if (options.originalId) { + whereConditions.push(`original_id::text = $${paramIndex}`); + values.push(options.originalId); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ + const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`; + const countResult = await query(countQuery, values); + const total = parseInt(countResult[0].count); + + // ๋ฐ์ดํ„ฐ ์กฐํšŒ + const dataQuery = ` + SELECT * FROM ${logTableName} + ${whereClause} + ORDER BY changed_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await query(dataQuery, [ + ...values, + options.size, + offset, + ]); + + const totalPages = Math.ceil(total / options.size); + + logger.info( + `๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: ${logTableName}, ์ด ${total}๊ฑด, ${data.length}๊ฐœ ๋ฐ˜ํ™˜` + ); + + return { + data, + total, + page: options.page, + size: options.size, + totalPages, + }; + } catch (error) { + logger.error(`๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹คํŒจ: ${tableName}`, error); + throw error; + } + } + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” + */ + async toggleLogTable(tableName: string, isActive: boolean): Promise { + try { + const logConfig = await this.getLogConfig(tableName); + if (!logConfig) { + throw new Error(`๋กœ๊ทธ ์„ค์ •์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: ${tableName}`); + } + + logger.info( + `๋กœ๊ทธ ํ…Œ์ด๋ธ” ${isActive ? "ํ™œ์„ฑํ™”" : "๋น„ํ™œ์„ฑํ™”"}: ${tableName}` + ); + + await transaction(async (client) => { + // ํŠธ๋ฆฌ๊ฑฐ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” + if (isActive) { + await client.query( + `ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}` + ); + } else { + await client.query( + `ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}` + ); + } + + // ์„ค์ • ์—…๋ฐ์ดํŠธ + await client.query( + `UPDATE table_log_config + SET is_active = $1, updated_at = NOW() + WHERE original_table_name = $2`, + [isActive ? "Y" : "N", tableName] + ); + }); + + logger.info( + `๋กœ๊ทธ ํ…Œ์ด๋ธ” ${isActive ? "ํ™œ์„ฑํ™”" : "๋น„ํ™œ์„ฑํ™”"} ์™„๋ฃŒ: ${tableName}` + ); + } catch (error) { + logger.error( + `๋กœ๊ทธ ํ…Œ์ด๋ธ” ${isActive ? "ํ™œ์„ฑํ™”" : "๋น„ํ™œ์„ฑํ™”"} ์‹คํŒจ: ${tableName}`, + error + ); + throw error; + } + } } diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index e415fec8..74fd30af 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -20,6 +20,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; +import { TableLogViewer } from "@/components/admin/TableLogViewer"; // ๊ฐ€์ƒํ™” ์Šคํฌ๋กค๋ง์„ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ๊ตฌํ˜„ interface TableInfo { @@ -76,6 +77,10 @@ export default function TableManagementPage() { const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); + // ๋กœ๊ทธ ๋ทฐ์–ด ์ƒํƒœ + const [logViewerOpen, setLogViewerOpen] = useState(false); + const [logViewerTableName, setLogViewerTableName] = useState(""); + // ์ตœ๊ณ  ๊ด€๋ฆฌ์ž ์—ฌ๋ถ€ ํ™•์ธ (ํšŒ์‚ฌ์ฝ”๋“œ๊ฐ€ "*"์ธ ๊ฒฝ์šฐ) const isSuperAdmin = user?.companyCode === "*"; @@ -645,15 +650,30 @@ export default function TableManagementPage() { onClick={() => handleTableSelect(table.tableName)} >
-
+

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "์„ค๋ช… ์—†์Œ")}

- - {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "์ปฌ๋Ÿผ")} - +
+ + {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "์ปฌ๋Ÿผ")} + + +
)) @@ -972,6 +992,9 @@ export default function TableManagementPage() { /> setDdlLogViewerOpen(false)} /> + + {/* ํ…Œ์ด๋ธ” ๋กœ๊ทธ ๋ทฐ์–ด */} + )} diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 7e075ad1..c31482dc 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -19,10 +19,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react"; import { toast } from "sonner"; import { ColumnDefinitionTable } from "./ColumnDefinitionTable"; import { ddlApi } from "../../lib/api/ddl"; +import { tableManagementApi } from "../../lib/api/tableManagement"; import { CreateTableModalProps, CreateColumnDefinition, @@ -47,6 +49,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa const [validating, setValidating] = useState(false); const [tableNameError, setTableNameError] = useState(""); const [validationResult, setValidationResult] = useState(null); + const [useLogTable, setUseLogTable] = useState(false); /** * ๋ชจ๋‹ฌ ๋ฆฌ์…‹ @@ -65,6 +68,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa ]); setTableNameError(""); setValidationResult(null); + setUseLogTable(false); }; /** @@ -204,6 +208,23 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa if (result.success) { toast.success(result.message); + + // ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์˜ต์…˜์ด ์„ ํƒ๋˜์—ˆ๋‹ค๋ฉด ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + if (useLogTable) { + try { + const pkColumn = { columnName: "id", dataType: "integer" }; + const logResult = await tableManagementApi.createLogTable(tableName, pkColumn); + + if (logResult.success) { + toast.success(`${tableName}_log ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + } else { + toast.warning(`ํ…Œ์ด๋ธ”์€ ์ƒ์„ฑ๋˜์—ˆ์œผ๋‚˜ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ: ${logResult.message}`); + } + } catch (logError) { + toast.warning("ํ…Œ์ด๋ธ”์€ ์ƒ์„ฑ๋˜์—ˆ์œผ๋‚˜ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + } + onSuccess(result); onClose(); } else { @@ -248,7 +269,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa placeholder="์˜ˆ: customer_info" className={tableNameError ? "border-red-300" : ""} /> - {tableNameError &&

{tableNameError}

} + {tableNameError &&

{tableNameError}

}

์˜๋ฌธ์ž๋กœ ์‹œ์ž‘, ์˜๋ฌธ์ž/์ˆซ์ž/์–ธ๋”์Šค์ฝ”์–ด๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

@@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์˜ต์…˜ */} +
+ setUseLogTable(checked as boolean)} + disabled={loading} + /> +
+ +

+ ์„ ํƒ ์‹œ {tableName || "table"}_log ํ…Œ์ด๋ธ”์ด + ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋˜์–ด INSERT/UPDATE/DELETE ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. +

+
+
+ {/* ์ž๋™ ์ถ”๊ฐ€ ์ปฌ๋Ÿผ ์•ˆ๋‚ด */} diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx new file mode 100644 index 00000000..6b899bf6 --- /dev/null +++ b/frontend/components/admin/TableLogViewer.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { toast } from "sonner"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { History, RefreshCw, Filter, X } from "lucide-react"; + +interface TableLogViewerProps { + tableName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface LogData { + log_id: number; + operation_type: string; + original_id: string; + changed_column?: string; + old_value?: string; + new_value?: string; + changed_by?: string; + changed_at: string; + ip_address?: string; +} + +export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewerProps) { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [totalPages, setTotalPages] = useState(0); + + // ํ•„ํ„ฐ ์ƒํƒœ + const [operationType, setOperationType] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [originalId, setOriginalId] = useState(""); + + // ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + const loadLogs = async () => { + if (!tableName) return; + + setLoading(true); + try { + const response = await tableManagementApi.getLogData(tableName, { + page, + size: pageSize, + operationType: operationType || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + changedBy: changedBy || undefined, + originalId: originalId || undefined, + }); + + if (response.success && response.data) { + setLogs(response.data.data); + setTotal(response.data.total); + setTotalPages(response.data.totalPages); + } else { + toast.error(response.message || "๋กœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } catch (error) { + toast.error("๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } finally { + setLoading(false); + } + }; + + // ๋‹ค์ด์–ผ๋กœ๊ทธ๊ฐ€ ์—ด๋ฆด ๋•Œ ๋กœ๊ทธ ๋กœ๋“œ + useEffect(() => { + if (open && tableName) { + loadLogs(); + } + }, [open, tableName, page]); + + // ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + const resetFilters = () => { + setOperationType(""); + setStartDate(""); + setEndDate(""); + setChangedBy(""); + setOriginalId(""); + setPage(1); + }; + + // ์ž‘์—… ํƒ€์ž…์— ๋”ฐ๋ฅธ ๋ฑƒ์ง€ ์ƒ‰์ƒ + const getOperationBadge = (type: string) => { + switch (type) { + case "INSERT": + return ์ถ”๊ฐ€; + case "UPDATE": + return ์ˆ˜์ •; + case "DELETE": + return ์‚ญ์ œ; + default: + return {type}; + } + }; + + // ๋‚ ์งœ ํฌ๋งทํŒ… + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + return ( + + + + + + {tableName} - ๋ณ€๊ฒฝ ์ด๋ ฅ + + + + {/* ํ•„ํ„ฐ ์˜์—ญ */} +
+
+

+ + ํ•„ํ„ฐ +

+ +
+ +
+
+ + +
+ +
+ + setStartDate(e.target.value)} /> +
+ +
+ + setEndDate(e.target.value)} /> +
+ +
+ + setChangedBy(e.target.value)} /> +
+ +
+ + setOriginalId(e.target.value)} /> +
+ +
+ +
+
+
+ + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” */} +
+ {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
๋ณ€๊ฒฝ ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.
+ ) : ( + + + + ์ž‘์—… + ์›๋ณธ ID + ๋ณ€๊ฒฝ ์ปฌ๋Ÿผ + ๋ณ€๊ฒฝ ์ „ + ๋ณ€๊ฒฝ ํ›„ + ๋ณ€๊ฒฝ์ž + ๋ณ€๊ฒฝ ์‹œ๊ฐ + IP + + + + {logs.map((log) => ( + + {getOperationBadge(log.operation_type)} + {log.original_id} + {log.changed_column || "-"} + + {log.old_value || "-"} + + + {log.new_value || "-"} + + {log.changed_by || "system"} + {formatDate(log.changed_at)} + {log.ip_address || "-"} + + ))} + +
+ )} +
+ + {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */} +
+
+ ์ „์ฒด {total}๊ฑด (ํŽ˜์ด์ง€ {page} / {totalPages}) +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 5dc1cc0a..6a8363ba 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -211,6 +211,114 @@ class TableManagementApi { }; } } + + // ======================================== + // ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ API + // ======================================== + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + ): Promise> { + try { + const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log`, { pkColumn }); + return response.data; + } catch (error: any) { + console.error(`โŒ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "๋กœ๊ทธ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ + */ + async getLogConfig(tableName: string): Promise< + ApiResponse<{ + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + isActive: string; + createdAt: Date; + createdBy: string; + } | null> + > { + try { + const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log/config`); + return response.data; + } catch (error: any) { + console.error(`โŒ ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ ์‹คํŒจ: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "๋กœ๊ทธ ์„ค์ •์„ ์กฐํšŒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + */ + async getLogData( + tableName: string, + options: { + page?: number; + size?: number; + operationType?: string; + startDate?: string; + endDate?: string; + changedBy?: string; + originalId?: string; + } = {}, + ): Promise< + ApiResponse<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> + > { + try { + const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log`, { + params: options, + }); + return response.data; + } catch (error: any) { + console.error(`โŒ ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹คํŒจ: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "๋กœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” + */ + async toggleLogTable(tableName: string, isActive: boolean): Promise> { + try { + const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log/toggle`, { + isActive, + }); + return response.data; + } catch (error: any) { + console.error(`โŒ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์‹คํŒจ: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "๋กœ๊ทธ ํ…Œ์ด๋ธ” ์„ค์ •์„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + errorCode: error.response?.data?.errorCode, + }; + } + } } // ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ diff --git a/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md b/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md new file mode 100644 index 00000000..e7f43773 --- /dev/null +++ b/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md @@ -0,0 +1,773 @@ +# ํ…Œ์ด๋ธ” ๋ณ€๊ฒฝ ์ด๋ ฅ ๋กœ๊ทธ ์‹œ์Šคํ…œ ๊ตฌํ˜„ ๊ณ„ํš์„œ + +## 1. ๊ฐœ์š” + +ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ์ž๋™์œผ๋กœ ๊ธฐ๋กํ•˜๋Š” ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. +์‚ฌ์šฉ์ž๊ฐ€ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•  ๋•Œ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์—ฌ๋ถ€๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์„ ํƒ ์‹œ ์ž๋™์œผ๋กœ ๋กœ๊ทธ ํ…Œ์ด๋ธ”๊ณผ ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. + +## 2. ํ•ต์‹ฌ ๊ธฐ๋Šฅ + +### 2.1 ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์˜ต์…˜ + +- ํ…Œ์ด๋ธ” ์ƒ์„ฑ ํผ์— "๋ณ€๊ฒฝ ์ด๋ ฅ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ" ์ฒดํฌ๋ฐ•์Šค ์ถ”๊ฐ€ +- ์ฒดํฌ ์‹œ `{์›๋ณธํ…Œ์ด๋ธ”๋ช…}_log` ํ˜•์‹์˜ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ž๋™ ์ƒ์„ฑ + +### 2.2 ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๊ตฌ์กฐ + +```sql +CREATE TABLE {table_name}_log ( + log_id SERIAL PRIMARY KEY, -- ๋กœ๊ทธ ๊ณ ์œ  ID + operation_type VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE + original_id {์›๋ณธPKํƒ€์ž…}, -- ์›๋ณธ ํ…Œ์ด๋ธ”์˜ PK ๊ฐ’ + changed_column VARCHAR(100), -- ๋ณ€๊ฒฝ๋œ ์ปฌ๋Ÿผ๋ช… (UPDATE ์‹œ) + old_value TEXT, -- ๋ณ€๊ฒฝ ์ „ ๊ฐ’ + new_value TEXT, -- ๋ณ€๊ฒฝ ํ›„ ๊ฐ’ + changed_by VARCHAR(50), -- ๋ณ€๊ฒฝํ•œ ์‚ฌ์šฉ์ž ID + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- ๋ณ€๊ฒฝ ์‹œ๊ฐ + ip_address VARCHAR(50), -- ๋ณ€๊ฒฝ ์š”์ฒญ IP + user_agent TEXT, -- ๋ณ€๊ฒฝ ์š”์ฒญ User-Agent + full_row_before JSONB, -- ๋ณ€๊ฒฝ ์ „ ์ „์ฒด ํ–‰ (JSON) + full_row_after JSONB -- ๋ณ€๊ฒฝ ํ›„ ์ „์ฒด ํ–‰ (JSON) +); + +CREATE INDEX idx_{table_name}_log_original_id ON {table_name}_log(original_id); +CREATE INDEX idx_{table_name}_log_changed_at ON {table_name}_log(changed_at); +CREATE INDEX idx_{table_name}_log_operation ON {table_name}_log(operation_type); +``` + +### 2.3 ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ + +```sql +CREATE OR REPLACE FUNCTION {table_name}_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + -- ์„ธ์…˜ ๋ณ€์ˆ˜์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_by, ip_address, + full_row_after + ) VALUES ( + 'INSERT', NEW.{pk_column}, v_user_id, v_ip_address, + row_to_json(NEW)::jsonb + ); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + -- ๊ฐ ์ปฌ๋Ÿผ๋ณ„๋กœ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๊ธฐ๋ก + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = TG_TABLE_NAME + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', + v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_column, + old_value, new_value, changed_by, ip_address, + full_row_before, full_row_after + ) VALUES ( + 'UPDATE', NEW.{pk_column}, v_column_name, + v_old_value, v_new_value, v_user_id, v_ip_address, + row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_by, ip_address, + full_row_before + ) VALUES ( + 'DELETE', OLD.{pk_column}, v_user_id, v_ip_address, + row_to_json(OLD)::jsonb + ); + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +``` + +### 2.4 ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ + +```sql +CREATE TRIGGER {table_name}_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW EXECUTE FUNCTION {table_name}_log_trigger_func(); +``` + +## 3. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ + +### 3.1 table_type_mng ํ…Œ์ด๋ธ” ์ˆ˜์ • + +```sql +ALTER TABLE table_type_mng +ADD COLUMN use_log_table VARCHAR(1) DEFAULT 'N'; + +COMMENT ON COLUMN table_type_mng.use_log_table IS '๋ณ€๊ฒฝ ์ด๋ ฅ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์‚ฌ์šฉ ์—ฌ๋ถ€ (Y/N)'; +``` + +### 3.2 ์ƒˆ๋กœ์šด ๊ด€๋ฆฌ ํ…Œ์ด๋ธ” ์ถ”๊ฐ€ + +```sql +CREATE TABLE table_log_config ( + config_id SERIAL PRIMARY KEY, + original_table_name VARCHAR(100) NOT NULL, + log_table_name VARCHAR(100) NOT NULL, + trigger_name VARCHAR(100) NOT NULL, + trigger_function_name VARCHAR(100) NOT NULL, + is_active VARCHAR(1) DEFAULT 'Y', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + UNIQUE(original_table_name) +); + +COMMENT ON TABLE table_log_config IS 'ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์„ค์ • ๊ด€๋ฆฌ'; +COMMENT ON COLUMN table_log_config.original_table_name IS '์›๋ณธ ํ…Œ์ด๋ธ”๋ช…'; +COMMENT ON COLUMN table_log_config.log_table_name IS '๋กœ๊ทธ ํ…Œ์ด๋ธ”๋ช…'; +COMMENT ON COLUMN table_log_config.trigger_name IS 'ํŠธ๋ฆฌ๊ฑฐ๋ช…'; +COMMENT ON COLUMN table_log_config.trigger_function_name IS 'ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜๋ช…'; +COMMENT ON COLUMN table_log_config.is_active IS 'ํ™œ์„ฑ ์ƒํƒœ (Y/N)'; +``` + +## 4. ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ + +### 4.1 Service Layer ์ˆ˜์ • + +**ํŒŒ์ผ**: `backend-node/src/services/admin/table-type-mng.service.ts` + +#### 4.1.1 ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ๋กœ์ง + +```typescript +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ +private async createLogTable( + tableName: string, + columns: any[], + connectionId?: number, + userId?: string +): Promise { + const logTableName = `${tableName}_log`; + const triggerFuncName = `${tableName}_log_trigger_func`; + const triggerName = `${tableName}_audit_trigger`; + + // PK ์ปฌ๋Ÿผ ์ฐพ๊ธฐ + const pkColumn = columns.find(col => col.isPrimaryKey); + if (!pkColumn) { + throw new Error('PK ์ปฌ๋Ÿผ์ด ์—†์œผ๋ฉด ๋กœ๊ทธ ํ…Œ์ด๋ธ”์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + // ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ + const logTableDDL = this.generateLogTableDDL( + logTableName, + pkColumn.COLUMN_NAME, + pkColumn.DATA_TYPE + ); + + // ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ DDL ์ƒ์„ฑ + const triggerFuncDDL = this.generateTriggerFunctionDDL( + triggerFuncName, + logTableName, + tableName, + pkColumn.COLUMN_NAME + ); + + // ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ + const triggerDDL = this.generateTriggerDDL( + triggerName, + tableName, + triggerFuncName + ); + + try { + // 1. ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + await this.executeDDL(logTableDDL, connectionId); + + // 2. ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ + await this.executeDDL(triggerFuncDDL, connectionId); + + // 3. ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ + await this.executeDDL(triggerDDL, connectionId); + + // 4. ๋กœ๊ทธ ์„ค์ • ์ €์žฅ + await this.saveLogConfig({ + originalTableName: tableName, + logTableName, + triggerName, + triggerFunctionName: triggerFuncName, + createdBy: userId + }); + + console.log(`๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์™„๋ฃŒ: ${logTableName}`); + } catch (error) { + console.error('๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹คํŒจ:', error); + throw error; + } +} + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ + */ +private generateLogTableDDL( + logTableName: string, + pkColumnName: string, + pkDataType: string +): string { + return ` + CREATE TABLE ${logTableName} ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id ${pkDataType}, + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB + ); + + CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id); + CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at); + CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type); + + COMMENT ON TABLE ${logTableName} IS '${logTableName.replace('_log', '')} ํ…Œ์ด๋ธ” ๋ณ€๊ฒฝ ์ด๋ ฅ'; + COMMENT ON COLUMN ${logTableName}.operation_type IS '์ž‘์—… ์œ ํ˜• (INSERT/UPDATE/DELETE)'; + COMMENT ON COLUMN ${logTableName}.original_id IS '์›๋ณธ ํ…Œ์ด๋ธ” PK ๊ฐ’'; + COMMENT ON COLUMN ${logTableName}.changed_column IS '๋ณ€๊ฒฝ๋œ ์ปฌ๋Ÿผ๋ช…'; + COMMENT ON COLUMN ${logTableName}.old_value IS '๋ณ€๊ฒฝ ์ „ ๊ฐ’'; + COMMENT ON COLUMN ${logTableName}.new_value IS '๋ณ€๊ฒฝ ํ›„ ๊ฐ’'; + `; +} + +/** + * ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ DDL ์ƒ์„ฑ + */ +private generateTriggerFunctionDDL( + funcName: string, + logTableName: string, + originalTableName: string, + pkColumnName: string +): string { + return ` + CREATE OR REPLACE FUNCTION ${funcName}() + RETURNS TRIGGER AS $$ + DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); + BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_by, ip_address, full_row_after + ) VALUES ( + 'INSERT', NEW.${pkColumnName}, v_user_id, v_ip_address, row_to_json(NEW)::jsonb + ); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${originalTableName}' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_column, old_value, new_value, + changed_by, ip_address, full_row_before, full_row_after + ) VALUES ( + 'UPDATE', NEW.${pkColumnName}, v_column_name, v_old_value, v_new_value, + v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_by, ip_address, full_row_before + ) VALUES ( + 'DELETE', OLD.${pkColumnName}, v_user_id, v_ip_address, row_to_json(OLD)::jsonb + ); + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + `; +} + +/** + * ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ + */ +private generateTriggerDDL( + triggerName: string, + tableName: string, + funcName: string +): string { + return ` + CREATE TRIGGER ${triggerName} + AFTER INSERT OR UPDATE OR DELETE ON ${tableName} + FOR EACH ROW EXECUTE FUNCTION ${funcName}(); + `; +} + +/** + * ๋กœ๊ทธ ์„ค์ • ์ €์žฅ + */ +private async saveLogConfig(config: { + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + createdBy?: string; +}): Promise { + const query = ` + INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, created_by + ) VALUES ($1, $2, $3, $4, $5) + `; + + await this.executeQuery(query, [ + config.originalTableName, + config.logTableName, + config.triggerName, + config.triggerFunctionName, + config.createdBy + ]); +} +``` + +#### 4.1.2 ํ…Œ์ด๋ธ” ์ƒ์„ฑ ๋ฉ”์„œ๋“œ ์ˆ˜์ • + +```typescript +async createTable(params: { + tableName: string; + columns: any[]; + useLogTable?: boolean; // ์ถ”๊ฐ€ + connectionId?: number; + userId?: string; +}): Promise { + const { tableName, columns, useLogTable, connectionId, userId } = params; + + // 1. ์›๋ณธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + const ddl = this.generateCreateTableDDL(tableName, columns); + await this.executeDDL(ddl, connectionId); + + // 2. ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ (์˜ต์…˜) + if (useLogTable === true) { + await this.createLogTable(tableName, columns, connectionId, userId); + } + + // 3. ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ + await this.saveTableMetadata({ + tableName, + columns, + useLogTable: useLogTable ? 'Y' : 'N', + connectionId, + userId + }); +} +``` + +### 4.2 Controller Layer ์ˆ˜์ • + +**ํŒŒ์ผ**: `backend-node/src/controllers/admin/table-type-mng.controller.ts` + +```typescript +/** + * ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ +async createTable(req: Request, res: Response): Promise { + try { + const { tableName, columns, useLogTable, connectionId } = req.body; + const userId = req.user?.userId; + + await this.tableTypeMngService.createTable({ + tableName, + columns, + useLogTable: useLogTable === 'Y' || useLogTable === true, + connectionId, + userId + }); + + res.json({ + success: true, + message: useLogTable + ? 'ํ…Œ์ด๋ธ” ๋ฐ ๋กœ๊ทธ ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' + : 'ํ…Œ์ด๋ธ”์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' + }); + } catch (error) { + console.error('ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์˜ค๋ฅ˜:', error); + res.status(500).json({ + success: false, + message: 'ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.' + }); + } +} +``` + +### 4.3 ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • ๋ฏธ๋“ค์›จ์–ด + +**ํŒŒ์ผ**: `backend-node/src/middleware/db-session.middleware.ts` + +```typescript +import { Request, Response, NextFunction } from "express"; + +/** + * DB ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • ๋ฏธ๋“ค์›จ์–ด + * ํŠธ๋ฆฌ๊ฑฐ์—์„œ ์‚ฌ์šฉํ•  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์„ธ์…˜ ๋ณ€์ˆ˜์— ์„ค์ • + */ +export const setDBSessionVariables = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.user?.userId || "system"; + const ipAddress = req.ip || req.socket.remoteAddress || "unknown"; + + // PostgreSQL ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • + const queries = [ + `SET app.user_id = '${userId}'`, + `SET app.ip_address = '${ipAddress}'`, + ]; + + // ๊ฐ DB ์—ฐ๊ฒฐ์— ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • + // (์‹ค์ œ ๊ตฌํ˜„์€ DB ์—ฐ๊ฒฐ ํ’€ ๊ด€๋ฆฌ ๋ฐฉ์‹์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) + + next(); + } catch (error) { + console.error("DB ์„ธ์…˜ ๋ณ€์ˆ˜ ์„ค์ • ์˜ค๋ฅ˜:", error); + next(error); + } +}; +``` + +## 5. ํ”„๋ก ํŠธ์—”๋“œ ๊ตฌํ˜„ + +### 5.1 ํ…Œ์ด๋ธ” ์ƒ์„ฑ ํผ ์ˆ˜์ • + +**ํŒŒ์ผ**: `frontend/src/app/admin/tableMng/components/TableCreateForm.tsx` + +```typescript +const TableCreateForm = () => { + const [useLogTable, setUseLogTable] = useState(false); + + return ( +
+ {/* ๊ธฐ์กด ํผ ํ•„๋“œ๋“ค */} + + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์˜ต์…˜ ์ถ”๊ฐ€ */} +
+ +

+ ์ฒดํฌ ์‹œ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๊ธฐ๋กํ•˜๋Š” ๋กœ๊ทธ ํ…Œ์ด๋ธ”์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. + (ํ…Œ์ด๋ธ”๋ช…: {tableName}_log) +

+
+ + {useLogTable && ( +
+

๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ •๋ณด

+
    +
  • โ€ข INSERT/UPDATE/DELETE ์ž‘์—…์ด ์ž๋™์œผ๋กœ ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค
  • +
  • โ€ข ๋ณ€๊ฒฝ ์ „ํ›„ ๊ฐ’๊ณผ ๋ณ€๊ฒฝ์ž ์ •๋ณด๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค
  • +
  • + โ€ข ๋กœ๊ทธ๋Š” ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜์–ด ์›๋ณธ ํ…Œ์ด๋ธ” ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ + ์ตœ์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค +
  • +
+
+ )} +
+ ); +}; +``` + +### 5.2 ๋กœ๊ทธ ์กฐํšŒ ํ™”๋ฉด ์ถ”๊ฐ€ + +**ํŒŒ์ผ**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx` + +```typescript +interface TableLogViewerProps { + tableName: string; +} + +const TableLogViewer: React.FC = ({ tableName }) => { + const [logs, setLogs] = useState([]); + const [filters, setFilters] = useState({ + operationType: "", + startDate: "", + endDate: "", + changedBy: "", + }); + + const fetchLogs = async () => { + // ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ + const response = await fetch(`/api/admin/table-log/${tableName}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(filters), + }); + const data = await response.json(); + setLogs(data.logs); + }; + + return ( +
+

๋ณ€๊ฒฝ ์ด๋ ฅ ์กฐํšŒ: {tableName}

+ + {/* ํ•„ํ„ฐ */} +
+ + + {/* ๋‚ ์งœ ํ•„ํ„ฐ, ์‚ฌ์šฉ์ž ํ•„ํ„ฐ ๋“ฑ */} +
+ + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” */} + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + ))} + +
์ž‘์—…์œ ํ˜•์›๋ณธID๋ณ€๊ฒฝ์ปฌ๋Ÿผ๋ณ€๊ฒฝ์ „๋ณ€๊ฒฝํ›„๋ณ€๊ฒฝ์ž๋ณ€๊ฒฝ์‹œ๊ฐ
{log.operation_type}{log.original_id}{log.changed_column}{log.old_value}{log.new_value}{log.changed_by}{log.changed_at}
+
+ ); +}; +``` + +## 6. API ์—”๋“œํฌ์ธํŠธ + +### 6.1 ๋กœ๊ทธ ์กฐํšŒ API + +``` +POST /api/admin/table-log/:tableName +Request Body: +{ + "operationType": "UPDATE", // ์„ ํƒ: INSERT, UPDATE, DELETE + "startDate": "2024-01-01", // ์„ ํƒ + "endDate": "2024-12-31", // ์„ ํƒ + "changedBy": "user123", // ์„ ํƒ + "originalId": 123 // ์„ ํƒ +} + +Response: +{ + "success": true, + "logs": [ + { + "log_id": 1, + "operation_type": "UPDATE", + "original_id": "123", + "changed_column": "user_name", + "old_value": "ํ™๊ธธ๋™", + "new_value": "๊น€์ฒ ์ˆ˜", + "changed_by": "admin", + "changed_at": "2024-10-21T10:30:00Z", + "ip_address": "192.168.1.100" + } + ] +} +``` + +### 6.2 ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” API + +``` +POST /api/admin/table-log/:tableName/toggle +Request Body: +{ + "isActive": "Y" // Y ๋˜๋Š” N +} + +Response: +{ + "success": true, + "message": "๋กœ๊ทธ ๊ธฐ๋Šฅ์ด ํ™œ์„ฑํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค." +} +``` + +## 7. ํ…Œ์ŠคํŠธ ๊ณ„ํš + +### 7.1 ๋‹จ์œ„ ํ…Œ์ŠคํŠธ + +- [ ] ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ +- [ ] ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ DDL ์ƒ์„ฑ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ +- [ ] ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ +- [ ] ๋กœ๊ทธ ์„ค์ • ์ €์žฅ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ + +### 7.2 ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ + +- [ ] ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ž๋™ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ +- [ ] INSERT ์ž‘์—… ์‹œ ๋กœ๊ทธ ๊ธฐ๋ก ํ…Œ์ŠคํŠธ +- [ ] UPDATE ์ž‘์—… ์‹œ ๋กœ๊ทธ ๊ธฐ๋ก ํ…Œ์ŠคํŠธ +- [ ] DELETE ์ž‘์—… ์‹œ ๋กœ๊ทธ ๊ธฐ๋ก ํ…Œ์ŠคํŠธ +- [ ] ์—ฌ๋Ÿฌ ์ปฌ๋Ÿผ ๋™์‹œ ๋ณ€๊ฒฝ ์‹œ ๋กœ๊ทธ ๊ธฐ๋ก ํ…Œ์ŠคํŠธ + +### 7.3 ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ + +- [ ] ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ INSERT ์‹œ ์„ฑ๋Šฅ ์˜ํ–ฅ ์ธก์ • +- [ ] ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ UPDATE ์‹œ ์„ฑ๋Šฅ ์˜ํ–ฅ ์ธก์ • +- [ ] ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํฌ๊ธฐ ์ฆ๊ฐ€์— ๋”ฐ๋ฅธ ์„ฑ๋Šฅ ์˜ํ–ฅ ์ธก์ • + +## 8. ์ฃผ์˜์‚ฌํ•ญ ๋ฐ ์ œ์•ฝ์‚ฌํ•ญ + +### 8.1 ์„ฑ๋Šฅ ๊ณ ๋ ค์‚ฌํ•ญ + +- ํŠธ๋ฆฌ๊ฑฐ๋Š” ๋ชจ๋“  ๋ณ€๊ฒฝ ์ž‘์—…์— ๋Œ€ํ•ด ์‹คํ–‰๋˜๋ฏ€๋กœ ์„ฑ๋Šฅ ์˜ํ–ฅ์ด ์žˆ์„ ์ˆ˜ ์žˆ์Œ +- ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์‹œ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํฌ๊ธฐ๊ฐ€ ๊ธ‰๊ฒฉํžˆ ์ฆ๊ฐ€ํ•  ์ˆ˜ ์žˆ์Œ +- ๋กœ๊ทธ ํ…Œ์ด๋ธ”์— ์ ์ ˆํ•œ ์ธ๋ฑ์Šค ์„ค์ • ํ•„์š” + +### 8.2 ์šด์˜ ๊ณ ๋ ค์‚ฌํ•ญ + +- ๋กœ๊ทธ ๋ฐ์ดํ„ฐ์˜ ๋ณด๊ด€ ์ฃผ๊ธฐ ์ •์ฑ… ์ˆ˜๋ฆฝ ํ•„์š” +- ์˜ค๋ž˜๋œ ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์•„์นด์ด๋น™ ์ „๋žต ํ•„์š” +- ๋กœ๊ทธ ํ…Œ์ด๋ธ”์˜ ์ •๊ธฐ์ ์ธ ํŒŒํ‹ฐ์…”๋‹ ๊ณ ๋ ค + +### 8.3 ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ + +- ๋กœ๊ทธ ๋ฐ์ดํ„ฐ์—๋Š” ๋ฏผ๊ฐํ•œ ์ •๋ณด๊ฐ€ ํฌํ•จ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ ‘๊ทผ ๊ถŒํ•œ ๊ด€๋ฆฌ ํ•„์š” +- ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์ž์ฒด์˜ ๋ฌด๊ฒฐ์„ฑ ๋ณด์žฅ ํ•„์š” +- ๋กœ๊ทธ ๋ฐ์ดํ„ฐ์˜ ์•”ํ˜ธํ™” ์ €์žฅ ๊ณ ๋ ค + +## 9. ํ–ฅํ›„ ํ™•์žฅ ๊ณ„ํš + +### 9.1 ๋กœ๊ทธ ๋ถ„์„ ๊ธฐ๋Šฅ + +- ๋ณ€๊ฒฝ ํŒจํ„ด ๋ถ„์„ +- ์‚ฌ์šฉ์ž๋ณ„ ๋ณ€๊ฒฝ ํ†ต๊ณ„ +- ์‹œ๊ฐ„๋Œ€๋ณ„ ๋ณ€๊ฒฝ ์ถ”์ด + +### 9.2 ๋กœ๊ทธ ์•Œ๋ฆผ ๊ธฐ๋Šฅ + +- ํŠน์ • ํ…Œ์ด๋ธ”/์ปฌ๋Ÿผ ๋ณ€๊ฒฝ ์‹œ ์•Œ๋ฆผ +- ๋น„์ •์ƒ์ ์ธ ๋Œ€๋Ÿ‰ ๋ณ€๊ฒฝ ๊ฐ์ง€ +- ํŠน์ • ์‚ฌ์šฉ์ž์˜ ๋ณ€๊ฒฝ ์ž‘์—… ๋ชจ๋‹ˆํ„ฐ๋ง + +### 9.3 ๋กœ๊ทธ ๋ณต์› ๊ธฐ๋Šฅ + +- ํŠน์ • ์‹œ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ ๋กค๋ฐฑ +- ๋ณ€๊ฒฝ ์ด๋ ฅ ๊ธฐ๋ฐ˜ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ +- ๋ณ€๊ฒฝ ์ด๋ ฅ ์‹œ๊ฐํ™” + +## 10. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ + +### 10.1 ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ๋กœ๊ทธ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ + +```typescript +// ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ถ”๊ฐ€ํ•˜๋Š” API +POST /api/admin/table-log/:tableName/enable + +// ์‹คํ–‰ ์ˆœ์„œ: +// 1. ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ +// 2. ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์ƒ์„ฑ +// 3. ํŠธ๋ฆฌ๊ฑฐ ์ƒ์„ฑ +// 4. ๋กœ๊ทธ ์„ค์ • ์ €์žฅ +``` + +### 10.2 ๋กœ๊ทธ ๊ธฐ๋Šฅ ์ œ๊ฑฐ + +```typescript +// ๋กœ๊ทธ ๊ธฐ๋Šฅ ์ œ๊ฑฐ API +POST /api/admin/table-log/:tableName/disable + +// ์‹คํ–‰ ์ˆœ์„œ: +// 1. ํŠธ๋ฆฌ๊ฑฐ ์‚ญ์ œ +// 2. ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜ ์‚ญ์ œ +// 3. ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์‚ญ์ œ (์„ ํƒ) +// 4. ๋กœ๊ทธ ์„ค์ • ๋น„ํ™œ์„ฑํ™” +``` + +## 11. ๊ฐœ๋ฐœ ์šฐ์„ ์ˆœ์œ„ + +### Phase 1: ๊ธฐ๋ณธ ๊ธฐ๋Šฅ (ํ•„์ˆ˜) + +1. DB ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ (table_type_mng, table_log_config) +2. ๋กœ๊ทธ ํ…Œ์ด๋ธ” DDL ์ƒ์„ฑ ๋กœ์ง +3. ํŠธ๋ฆฌ๊ฑฐ ํ•จ์ˆ˜/ํŠธ๋ฆฌ๊ฑฐ DDL ์ƒ์„ฑ ๋กœ์ง +4. ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ž๋™ ์ƒ์„ฑ + +### Phase 2: UI ๊ฐœ๋ฐœ + +1. ํ…Œ์ด๋ธ” ์ƒ์„ฑ ํผ์— ๋กœ๊ทธ ์˜ต์…˜ ์ถ”๊ฐ€ +2. ๋กœ๊ทธ ์กฐํšŒ ํ™”๋ฉด ๊ฐœ๋ฐœ +3. ๋กœ๊ทธ ํ•„ํ„ฐ๋ง ๊ธฐ๋Šฅ + +### Phase 3: ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ + +1. ๋กœ๊ทธ ํ™œ์„ฑํ™”/๋น„ํ™œ์„ฑํ™” ๊ธฐ๋Šฅ +2. ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ๋กœ๊ทธ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ +3. ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์•„์นด์ด๋น™ ๊ธฐ๋Šฅ + +### Phase 4: ๋ถ„์„ ๋ฐ ์ตœ์ ํ™” + +1. ๋กœ๊ทธ ๋ถ„์„ ๋Œ€์‹œ๋ณด๋“œ +2. ์„ฑ๋Šฅ ์ตœ์ ํ™” +3. ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ํŒŒํ‹ฐ์…”๋‹