o2o-negosium-original/negodata/backend/services/lps_sync_service.py
민헌 523661e3d2 feat(negodata): LPS 최저가 동기화 배치 — 요청(API)·수집(lps_db)·반영(items)
설계(2026-07-10 결정): 요청은 LPS API(dedupe·워커알림 보존), 결과는
lps_db.price_history 직접 읽기(읽기전용 엔진). product_code=item_id
로 결과가 자동 매핑된다.

- 잡③ request_lps_searches(매일 04:00 KST): 24h 이상 미수집 상품을
  최대 500건 enqueue(job_type=batch, 100건/콜 청크) — 비용 발생 잡
- 잡④ sync_lps_results(5분): 워터마크(max crawl_end_time) 증분 수집
  → item_internet_lowest_prices append(성공/실패 모두) + 성공분 최신값
  items.internet_lowest_price 박제(+yn). 한 트랜잭션(부분반영 방지)
- 스캔 바닥 3일 — 비uuid·미존재 상품 행이 워터마크를 못 올려도
  재스캔 범위 유한
- ⚠️ 시각은 aware UTC 통일 — naive 를 timestamptz 파라미터로 넘기면
  PG 세션 타임존(KST) 해석으로 9시간 어긋남(중복 수집 실측 버그 수정)
- LowestPriceWebsite 코드 enum(naver=1 coupang=2 …), iilp ORM 모델,
  lps_base_url config(+LPS_BASE_URL env)

검증: 실상품 1건 e2e(요청→크롤 found 15,000원→이력+박제 반영),
멱등성(재실행 빈 카운터), 비uuid 12건 스킵, 대상선정 쿼리 500건 상한

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:38:57 +09:00

