From ca1291708f2cdb3a5148bf21b4c1efd035196429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 09:16:19 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[fix]=20lps-worker:=20compose=20=EC=97=90?= =?UTF-8?q?=20platform=3Dlinux/amd64=20=EB=AA=85=EC=8B=9C=20(arm64=20?= =?UTF-8?q?=EB=A7=A5=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=ED=95=B4=EC=86=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google-chrome-stable(Linux)은 amd64 전용이라 워커 이미지는 본질적으로 amd64다. arm64 맥에서 platform 없이 빌드하면 arm64 로 시도하다 Chrome 의존성 (libasound2·libxfixes3 등)에서 exit 100 으로 실패하고, 그 결과 예전에 빌드된 구 chromium 이미지로 뜨면서 LPS_CHROME_EXECUTABLE(/opt/google/chrome/chrome) 경로가 없어 쿠팡 웜업이 매번 실패했다. platform 명시 후 Rosetta 에뮬로 실제 Chrome 150 이 뜨고 쿠팡 Akamai 챌린지를 시도 1회에 통과함을 확인. prod(amd64 서버)에서는 no-op. Co-Authored-By: Claude Opus 5 (1M context) --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a7dde9d..7b37086 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,6 +160,9 @@ services: build: context: ./lps dockerfile: Dockerfile.worker + # google-chrome-stable(Linux)은 amd64 전용 → 이미지 자체가 amd64. arm64 맥에선 명시 없으면 + # arm64 로 빌드를 시도하다 Chrome 의존성에서 실패한다(Rosetta 로 에뮬 실행). prod(amd64)에선 무영향. + platform: linux/amd64 container_name: lps-worker environment: APP_ENV: local From ba9d34fd316d02a6cf74c6c586e01307ee96bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 11:55:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[fix]=20lps:=20=EB=84=A4=EA=B1=B0=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20+=20force=20=EC=9E=AC=EA=B2=80=EC=83=89=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lps/crud/negative_cache.py | 15 +++++++++++++++ lps/router/v1/lps/protocol.py | 1 + lps/worker/handlers.py | 12 ++++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lps/crud/negative_cache.py b/lps/crud/negative_cache.py index f657045..ce323e9 100644 --- a/lps/crud/negative_cache.py +++ b/lps/crud/negative_cache.py @@ -26,6 +26,21 @@ class NegativeCache: finally: await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value) + async def drop(self, key: str): + """key 의 not_found 기록을 지운다 — 사용자가 강제 재검색(force)을 요청했을 때. + 캐시 키가 product_code 라 상품명·모델을 고쳐 다시 찾는 경우에도 이걸로 풀어줘야 한다.""" + if not key: + return + s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) + try: + await s.execute(text("DELETE FROM search_negative WHERE key = :k"), {"k": key}) + await s.commit() + except Exception: + await s.rollback() + raise + finally: + await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value) + async def put(self, key: str, ttl_sec: int = 86400, reason: str = "not_found"): """key 를 ttl_sec 동안 not_found 로 기록(upsert).""" if not key: diff --git a/lps/router/v1/lps/protocol.py b/lps/router/v1/lps/protocol.py index 4097f2d..8deae0c 100644 --- a/lps/router/v1/lps/protocol.py +++ b/lps/router/v1/lps/protocol.py @@ -17,6 +17,7 @@ class SearchItem(BaseModel): 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): diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 21be864..523fd27 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -192,8 +192,16 @@ def build_search_handler( cache_key = payload.get("product_code") or base_query metrics = SearchMetrics(ai_model, proxy_cost_per_gb) # 검색 1건의 리소스/비용/시간 계측 - # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략 - if neg_cache is not None and await neg_cache.is_negative(cache_key): + # 0) 네거티브 캐시 — 최근 not_found면 재검색 생략. + # force=True(사용자가 '다시 검색'을 명시적으로 누름)면 기록을 지우고 실제 검색을 돌린다. + # 캐시 키가 product_code 라, 상품명·모델을 고쳐 재시도하는 경우 이 우회가 없으면 영원히 막힌다. + if neg_cache is not None and payload.get("force"): + await neg_cache.drop(cache_key) + if neg_cache is not None and not payload.get("force") and await neg_cache.is_negative(cache_key): + # 캐시 히트도 '이 잡의 결과'이므로 이력을 남긴다. 남기지 않으면 잡은 완료인데 + # price_history 에 새 행이 없어, 이를 폴링하는 소비자(negodata 최저가 모달)가 + # 결과를 영영 못 받고 로딩만 돈다(실측 버그). + await _record_history(cache_key, job.get("job_id"), "not_found", []) return {"outcome": "not_found", "cached": True, "query": base_query, "rounds_tried": 0, "lowest": None, "top": [], "stages": [], "sources": {}, "metrics": metrics.snapshot()} 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 3/6] =?UTF-8?q?[feat]=20negodata/backend:=20=EB=AA=B0?= =?UTF-8?q?=EB=B3=84=20=EC=B5=9C=EC=A0=80=EA=B0=80(by=5Fmall)=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20+=20=EA=B0=95=EC=A0=9C=20=EC=9E=AC=EA=B2=80?= =?UTF-8?q?=EC=83=89(force)=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 From 5e24741395cce8a631ef93d0c7ad9a05b9962df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 11:56:00 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[refactor]=20negodata/front:=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=80=EA=B0=80=20=ED=83=90=EC=83=89=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?B2B=20=EC=9E=AC=EC=84=A4=EA=B3=84=20+=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 판정 버그(로딩이 안 멈추던 원인 중 하나) 폴링이 crawl_end_time 을 문자열로 비교했다. 서버는 마이크로초 6자리(.755705Z), JS toISOString() 은 밀리초 3자리(.755Z)라 자릿수가 달라 사전순이 시간순과 어긋난다 ('.' < 'Z'). 게다가 클라이언트 시각을 서버 타임스탬프의 기준점으로 써서 브라우저 시계가 조금만 앞서도 어떤 결과도 기준을 넘지 못했다. → 요청 직전 서버가 준 최신 수집 시각을 상품별 기준선으로 읽고 epoch ms 로 비교. UI 재설계 (소비자 앱풍 → B2B 실무 툴) - 상태 3분리: confirm | searching | done. 검색 중에 완료 결론을 섞지 않는다 - 표가 주인공: [선택 | 상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과], 컴팩트 행, tabular-nums - 결과 열은 상태어(탐색 성공 / 변동 없음 / 탐색 실패 / 탐색 중) - 진행 카드: 표시등 + mm:ss 경과 + 진행바 + N/M, 셀 단위 스켈레톤으로 진행 위치 표시 - radius 8/6/4, shadow 최소화, CTA 우측 정렬(풀와이드 금지), 강조는 indigo-800 한 톤 - 완료 후 자동으로 닫지 않는다(결과 확인). 선택 해제는 닫을 때로 미룸 선택 재검색 체크박스로 대상을 고르고, 검색이 끝나면 갱신되지 않은 행만 자동 선택된다 (이미 갱신된 상품에 크롤 비용을 다시 쓰지 않도록). 재검색은 force=true 로 나가 네거티브 캐시를 우회한다. 접근성 (스킬 기준 + 대비 실측) - 보조 텍스트 muted-foreground 4.88:1, indigo-800 9.93:1 (기존 1.92:1 FAIL 해소) - 상태는 색 단독이 아니라 텍스트/굵기/sr-only 병행 - role=status aria-live, 탈출구 3중(X·ESC·오버레이), 버튼 높이 통일 api/generated 는 orval 재생성분. resTargetBreakdown 변경은 이전 백엔드 수정과의 스펙 동기화(이번 작업과 무관하지만 재생성으로 함께 정리됨). Co-Authored-By: Claude Opus 5 (1M context) --- negodata/front/src/api/generated/item/item.ts | 21 +- .../front/src/api/generated/model/index.ts | 4 +- .../api/generated/model/lowestPriceEntry.ts | 2 + .../generated/model/lowestPriceEntryByMall.ts | 9 + ....ts => lowestPriceEntryByMallAnyOfItem.ts} | 2 +- .../api/generated/model/resTargetBreakdown.ts | 2 - .../model/triggerLowestPriceParams.ts | 13 + .../products/components/PriceUpdateModal.tsx | 590 +++++++++++++----- 8 files changed, 459 insertions(+), 184 deletions(-) create mode 100644 negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts rename negodata/front/src/api/generated/model/{resTargetBreakdownTargetPriceMode.ts => lowestPriceEntryByMallAnyOfItem.ts} (62%) create mode 100644 negodata/front/src/api/generated/model/triggerLowestPriceParams.ts diff --git a/negodata/front/src/api/generated/item/item.ts b/negodata/front/src/api/generated/item/item.ts index 4aa5872..f2660ce 100644 --- a/negodata/front/src/api/generated/item/item.ts +++ b/negodata/front/src/api/generated/item/item.ts @@ -37,7 +37,8 @@ import type { ResItemImage, ResItemList, ResLowestPriceResult, - ResLowestPriceTrigger + ResLowestPriceTrigger, + TriggerLowestPriceParams } from '.././model'; import { customFetch } from '../../mutator/custom-fetch'; @@ -649,12 +650,14 @@ export const useDeleteItem = ,signal?: AbortSignal ) => { return customFetch( - {url: `/v1/item/${itemId}/lowest-price`, method: 'POST', signal + {url: `/v1/item/${itemId}/lowest-price`, method: 'POST', + params, signal }, options); } @@ -662,8 +665,8 @@ export const triggerLowestPrice = ( export const getTriggerLowestPriceMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{itemId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{itemId: string}, TContext> => { + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext> => { const mutationKey = ['triggerLowestPrice']; const {mutation: mutationOptions, request: requestOptions} = options ? @@ -675,10 +678,10 @@ const {mutation: mutationOptions, request: requestOptions} = options ? - const mutationFn: MutationFunction>, {itemId: string}> = (props) => { - const {itemId} = props ?? {}; + const mutationFn: MutationFunction>, {itemId: string;params?: TriggerLowestPriceParams}> = (props) => { + const {itemId,params} = props ?? {}; - return triggerLowestPrice(itemId,requestOptions) + return triggerLowestPrice(itemId,params,requestOptions) } @@ -694,11 +697,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ? * @summary 최저가 수집 요청 */ export const useTriggerLowestPrice = (options?: { mutation?:UseMutationOptions>, TError,{itemId: string}, TContext>, request?: SecondParameter} + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{itemId: string;params?: TriggerLowestPriceParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, - {itemId: string}, + {itemId: string;params?: TriggerLowestPriceParams}, TContext > => { diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index f5f166c..6573f67 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -84,6 +84,8 @@ export * from './listRequestsParams'; export * from './listSuppliersParams'; export * from './listUsersParams'; export * from './lowestPriceEntry'; +export * from './lowestPriceEntryByMall'; +export * from './lowestPriceEntryByMallAnyOfItem'; export * from './lowestPriceEntryCrawlEndTime'; export * from './lowestPriceEntryFailReason'; export * from './lowestPriceEntryLpName'; @@ -392,7 +394,6 @@ export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; -export * from './resTargetBreakdownTargetPriceMode'; export * from './resWebPacketProtocol'; export * from './resWebPacketProtocolMsg'; export * from './sessionData'; @@ -437,6 +438,7 @@ export * from './supplierItemDataItemCode'; export * from './supplierItemDataItemManufacturer'; export * from './supplierItemDataUpdatedAt'; export * from './targetCandidate'; +export * from './triggerLowestPriceParams'; export * from './userRole'; export * from './userStatus'; export * from './validationError'; diff --git a/negodata/front/src/api/generated/model/lowestPriceEntry.ts b/negodata/front/src/api/generated/model/lowestPriceEntry.ts index 54d102b..105c96f 100644 --- a/negodata/front/src/api/generated/model/lowestPriceEntry.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntry.ts @@ -8,6 +8,7 @@ import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice'; import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason'; import type { LowestPriceEntryLpName } from './lowestPriceEntryLpName'; import type { LowestPriceEntryLpUrl } from './lowestPriceEntryLpUrl'; +import type { LowestPriceEntryByMall } from './lowestPriceEntryByMall'; import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime'; /** @@ -20,5 +21,6 @@ export interface LowestPriceEntry { fail_reason?: LowestPriceEntryFailReason; lp_name?: LowestPriceEntryLpName; lp_url?: LowestPriceEntryLpUrl; + by_mall?: LowestPriceEntryByMall; crawl_end_time?: LowestPriceEntryCrawlEndTime; } diff --git a/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts b/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts new file mode 100644 index 0000000..a301c21 --- /dev/null +++ b/negodata/front/src/api/generated/model/lowestPriceEntryByMall.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { LowestPriceEntryByMallAnyOfItem } from './lowestPriceEntryByMallAnyOfItem'; + +export type LowestPriceEntryByMall = LowestPriceEntryByMallAnyOfItem[] | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts b/negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts similarity index 62% rename from negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts rename to negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts index baece13..819117e 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdownTargetPriceMode.ts +++ b/negodata/front/src/api/generated/model/lowestPriceEntryByMallAnyOfItem.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResTargetBreakdownTargetPriceMode = string | null; +export type LowestPriceEntryByMallAnyOfItem = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts index 5a7eb00..3a3d7af 100644 --- a/negodata/front/src/api/generated/model/resTargetBreakdown.ts +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -11,7 +11,6 @@ import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInter import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; import type { TargetCandidate } from './targetCandidate'; -import type { ResTargetBreakdownTargetPriceMode } from './resTargetBreakdownTargetPriceMode'; import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice'; @@ -28,7 +27,6 @@ export interface ResTargetBreakdown { margin?: number; anchoring_value?: number; candidates?: TargetCandidate[]; - target_price_mode?: ResTargetBreakdownTargetPriceMode; hidden_price_fields?: string[]; chosen_basis?: ResTargetBreakdownChosenBasis; target_price?: number; diff --git a/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts b/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts new file mode 100644 index 0000000..7be74e3 --- /dev/null +++ b/negodata/front/src/api/generated/model/triggerLowestPriceParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type TriggerLowestPriceParams = { +/** + * LPS 네거티브 캐시(24h not_found)를 무시하고 재검색. 사용자가 '다시 검색'을 누른 경우만 true + */ +force?: boolean; +}; diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 3240c42..4939418 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -1,251 +1,499 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react'; +import { Clock, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { showToast } from '@/lib/notify'; -import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; +import { Typography } from '@/components/ui/typography'; import { useScrollLock } from '@/lib/useScrollLock'; import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item'; +import type { LowestPriceEntryByMall } from '@/api/generated/model/lowestPriceEntryByMall'; import type { Product } from '../types'; type PriceUpdateModalProps = { open: boolean; products: Product[]; selectedIds: string[]; - onDone: () => void; // 완료 시 선택 해제 + onDone: () => void; // 선택 해제 — 결과를 계속 보여줘야 해서 창을 닫을 때 호출한다 onClose: () => void; }; const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함) const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분) -// 인터넷 최저가 실시간 수집 모달 — LPS 연동. +// 강조색 — 채도 낮춘 인디고 한 톤만 쓴다(절감액·주 버튼). 대비: #3730a3 on white 9.93:1. +// ⚠️ 앱 전역 --primary(#5e6ad2)보다 어두운 계열이라 이 모달에서만 국소 적용한다. +const ACCENT_TEXT = 'text-indigo-800 dark:text-indigo-300'; +const ACCENT_BTN = + 'bg-indigo-800 text-white hover:bg-indigo-900 dark:bg-indigo-300 dark:text-indigo-950 dark:hover:bg-indigo-200'; + +// 표에 열로 세우는 검색 소스. by_mall 에는 매칭된 몰만 담겨 오므로, 여기 없는 소스는 그 회차에 빈손이었다는 뜻. +// (오픈마켓 폴백은 기본 OFF — 켜지면 이 목록과 아래 ROW_GRID 열 개수를 함께 넓힌다.) +const SOURCES = [ + { key: 'naver', label: '네이버' }, + { key: 'coupang', label: '쿠팡' }, +] as const; + +// 몰별 최저가 = by_mall 을 source 로 묶어 최저가만 남긴 것. 값이 없으면 그 몰은 못 찾은 것. +type MallPrices = Record; + +const toMallPrices = (byMall: LowestPriceEntryByMall): MallPrices => { + const out: MallPrices = {}; + for (const entry of byMall ?? []) { + const source = String(entry.source ?? '').toLowerCase(); + const price = Number(entry.price); + if (!source || !Number.isFinite(price)) continue; + if (out[source] === undefined || price < out[source]) out[source] = price; + } + return out; +}; + +// 상품 1건의 처리 상태(화면 전체 상태와 구분해 ItemState 로 둔다). +type ItemState = + | { kind: 'pending' } // 접수됨 — 결과 대기 + | { kind: 'done'; price: number; malls: MallPrices } + | { kind: 'notfound'; malls: MallPrices } // 동일 상품 판정 실패(기존 값 유지) + | { kind: 'failed'; reason?: string } // 요청 자체가 접수되지 않음 — 재시도 대상 + | { kind: 'timeout' } // 폴링 상한 초과 — 서버는 계속 검색 중 + | { kind: 'stopped' }; // 사용자가 지켜보기를 중단 — 서버는 계속 검색 중 + +// 표 정렬 — 헤더와 본문 행이 같은 그리드를 써야 열이 어긋나지 않는다. +// [상품 | 기존 최저가 | 네이버 | 쿠팡 | 결과] +// ⚠️ Tailwind JIT 는 소스에 리터럴로 적힌 클래스만 생성한다 — SOURCES 를 늘리면 +// 여기 열 개수(5rem 반복)도 함께 손으로 맞춰야 한다(템플릿 문자열 금지). +const ROW_GRID = 'grid grid-cols-[1.5rem_minmax(0,1fr)_5.5rem_5rem_5rem_6.5rem] gap-x-3 px-3'; +const NUM = 'text-right tabular-nums'; // 금액 열은 등폭 숫자로 자릿수를 맞춘다 +// 표 체크박스 — DataTable 과 같은 규격을 쓴다(앱 전역 일관성) +const CHECKBOX = 'h-3.5 w-3.5 rounded border-border text-primary focus:ring-primary cursor-pointer accent-primary'; + +// 경과 시간 mm:ss — 등폭 숫자와 맞물려 자릿수가 흔들리지 않는다(1분 넘어가도 폭 유지). +const fmtClock = (sec: number) => + `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`; +const fmtPrice = (v: number | undefined) => (v && v > 0 ? v.toLocaleString() : '–'); + +// 수집 시각을 epoch ms 로. 파싱 불가/누락이면 0(= 어떤 기준선도 넘지 못함). +const crawlMs = (iso: string | null | undefined) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isNaN(t) ? 0 : t; +}; +const latestCrawlMs = (entries: { crawl_end_time?: string | null }[] | undefined) => + (entries ?? []).reduce((max, e) => Math.max(max, crawlMs(e.crawl_end_time)), 0); + +// 인터넷 최저가 검색 모달 — LPS 연동. // 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료). -// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다. +// 폴링이 시간을 초과하거나 사용자가 중단해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다. export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const queryClient = useQueryClient(); - const [isCrawling, setIsCrawling] = useState(false); - const [crawlingProgress, setCrawlingProgress] = useState(0); - const [crawlerLogs, setCrawlerLogs] = useState([]); - const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속) + const [isSearching, setIsSearching] = useState(false); + const [items, setItems] = useState>({}); + const [elapsedSec, setElapsedSec] = useState(0); // 경과 시간 — "멈춘 건지 오래 걸리는 건지" 판단 근거 + // 검색 시작 시점의 대상 스냅샷. 결과를 계속 띄워둬야 하는데 부모의 selectedIds 는 + // 창을 닫을 때 비워지므로, 표는 이 스냅샷을 그린다. + const [targetIds, setTargetIds] = useState([]); + // 재검색 대상 선택. null = 아직 손대지 않음(전체 선택으로 간주). + // 검색이 끝나면 '성공하지 못한 행'만 남겨 두어, 이미 갱신된 상품에 크롤 비용을 다시 쓰지 않게 한다. + const [checkedIds, setCheckedIds] = useState | null>(null); + const cancelledRef = useRef(false); // 중단/닫기 시 폴링 루프 탈출(서버 검색은 계속) + const closeRef = useRef<() => void>(() => {}); // ESC 핸들러가 최신 닫기 로직을 보게 하는 통로 + + useEffect(() => { + if (!isSearching) return; + const timer = setInterval(() => setElapsedSec((s) => s + 1), 1_000); + return () => clearInterval(timer); + }, [isSearching]); + + // ESC 로 닫기 — X 버튼/푸터 닫기와 함께 탈출구 3중 확보 + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); if (!open) return null; - const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]); - const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id; + const rows = targetIds.length > 0 ? targetIds : selectedIds; + // 화면 전체 상태 — 한 화면에는 한 상태만 선언한다(검색 중에 완료 결론을 섞지 않는다). + const status: 'confirm' | 'searching' | 'done' = + targetIds.length === 0 ? 'confirm' : isSearching ? 'searching' : 'done'; - const handleStartCrawling = async () => { - if (selectedIds.length === 0) { - toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.'); - return; - } + const checked = checkedIds ?? new Set(rows); + const allChecked = rows.length > 0 && rows.every((id) => checked.has(id)); + const toggleOne = (id: string, on: boolean) => + setCheckedIds(() => { + const next = new Set(checked); + if (on) next.add(id); + else next.delete(id); + return next; + }); + const toggleAll = (on: boolean) => setCheckedIds(on ? new Set(rows) : new Set()); + const setItem = (id: string, s: ItemState) => setItems((prev) => ({ ...prev, [id]: s })); + const productOf = (id: string) => products.find((p) => p.item_id === id); + const nameOf = (id: string) => productOf(id)?.name || id; + const prevPriceOf = (id: string) => Number(productOf(id)?.internet_lowest_price ?? 0); + const settled = rows.filter((id) => items[id] && items[id].kind !== 'pending').length; + const failedIds = rows.filter((id) => items[id]?.kind === 'failed'); + const failReason = failedIds + .map((id) => (items[id] as { kind: 'failed'; reason?: string }).reason) + .find(Boolean); + + // 한 행의 절감액 — 기존 최저가보다 싸게 찾았을 때만 값이 생긴다. + const savingOf = (id: string) => { + const s = items[id]; + if (s?.kind !== 'done') return 0; + const prev = prevPriceOf(id); + return prev > 0 && s.price < prev ? prev - s.price : 0; + }; + const updatedIds = rows.filter((id) => savingOf(id) > 0); + const totalSaved = updatedIds.reduce((sum, id) => sum + savingOf(id), 0); + const runningIds = rows.filter((id) => ['timeout', 'stopped'].includes(items[id]?.kind ?? '')); + + // 상태 블록·보조 안내는 내용이 있을 때만 그린다 — 빈 요소가 여백만 차지하지 않도록. + const hasStatusBlock = + status === 'searching' || (status === 'done' && (totalSaved > 0 || failedIds.length > 0)); + const note = + status === 'confirm' + ? '상품당 수십 초가 소요됩니다. 미발견 시 기존 최저가가 유지됩니다.' + : status === 'searching' + ? '창을 닫아도 검색은 계속됩니다.' + : failReason + ? `탐색 실패 사유: ${failReason}` + : runningIds.length > 0 + ? `${runningIds.length}개는 서버에서 검색 중입니다. 완료되면 자동 반영됩니다.` + : ''; + + const handleClose = () => { + cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영) + if (targetIds.length > 0) onDone(); // 한 번이라도 돌렸으면 선택 해제 + onClose(); + }; + closeRef.current = handleClose; + + // 지켜보기 중단 — 서버 검색까지 취소하는 API 는 없으므로, 화면 감시만 멈추고 그 사실을 문구로 밝힌다. + const handleStop = () => { + cancelledRef.current = true; + setIsSearching(false); + setItems((prev) => { + const next = { ...prev }; + rows.forEach((id) => { + if (next[id]?.kind === 'pending') next[id] = { kind: 'stopped' }; + }); + return next; + }); + }; + + // 주어진 상품들만 검색한다. 재시도에서도 그대로 쓰므로 나머지 행의 결과는 건드리지 않는다. + // force=true 면 LPS 네거티브 캐시(24h not_found)를 무시하고 실제로 다시 크롤한다. + const runSearch = async (ids: string[], force = false) => { cancelledRef.current = false; - setIsCrawling(true); - setCrawlingProgress(5); - setCrawlerLogs([ - '[System] 인터넷 최저가 검색(LPS) 요청 접수 중...', - `[Target] 선택된 ${selectedIds.length}개 상품`, - ]); - const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정 + setIsSearching(true); + setElapsedSec(0); + setItems((prev) => { + const next = { ...prev }; + ids.forEach((id) => delete next[id]); + return next; + }); + // "이번 요청 결과"의 판정 기준선 — 상품별로 '요청 직전의 최신 수집 시각'을 서버 값에서 읽어 둔다. + // 클라이언트 시각(new Date())을 기준으로 쓰면 브라우저 시계가 서버보다 조금만 앞서도 + // 어떤 결과도 기준을 넘지 못해 영영 대기한다. 서버 값끼리 비교해 시계 의존을 없앤다. + const baselineOf = new Map(); - // 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행 + // 1) 상품별 검색 요청(POST) — 실패/중복은 상태로 구분하고 계속 진행 const pending = new Set(); - for (const id of selectedIds) { + for (const id of ids) { try { - const r = await triggerLowestPrice(id); - if (r.status === 'queued') { + const before = await getLowestPrice(id); + baselineOf.set(id, latestCrawlMs(before.results)); + } catch { + baselineOf.set(id, Date.now()); // 기준선을 못 읽으면 과거 이력을 결과로 오인하지 않도록 보수적으로 + } + try { + const r = await triggerLowestPrice(id, force ? { force: true } : undefined); + // 'duplicated' = 이미 진행 중 → 결과는 폴링으로 같이 받는다 + if (r.status === 'queued' || r.status === 'duplicated') { pending.add(id); - pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`); - } else if (r.status === 'duplicated') { - pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다 - pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`); + setItem(id, { kind: 'pending' }); } else { - pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`); + // 서버가 준 사유를 버리지 않는다 — 실패 원인을 화면에서 읽을 수 있어야 한다 + setItem(id, { kind: 'failed', reason: r.message || '검색 서비스에 연결하지 못했습니다' }); } } catch { - pushLog(`[오류] '${nameOf(id)}' 요청 실패`); + setItem(id, { kind: 'failed', reason: '요청을 보내지 못했습니다(네트워크 오류)' }); } } if (pending.size === 0) { showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error'); - setIsCrawling(false); - setCrawlingProgress(0); + setIsSearching(false); return; } - setCrawlingProgress(15); - pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`); // 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리. - const total = pending.size; + const succeeded = new Set(); // 실제로 값이 내려간 행 — 끝나면 선택에서 빼 준다 let found = 0; - let notFound = 0; const deadline = Date.now() + POLL_TIMEOUT_MS; while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); for (const id of [...pending]) { try { const r = await getLowestPrice(id); - const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt); + // 문자열 비교 금지 — 서버는 마이크로초(.755705Z), JS 는 밀리초(.755Z)라 자릿수가 달라 + // 사전순 비교가 시간순과 어긋난다('.' < 'Z'). 반드시 epoch ms 로 환산해 비교한다. + const baseline = baselineOf.get(id) ?? 0; + const fresh = (r.results ?? []).find((e) => crawlMs(e.crawl_end_time) > baseline); if (!fresh) continue; pending.delete(id); + const malls = toMallPrices(fresh.by_mall ?? null); if (fresh.success_yn && fresh.lp_price != null) { found += 1; - pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`); + const prev = prevPriceOf(id); + if (prev <= 0 || fresh.lp_price < prev) succeeded.add(id); // 값이 실제로 갱신된 행 + // by_mall 이 비어 오는 옛 이력 대비 — 최소한 대표 최저가는 보이도록 폴백을 채운다. + if (Object.keys(malls).length === 0) malls.etc = fresh.lp_price; + setItem(id, { kind: 'done', price: fresh.lp_price, malls }); } else { - notFound += 1; - pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`); + setItem(id, { kind: 'notfound', malls }); } } catch { /* 일시 오류는 다음 tick 재시도 */ } - setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85)); } } - // 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료 - const timedOut = pending.size > 0 && !cancelledRef.current; - if (timedOut) { - pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`); - } + // 3) 마무리 — 목록만 갱신하고 창은 그대로 둔다(결과를 바로 확인할 수 있어야 하므로 자동으로 닫지 않는다). + if (pending.size > 0 && !cancelledRef.current) pending.forEach((id) => setItem(id, { kind: 'timeout' })); await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] }); - setCrawlingProgress(100); + // 다음 '다시 검색'의 기본 대상 = 이번에 갱신되지 않은 행. 이미 갱신된 상품엔 크롤 비용을 다시 쓰지 않는다. + setCheckedIds(new Set(ids.filter((id) => !succeeded.has(id)))); if (!cancelledRef.current) { - showToast( - `최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`, - found > 0 ? 'success' : 'info', + setIsSearching(false); + if (found === 0) showToast('더 낮은 가격을 찾지 못했습니다.', 'info'); + } + }; + + const handleStart = () => { + if (checked.size === 0) { + toast.error('검색할 상품을 1개 이상 선택해 주십시오.'); + return; + } + setTargetIds([...rows]); // 표는 대상 전체를 계속 보여주고, 검색은 체크된 행만 돈다 + setItems({}); + void runSearch([...checked]); + }; + + // 몰 셀 — 검색 중에는 셀 단위 스피너로 진행 위치를 보여주고, 끝나면 금액을 찍는다. + const renderMallCell = (id: string, source: string) => { + const s = items[id]; + if (!s) return –; + if (s.kind === 'pending') { + // 작은 회전 아이콘은 표 안에서 어수선하게 읽힌다. 값이 들어올 자리를 잡아 두는 + // 스켈레톤 막대로 대신한다(레이아웃 이동도 없음). + return ( + + + 검색 중 + ); } - onDone(); - setIsCrawling(false); - onClose(); - setCrawlingProgress(0); - setCrawlerLogs([]); + const malls = s.kind === 'done' || s.kind === 'notfound' ? s.malls : {}; + const prices = Object.values(malls); + const best = prices.length > 0 ? Math.min(...prices) : null; + const price = malls[source]; + if (price === undefined) return –; + const isBest = price === best; + return ( + + {price.toLocaleString()} + {isBest && (최저가)} + + ); + }; + + // 결과 열 — 완료 상태에서만 각 행의 '처리 결과 상태'를 적는다(검색 중에는 비워 둔다). + // 금액이 아니라 상태어를 쓴다: 가격 자체는 왼쪽 몰 열에 이미 있고, 이 열은 "그래서 어떻게 됐나"를 답한다. + const renderResultCell = (id: string) => { + const s = items[id]; + if (status !== 'done' || !s) return –; + if (savingOf(id) > 0) return 탐색 성공; + switch (s.kind) { + case 'done': + case 'notfound': + // 검색은 정상 수행됐으나 기존보다 낮은 가격이 없었음(미발견 포함) — 값이 안 바뀐 상태. + return 변동 없음; + case 'failed': + return 탐색 실패; + case 'timeout': + case 'stopped': + return 탐색 중; + default: + return –; + } }; return ( -
-
+
{ + if (e.target === e.currentTarget) handleClose(); // 오버레이 클릭으로도 닫기 + }} + > +
- {/* Modal Title */} -
-
- - 인터넷 최저가 실시간 수집 및 동기화 -
- {!isCrawling && ( - - )} -
- - {/* Modal Body */} -
-
- - - - 총 {selectedIds.length}개 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다. - - -
- - {/* Targeted Items List */} -
- 수집 대상 상품 ({selectedIds.length}) -
- {selectedIds.map((id) => { - const prod = products.find((p) => p.item_id === id); - return prod ? ( -
- {prod.name} -
- {(prod.price ?? 0).toLocaleString()}원 - → - - {prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'} - -
-
- ) : null; - })} -
-
- - {/* Crawling Progress View */} - {isCrawling ? ( -
-
- - - 최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다) - - {crawlingProgress}% -
- - {/* Progress Bar Container */} -
-
-
- - {/* Terminal Log Output */} -
- {crawlerLogs.map((log, idx) => ( -
- > - - {log} - -
- ))} -
-
- ) : ( -
- - - "인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다. - -
- )} -
- - {/* Modal Footer */} -
+ {/* 다이얼로그 헤더 — X 는 푸터 버튼과 같은 높이(size-7)로 맞춘다 */} +
+ 인터넷 최저가 검색 +
+ +
+ {/* 결과 표 — 데이터가 주인공이라 맨 위에 둔다 */} +
+
+
+ toggleAll(e.target.checked)} + aria-label="전체 선택" + /> + 상품 + 기존 최저가 + {SOURCES.map(({ key, label }) => ( + {label} + ))} + 결과 +
+ +
+ {rows.length === 0 && ( +
+ 선택된 상품이 없습니다. 목록에서 상품을 선택해 주십시오. +
+ )} + {rows.map((id) => ( + + ))} +
+
+
+ + {/* 상태 요약 — 한 화면에 한 상태만. 표(데이터)가 먼저 오고 진행/결과는 그 아래. + confirm 단계는 별도 문구 없이 표와 하단 안내만으로 충분하다. */} +
+ {status === 'searching' && ( +
+
+ + {/* 동작 중임을 알리는 표시등 — 의미는 옆 문구가 진다(색 단독 의존 방지) */} + + 더 낮은 가격 검색 중 + + + + 경과 시간 + {fmtClock(elapsedSec)} + +
+
+ + + + + {settled} / {rows.length} + +
+
+ )} + + {/* 완료 — 행별 결과는 표의 '결과' 열이 말해주므로, 여기엔 절감액만 남긴다. + 절감이 없으면 시각적으로는 비우고 완료 사실만 스크린리더에 알린다. */} + {status === 'done' && ( +
+ {totalSaved > 0 ? ( + + 총 {totalSaved.toLocaleString()}원 절감 + + ) : ( + 검색이 완료되었습니다. 갱신된 가격은 없습니다. + )} + {failedIds.length > 0 && ( + + 탐색 실패 {failedIds.length}개 + + )} +
+ )} +
+ + {/* 보조 안내 — 할 말이 없으면 아예 그리지 않는다(빈 줄이 여백만 잡는 걸 막는다) */} + {note &&

{note}

} +
+ + {/* 액션 — 풀와이드 금지. 보조는 좌측 텍스트, 주 액션은 우측 */} +
+ - + {status === 'searching' ? ( + + ) : ( + + )}
From 89652fac1f1deb16e44556ac912ae082c4000697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 16:06:27 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[refactor]=20negodata/front:=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EB=84=B7=20=EC=B5=9C=EC=A0=80=EA=B0=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=8B=9C=ED=8A=B8=20=EC=9E=AC=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=A4=EA=B5=AC=EB=A7=A4=EA=B0=80=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=20+=20=EA=B7=BC=EA=B1=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 협상 근거로 쓰는 화면이라 "얼마에 살 수 있고, 그게 어디서 나온 값인가"를 한 번에 검증할 수 있게 정보 구조를 다시 짰다. 구조: 결론 → 판단 → 근거 - 히어로: 합계(상품가+배송비) 32px + 단가 대비 판정을 한 블록에. 면은 브랜드보다 채도를 낮춘 --accent 표면, 값만 브랜드 컬러(화면에 단 하나). - 판매처 비교표: 상품가/배송비/합계를 몰별로. 상품가 최저와 실구매가 최저가 다를 수 있어 둘 다 표시. 결과 없는 몰도 '미발견' 행으로 남겨 빈손임을 드러낸다. - 검색어(찾은 상품명) + 복사 버튼 → 판매처에서 다시 찾아볼 때 붙여넣기용. - 가격 추이: 상품가 단일 기준. 합계 기준은 배송비를 아는 회차에만 값이 생겨 (네이버는 배송비 미제공 — 실측 39/39) 기준을 바꾸면 값의 의미와 표본 수가 동시에 변한다. 기준선(상품 단가) 추가 — 이 선 위면 사는 게 손해. - 수집 이력: 미발견도 노출(전체의 30%가 not_found 인데 그동안 숨겨져 있었다). 데이터 정직성 - 배송비 미상(전체 69%)을 0원으로 뭉개지 않는다 — 무료배송으로 읽히면 안 된다. 모르면 히어로 라벨이 '합계' → '상품가'로 바뀌고 그 사실을 문구로 밝힌다. - 화면 주인공(합계)과 목표가 산정에 쓰이는 값(상품가)이 다르므로 각주로 명시. - 금액 옆 배송 주석은 '+3,500'(연산으로 오해) → '배송비 포함(3,500원)'. 접근성·일관성 - 대비 전수 계산: --accent 면 위에서 muted-fg(4.16)·amber-700(4.28)이 AA 미달이라 accent-foreground(9.85)·800 계열(6.0~6.5)로 교체. 히어로는 큰 글씨 기준 통과. - rose(파괴적 뉘앙스) 제거 → 브랜드 인디고 한 톤. 장식 아이콘 전부 제거. - 타이포 7단계를 상수 T 로 고정(임의 크기 금지). - 차트 점 3종 모양을 DOT 상수로 추출해 차트와 범례가 공유 — 범례는 차트 밖이라 --color-price 가 해석되지 않아 색이 어긋났다. 전역 토큰으로 통일. - Y축 눈금 1·2·5×10ⁿ, 기준 변경 시 애니메이션 끔, 커스텀 X축 tick 제거 (recharts 의 가장자리 앵커 보정을 건너뛰어 점과 라벨이 어긋났다). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/LowestPriceHistorySheet.tsx | 645 +++++++++++++----- 1 file changed, 482 insertions(+), 163 deletions(-) diff --git a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx index 7d21e10..8d1b671 100644 --- a/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx +++ b/negodata/front/src/features/products/components/LowestPriceHistorySheet.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from 'react'; -import { ChartSpline, ExternalLink, Loader2, TrendingDown, TrendingUp } from 'lucide-react'; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Sheet } from '@/components/ui/sheet'; -import { Typography } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { showToast } from '@/lib/notify'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { useGetLowestPrice } from '@/api/generated/item/item'; import type { LowestPriceEntry } from '@/api/generated/model'; import type { Product } from '../types'; @@ -16,201 +17,436 @@ type LowestPriceHistorySheetProps = { }; // LowestPriceWebsite 코드(백엔드 common/enums.py) → 표시 라벨 -const WEBSITE_LABEL: Record = { - 1: '네이버', - 2: '쿠팡', - 3: 'G마켓', - 4: '옥션', - 5: '11번가', - 99: '기타', -}; +const WEBSITE_LABEL: Record = { 1: '네이버', 2: '쿠팡', 3: 'G마켓', 4: '옥션', 5: '11번가', 99: '기타' }; const websiteLabel = (code?: number) => WEBSITE_LABEL[code ?? 99] ?? '기타'; -// 단일 시리즈(인터넷 최저가) — 가격 UI 컨벤션인 rose 를 라이트/다크 쌍으로(통계 팔레트와 동일 문법). +// by_mall 의 source 키 → 라벨. 기본 두 몰은 결과가 없어도 행을 만들어 "빈손"임을 드러낸다. +const BASE_SOURCES = ['naver', 'coupang'] as const; +const SOURCE_LABEL: Record = { + naver: '네이버', coupang: '쿠팡', gmarket: 'G마켓', auction: '옥션', st11: '11번가', +}; + +// 액센트는 브랜드 인디고(--primary) 하나로 통일한다. rose 는 파괴적 액션 뉘앙스라 쓰지 않는다. +// 대비: #5e6ad2 on white 4.7:1 / #8b93e8 on dark card 6.06:1 (둘 다 AA). const chartConfig = { - price: { label: '인터넷 최저가', theme: { light: '#f43f5e', dark: '#fb7185' } }, + // 브랜드 토큰을 그대로 넘긴다 — 범례는 차트 밖(=--color-price 스코프 밖)이라 + // 하드코딩 색을 쓰면 둘이 어긋난다. 같은 var 를 보게 해서 항상 일치시킨다. + price: { label: '최저가', color: 'var(--primary)' }, + // 단가 기준선 — 흰 배경 5.03:1(AA). 다크는 amber-400. + contract: { label: '단가 기준선', theme: { light: '#bb4d00', dark: '#fbbf24' } }, } satisfies ChartConfig; -const won = (n: number) => `₩${Math.round(n).toLocaleString()}`; -const timeLabel = (iso: string) => - new Date(iso).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +// 꺾은선 점 3종의 모양 정의 — 차트(PricePoint)와 범례(DotLegend)가 이 한 곳을 공유한다. +// 색은 전역 토큰(var(--primary)/var(--card))이라 차트 안팎 어디서든 동일하게 그려진다. +const DOT = { + normal: (cx: number, cy: number) => , + peak: (cx: number, cy: number) => ( + + ), + latest: (cx: number, cy: number) => ( + <> + + + + ), +} as const; -/** 견적 상세 드로어와 같은 문법의 섹션 카드 — 11px 볼드 타이틀 + 하단 구분선. */ -function SectionCard({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { +// ── 타이포 스케일 — 이 화면에서 쓰는 크기는 이 7단계뿐이다(임의 크기 금지) ── +const T = { + hero: 'text-[32px] font-bold leading-none tracking-tight', // 합계 (화면당 1개) + major: 'text-[18px]', // 통계값 + title: 'text-[15px] font-medium', // 카드 제목 + body: 'text-[13px]', // 본문 — 라벨·상품명·경고·이력 시각·금액 + meta: 'text-xs', // 보조 — 메타·서브라인 (12px) + fine: 'text-[11px]', // 부가 — 배송비 주석·차트 눈금 + tick: 11, +} as const; + +const num = (n: number) => Math.round(n).toLocaleString(); +/** 이력용 짧은 타임스탬프 — 24시간제라 등폭 숫자와 맞물려 폭이 흔들리지 않는다. */ +const stampLabel = (iso: string) => { + const d = new Date(iso); + return `${d.getMonth() + 1}.${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; +const clockLabel = (iso: string) => { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +}; +/** 금액에 붙는 배송비 주석. 금액은 '이미 배송비가 더해진 합계'이므로 + * `+3,500` 처럼 연산으로 읽히는 표기를 쓰지 않는다(더 더해야 하나? 로 오해된다). */ +const shipNote = (shipping: number | null) => + shipping == null ? '배송비 미상' : shipping === 0 ? '무료배송' : `배송비 포함(${num(shipping)}원)`; + +/** Y축 눈금을 1·2·5 × 10ⁿ 단위로 떨어뜨린다 — 1원 단위 눈금이 나오지 않게. */ +function niceScale(min: number, max: number, target = 4) { + let lo = min; + let hi = max; + if (hi <= lo) { lo = min * 0.95; hi = max * 1.05; } // 값이 하나뿐이거나 전부 같을 때 + const mag = Math.pow(10, Math.floor(Math.log10(Math.max((hi - lo) / target, 1)))); + const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= (hi - lo) / target) ?? 10 * mag; + const from = Math.floor(lo / step) * step; + const to = Math.ceil(hi / step) * step; + const ticks: number[] = []; + for (let v = from; v <= to + step / 2; v += step) ticks.push(Math.round(v)); + return { domain: [from, to] as [number, number], ticks }; +} + +// 몰 1건의 가격 구성. shipping=null 은 "배송비를 모른다"(0원이 아니다). +// 네이버 쇼핑 API 는 배송비를 주지 않아 항상 null 이다 — 실측 39/39. +type Mall = { source: string; label: string; price: number; shipping: number | null; name?: string; url?: string }; + +const readMalls = (e: LowestPriceEntry): Mall[] => { + const out: Mall[] = []; + for (const raw of e.by_mall ?? []) { + const source = String(raw.source ?? '').toLowerCase(); + const price = Number(raw.price); + if (!source || !Number.isFinite(price)) continue; + // shipping_type 이 있어야 배송비를 신뢰한다. 없으면 미상(null) — 0원으로 뭉개면 무료배송처럼 읽힌다. + const shipping = raw.shipping_type != null ? Number(raw.shipping_fee ?? 0) : null; + out.push({ + source, + label: SOURCE_LABEL[source] ?? String(raw.mall_name ?? source), + price, + shipping: Number.isFinite(shipping as number) ? shipping : null, + name: raw.name ? String(raw.name) : undefined, + url: raw.detail_url ? String(raw.detail_url) : undefined, + }); + } + return out.sort((a, b) => a.price - b.price); +}; + +/** 수집 1건을 화면용으로 정리. total 은 배송비를 아는 경우에만 값이 생긴다. */ +function readEntry(e: LowestPriceEntry) { + const ok = !!e.success_yn && e.lp_price != null; + const malls = readMalls(e); + const itemPrice = ok ? Number(e.lp_price) : null; + const winner = malls[0]; // 최저가를 낸 몰(가격 오름차순 첫 행). 옛 이력엔 by_mall 이 없어 undefined + const shipping = winner?.shipping ?? null; + return { + entry: e, ok, malls, winner, itemPrice, shipping, + total: itemPrice != null && shipping != null ? itemPrice + shipping : null, + }; +} +/** 섹션 카드 — 제목 + 우측 메타 + 보조 설명 한 줄. */ +function SectionCard({ title, meta, sub, children }: { title: ReactNode; meta?: ReactNode; sub?: ReactNode; children: ReactNode }) { return ( - -
- {title} - {action} + +
+ {title} + {meta}
- {children} + {sub &&
{sub}
} +
{children}
); } -// 인터넷 최저가 상세 시트 — 대표값·출처(사이트/상품명/링크)·가격 추이 그래프·수집 이력. -// 협상 근거로 쓰는 값이므로 "어디서 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. +// 인터넷 최저가 상세 — 판매처별 실구매가·가격 추이·수집 이력. +// 협상 근거로 쓰는 값이므로 "어디서 얼마에 찾았는지"를 클릭 한 번으로 검증할 수 있게 한다. export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistorySheetProps) { // GET 이 서버에서 lps_db 증분 동기화를 겸하므로, 열 때마다 최신 상태가 온다. const { data, isLoading } = useGetLowestPrice(product.item_id); + const labels = useLabels(); // 회사가 '상품 단가'를 '계약 단가' 등으로 바꿔 쓸 수 있다 + const priceLabel = labels('item.price'); - const entries = data?.results ?? []; - const successes = entries.filter((e): e is LowestPriceEntry & { lp_price: number } => !!e.success_yn && e.lp_price != null); + const rows = (data?.results ?? []).map(readEntry); + const successes = rows.filter((r) => r.ok); const latest = successes[0]; // API 가 최신순으로 준다 - const representative = data?.lowest_price ?? product.internet_lowest_price ?? latest?.lp_price ?? null; - const diff = product.price != null && representative != null ? Number(representative) - product.price : null; + const missCount = rows.length - successes.length; - // 그래프는 시간 오름차순(성공 수집만). 점 1개는 추이가 아니므로 2건부터 그린다. + // 헤드라인 — 합계가 주인공. 배송비를 모르면 상품가로 라벨을 바꿔 '배송비 포함'을 참칭하지 않는다. + const heroValue = latest ? (latest.total ?? latest.itemPrice) : null; + const heroIsTotal = latest?.total != null; + const diff = product.price != null && heroValue != null ? heroValue - product.price : null; + + // 검색어(= 최저가의 근거가 된 상품명) — 판매처에서 다시 찾아볼 때 그대로 붙여넣을 수 있게 복사를 붙인다. + const queryText = latest?.entry.lp_name || latest?.winner?.name || ''; + const copyQuery = async () => { + try { + await navigator.clipboard.writeText(queryText); + showToast('검색어를 복사했습니다.', 'success'); + } catch { + showToast('복사할 수 없습니다. 텍스트를 직접 선택해 주세요.', 'error'); + } + }; + + // ── 추이는 '상품가' 단일 기준으로만 그린다. + // 합계(=상품가+배송비)는 배송비를 아는 회차에만 값이 생겨(네이버는 항상 미제공) + // 기준을 바꾸면 '값의 의미'와 '표본 수'가 동시에 변한다 — 선 모양의 변화가 + // 가격 변동인지 표본 변화인지 구분이 안 된다. 상품가는 모든 성공 수집에 존재해 + // 추이가 왜곡되지 않고, 기준선(상품 단가)·목표가 산정과도 단위가 같다. + // 실구매가(합계)는 '지금 얼마에 살 수 있나'라서 요약 카드가 맡는다. const chartData = [...successes] .reverse() - .map((e) => ({ t: e.crawl_end_time ?? '', price: e.lp_price })); + .map((r) => ({ t: r.entry.crawl_end_time ?? '', price: r.itemPrice! })); + const values = chartData.map((d) => d.price); + const stats = values.length + ? { min: Math.min(...values), max: Math.max(...values), avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length) } + : null; + const maxAt = stats ? values.lastIndexOf(stats.max) : -1; + const scale = stats + ? niceScale(Math.min(stats.min, product.price ?? stats.min), Math.max(stats.max, product.price ?? stats.max)) + : null; return ( -
- {/* ── 헤드라인: 상품명 · 대표가 · 단가 대비 칩 ── */} -
- {product.name} -
- - {representative != null ? won(Number(representative)) : '수집 전'} - - {diff != null && ( - - {diff <= 0 ? : } - 단가 대비 {diff > 0 ? '+' : diff < 0 ? '-' : ''}{won(Math.abs(diff))} - - )} -
- - 상품 단가 {product.price != null ? won(product.price) : '-'} - {latest?.crawl_end_time && <> · 마지막 수집 {timeLabel(latest.crawl_end_time)}} - -
+
- {/* ── 출처 — 최신 성공 수집의 사이트/상품명/링크 ── */} - {latest && ( - {websiteLabel(latest.website)}} - > - {latest.lp_name ? ( - {latest.lp_name} - ) : ( - 출처 상세 미수집(이전 버전 수집분) - )} - {latest.lp_url && ( - - 판매 페이지에서 확인 - - )} - - )} + {/* ── 요약: 판매처별 내역 → 합계 → 단가 대비 → 출처를 한 섹션으로 ── */} + + {latest ? ( + <> + {/* 결론 먼저 — 카드를 열면 여기에 시선이 먼저 닿아야 한다. + 면은 브랜드보다 채도를 낮춘 인디고 계열 표면 토큰(--accent, #ececf4). + 이 면 위에서는 muted-foreground(4.16:1)·amber-700(4.28:1)이 AA 미달이라 + 라벨은 accent-foreground(9.85:1), 판정은 800 계열(6.0~6.5:1)로 한 단계 진하게 쓴다. */} +
+
+ + {heroIsTotal ? '합계 (배송비 포함)' : '상품가 (배송비 미포함)'} + + {latest.entry.crawl_end_time && ( + + {clockLabel(latest.entry.crawl_end_time)} 수집 + + )} +
+

+ {num(heroValue!)} + 원 +

+ {diff != null && ( +

0 ? 'text-amber-800 dark:text-amber-400' : 'text-emerald-800 dark:text-emerald-300', + )} + > + {priceLabel} {num(product.price!)}원보다{' '} + + {num(Math.abs(diff))}원 {diff > 0 ? '높습니다' : '낮습니다'} + +

+ )} +
+ +
+ +
+ + {/* 검색어 — 이 가격의 근거가 된 상품이 무엇인지 밝힌다 */} +
+

