o2o-negosium-original/lps/router/v1/lps/admin.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

79 lines
3.3 KiB
Python

"""관리자 FE 라우터 — 잡 목록/재큐·상품 목록·IP세션/차단/비용 통계.
검증→service→응답만(backend 컨벤션). guard(X-API-Key)는 router.py 의 include 에서 일괄 적용.
"""
from fastapi import APIRouter, Depends, Query
from router.v1.validator.dependencies import RemoveNoneResponse
from services.admin_service import AdminService
from router.v1.lps.admin_protocol import (
Res_BotStats, Res_CostStats, Res_IpSessionStats, Res_JobList, Res_ProductList, Res_Requeue,
)
router = APIRouter(prefix="/v1/lps", tags=["LPS Admin"], responses={404: {"description": "Not found"}})
@router.get(
path="/jobs",
response_model=Res_JobList,
summary="잡 목록(관리자)",
description="최신순 잡 목록 + 전체 건수. status(PENDING/RUNNING/DONE/DEAD)·q(상품코드/상품명 부분일치) 필터.",
)
async def list_jobs(status: str | None = Query(None), q: str | None = Query(None),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
service: AdminService = Depends()):
return RemoveNoneResponse(await service.list_jobs(status, q, limit, offset))
@router.post(
path="/jobs/{job_id}/requeue",
response_model=Res_Requeue,
summary="DEAD 잡 재큐(관리자)",
description="재시도 소진으로 죽은 잡을 attempts 리셋 후 다시 대기열에 넣는다(워커 즉시 깨움). "
"같은 상품의 활성 잡이 있으면 DB_ALREADY_SAME_KEY.",
)
async def requeue_job(job_id: str, service: AdminService = Depends()):
return RemoveNoneResponse(await service.requeue(job_id))
@router.get(
path="/products",
response_model=Res_ProductList,
summary="검색 이력 상품 목록(관리자)",
description="price_history 에 이력이 있는 상품별 최신 스냅샷 + 누적 검색 수. 최근 검색순.",
)
async def list_products(q: str | None = Query(None), limit: int = Query(50, ge=1, le=200),
service: AdminService = Depends()):
return RemoveNoneResponse(await service.list_products(q, limit))
@router.get(
path="/stats/ip-sessions",
response_model=Res_IpSessionStats,
summary="IP 세션 통계(관리자)",
description="종료 사유 분포·세션당 요청 수 히스토그램·차단 세션 최소 요청 수(예산 튜닝 기준선)·최근 세션.",
)
async def ip_session_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.ip_session_stats(hours))
@router.get(
path="/stats/bot",
response_model=Res_BotStats,
summary="차단(봇 감지) 통계(관리자)",
description="시간대별 차단 건수 + 최근 감지 목록(마커·포트·IP 요청순번).",
)
async def bot_stats(hours: int = Query(168, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.bot_stats(hours))
@router.get(
path="/stats/cost",
response_model=Res_CostStats,
summary="검색원가 시계열(관리자)",
description="완료 잡의 metrics 를 시간별 합산 — AI vs 프록시 대역폭 비용 분해 + 평균 소요.",
)
async def cost_stats(hours: int = Query(48, ge=1, le=720), service: AdminService = Depends()):
return RemoveNoneResponse(await service.cost_stats(hours))