Merge branch 'feature/negodata-lps' — negodata↔LPS 인터넷 최저가 연동(수동 트리거 전용)

This commit is contained in:
민헌 2026-07-10 16:49:49 +09:00
commit 1cce752b47
24 changed files with 700 additions and 66 deletions

View File

@ -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:

View File

@ -9,7 +9,7 @@ from common.database.model.models import MAIN_BASE
from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG
from common.singleton import Singleton
from config.server_configs import main_db_config
from config.server_configs import main_db_config, lps_db_config
class DBSessionManager(Singleton):
@ -47,6 +47,12 @@ class DBSessionManager(Singleton):
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value),
}
# LPS 결과 DB(lps_db) — **읽기전용**: read 엔진만 등록한다(write 로 열면 KeyError = 앱 차원 가드).
# config 의 name 이 비어 있으면 미사용(스키마 없는 환경에서도 부팅 가능, 배치는 is_registered 로 스킵).
if lps_db_config.name:
self.__db_type_map[DBType.LPS.value] = lps_db_config
self.__read_session[DBType.LPS.value] = self.create_engine(DBType.LPS.value, DBWRType.DB_READ.value)
def create_engine(self, db_type: int, db_wr_type: int):
db_config = self.__db_type_map.get(db_type)
if not db_config:
@ -83,6 +89,10 @@ class DBSessionManager(Singleton):
)
return scoped_session
def is_registered(self, db_type: int) -> bool:
"""해당 논리 DB 가 등록돼 있는지 — 선택 연동(LPS 등)의 배치가 실행 전 확인하는 용도."""
return db_type in self.__db_type_map
async def dispose_all(self):
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.

View File

@ -111,6 +111,26 @@ class items(MainTableMixin, MAIN_BASE):
delivery_fee_yn = Column(Boolean, nullable=True)
class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
"""인터넷 최저가 수집 이력(시도 단위, 성공/실패 모두 기록).
확정 대표값은 items.internet_lowest_price 에 박제된다(N건 이력 → 1건 대표 패턴).
LPS 동기화 배치(services/lps_sync_service)가 lps_db.price_history 를 읽어 채운다."""
__tablename__ = "item_internet_lowest_prices"
__table_args__ = {"schema": "partner"}
lp_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
item_id = Column(UUID(as_uuid=True), nullable=False, index=True)
lp_price = Column(BigInteger, nullable=True) # 수집한 최저가(원). 실패면 NULL
website = Column(SmallInteger, nullable=False) # LowestPriceWebsite 코드
success_yn = Column(Boolean, nullable=False)
fail_reason = Column(String(100), nullable=True) # 실패 사유(예: not_found)
ai_model = Column(SmallInteger, nullable=True) # (예약) AI 모델 코드 — LPS 계약엔 미포함
crawl_duration_ms = Column(Integer, nullable=True) # (예약) 수집 소요 — LPS 계약엔 미포함
crawl_end_time = Column(DateTime(timezone=True), nullable=False) # 수집 완료 시각(=price_history.created_at, 워터마크 기준)
class suppliers(MainTableMixin, MAIN_BASE):
__tablename__ = "suppliers"
__table_args__ = {"schema": "partner"}

View File

