- POST /v1/item/{id}/lowest-price: 상품 1건 LPS 즉시 검색요청(manual
우선순위). queued/duplicated/unavailable 상태 반환, 실패는
ErrorType.LPS_UNAVAILABLE(2000 블록 신설)
- GET /v1/item/{id}/lowest-price: 대표 최저가 + 최근 수집 이력(최신순,
LowestPriceEntry 타입화). 조회 전 lps_db 증분 동기화 1회 수행 —
5분 크론을 기다리지 않는 실시간 폴링 UX(멱등·저비용)
- 동시성 방어 2겹: 프로세스 내 asyncio.Lock(크론·온디맨드 직렬화) +
uq_iilp_item_crawl_time 유니크 인덱스(alters/2026-07-10, init.sql
멱등 반영·dev DB 적용) — 경합 진 쪽 tx 실패 후 다음 tick 흡수
- LpsSyncService 무인자 생성자(FastAPI Depends 호환)
- compose: negodata-backend 에 LPS_DB_HOST·LPS_BASE_URL env(미설정 시
연동 비활성으로 조용히 동작)
검증(도커 컨테이너 e2e): 인증→POST queued→LPS 크롤→GET 온디맨드
동기화로 이력 즉시 노출(not_found 정책: 대표값 미변경 확인),
company 스코프 차단(타사 상품 ITEM_NOT_FOUND) 확인
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
7.6 KiB
Python
167 lines
7.6 KiB
Python
"""LPS 동기화 CRUD — negosium(main) 쪽 조회/반영 + lps_db(읽기전용) 결과 조회.
|
|
|
|
경계 규칙
|
|
- lps_db 는 LPS 서비스 소유라 ORM 모델을 만들지 않는다 — anchoring reader 선례대로
|
|
raw table() 로 **price_history 만** 읽는다(그 외 테이블은 LPS 내부 구현으로 간주).
|
|
- 워터마크는 별도 상태 테이블 없이 partner.item_internet_lowest_prices 의
|
|
max(crawl_end_time) 을 쓴다(crawl_end_time = price_history.created_at 을 그대로 보존).
|
|
반영이 중간에 실패해도 다음 tick 이 같은 지점부터 다시 읽는다(자기 치유).
|
|
"""
|
|
from abc import ABC, abstractmethod
|
|
from typing import Tuple
|
|
|
|
from sqlalchemy import column, func, or_, select, table, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import item_internet_lowest_prices, items
|
|
from common.enums import ErrorType
|
|
from common.logger import LOG
|
|
|
|
# lps_db.price_history — LPS 가 검색 1건마다 남기는 append-only 스냅샷(읽기 계약).
|
|
_price_history = table(
|
|
"price_history",
|
|
column("product_code"), # = negosium items.item_id (요청 시 그대로 넣는다)
|
|
column("outcome"), # found | not_found
|
|
column("final_lowest"), # 전체 최저가(원)
|
|
column("final_source"), # naver | coupang | (폴백몰)
|
|
column("created_at"),
|
|
)
|
|
|
|
|
|
class ILpsSyncCRUD(ABC):
|
|
@abstractmethod
|
|
async def watermark(self, cdb: AsyncSession) -> Tuple[ErrorType, object]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def stale_items(self, cdb: AsyncSession, cutoff, limit) -> Tuple[ErrorType, list]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def existing_item_ids(self, cdb: AsyncSession, item_ids: list) -> Tuple[ErrorType, set]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def fetch_results_since(self, ldb: AsyncSession, since) -> Tuple[ErrorType, list]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def recent_history(self, cdb: AsyncSession, item_id, limit) -> Tuple[ErrorType, list]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_item_lowest(self, cdb: AsyncSession, item_id, price) -> ErrorType:
|
|
pass
|
|
|
|
|
|
class LpsSyncCRUD(ILpsSyncCRUD):
|
|
# ---- negosium(main) 조회 -------------------------------------------
|
|
async def watermark(self, cdb: AsyncSession):
|
|
"""수집 이력의 최신 crawl_end_time — 증분 동기화 기준점. 이력이 없으면 None(전체 수집)."""
|
|
try:
|
|
q = select(func.max(item_internet_lowest_prices.crawl_end_time)).where(
|
|
item_internet_lowest_prices.deleted == False # noqa: E712
|
|
)
|
|
return ErrorType.SUCCESS, (await cdb.execute(q)).scalar()
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[lps-sync] watermark 조회 실패: {ex}")
|
|
return ErrorType.DB_RUN_FAILED, None
|
|
|
|
async def stale_items(self, cdb: AsyncSession, cutoff, limit: int):
|
|
"""재검색 대상 상품 — 수집 이력이 없거나 마지막 수집이 cutoff 이전인 활성 상품.
|
|
반환 행: (item_id, name, model_name, spec, manufacturer, price)"""
|
|
try:
|
|
latest = (
|
|
select(
|
|
item_internet_lowest_prices.item_id.label("item_id"),
|
|
func.max(item_internet_lowest_prices.crawl_end_time).label("last_ts"),
|
|
)
|
|
.where(item_internet_lowest_prices.deleted == False) # noqa: E712
|
|
.group_by(item_internet_lowest_prices.item_id)
|
|
.subquery()
|
|
)
|
|
q = (
|
|
select(items.item_id, items.name, items.model_name, items.spec, items.manufacturer, items.price)
|
|
.join(latest, items.item_id == latest.c.item_id, isouter=True)
|
|
.where(
|
|
items.deleted == False, # noqa: E712
|
|
or_(latest.c.last_ts.is_(None), latest.c.last_ts < cutoff),
|
|
)
|
|
.order_by(latest.c.last_ts.asc().nullsfirst()) # 오래된 것부터(이력 없는 신규 최우선)
|
|
.limit(limit)
|
|
)
|
|
return ErrorType.SUCCESS, (await cdb.execute(q)).all()
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[lps-sync] stale_items 조회 실패: {ex}")
|
|
return ErrorType.DB_RUN_FAILED, []
|
|
|
|
async def existing_item_ids(self, cdb: AsyncSession, item_ids: list):
|
|
"""전달된 id 중 실재하는 활성 상품 id 집합 — 결과 반영 전 매핑 검증용."""
|
|
if not item_ids:
|
|
return ErrorType.SUCCESS, set()
|
|
try:
|
|
q = select(items.item_id).where(
|
|
items.item_id.in_(item_ids),
|
|
items.deleted == False, # noqa: E712
|
|
)
|
|
return ErrorType.SUCCESS, {r[0] for r in (await cdb.execute(q)).all()}
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[lps-sync] existing_item_ids 조회 실패: {ex}")
|
|
return ErrorType.DB_RUN_FAILED, set()
|
|
|
|
async def recent_history(self, cdb: AsyncSession, item_id, limit: int):
|
|
"""상품의 최근 수집 이력(최신순) — lowest-price 조회 API 용. idx_iilp_item_crawl_time 사용."""
|
|
try:
|
|
q = (
|
|
select(item_internet_lowest_prices)
|
|
.where(
|
|
item_internet_lowest_prices.item_id == item_id,
|
|
item_internet_lowest_prices.deleted == False, # noqa: E712
|
|
)
|
|
.order_by(item_internet_lowest_prices.crawl_end_time.desc())
|
|
.limit(limit)
|
|
)
|
|
return ErrorType.SUCCESS, (await cdb.execute(q)).scalars().all()
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[lps-sync] recent_history 조회 실패: {ex}")
|
|
return ErrorType.DB_RUN_FAILED, []
|
|
|
|
# ---- lps_db(읽기전용) 조회 -----------------------------------------
|
|
async def fetch_results_since(self, ldb: AsyncSession, since):
|
|
"""워터마크 이후의 price_history 증분. since 가 None 이면 전체(첫 동기화)."""
|
|
try:
|
|
q = select(
|
|
_price_history.c.product_code,
|
|
_price_history.c.outcome,
|
|
_price_history.c.final_lowest,
|
|
_price_history.c.final_source,
|
|
_price_history.c.created_at,
|
|
).order_by(_price_history.c.created_at.asc())
|
|
if since is not None:
|
|
q = q.where(_price_history.c.created_at > since)
|
|
return ErrorType.SUCCESS, (await ldb.execute(q)).all()
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(f"[lps-sync] price_history 조회 실패: {ex}")
|
|
return ErrorType.DB_RUN_FAILED, []
|
|
|
|
# ---- negosium(main) 반영 (execute_lambda_run 안에서 호출) -----------
|
|
async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType:
|
|
"""수집 이력 일괄 insert. rows = item_internet_lowest_prices ORM 객체 리스트."""
|
|
if not rows:
|
|
return ErrorType.SUCCESS
|
|
return await DB_SESSION_MNG.insert(cdb, rows, err_msg="lps-sync history insert 실패")
|
|
|
|
async def update_item_lowest(self, cdb: AsyncSession, item_id, price) -> ErrorType:
|
|
"""상품 대표 최저가 박제 — items.internet_lowest_price + yn 플래그."""
|
|
q = (
|
|
update(items)
|
|
.where(items.item_id == item_id, items.deleted == False) # noqa: E712
|
|
.values(internet_lowest_price=price, internet_lowest_price_yn=True)
|
|
)
|
|
return await DB_SESSION_MNG.add(cdb, q, err_msg=f"lps-sync 최저가 반영 실패 item={item_id}")
|