"""LPS 검색 요청 도메인 로직. 요청을 검색 잡으로 큐에 적재하고 상태/통계를 조회한다. 라우터는 요청 검증→service 호출→RemoveNoneResponse 만 담당(backend 컨벤션). 실제 검색/크롤링은 워커가 큐에서 잡을 꺼내 파이프라인으로 수행한다(비동기 분리). """ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.enums import ErrorType, JobStatus, JobType from crud.job_crud import JobQueue from crud.price_history import PriceHistory from crud.bot_detection import BotDetectionLog from router.v1.lps.protocol import ( EnqueuedItem, PricePoint, Res_JobStatus, Res_PriceHistory, Res_QueueStats, Res_Search, SearchItem, ) # 요청 유형 → 우선순위(낮을수록 우선). 알 수 없는 유형은 가장 낮은 우선순위(배치와 동급). _PRIORITY = {"new_product": 1, "manual": 2, "partner": 3, "batch": 4} class LpsService: def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory), bot_log: BotDetectionLog = Depends(BotDetectionLog)): self.queue = queue self.history = history self.bot_log = bot_log async def submit_search(self, items: list[SearchItem]) -> Res_Search: res = Res_Search() for it in items: priority = _PRIORITY.get(it.job_type, 4) job_id = await self.queue.enqueue( JobType.SEARCH.value, it.model_dump(), priority=priority, dedupe_key=f"search-{it.product_code}", ) res.items.append(EnqueuedItem(product_code=it.product_code, job_id=job_id, duplicated=job_id is None)) res.accepted = sum(1 for i in res.items if i.job_id is not None) return res async def get_job(self, job_id_str: str) -> Res_JobStatus: res = Res_JobStatus() try: uuid.UUID(job_id_str) # 잘못된 id 는 DB 조회 전에 컷 except (ValueError, TypeError): res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) return res row = await self.queue.get(job_id_str) if row is None: res.result.SetResult(ErrorType.LPS_JOB_NOT_FOUND) return res res.job_id = row["job_id"] res.status = JobStatus(row["status"]).name res.attempts = row["attempts"] res.max_attempts = row["max_attempts"] res.output = row.get("result") res.last_error = row.get("last_error") return res async def stats(self) -> Res_QueueStats: res = Res_QueueStats() res.counts = await self.queue.counts() return res async def ops(self) -> dict: """운영 스냅샷(모니터링·알림용, 플랫 JSON): 큐 카운트·지연·최근 DEAD·최근 차단 수 + DB 풀 사용률.""" snap = await self.queue.ops() snap["blocks_1h"] = await self.bot_log.recent_count(60) pool = DB_SESSION_MNG.pool_status() # API 프로세스 자신의 풀(워커 풀은 워커 ops-monitor 가 감시) snap["pool_checked_out"], snap["pool_capacity"], snap["pool_pct"] = pool["checked_out"], pool["capacity"], pool["pct"] return snap async def price_history(self, product_code: str, limit: int = 100) -> Res_PriceHistory: res = Res_PriceHistory(product_code=product_code) rows = await self.history.list_by_product(product_code, limit) res.points = [ PricePoint( triggered_at=r["triggered_at"].isoformat(timespec="seconds"), outcome=r["outcome"], matched_count=r["matched_count"], naver=r["naver_lowest"], coupang=r["coupang_lowest"], final=r["final_lowest"], final_source=r["final_source"], naver_name=r["naver_name"], naver_url=r["naver_url"], coupang_name=r["coupang_name"], coupang_url=r["coupang_url"], by_mall=r.get("by_mall"), sources=r.get("sources"), partial=bool(r.get("partial")), ) for r in rows ] return res