[feat] negodata/backend: 몰별 최저가(by_mall) 노출 + 강제 재검색(force) 연동

몰별 성공/실패 표시
  lps_db.price_history 에는 몰별 정보가 다 있는데(naver_lowest·coupang_lowest·by_mall),
  동기화가 final_source(이긴 몰) 하나만 남기고 나머지를 버려서 API 로는
  "네이버는 어땠는지"를 알 수 없었다. 실제로 네이버는 빈손이고 쿠팡만 성공하는
  케이스가 기본값처럼 나오는 중이라 화면에 드러낼 필요가 있다.
  → price_history.by_mall(JSONB)을 partner.item_internet_lowest_prices 로 그대로
    미러링한다(열린 스키마 — 오픈마켓 폴백이 늘어도 스키마 변경 불필요).
  - crud: 읽기 계약에 by_mall 추가 + SELECT 포함
  - model: item_internet_lowest_prices.by_mall
  - service: 언팩·저장
  - protocol: LowestPriceEntry.by_mall
  - init.sql: 테이블 정의 + 하단 보정 ALTER(기존 DB 반영용)

강제 재검색
  POST /v1/item/{id}/lowest-price?force=true → LPS 네거티브 캐시 우회.
  사용자가 '다시 검색'을 누른 경우에만 true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-28 11:55:38 +09:00
parent ba9d34fd31
commit 0cefe315c4
6 changed files with 24 additions and 5 deletions

View File

@ -134,6 +134,10 @@ class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함
lp_name = Column(String(300), nullable=True) # 찾은 상품명(판매 페이지 기준) — 근거 검증용
lp_url = Column(String, nullable=True) # 찾은 판매 페이지 링크(TEXT) — 근거 검증용
# 몰별 최저가 스냅샷 — lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마).
# [{source, mall_name, price, shipping_fee, shipping_type, name, detail_url}, ...] 가격 오름차순.
# 매칭된 몰만 들어오므로, 특정 몰이 없으면 그 몰은 빈손이었다는 뜻이다(네이버 실패/쿠팡만 성공 구분).
by_mall = Column(JSONB, nullable=True)
crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준)

View File

@ -29,6 +29,7 @@ _price_history = table(
column("naver_url"),
column("coupang_name"), # 쿠팡 최저가 상품명/링크
column("coupang_url"),
column("by_mall"), # 몰별 최저가 스냅샷(JSONB 배열) — 어느 몰이 건졌고 어느 쪽이 빈손인지
column("created_at"),
)
@ -116,6 +117,7 @@ class LpsSyncCRUD(ILpsSyncCRUD):
_price_history.c.naver_url,
_price_history.c.coupang_name,
_price_history.c.coupang_url,
_price_history.c.by_mall,
_price_history.c.created_at,
).order_by(_price_history.c.created_at.asc())
if since is not None:

View File

@ -86,12 +86,13 @@ async def trigger_lowest_price(
service: ItemService = Depends(),
lps: LpsSyncService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
force: bool = Query(False, description="LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true"),
):
"""상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링."""
got = await service.get_item(user_info.company_id, str(item_id)) # 존재/소유(company 스코프) 확인
if got.item is None:
return RemoveNoneResponse(got)
status, message = await lps.request_search_for_item(got.item)
status, message = await lps.request_search_for_item(got.item, force=force)
res = Res_LowestPriceTrigger(item_id=str(item_id), status=status, message=message)
if status == "unavailable":
res.result.SetResult(ErrorType.LPS_UNAVAILABLE)

View File

@ -145,6 +145,9 @@ class LowestPriceEntry(WebPacketProtocol):
fail_reason: Optional[str] = None
lp_name: Optional[str] = None # 찾은 상품명(판매 페이지 기준) — 근거 검증용
lp_url: Optional[str] = None # 찾은 판매 페이지 링크
# 몰별 최저가 스냅샷(가격 오름차순). 매칭된 몰만 들어오므로, 없는 몰은 그 회차에 빈손이었다는 뜻.
# [{source: 'naver'|'coupang'|…, mall_name, price, shipping_fee, shipping_type, name, detail_url}]
by_mall: Optional[list[dict]] = None
crawl_end_time: Optional[datetime] = None

