o2o-negosium-original/lps/services/lps_service.py
민헌 d1d1ed6ec5 feat(lps-admin): 3단계 — 몰별 확인 상태를 운영 화면에 노출
2단계에서 저장한 sources/partial 을 운영자가 볼 수 있게 한다. 운영자 목적은 **진단**이라
상태를 접지 않는다 — blocked(IP 회전으로 자동 회복)와 env_blocked(사람이 환경·설정을 고쳐야 함)를
뭉뚱그리면 회복될 일에 매달리거나 손봐야 할 설정을 방치하게 된다.

API
- /v1/lps/products, /v1/lps/products/{code}/history 둘 다 sources·partial 을 싣는다.
- 이력은 **시점마다** 싣는다. 최신 상태를 과거 시점의 몰별 표 옆에 붙이면 '그때도 막혔던 것처럼'
  보여 오해를 부른다 — 그래서 ProductItem 이 아니라 PricePoint 에 담았다.

화면
- lib/sourceState.ts: 상태별 라벨·색·설명·confirmed 를 한곳에. 미지의 상태가 와도 화면이 깨지지
  않는다(값 그대로 표시 + '모름' 취급). 색은 전부 @theme 토큰 참조(raw hex 금지).
- 상품 목록: partial 이면 '일부 확인 못함' 배지 + 툴팁에 어느 몰인지.
- 몰별 비교 카드 위: 몰별 상태·수집 건수·실패 사유 원문(툴팁). 가격표에 없는 몰이 **왜** 없는지를
  여기서 답한다 — by_mall 은 가격이 있는 몰만 담으므로 그 답이 여기밖에 없다.

검증: ASGI 직접 호출로 두 엔드포인트 응답 확인(한글 사유 포함), tsc 오류 없음.
테스트 3건 추가(목록 노출 / 시점별 상태가 각각 다르게 / 컬럼 추가 이전 옛 행 호환).
전체 292 passed. 진행 상황은 docs/result-states.md 4절.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:05:31 +09:00

103 lines
4.1 KiB
Python

"""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