검색어

+
+

+ {queryText || '출처 상세 미수집(이전 버전 수집분)'} +

+ {queryText && ( + + )} +
+
+ + {/* 출처 — 판매 페이지(수집 시각은 히어로 우측으로 올렸다) */} +
+ {(latest.entry.lp_url || latest.winner?.url) && ( + + )} + {/* 화면의 주인공(합계)과 시스템이 쓰는 값(상품가)이 다르므로 숨기지 않고 밝힌다 */} + {heroIsTotal && latest.shipping! > 0 && ( +

+ 목표가 산정에는 배송비를 뺀 상품가 {num(latest.itemPrice!)}원이 사용됩니다. +

+ )} + {!heroIsTotal && ( +

+ 판매처가 배송비를 제공하지 않아 상품가만 표시합니다. +

+ )} +
+ + ) : ( +

+ 아직 수집된 최저가가 없습니다. {priceLabel} {product.price != null ? `${num(product.price)}원` : '-'} +

+ )} +
{/* ── 가격 추이 ── */} 0 ? ( - {successes.length}회 수집 - ) : undefined + meta={상품가 기준} + sub={ + <> + 배송비 제외 · 성공 {successes.length}회 + {missCount > 0 && ` · 미발견 ${missCount}건 제외`} + } > - {chartData.length >= 2 ? ( - - - - - Number(v).toLocaleString()} - fontSize={10} - /> - } /> - - - + {chartData.length >= 2 && scale ? ( + <> + + + + {/* 커스텀 tick 은 recharts 의 눈금 정렬 보정(가장자리 앵커 조정)을 건너뛰어 + 점과 라벨이 어긋난다 — 기본 렌더러에 포맷터만 준다. */} + + num(Number(v))} + fontSize={T.tick} + /> + {/* 계약/상품 단가 기준선 — 최저가가 이 선 위에 있으면 사는 게 손해다 */} + {product.price != null && ( + + )} + } /> + {/* 기준 토글로 데이터가 통째로 바뀌므로 재생 애니메이션은 끈다(점이 튀어 보인다) */} + } + activeDot={{ r: 6 }} + /> + + + + {stats && ( +
+ {([['최저', stats.min], ['평균', stats.avg], ['최고', stats.max]] as const).map(([label, v]) => ( +
+
{label}
+
{num(v)}
+
+ ))} +
+ )} + + {/* 점 모양에 뜻이 있으므로 범례로 밝힌다 */} + + ) : ( -
- - - {successes.length === 1 - ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' - : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} - -
+

+ {chartData.length === 1 + ? '수집이 2회 이상 쌓이면 가격 추이 그래프가 표시됩니다.' + : '성공한 수집이 쌓이면 가격 추이 그래프가 표시됩니다.'} +

)}
- {/* ── 수집 이력 ── */} - + {/* ── 수집 이력 (최근 10건 고정 — 서버가 limit=10 으로 내려준다) ── */} + 0 ? 최근 {rows.length}건 : undefined} + sub={rows.length > 0 ? `성공 ${successes.length}건 · 미발견 ${missCount}건` : undefined} + > {isLoading ? ( -
- - 이력을 불러오는 중... -
- ) : entries.length === 0 ? ( -
- - - 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. - -
+

이력을 불러오는 중...

+ ) : rows.length === 0 ? ( +

+ 수집 이력이 없습니다 — 상품 목록에서 "최저가 업데이트하기"로 수집을 시작하세요. +

) : ( -
    - {entries.map((e, i) => ( -
  • -
    - - {websiteLabel(e.website)} - - - {e.crawl_end_time ? timeLabel(e.crawl_end_time) : '-'} +
      + {rows.map((r, i) => { + // 이력은 언제나 '실제 지불액' 기준 — 위 토글에 영향받지 않는다. + const paid = r.total ?? r.itemPrice; + const url = r.entry.lp_url || r.winner?.url; + return ( + // 열 폭을 고정해 금액이 배송 주석 길이에 밀리지 않게 한다(모든 행의 금액 우측이 한 줄). +
    • + + {r.entry.crawl_end_time ? stampLabel(r.entry.crawl_end_time) : '-'} -
    -
    - {e.success_yn && e.lp_price != null ? ( - {won(e.lp_price)} + {r.ok ? ( + <> + + + {r.winner?.label ?? websiteLabel(r.entry.website)} + + + {/* 금액과 배송비 주석을 세로로 쌓는다 — 가로로 붙이면 주석이 길어질수록 + 금액 시작점이 밀리고, 열을 넓히면 다른 열을 잡아먹는다. */} + + {num(paid!)} + + {shipNote(r.shipping)} + + + {url ? ( + + 링크 이동 + + ) : ( + + )} + ) : ( - 미발견 + // 값이 없는 행은 열을 쪼개 봐야 빈칸만 남는다 — 시각을 뺀 나머지 폭에 사유를 가운데 둔다. + + 조건에 맞는 상품 없음 + )} - {e.lp_url ? ( - - - - ) : ( - /* 링크 없는 행도 가격 우측 정렬 유지 */ - )} -
    -
  • - ))} + + ); + })}
)}
@@ -219,14 +455,97 @@ export function LowestPriceHistorySheet({ product, onClose }: LowestPriceHistory ); } -// 툴팁 — 시각 + 가격(텍스트 토큰, 시리즈색은 마크에만) +/** 점 모양 범례 — 채움/속 빔/크기가 각각 뜻이 있으므로 밝힌다. + * 스와치는 DOT 정의를 그대로 써서 차트와 모양·색이 어긋날 수 없다. */ +function DotLegend({ showPeak }: { showPeak: boolean }) { + const items = [ + { key: 'normal', label: '수집 시점', draw: DOT.normal }, + ...(showPeak ? [{ key: 'peak', label: '기간 내 최고가', draw: DOT.peak }] : []), + { key: 'latest', label: '최근 수집(현재값)', draw: DOT.latest }, + ]; + return ( +
+
    + {items.map(({ key, label, draw }) => ( +
  • + + {draw(9, 9)} + + {label} +
  • + ))} +
+
+ ); +} + +/** 꺾은선 점 — 마지막 수집은 크게(현재값), 최고가는 속 빈 원으로 구분한다. */ +function PricePoint(props: { cx?: number; cy?: number; index?: number; lastIndex: number; maxIndex: number }) { + const { cx, cy, index = 0, lastIndex, maxIndex } = props; + if (cx == null || cy == null) return ; + if (index === lastIndex) return {DOT.latest(cx, cy)}; + if (index === maxIndex) return {DOT.peak(cx, cy)}; + return {DOT.normal(cx, cy)}; +} + +/** 판매처별 내역 — 상품가가 싼 곳과 배송비까지 더해 실제로 싼 곳이 다를 수 있어 둘 다 표시한다. */ +function MallTable({ malls }: { malls: Mall[] }) { + if (malls.length === 0) return null; + const keys = [...malls.map((m) => m.source), ...BASE_SOURCES.filter((s) => !malls.some((m) => m.source === s))]; + const bestItem = Math.min(...malls.map((m) => m.price)); + const totals = malls.filter((m) => m.shipping != null).map((m) => m.price + m.shipping!); + const bestTotal = totals.length > 0 ? Math.min(...totals) : null; + const unknown = malls.some((m) => m.shipping == null); + + return ( +
+
+ 판매처 + 상품가 + 배송비 + 합계 +
+ {keys.map((k) => { + const m = malls.find((x) => x.source === k); + const total = m && m.shipping != null ? m.price + m.shipping : null; + return ( +
+ {SOURCE_LABEL[k] ?? k} + {m ? ( + <> + + {num(m.price)} + + + {m.shipping == null ? '미상' : m.shipping === 0 ? '무료' : num(m.shipping)} + + + {total != null ? num(total) : '—'} + + + ) : ( + 미발견 + )} +
+ ); + })} + {unknown && ( +

+ 배송비를 제공하지 않는 판매처가 있어 합계 비교가 완전하지 않습니다. +

+ )} +
+ ); +} + +// 툴팁 — 시각 + 금액 function PriceTooltip({ active, payload }: { active?: boolean; payload?: { payload: { t: string; price: number } }[] }) { if (!active || !payload?.length) return null; const p = payload[0].payload; return ( -
- {timeLabel(p.t)} - {won(p.price)} +
+

