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
+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},
)