From 0cefe315c4cf598065f96ca67cc222cceb979432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 11:55:38 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata/backend:=20=EB=AA=B0=EB=B3=84?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=80=EA=B0=80(by=5Fmall)=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20+=20=EA=B0=95=EC=A0=9C=20=EC=9E=AC=EA=B2=80=EC=83=89(force)?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 몰별 성공/실패 표시 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) --- negodata/backend/common/database/model/models.py | 4 ++++ negodata/backend/crud/lps_sync_crud.py | 2 ++ negodata/backend/router/v1/item/item.py | 3 ++- negodata/backend/router/v1/item/protocol.py | 3 +++ negodata/backend/services/lps_sync_service.py | 12 ++++++++---- postgres-init/init-data/init.sql | 5 +++++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index b4fa86f..83344ea 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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, 워터마크 기준) diff --git a/negodata/backend/crud/lps_sync_crud.py b/negodata/backend/crud/lps_sync_crud.py index 4d0ffeb..94126e6 100644 --- a/negodata/backend/crud/lps_sync_crud.py +++ b/negodata/backend/crud/lps_sync_crud.py @@ -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: diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index a0abc14..e011635 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -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) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index e00d98c..1ef773d 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -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 diff --git a/negodata/backend/services/lps_sync_service.py b/negodata/backend/services/lps_sync_service.py index 2325ce1..9553cd7 100644 --- a/negodata/backend/services/lps_sync_service.py +++ b/negodata/backend/services/lps_sync_service.py @@ -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 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 7074e97..e2c7d14 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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