@ -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)
@ -99,6 +103,7 @@ class DBType(Enum):
"""
MAIN = 1
LPS = 2 # 인터넷 최저가 검색(lps_db) — 읽기전용(price_history 동기화 배치용, write 엔진 미등록)
class DBWRType(Enum):
@ -264,6 +269,25 @@ class SupplierType(CodeEnum):
SOLE_AGENCY = 3 # 총판
class LowestPriceWebsite(CodeEnum):
"""partner.item_internet_lowest_prices.website — 최저가 수집 사이트 코드.
LPS(lps_db.price_history.final_source)의 소스 문자열을 코드값으로 매핑한다."""
NAVER = 1
COUPANG = 2
GMARKET = 3
AUCTION = 4
ST11 = 5
ETC = 99
@classmethod
def from_source(cls, source) -> "LowestPriceWebsite":
return {
"naver": cls.NAVER, "coupang": cls.COUPANG,
"gmarket": cls.GMARKET, "auction": cls.AUCTION, "st11": cls.ST11,
}.get((source or "").lower(), cls.ETC)
class CardUsageType(CodeEnum):
"""nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
공통=모두 적용(기본), 신규견적전용, 재견적전용."""

View File

@ -32,6 +32,20 @@ pool_size = 10
max_overflow = 20
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
# 인터넷 최저가 검색(LPS) 결과 DB — 읽기전용(price_history 동기화 배치).
# name 을 비우면 LPS 연동 비활성(엔진 미등록·배치 스킵). 도커는 LPS_DB_HOST 등 env 로 override.
[LpsDBConfig]
db_type = "postgresql"
name = "lps_db"
read_host = "127.0.0.1"
read_port = 5432
read_id = "postgres"
read_pw = "postgres"
show_log = false
pool_size = 2
max_overflow = 2
sslmode = ""
[JwtToken]
access_key = "<JWT_ACCESS_SECRET>"
refresh_key = "<JWT_REFRESH_SECRET>"

View File

@ -10,6 +10,7 @@ class WebServerConfig(ConfigModel):
client_url: str = ""
nego_chat_url: str = "http://localhost:3300"
agent_base_url: str = "http://localhost:9500" # 협상 agent(9500). 공용 카탈로그 변경 알림용.
lps_base_url: str = "http://localhost:9600" # 인터넷 최저가 검색 LPS(9600). 검색요청 enqueue 용.
class LogConfig(ConfigModel):
@ -39,6 +40,23 @@ class MainDBConfig(ConfigModel):
sslmode: str = ""
# 인터넷 최저가 검색(LPS)의 결과 DB(lps_db) — **읽기전용** 접속.
# LPS 는 별도 서비스(자체 스키마 소유)이고, negodata 는 price_history 를 주기 배치로 읽어만 온다.
# 그래서 read_* 만 둔다(write 엔진 미등록 = 앱 차원 읽기전용 강제). name 이 비면 미사용(등록 스킵).
class LpsDBConfig(ConfigModel):
db_type: str = "postgresql"
name: str = "" # 비우면 LPS 연동 비활성(엔진 미등록, 배치 스킵)
read_host: str = ""
read_port: int = 5432
read_id: str = ""
read_pw: str = ""
show_log: bool = False
# 배치 전용이라 작은 풀이면 충분 (동시 사용처 = 스케줄러 잡 1개)
pool_size: int = 2
max_overflow: int = 2
sslmode: str = ""
class JwtToken(ConfigModel):
access_key: str = ""
refresh_key: str = ""

View File

@ -1,7 +1,7 @@
import os
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig, MailConfig
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, LpsDBConfig, JwtToken, StorageConfig, MailConfig
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
@ -18,6 +18,8 @@ configs = Configs(_config_file)
web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
# [LpsDBConfig] 섹션이 없는 toml(구버전)에서도 죽지 않게 기본값 폴백(name 빈 값 → LPS 연동 비활성).
lps_db_config: LpsDBConfig = configs.get(LpsDBConfig) or LpsDBConfig()
jwt_token_config: JwtToken = configs.get(JwtToken)
storage_config: StorageConfig = configs.get(StorageConfig)
# [MailConfig] 섹션이 없는 toml(구버전)에서도 죽지 않도록 기본값으로 폴백(전 필드 빈 값 → 발송 시 EmailUnavailable).
@ -40,3 +42,24 @@ def _apply_db_env_override(cfg: MainDBConfig):
_apply_db_env_override(main_db_config)
# LPS 결과 DB(읽기전용) env override — 도커에서 host 등만 교체. 로컬은 toml 그대로.
def _apply_lps_db_env_override(cfg: LpsDBConfig):
if os.environ.get("LPS_DB_HOST"):
cfg.read_host = os.environ["LPS_DB_HOST"]
if os.environ.get("LPS_DB_PORT"):
cfg.read_port = int(os.environ["LPS_DB_PORT"])
if os.environ.get("LPS_DB_USER"):
cfg.read_id = os.environ["LPS_DB_USER"]
if os.environ.get("LPS_DB_PASSWORD"):
cfg.read_pw = os.environ["LPS_DB_PASSWORD"]
if os.environ.get("LPS_DB_NAME"):
cfg.name = os.environ["LPS_DB_NAME"]
_apply_lps_db_env_override(lps_db_config)
# LPS API 주소 env override (도커: http://lps-api:9600 또는 host.docker.internal:9600)
if os.environ.get("LPS_BASE_URL"):
web_server_config.lps_base_url = os.environ["LPS_BASE_URL"]

View File

@ -0,0 +1,134 @@
"""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, 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 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 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}")

