몰이 열린 집합이라 와이드 컬럼(gmarket_*, st11_* …) 대신 JSONB 한 컬럼으로 담는다 — 몰 추가 시 마이그레이션 0. naver/coupang/final 3선 컬럼은 그래프 하위호환 유지. - models: price_history.by_mall JSONB 추가 - crud: record/list 에 by_mall 왕복(json.dumps + CAST jsonb) - handler: _price_snapshot 에 summarize_by_mall 적재, final 은 소스무관 전체 최저로 - protocol/service: history API 응답에 by_mall 노출 - migrations/2026-07-09: 기존 dev DB 동기화용 ALTER(추적 파일) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
68 lines
2.9 KiB
Python
68 lines
2.9 KiB
Python
"""LPS API 요청/응답 프로토콜. 커머스→오투오 검색요청 계약을 미러링한다."""
|
|
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
|
|
|
|
|
|
class SearchItem(BaseModel):
|
|
"""검색 대상 상품 1건. 규격/모델/제조사는 매칭(향후 AI 유사도) 입력으로 함께 적재한다."""
|
|
|
|
product_code: str = Field(description="상품 식별 코드(커머스 기준). dedupe 키로도 사용")
|
|
product_name: str = Field(description="상품명")
|
|
job_type: str = Field("manual", description="요청 유형(우선순위): new_product|manual|partner|batch")
|
|
model: str = Field("", description="모델명")
|
|
specification: str = Field("", description="규격(용량/개입/수량 등)")
|
|
company: str = Field("", description="제조사/브랜드")
|
|
price: str = Field("", description="현재가(참고, 문자열)")
|
|
|
|
|
|
class Req_Search(Req_WebPacketProtocol):
|
|
data: list[SearchItem] = Field(description="검색 대상 상품 리스트")
|
|
|
|
|
|
class EnqueuedItem(BaseModel):
|
|
product_code: str
|
|
job_id: Optional[str] = Field(None, description="적재된 잡 ID. 활성 중복이면 None")
|
|
duplicated: bool = Field(False, description="활성 중복(PENDING/RUNNING)이라 스킵됐는지")
|
|
|
|
|
|
class Res_Search(Res_WebPacketProtocol):
|
|
accepted: int = Field(0, description="새로 적재된 잡 수(중복 제외)")
|
|
items: list[EnqueuedItem] = Field(default_factory=list)
|
|
|
|
|
|
class Res_JobStatus(Res_WebPacketProtocol):
|
|
job_id: Optional[str] = None
|
|
status: Optional[str] = Field(None, description="JobStatus 이름(PENDING/RUNNING/DONE/DEAD)")
|
|
attempts: Optional[int] = None
|
|
max_attempts: Optional[int] = None
|
|
output: Optional[dict] = Field(None, description="잡 결과(완료 시). 봉투 result 와 구분")
|
|
last_error: Optional[str] = None
|
|
|
|
|
|
class Res_QueueStats(Res_WebPacketProtocol):
|
|
counts: dict[str, int] = Field(default_factory=dict, description="상태별 잡 개수")
|
|
|
|
|
|
class PricePoint(BaseModel):
|
|
triggered_at: str = Field(description="관측 시각(X축)")
|
|
outcome: str
|
|
matched_count: Optional[int] = None
|
|
naver: Optional[int] = Field(None, description="네이버 최저가")
|
|
coupang: Optional[int] = Field(None, description="쿠팡 최저가")
|
|
final: Optional[int] = Field(None, description="전체 최저가(Y축)")
|
|
final_source: Optional[str] = None
|
|
naver_name: Optional[str] = None
|
|
naver_url: Optional[str] = None
|
|
coupang_name: Optional[str] = None
|
|
coupang_url: Optional[str] = None
|
|
by_mall: Optional[list[dict]] = Field(None, description="몰별 최저가 스냅샷(G마켓·옥션·11번가 등 포함)")
|
|
|
|
|
|
class Res_PriceHistory(Res_WebPacketProtocol):
|
|
product_code: Optional[str] = None
|
|
points: list[PricePoint] = Field(default_factory=list, description="시각 오름차순 스냅샷(그래프용)")
|