Files
dbx-main/app/integrations/cafe24/client.py
T
king 40766d805d feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널
증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야
반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT
뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만
기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값
백업이 생겼다.

- 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이
  유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막
  쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external).
  store.resolve_description / db.revision_digests(md5) / 배너 2종.
- 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회
  확인 결과는 감사로그에만 남긴다.
- 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products.
  last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date
  가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷.
- 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을
  한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/
  소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image
  + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목
  자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다.
- client.delete/timeout, products.upload_images·options·variants 래퍼.
- 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES,
  .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:07:42 +09:00

266 lines
10 KiB
Python

"""카페24 Admin API 전송 계층.
라우터/서비스는 httpx 를 직접 쓰지 않고 이 클라이언트만 쓴다.
여기서 처리하는 것:
- Authorization 헤더 부착 (TokenService 가 만료 시 자동 갱신)
- X-Cafe24-Api-Version 헤더
- timeout
- 401 → 토큰 1회 강제 갱신 후 재시도
- 429 → Retry-After 존중, 제한 횟수만큼 대기 후 재시도
- 5xx / 네트워크 오류 → 지수 백오프 재시도
- 호출당 최소 간격 유지(대량 작업이 한 번에 몰리지 않게)
- API 로그 기록 (토큰/시크릿은 절대 기록하지 않음)
동기(sync) 클라이언트다. 예약 worker 가 평범한 스크립트이고, 라우터에서는
`async def` 대신 `def` 핸들러로 선언해 FastAPI 의 스레드풀에서 실행하면
이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Callable
import httpx
from .config import Cafe24Config
from .errors import (
Cafe24ApiError,
Cafe24AuthError,
Cafe24ConfigError,
Cafe24RateLimitError,
)
from .tokens import TokenService
logger = logging.getLogger("cafe24.client")
DEFAULT_TIMEOUT = 30.0
DEFAULT_MAX_RETRIES = 3
# 카페24 호출 사이 최소 간격(초). 대량 수정 시 429 를 미리 피한다.
DEFAULT_MIN_INTERVAL = 0.35
# api_logger(endpoint, method, product_no, http_status, result, error_message, duration_ms)
ApiLogger = Callable[..., None]
class Cafe24Client:
def __init__(
self,
config: Cafe24Config,
token_service: TokenService,
*,
api_logger: ApiLogger | None = None,
timeout: float = DEFAULT_TIMEOUT,
max_retries: int = DEFAULT_MAX_RETRIES,
min_interval: float = DEFAULT_MIN_INTERVAL,
):
self._config = config
self._tokens = token_service
self._api_logger = api_logger
self._timeout = timeout
self._max_retries = max_retries
self._min_interval = min_interval
self._pace_lock = threading.Lock()
self._last_call = 0.0
# ────────────────────────────────────────────────────────────
# 내부 헬퍼
# ────────────────────────────────────────────────────────────
def _pace(self) -> None:
"""호출 간 최소 간격 확보 (스레드 안전)."""
with self._pace_lock:
gap = time.monotonic() - self._last_call
if gap < self._min_interval:
time.sleep(self._min_interval - gap)
self._last_call = time.monotonic()
def _headers(self, access_token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": self._config.api_version,
}
def _log(
self,
*,
endpoint: str,
method: str,
product_no: int | None,
http_status: int | None,
result: str,
error_message: str,
duration_ms: int,
) -> None:
if self._api_logger is None:
return
try:
self._api_logger(
endpoint=endpoint,
method=method,
product_no=product_no,
http_status=http_status,
result=result,
error_message=error_message[:500],
duration_ms=duration_ms,
)
except Exception: # noqa: BLE001 — 로그 실패가 본 작업을 막으면 안 된다.
logger.exception("카페24 API 로그 기록 실패")
@staticmethod
def _error_message(response: httpx.Response) -> str:
"""카페24 오류 응답에서 사람이 읽을 메시지만 뽑는다."""
try:
payload = response.json()
except ValueError:
return response.text[:500]
error = payload.get("error")
if isinstance(error, dict):
parts = [str(error.get("message") or "")]
detail = error.get("details")
if isinstance(detail, list) and detail:
parts.append("; ".join(str(d.get("message", d)) for d in detail[:3]))
message = " / ".join(p for p in parts if p)
if message:
return message[:500]
return str(payload)[:500]
@staticmethod
def _retry_after(response: httpx.Response, *, attempt: int) -> float:
raw = (response.headers.get("Retry-After") or "").strip()
if raw:
try:
return max(0.5, float(raw))
except ValueError:
pass
return min(8.0, 0.5 * (2**attempt))
# ────────────────────────────────────────────────────────────
# 공개 API
# ────────────────────────────────────────────────────────────
def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
product_no: int | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.
timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값.
"""
if not self._config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
)
endpoint = path if path.startswith("/") else f"/{path}"
url = f"{self._config.api_base}{endpoint}"
method = method.upper()
forced_refresh = False
last_error: Exception | None = None
for attempt in range(self._max_retries + 1):
self._pace()
started = time.monotonic()
status: int | None = None
try:
access_token = self._tokens.get_access_token()
with httpx.Client(timeout=timeout or self._timeout) as client:
response = client.request(
method,
url,
headers=self._headers(access_token),
params=params,
json=json,
)
status = response.status_code
elapsed = int((time.monotonic() - started) * 1000)
if 200 <= status < 300:
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="SUCCESS", error_message="",
duration_ms=elapsed,
)
try:
return response.json()
except ValueError:
return {}
message = self._error_message(response)
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="FAIL", error_message=message,
duration_ms=elapsed,
)
if status == 401 and not forced_refresh:
# 서버가 토큰을 먼저 무효화한 경우 — 1회만 강제 갱신 후 재시도.
forced_refresh = True
self._tokens.force_expire()
last_error = Cafe24AuthError("카페24 인증이 만료되어 갱신 후 재시도합니다.")
continue
if status == 401:
raise Cafe24AuthError(
"카페24 인증에 실패했습니다. 시스템 → 카페24 연결에서 재인증하세요.",
needs_reauth=True,
)
if status == 429:
wait = self._retry_after(response, attempt=attempt)
last_error = Cafe24RateLimitError(
f"카페24 API 호출 제한(429). {wait:.1f}초 후 재시도합니다.",
retry_after=wait,
)
if attempt >= self._max_retries:
raise last_error
time.sleep(wait)
continue
error = Cafe24ApiError(message, status=status, endpoint=endpoint)
if error.retryable and attempt < self._max_retries:
last_error = error
time.sleep(min(8.0, 0.5 * (2**attempt)))
continue
raise error
except (Cafe24AuthError, Cafe24RateLimitError, Cafe24ApiError, Cafe24ConfigError):
raise
except httpx.HTTPError as exc:
elapsed = int((time.monotonic() - started) * 1000)
message = f"네트워크 오류 ({type(exc).__name__})"
self._log(
endpoint=endpoint, method=method, product_no=product_no,
http_status=status, result="ERROR", error_message=message,
duration_ms=elapsed,
)
last_error = Cafe24ApiError(message, status=0, endpoint=endpoint)
if attempt < self._max_retries:
time.sleep(min(8.0, 0.5 * (2**attempt)))
continue
raise last_error from None
# 재시도를 모두 소진 (401 강제갱신 루프 포함)
if last_error:
raise last_error
raise Cafe24ApiError("카페24 API 호출에 실패했습니다.", endpoint=endpoint)
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("GET", path, **kwargs)
def put(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("PUT", path, **kwargs)
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("POST", path, **kwargs)
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("DELETE", path, **kwargs)