feat(negodata): lowest-price 스텁 API 실구현 — 즉시 트리거 + 온디맨드 동기화 조회
- 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>
This commit is contained in:
parent
523661e3d2
commit
28b4a9e797
@ -38,6 +38,9 @@ services:
|
||||
RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요)
|
||||
SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1
|
||||
PYTHONUNBUFFERED: "1" # 컨테이너 로그 실시간 출력(stdout 버퍼링 끔)
|
||||
# ── LPS(인터넷 최저가) 연동 — 미설정이면 연동 비활성으로 조용히 동작 ──
|
||||
LPS_DB_HOST: host.docker.internal # lps_db 읽기전용(수집 배치·조회 API)
|
||||
LPS_BASE_URL: http://host.docker.internal:9600 # 검색요청 enqueue. lps-api 컨테이너 사용 시 http://lps-api:9600
|
||||
volumes:
|
||||
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
|
||||
ports:
|
||||
|
||||
@ -82,6 +82,10 @@ class ErrorType(Enum):
|
||||
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
|
||||
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
|
||||
|
||||
# LPS(인터넷 최저가 검색) 연동 관련 에러
|
||||
LPS_UNAVAILABLE = 2000 # lps_db 미등록(연동 비활성 환경) — 기능 사용 불가
|
||||
LPS_REQUEST_FAILED = auto() # LPS 검색요청 API 호출 실패(LPS 다운/네트워크)
|
||||
|
||||
|
||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
|
||||
|
||||
@ -46,6 +46,10 @@ class ILpsSyncCRUD(ABC):
|
||||
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
|
||||
@ -110,6 +114,23 @@ class LpsSyncCRUD(ILpsSyncCRUD):
|
||||
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 이면 전체(첫 동기화)."""
|
||||
|
||||
@ -2,10 +2,13 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
|
||||
from common.enums import ErrorType
|
||||
from common.models.gmodel import PageParams, UserInfo
|
||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||
from services.item_service import ItemService
|
||||
from services.lps_sync_service import LpsSyncService
|
||||
from .protocol import (
|
||||
LowestPriceEntry,
|
||||
Req_CheckCodes,
|
||||
Req_CreateItem,
|
||||
Req_UpdateItem,
|
||||
@ -77,18 +80,43 @@ async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info
|
||||
return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role))
|
||||
|
||||
|
||||
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)")
|
||||
async def trigger_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
# 존재/소유 확인만 (없으면 result 에 ITEM_NOT_FOUND)
|
||||
@router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청")
|
||||
async def trigger_lowest_price(
|
||||
item_id: UUID,
|
||||
service: ItemService = Depends(),
|
||||
lps: LpsSyncService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
"""상품 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)
|
||||
res = Res_LowestPriceTrigger(item_id=str(item_id), status=status, message=message)
|
||||
if status == "unavailable":
|
||||
res.result.SetResult(ErrorType.LPS_UNAVAILABLE)
|
||||
return RemoveNoneResponse(res)
|
||||
|
||||
|
||||
@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과")
|
||||
async def get_lowest_price(
|
||||
item_id: UUID,
|
||||
service: ItemService = Depends(),
|
||||
lps: LpsSyncService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
"""대표 최저가(items.internet_lowest_price) + 최근 수집 이력(최신순).
|
||||
조회 전에 lps_db 증분 동기화를 한 번 수행해 5분 크론을 기다리지 않는다(멱등·저비용)."""
|
||||
got = await service.get_item(user_info.company_id, str(item_id))
|
||||
if got.item is None:
|
||||
return RemoveNoneResponse(got)
|
||||
return RemoveNoneResponse(Res_LowestPriceTrigger(item_id=str(item_id), status="queued", message="최저가 수집 요청됨(스텁)"))
|
||||
|
||||
|
||||
@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과(스텁)")
|
||||
async def get_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
got = await service.get_item(user_info.company_id, str(item_id))
|
||||
if got.item is None:
|
||||
return RemoveNoneResponse(got)
|
||||
return RemoveNoneResponse(Res_LowestPriceResult(item_id=str(item_id), results=[], message="최저가 수집 결과 없음(스텁)"))
|
||||
err, history = await lps.lowest_price_view(item_id)
|
||||
res = Res_LowestPriceResult(
|
||||
item_id=str(item_id),
|
||||
lowest_price=got.item.internet_lowest_price,
|
||||
results=[LowestPriceEntry.model_validate(h) for h in history],
|
||||
message="" if history else "수집 이력이 없습니다",
|
||||
)
|
||||
if err != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err)
|
||||
return RemoveNoneResponse(res)
|
||||
|
||||
@ -126,11 +126,24 @@ class Res_ItemImage(Res_WebPacketProtocol):
|
||||
|
||||
class Res_LowestPriceTrigger(Res_WebPacketProtocol):
|
||||
item_id: str = ""
|
||||
status: str = ""
|
||||
status: str = "" # queued(접수) | duplicated(이미 진행 중) | unavailable(연동 비활성/LPS 다운)
|
||||
message: str = ""
|
||||
|
||||
|
||||
class LowestPriceEntry(WebPacketProtocol):
|
||||
"""최저가 수집 이력 1건(partner.item_internet_lowest_prices)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
lp_price: Optional[int] = None # 수집한 최저가(원). 실패면 None
|
||||
website: int = 0 # LowestPriceWebsite 코드(1=naver 2=coupang …)
|
||||
success_yn: bool = False
|
||||
fail_reason: Optional[str] = None
|
||||
crawl_end_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class Res_LowestPriceResult(Res_WebPacketProtocol):
|
||||
item_id: str = ""
|
||||
results: list = []
|
||||
lowest_price: Optional[int] = None # 상품에 박제된 대표 최저가(items.internet_lowest_price)
|
||||
results: list[LowestPriceEntry] = [] # 최근 수집 이력(최신순)
|
||||
message: str = ""
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
- product_code 가 uuid 가 아니거나(item_id 아님 — 예: LPS 자체 부하테스트 잡) 상품이 없으면 스킵.
|
||||
- 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import timedelta, timezone
|
||||
@ -41,9 +42,16 @@ def _utc_now_aware():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# 온디맨드 동기화(조회 API)와 크론 잡의 동시 실행 직렬화(프로세스 내).
|
||||
# 프로세스 간 경합은 uq_iilp_item_crawl_time 유니크 인덱스가 최종 방어(진 쪽 tx 실패 → 다음 tick 흡수).
|
||||
_sync_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class LpsSyncService:
|
||||
def __init__(self, crud: ILpsSyncCRUD = None):
|
||||
self.crud = crud or LpsSyncCRUD()
|
||||
# FastAPI Depends() 로도 쓰이므로 무인자 생성자(파라미터가 있으면 DI 가 의존성으로 해석해 부팅 실패).
|
||||
# 테스트는 인스턴스 생성 후 .crud 교체로 주입한다.
|
||||
def __init__(self):
|
||||
self.crud: ILpsSyncCRUD = LpsSyncCRUD()
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
@ -96,8 +104,56 @@ class LpsSyncService:
|
||||
LOG.w(f"[lps-sync] 검색요청 실패(청크 {i // ENQUEUE_CHUNK}): {type(ex).__name__}: {ex}")
|
||||
return results
|
||||
|
||||
# ---- 단건 즉시 요청 (lowest-price 트리거 API 용) ---------------------
|
||||
async def request_search_for_item(self, item) -> tuple:
|
||||
"""상품 1건을 즉시 LPS 에 검색 요청(수동 트리거 — job_type=manual, 배치보다 높은 우선순위).
|
||||
반환: (status, message) — queued | duplicated | unavailable."""
|
||||
if not self.available():
|
||||
return "unavailable", "LPS 연동이 비활성 상태입니다(설정 없음)"
|
||||
payload = {
|
||||
"product_code": str(item.item_id),
|
||||
"product_name": item.name,
|
||||
"job_type": "manual",
|
||||
"model": item.model_name or "",
|
||||
"specification": item.spec or "",
|
||||
"company": item.manufacturer or "",
|
||||
"price": str(item.price) if item.price else "",
|
||||
}
|
||||
base = web_server_config.lps_base_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(f"{base}/v1/lps/search", json={"data": [payload]})
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
entry = (body.get("items") or [{}])[0]
|
||||
if entry.get("duplicated"):
|
||||
return "duplicated", "이미 검색이 진행 중입니다(활성 중복)"
|
||||
return "queued", "최저가 검색이 접수됐습니다"
|
||||
except Exception as ex:
|
||||
LOG.w(f"[lps-sync] 단건 검색요청 실패 item={item.item_id}: {type(ex).__name__}: {ex}")
|
||||
return "unavailable", "검색 서비스에 연결할 수 없습니다"
|
||||
|
||||
# ---- 조회 뷰 (lowest-price 조회 API 용) ------------------------------
|
||||
async def lowest_price_view(self, item_id, limit: int = 10) -> tuple:
|
||||
"""대표 최저가 + 최근 수집 이력. 조회 전에 증분 동기화를 한 번 돌려
|
||||
5분 크론을 기다리지 않고 최신 결과를 반영한다(멱등·증분이라 저비용).
|
||||
반환: (ErrorType, history_rows)."""
|
||||
if self.available():
|
||||
try:
|
||||
await self.sync_results()
|
||||
except Exception as ex: # 조회는 동기화 실패에도 계속(마지막 반영분이라도 보여준다)
|
||||
LOG.w(f"[lps-sync] 조회 전 동기화 실패(무시): {type(ex).__name__}: {ex}")
|
||||
return await DB_SESSION_MNG.execute_lambda(
|
||||
DBType.MAIN.value, DBWRType.DB_READ.value,
|
||||
lambda s: self.crud.recent_history(s, item_id, limit),
|
||||
)
|
||||
|
||||
# ---- ②+③ 수집·반영: price_history 증분 → 이력 append + 대표값 박제 --
|
||||
async def sync_results(self) -> Counter:
|
||||
async with _sync_lock: # 크론 잡과 온디맨드 조회의 프로세스 내 동시 실행 직렬화
|
||||
return await self._sync_results_locked()
|
||||
|
||||
async def _sync_results_locked(self) -> Counter:
|
||||
results = Counter()
|
||||
if not self.available():
|
||||
return results
|
||||
|
||||
8
postgres-init/alters/2026-07-10-iilp-unique-crawl.sql
Normal file
8
postgres-init/alters/2026-07-10-iilp-unique-crawl.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- 2026-07-10 · LPS 최저가 수집 이력 중복 방지
|
||||
-- partner.item_internet_lowest_prices 는 LPS 동기화(negodata 배치 + lowest-price API 의
|
||||
-- 온디맨드 동기화)가 채운다. 같은 수집분(item_id, crawl_end_time)이 동시 호출 경합으로
|
||||
-- 두 번 들어가지 않도록 유니크 인덱스를 건다 — 경합의 진 쪽 트랜잭션은 실패하고,
|
||||
-- 다음 tick 워터마크가 흡수한다(중복 0 보장).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_iilp_item_crawl_time
|
||||
ON partner.item_internet_lowest_prices (item_id, crawl_end_time)
|
||||
WHERE deleted = FALSE;
|
||||
@ -446,6 +446,8 @@ CREATE INDEX IF NOT EXISTS idx_sessions_end_time ON negotiation.sessions (e
|
||||
|
||||
-- 상품별 최신 크롤링 최저가 조회
|
||||
CREATE INDEX IF NOT EXISTS idx_iilp_item_crawl_time ON partner.item_internet_lowest_prices (item_id, crawl_end_time DESC) WHERE deleted = FALSE;
|
||||
-- [2026-07-10] LPS 동기화 중복 방지 — 같은 수집분(item_id, crawl_end_time)은 1행만(경합 시 진 쪽 tx 실패)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_iilp_item_crawl_time ON partner.item_internet_lowest_prices (item_id, crawl_end_time) WHERE deleted = FALSE;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user