feat(cafe24): 상품 상세페이지 관리 모듈 Phase 1

카페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>
This commit is contained in:
2026-08-14 00:23:02 +09:00
parent 31eab0d4cb
commit c6fb8ed375
29 changed files with 2689 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
"""카페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)