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
+5
View File
@@ -0,0 +1,5 @@
"""외부 서비스 연동 공통 계층.
모듈(app/modules/*)에 종속되지 않는 재사용 가능한 API 클라이언트를 둔다.
현재: cafe24 (상품관리 + 향후 주문관리가 공유).
"""
+91
View File
@@ -0,0 +1,91 @@
"""카페24 연동 공통 계층 (상품관리 + 향후 주문관리 공유).
구성
config.py 환경변수 → Cafe24Config (하드코딩 금지)
crypto.py 토큰 Fernet 암복호화
oauth.py 인증 URL / code→token / refresh
tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
client.py Cafe24Client — 전송·재시도·429/5xx·API 로그
products.py 상품 엔드포인트 래퍼
errors.py 공통 예외
사용 예 (모듈 라우터에서):
from app.integrations.cafe24 import build_cafe24_api
api = build_cafe24_api(store) # store = Cafe24Store
html = products.get_description(api.client, 123)
CAFE24_* 환경변수가 없어도 import 는 성공한다. 실제 호출 시점에
Cafe24ConfigError 가 나며, 라우터가 "설정 필요" 안내를 보여준다.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from . import products
from .client import Cafe24Client
from .config import (
ORDER_SCOPES,
PRODUCT_SCOPES,
Cafe24Config,
load_config,
)
from .errors import (
Cafe24ApiError,
Cafe24AuthError,
Cafe24ConfigError,
Cafe24Error,
Cafe24RateLimitError,
)
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
from .tokens import TokenService
__all__ = [
"Cafe24Api",
"build_cafe24_api",
"Cafe24Client",
"Cafe24Config",
"TokenService",
"TokenBundle",
"load_config",
"build_authorize_url",
"exchange_code",
"refresh_tokens",
"new_state",
"products",
"PRODUCT_SCOPES",
"ORDER_SCOPES",
"Cafe24Error",
"Cafe24ConfigError",
"Cafe24AuthError",
"Cafe24RateLimitError",
"Cafe24ApiError",
]
@dataclass(frozen=True)
class Cafe24Api:
"""설정 + 토큰서비스 + 클라이언트 묶음. 라우터/worker 가 이것만 들고 다닌다."""
config: Cafe24Config
tokens: TokenService
client: Cafe24Client
def build_cafe24_api(store: Any, *, scopes: tuple[str, ...] = PRODUCT_SCOPES) -> Cafe24Api:
"""Cafe24Store 를 저장소로 쓰는 API 묶음 생성.
store 는 토큰 3개 메서드(get_token_row/save_token_row/token_lock)와
API 로그 기록용 log_api_call 을 제공해야 한다.
"""
config = load_config(scopes=scopes)
token_service = TokenService(store, config)
client = Cafe24Client(
config,
token_service,
api_logger=getattr(store, "log_api_call", None),
)
return Cafe24Api(config=config, tokens=token_service, client=client)
+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)
+74
View File
@@ -0,0 +1,74 @@
"""카페24 연동 설정 — 환경변수만 읽는다(하드코딩 금지).
app/main.py 의 env() 헬퍼와 동일하게 os.getenv + strip 규칙을 쓴다.
integrations 계층은 app.main 을 import 하지 않는다(순환 import 방지).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
# 상품관리에 필요한 최소 scope. 향후 주문관리는 ORDER_SCOPES 를 더한다.
PRODUCT_SCOPES: tuple[str, ...] = ("mall.read_product", "mall.write_product")
ORDER_SCOPES: tuple[str, ...] = ("mall.read_order", "mall.write_order")
DEFAULT_API_VERSION = "2026-03-01"
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, "").strip()
return value if value else default
@dataclass(frozen=True)
class Cafe24Config:
mall_id: str
client_id: str
client_secret: str
redirect_uri: str
api_version: str
token_secret: str
scopes: tuple[str, ...]
@property
def configured(self) -> bool:
"""OAuth 를 시작할 수 있는 최소 조건."""
return bool(self.mall_id and self.client_id and self.client_secret and self.redirect_uri)
@property
def missing(self) -> list[str]:
"""설정 안내 화면에 표시할 미설정 환경변수 이름들."""
pairs = (
("CAFE24_MALL_ID", self.mall_id),
("CAFE24_CLIENT_ID", self.client_id),
("CAFE24_CLIENT_SECRET", self.client_secret),
("CAFE24_REDIRECT_URI", self.redirect_uri),
("CAFE24_TOKEN_SECRET", self.token_secret),
)
return [name for name, value in pairs if not value]
@property
def api_base(self) -> str:
return f"https://{self.mall_id}.cafe24api.com/api/v2"
@property
def scope_param(self) -> str:
return ",".join(self.scopes)
def load_config(*, scopes: tuple[str, ...] = PRODUCT_SCOPES) -> Cafe24Config:
"""환경변수에서 설정을 읽는다. 값이 없어도 예외를 던지지 않는다.
미설정 판단은 호출부가 `configured` / `missing` 으로 한다
(앱 기동을 막지 않기 위해 — 다른 모듈과 동일한 정책).
"""
return Cafe24Config(
mall_id=_env("CAFE24_MALL_ID"),
client_id=_env("CAFE24_CLIENT_ID"),
client_secret=_env("CAFE24_CLIENT_SECRET"),
redirect_uri=_env("CAFE24_REDIRECT_URI"),
api_version=_env("CAFE24_API_VERSION", DEFAULT_API_VERSION),
token_secret=_env("CAFE24_TOKEN_SECRET"),
scopes=scopes,
)
+51
View File
@@ -0,0 +1,51 @@
"""토큰 암호화 — Fernet(AES-128-CBC + HMAC).
DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 한다.
키는 .env 의 CAFE24_TOKEN_SECRET 하나이며, 임의 길이 문자열을 받아
SHA-256 으로 32바이트를 만든 뒤 Fernet 키 형식으로 변환한다
(운영자가 `openssl rand -hex 32` 같은 익숙한 방식을 그대로 쓰게 하려는 것).
⚠️ CAFE24_TOKEN_SECRET 을 바꾸면 기존 저장 토큰은 복호화할 수 없다.
그 경우 관리자 화면에서 카페24 재연결(재인증)을 하면 된다.
"""
from __future__ import annotations
import base64
import hashlib
from .errors import Cafe24ConfigError
def _fernet(secret: str):
from cryptography.fernet import Fernet # 지연 import
if not (secret or "").strip():
raise Cafe24ConfigError(
"CAFE24_TOKEN_SECRET 환경변수가 설정되지 않았습니다. "
"openssl rand -hex 32 로 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요."
)
digest = hashlib.sha256(secret.strip().encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt(value: str, *, secret: str) -> str:
"""평문 → 암호문. 빈 문자열은 그대로 둔다(미연결 상태 표현)."""
if not value:
return ""
return _fernet(secret).encrypt(value.encode("utf-8")).decode("ascii")
def decrypt(value: str, *, secret: str) -> str:
"""암호문 → 평문. 키가 바뀌었거나 손상되면 Cafe24ConfigError."""
if not value:
return ""
from cryptography.fernet import InvalidToken # 지연 import
try:
return _fernet(secret).decrypt(value.encode("ascii")).decode("utf-8")
except InvalidToken:
raise Cafe24ConfigError(
"저장된 카페24 토큰을 복호화하지 못했습니다. "
"CAFE24_TOKEN_SECRET 이 변경되었을 수 있습니다. 카페24 재연결이 필요합니다."
) from None
+45
View File
@@ -0,0 +1,45 @@
"""카페24 연동 공통 예외.
라우터/서비스는 httpx 예외를 직접 다루지 않고 여기 정의된 타입만 잡는다.
모든 메시지는 사용자에게 그대로 노출될 수 있으므로 토큰/시크릿을 담지 않는다.
"""
from __future__ import annotations
class Cafe24Error(Exception):
"""카페24 연동 최상위 예외."""
class Cafe24ConfigError(Cafe24Error):
"""CAFE24_* 환경변수 미설정 등 설정 문제."""
class Cafe24AuthError(Cafe24Error):
"""인증 실패 — 토큰 없음/만료/refresh 불가. 재인증이 필요하다."""
def __init__(self, message: str, *, needs_reauth: bool = False):
super().__init__(message)
self.needs_reauth = needs_reauth
class Cafe24RateLimitError(Cafe24Error):
"""429 Too Many Requests. retry_after 초 뒤 재시도 가능."""
def __init__(self, message: str, *, retry_after: float = 1.0):
super().__init__(message)
self.retry_after = retry_after
class Cafe24ApiError(Cafe24Error):
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
def __init__(self, message: str, *, status: int = 0, endpoint: str = ""):
super().__init__(message)
self.status = status
self.endpoint = endpoint
@property
def retryable(self) -> bool:
"""5xx 와 타임아웃(status=0)만 재시도 대상. 4xx 는 고쳐야 할 요청."""
return self.status == 0 or self.status >= 500
+145
View File
@@ -0,0 +1,145 @@
"""카페24 OAuth 2.0 (Authorization Code) — URL 생성 / 토큰 발급 / 갱신.
토큰 저장은 여기서 하지 않는다(tokens.TokenService 담당). 이 모듈은 순수하게
카페24 인증 엔드포인트와만 대화한다.
카페24 토큰 응답의 만료시각(`expires_at`, `refresh_token_expires_at`)은
타임존 표기가 없는 KST 문자열이므로 KST 를 붙여 aware datetime 으로 만든다.
"""
from __future__ import annotations
import base64
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from urllib.parse import urlencode
import httpx
from app.timezone import KST, now_kst
from .config import Cafe24Config
from .errors import Cafe24AuthError, Cafe24ConfigError
TOKEN_TIMEOUT = 20.0
@dataclass(frozen=True)
class TokenBundle:
"""카페24가 돌려준 토큰 한 벌 (평문 — 저장 직전에 암호화된다)."""
access_token: str
refresh_token: str
access_token_expires_at: datetime
refresh_token_expires_at: datetime | None
scopes: str
def new_state() -> str:
"""CSRF 방어용 state. 세션에 넣어두고 콜백에서 대조한다."""
return secrets.token_urlsafe(24)
def build_authorize_url(config: Cafe24Config, *, state: str) -> str:
if not config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(config.missing)
)
query = urlencode(
{
"response_type": "code",
"client_id": config.client_id,
"redirect_uri": config.redirect_uri,
"scope": config.scope_param,
"state": state,
}
)
return f"{config.api_base}/oauth/authorize?{query}"
def _basic_auth_header(config: Cafe24Config) -> str:
raw = f"{config.client_id}:{config.client_secret}".encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def _parse_expiry(value: str | None, *, fallback_seconds: int) -> datetime:
"""'2026-08-20T14:00:00.000' → KST aware datetime. 실패 시 fallback."""
text = (value or "").strip()
if text:
try:
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
except ValueError:
pass
return now_kst() + timedelta(seconds=fallback_seconds)
def _to_bundle(payload: dict) -> TokenBundle:
access = (payload.get("access_token") or "").strip()
refresh = (payload.get("refresh_token") or "").strip()
if not access:
raise Cafe24AuthError("카페24 응답에 access_token 이 없습니다.", needs_reauth=True)
scopes = payload.get("scopes")
if isinstance(scopes, list):
scope_text = ",".join(str(s) for s in scopes)
else:
scope_text = str(scopes or "")
return TokenBundle(
access_token=access,
refresh_token=refresh,
# access token 은 통상 2시간, refresh token 은 2주.
access_token_expires_at=_parse_expiry(payload.get("expires_at"), fallback_seconds=7200),
refresh_token_expires_at=(
_parse_expiry(payload.get("refresh_token_expires_at"), fallback_seconds=1209600)
if refresh
else None
),
scopes=scope_text,
)
def _post_token(config: Cafe24Config, data: dict[str, str]) -> TokenBundle:
url = f"{config.api_base}/oauth/token"
headers = {
"Authorization": _basic_auth_header(config),
"Content-Type": "application/x-www-form-urlencoded",
}
try:
with httpx.Client(timeout=TOKEN_TIMEOUT) as client:
response = client.post(url, headers=headers, data=data)
except httpx.HTTPError as exc:
# 예외 문자열에 Authorization 헤더가 들어가지 않도록 타입명만 남긴다.
raise Cafe24AuthError(f"카페24 인증 서버에 연결하지 못했습니다. ({type(exc).__name__})") from None
if response.status_code != 200:
# 400/401 = 코드/리프레시토큰 무효 → 재인증 필요.
raise Cafe24AuthError(
f"카페24 토큰 요청이 거부되었습니다. (HTTP {response.status_code})",
needs_reauth=response.status_code in (400, 401),
)
return _to_bundle(response.json())
def exchange_code(config: Cafe24Config, *, code: str) -> TokenBundle:
"""authorization code → 최초 토큰."""
return _post_token(
config,
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config.redirect_uri,
},
)
def refresh_tokens(config: Cafe24Config, *, refresh_token: str) -> TokenBundle:
"""refresh token → 새 토큰 한 벌 (refresh token 도 함께 회전된다)."""
if not (refresh_token or "").strip():
raise Cafe24AuthError("저장된 refresh token 이 없습니다.", needs_reauth=True)
return _post_token(
config,
{"grant_type": "refresh_token", "refresh_token": refresh_token},
)
+117
View File
@@ -0,0 +1,117 @@
"""카페24 상품 엔드포인트 래퍼.
전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
⚠️ 상품 수정 payload 구조는 카페24 Admin API 버전에 따라 다를 수 있다.
실제 쇼핑몰에 반영하기 전 반드시 테스트 상품 1건으로 검증할 것.
"""
from __future__ import annotations
from typing import Any
from .client import Cafe24Client
# 카페24 상품 목록 API 의 1회 최대 조회 수
PAGE_LIMIT = 100
def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
params: dict[str, Any] = {}
if product_name:
params["product_name"] = product_name
payload = client.get("/admin/products/count", params=params)
try:
return int(payload.get("count") or 0)
except (TypeError, ValueError):
return 0
def list_products(
client: Cafe24Client,
*,
limit: int = PAGE_LIMIT,
offset: int = 0,
product_name: str = "",
product_no: int | None = None,
) -> list[dict[str, Any]]:
"""상품 목록 1페이지. 검색어가 있으면 상품명 부분일치로 조회한다."""
params: dict[str, Any] = {
"limit": max(1, min(int(limit), PAGE_LIMIT)),
"offset": max(0, int(offset)),
}
if product_name:
params["product_name"] = product_name
if product_no:
params["product_no"] = int(product_no)
payload = client.get("/admin/products", params=params)
products = payload.get("products")
return products if isinstance(products, list) else []
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
"""상품 1건 기본 정보 (상세설명은 별도 조회 — get_description)."""
payload = client.get(f"/admin/products/{int(product_no)}", product_no=int(product_no))
product = payload.get("product")
return product if isinstance(product, dict) else {}
def get_description(client: Cafe24Client, product_no: int) -> str:
"""상품의 현재 상세설명 HTML.
카페24는 상세설명을 별도 리소스로 제공한다. 이 값이 언제나 source of truth
이며, 로컬 DB 의 마지막 버전을 현재값이라고 가정하지 않는다.
"""
no = int(product_no)
payload = client.get(f"/admin/products/{no}/description", product_no=no)
description = payload.get("description")
if isinstance(description, dict):
return str(description.get("description") or "")
return ""
def update_description(client: Cafe24Client, product_no: int, html: str) -> dict[str, Any]:
"""상세설명 HTML 전체 교체. 성공하면 카페24 응답 dict 를 돌려준다.
실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로, 호출부는 예외가 없을
때만 성공으로 처리하면 된다.
"""
no = int(product_no)
payload = client.put(
f"/admin/products/{no}/description",
json={"request": {"description": html}},
product_no=no,
)
description = payload.get("description")
return description if isinstance(description, dict) else payload
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블 컬럼 모양으로 정규화.
카페24는 boolean 을 'T'/'F' 문자열로 준다.
"""
def flag(value: Any, *, default: bool = True) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "Y", "1"):
return True
if text in ("F", "FALSE", "N", "0"):
return False
return default
try:
product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError):
product_no = 0
return {
"product_no": product_no,
"product_code": str(raw.get("product_code") or ""),
"product_name": str(raw.get("product_name") or ""),
"display": flag(raw.get("display")),
"selling": flag(raw.get("selling")),
}
+180
View File
@@ -0,0 +1,180 @@
"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신.
저장소(repo)는 duck typing 으로 주입한다. 실제 구현은
`app/modules/cafe24/db.py` 의 Cafe24Store 이며, 아래 3개만 있으면 된다.
repo.get_token_row(mall_id) -> dict | None (암호문 그대로)
repo.save_token_row(**fields)-> None (UPSERT)
repo.token_lock(mall_id) -> contextmanager (FOR UPDATE, .row / .save())
`token_lock` 은 web 컨테이너와 worker 컨테이너가 동시에 refresh 를 시도해도
한쪽만 카페24에 요청하도록 행 잠금을 건다(카페24는 refresh token 을 회전시키므로
동시 refresh 시 한쪽 토큰이 무효화된다).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from app.timezone import KST, now_kst
from .config import Cafe24Config
from .crypto import decrypt, encrypt
from .errors import Cafe24AuthError
from .oauth import TokenBundle, refresh_tokens
logger = logging.getLogger("cafe24.tokens")
# 만료 몇 초 전부터 미리 갱신할지 (네트워크 지연 여유)
REFRESH_MARGIN = timedelta(seconds=120)
class TokenService:
def __init__(self, repo: Any, config: Cafe24Config):
self._repo = repo
self._config = config
# ────────────────────────────────────────────────────────────
# 저장
# ────────────────────────────────────────────────────────────
def save_bundle(self, bundle: TokenBundle, *, connected_by: str = "") -> None:
"""최초 인증/재인증 후 토큰 저장. 토큰은 암호화해서 넣는다."""
secret = self._config.token_secret
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
connected_by=connected_by,
)
# ────────────────────────────────────────────────────────────
# 조회
# ────────────────────────────────────────────────────────────
def _aware(self, value: Any):
"""DB 에서 온 datetime 을 KST aware 로 정규화."""
if value is None:
return None
return value if value.tzinfo else value.replace(tzinfo=KST)
def status(self) -> dict[str, Any]:
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""
if not self._config.configured:
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": False,
"reason": "환경변수 미설정",
}
row = self._repo.get_token_row(self._config.mall_id)
if not row or not row.get("access_token"):
return {
"connected": False,
"mall_id": self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": True,
"reason": "아직 카페24 연결(인증)을 하지 않았습니다.",
}
access_exp = self._aware(row.get("access_token_expires_at"))
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
now = now_kst()
refresh_dead = bool(refresh_exp and now >= refresh_exp)
return {
"connected": not refresh_dead,
"mall_id": row.get("mall_id") or self._config.mall_id,
"missing": self._config.missing,
"needs_reauth": refresh_dead,
"reason": "refresh token 이 만료되었습니다. 재연결이 필요합니다." if refresh_dead else "",
"scopes": row.get("scopes") or "",
"access_token_expires_at": access_exp.isoformat(timespec="seconds") if access_exp else "",
"refresh_token_expires_at": refresh_exp.isoformat(timespec="seconds") if refresh_exp else "",
"access_expired": bool(access_exp and now >= access_exp),
"last_refreshed_at": (
self._aware(row.get("last_refreshed_at")).isoformat(timespec="seconds")
if row.get("last_refreshed_at")
else ""
),
"last_error": row.get("last_error") or "",
"connected_by": row.get("connected_by") or "",
}
# ────────────────────────────────────────────────────────────
# 사용 (Cafe24Client 가 호출)
# ────────────────────────────────────────────────────────────
def get_access_token(self) -> str:
"""유효한 access token. 만료(임박)면 잠금 걸고 1회 갱신 후 반환."""
mall_id = self._config.mall_id
row = self._repo.get_token_row(mall_id)
if not row or not row.get("access_token"):
raise Cafe24AuthError(
"카페24에 연결되어 있지 않습니다. 시스템 → 카페24 연결에서 인증하세요.",
needs_reauth=True,
)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
return decrypt(row["access_token"], secret=self._config.token_secret)
return self._refresh_locked(mall_id)
def force_expire(self) -> None:
"""access token 만료시각을 과거로 밀어 다음 호출에서 반드시 갱신하게 한다.
서버가 만료 전에 토큰을 무효화해 401 이 온 경우(Cafe24Client)에 쓴다.
"""
self._repo.save_token_row(
mall_id=self._config.mall_id,
access_token_expires_at=now_kst() - timedelta(seconds=1),
)
def _refresh_locked(self, mall_id: str) -> str:
"""행 잠금 안에서 갱신. 잠금 대기 중 다른 프로세스가 이미 갱신했으면 그 값 사용."""
secret = self._config.token_secret
with self._repo.token_lock(mall_id) as handle:
row = handle.row
if not row:
raise Cafe24AuthError("카페24 토큰이 없습니다.", needs_reauth=True)
expires_at = self._aware(row.get("access_token_expires_at"))
if expires_at and now_kst() < expires_at - REFRESH_MARGIN:
# 잠금 대기 사이에 다른 프로세스가 갱신 완료.
return decrypt(row["access_token"], secret=secret)
refresh_exp = self._aware(row.get("refresh_token_expires_at"))
if refresh_exp and now_kst() >= refresh_exp:
handle.save(last_error="refresh token 만료 — 재인증 필요")
raise Cafe24AuthError(
"카페24 refresh token 이 만료되었습니다. 시스템 → 카페24 연결에서 재인증하세요.",
needs_reauth=True,
)
try:
bundle = refresh_tokens(
self._config,
refresh_token=decrypt(row.get("refresh_token") or "", secret=secret),
)
except Cafe24AuthError as exc:
handle.save(last_error=str(exc))
raise
logger.info("카페24 access token 갱신 완료 (mall_id=%s)", mall_id)
handle.save(
access_token=encrypt(bundle.access_token, secret=secret),
refresh_token=encrypt(bundle.refresh_token, secret=secret),
access_token_expires_at=bundle.access_token_expires_at,
refresh_token_expires_at=bundle.refresh_token_expires_at,
scopes=bundle.scopes,
last_refreshed_at=now_kst(),
last_error="",
)
return bundle.access_token