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>
181 lines
8.5 KiB
Python
181 lines
8.5 KiB
Python
"""토큰 수명 관리 — 저장/복호화/만료판정/자동 갱신.
|
|
|
|
저장소(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
|