[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:
parent
ba9d34fd31
commit
0cefe315c4
@ -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, 워터마크 기준)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user