o2o-negosium-original/lps/services/lps_service.py
민헌 5906acc48a feat(lps): 최저가 이력(price_history) — 트리거 기반 시계열 그래프
같은 상품 반복 검색 시 최저가를 스냅샷으로 적재 → 네이버/쿠팡/최종 3개 선 그래프.
배치 아님(조회된 상품만, 실제 검색 시각에 기록) — 트래픽/리소스 절약.

- price_history 테이블: product_code·triggered_at(X축)·naver/coupang/final 최저가+상세·outcome
- crud/price_history: record() + list_by_product(시각 오름차순)
- handler: AI 매칭 후 소스별 min + 전체 min 스냅샷 기록(_price_snapshot).
  found/not_found 기록, 네거티브 캐시 히트·기술실패는 미기록
- API: GET /v1/lps/products/{product_code}/history → 그래프 데이터(시각 오름차순)
- tests: 스냅샷 계산/기록·조회/핸들러 기록규칙/API → 전체 54/54

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:13:28 +09:00

89 lines
3.3 KiB
Python

"""LPS 검색 요청 도메인 로직. 요청을 검색 잡으로 큐에 적재하고 상태/통계를 조회한다.
라우터는 요청 검증→service 호출→RemoveNoneResponse 만 담당(backend 컨벤션).
실제 검색/크롤링은 워커가 큐에서 잡을 꺼내 파이프라인으로 수행한다(비동기 분리).
"""
import uuid
from fastapi import Depends
from common.enums import ErrorType, JobStatus, JobType
from crud.job_crud import JobQueue
from crud.price_history import PriceHistory
from router.v1.lps.protocol import (
EnqueuedItem,
PricePoint,
Res_JobStatus,
Res_PriceHistory,
Res_QueueStats,
Res_Search,
SearchItem,
)
# 요청 유형 → 우선순위(낮을수록 우선). 알 수 없는 유형은 가장 낮은 우선순위(배치와 동급).
_PRIORITY = {"new": 1, "single": 2, "negowiz": 3, "batch": 4}
class LpsService:
def __init__(self, queue: JobQueue = Depends(JobQueue), history: PriceHistory = Depends(PriceHistory)):
self.queue = queue
self.history = history
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 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"],
)
for r in rows
]
return res