o2o-negosium-original/lps/services/admin_service.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

117 lines
4.8 KiB
Python

"""관리자 FE 도메인 로직 — 조회 중심 + 필수 액션(DEAD 재큐)만.
설정 변경(예산·임계 등)은 FE 에 두지 않는다 — 설정은 toml 단일 소스(재시작 반영) 원칙.
"""
import uuid
from fastapi import Depends
from sqlalchemy.exc import IntegrityError
from common.enums import ErrorType, JobStatus
from crud.bot_detection import BotDetectionLog
from crud.ip_session import IpSessionLog
from crud.job_crud import JobQueue
from crud.price_history import PriceHistory
from router.v1.lps.admin_protocol import (
BotRow,
CostBucket,
HistogramBin,
HourlyCount,
IpSessionRow,
JobListItem,
ProductItem,
Res_BotStats,
Res_CostStats,
Res_IpSessionStats,
Res_JobList,
Res_ProductList,
Res_Requeue,
)
_STATUS_BY_NAME = {js.name: js.value for js in JobStatus}
class AdminService:
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory),
ip_log: IpSessionLog = Depends(IpSessionLog), bot_log: BotDetectionLog = Depends(BotDetectionLog)):
self.queue = queue
self.history = history
self.ip_log = ip_log
self.bot_log = bot_log
async def list_jobs(self, status: str | None, q: str | None, limit: int, offset: int) -> Res_JobList:
res = Res_JobList()
status_code = _STATUS_BY_NAME.get(status.upper()) if status else None
if status and status_code is None:
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) # 알 수 없는 상태 이름
return res
rows, total = await self.queue.list_jobs(status_code, q, limit, offset)
res.total = total
res.items = [JobListItem(
job_id=r["job_id"], job_type=r["job_type"], status=JobStatus(r["status"]).name,
priority=r["priority"], attempts=r["attempts"], max_attempts=r["max_attempts"],
product_code=r.get("product_code"), product_name=r.get("product_name"),
outcome=r.get("outcome"), final_lowest=r.get("final_lowest"), cost_usd=r.get("cost_usd"),
last_error=r.get("last_error"),
created_at=r["created_at"].isoformat(timespec="seconds"),
run_started_at=r["run_started_at"].isoformat(timespec="seconds") if r.get("run_started_at") else None,
updated_at=r["updated_at"].isoformat(timespec="seconds"),
) for r in rows]
return res
async def requeue(self, job_id: str) -> Res_Requeue:
res = Res_Requeue(job_id=job_id)
try:
uuid.UUID(job_id)
except (ValueError, TypeError):
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
return res
try:
requeued = await self.queue.requeue(job_id)
except IntegrityError:
# 같은 상품의 활성 잡(PENDING/RUNNING)이 이미 있음 — 부분 유니크(dedupe) 위반
res.result.SetResult(ErrorType.DB_ALREADY_SAME_KEY)
return res
if requeued is None: # 없거나 DEAD 가 아님
res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND)
return res
res.requeued = True
return res
async def list_products(self, q: str | None, limit: int) -> Res_ProductList:
res = Res_ProductList()
res.items = [ProductItem(
product_code=r["product_code"], display_name=r.get("display_name"),
triggered_at=r["triggered_at"].isoformat(timespec="seconds"), outcome=r["outcome"],
naver_lowest=r.get("naver_lowest"), coupang_lowest=r.get("coupang_lowest"),
final_lowest=r.get("final_lowest"), final_source=r.get("final_source"),
searches=int(r.get("searches") or 0),
) for r in await self.history.list_products(q, limit)]
return res
async def ip_session_stats(self, hours: int) -> Res_IpSessionStats:
res = Res_IpSessionStats()
st = await self.ip_log.admin_stats(hours)
res.by_reason = st["by_reason"]
res.histogram = [HistogramBin(**b) for b in st["histogram"]]
res.block_min_requests = st["block_min_requests"]
res.sessions = [IpSessionRow(
**{**s, "created_at": s["created_at"].isoformat(timespec="seconds")}
) for s in st["sessions"]]
return res
async def bot_stats(self, hours: int) -> Res_BotStats:
res = Res_BotStats()
st = await self.bot_log.admin_stats(hours)
res.hourly = [HourlyCount(bucket=h["bucket"], count=h["count"]) for h in st["hourly"]]
res.items = [BotRow(
**{**b, "created_at": b["created_at"].isoformat(timespec="seconds")}
) for b in st["items"]]
return res
async def cost_stats(self, hours: int) -> Res_CostStats:
res = Res_CostStats()
res.buckets = [CostBucket(**b) for b in await self.queue.cost_buckets(hours)]
return res