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:
민헌 2026-07-10 16:17:05 +09:00
parent 523661e3d2
commit 28b4a9e797
8 changed files with 151 additions and 16 deletions

View File

@ -38,6 +38,9 @@ services:
RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요) RELOAD: "1" # uvicorn --reload 활성 → 소스 저장 시 자동 재기동(재빌드 불필요)
SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1 SCHEDULER_ENABLED: "1" # 마감 크론 활성(단일 워커라 중복 없음). 운영 다중 워커면 1개 프로세스에서만 1
PYTHONUNBUFFERED: "1" # 컨테이너 로그 실시간 출력(stdout 버퍼링 끔) 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: volumes:
- ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨 - ./negodata/backend:/app # 호스트 소스 = 컨테이너 코드. 이게 있어야 수정이 즉시 반영됨
ports: ports:

View File

@ -82,6 +82,10 @@ class ErrorType(Enum):
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요) EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공) EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
# LPS(인터넷 최저가 검색) 연동 관련 에러
LPS_UNAVAILABLE = 2000 # lps_db 미등록(연동 비활성 환경) — 기능 사용 불가
LPS_REQUEST_FAILED = auto() # LPS 검색요청 API 호출 실패(LPS 다운/네트워크)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name) EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)

View File

@ -46,6 +46,10 @@ class ILpsSyncCRUD(ABC):
async def fetch_results_since(self, ldb: AsyncSession, since) -> Tuple[ErrorType, list]: async def fetch_results_since(self, ldb: AsyncSession, since) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod
async def recent_history(self, cdb: AsyncSession, item_id, limit) -> Tuple[ErrorType, list]:
pass
@abstractmethod @abstractmethod
async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType: async def add_history_rows(self, cdb: AsyncSession, rows: list) -> ErrorType:
pass pass
@ -110,6 +114,23 @@ class LpsSyncCRUD(ILpsSyncCRUD):
LOG.e_no_callstack(f"[lps-sync] existing_item_ids 조회 실패: {ex}") LOG.e_no_callstack(f"[lps-sync] existing_item_ids 조회 실패: {ex}")
return ErrorType.DB_RUN_FAILED, set() 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(읽기전용) 조회 ----------------------------------------- # ---- lps_db(읽기전용) 조회 -----------------------------------------
async def fetch_results_since(self, ldb: AsyncSession, since): async def fetch_results_since(self, ldb: AsyncSession, since):
"""워터마크 이후의 price_history 증분. since 가 None 이면 전체(첫 동기화).""" """워터마크 이후의 price_history 증분. since 가 None 이면 전체(첫 동기화)."""

View File

@ -2,10 +2,13 @@ from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile from fastapi import APIRouter, Depends, File, Query, UploadFile
from common.enums import ErrorType
from common.models.gmodel import PageParams, UserInfo from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.item_service import ItemService from services.item_service import ItemService
from services.lps_sync_service import LpsSyncService
from .protocol import ( from .protocol import (
LowestPriceEntry,
Req_CheckCodes, Req_CheckCodes,
Req_CreateItem, Req_CreateItem,
Req_UpdateItem, 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)) 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="최저가 수집 요청(스텁)") @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)): async def trigger_lowest_price(
# 존재/소유 확인만 (없으면 result 에 ITEM_NOT_FOUND) 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)) got = await service.get_item(user_info.company_id, str(item_id))
if got.item is None: if got.item is None:
return RemoveNoneResponse(got) return RemoveNoneResponse(got)
return RemoveNoneResponse(Res_LowestPriceTrigger(item_id=str(item_id), status="queued", message="최저가 수집 요청됨(스텁)")) err, history = await lps.lowest_price_view(item_id)
res = Res_LowestPriceResult(
item_id=str(item_id),
@router.get(path="/{item_id}/lowest-price", response_model=Res_LowestPriceResult, summary="최저가 수집 결과(스텁)") lowest_price=got.item.internet_lowest_price,
async def get_lowest_price(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): results=[LowestPriceEntry.model_validate(h) for h in history],
got = await service.get_item(user_info.company_id, str(item_id)) message="" if history else "수집 이력이 없습니다",
if got.item is None: )
return RemoveNoneResponse(got) if err != ErrorType.SUCCESS:
return RemoveNoneResponse(Res_LowestPriceResult(item_id=str(item_id), results=[], message="최저가 수집 결과 없음(스텁)")) res.result.SetResult(err)
return RemoveNoneResponse(res)

View File

@ -126,11 +126,24 @@ class Res_ItemImage(Res_WebPacketProtocol):
class Res_LowestPriceTrigger(Res_WebPacketProtocol): class Res_LowestPriceTrigger(Res_WebPacketProtocol):
item_id: str = "" item_id: str = ""
status: str = "" status: str = "" # queued(접수) | duplicated(이미 진행 중) | unavailable(연동 비활성/LPS 다운)
message: str = "" 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): class Res_LowestPriceResult(Res_WebPacketProtocol):
item_id: str = "" item_id: str = ""
results: list = [] lowest_price: Optional[int] = None # 상품에 박제된 대표 최저가(items.internet_lowest_price)
results: list[LowestPriceEntry] = [] # 최근 수집 이력(최신순)
message: str = "" message: str = ""

View File

@ -13,6 +13,7 @@
- product_code 가 uuid 가 아니거나(item_id 아님 — 예: LPS 자체 부하테스트 잡) 상품이 없으면 스킵. - product_code 가 uuid 가 아니거나(item_id 아님 — 예: LPS 자체 부하테스트 잡) 상품이 없으면 스킵.
- 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다. - 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다.
""" """
import asyncio
import uuid import uuid
from collections import Counter from collections import Counter
from datetime import timedelta, timezone from datetime import timedelta, timezone
@ -41,9 +42,16 @@ def _utc_now_aware():
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# 온디맨드 동기화(조회 API)와 크론 잡의 동시 실행 직렬화(프로세스 내).
# 프로세스 간 경합은 uq_iilp_item_crawl_time 유니크 인덱스가 최종 방어(진 쪽 tx 실패 → 다음 tick 흡수).
_sync_lock = asyncio.Lock()
class LpsSyncService: class LpsSyncService:
def __init__(self, crud: ILpsSyncCRUD = None): # FastAPI Depends() 로도 쓰이므로 무인자 생성자(파라미터가 있으면 DI 가 의존성으로 해석해 부팅 실패).
self.crud = crud or LpsSyncCRUD() # 테스트는 인스턴스 생성 후 .crud 교체로 주입한다.
def __init__(self):
self.crud: ILpsSyncCRUD = LpsSyncCRUD()
@staticmethod @staticmethod
def available() -> bool: def available() -> bool:
@ -96,8 +104,56 @@ class LpsSyncService:
LOG.w(f"[lps-sync] 검색요청 실패(청크 {i // ENQUEUE_CHUNK}): {type(ex).__name__}: {ex}") LOG.w(f"[lps-sync] 검색요청 실패(청크 {i // ENQUEUE_CHUNK}): {type(ex).__name__}: {ex}")
return results 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 + 대표값 박제 -- # ---- ②+③ 수집·반영: price_history 증분 → 이력 append + 대표값 박제 --
async def sync_results(self) -> Counter: 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() results = Counter()
if not self.available(): if not self.available():
return results return results

View 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;

View File

@ -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; 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;
-- ============================================================ -- ============================================================