c6fb8ed375
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를 편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통 Integration 계층, cafe24_db, OAuth 연결 화면까지다. 카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고 app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장 일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기 때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서 재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다. 토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와 연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다. 토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가 동시에 갱신하면 한쪽 토큰이 무효화된다. 기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트· 테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None 이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다. 가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를 include 하는 구조에서 순환 import 가 생기기 때문이다. 검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시 토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과. cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
10 KiB
Python
259 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,
|
|
) -> dict[str, Any]:
|
|
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
|
|
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=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)
|