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>
74 lines
3.6 KiB
Python
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)
|