o2o-negosium-original/lps/common/database/model/models.py
민헌 bca8032b79 feat(lps): 봇 감지 시 IP 회전+재시도 + 감지 이력 기록
쿠팡이 봇으로 감지(Akamai 차단 페이지)하면, 같은 IP로 재시도하던 것을
'브라우저 끄고 새 IP로 켜서 재시도'로 전환. 감지 패턴도 축적한다.

- DecodoProxy.rotate(): 시간창 무관 즉시 다음 포트(=새 IP). current_port 노출
- coupang/adapter: 0건+차단마커 감지 시 → 감지기록 → proxy.rotate()+강제재기동 →
  인라인 재시도(max_block_retries=1). IP당 요청수(ip_request_no) 추적, 재기동 시 리셋
- bot_detection 테이블 + crud: source/query/ip_request_no/proxy_port/elapsed_sec/marker/
  headless/html_len 기록 → 'IP당 몇 요청 만에 감지되나' 분석 가능
- worker_main: on_detect=BotDetectionLog.record 주입
- 로그: [coupang][BOT-DETECTED] ip_req#N port=... elapsed=...s marker=...
- tests: proxy rotate 즉시회전 + 감지기록 CRUD → 전체 47/47

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:04:07 +09:00

91 lines
5.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 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()"))