o2o-negosium-original/lps/docs/api.md
민헌 e7d2c87fbe feat(lps): API guard — LPS_API_KEY 설정 시에만 /v1 에 X-API-Key 검증
외부에서 API 를 함부로 호출(비용 발생 enqueue 등)하지 못하도록 정적 키
guard 를 추가한다. '키의 존재'가 토글 — 개발(local/dev)은 env 를 비워
개방 모드(기동 시 WARN), prod 만 키를 주입한다(협의 결정).

- router/v1/validator/auth.py: X-API-Key 의존성 — secrets.compare_digest
  상수시간 비교, 콤마 구분 복수 키(무중단 키 교체), 매 요청 env 조회
  (재기동 없이 테스트 가능). /v1 라우터 전체에 적용.
- /healthz·/readyz 는 라우터 밖이라 항상 개방(LB 프로브).
- negodata lps_sync_service: LPS_API_KEY env 있으면 헤더 자동 첨부(한 곳).
- compose(lps-api·negodata-backend) LPS_API_KEY 패스스루 + .env.example.
- prod 체크리스트(operations.md): 키 주입 + lps-api 포트 비공개 + 기동
  로그 'API guard ON' 확인. api.md 인증 섹션 추가.
- 라이브 스모크: 무헤더/오키 401 · 정키 2종 200 · healthz 200 확인.
- 테스트 6건 추가, 전체 141 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:45:31 +09:00

6.8 KiB

API 사용법

← README로

  • 베이스 URL(로컬): http://localhost:9600
  • Swagger 문서: http://localhost:9600/docs (브라우저에서 바로 테스트 가능)
  • 모든 응답에는 공통 결과 봉투 result가 붙습니다:
    "result": { "success": true, "code": 0, "desc": "SUCCESS" }
    
    (실패 시 success:false, code/desc에 오류 코드)
  • 인증(guard): 서버에 LPS_API_KEY 가 설정된 환경(prod)에서는 모든 /v1/* 요청에 X-API-Key 헤더가 필요합니다(불일치 시 401). 개발(local/dev)은 env 를 비워 개방 모드로 동작합니다. /healthz·/readyz 는 항상 개방(LB 프로브). 키는 콤마 구분 복수 등록 가능 (무중단 키 교체). 호출 예: curl -H "X-API-Key: <키>" http://.../v1/lps/queue/stats

1. 검색 요청 — POST /v1/lps/search

상품 리스트를 보내면 상품마다 검색 작업을 큐에 넣고 **접수번호(job_id)**를 즉시 반환합니다. (실제 검색은 뒤에서 진행)

요청

{
  "data": [
    {
      "product_code": "T1",              // (필수) 상품 식별 코드 — 이력·중복방지 키
      "product_name": "맥심 커피",        // (필수) 상품명
      "specification": "1박스, 160개입",  // (선택) 규격 — 자유 서술, 형식 무관
      "model": "모카골드",                // (선택) 모델명
      "company": "동서식품",              // (선택) 제조사/브랜드
      "price": "25000",                  // (선택) 현재가 — 있으면 가격 범위 필터 기준
      "job_type": "manual"               // (선택) 요청 유형 → 우선순위
    }
  ]
}

specification은 나눌 필요 없이 "1박스, 160개입"처럼 통째로 넣으면 AI가 해석합니다.

job_type → 우선순위 (낮을수록 먼저):

값 우선순위 의미
new_product 1 (최우선) 신규 상품 등록 시
manual 2 (기본) 담당자 수동 요청
partner 3 외부 시스템/파트너 연동
batch 4 (최하위) 정기 배치

(알 수 없는 값은 batch와 동급으로 처리)

응답

{
  "result": { "success": true, "code": 0, "desc": "SUCCESS" },
  "accepted": 1,
  "items": [ { "product_code": "T1", "job_id": "c885...", "duplicated": false } ]
}
  • duplicated: true → 같은 상품이 이미 처리 대기/진행 중이라 중복 접수 생략(그 경우 job_id 없음).

curl

curl -X POST localhost:9600/v1/lps/search -H 'Content-Type: application/json' \
  -d '{"data":[{"product_code":"T1","product_name":"맥심 커피","specification":"1박스, 160개입"}]}'

2. 작업 상태·결과 — GET /v1/lps/jobs/{job_id}

접수번호로 진행 상태와 결과를 조회합니다. (PENDING → RUNNING → DONE)

응답 (완료 시)

{
  "result": { "success": true, "code": 0, "desc": "SUCCESS" },
  "job_id": "c885...",
  "status": "DONE",              // PENDING / RUNNING / DONE / DEAD
  "attempts": 1,
  "output": {
    "outcome": "found",          // found / not_found
    "query": "맥심 커피",
    "lowest": { "price": 25200, "source": "coupang", "name": "맥심모카골드 ...", "detail_url": "...",
                "shipping_fee": 0, "shipping_type": "rocket" },
    "top": [ /* 최저가 상위 N개 */ ],
    "sources": { "naver": {"count": 40}, "coupang": {"count": 40} },
    "stages": [ {"stage":"outlier","in":80,"out":76}, {"stage":"ai_match","in":76,"out":1}, {"stage":"top_n","in":1,"out":1} ],
    "metrics": {                       // 이 검색 1건이 쓴 리소스/비용/시간
      "duration_ms": 21500,
      "ai": { "calls": 2, "prompt_tokens": 5200, "completion_tokens": 180, "est_cost_usd": 0.000888 },
      "crawl": { "fetches": 3, "html_bytes": 1560000, "malls_crawled": ["gmarket"] },
      "source_ms": { "naver": 480, "coupang": 12300, "gmarket": 8700 }
    }
  }
}
  • output.stages = 각 단계에서 몇 건이 걸러졌는지(디버깅·품질 확인용).
  • output.metrics = 검색 1건의 원가. ai(호출·토큰·추정 비용$), crawl(fetch 수·실제 전송 바이트·크롤한 몰), source_ms(소스별 소요), duration_ms(전체). 바이트는 CDP Network.loadingFinished의 encodedDataLength(실제 프록시 전송량)로 측정. proxy_bytes(네이버 직접 제외)로 DECODO 비용 산정.
  • 없는 job_id/잘못된 형식 → result.desc = "LPS_JOB_NOT_FOUND".