View File

@ -57,8 +57,10 @@ class LpsSyncService:
return DB_SESSION_MNG.is_registered(DBType.LPS.value)
# ---- 단건 즉시 요청 (lowest-price 트리거 API 용) ---------------------
async def request_search_for_item(self, item) -> tuple:
async def request_search_for_item(self, item, force: bool = False) -> tuple:
"""상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위).
force=True 면 LPS 의 네거티브 캐시(24h not_found)를 무시하고 실제로 재검색한다
(사용자가 '다시 검색'을 누른 경우. 상품명·모델을 고쳐 재시도하는 흐름에 필요).
반환: (status, message) — queued | duplicated | unavailable."""
if not self.available():
return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)"
@ -70,6 +72,7 @@ class LpsSyncService:
"specification": item.spec or "",
"company": item.manufacturer or "",
"price": str(item.price) if item.price else "",
"force": force,
}
base = web_server_config.lps_base_url.rstrip("/")
# LPS API guard: prod 는 lps_api_key 를 채워 X-API-Key 로 인증(개발은 빈값=개방 모드).
@ -131,7 +134,7 @@ class LpsSyncService:
# product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵
parsed = []
for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, created_at in rows:
for code, outcome, final_lowest, final_source, nv_name, nv_url, cp_name, cp_url, by_mall, created_at in rows:
try:
iid = uuid.UUID(code)
except (ValueError, AttributeError, TypeError):
@ -142,7 +145,7 @@ class LpsSyncService:
"naver": (nv_name, nv_url),
"coupang": (cp_name, cp_url),
}.get((final_source or "").lower(), (None, None))
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, created_at))
parsed.append((iid, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at))
err, existing = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value,
@ -152,7 +155,7 @@ class LpsSyncService:
return results
history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price)
for item_id, outcome, final_lowest, final_source, src_name, src_url, created_at in parsed:
for item_id, outcome, final_lowest, final_source, src_name, src_url, by_mall, created_at in parsed:
if item_id not in existing:
results["skipped_unknown_item"] += 1
continue
@ -165,6 +168,7 @@ class LpsSyncService:
fail_reason=None if found else (outcome or "unknown")[:100],
lp_name=(src_name or None) and src_name[:300],
lp_url=src_url or None,
by_mall=by_mall or None, # 몰별 스냅샷 그대로 미러링 — 몰별 성공/실패 표시용
crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존
))
results["found" if found else "not_found"] += 1

View File

@ -186,6 +186,7 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
crawl_duration_ms INTEGER NULL,
lp_name VARCHAR(300) NULL, -- [2026-07-10] 찾은 상품명(판매 페이지 기준)
lp_url TEXT NULL, -- [2026-07-10] 찾은 판매 페이지 링크(근거 검증용) -- 크롤링 소요 시간(ms)
by_mall JSONB NULL, -- [2026-07-28] 몰별 최저가 스냅샷(lps_db.price_history.by_mall 미러링, 가격 오름차순)
crawl_end_time TIMESTAMPTZ NOT NULL, -- 크롤링 종료 시각
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
@ -683,6 +684,10 @@ ALTER TABLE partner.suppliers ADD COLUMN IF NOT EXISTS custom JSONB NULL;
-- [2026-07-22] 협상완료 부가정보(표준납기/MOQ/발주배수/배송유형) — 회사 정의(session_fields) 값 저장(#5)
ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS custom JSONB NULL;
-- [2026-07-28] 몰별 최저가 스냅샷 — 네이버/쿠팡 중 어느 쪽이 건졌고 어느 쪽이 빈손인지 UI 에 표시하기 위해
-- lps_db.price_history.by_mall 을 그대로 미러링한다(열린 스키마 — 폴백몰이 늘어도 스키마 변경 불필요).
ALTER TABLE partner.item_internet_lowest_prices ADD COLUMN IF NOT EXISTS by_mall JSONB NULL;
-- ============================================================
-- LPS (인터넷 최저가 검색) — 별도 database lps_db