# ๐ ์์ ํ์คํ ๋ฆฌ ์ ์ฅ ๋ฐฉ์ ## ๐ฏ ๋ชฉ์ - ๊ฐ ํ์ด์ง์์ ์์ ํ ๋ด์ฉ์ ๋ณ๊ฒฝ ์ด๋ ฅ ์ถ์ - ๋๊ฐ, ์ธ์ , ๋ฌด์์, ์ ๋ณ๊ฒฝํ๋์ง ๊ธฐ๋ก - ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ ๋ฐ ๊ฐ์ฌ ์ถ์ (Audit Trail) ์ ๊ณต ## ๐๏ธ DB ๊ตฌ์กฐ ๋ฐฉ์ ### ๋ฐฉ์ 1: ๋จ์ผ ํ์คํ ๋ฆฌ ํ ์ด๋ธ (๊ถ์ฅ) ```sql CREATE TABLE issue_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), change_type VARCHAR(50) NOT NULL, -- 'UPDATE', 'STATUS_CHANGE', 'COMPLETION_REQUEST' ๋ฑ page_source VARCHAR(50) NOT NULL, -- 'INBOX', 'MANAGEMENT', 'DASHBOARD' ๋ฑ field_name VARCHAR(100), -- ๋ณ๊ฒฝ๋ ํ๋๋ช old_value TEXT, -- ์ด์ ๊ฐ (JSON ํํ๋ก ์ ์ฅ ๊ฐ๋ฅ) new_value TEXT, -- ์๋ก์ด ๊ฐ (JSON ํํ๋ก ์ ์ฅ ๊ฐ๋ฅ) change_reason TEXT, -- ๋ณ๊ฒฝ ์ฌ์ (์ ํ์ฌํญ) session_id VARCHAR(100), -- ๋์ผ ์ธ์ ์์ ์ฌ๋ฌ ํ๋ ๋ณ๊ฒฝ ์ ๊ทธ๋ฃนํ ip_address INET, -- ๋ณ๊ฒฝ์ IP ์ฃผ์ user_agent TEXT -- ๋ธ๋ผ์ฐ์ ์ ๋ณด ); -- ์ธ๋ฑ์ค ์์ฑ CREATE INDEX idx_issue_history_issue_id ON issue_history(issue_id); CREATE INDEX idx_issue_history_changed_at ON issue_history(changed_at); CREATE INDEX idx_issue_history_changed_by ON issue_history(changed_by_id); ``` ### ๋ฐฉ์ 2: ํ์ด์ง๋ณ ํ์คํ ๋ฆฌ ํ ์ด๋ธ ```sql -- ์์ ํจ ํ์คํ ๋ฆฌ CREATE TABLE inbox_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), action_type VARCHAR(50), -- 'REVIEW', 'DISPOSE', 'STATUS_CHANGE' old_data JSONB, new_data JSONB, changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- ๊ด๋ฆฌํจ ํ์คํ ๋ฆฌ CREATE TABLE management_history ( id SERIAL PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), action_type VARCHAR(50), -- 'UPDATE', 'COMPLETION_REQUEST', 'APPROVAL' old_data JSONB, new_data JSONB, changed_by_id INTEGER NOT NULL REFERENCES users(id), changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); ``` ## ๐ง ๊ตฌํ ๋ฐฉ์ ### 1๏ธโฃ ๋ฐฑ์๋ ๊ตฌํ (FastAPI) #### ํ์คํ ๋ฆฌ ์๋น์ค ํด๋์ค ```python # services/history_service.py from typing import Dict, Any, Optional import json from datetime import datetime from sqlalchemy.orm import Session class HistoryService: @staticmethod def log_change( db: Session, issue_id: int, changed_by_id: int, change_type: str, page_source: str, old_data: Dict[str, Any], new_data: Dict[str, Any], change_reason: Optional[str] = None, session_id: Optional[str] = None ): """๋ณ๊ฒฝ ์ด๋ ฅ ๊ธฐ๋ก""" # ๋ณ๊ฒฝ๋ ํ๋๋ค ์ฐพ๊ธฐ changed_fields = [] for field, new_value in new_data.items(): old_value = old_data.get(field) if old_value != new_value: history_entry = IssueHistory( issue_id=issue_id, changed_by_id=changed_by_id, change_type=change_type, page_source=page_source, field_name=field, old_value=json.dumps(old_value) if old_value else None, new_value=json.dumps(new_value) if new_value else None, change_reason=change_reason, session_id=session_id ) db.add(history_entry) changed_fields.append(field) db.commit() return changed_fields @staticmethod def get_issue_history(db: Session, issue_id: int, limit: int = 50): """์ด์์ ๋ณ๊ฒฝ ์ด๋ ฅ ์กฐํ""" return db.query(IssueHistory)\ .filter(IssueHistory.issue_id == issue_id)\ .order_by(IssueHistory.changed_at.desc())\ .limit(limit)\ .all() ``` #### API ์๋ํฌ์ธํธ์์ ํ์คํ ๋ฆฌ ๊ธฐ๋ก ```python # routers/management.py from services.history_service import HistoryService @router.put("/{issue_id}") async def update_issue( issue_id: int, update_request: ManagementUpdateRequest, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): # ๊ธฐ์กด ์ด์ ๋ฐ์ดํฐ ๋ฐฑ์ issue = db.query(Issue).filter(Issue.id == issue_id).first() old_data = { "final_description": issue.final_description, "solution": issue.solution, "responsible_department": issue.responsible_department, "responsible_person": issue.responsible_person, "expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None } # ์ ๋ฐ์ดํธ ์ํ update_data = update_request.dict(exclude_unset=True) for field, value in update_data.items(): setattr(issue, field, value) db.commit() db.refresh(issue) # ์๋ก์ด ๋ฐ์ดํฐ new_data = { "final_description": issue.final_description, "solution": issue.solution, "responsible_department": issue.responsible_department, "responsible_person": issue.responsible_person, "expected_completion_date": issue.expected_completion_date.isoformat() if issue.expected_completion_date else None } # ํ์คํ ๋ฆฌ ๊ธฐ๋ก session_id = str(uuid.uuid4()) HistoryService.log_change( db=db, issue_id=issue_id, changed_by_id=current_user.id, change_type="UPDATE", page_source="MANAGEMENT", old_data=old_data, new_data=new_data, session_id=session_id ) return {"message": "์ ๋ฐ์ดํธ ์๋ฃ", "changed_fields": list(update_data.keys())} ``` ### 2๏ธโฃ ํ๋ก ํธ์๋ ๊ตฌํ #### ํ์คํ ๋ฆฌ ์กฐํ ๋ชจ๋ฌ ```javascript // ํ์คํ ๋ฆฌ ์กฐํ ํจ์ async function showIssueHistory(issueId) { try { const response = await fetch(`/api/issues/${issueId}/history`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } }); if (response.ok) { const history = await response.json(); openHistoryModal(issueId, history); } } catch (error) { console.error('ํ์คํ ๋ฆฌ ์กฐํ ์คํจ:', error); } } // ํ์คํ ๋ฆฌ ๋ชจ๋ฌ ์์ฑ function openHistoryModal(issueId, history) { const modalContent = `
${entry.old_value || '์์'}
${entry.new_value || '์์'}