169 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""LPS(인터넷 최저가 검색) 동기화 서비스 — 요청·수집·반영의 도메인 로직.
흐름 (요청은 API, 결과는 DB — 2026-07-10 설계 결정)
① 요청: 갱신이 오래된 상품을 골라 LPS API(POST /v1/lps/search)로 enqueue.
product_code = items.item_id(uuid 문자열) — 이걸로 결과가 자동 매핑된다.
enqueue 를 DB insert 로 하지 않는 이유: LPS 의 활성중복 dedupe·pg_notify 워커 깨움을 우회하게 됨.
② 수집: lps_db.price_history(읽기전용)를 워터마크(max crawl_end_time) 증분으로 읽는다.
③ 반영: 이력은 partner.item_internet_lowest_prices 에 append(성공/실패 모두),
성공분의 상품별 최신값을 items.internet_lowest_price 에 박제(+yn).
방어
- LPS 미설정 환경(lps_db 미등록)이면 조용히 스킵 — 연동은 선택 기능, 본 서비스를 못 멈춘다.
- product_code 가 uuid 가 아니거나(item_id 아님 — 예: LPS 자체 부하테스트 잡) 상품이 없으면 스킵.
- 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다.
"""
import uuid
from collections import Counter
from datetime import timedelta, timezone
import httpx
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import item_internet_lowest_prices
from common.enums import DBType, DBWRType, ErrorType, LowestPriceWebsite
from common.logger import LOG
from config.server_configs import web_server_config
from crud.lps_sync_crud import ILpsSyncCRUD, LpsSyncCRUD
# 재검색 주기·요청 배치 크기 기본값. 상품당 실측 비용 ~$0.004 이므로
# (상품 수 × 24h 주기)로 월 비용이 바로 계산된다. 필요 시 여기만 조정.
REFRESH_HOURS = 24 # 마지막 수집이 이보다 오래된 상품만 재요청
REQUEST_BATCH_LIMIT = 500 # 요청 잡 1회가 enqueue 하는 최대 상품 수(비용 상한)
ENQUEUE_CHUNK = 100 # LPS API 1콜에 담는 상품 수
SCAN_FLOOR_DAYS = 3 # price_history 스캔 하한(일) — 스킵행(비uuid 등)은 워터마크를
# 못 올리므로, 바닥 없이는 같은 행을 영원히 재스캔하게 된다
def _utc_now_aware():
"""aware UTC now — 이 서비스는 timestamptz 비교/워터마크에 쓰므로 GTime(naive)이 아닌 aware 를 쓴다."""
from datetime import datetime
return datetime.now(timezone.utc)
class LpsSyncService:
def __init__(self, crud: ILpsSyncCRUD = None):
self.crud = crud or LpsSyncCRUD()
@staticmethod
def available() -> bool:
"""LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다."""
return DB_SESSION_MNG.is_registered(DBType.LPS.value)
# ---- ① 요청: 오래된 상품을 LPS 에 검색 enqueue ----------------------
async def request_stale_searches(self) -> Counter:
results = Counter()
if not self.available():
return results
cutoff = _utc_now_aware() - timedelta(hours=REFRESH_HOURS) # timestamptz 비교 — aware 필수(위 sync_results 주석)
err, rows = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value,
lambda s: self.crud.stale_items(s, cutoff, REQUEST_BATCH_LIMIT),
)
if err != ErrorType.SUCCESS or not rows:
return results
payload_items = []
for item_id, name, model_name, spec, manufacturer, price in rows:
if not (name or "").strip():
results["skipped_no_name"] += 1
continue
payload_items.append({
"product_code": str(item_id),
"product_name": name,
"job_type": "batch",
"model": model_name or "",
"specification": spec or "",
"company": manufacturer or "",
"price": str(price) if price else "",
})
base = web_server_config.lps_base_url.rstrip("/")
async with httpx.AsyncClient(timeout=10.0) as client:
for i in range(0, len(payload_items), ENQUEUE_CHUNK):
chunk = payload_items[i:i + ENQUEUE_CHUNK]
try:
r = await client.post(f"{base}/v1/lps/search", json={"data": chunk})
r.raise_for_status()
body = r.json()
results["accepted"] += int(body.get("accepted", 0))
results["duplicated"] += sum(1 for it in body.get("items", []) if it.get("duplicated"))
except Exception as ex:
# LPS 다운은 이 배치의 실패일 뿐 — 다음 tick 이 같은 대상(여전히 stale)을 다시 요청한다.
results["http_error"] += 1
LOG.w(f"[lps-sync] 검색요청 실패(청크 {i // ENQUEUE_CHUNK}): {type(ex).__name__}: {ex}")
return results
# ---- ②+③ 수집·반영: price_history 증분 → 이력 append + 대표값 박제 --
async def sync_results(self) -> Counter:
results = Counter()
if not self.available():
return results
err, wm = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: self.crud.watermark(s))
if err != ErrorType.SUCCESS:
return results
# 스캔 시작점 = max(워터마크, 최근 N일 바닥) — 반영 안 되는 행(비uuid·미존재 상품)이
# 섞여 있어도 재스캔 범위가 유한하게 유지된다(N일 지나면 자연 소멸).
# ⚠️ 이 경로의 시각은 전부 aware UTC 로 다룬다. naive 를 timestamptz 파라미터로 넘기면
# PG 가 세션 타임존(KST)으로 해석해 9시간 어긋난 증분을 읽는다(중복 수집 실측 버그).
floor = _utc_now_aware() - timedelta(days=SCAN_FLOOR_DAYS)
since = max(wm, floor) if wm is not None else floor
err, rows = await DB_SESSION_MNG.execute_lambda(
DBType.LPS.value, DBWRType.DB_READ.value, lambda s: self.crud.fetch_results_since(s, since))
if err != ErrorType.SUCCESS or not rows:
return results
# product_code(uuid=item_id) 검증 — LPS 부하테스트 등 비상품 코드는 조용히 스킵
parsed = []
for code, outcome, final_lowest, final_source, created_at in rows:
try:
parsed.append((uuid.UUID(code), outcome, final_lowest, final_source, created_at))
except (ValueError, AttributeError, TypeError):
results["skipped_not_uuid"] += 1
err, existing = await DB_SESSION_MNG.execute_lambda(
DBType.MAIN.value, DBWRType.DB_READ.value,
lambda s: self.crud.existing_item_ids(s, list({p[0] for p in parsed})),
)
if err != ErrorType.SUCCESS:
return results
history_rows, latest_found = [], {} # latest_found: item_id → (created_at, price)
for item_id, outcome, final_lowest, final_source, created_at in parsed:
if item_id not in existing:
results["skipped_unknown_item"] += 1
continue
found = outcome == "found" and final_lowest is not None
history_rows.append(item_internet_lowest_prices(
item_id=item_id,
lp_price=final_lowest if found else None,
website=LowestPriceWebsite.from_source(final_source).value,
success_yn=found,
fail_reason=None if found else (outcome or "unknown")[:100],
crawl_end_time=created_at, # 워터마크 기준값 — price_history.created_at 그대로 보존
))
results["found" if found else "not_found"] += 1
if found and (item_id not in latest_found or created_at > latest_found[item_id][0]):
latest_found[item_id] = (created_at, final_lowest)
if not history_rows:
return results
funcs = [lambda s, r=history_rows: self.crud.add_history_rows(s, r)]
funcs += [
(lambda s, i=item_id, p=price: self.crud.update_item_lowest(s, i, p))
for item_id, (_, price) in latest_found.items()
]
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value] * len(funcs), funcs)
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[lps-sync] 반영 트랜잭션 실패: {err.name} (다음 tick 재시도)")
return Counter({"tx_error": 1})
results["items_updated"] = len(latest_found)
return results