"""시스템 로그 기록·정리 한 곳 (2026-09-06 사용자 확정). 같은 INSERT 문이 여섯 자리에 흩어져 있었고, 대상(무엇에 한 일인가)과 접속 정보(어디서 했는가)는 칸만 있고 값이 비어 있었다. 기록은 이 함수 하나로 모은다. 보관 기간은 `AUDIT_LOG_RETENTION_DAYS`(기본 365일) — 사고 추적에 1년이면 충분하다는 사용자 판단(2026-09-06). 임시 보관함 정리 루프가 돌 때 함께 지운다. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Any from config.config_db import get_db_pool from config.config_system import AUDIT_LOG_RETENTION_DAYS logger = logging.getLogger(__name__) # 브라우저 문자열은 길다 — 표 칸은 TEXT 지만 화면·로그가 감당할 만큼만 자른다. _USER_AGENT_MAX = 300 def request_origin(request: Any | None) -> tuple[str | None, str | None]: """요청에서 접속 주소와 브라우저 문자열을 꺼낸다. 없으면 (None, None). 프록시 뒤에서는 `X-Forwarded-For` 의 **첫 주소**가 실제 사용자다. """ if request is None: return None, None try: forwarded = request.headers.get("x-forwarded-for") address = forwarded.split(",")[0].strip() if forwarded else None if not address and request.client is not None: address = request.client.host agent = (request.headers.get("user-agent") or "")[:_USER_AGENT_MAX] or None return address, agent except Exception: # 기록이 본 작업을 막으면 안 된다. return None, None async def record_audit( cursor: Any, *, actor_id: int, action: str, resource_type: str | None = None, resource_ref: str | int | None = None, request: Any | None = None, ) -> None: """시스템 로그 한 줄을 적는다 — 호출부의 트랜잭션(cursor)에 얹는다. `resource_ref` 는 프로젝트 UUID 처럼 문자열이어도 되고 숫자여도 된다. 숫자면 옛 `resource_id` 칸에도 같이 넣어 예전 기록과 같은 모양을 지킨다. """ reference = None if resource_ref is None else str(resource_ref) numeric = int(resource_ref) if isinstance(resource_ref, int) else None address, agent = request_origin(request) await cursor.execute( """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id, resource_ref, ip_address, user_agent) VALUES (%s, %s, %s, %s, %s, %s, %s)""", (actor_id, action, resource_type, numeric, reference, address, agent), ) async def purge_expired_audit_logs() -> int: """보관 기간이 지난 시스템 로그를 지운다. 지운 줄 수를 돌려준다.""" cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, AUDIT_LOG_RETENTION_DAYS)) pool = get_db_pool() try: async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute("DELETE FROM system_audit_logs WHERE timestamp < %s", (cutoff,)) removed = cursor.rowcount if removed: await connection.commit() logger.info( "시스템 로그 정리: %d건 삭제 (보관 %d일)", removed, AUDIT_LOG_RETENTION_DAYS ) return removed except Exception: logger.exception("시스템 로그 정리 실패") return 0