40766d805d
증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야 반영됨. 원인은 우리 캐시가 아니라(전부 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>
266 lines
10 KiB
Python
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)
|