o2o-negosium-original/lps/crud/ip_session.py
민헌 61b438d8d0 feat(lps): 관리자 FE 용 API 6종 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계
React 관리자 페이지(협의: 모니터링+필수 액션)의 데이터 소스.

- GET /v1/lps/jobs: 최신순 목록+총건수, status/q(상품코드·명) 필터.
  결과에 outcome·최저가·검색원가·오류를 평탄화해 목록에서 바로 보이게.
- POST /v1/lps/jobs/{id}/requeue: DEAD 재큐(attempts 리셋+pg_notify 워커
  깨움). 활성 중복(dedupe)이면 DB_ALREADY_SAME_KEY 로 거절.
- GET /v1/lps/products: 상품별 최신 스냅샷+누적 검색 수(최근 검색순).
- GET /v1/lps/stats/ip-sessions: 종료사유 분포·요청수 히스토그램·차단
  세션 최소 요청수(예산 튜닝 기준선)·최근 세션 50.
- GET /v1/lps/stats/bot: 시간대별 차단 + 최근 감지 목록.
- GET /v1/lps/stats/cost: 시간별 원가(AI/프록시 분해)+평균 소요.
- AdminService/admin_protocol/admin 라우터 신설, guard 일괄 적용.
  설정 변경 UI 는 두지 않음 — toml 단일 소스 원칙.
- 테스트 9건 추가, 전체 154 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:42:38 +09:00

74 lines
3.6 KiB
Python

"""IP 세션 종료 이력 CRUD — 세션당 요청 수·종료 사유를 축적(요청 예산 상한 튜닝용)."""
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
class IpSessionLog:
DB = DBType.MAIN.value
async def record(self, event: dict):
"""세션 종료 이벤트 1건 저장. 기록 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다."""
sql = text("""
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason)
VALUES (:source, :proxy_port, :requests, :ok_count, :blocked_count, :elapsed_sec, :end_reason)
""")
params = {k: event.get(k) for k in
("source", "proxy_port", "requests", "ok_count", "blocked_count", "elapsed_sec", "end_reason")}
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
try:
await s.execute(sql, params)
await s.commit()
except Exception:
await s.rollback()
raise
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
async def admin_stats(self, hours: int = 168) -> dict:
"""관리자 FE 용 IP 세션 통계 — 종료 사유 분포·세션당 요청 수 히스토그램·
차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션 목록."""
p = {"h": hours}
by_reason = text("""
SELECT end_reason, count(*) AS n FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) GROUP BY end_reason
""")
histogram = text("""
SELECT requests, count(*) AS n FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) GROUP BY requests ORDER BY requests
""")
block_min = text("""
SELECT min(requests) FROM ip_session
WHERE created_at > now() - make_interval(hours => :h) AND end_reason = 'block'
""")
recent = text("""
SELECT source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason, created_at
FROM ip_session WHERE created_at > now() - make_interval(hours => :h)
ORDER BY created_at DESC LIMIT 50
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
return {
"by_reason": {r[0]: int(r[1]) for r in (await s.execute(by_reason, p)).all()},
"histogram": [{"requests": int(r[0]), "count": int(r[1])} for r in (await s.execute(histogram, p)).all()],
"block_min_requests": (lambda v: int(v) if v is not None else None)((await s.execute(block_min, p)).scalar()),
"sessions": [dict(r) for r in (await s.execute(recent, p)).mappings().all()],
}
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
async def recent_stats(self, minutes: int = 60) -> dict:
"""최근 N분 세션 요약 — 종료 사유별 건수(모니터링·알림용). 예: {"budget": 12, "block": 1}"""
sql = text("""
SELECT end_reason, count(*) FROM ip_session
WHERE created_at > now() - make_interval(mins => :m) GROUP BY end_reason
""")
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
try:
rows = (await s.execute(sql, {"m": minutes})).all()
return {r[0]: int(r[1]) for r in rows}
finally:
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)