View File

@ -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)

View File

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

View File

@ -4,6 +4,11 @@
잡 ① close_expired_quotations : 5분마다(KST) — 마감시각 지난 견적 마감
잡 ② close_negotiated_quotations: 5분마다(KST) — 모든 세션 협상 끝난 견적 즉시 마감(타입 무관)
잡 ③ sync_lps_results : 5분마다(KST) — lps_db 결과 증분 수집 → 이력 append + 상품 최저가 박제
(수동 트리거된 검색의 반영 백스톱 — 크롤을 일으키지 않음, 비용 0)
LPS 검색 **요청**은 배치로 돌리지 않는다(2026-07-10 협의) — 상품 화면의 수동 트리거
(POST /v1/item/{id}/lowest-price)로만 검색한다. 크롤 비용이 사용자 행동에만 비례하게.
"""
import os
@ -51,8 +56,17 @@ def start_scheduler():
misfire_grace_time=600,
max_instances=1,
)
# 잡 ③ LPS 결과 수집·반영 — 증분·멱등이라 잦아도 안전. 크롤 요청은 하지 않는다(수동 트리거 전용).
_scheduler.add_job(
jobs.sync_lps_results,
CronTrigger(minute="*/5"),
id="sync_lps_results",
coalesce=True,
misfire_grace_time=600,
max_instances=1,
)
_scheduler.start()
LOG.i("[scheduler] started (KST, both every 5min)")
LOG.i("[scheduler] started (KST: 견적마감 2잡 5분 · LPS 수집 5분 — LPS 요청 배치 없음, 수동 트리거 전용)")
def shutdown_scheduler():

View File

@ -79,3 +79,24 @@ async def close_negotiated_quotations() -> int:
if results:
LOG.i(f"[scheduler] close_negotiated: {_format_results(results)}")
return sum(results.values())
# ---- LPS(인터넷 최저가) 동기화 ------------------------------------------
async def sync_lps_results() -> int:
"""[잡③] lps_db.price_history 증분을 읽어 수집 이력 append + 상품 대표 최저가 박제. 5분마다.
검색 **요청**은 하지 않는다(수동 트리거 전용, 2026-07-10 협의) — 이 잡은 반영 백스톱.
워터마크(=이력의 max crawl_end_time) 기준 증분이라 멱등 — 실패 tick 은 다음 tick 이 흡수."""
from services.lps_sync_service import LpsSyncService
service = LpsSyncService()
if not service.available():
return 0
results = await service.sync_results()
if results:
LOG.i(
f"[scheduler] lps_sync: found {results['found']} / not_found {results['not_found']} / "
f"상품반영 {results['items_updated']} / 비uuid스킵 {results['skipped_not_uuid']} / "
f"미존재상품 {results['skipped_unknown_item']} / 트랜잭션오류 {results['tx_error']}"
)
return results["items_updated"]

View File

@ -0,0 +1,176 @@
"""LPS(인터넷 최저가 검색) 동기화 서비스 — 요청·수집·반영의 도메인 로직.
흐름 (요청은 API, 결과는 DB — 2026-07-10 설계 결정)
① 요청: **수동 트리거 전용**(상품 화면 → POST /v1/item/{id}/lowest-price → 여기의
request_search_for_item). 자동 요청 배치는 두지 않는다(2026-07-10 협의 — 크롤 비용이
사용자 행동에만 비례하게). product_code = items.item_id(uuid 문자열) — 이걸로 결과가
자동 매핑된다. enqueue 를 DB insert 로 하지 않는 이유: LPS 의 활성중복 dedupe·pg_notify
워커 깨움을 우회하게 됨.
② 수집: lps_db.price_history(읽기전용)를 워터마크(max crawl_end_time) 증분으로 읽는다.
(5분 크론 + 조회 API 의 온디맨드 — 트리거된 검색의 반영 백스톱, 크롤 비용 0)
③ 반영: 이력은 partner.item_internet_lowest_prices 에 append(성공/실패 모두),
성공분의 상품별 최신값을 items.internet_lowest_price 에 박제(+yn).
방어
- LPS 미설정 환경(lps_db 미등록)이면 조용히 스킵 — 연동은 선택 기능, 본 서비스를 못 멈춘다.
- product_code 가 uuid 가 아니거나(item_id 아님 — 예: LPS 자체 부하테스트 잡) 상품이 없으면 스킵.
- 반영은 한 트랜잭션(execute_lambda_run) — 부분 반영으로 워터마크가 오염되지 않는다.
"""
import asyncio
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
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)
# 온디맨드 동기화(조회 API)와 크론 잡의 동시 실행 직렬화(프로세스 내).
# 프로세스 간 경합은 uq_iilp_item_crawl_time 유니크 인덱스가 최종 방어(진 쪽 tx 실패 → 다음 tick 흡수).
_sync_lock = asyncio.Lock()
class LpsSyncService:
# FastAPI Depends() 로도 쓰이므로 무인자 생성자(파라미터가 있으면 DI 가 의존성으로 해석해 부팅 실패).
# 테스트는 인스턴스 생성 후 .crud 교체로 주입한다.
def __init__(self):
self.crud: ILpsSyncCRUD = LpsSyncCRUD()
@staticmethod
def available() -> bool:
"""LPS 연동 활성 여부 — lps_db 가 등록된 환경에서만 배치가 돈다."""
return DB_SESSION_MNG.is_registered(DBType.LPS.value)
# ---- 단건 즉시 요청 (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
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

View File

@ -644,7 +644,8 @@ export const useDeleteItem = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 최저가 수집 요청(스텁)
* 상품 1건을 LPS 에 즉시 검색 요청(수동 트리거, manual 우선순위). 결과는 GET lowest-price 폴링.
* @summary 최저가 수집 요청
*/
export const triggerLowestPrice = (
itemId: string,
@ -690,7 +691,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
export type TriggerLowestPriceMutationError = void | HTTPValidationError
/**
* @summary 최저가 수집 요청(스텁)
* @summary 최저가 수집 요청
*/
export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof triggerLowestPrice>>, TError,{itemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
@ -706,7 +707,9 @@ export const useTriggerLowestPrice = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 최저가 수집 결과(스텁)
* 대표 최저가(items.internet_lowest_price) + 최근 수집 이력(최신순).
조회 전에 lps_db 증분 동기화를 한 번 수행해 5분 크론을 기다리지 않는다(멱등·저비용).
* @summary 최저가 수집 결과
*/
export const getLowestPrice = (
itemId: string,
@ -777,7 +780,7 @@ export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPri
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 최저가 수집 결과(스텁)
* @summary 최저가 수집 결과
*/
export function useGetLowestPrice<TData = Awaited<ReturnType<typeof getLowestPrice>>, TError = void | HTTPValidationError>(

View File

@ -78,6 +78,10 @@ export * from './listNotificationsParams';
export * from './listQuotationsParams';
export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './lowestPriceEntry';
export * from './lowestPriceEntryCrawlEndTime';
export * from './lowestPriceEntryFailReason';
export * from './lowestPriceEntryLpPrice';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';
@ -278,6 +282,7 @@ export * from './resItemSupplyTypeListMsg';
export * from './resLogin';
export * from './resLoginMsg';
export * from './resLowestPriceResult';
export * from './resLowestPriceResultLowestPrice';
export * from './resLowestPriceResultMsg';
export * from './resLowestPriceTrigger';
export * from './resLowestPriceTriggerMsg';

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { LowestPriceEntryLpPrice } from './lowestPriceEntryLpPrice';
import type { LowestPriceEntryFailReason } from './lowestPriceEntryFailReason';
import type { LowestPriceEntryCrawlEndTime } from './lowestPriceEntryCrawlEndTime';
/**
* 최저가 수집 이력 1건(partner.item_internet_lowest_prices).
*/
export interface LowestPriceEntry {
lp_price?: LowestPriceEntryLpPrice;
website?: number;
success_yn?: boolean;
fail_reason?: LowestPriceEntryFailReason;
crawl_end_time?: LowestPriceEntryCrawlEndTime;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntryCrawlEndTime = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntryFailReason = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type LowestPriceEntryLpPrice = number | null;

View File

@ -6,11 +6,14 @@
*/
import type { ErrorInfo } from './errorInfo';
import type { ResLowestPriceResultMsg } from './resLowestPriceResultMsg';
import type { ResLowestPriceResultLowestPrice } from './resLowestPriceResultLowestPrice';
import type { LowestPriceEntry } from './lowestPriceEntry';
export interface ResLowestPriceResult {
result?: ErrorInfo;
msg?: ResLowestPriceResultMsg;
item_id?: string;
results?: unknown[];
lowest_price?: ResLowestPriceResultLowestPrice;
results?: LowestPriceEntry[];
message?: string;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ResLowestPriceResultLowestPrice = number | null;

View File

@ -1,10 +1,12 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { useScrollLock } from '@/lib/useScrollLock';
import { triggerLowestPrice, getLowestPrice } from '@/api/generated/item/item';
import type { Product } from '../types';
type PriceUpdateModalProps = {
@ -15,62 +17,113 @@ type PriceUpdateModalProps = {
onClose: () => void;
};
// 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다.
// NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모.
const POLL_INTERVAL_MS = 5_000; // GET lowest-price 폴링 간격(서버가 조회 시 lps_db 증분 동기화를 겸함)
const POLL_TIMEOUT_MS = 300_000; // 상품당 수십 초 × 순차 처리 감안한 전체 상한(5분)
// 인터넷 최저가 실시간 수집 모달 — LPS 연동.
// 흐름: 선택 상품마다 POST(수집 요청, 큐 접수) → GET 폴링(요청 시각 이후의 수집 이력이 생기면 완료).
// 폴링이 시간을 초과해도 서버 검색은 계속되고, 5분 주기 동기화 배치가 결과를 자동 반영한다.
export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const queryClient = useQueryClient();
const [isCrawling, setIsCrawling] = useState(false);
const [crawlingProgress, setCrawlingProgress] = useState(0);
const [crawlerLogs, setCrawlerLogs] = useState<string[]>([]);
const cancelledRef = useRef(false); // 닫기 시 폴링 루프 중단(서버 검색은 계속)
if (!open) return null;
const handleStartCrawling = () => {
const pushLog = (line: string) => setCrawlerLogs((prev) => [...prev, line]);
const nameOf = (id: string) => products.find((p) => p.item_id === id)?.name || id;
const handleStartCrawling = async () => {
if (selectedIds.length === 0) {
toast.error('업데이트할 상품을 1개 이상 선택해 주십시오.');
return;
}
cancelledRef.current = false;
setIsCrawling(true);
setCrawlingProgress(10);
setCrawlingProgress(5);
setCrawlerLogs([
'[System] 실시간 최저가 수집용 웹 크롤러 엔진 가동...',
'[System] API Endpoint: https://api.commerce-crawler.co.kr/v2/itemsync',
`[Target] 선택된 ${selectedIds.length}개 상품의 고유 코드 및 품목 매핑 중...`,
'[System] 인터넷 최저가 검색(LPS) 요청 접수 중...',
`[Target] 선택된 ${selectedIds.length}개 상품`,
]);
const startedAt = new Date().toISOString(); // 이 시각 이후의 수집 이력만 "이번 요청 결과"로 인정
// Fast simulation timers
setTimeout(() => {
setCrawlingProgress(35);
setCrawlerLogs((prev) => [
...prev,
...selectedIds.map((id) => {
const p = products.find((prod) => prod.item_id === id);
return `[크롤링] '${p?.name || id}' 인터넷 최저가 비교 검색 수집 진행`;
}),
`[Search] 외부 커머스 유통 플랫폼(Coupang, Gmarket, Danawa) 지표 추출 시작...`,
]);
}, 500);
// 1) 상품별 수집 요청(POST) — 실패/중복은 로그로 구분하고 계속 진행
const pending = new Set<string>();
for (const id of selectedIds) {
try {
const r = await triggerLowestPrice(id);
if (r.status === 'queued') {
pending.add(id);
pushLog(`[접수] '${nameOf(id)}' 검색 큐 등록`);
} else if (r.status === 'duplicated') {
pending.add(id); // 이미 진행 중 → 결과는 폴링으로 같이 받는다
pushLog(`[진행중] '${nameOf(id)}' 이미 검색이 진행 중 — 결과 대기에 합류`);
} else {
pushLog(`[불가] '${nameOf(id)}' ${r.message || '검색 서비스 연결 불가'}`);
}
} catch {
pushLog(`[오류] '${nameOf(id)}' 요청 실패`);
}
}
setTimeout(() => {
setCrawlingProgress(72);
setCrawlerLogs((prev) => [
...prev,
`[OCR] 수집 완료된 실시간 HTML/DOM 가격 노드 데이터 분석 중...`,
`[Sync] 정제 단가 적용 (부가세 보정 및 할인 쿠폰 혜택가 산정)`,
]);
}, 1100);
if (pending.size === 0) {
showToast('접수된 상품이 없습니다. 검색 서비스 상태를 확인해 주세요.', 'error');
setIsCrawling(false);
setCrawlingProgress(0);
return;
}
setCrawlingProgress(15);
pushLog(`[Search] ${pending.size}개 상품 크롤링 진행 — 네이버·쿠팡 수집 및 AI 동일상품 판정...`);
setTimeout(() => {
// 2) 폴링 — GET 이 서버측 증분 동기화를 겸함. 요청 시각 이후 이력이 생긴 상품부터 완료 처리.
const total = pending.size;
let found = 0;
let notFound = 0;
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (pending.size > 0 && Date.now() < deadline && !cancelledRef.current) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
for (const id of [...pending]) {
try {
const r = await getLowestPrice(id);
const fresh = (r.results ?? []).find((e) => (e.crawl_end_time ?? '') >= startedAt);
if (!fresh) continue;
pending.delete(id);
if (fresh.success_yn && fresh.lp_price != null) {
found += 1;
pushLog(`[완료] '${nameOf(id)}' 최저가 ${fresh.lp_price.toLocaleString()}원 반영`);
} else {
notFound += 1;
pushLog(`[미발견] '${nameOf(id)}' 동일 상품을 찾지 못함(기존 값 유지)`);
}
} catch {
/* 일시 오류는 다음 tick 재시도 */
}
setCrawlingProgress(15 + Math.round(((total - pending.size) / total) * 85));
}
}
// 3) 마무리 — 목록 갱신(테이블 인터넷 최저가 컬럼 반영) 후 종료
const timedOut = pending.size > 0 && !cancelledRef.current;
if (timedOut) {
pushLog(`[대기초과] ${pending.size}개 상품은 아직 검색 중 — 완료되면 주기 동기화로 자동 반영됩니다.`);
}
await queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] });
setCrawlingProgress(100);
// NOTE: 인터넷 최저가 동기화는 전용 엔드포인트 연동 예정. 현재는 진행 애니메이션만 데모(서버 미반영).
showToast(`선택한 ${selectedIds.length}개 상품의 인터넷 최저가 동기화는 준비 중입니다(데모).`, 'info');
if (!cancelledRef.current) {
showToast(
`최저가 수집 완료: 반영 ${found} · 미발견 ${notFound}${timedOut ? ` · 검색중 ${pending.size}(자동 반영 예정)` : ''}`,
found > 0 ? 'success' : 'info',
);
}
onDone();
setIsCrawling(false);
onClose();
setCrawlingProgress(0);
setCrawlerLogs([]);
}, 2000);
};
return (
@ -81,7 +134,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2 text-foreground">
<Globe className="text-rose-500 animate-pulse" size={18} />
<Typography variant="h3">인터넷 최저가 API 실시간 수집 및 동기화</Typography>
<Typography variant="h3">인터넷 최저가 실시간 수집 및 동기화</Typography>
</div>
{!isCrawling && (
<button
@ -99,7 +152,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<Typography variant="small" className="text-[11.5px] leading-relaxed text-left flex items-start gap-2">
<AlertCircle size={15} className="text-rose-500 shrink-0 mt-0.5" />
<span>
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 오픈마켓, 다나와, 쿠팡 및 B2B 공공 유통망의 최저가 데이터를 수집 및 비교 분석하여 최신 최저가(minPrice) 필드로 다이렉트 동기화합니다.
총 <span className="font-bold text-rose-600 dark:text-rose-400 underline decoration-rose-500/50 decoration-2">{selectedIds.length}개</span> 품목에 대하여 네이버 쇼핑·쿠팡의 최저가를 수집하고 AI 가 동일 상품을 판정하여 인터넷 최저가 필드로 동기화합니다. 상품당 수십 초가 소요될 수 있습니다.
</span>
</Typography>
</div>
@ -116,7 +169,9 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex items-center gap-1.5 font-mono">
<span className="text-muted-foreground">{(prod.price ?? 0).toLocaleString()}원</span>
<span className="text-muted-foreground">→</span>
<span className="text-rose-500 font-bold">자동 가공 수집</span>
<span className="text-rose-500 font-bold">
{prod.internet_lowest_price != null ? `${Number(prod.internet_lowest_price).toLocaleString()}원 갱신` : '신규 수집'}
</span>
</div>
</div>
) : null;
@ -130,7 +185,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="flex justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5 font-bold text-foreground">
<Loader2 size={13} className="animate-spin text-rose-500" />
실시간 크롤링 엔진 데이터 동기화 동작 중...
최저가 검색·수집 진행 중... (닫아도 검색은 계속됩니다)
</span>
<span className="font-bold text-rose-500">{crawlingProgress}%</span>
</div>
@ -148,7 +203,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
{crawlerLogs.map((log, idx) => (
<div key={idx} className="flex items-start gap-1">
<span className="text-zinc-600 select-none shrink-0">&gt;</span>
<span className={`text-left break-all ${log.includes('[System]') ? 'text-zinc-400 font-bold' : log.includes('[OCR]') ? 'text-blue-400' : 'text-emerald-400'}`}>
<span className={`text-left break-all ${log.includes('[System]') || log.includes('[Search]') ? 'text-zinc-400 font-bold' : log.includes('[완료]') ? 'text-emerald-400' : log.includes('[미발견]') || log.includes('[불가]') || log.includes('[오류]') || log.includes('[대기초과]') ? 'text-amber-400' : 'text-rose-300'}`}>
{log}
</span>
</div>
@ -159,7 +214,7 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
<div className="text-center py-4 border border-dashed border-border rounded bg-muted/10 space-y-2">
<Cpu size={24} className="mx-auto text-muted-foreground/60" />
<Typography variant="muted" className="text-[11px]">
"최저가 업데이트 시작" 버튼을 누르시면 실시간 스크래핑 엔진이 시작됩니다.
"인터넷 최저가 가동" 버튼을 누르시면 실시간 수집이 시작됩니다.
</Typography>
</div>
)}
@ -167,7 +222,15 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose
{/* Modal Footer */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button type="button" variant="outline" size="sm" disabled={isCrawling} onClick={onClose}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
cancelledRef.current = true; // 진행 중이면 폴링만 중단(서버 검색은 계속 → 주기 동기화로 반영)
onClose();
}}
>
닫기
</Button>
<Button type="button" variant="destructive" size="sm" disabled={isCrawling} onClick={handleStartCrawling}>

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