From 74db218410d0bb7d810e2b2ae1a337c8714754d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 7 Aug 2026 10:57:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=202=EB=8B=A8=EA=B3=84=20=E2=80=94=20?= =?UTF-8?q?price=5Fhistory=20=EC=97=90=20=EB=AA=B0=EB=B3=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1단계에서 만든 SourceState 가 job.result 에만 있어 화면까지 못 갔다. by_mall 은 **가격이 있는 몰만** 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지 구분할 자리가 없었다. - price_history.sources (JSONB): 몰별 상태를 그대로 담는다. {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} 열린 스키마라 몰이 늘거나 근거를 덧붙여도 마이그레이션이 필요 없다(by_mall 과 같은 방침). - price_history.partial (bool): 결과가 완전한가. sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는 판단 규칙까지 알아야 하면 **상태 정의가 두 곳으로 흩어진다**. 판단은 LPS 가 끝내고 소비자(negodata·lps-admin)는 사실 하나만 읽는다. - 부분 인덱스 ix_price_history_partial — partial=true 행만 담아 작게 유지(운영 점검·알림용). - _record_history 가 per_source 를 받아 partial 을 계산해 기록한다. 네거티브 캐시 히트 경로는 sources 없이 남긴다(부분 결과는 애초에 캐시하지 않으므로 항상 확정). - 마이그레이션: postgres-init/dbeaver/7_lps_source_state_dbeaver.sql (재실행 안전, **운영 적용 필요**) 검증(로컬 실 DB): 쿠팡 차단 vs 쿠팡 0건은 by_mall 이 둘 다 ['naver'] 로 같지만 partial(true/false)·sources.coupang.state(blocked/empty)가 두 경우를 갈라낸다. JSONB 는 ensure_ascii=False 로 한글 사유가 깨지지 않는 것도 테스트로 고정. 테스트 3건 추가, 전체 289 passed. 진행 상황은 docs/result-states.md 4절. Co-Authored-By: Claude Opus 5 --- lps/common/database/model/models.py | 10 +++++ lps/crud/price_history.py | 12 ++++-- lps/docs/database.md | 2 + lps/docs/result-states.md | 18 +++++++-- lps/tests/test_price_history.py | 39 +++++++++++++++++++ lps/worker/handlers.py | 20 +++++++--- .../dbeaver/7_lps_source_state_dbeaver.sql | 32 +++++++++++++++ 7 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 postgres-init/dbeaver/7_lps_source_state_dbeaver.sql diff --git a/lps/common/database/model/models.py b/lps/common/database/model/models.py index f458e9b..62cd3a7 100644 --- a/lps/common/database/model/models.py +++ b/lps/common/database/model/models.py @@ -105,6 +105,16 @@ class price_history(MAIN_BASE): # 몰별 최저가 스냅샷(열린 스키마) — [{mall, source, price, shipping_fee, shipping_type, url}, ...]. # 몰이 늘어도 컬럼 추가/마이그레이션 없이 담는다(G마켓·옥션·11번가 등). naver/coupang 3선은 위 컬럼 유지. by_mall = Column(JSONB, nullable=True) + # ── 몰별 '확인했는가' ───────────────────────────────────────────────────── + # by_mall 은 **가격이 있는 몰만** 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지 + # '거기를 못 봤다'인지 알 수 없었다 — 안 본 걸 없다고 말하는 셈이었다. + # {"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}} + # state 값은 common.enums.SourceState (정의·표기 규칙은 docs/result-states.md). + sources = Column(JSONB, nullable=True) + # 결과가 완전한가. True = 못 본 몰이 있어 이 값이 최종이 아니다. + # sources 에서 유도 가능하지만 컬럼으로 둔다 — 소비자가 '어떤 상태가 확인된 것인가'라는 + # 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 여기서 끝내고 사실만 넘긴다. + partial = Column(Boolean, nullable=False, server_default=text("false")) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) diff --git a/lps/crud/price_history.py b/lps/crud/price_history.py index 869455c..09cd206 100644 --- a/lps/crud/price_history.py +++ b/lps/crud/price_history.py @@ -13,6 +13,7 @@ _FIELDS = ( "coupang_lowest", "coupang_name", "coupang_url", "final_lowest", "final_source", "final_rating", "final_review_count", "final_shipping_fee", "final_shipping_type", "final_shipping_label", + "partial", ) @@ -27,17 +28,20 @@ class PriceHistory: naver_lowest, naver_name, naver_url, coupang_lowest, coupang_name, coupang_url, final_lowest, final_source, final_rating, final_review_count, - final_shipping_fee, final_shipping_type, final_shipping_label, by_mall) + final_shipping_fee, final_shipping_type, final_shipping_label, + by_mall, sources, partial) VALUES (:product_code, :job_id, :outcome, :matched_count, :naver_lowest, :naver_name, :naver_url, :coupang_lowest, :coupang_name, :coupang_url, :final_lowest, :final_source, :final_rating, :final_review_count, - :final_shipping_fee, :final_shipping_type, :final_shipping_label, CAST(:by_mall AS jsonb)) + :final_shipping_fee, :final_shipping_type, :final_shipping_label, + CAST(:by_mall AS jsonb), CAST(:sources AS jsonb), COALESCE(:partial, FALSE)) """) params = {k: event.get(k) for k in _FIELDS} - by_mall = event.get("by_mall") - params["by_mall"] = json.dumps(by_mall) if by_mall is not None else None + for col in ("by_mall", "sources"): # JSONB 는 문자열로 넘겨 CAST 한다 + v = event.get(col) + params[col] = json.dumps(v, ensure_ascii=False) if v is not None else None s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value) try: await s.execute(sql, params) diff --git a/lps/docs/database.md b/lps/docs/database.md index 9056a26..5914f9c 100644 --- a/lps/docs/database.md +++ b/lps/docs/database.md @@ -62,6 +62,8 @@ | `final_lowest` | 전체 최저가 (**그래프 Y축 핵심**) | | `final_source` | 최종 최저가가 나온 소스(naver/coupang/gmarket/auction/st11) | | `by_mall` | 몰별 최저가 스냅샷(JSONB, 열린 스키마) — `[{mall, source, price, shipping_fee, shipping_type, url}, …]`. G마켓·옥션·11번가 등이 늘어도 컬럼 추가 없이 담는다 | +| `sources` | **몰별 확인 상태**(JSONB) — `{"naver": {"state": "matched", "count": 40}, "coupang": {"state": "blocked", "error": "..."}}`. `by_mall` 은 가격이 있는 몰만 담으므로, 빠진 몰이 '거기엔 없더라'인지 '거기를 못 봤다'인지는 이 값에만 있다. state 값은 `SourceState`([정의](result-states.md)) | +| `partial` | 결과가 **완전한가**. `true`=못 본 몰이 있어 최종이 아니다. `sources` 에서 유도 가능하지만 컬럼으로 두어, 소비자가 상태 분류 규칙을 몰라도 되게 한다 | | `job_id` / `created_at` | 검색 잡 연결 / 생성 시각 | > 한쪽 소스에 그 상품이 없던 시점은 해당 컬럼이 `null`(그래프 선이 빈다 — 정상). diff --git a/lps/docs/result-states.md b/lps/docs/result-states.md index b277aa0..07f52bb 100644 --- a/lps/docs/result-states.md +++ b/lps/docs/result-states.md @@ -174,14 +174,24 @@ per_source[src] = {"error": f"{type(res).__name__}: {res}"} # ← blocked/fata 검증(9조합 실측): `empty` → `partial=False`(확정) / `blocked`·`env_blocked`·`unavailable` → `partial=True`(미확정). 테스트 12건 추가. +**2단계 — 저장할 자리 ✅ 완료** (`feat/source-state`) + +- `price_history.sources`(JSONB) — 몰별 상태를 그대로 담는다. 열린 스키마라 몰이 늘거나 + 상태에 근거를 덧붙여도 마이그레이션이 필요 없다. +- `price_history.partial`(bool) — 결과가 완전한가. `sources` 에서 유도할 수 있지만 굳이 컬럼으로 + 둔다: 소비자가 '어떤 상태가 확인된 것인가'라는 **판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 + 흩어진다**. 판단은 LPS 가 끝내고 소비자는 사실 하나만 읽는다. +- 부분 인덱스 `ix_price_history_partial` — '확인 못한 결과'만 뽑는 운영 점검용(작게 유지된다). +- 마이그레이션: `postgres-init/dbeaver/7_lps_source_state_dbeaver.sql` (**운영 적용 필요**) + +검증(실 DB): 쿠팡 차단과 쿠팡 0건은 `by_mall` 이 둘 다 `['naver']` 로 같지만 +`partial`(true/false)과 `sources.coupang.state`(blocked/empty)가 두 경우를 갈라낸다. 테스트 3건 추가. + **남은 것** -2. `price_history` 에 몰별 상태·`partial` 을 담을 자리가 없다 → 두 화면 모두 못 읽는다 - (지금은 `job.result` 에만 있다) 3. lps-admin 이 몰별 상태·원인을 못 보여준다(잡 목록의 outcome 까지만) 4. negodata 가 `–`(없음)와 `확인 못함`(미확인)을 구분하지 못한다 -**2 → (3, 4)** 순서다. 2가 없으면 3·4가 읽을 게 없다. -3과 4는 같은 데이터를 각자 다르게 접는 것이므로 순서가 없다 — 병행 가능하다. +이제 읽을 데이터가 생겼으므로 3·4 는 **각자 다르게 접기만** 하면 된다 — 순서 없이 병행 가능하다. > 이 문서는 **정의**다. 구현 전에 용어를 맞추기 위한 것이고, 실제 반영 여부는 위 4절이 소스다. diff --git a/lps/tests/test_price_history.py b/lps/tests/test_price_history.py index bc6b032..ad4db89 100644 --- a/lps/tests/test_price_history.py +++ b/lps/tests/test_price_history.py @@ -113,3 +113,42 @@ async def test_snapshot_carries_trust_of_the_lowest_offer(): e = rec.events[0] assert e["final_lowest"] == 900 and e["final_source"] == "naver" assert e["final_rating"] is None and e["final_review_count"] is None # 미검증 오퍼임이 드러난다 + + +# ── 몰별 확인 상태 (2026-08-07, 2단계) ────────────────────────────────── +# by_mall 은 '가격이 있는 몰'만 담는다. 그래서 어떤 몰이 빠졌을 때 '거기엔 없더라'인지 +# '거기를 못 봤다'인지 구분되지 않았다 — sources/partial 이 그 자리를 메운다. + +async def test_records_source_states_and_partial(ph, db_engine): + await ph.record({ + "product_code": "SRC1", "outcome": "found", "final_lowest": 9000, "partial": True, + "sources": {"naver": {"state": "matched", "count": 40}, + "coupang": {"state": "blocked", "error": "AdapterError: 차단"}}, + }) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT partial, sources FROM price_history WHERE product_code='SRC1'"))).first() + assert row.partial is True + assert row.sources["coupang"]["state"] == "blocked" + assert row.sources["naver"]["count"] == 40 + + +async def test_partial_defaults_to_false_when_absent(ph, db_engine): + """옛 호출부(값을 안 주는 경로)도 깨지지 않아야 한다 — 기본은 '완전한 결과'.""" + await ph.record({"product_code": "SRC2", "outcome": "not_found"}) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT partial, sources FROM price_history WHERE product_code='SRC2'"))).first() + assert row.partial is False and row.sources is None + + +async def test_source_state_survives_korean_text(ph, db_engine): + """error 메시지에 한글이 섞여도 JSONB 가 깨지지 않아야 한다(ensure_ascii=False).""" + await ph.record({ + "product_code": "SRC3", "outcome": "found", "partial": True, + "sources": {"coupang": {"state": "env_blocked", "error": "사용권한이 제한된 페이지"}}, + }) + async with db_engine.begin() as conn: + row = (await conn.execute(text( + "SELECT sources FROM price_history WHERE product_code='SRC3'"))).first() + assert "사용권한이 제한된" in row.sources["coupang"]["error"] diff --git a/lps/worker/handlers.py b/lps/worker/handlers.py index 7d1f69b..e363741 100644 --- a/lps/worker/handlers.py +++ b/lps/worker/handlers.py @@ -101,10 +101,20 @@ def build_search_handler( use = list(sources) if sources else list(adapters.keys()) fallbacks = fallback_adapters or {} - async def _record_history(product_code: str, job_id, outcome: str, matched: list): + async def _record_history(product_code: str, job_id, outcome: str, matched: list, + sources: dict | None = None): + """price_history 1행 기록. + + sources 를 함께 남기는 게 중요하다 — by_mall 은 **가격이 있는 몰만** 담으므로, 어떤 몰이 + 빠졌을 때 '거기엔 없더라'인지 '거기를 못 봤다'인지 이 값 없이는 알 수 없다. + partial 은 여기서 판단해 사실로 넘긴다(소비자가 상태 분류 규칙을 알 필요 없게). + """ if history is None: return - event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, **_price_snapshot(matched)} + confirmed = {s.name.lower() for s in SourceState if s.confirmed} + partial = any((i or {}).get("state") not in confirmed for i in (sources or {}).values()) + event = {"product_code": product_code, "job_id": job_id, "outcome": outcome, + "sources": sources or None, "partial": partial, **_price_snapshot(matched)} try: await history.record(event) except Exception as ex: @@ -268,7 +278,7 @@ def build_search_handler( partial=bool(failed_sources), metrics=metrics.snapshot()) if failed_sources: LOG.w(f"[partial] {failed_sources} 없이 결과를 냈습니다 — 그 몰의 더 싼 값은 못 봤을 수 있습니다") - await _record_history(cache_key, job.get("job_id"), "found", candidates) + await _record_history(cache_key, job.get("job_id"), "found", candidates, per_source) return result # 0매칭 + 일부 소스 실패. **살아있는 소스가 하나라도 있으면 그 결과로 진행한다** — @@ -287,7 +297,7 @@ def build_search_handler( result.update(outcome="error", query=query, rounds_tried=rounds_done, sources=per_source, sources_ok=[], sources_failed=failed_sources, partial=True, metrics=metrics.snapshot()) - await _record_history(cache_key, job.get("job_id"), "error", []) + await _record_history(cache_key, job.get("job_id"), "error", [], per_source) return result raise RuntimeError(f"모든 소스 실패로 0매칭(round={label}) — 잡 재시도: {per_source}") @@ -303,7 +313,7 @@ def build_search_handler( partial=partial, metrics=metrics.snapshot()) if partial: LOG.w(f"[partial] {last_failed} 없이 not_found — 네거티브 캐시는 건너뜁니다(그 몰엔 있었을 수 있음)") - await _record_history(cache_key, job.get("job_id"), "not_found", []) + await _record_history(cache_key, job.get("job_id"), "not_found", [], last_sources) return result return handler diff --git a/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql b/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql new file mode 100644 index 0000000..a6ed45f --- /dev/null +++ b/postgres-init/dbeaver/7_lps_source_state_dbeaver.sql @@ -0,0 +1,32 @@ +-- LPS 몰별 확인 상태 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS). +-- +-- 왜 필요한가: '그 몰에 더 싼 게 없었다'와 '그 몰이 막혀서 못 봤다'가 지금 화면에서 똑같이 +-- '–' 로 보인다. 사용자는 앞쪽으로 읽지만 실제로는 뒤쪽일 수 있다 — 안 본 걸 없다고 말하는 셈이다. +-- 크롤러는 그 차이를 이미 알고 있는데(SourceState), 담을 자리가 없어 화면까지 못 갔다. +-- +-- 상태 정의와 표기 규칙은 lps/docs/result-states.md 가 소스다. + +-- 몰별 확인 상태 — {"naver": {"state": "matched", "count": 40}, +-- "coupang": {"state": "blocked", "error": "AdapterError: ..."}} +-- 열린 스키마(JSONB)로 둔다: 몰이 늘거나 상태에 근거를 덧붙여도 마이그레이션이 필요 없다. +-- state 값: matched / no_match / empty / blocked / env_blocked / unavailable / skipped +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS sources JSONB; + +-- 결과가 **완전한가**. true = 못 본 몰이 있어 이 값이 최종이 아니다. +-- sources 에서 유도할 수 있지만 굳이 컬럼으로 둔다 — 소비자(negodata)가 '어떤 상태가 확인된 +-- 것인가'라는 판단 규칙까지 알아야 하면 상태 정의가 두 곳으로 흩어진다. 판단은 LPS 가 하고, +-- 소비자는 사실 하나만 읽게 한다. +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS partial BOOLEAN NOT NULL DEFAULT FALSE; + +-- '확인 못한 결과'만 빠르게 뽑기 위한 부분 인덱스(운영 점검·알림용). +-- 전체가 아니라 partial=true 행만 담아 인덱스가 작게 유지된다. +CREATE INDEX IF NOT EXISTS ix_price_history_partial + ON price_history (triggered_at) WHERE partial; + +-- ── 검증 ────────────────────────────────────────────────────────────────────── +SELECT column_name, data_type, + CASE WHEN column_name IN ('sources','partial') THEN '이번 추가' ELSE '' END AS note + FROM information_schema.columns + WHERE table_name = 'price_history' + AND column_name IN ('outcome','by_mall','sources','partial') + ORDER BY column_name;