같은 상품 반복 검색 시 최저가를 스냅샷으로 적재 → 네이버/쿠팡/최종 3개 선 그래프.
배치 아님(조회된 상품만, 실제 검색 시각에 기록) — 트래픽/리소스 절약.
- price_history 테이블: product_code·triggered_at(X축)·naver/coupang/final 최저가+상세·outcome
- crud/price_history: record() + list_by_product(시각 오름차순)
- handler: AI 매칭 후 소스별 min + 전체 min 스냅샷 기록(_price_snapshot).
found/not_found 기록, 네거티브 캐시 히트·기술실패는 미기록
- API: GET /v1/lps/products/{product_code}/history → 그래프 데이터(시각 오름차순)
- tests: 스냅샷 계산/기록·조회/핸들러 기록규칙/API → 전체 54/54
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
7.5 KiB
Python
125 lines
7.5 KiB
Python
from sqlalchemy import Boolean, Column, Index, Integer, SmallInteger, String, Text, DateTime
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
from sqlalchemy.orm import declarative_base
|
|
from sqlalchemy.sql import text
|
|
|
|
from common.enums import DBType
|
|
|
|
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
|
MAIN_BASE = declarative_base()
|
|
|
|
|
|
class job(MAIN_BASE):
|
|
"""작업 큐. PostgreSQL 을 '제대로' 큐로 쓴다 — 원자적 CAS claim + lease 소유권 + dead-letter.
|
|
코드값(status/type)은 SMALLINT 정수 코드(common.enums 매핑), 시각은 전 구간 TIMESTAMPTZ, 무 FK.
|
|
"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "job"
|
|
|
|
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
job_type = Column(SmallInteger, nullable=False) # JobType
|
|
status = Column(SmallInteger, nullable=False, server_default=text("1")) # JobStatus (1=PENDING)
|
|
priority = Column(SmallInteger, nullable=False, server_default=text("100")) # 낮을수록 우선
|
|
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
|
|
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
|
|
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
|
|
attempts = Column(SmallInteger, nullable=False, server_default=text("0")) # 시도 횟수(claim 시 +1)
|
|
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"))
|
|
run_after = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # 이 시각 이후에만 claim(백오프)
|
|
lease_until = Column(DateTime(timezone=True), nullable=True) # 소유권 임대 만료(reaper 회수 기준)
|
|
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
|
|
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각(할당시각과 분리)
|
|
last_error = Column(Text, nullable=True)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"), onupdate=text("now()"))
|
|
|
|
__table_args__ = (
|
|
# claim 정렬/필터용: PENDING 중 run_after 지난 것을 priority·생성순으로
|
|
Index("ix_job_claim", "status", "run_after", "priority", "created_at"),
|
|
# reaper: 만료된 RUNNING lease 회수용
|
|
Index("ix_job_lease", "status", "lease_until"),
|
|
# 활성 중복 방지: 같은 dedupe_key 는 PENDING/RUNNING 중 하나만 존재 가능
|
|
Index(
|
|
"uq_job_dedupe_active",
|
|
"dedupe_key",
|
|
unique=True,
|
|
postgresql_where=text("status IN (1, 2) AND dedupe_key IS NOT NULL"),
|
|
),
|
|
)
|
|
|
|
|
|
class search_negative(MAIN_BASE):
|
|
"""네거티브 캐시 — '검색해도 없더라'를 TTL 동안 기억해 재검색 낭비를 막는다.
|
|
until 이 지나면 자동 무효(재도전 허용 — 나중에 입고될 수 있으므로)."""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "search_negative"
|
|
|
|
key = Column(String(300), primary_key=True) # 보통 product_code(없으면 query)
|
|
until = Column(DateTime(timezone=True), nullable=False) # 이 시각까지 not_found 로 간주
|
|
reason = Column(String(200), nullable=True) # 종료 사유 메모(관측)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
|
|
|
|
class price_history(MAIN_BASE):
|
|
"""상품별 최저가 스냅샷(트리거 기반). 네이버/쿠팡/최종 최저가를 검색 시점마다 적재해
|
|
시계열 그래프(X=triggered_at, Y=가격, 3개 선)로 본다. 배치 아님 — 조회된 상품만 기록."""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "price_history"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
product_code = Column(String(100), nullable=False) # 상품 식별(조회 키)
|
|
job_id = Column(UUID(as_uuid=True), nullable=True) # 검색 잡 연결(추적)
|
|
triggered_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()")) # X축(검색 실행 시각)
|
|
outcome = Column(String(20), nullable=False) # found / not_found
|
|
matched_count = Column(Integer, nullable=True) # AI 매칭 건수
|
|
|
|
naver_lowest = Column(Integer, nullable=True) # 네이버 최저가(같은 상품)
|
|
naver_name = Column(String(300), nullable=True)
|
|
naver_url = Column(Text, nullable=True)
|
|
coupang_lowest = Column(Integer, nullable=True) # 쿠팡 최저가(같은 상품)
|
|
coupang_name = Column(String(300), nullable=True)
|
|
coupang_url = Column(Text, nullable=True)
|
|
final_lowest = Column(Integer, nullable=True) # 전체 최저가(Y축 핵심)
|
|
final_source = Column(String(20), nullable=True) # 최종 최저가 소스
|
|
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
|
|
|
__table_args__ = (
|
|
# 특정 상품 시계열 조회 최적화
|
|
Index("ix_price_history_product", "product_code", "triggered_at"),
|
|
)
|
|
|
|
|
|
class bot_detection(MAIN_BASE):
|
|
"""봇 감지 이력 — '이 IP로 몇 번째 요청에서, 어떤 방식으로 차단됐나'를 축적해 패턴 분석.
|
|
(예: SELECT avg(ip_request_no) → IP당 평균 몇 요청 만에 감지되는지)"""
|
|
|
|
@staticmethod
|
|
def DBType():
|
|
return DBType.MAIN.value
|
|
|
|
__tablename__ = "bot_detection"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
|
|
source = Column(String(20), nullable=False) # coupang 등
|
|
query = Column(String(300), nullable=True) # 감지 당시 검색어
|
|
ip_request_no = Column(Integer, nullable=True) # 현재 IP(브라우저)로 몇 번째 요청이었나
|
|
proxy_port = Column(Integer, nullable=True) # 사용 중이던 프록시 포트(=IP 세션)
|
|
elapsed_sec = Column(Integer, nullable=True) # 브라우저 실행 후 경과(초)
|
|
marker = Column(String(120), nullable=True) # 감지 근거(차단 페이지 마커)
|
|
headless = Column(Boolean, nullable=True)
|
|
html_len = Column(Integer, nullable=True) # 응답 길이(차단 페이지는 작음)
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|