⚠️ 가격의 의미 (배송비)

  • price 는 상품가입니다. 배송비 포함 여부는 shipping_fee/shipping_type 으로 판단합니다.
  • 쿠팡: 검색 화면의 배송 신호를 파싱해 채웁니다 — shipping_type: rocket(로켓배송, 와우 무료/일반 19,800원↑ 무료) · rocket_merchant(판매자로켓) · free(명시 무료) · paid(유료, shipping_fee에 금액) · null(미확인)
  • 네이버: 오픈API lprice 는 배송비 제외 상품가라 둘 다 항상 null 입니다. 가격비교(카탈로그) 화면의 기본 표시는 "배송비포함 최저가"라서 API 값과 다르게 보이는 것이 정상입니다 (예: API 5,880원 vs 화면 7,520원). 카탈로그 페이지 자동 수집은 네이버 캡차로 차단되어 미지원.
curl localhost:9600/v1/lps/jobs/c885...

3. 최저가 이력(그래프) — GET /v1/lps/products/{product_code}/history

같은 상품을 여러 번 검색하면 쌓인 스냅샷을 시각 오름차순으로 반환합니다. 프론트에서 그래프로 그립니다.

쿼리 파라미터: limit (기본 100, 최대 1000)

응답

{
  "result": { "success": true, "code": 0, "desc": "SUCCESS" },
  "product_code": "T1",
  "points": [
    {
      "triggered_at": "2026-07-09T10:48:47",   // X축
      "outcome": "found",
      "matched_count": 1,
      "naver": 25200,                          // 네이버 최저가
      "coupang": 24800,                        // 쿠팡 최저가
      "final": 24800,                          // 최종 최저가 (Y축)
      "final_source": "coupang",
      "naver_name": "...", "naver_url": "...", "coupang_name": "...", "coupang_url": "..."
    }
  ]
}
  • naver/coupang가 null인 지점 = 그 시점에 해당 소스엔 그 상품이 없었음(그래프 선 공백).
curl "localhost:9600/v1/lps/products/T1/history?limit=100"

4. 큐 상태 — GET /v1/lps/queue/stats

{ "result": {...}, "counts": { "PENDING": 0, "RUNNING": 1, "DONE": 12, "DEAD": 0 } }

5. 헬스체크 — GET /healthz

서버 기동 시각을 반환(살아있는지 확인용).


상태 코드 요약

  • 작업 상태: PENDING(대기) · RUNNING(처리중) · DONE(완료) · DEAD(실패-확인필요)
  • 결과 outcome: found(찾음) · not_found(검색했으나 같은 상품 없음)