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
+22
View File
@@ -64,6 +64,28 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송.
# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr
# ─── 카페24 상품 상세페이지 관리 모듈 (cafe24_db) ───
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
# DB/역할/스키마 생성: scripts/sql/cafe24_db_init.sql 참고.
# 권한키: cafe24(접근). admin 은 항상 통과. 카페24 연결(OAuth)은 admin 전용.
# CAFE24_DB_URL=postgresql://cafe24_app:replace-me@postgres-db:5432/cafe24_db
#
# 카페24 개발자센터(https://developers.cafe24.com)에서 앱을 만들고 발급받은 값.
# - Redirect URI 는 아래 CAFE24_REDIRECT_URI 와 반드시 동일하게 앱에 등록.
# - Scope 는 상품관리에 mall.read_product, mall.write_product 가 필요하다.
# (향후 주문관리 추가 시 mall.read_order, mall.write_order 를 앱에 추가하고
# 재인증하면 된다 — 코드는 app/integrations/cafe24/config.py 의 SCOPES)
# CAFE24_MALL_ID=miraskitchen
# CAFE24_CLIENT_ID=
# CAFE24_CLIENT_SECRET=
# CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
# CAFE24_API_VERSION=2026-03-01
#
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
# 카페24 재연결(재인증)이 필요하다.
# CAFE24_TOKEN_SECRET=
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능.
+1
View File
@@ -35,6 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`.
+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
+19
View File
@@ -13,6 +13,8 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from .modules.cafe24 import build_cafe24_store
from .modules.cafe24 import router as cafe24_router
from .modules.cupang import build_cupang_store, build_itemcode_reader
from .modules.cupang import router as cupang_router
from .modules.dispatch import build_dispatch_store
@@ -45,6 +47,7 @@ MODULE_LABELS: dict[str, str] = {
"malaysia": "말레이시아 재고관리",
"dispatch": "말레이시아 배송",
"project": "프로젝트 관리",
"cafe24": "카페24 상품관리",
"expense_approver": "개인경비",
"vacation_approver": "휴가",
}
@@ -120,6 +123,7 @@ _MODULE_TEMPLATE_DIRS = [
BASE_DIR / "modules" / "malaysia" / "templates",
BASE_DIR / "modules" / "dispatch" / "templates",
BASE_DIR / "modules" / "project" / "templates",
BASE_DIR / "modules" / "cafe24" / "templates",
]
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.loader = ChoiceLoader(
@@ -170,6 +174,9 @@ app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or No
# 프로젝트 관리(아사나식): PROJECT_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 메일 알림은 SMTP_* 환경변수 기반(app/mail.py). 미설정 시 조용히 skip.
app.state.project_store = build_project_store(dsn=env("PROJECT_DB_URL") or None)
# 카페24 상품관리: CAFE24_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
# 카페24 API 호출/토큰은 app/integrations/cafe24 공통 계층(CAFE24_* 환경변수).
app.state.cafe24_store = build_cafe24_store(dsn=env("CAFE24_DB_URL") or None)
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
app.include_router(expense_router)
@@ -178,6 +185,7 @@ app.include_router(vacation_router)
app.include_router(malaysia_router)
app.include_router(dispatch_router)
app.include_router(project_router)
app.include_router(cafe24_router)
def public_url_for(request: Request, route_name: str) -> str:
@@ -350,6 +358,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
"status": "ready",
"category": "관리",
},
{
"key": "cafe24",
"title": "카페24 상품관리",
"subtitle": "Cafe24 Products",
"description": "카페24 관리자에 들어가지 않고 상품 상세페이지를 편집·예약 적용하고 이전 버전으로 되돌립니다.",
"url": "/cafe24/",
"health_url": "/cafe24/health",
"status": "ready",
"category": "운영",
},
]
allowed = allowed_modules(user_rec)
for item in items:
@@ -369,6 +387,7 @@ def _icon_svg(name: str) -> str:
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
"project": '<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="13" x2="13" y2="13"/><line x1="8" y1="16" x2="11" y2="16"/>',
"cafe24": '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10"/><path d="M7 13h7"/><path d="M7 17h4"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
}
body = paths.get(name, paths["modules"])
+48
View File
@@ -0,0 +1,48 @@
"""카페24 상품 상세페이지 관리 모듈.
라우터/저장소/순수로직/템플릿을 한 디렉토리에서 관리한다.
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cafe24) + `routes_*.py`
- 저장소: `db.py` (cafe24_db / PostgreSQL 전용)
- 순수 로직: `store.py` (버전/예약 상수, 재시도 규칙, 검증)
- 템플릿: `templates/cafe24/`
카페24 API 호출은 이 모듈에 두지 않는다. 향후 주문관리 모듈과 공유하기 위해
`app/integrations/cafe24/` 공통 계층을 쓴다.
데이터 저장은 cafe24_db 전용이다. CAFE24_DB_URL 미설정 시
build_cafe24_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를
보여준다(앱은 죽지 않음).
"""
from typing import Any
from . import store
from .router import router
from .store import (
REVISION_LABELS,
REVISION_TYPES,
SCHEDULE_STATUS_LABELS,
SCHEDULE_STATUSES,
)
__all__ = [
"router",
"store",
"REVISION_TYPES",
"REVISION_LABELS",
"SCHEDULE_STATUSES",
"SCHEDULE_STATUS_LABELS",
"build_cafe24_store",
]
def build_cafe24_store(*, dsn: str | None) -> Any:
"""CAFE24_DB_URL 이 있으면 Cafe24Store, 없으면 None.
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시.
"""
if not dsn:
return None
from .db import Cafe24Store # 지연 import (개발 환경 deps 없을 수 있음)
return Cafe24Store(dsn)
+118
View File
@@ -0,0 +1,118 @@
"""카페24 모듈 공용 가드/컨텍스트 헬퍼.
router.py 와 routes_*.py 가 함께 쓴다(순환 import 방지를 위해 분리).
다른 모듈과 동일한 규칙:
- JSON API → require_user() : 401/403 HTTPException
- HTML 페이지 → guard() : 리다이렉트 / denied.html 응답 반환
app.main 은 함수 안에서 지연 import 한다(순환 import 방지).
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
MODULE_KEY = "cafe24"
MODULE_NAME = "카페24 상품관리"
CONFIG_HELP = (
"카페24 모듈이 아직 설정되지 않았습니다. "
"CAFE24_DB_URL 환경변수를 설정하고 "
"scripts/sql/cafe24_db_init.sql 로 cafe24_db 를 초기화한 뒤 "
"컨테이너를 재기동하세요."
)
def get_store(request: Request) -> Any:
return getattr(request.app.state, "cafe24_store", None)
def require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, MODULE_KEY):
raise HTTPException(status_code=403, detail=f"{MODULE_NAME} 모듈 권한이 없습니다.")
return user
def require_admin(request: Request) -> dict[str, Any]:
"""카페24 연결(OAuth)·연결 해제는 관리자만."""
from app.store import is_admin # noqa: WPS433
user = require_user(request)
if not is_admin(user):
raise HTTPException(status_code=403, detail="관리자만 카페24 연결을 변경할 수 있습니다.")
return user
def require_store(request: Request) -> tuple[Any, dict[str, Any]]:
"""JSON API 용 — store 미설정이면 503."""
user = require_user(request)
st = get_store(request)
if st is None:
raise HTTPException(status_code=503, detail=CONFIG_HELP)
return st, user
def render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return render_template(
request,
"denied.html",
{
"reason": CONFIG_HELP,
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active=MODULE_KEY),
},
status_code=503,
)
def guard(request: Request):
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.
반환이 tuple 이면 (store, user), 아니면 그대로 응답으로 돌려준다.
"""
from app.main import get_current_user_record, render_template # noqa: WPS433
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, MODULE_KEY):
return render_template(
request,
"denied.html",
{
"reason": f"{MODULE_NAME} 접근 권한이 없습니다.",
"user": user,
"is_admin": is_admin(user),
},
status_code=403,
)
st = get_store(request)
if st is None:
return render_config_needed(request, user)
return st, user
def base_ctx(request: Request, user: dict[str, Any], *, active_tab: str = "") -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return {
"user": user,
"is_admin": is_admin(user),
"is_super": bool(user.get("is_super_admin")),
"nav_items": build_erp_nav(user, active=MODULE_KEY),
"active_tab": active_tab,
}
+237
View File
@@ -0,0 +1,237 @@
"""cafe24_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `CAFE24_DB_URL`
(예: postgresql://cafe24_app:<pwd>@postgres-db:5432/cafe24_db)
- 스키마는 앱이 만들지 않는다. `scripts/sql/cafe24_db_init.sql` 을 superuser 가
사전 적용한다. 앱 계정(cafe24_app)은 CRUD 권한만 받는다.
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
토큰 값은 이 계층에 도달하기 전 이미 Fernet 암호문이다(평문 취급 금지).
API 로그에는 토큰/시크릿을 넣지 않는다.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from datetime import date, datetime
from typing import Any, Iterator
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.timezone import KST
from . import store
logger = logging.getLogger("cafe24.db")
# save_token_row / TokenLock.save 에서 부분 갱신을 허용하는 컬럼 화이트리스트.
# 여기 없는 키는 무시한다(임의 컬럼 주입 방지).
_TOKEN_FIELDS: tuple[str, ...] = (
"access_token",
"refresh_token",
"access_token_expires_at",
"refresh_token_expires_at",
"scopes",
"last_refreshed_at",
"last_error",
"connected_by",
)
class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
def __init__(self, conn: Any, mall_id: str, row: dict[str, Any] | None):
self._conn = conn
self._mall_id = mall_id
self.row = row
def save(self, **fields: Any) -> None:
_update_token_row(self._conn, self._mall_id, fields)
def _update_token_row(conn: Any, mall_id: str, fields: dict[str, Any]) -> None:
"""UPSERT. 주어진 컬럼만 갱신한다(부분 갱신)."""
allowed = {k: v for k, v in fields.items() if k in _TOKEN_FIELDS}
if not allowed:
return
columns = list(allowed.keys())
placeholders = ", ".join(["%s"] * len(columns))
assignments = ", ".join(f"{col} = EXCLUDED.{col}" for col in columns)
conn.execute(
f"""
INSERT INTO cafe24_oauth_tokens (mall_id, {", ".join(columns)})
VALUES (%s, {placeholders})
ON CONFLICT (mall_id) DO UPDATE SET {assignments}
""",
(mall_id, *[allowed[col] for col in columns]),
)
class Cafe24Store:
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
self._pool = ConnectionPool(
conninfo=dsn,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "autocommit": True},
open=False,
)
self._pool.open(wait=False)
def close(self) -> None:
self._pool.close()
# ════════════════════════════════════════════════════════════
# OAuth 토큰 — app/integrations/cafe24/tokens.py 가 요구하는 3개 메서드
# ════════════════════════════════════════════════════════════
def get_token_row(self, mall_id: str) -> dict[str, Any] | None:
with self._pool.connection() as conn:
return conn.execute(
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s",
(mall_id,),
).fetchone()
def save_token_row(self, *, mall_id: str, **fields: Any) -> None:
with self._pool.connection() as conn:
_update_token_row(conn, mall_id, fields)
@contextmanager
def token_lock(self, mall_id: str) -> Iterator[TokenLock]:
"""토큰 행을 FOR UPDATE 로 잠근 채 작업.
web 컨테이너와 worker 컨테이너가 동시에 refresh 하는 것을 막는다
(카페24는 refresh token 을 회전시키므로 동시 갱신 시 한쪽이 무효화됨).
행이 아직 없으면 row=None 으로 넘어간다.
"""
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"SELECT * FROM cafe24_oauth_tokens WHERE mall_id = %s FOR UPDATE",
(mall_id,),
).fetchone()
yield TokenLock(conn, mall_id, row)
def disconnect(self, mall_id: str) -> None:
"""연결 해제 — 토큰만 지운다(이력/예약은 보존)."""
with self._pool.connection() as conn:
conn.execute("DELETE FROM cafe24_oauth_tokens WHERE mall_id = %s", (mall_id,))
# ════════════════════════════════════════════════════════════
# API 호출 로그 (Cafe24Client 가 주입받아 호출)
# ⚠️ Authorization/토큰/시크릿은 절대 기록하지 않는다.
# ════════════════════════════════════════════════════════════
def log_api_call(
self,
*,
endpoint: str,
method: str,
product_no: int | None,
http_status: int | None,
result: str,
error_message: str,
duration_ms: int,
) -> None:
with self._pool.connection() as conn:
conn.execute(
"""
INSERT INTO cafe24_api_logs
(endpoint, method, product_no, http_status, result, error_message, duration_ms)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(endpoint, method, product_no, http_status, result, error_message, duration_ms),
)
def list_api_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT * FROM cafe24_api_logs
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(max(1, min(int(limit), 500)),),
).fetchall()
return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 작업 감사 로그
# ════════════════════════════════════════════════════════════
def log_audit(
self,
*,
actor: str,
action: str,
product_no: int | None = None,
revision_id: int | None = None,
schedule_id: int | None = None,
result: str = "",
detail: str = "",
) -> None:
with self._pool.connection() as conn:
self._insert_audit(
conn,
actor=actor,
action=action,
product_no=product_no,
revision_id=revision_id,
schedule_id=schedule_id,
result=result,
detail=detail,
)
@staticmethod
def _insert_audit(
conn: Any,
*,
actor: str,
action: str,
product_no: int | None = None,
revision_id: int | None = None,
schedule_id: int | None = None,
result: str = "",
detail: str = "",
) -> None:
"""호출자의 트랜잭션에 합류시키기 위해 conn 을 받는 정적 헬퍼."""
conn.execute(
"""
INSERT INTO cafe24_audit_logs
(actor, action, product_no, revision_id, schedule_id, result, detail)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(actor, action, product_no, revision_id, schedule_id, result, detail[:1000]),
)
def list_audit_logs(self, *, limit: int = 100) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT * FROM cafe24_audit_logs
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(max(1, min(int(limit), 500)),),
).fetchall()
return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
# ════════════════════════════════════════════════════════════
@staticmethod
def _serialize(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {}
out = dict(row)
for key, value in list(out.items()):
if isinstance(value, datetime):
aware = value if value.tzinfo else value.replace(tzinfo=KST)
out[key] = aware.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(value, date):
out[key] = value.isoformat()
return out
__all__ = ["Cafe24Store", "TokenLock", "store"]
+56
View File
@@ -0,0 +1,56 @@
"""카페24 상품 상세페이지 관리 모듈 라우터.
- 경로: /cafe24
- 권한: 로그인 + `cafe24` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
카페24 연결(OAuth) 변경은 `is_admin` 만.
- 데이터: Cafe24Store (cafe24_db / PostgreSQL) 전용.
CAFE24_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 카페24 API 호출은 app/integrations/cafe24 공통 계층을 통해서만 한다.
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_system 연결(OAuth)·상태·API 로그·작업 로그
(Phase 2~) routes_products / routes_schedules
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from .common import base_ctx, guard
from .routes_system import system_router
logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(system_router)
@router.get("/health")
def health() -> dict[str, str]:
"""포털 카드의 상태 점(dot) 용. 인증 불필요 — 상태 문자열만 반환."""
return {"status": "ok"}
@router.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
"""상품 목록 (Phase 2 에서 구현). 지금은 연결 상태 안내만."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
_st, user = checked
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
}
)
return render_template(request, "cafe24/index.html", ctx)
+162
View File
@@ -0,0 +1,162 @@
"""카페24 시스템 화면 — 연결(OAuth) / 연결 상태 / API 로그 / 작업 로그.
OAuth 흐름
1) 관리자가 [카페24 연결] → GET /cafe24/system/oauth/start
state 를 만들어 세션에 넣고 카페24 인증 페이지로 302.
2) 카페24가 GET /cafe24/oauth/callback?code=&state= 로 되돌려보냄.
세션 state 와 대조(CSRF 방어) 후 code → 토큰 교환, 암호화 저장.
핸들러는 `def`(동기)로 선언한다. 카페24 API·DB 호출이 블로킹이므로 FastAPI 의
스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import (
Cafe24AuthError,
Cafe24ConfigError,
Cafe24Error,
build_authorize_url,
build_cafe24_api,
exchange_code,
load_config,
new_state,
)
from .common import base_ctx, guard, render_config_needed, require_admin
logger = logging.getLogger("cafe24.system")
system_router = APIRouter()
# 세션에 state 를 담는 키
_STATE_KEY = "cafe24_oauth_state"
@system_router.get("/system", response_class=HTMLResponse)
def system_page(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
api = build_cafe24_api(st)
try:
status = api.tokens.status()
except Cafe24Error as exc:
status = {
"connected": False,
"mall_id": api.config.mall_id,
"missing": api.config.missing,
"needs_reauth": True,
"reason": str(exc),
}
ctx = base_ctx(request, user, active_tab="system")
ctx.update(
{
"page_title": "카페24 — 시스템",
"page_subtitle": "연결 상태 · API 로그 · 작업 로그",
"status": status,
"api_version": api.config.api_version,
"scopes": api.config.scope_param,
"redirect_uri": api.config.redirect_uri,
"api_logs": st.list_api_logs(limit=50),
"audit_logs": st.list_audit_logs(limit=50),
"flash": request.query_params.get("msg", ""),
"flash_error": request.query_params.get("err", ""),
}
)
return render_template(request, "cafe24/system.html", ctx)
@system_router.get("/system/oauth/start")
def oauth_start(request: Request):
"""카페24 인증 시작 (관리자 전용)."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
config = load_config()
try:
state = new_state()
url = build_authorize_url(config, state=state)
except Cafe24ConfigError as exc:
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
request.session[_STATE_KEY] = state
return RedirectResponse(url=url, status_code=303)
@system_router.get("/oauth/callback")
def oauth_callback(request: Request):
"""카페24 콜백 — code → 토큰 교환 후 암호화 저장."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
expected = request.session.pop(_STATE_KEY, "")
received = request.query_params.get("state", "")
error = request.query_params.get("error", "")
code = request.query_params.get("code", "")
if error:
return RedirectResponse(url=f"/cafe24/system?err=카페24 인증이 취소되었습니다. ({error})", status_code=303)
if not expected or expected != received:
# state 불일치 = 위조된 콜백일 수 있다. 토큰 교환하지 않는다.
logger.warning("카페24 OAuth state 불일치 — 콜백 거부")
return RedirectResponse(
url="/cafe24/system?err=인증 state 가 일치하지 않습니다. 다시 시도하세요.",
status_code=303,
)
if not code:
return RedirectResponse(url="/cafe24/system?err=인증 코드가 없습니다.", status_code=303)
api = build_cafe24_api(st)
try:
bundle = exchange_code(api.config, code=code)
api.tokens.save_bundle(bundle, connected_by=str(user.get("email") or ""))
except (Cafe24AuthError, Cafe24ConfigError) as exc:
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_connect",
result="FAIL",
detail=str(exc),
)
return RedirectResponse(url=f"/cafe24/system?err={exc}", status_code=303)
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_connect",
result="SUCCESS",
detail=f"scopes={bundle.scopes}",
)
logger.info("카페24 연결 완료 (mall_id=%s)", api.config.mall_id)
return RedirectResponse(url="/cafe24/system?msg=카페24에 연결되었습니다.", status_code=303)
@system_router.post("/system/oauth/disconnect")
def oauth_disconnect(request: Request):
"""저장된 토큰 삭제 (관리자 전용). 이력/예약 데이터는 지우지 않는다."""
user = require_admin(request)
st = getattr(request.app.state, "cafe24_store", None)
if st is None:
return render_config_needed(request, user)
config = load_config()
st.disconnect(config.mall_id)
st.log_audit(
actor=str(user.get("email") or ""),
action="oauth_disconnect",
result="SUCCESS",
)
return RedirectResponse(url="/cafe24/system?msg=카페24 연결을 해제했습니다.", status_code=303)
+106
View File
@@ -0,0 +1,106 @@
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
"""
from __future__ import annotations
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
REVISION_MANUAL = "MANUAL" # 즉시 적용
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
REVISION_TYPES: tuple[str, ...] = (
REVISION_SYNC,
REVISION_DRAFT,
REVISION_BACKUP,
REVISION_MANUAL,
REVISION_SCHEDULED,
REVISION_ROLLBACK,
)
REVISION_LABELS: dict[str, str] = {
REVISION_SYNC: "현재값 동기화",
REVISION_DRAFT: "초안",
REVISION_BACKUP: "적용 직전 자동백업",
REVISION_MANUAL: "즉시 적용",
REVISION_SCHEDULED: "예약 적용",
REVISION_ROLLBACK: "복원",
}
# ── 예약 상태 (cafe24_product_schedules.status) ──
STATUS_PENDING = "PENDING"
STATUS_PROCESSING = "PROCESSING"
STATUS_SUCCESS = "SUCCESS"
STATUS_FAILED = "FAILED"
STATUS_CANCELLED = "CANCELLED"
SCHEDULE_STATUSES: tuple[str, ...] = (
STATUS_PENDING,
STATUS_PROCESSING,
STATUS_SUCCESS,
STATUS_FAILED,
STATUS_CANCELLED,
)
SCHEDULE_STATUS_LABELS: dict[str, str] = {
STATUS_PENDING: "대기",
STATUS_PROCESSING: "실행중",
STATUS_SUCCESS: "완료",
STATUS_FAILED: "실패",
STATUS_CANCELLED: "취소",
}
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
# 예약 실패 시 최대 재시도 횟수
MAX_RETRY = 3
# 종료 후 동작 (cafe24_product_schedules.end_action)
END_NONE = ""
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
END_REVISION = "revision" # 지정한 버전 적용
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
def is_editable(status: str) -> bool:
"""예약을 수정/취소할 수 있는 상태인지."""
return (status or "").strip().upper() in EDITABLE_STATUSES
def can_retry(retry_count: int) -> bool:
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
try:
return int(retry_count) < MAX_RETRY
except (TypeError, ValueError):
return False
def retry_backoff_seconds(retry_count: int) -> int:
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
table = (60, 300, 900)
try:
index = max(0, int(retry_count))
except (TypeError, ValueError):
index = 0
return table[min(index, len(table) - 1)]
def normalize_revision_type(value: str) -> str:
text = (value or "").strip().upper()
return text if text in REVISION_TYPES else REVISION_DRAFT
def parse_product_no(value: object) -> int:
"""상품번호 정규화. 잘못된 값이면 ValueError."""
try:
number = int(str(value).strip())
except (TypeError, ValueError):
raise ValueError("상품번호는 숫자여야 합니다.") from None
if number <= 0:
raise ValueError("상품번호는 1 이상이어야 합니다.")
return number
@@ -0,0 +1,9 @@
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #}
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/">상품관리</a>
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/schedules">예약관리</a>
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
href="/cafe24/system">시스템</a>
</div>
@@ -0,0 +1,17 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
<div class="erp-card cf24-empty">
<h3>상품 목록은 Phase 2 에서 열립니다.</h3>
<p>
먼저 <a href="/cafe24/system">시스템 → 카페24 연결</a>에서 인증을 완료하세요.
연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다.
</p>
</div>
{% endblock %}
@@ -0,0 +1,137 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814a" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
{# ── 연결 상태 ───────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>카페24 연결</h3>
{% if status.connected %}
<span class="erp-badge cf24-badge-ok">연결됨</span>
{% else %}
<span class="erp-badge cf24-badge-off">연결 안 됨</span>
{% endif %}
</div>
{% if status.missing %}
<div class="cf24-flash cf24-flash-err">
다음 환경변수가 설정되지 않았습니다:
<code>{{ status.missing | join(', ') }}</code><br />
<code>.env</code> 에 추가한 뒤 컨테이너를 재기동하세요.
</div>
{% endif %}
{% if status.reason %}<p class="cf24-muted">{{ status.reason }}</p>{% endif %}
<table class="erp-table cf24-kv">
<tbody>
<tr><th>쇼핑몰 ID</th><td>{{ status.mall_id or '—' }}</td></tr>
<tr><th>API 버전</th><td>{{ api_version }}</td></tr>
<tr><th>요청 권한(scope)</th><td><code>{{ scopes }}</code></td></tr>
<tr><th>Redirect URI</th><td><code>{{ redirect_uri or '—' }}</code></td></tr>
<tr>
<th>승인된 권한</th>
<td>{% if status.scopes %}<code>{{ status.scopes }}</code>{% else %}—{% endif %}</td>
</tr>
<tr>
<th>Access Token 만료</th>
<td>
{{ status.access_token_expires_at or '—' }}
{% if status.access_expired %}<span class="cf24-muted">(만료 — 다음 호출 시 자동 갱신)</span>{% endif %}
</td>
</tr>
<tr><th>Refresh Token 만료</th><td>{{ status.refresh_token_expires_at or '—' }}</td></tr>
<tr><th>마지막 갱신</th><td>{{ status.last_refreshed_at or '—' }}</td></tr>
<tr><th>연결한 사람</th><td>{{ status.connected_by or '—' }}</td></tr>
{% if status.last_error %}
<tr><th>마지막 오류</th><td class="cf24-err">{{ status.last_error }}</td></tr>
{% endif %}
</tbody>
</table>
{% if is_admin %}
<div class="cf24-actions">
<a class="erp-btn erp-btn-primary" href="/cafe24/system/oauth/start">
{% if status.connected %}카페24 재연결{% else %}카페24 연결{% endif %}
</a>
{% if status.connected or status.needs_reauth %}
<form method="post" action="/cafe24/system/oauth/disconnect" style="display:inline;"
onsubmit="return confirm('저장된 카페24 토큰을 삭제합니다. 계속할까요?\n(변경 이력·예약 데이터는 지워지지 않습니다)');">
<button type="submit" class="erp-btn erp-btn-outline">연결 해제</button>
</form>
{% endif %}
</div>
{% else %}
<p class="cf24-muted">카페24 연결 변경은 관리자만 할 수 있습니다.</p>
{% endif %}
</div>
{# ── 작업 로그 ───────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head"><h3>작업 로그</h3><span class="cf24-muted">최근 50건</span></div>
{% if audit_logs %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr><th>시각</th><th>작업자</th><th>작업</th><th>상품</th><th>결과</th><th>내용</th></tr>
</thead>
<tbody>
{% for log in audit_logs %}
<tr>
<td class="cf24-nowrap">{{ log.created_at }}</td>
<td>{{ log.actor or '—' }}</td>
<td>{{ log.action }}</td>
<td>{{ log.product_no or '—' }}</td>
<td class="{% if log.result == 'FAIL' %}cf24-err{% endif %}">{{ log.result or '—' }}</td>
<td>{{ log.detail or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cf24-muted">아직 기록된 작업이 없습니다.</p>
{% endif %}
</div>
{# ── API 로그 ────────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>카페24 API 로그</h3>
<span class="cf24-muted">최근 50건 · 토큰/시크릿은 기록하지 않습니다</span>
</div>
{% if api_logs %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr><th>시각</th><th>메서드</th><th>엔드포인트</th><th>상품</th><th>상태</th><th>결과</th><th>소요</th><th>오류</th></tr>
</thead>
<tbody>
{% for log in api_logs %}
<tr>
<td class="cf24-nowrap">{{ log.created_at }}</td>
<td>{{ log.method }}</td>
<td><code>{{ log.endpoint }}</code></td>
<td>{{ log.product_no or '—' }}</td>
<td>{{ log.http_status or '—' }}</td>
<td class="{% if log.result != 'SUCCESS' %}cf24-err{% endif %}">{{ log.result }}</td>
<td class="cf24-nowrap">{{ log.duration_ms }}ms</td>
<td>{{ log.error_message or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cf24-muted">아직 API 호출 기록이 없습니다.</p>
{% endif %}
</div>
{% endblock %}
+252
View File
@@ -0,0 +1,252 @@
"""카페24 모듈 순수 로직 + 토큰/암호화 테스트.
DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입).
python -m app.modules.cafe24.tests.test_cafe24
또는 pytest 로 실행 가능.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from datetime import timedelta
from app.integrations.cafe24 import config as cfgmod
from app.integrations.cafe24 import crypto, oauth, tokens
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
from app.modules.cafe24 import store
from app.timezone import now_kst
SECRET = "unit-test-secret"
_TEST_ENV = {
"CAFE24_MALL_ID": "testmall",
"CAFE24_CLIENT_ID": "cid",
"CAFE24_CLIENT_SECRET": "csecret",
"CAFE24_REDIRECT_URI": "http://localhost:8080/cafe24/oauth/callback",
"CAFE24_TOKEN_SECRET": SECRET,
}
def _config():
"""환경변수에 의존하지 않도록 테스트용 값을 주입해 설정을 만든다."""
saved = {k: os.environ.get(k) for k in _TEST_ENV}
os.environ.update(_TEST_ENV)
try:
return cfgmod.load_config()
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
class _FakeRepo:
"""Cafe24Store 의 토큰 3개 메서드만 흉내낸다."""
def __init__(self, row=None):
self.row = row
self.saves: list[dict] = []
def get_token_row(self, mall_id):
return self.row
def save_token_row(self, *, mall_id, **fields):
self.saves.append(fields)
if self.row is None:
self.row = {"mall_id": mall_id}
self.row.update(fields)
@contextmanager
def token_lock(self, mall_id):
outer = self
class Handle:
row = outer.row
def save(self, **fields):
outer.save_token_row(mall_id=mall_id, **fields)
yield Handle()
def _row(**overrides):
row = {
"mall_id": "testmall",
"access_token": crypto.encrypt("AT", secret=SECRET),
"refresh_token": crypto.encrypt("RT", secret=SECRET),
"access_token_expires_at": now_kst() + timedelta(hours=1),
"refresh_token_expires_at": now_kst() + timedelta(days=13),
"scopes": "mall.read_product,mall.write_product",
"last_refreshed_at": now_kst(),
"last_error": "",
"connected_by": "king@dbxcorp.co.kr",
}
row.update(overrides)
return row
# ════════════════════════════════════════════════════════════
# 암호화
# ════════════════════════════════════════════════════════════
def test_crypto_roundtrip():
token = "ACCESS-TOKEN-한글-123"
encrypted = crypto.encrypt(token, secret=SECRET)
assert encrypted != token and token not in encrypted
assert crypto.decrypt(encrypted, secret=SECRET) == token
def test_crypto_empty_passthrough():
assert crypto.encrypt("", secret=SECRET) == ""
assert crypto.decrypt("", secret=SECRET) == ""
def test_crypto_wrong_secret_raises():
encrypted = crypto.encrypt("AT", secret=SECRET)
try:
crypto.decrypt(encrypted, secret="다른키")
except Cafe24ConfigError:
return
raise AssertionError("키가 바뀌면 Cafe24ConfigError 가 나야 한다")
def test_crypto_requires_secret():
try:
crypto.encrypt("x", secret="")
except Cafe24ConfigError:
return
raise AssertionError("CAFE24_TOKEN_SECRET 없으면 예외여야 한다")
# ════════════════════════════════════════════════════════════
# 설정 / 인증 URL
# ════════════════════════════════════════════════════════════
def test_config_basics():
config = _config()
assert config.configured
assert config.missing == []
assert config.api_base == "https://testmall.cafe24api.com/api/v2"
assert config.scope_param == "mall.read_product,mall.write_product"
def test_authorize_url_has_state_and_no_secret():
url = oauth.build_authorize_url(_config(), state="STATE123")
assert url.startswith("https://testmall.cafe24api.com/api/v2/oauth/authorize?")
assert "state=STATE123" in url
# client_secret 은 authorize 단계에 절대 실리면 안 된다.
assert "csecret" not in url
# ════════════════════════════════════════════════════════════
# 토큰 상태 / 자동 갱신
# ════════════════════════════════════════════════════════════
def test_status_without_token():
status = tokens.TokenService(_FakeRepo(None), _config()).status()
assert status["connected"] is False
assert status["needs_reauth"] is True
def test_status_never_leaks_token_values():
service = tokens.TokenService(_FakeRepo(_row()), _config())
status = service.status()
assert status["connected"] is True
assert "AT" not in str(status) and "RT" not in str(status)
def test_valid_token_returned_without_refresh():
service = tokens.TokenService(_FakeRepo(_row()), _config())
assert service.get_access_token() == "AT"
def test_expired_refresh_token_needs_reauth():
row = _row(refresh_token_expires_at=now_kst() - timedelta(days=1))
assert tokens.TokenService(_FakeRepo(row), _config()).status()["needs_reauth"] is True
def test_expired_access_token_triggers_refresh():
"""만료된 access token 은 refresh 후 새 값을 돌려주고, 저장은 암호문으로 한다."""
repo = _FakeRepo(_row(access_token_expires_at=now_kst() - timedelta(minutes=5)))
service = tokens.TokenService(repo, _config())
seen: list[str] = []
def fake_refresh(config, *, refresh_token):
seen.append(refresh_token)
return oauth.TokenBundle(
access_token="NEW-AT",
refresh_token="NEW-RT",
access_token_expires_at=now_kst() + timedelta(hours=2),
refresh_token_expires_at=now_kst() + timedelta(days=14),
scopes="mall.read_product,mall.write_product",
)
original = tokens.refresh_tokens
tokens.refresh_tokens = fake_refresh
try:
assert service.get_access_token() == "NEW-AT"
finally:
tokens.refresh_tokens = original
assert seen == ["RT"] # 복호화된 refresh token 이 전달돼야 한다
saved = repo.saves[-1]
assert saved["access_token"] != "NEW-AT" # 평문 저장 금지
assert crypto.decrypt(saved["access_token"], secret=SECRET) == "NEW-AT"
assert saved["last_error"] == ""
def test_dead_refresh_token_raises_auth_error():
row = _row(
access_token_expires_at=now_kst() - timedelta(minutes=1),
refresh_token_expires_at=now_kst() - timedelta(days=1),
)
service = tokens.TokenService(_FakeRepo(row), _config())
try:
service.get_access_token()
except Cafe24AuthError as exc:
assert exc.needs_reauth is True
return
raise AssertionError("refresh token 만료 시 Cafe24AuthError 여야 한다")
# ════════════════════════════════════════════════════════════
# store.py 순수 로직
# ════════════════════════════════════════════════════════════
def test_schedule_editable_only_when_pending():
assert store.is_editable("PENDING") is True
for locked in ("PROCESSING", "SUCCESS", "FAILED", "CANCELLED"):
assert store.is_editable(locked) is False, locked
def test_retry_budget_and_backoff():
assert all(store.can_retry(i) for i in range(store.MAX_RETRY))
assert store.can_retry(store.MAX_RETRY) is False
# 무한 재시도 방지 — 간격은 증가하되 상한이 있다.
assert store.retry_backoff_seconds(0) < store.retry_backoff_seconds(1)
assert store.retry_backoff_seconds(99) == store.retry_backoff_seconds(2)
def test_normalize_revision_type():
assert store.normalize_revision_type("backup") == store.REVISION_BACKUP
assert store.normalize_revision_type("nope") == store.REVISION_DRAFT
def test_parse_product_no():
assert store.parse_product_no(" 123 ") == 123
for bad in ("abc", "0", "-3", None, ""):
try:
store.parse_product_no(bad)
except ValueError:
continue
raise AssertionError(f"{bad!r} 는 거부해야 한다")
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print("PASS", fn.__name__)
print(f"\n{len(fns)} tests passed.")
if __name__ == "__main__":
_run_all()
+96
View File
@@ -0,0 +1,96 @@
/* 카페24 상품관리 모듈 전용 스타일.
전역(erp.css) 은 건드리지 않는다. 클래스 접두사: cf24-
색상은 erp.css 의 :root 토큰을 재사용한다. */
.cf24-card {
margin-bottom: var(--sp-16, 16px);
}
.cf24-card-head {
display: flex;
align-items: center;
gap: var(--sp-8, 8px);
margin-bottom: var(--sp-12, 12px);
}
.cf24-card-head h3 {
margin: 0;
font-size: var(--text-heading, 18px);
letter-spacing: -0.45px;
}
.cf24-muted {
color: var(--color-midtone-gray, #737373);
font-size: var(--text-caption, 12px);
}
.cf24-err {
color: var(--color-callout-red, #c22b10);
}
.cf24-nowrap {
white-space: nowrap;
}
/* 상태 배지 */
.cf24-badge-ok {
background: var(--color-success-green, #10c22b);
color: #fff;
}
.cf24-badge-off {
background: var(--color-ghost-gray, #f2f2f2);
color: var(--color-rich-black, #0a0a0a);
}
/* 안내/오류 배너 */
.cf24-flash {
padding: var(--sp-10, 10px) var(--sp-12, 12px);
border-radius: var(--r-lg, 10px);
margin-bottom: var(--sp-12, 12px);
font-size: var(--text-body, 14px);
}
.cf24-flash-ok {
background: #eefaf0;
border: 1px solid var(--color-success-green, #10c22b);
}
.cf24-flash-err {
background: #fdefec;
border: 1px solid var(--color-callout-red, #c22b10);
}
/* 키-값 표 */
.cf24-kv th {
width: 180px;
text-align: left;
color: var(--color-midtone-gray, #737373);
font-weight: 500;
white-space: nowrap;
}
.cf24-actions {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
margin-top: var(--sp-12, 12px);
}
/* 넓은 로그 표는 카드 안에서만 가로 스크롤 */
.cf24-scroll {
overflow-x: auto;
}
.cf24-scroll code {
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
}
.cf24-empty {
padding: var(--sp-24, 24px);
}
.cf24-empty h3 {
margin: 0 0 var(--sp-8, 8px);
}
+1
View File
@@ -31,6 +31,7 @@ MODULE_KEYS: tuple[str, ...] = (
"malaysia",
"dispatch",
"project",
"cafe24",
"expense_approver",
"vacation_approver",
)
+164
View File
@@ -0,0 +1,164 @@
# 카페24 상품 상세페이지 관리 모듈 (cafe24)
> 카페24 관리자 페이지에 직접 들어가지 않고 상품 상세페이지를 조회·편집·예약
> 적용하고, 언제든 이전 상태로 되돌리기 위한 운영용 모듈.
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
> OAuth + 권한키 `cafe24` 를 재사용한다.
---
## 1. 구조 — 왜 두 곳으로 나눴나
향후 **카페24 주문관리**(주문 조회·송장 일괄등록·취소/반품/교환)를 같은
프로젝트에 추가할 예정이다. 그래서 카페24 인증/전송은 상품관리에 종속시키지
않고 공통 계층으로 분리했다.
```
app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유)
├─ config.py 환경변수 → Cafe24Config (하드코딩 금지)
├─ crypto.py 토큰 Fernet 암복호화
├─ oauth.py 인증 URL / code→token / refresh
├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그
├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가)
└─ errors.py 공통 예외
app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · index.html · system.html
```
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
`app.integrations.cafe24``Cafe24Client` 를 통한다(재시도·로그·토큰 갱신이
한 곳에 모여 있어야 하기 때문).
핸들러는 `async def` 가 아니라 **`def`(동기)** 로 선언한다. 카페24 API·DB 호출이
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
---
## 2. 화면 / 경로
| 경로 | 화면 | 권한 |
| --- | --- | --- |
| `GET /cafe24/` | 상품 목록 (Phase 2) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
| `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** |
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
---
## 3. OAuth 흐름
```
관리자 [카페24 연결]
↓ state 생성 → 세션 저장
GET /cafe24/system/oauth/start → 302 카페24 인증 페이지
↓ 사용자 승인
GET /cafe24/oauth/callback?code=&state=
↓ 세션 state 와 대조 (불일치 시 토큰 교환 거부 — CSRF 방어)
code → access/refresh token 교환
↓ Fernet 암호화
cafe24_oauth_tokens 저장
```
- scope 는 `app/integrations/cafe24/config.py``PRODUCT_SCOPES` =
`mall.read_product`, `mall.write_product`.
주문관리 추가 시 `ORDER_SCOPES` 를 합쳐 넘기고, 카페24 개발자센터 앱에서도
권한을 추가한 뒤 **재인증**하면 된다.
- access token 은 만료 2분 전부터 자동 갱신된다. 갱신은 토큰 행을
`SELECT ... FOR UPDATE` 로 잠근 채 수행 — web 컨테이너와 worker 컨테이너가
동시에 refresh 해서 한쪽 토큰이 무효화되는 것을 막는다(카페24는 refresh
token 을 회전시킨다).
- refresh token 이 만료되면 자동 복구가 불가능하므로 화면에 "재연결 필요"를
표시한다.
---
## 4. 보안 규칙 (반드시 지킬 것)
- `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`.
- **로그·예외 메시지·템플릿에 토큰/시크릿을 절대 출력하지 않는다.**
`cafe24_api_logs` 에도 Authorization 헤더를 기록하지 않는다.
- 토큰은 DB 에 Fernet 암호문으로만 저장한다(`CAFE24_TOKEN_SECRET`).
- 카페24 연결/해제는 `is_admin` 전용.
- OAuth 콜백은 세션 `state` 대조 후에만 code 를 교환한다.
- SQL 은 `%s` 플레이스홀더만 사용한다(문자열 조립 금지).
---
## 5. 설치 / 실행
### 5-1. 카페24 개발자센터 앱 등록 (사람이 해야 하는 일)
1. <https://developers.cafe24.com> 로그인 → 앱 생성
2. Redirect URI 를 `.env``CAFE24_REDIRECT_URI`**정확히 동일하게** 등록
(운영: `https://dbx.no1king.freeddns.org/cafe24/oauth/callback`)
3. 권한(Scope)에 `mall.read_product`, `mall.write_product` 체크
4. 발급된 Client ID / Client Secret 을 `.env` 에 기입
### 5-2. DB 초기화 (superuser 로 1회)
```bash
read -s -p "cafe24_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cafe24_db_init.sql
```
### 5-3. .env
```
CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
CAFE24_MALL_ID=miraskitchen
CAFE24_CLIENT_ID=...
CAFE24_CLIENT_SECRET=...
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
CAFE24_API_VERSION=2026-03-01
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
```
### 5-4. 재기동 + 권한 부여
```bash
cd /opt/www/main && docker compose up -d --build web
```
관리자 페이지(`/admin`)에서 직원에게 **카페24 상품관리** 권한을 부여한다.
---
## 6. 테스트
```bash
python -m app.modules.cafe24.tests.test_cafe24
```
DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노출(토큰 미유출),
재시도 예산, 예약 상태 전이를 검증한다.
---
## 7. 진행 상태
| Phase | 내용 | 상태 |
| --- | --- | --- |
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
| 2 | 상품 목록·검색·현재 HTML 조회 | 예정 |
| 3 | Monaco 편집 · 미리보기 · Diff · 초안 | 예정 |
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | 예정 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
| 6 | 자동 종료/복원 · 롤백 | 예정 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | 예정 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile`
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
`docker-compose.yml` 에 같은 이미지로 `dbx-cafe24-worker` 서비스를 추가해
`python -m app.modules.cafe24.worker --loop 60` 으로 돌린다.
+41
View File
@@ -10,6 +10,7 @@
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 |
| `cafe24_db` | 카페24 연동 — OAuth 토큰/상품 캐시/상세페이지 버전/예약/감사·API 로그 |
---
@@ -290,6 +291,46 @@ cd /opt/www/main && docker compose up -d --build
---
## cafe24_db 스키마 / 초기화
DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이블·인덱스·트리거를 한 번에 생성. **JSON 폴백 없음**`CAFE24_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
테이블:
| 테이블 | 용도 |
| --- | --- |
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 |
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
| `cafe24_api_logs` | 카페24 API 호출 기록. **토큰/Authorization/client_secret 미기록** |
핵심 규칙:
- 카페24에 쓰기 직전 **반드시 현재 HTML 을 다시 조회해 `BACKUP` revision 으로 저장**한다. 로컬 DB 의 마지막 값을 현재값으로 가정하지 않는다.
- 예약의 `restore_revision_id`**예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
- 토큰 암호화 키는 `.env``CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
### 운영 서버 초기화 (1회, 사용자 승인 후)
```bash
read -s -p "cafe24_app password: " APP_PWD; echo
docker exec -i postgres-db psql -U postgres \
-v app_password="$APP_PWD" \
< scripts/sql/cafe24_db_init.sql
# main-app .env 에 추가:
# CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
# CAFE24_MALL_ID / CAFE24_CLIENT_ID / CAFE24_CLIENT_SECRET / CAFE24_REDIRECT_URI
# CAFE24_TOKEN_SECRET=$(openssl rand -hex 32)
cd /opt/www/main && docker compose up -d --build web
```
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 상세는 `docs/CAFE24_MODULE.md`.
---
## 백업 / 복구 (안전 절차)
### 백업
+2
View File
@@ -10,3 +10,5 @@ python-multipart>=0.0.20
openpyxl>=3.1
pdfplumber>=0.11
pillow>=10.0
# 카페24 OAuth 토큰 암호화 저장(Fernet) — app/integrations/cafe24/crypto.py
cryptography>=42.0
+235
View File
@@ -0,0 +1,235 @@
-- =====================================================================
-- cafe24_db 초기화 스크립트 (PostgreSQL) — 카페24 상품 상세페이지 관리
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
--
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
--
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
--
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
-- read -s -p "cafe24_app password: " APP_PWD; echo
-- docker exec -i postgres-db psql -U postgres \
-- -v app_password="$APP_PWD" \
-- < scripts/sql/cafe24_db_init.sql
--
-- 2) main-app .env 에 연결 정보 등록
-- CAFE24_DB_URL=postgresql://cafe24_app:<APP_PWD>@postgres-db:5432/cafe24_db
--
-- 3) main-app 재기동
-- cd /opt/www/main && docker compose up -d --build web
--
-- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - OAuth 토큰(access/refresh)은 애플리케이션에서 Fernet 으로 암호화한 뒤
-- 저장한다. 키는 .env 의 CAFE24_TOKEN_SECRET. 이 DB 에 평문 토큰은 없다.
-- - cafe24_api_logs 에는 Authorization 헤더/토큰/client_secret 을 절대
-- 기록하지 않는다(엔드포인트·상태코드·소요시간·오류메시지만).
-- - 향후 카페24 주문관리 모듈도 이 DB(특히 cafe24_oauth_tokens)를 재사용한다.
-- =====================================================================
\set ON_ERROR_STOP on
-- DB 가 없을 때만 생성
SELECT 'CREATE DATABASE cafe24_db ENCODING ''UTF8'' TEMPLATE template0'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cafe24_db')
\gexec
-- 앱 전용 로그인 역할
SELECT 'CREATE ROLE cafe24_app LOGIN PASSWORD ' || quote_literal(:'app_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cafe24_app')
\gexec
-- 항상 최신 비밀번호로 동기화
SELECT 'ALTER ROLE cafe24_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
\gexec
GRANT CONNECT ON DATABASE cafe24_db TO cafe24_app;
-- cafe24_db 컨텍스트로 전환
\connect cafe24_db
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
CREATE OR REPLACE FUNCTION cafe24_set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ════════════════════════════════════════════════════════════
-- 1) OAuth 토큰 (쇼핑몰 1개당 1행)
-- access_token / refresh_token 은 Fernet 암호문(TEXT)으로 저장한다.
-- 상품관리 + 향후 주문관리가 공유한다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_oauth_tokens (
id BIGSERIAL PRIMARY KEY,
mall_id TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL DEFAULT '',
refresh_token TEXT NOT NULL DEFAULT '',
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scopes TEXT NOT NULL DEFAULT '',
last_refreshed_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
connected_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_cafe24_oauth_tokens_updated ON cafe24_oauth_tokens;
CREATE TRIGGER trg_cafe24_oauth_tokens_updated
BEFORE UPDATE ON cafe24_oauth_tokens
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 2) 상품 캐시 (source of truth 는 언제나 Cafe24. 목록/검색 속도용)
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_products (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL UNIQUE,
product_code TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
display BOOLEAN NOT NULL DEFAULT TRUE,
selling BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);
DROP TRIGGER IF EXISTS trg_cafe24_products_updated ON cafe24_products;
CREATE TRIGGER trg_cafe24_products_updated
BEFORE UPDATE ON cafe24_products
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 3) 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
-- revision_type:
-- SYNC Cafe24 에서 읽어온 현재값 스냅샷
-- DRAFT 저장만 한 초안(미적용)
-- BACKUP Cafe24 에 쓰기 직전 자동 백업 ← 복원 기준
-- MANUAL 즉시 적용한 내용
-- SCHEDULED 예약으로 적용한 내용
-- ROLLBACK 과거 버전을 되돌린 내용
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_revisions (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
html_content TEXT NOT NULL DEFAULT '',
revision_type TEXT NOT NULL DEFAULT 'DRAFT',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_cafe24_revision_type CHECK (
revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK')
)
);
CREATE INDEX IF NOT EXISTS idx_cafe24_revisions_product
ON cafe24_product_revisions (product_no, created_at DESC, id DESC);
-- ════════════════════════════════════════════════════════════
-- 4) 예약 작업
-- 한 상품 = 한 행. 일괄 예약은 parent_job_id 로 묶되 행은 개별이므로
-- 한 상품 실패가 나머지를 막지 않는다.
-- end_at/end_action: 프로모션 종료 후 자동 복원용.
-- restore = 적용 직전 BACKUP(restore_revision_id)으로 되돌림
-- revision = end_revision_id 를 적용
-- restore_revision_id 는 예약 "실행 순간" Cafe24 에서 다시 읽어 만든
-- BACKUP revision 을 가리킨다(예약 생성 시점 값이 아님).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_product_schedules (
id BIGSERIAL PRIMARY KEY,
product_no BIGINT NOT NULL,
revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE RESTRICT,
scheduled_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
restore_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
end_at TIMESTAMPTZ,
end_action TEXT NOT NULL DEFAULT '',
end_revision_id BIGINT REFERENCES cafe24_product_revisions(id) ON DELETE SET NULL,
parent_job_id TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
CONSTRAINT chk_cafe24_schedule_status CHECK (
status IN ('PENDING','PROCESSING','SUCCESS','FAILED','CANCELLED')
),
CONSTRAINT chk_cafe24_schedule_end_action CHECK (
end_action IN ('','restore','revision')
)
);
-- worker 의 due 조회 인덱스 (PENDING + 시간순)
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_due
ON cafe24_product_schedules (scheduled_at)
WHERE status = 'PENDING';
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_product
ON cafe24_product_schedules (product_no, scheduled_at DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_schedules_parent
ON cafe24_product_schedules (parent_job_id);
DROP TRIGGER IF EXISTS trg_cafe24_schedules_updated ON cafe24_product_schedules;
CREATE TRIGGER trg_cafe24_schedules_updated
BEFORE UPDATE ON cafe24_product_schedules
FOR EACH ROW EXECUTE FUNCTION cafe24_set_updated_at();
-- ════════════════════════════════════════════════════════════
-- 5) 작업 감사 로그 (누가 무엇을 바꿨나)
-- worker 가 수행한 작업은 actor='SCHEDULER'.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_audit_logs (
id BIGSERIAL PRIMARY KEY,
actor TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL DEFAULT '',
product_no BIGINT,
revision_id BIGINT,
schedule_id BIGINT,
result TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_created ON cafe24_audit_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_audit_product ON cafe24_audit_logs (product_no, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 6) Cafe24 API 호출 로그 (실패 분석용 최소 정보)
-- ⚠️ Authorization 헤더 / access_token / refresh_token / client_secret 은
-- 절대 저장하지 않는다.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS cafe24_api_logs (
id BIGSERIAL PRIMARY KEY,
endpoint TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
product_no BIGINT,
http_status INTEGER,
result TEXT NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_created ON cafe24_api_logs (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_cafe24_api_logs_status ON cafe24_api_logs (http_status);
-- ════════════════════════════════════════════════════════════
-- 7) 권한 (cafe24_app: CRUD only, DDL 없음)
-- ════════════════════════════════════════════════════════════
GRANT USAGE ON SCHEMA public TO cafe24_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON
cafe24_oauth_tokens, cafe24_products, cafe24_product_revisions,
cafe24_product_schedules, cafe24_audit_logs, cafe24_api_logs
TO cafe24_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cafe24_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO cafe24_app;
SELECT 'cafe24_db ready' AS status;