o2o-negosium-original/lps/router/v1/lps/protocol.py
민헌 ba9d34fd31 [fix] lps: 네거티브 캐시 히트 시 이력 누락 수정 + force 재검색 지원
1) 캐시 히트가 price_history 를 남기지 않던 문제
   not_found 는 24시간 네거티브 캐시에 들어가는데, 캐시에 걸린 조기 반환 경로만
   _record_history 를 호출하지 않았다(다른 모든 경로는 호출).
   그 결과 잡은 완료인데 price_history 에 새 행이 없어, 이를 폴링하는 소비자
   (negodata 최저가 모달)가 결과를 영영 못 받고 로딩만 돌았다.
   → 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다.

2) force 플래그
   negodata 는 이제 수동 트리거 전용인데 is_negative() 가 job_type 을 보지 않아
   사람이 직접 누른 재검색까지 캐시가 가로막았다. 게다가 캐시 키가 product_code 라
   상품명·모델을 고쳐 재시도해도 동일하게 막힌다.
   → SearchItem.force=true 면 NegativeCache.drop() 으로 기록을 지우고 실제 검색.
   기본 요청은 캐시를 그대로 써서 비용 절감 효과는 유지.

실측: 캐시에 막혀 not_found 만 반복하던 상품이 force 재검색에서 2라운드 만에 found(8,500원).
⚠️ protocol.py 변경은 lps-api 와 lps-worker 를 함께 재빌드해야 반영된다
   (API 만 옛 스키마면 pydantic 이 force 를 조용히 버린다 — 실측으로 확인).

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

69 lines
3.1 KiB
Python

"""LPS API 요청/응답 프로토콜. 커머스→오투오 검색요청 계약을 미러링한다."""
from typing import Optional
from pydantic import BaseModel, Field
from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
class SearchItem(BaseModel):
"""검색 대상 상품 1건. 규격/모델/제조사는 매칭(향후 AI 유사도) 입력으로 함께 적재한다."""
product_code: str = Field(description="상품 식별 코드(커머스 기준). dedupe 키로도 사용")
product_name: str = Field(description="상품명")
job_type: str = Field("manual", description="요청 유형(우선순위): new_product|manual|partner|batch")
model: str = Field("", description="모델명")
specification: str = Field("", description="규격(용량/개입/수량 등)")
company: str = Field("", description="제조사/브랜드")
price: str = Field("", description="현재가(참고, 문자열)")
force: bool = Field(False, description="네거티브 캐시(24h not_found)를 무시하고 실제로 재검색할지. 사용자가 '다시 검색'을 누른 경우만 true")
class Req_Search(Req_WebPacketProtocol):
data: list[SearchItem] = Field(description="검색 대상 상품 리스트")
class EnqueuedItem(BaseModel):
product_code: str
job_id: Optional[str] = Field(None, description="적재된 잡 ID. 활성 중복이면 None")
duplicated: bool = Field(False, description="활성 중복(PENDING/RUNNING)이라 스킵됐는지")
class Res_Search(Res_WebPacketProtocol):
accepted: int = Field(0, description="새로 적재된 잡 수(중복 제외)")
items: list[EnqueuedItem] = Field(default_factory=list)
class Res_JobStatus(Res_WebPacketProtocol):
job_id: Optional[str] = None
status: Optional[str] = Field(None, description="JobStatus 이름(PENDING/RUNNING/DONE/DEAD)")
attempts: Optional[int] = None
max_attempts: Optional[int] = None
output: Optional[dict] = Field(None, description="잡 결과(완료 시). 봉투 result 와 구분")
last_error: Optional[str] = None
class Res_QueueStats(Res_WebPacketProtocol):
counts: dict[str, int] = Field(default_factory=dict, description="상태별 잡 개수")
class PricePoint(BaseModel):
triggered_at: str = Field(description="관측 시각(X축)")
outcome: str
matched_count: Optional[int] = None
naver: Optional[int] = Field(None, description="네이버 최저가")
coupang: Optional[int] = Field(None, description="쿠팡 최저가")
final: Optional[int] = Field(None, description="전체 최저가(Y축)")
final_source: Optional[str] = None
naver_name: Optional[str] = None
naver_url: Optional[str] = None
coupang_name: Optional[str] = None
coupang_url: Optional[str] = None
by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)")
class Res_PriceHistory(Res_WebPacketProtocol):
product_code: Optional[str] = None
points: list[PricePoint] = Field(default_factory=list, description="시각 오름차순 스냅샷(그래프용)")