{stampLabel(p.t)}

+

{num(p.price)}원

); } From 8fa77af8660da83f8b933e2cdfd06f0d661567da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 28 Jul 2026 16:39:14 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[chore]=20lps:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AC=B8=EC=84=9C=ED=99=94=20+=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=80=20env=20=EC=A0=9C=EA=B1=B0=20+=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포서버 쿠팡 크롤 조사(2026-07-28)에서 드러난 '설정한 줄 알았는데 아니었던' 것들 정리. 크롤 동작 자체를 바꾸는 변경은 없다. - Dockerfile.worker: ENV LPS_CHROME_EXECUTABLE 제거. 읽는 코드가 없는데 값이 박혀 있어 "컨테이너 Chrome 경로가 설정돼 있다"는 오해를 만들었다. 경로의 유일한 소스는 [WorkerConfig].chrome_executable 이다(설정 시 --no-sandbox 동반). - config.local.toml.example: profile_dir 경고 추가. 컨테이너에서 ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성마다 쿠키가 날아가고, compose 가 마운트한 lps-profiles 볼륨은 붙어만 있고 아무 일도 하지 않는다. 로컬·배포서버 양쪽에서 실측(/profiles 는 7/9·7/16 잔재, /app/.profiles 에 9MB 최신). - config.prod.toml.example 신규: 배포서버 설정 템플릿. 로컬과 다른 값만 ★ 표시. 헤더에 로드 경로의 함정을 명시했다 — docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트하므로 APP_ENV=local 인데도 내용은 prod 설정이다(파일명만 보면 오해한다). - worker_main.py: budget_leak 알림이 "ip_request_budget 하향 검토"를 단정하던 것을 수정. 차단이 ip_req#1 에 몰리면 새 IP 첫 요청부터 막히는 것이라 예산과 무관하다. bot_detection.ip_request_no 분포를 보고 처방을 고르도록 문구를 바꿨다. ⚠️ Dockerfile.worker 가 바뀌었으므로 배포 시 lps-worker 재빌드 필요. Co-Authored-By: Claude Opus 5 (1M context) --- lps/Dockerfile.worker | 5 +- lps/config/config.local.toml.example | 7 ++- lps/config/config.prod.toml.example | 85 ++++++++++++++++++++++++++++ lps/worker_main.py | 14 ++++- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 lps/config/config.prod.toml.example diff --git a/lps/Dockerfile.worker b/lps/Dockerfile.worker index 795796b..be8a597 100644 --- a/lps/Dockerfile.worker +++ b/lps/Dockerfile.worker @@ -28,9 +28,12 @@ COPY . . # 실값은 compose env 로 주입: DB_*, OPENAI_API_KEY, DECODO_*(포트 포함), NAVER_KEYS. RUN cp config/config.local.toml.example config/config.local.toml +# Chrome 경로는 [WorkerConfig].chrome_executable(설정 파일)이 유일한 소스다. +# 예전에 LPS_CHROME_EXECUTABLE env 를 뒀지만 읽는 코드가 없어 '설정한 줄 알았는데 아니었다'는 +# 오진을 만들었다(2026-07-28 배포서버 크롤 조사) — 죽은 env 는 두지 않는다. +# 컨테이너에서는 config 에 "/opt/google/chrome/chrome" 을 넣어야 --no-sandbox 가 함께 붙는다. ENV APP_ENV=local \ PYTHONUNBUFFERED=1 \ - LPS_CHROME_EXECUTABLE=/opt/google/chrome/chrome \ DISPLAY=:99 \ LPS_HEARTBEAT_FILE=/tmp/lps_worker_heartbeat diff --git a/lps/config/config.local.toml.example b/lps/config/config.local.toml.example index f95981d..83b2056 100644 --- a/lps/config/config.local.toml.example +++ b/lps/config/config.local.toml.example @@ -49,7 +49,12 @@ sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"ve [WorkerConfig] concurrency = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3 fallbacks = [] # 오픈마켓 폴백(기본 OFF). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크 -profile_dir = ".profiles" # Chrome 프로필 베이스. 영속 경로면 재시작에도 cf_clearance 유지(재웜업 회피) +# Chrome 프로필 베이스. 영속 경로면 재시작에도 쿠키 유지(재웜업 회피). +# ⚠️ 컨테이너로 띄우면 반드시 "/profiles" — compose 가 lps-profiles 볼륨을 그 경로에 마운트한다. +# ".profiles" 로 두면 /app/.profiles(컨테이너 레이어)에 쌓여 재생성 때마다 쿠키가 전부 날아가고, +# 볼륨은 붙어만 있고 아무 일도 하지 않는다(2026-07-28 로컬·배포서버 양쪽에서 실측). +# 호스트 직접 실행(run_local_worker.sh)일 때만 ".profiles" 를 쓴다. +profile_dir = ".profiles" job_deadline_sec = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용) shutdown_grace_sec = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게 chrome_channel = "chrome" # 로컬: 실제 Chrome diff --git a/lps/config/config.prod.toml.example b/lps/config/config.prod.toml.example new file mode 100644 index 0000000..4d67570 --- /dev/null +++ b/lps/config/config.prod.toml.example @@ -0,0 +1,85 @@ +# 배포서버용. 복사해서 사용: cp config.prod.toml.example config.prod.toml +# 실제 config.prod.toml 은 시크릿 포함이라 커밋하지 않는다. +# +# ⚠️ 로드 경로 주의 — docker-compose.prod.yml 이 이 파일을 config.local.toml 자리에 마운트한다: +# ./lps/config/config.prod.toml : /app/config/config.local.toml : ro +# 컨테이너는 APP_ENV=local 로 뜨므로 코드가 읽는 파일명은 config.local.toml 이지만 +# 내용은 이 파일이다. 파일명만 보고 "prod 설정이 안 읽힌다"고 오해하기 쉽다. +# +# 로컬(config.local.toml.example)과 다른 항목만 ★ 로 표시했다. + +[WebServerConfig] +server_name = "LpsServer" +port = 9600 +process_count = 1 +is_ssl = false # 리버스프록시 뒤 — TLS 는 프록시가 종단 +is_test = false # ★ prod +cors_origins = [] # ★ 브라우저가 직접 호출하지 않으면 비움 +api_keys = [""] # ★ prod 는 반드시 채운다(비면 무인증 개방). openssl rand -hex 32 + +[LogConfig] +print_console = true +log_level = "info" # ★ prod + +[MainDBConfig] +db_type = "postgresql" +name = "lps_db" +write_host = "host.docker.internal" # ★ 컨테이너 → 호스트 DB (compose extra_hosts) +write_port = 5432 +write_id = "" +write_pw = "" +read_host = "host.docker.internal" # ★ +read_port = 5432 +read_id = "" +read_pw = "" +show_log = false +pool_size = 10 +max_overflow = 20 +connection_budget = 40 +sslmode = "" # 관리형 DB 면 "require" + +[WorkerConfig] +concurrency = 1 # ★ 동시성을 올리면 차단 시 IP 소모도 비례해 늘어난다 +fallbacks = [] +profile_dir = "/profiles" # ★ 필수 — compose 가 lps-profiles 볼륨을 여기에 마운트한다. + # ".profiles" 로 두면 컨테이너 레이어에 쌓여 재생성마다 쿠키가 날아간다. +job_deadline_sec = 300 +shutdown_grace_sec = 60 +chrome_channel = "chrome" +chrome_executable = "/opt/google/chrome/chrome" # ★ 컨테이너 필수(설정 시 --no-sandbox 가 함께 붙음) +heartbeat_file = "/tmp/lps_worker_heartbeat" + +[AlertConfig] +webhook = "" # ★ Slack 호환 웹훅. 비우면 로그로만 알림 +cooldown_min = 30 +dead_1h = 20 +blocks_1h = 80 +queue_lag_sec = 300 +pool_pct = 90 +source_fail_30m = 5 +deadline_1h = 5 +cost_1h_usd = 1.0 +ports_low_pct = 30 +block_sessions_6h = 1 + +# ── 시크릿 ── + +[NaverConfig] +[[NaverConfig.keys]] +id = "" +secret = "" + +[OpenAIConfig] +api_key = "" # 소진되면 크롤이 성공해도 AI 판정 실패로 잡이 DEAD 된다 +model = "gpt-4o-mini" + +[DecodoConfig] +host = "gate.decodo.com" +username = "" +password = "" +port_start = 10001 +port_end = 10100 +session_minutes = 10 +cost_per_gb = 3.0 +ip_request_budget = 3 # IP당 요청 예산. 차단이 ip_req#1 에 몰리면 이 값과 무관한 문제다 +port_cooldown_sec = 0 # 0=자동 max(sticky, 30분) diff --git a/lps/worker_main.py b/lps/worker_main.py index 7af0cb7..aa25f34 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -164,11 +164,19 @@ async def run_ops_monitor(queue, bot_log, stop, interval: float = 30.0, adapters snap["proxy_ports_avail"], snap["proxy_ports_total"] = avail, total await alerts.check("proxy_ports_low", avail * 100 <= total * th.ports_low_pct, f"가용 프록시 포트 {avail}/{total} — 대규모 차단 진행 신호", snap) - # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생 = 현재 예산이 안전하지 않다는 신호. + # 예산 누수 — 요청 예산을 지켰는데도 차단된 IP 세션 발생. + # 처방은 '몇 번째 요청에서 막혔나'로 갈린다: + # ip_req#1 위주 → 새 IP 첫 요청부터 차단 = IP 평판 문제. 예산을 낮춰도 소용없다. + # ip_req#2~ 위주 → 같은 IP 로 너무 많이 긁은 것 = 예산 하향이 유효. + # 예산 하향만 권하면 오진을 부른다(2026-07-28 배포서버 조사에서 전량 ip_req#1 이었다). block_sessions = (await ip_log.recent_stats(360)).get("block", 0) snap["block_sessions_6h"] = block_sessions - await alerts.check("budget_leak", block_sessions >= th.block_sessions_6h, - f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — ip_request_budget 하향 검토", snap) + await alerts.check( + "budget_leak", block_sessions >= th.block_sessions_6h, + f"예산 회전에도 차단된 IP 세션 6h={block_sessions} — " + f"bot_detection.ip_request_no 분포 확인(1 위주면 IP 평판/프록시 대역, 2 이상이면 ip_request_budget 하향)", + snap, + ) # 소스별 장기 실패 — 최근 30분간 시도는 있는데 성공이 0건(쿼터 소진·셀렉터 드리프트·전면 차단 신호) per_source: dict[str, list[int]] = {} for ad in (adapters or []):