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>
79 lines
3.3 KiB
Python
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))
|