From c6fb8ed375eb9e6b433a6cc7167033a3c4a3f1e8 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 14 Aug 2026 00:23:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카페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 --- .env.example | 22 ++ CLAUDE.md | 1 + app/integrations/__init__.py | 5 + app/integrations/cafe24/__init__.py | 91 ++++++ app/integrations/cafe24/client.py | 258 ++++++++++++++++++ app/integrations/cafe24/config.py | 74 +++++ app/integrations/cafe24/crypto.py | 51 ++++ app/integrations/cafe24/errors.py | 45 +++ app/integrations/cafe24/oauth.py | 145 ++++++++++ app/integrations/cafe24/products.py | 117 ++++++++ app/integrations/cafe24/tokens.py | 180 ++++++++++++ app/main.py | 19 ++ app/modules/cafe24/__init__.py | 48 ++++ app/modules/cafe24/common.py | 118 ++++++++ app/modules/cafe24/db.py | 237 ++++++++++++++++ app/modules/cafe24/router.py | 56 ++++ app/modules/cafe24/routes_system.py | 162 +++++++++++ app/modules/cafe24/store.py | 106 +++++++ app/modules/cafe24/templates/cafe24/_nav.html | 9 + .../cafe24/templates/cafe24/index.html | 17 ++ .../cafe24/templates/cafe24/system.html | 137 ++++++++++ app/modules/cafe24/tests/__init__.py | 0 app/modules/cafe24/tests/test_cafe24.py | 252 +++++++++++++++++ app/static/cafe24.css | 96 +++++++ app/store.py | 1 + docs/CAFE24_MODULE.md | 164 +++++++++++ docs/DATABASES.md | 41 +++ requirements.txt | 2 + scripts/sql/cafe24_db_init.sql | 235 ++++++++++++++++ 29 files changed, 2689 insertions(+) create mode 100644 app/integrations/__init__.py create mode 100644 app/integrations/cafe24/__init__.py create mode 100644 app/integrations/cafe24/client.py create mode 100644 app/integrations/cafe24/config.py create mode 100644 app/integrations/cafe24/crypto.py create mode 100644 app/integrations/cafe24/errors.py create mode 100644 app/integrations/cafe24/oauth.py create mode 100644 app/integrations/cafe24/products.py create mode 100644 app/integrations/cafe24/tokens.py create mode 100644 app/modules/cafe24/__init__.py create mode 100644 app/modules/cafe24/common.py create mode 100644 app/modules/cafe24/db.py create mode 100644 app/modules/cafe24/router.py create mode 100644 app/modules/cafe24/routes_system.py create mode 100644 app/modules/cafe24/store.py create mode 100644 app/modules/cafe24/templates/cafe24/_nav.html create mode 100644 app/modules/cafe24/templates/cafe24/index.html create mode 100644 app/modules/cafe24/templates/cafe24/system.html create mode 100644 app/modules/cafe24/tests/__init__.py create mode 100644 app/modules/cafe24/tests/test_cafe24.py create mode 100644 app/static/cafe24.css create mode 100644 docs/CAFE24_MODULE.md create mode 100644 scripts/sql/cafe24_db_init.sql diff --git a/.env.example b/.env.example index 77117fc..1174609 100644 --- a/.env.example +++ b/.env.example @@ -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 에서 검색해 등록한다(읽기만). # 미설정 시 검색 비활성 → 수동 등록만 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 9d433a3..c315884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py new file mode 100644 index 0000000..d315e6d --- /dev/null +++ b/app/integrations/__init__.py @@ -0,0 +1,5 @@ +"""외부 서비스 연동 공통 계층. + +모듈(app/modules/*)에 종속되지 않는 재사용 가능한 API 클라이언트를 둔다. +현재: cafe24 (상품관리 + 향후 주문관리가 공유). +""" diff --git a/app/integrations/cafe24/__init__.py b/app/integrations/cafe24/__init__.py new file mode 100644 index 0000000..4b37b13 --- /dev/null +++ b/app/integrations/cafe24/__init__.py @@ -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) diff --git a/app/integrations/cafe24/client.py b/app/integrations/cafe24/client.py new file mode 100644 index 0000000..1bfc00e --- /dev/null +++ b/app/integrations/cafe24/client.py @@ -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) diff --git a/app/integrations/cafe24/config.py b/app/integrations/cafe24/config.py new file mode 100644 index 0000000..c212767 --- /dev/null +++ b/app/integrations/cafe24/config.py @@ -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, + ) diff --git a/app/integrations/cafe24/crypto.py b/app/integrations/cafe24/crypto.py new file mode 100644 index 0000000..14c476b --- /dev/null +++ b/app/integrations/cafe24/crypto.py @@ -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 diff --git a/app/integrations/cafe24/errors.py b/app/integrations/cafe24/errors.py new file mode 100644 index 0000000..77b380b --- /dev/null +++ b/app/integrations/cafe24/errors.py @@ -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 diff --git a/app/integrations/cafe24/oauth.py b/app/integrations/cafe24/oauth.py new file mode 100644 index 0000000..bca360b --- /dev/null +++ b/app/integrations/cafe24/oauth.py @@ -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}, + ) diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py new file mode 100644 index 0000000..7204a73 --- /dev/null +++ b/app/integrations/cafe24/products.py @@ -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")), + } diff --git a/app/integrations/cafe24/tokens.py b/app/integrations/cafe24/tokens.py new file mode 100644 index 0000000..39a8157 --- /dev/null +++ b/app/integrations/cafe24/tokens.py @@ -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 diff --git a/app/main.py b/app/main.py index b26499d..f6afe09 100644 --- a/app/main.py +++ b/app/main.py @@ -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": '', "dispatch": '', "project": '', + "cafe24": '', "modules": '', } body = paths.get(name, paths["modules"]) diff --git a/app/modules/cafe24/__init__.py b/app/modules/cafe24/__init__.py new file mode 100644 index 0000000..eb4ba07 --- /dev/null +++ b/app/modules/cafe24/__init__.py @@ -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) diff --git a/app/modules/cafe24/common.py b/app/modules/cafe24/common.py new file mode 100644 index 0000000..c5a7b45 --- /dev/null +++ b/app/modules/cafe24/common.py @@ -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, + } diff --git a/app/modules/cafe24/db.py b/app/modules/cafe24/db.py new file mode 100644 index 0000000..eae8fae --- /dev/null +++ b/app/modules/cafe24/db.py @@ -0,0 +1,237 @@ +"""cafe24_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴. +- 연결 정보: 환경변수 `CAFE24_DB_URL` + (예: postgresql://cafe24_app:@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"] diff --git a/app/modules/cafe24/router.py b/app/modules/cafe24/router.py new file mode 100644 index 0000000..a5a6b51 --- /dev/null +++ b/app/modules/cafe24/router.py @@ -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) diff --git a/app/modules/cafe24/routes_system.py b/app/modules/cafe24/routes_system.py new file mode 100644 index 0000000..a5628ce --- /dev/null +++ b/app/modules/cafe24/routes_system.py @@ -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) diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py new file mode 100644 index 0000000..8a80e2c --- /dev/null +++ b/app/modules/cafe24/store.py @@ -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 diff --git a/app/modules/cafe24/templates/cafe24/_nav.html b/app/modules/cafe24/templates/cafe24/_nav.html new file mode 100644 index 0000000..8d244f6 --- /dev/null +++ b/app/modules/cafe24/templates/cafe24/_nav.html @@ -0,0 +1,9 @@ +{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #} + diff --git a/app/modules/cafe24/templates/cafe24/index.html b/app/modules/cafe24/templates/cafe24/index.html new file mode 100644 index 0000000..fdd790f --- /dev/null +++ b/app/modules/cafe24/templates/cafe24/index.html @@ -0,0 +1,17 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +{% include "cafe24/_nav.html" %} + +
+

상품 목록은 Phase 2 에서 열립니다.

+

+ 먼저 시스템 → 카페24 연결에서 인증을 완료하세요. + 연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다. +

+
+{% endblock %} diff --git a/app/modules/cafe24/templates/cafe24/system.html b/app/modules/cafe24/templates/cafe24/system.html new file mode 100644 index 0000000..4f8ccef --- /dev/null +++ b/app/modules/cafe24/templates/cafe24/system.html @@ -0,0 +1,137 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +{% include "cafe24/_nav.html" %} + +{% if flash %}
{{ flash }}
{% endif %} +{% if flash_error %}
{{ flash_error }}
{% endif %} + +{# ── 연결 상태 ───────────────────────────────────────────── #} +
+
+

카페24 연결

+ {% if status.connected %} + 연결됨 + {% else %} + 연결 안 됨 + {% endif %} +
+ + {% if status.missing %} +
+ 다음 환경변수가 설정되지 않았습니다: + {{ status.missing | join(', ') }}
+ .env 에 추가한 뒤 컨테이너를 재기동하세요. +
+ {% endif %} + + {% if status.reason %}

{{ status.reason }}

{% endif %} + + + + + + + + + + + + + + + + + + + {% if status.last_error %} + + {% endif %} + +
쇼핑몰 ID{{ status.mall_id or '—' }}
API 버전{{ api_version }}
요청 권한(scope){{ scopes }}
Redirect URI{{ redirect_uri or '—' }}
승인된 권한{% if status.scopes %}{{ status.scopes }}{% else %}—{% endif %}
Access Token 만료 + {{ status.access_token_expires_at or '—' }} + {% if status.access_expired %}(만료 — 다음 호출 시 자동 갱신){% endif %} +
Refresh Token 만료{{ status.refresh_token_expires_at or '—' }}
마지막 갱신{{ status.last_refreshed_at or '—' }}
연결한 사람{{ status.connected_by or '—' }}
마지막 오류{{ status.last_error }}
+ + {% if is_admin %} +
+ + {% if status.connected %}카페24 재연결{% else %}카페24 연결{% endif %} + + {% if status.connected or status.needs_reauth %} +
+ +
+ {% endif %} +
+ {% else %} +

카페24 연결 변경은 관리자만 할 수 있습니다.

+ {% endif %} +
+ +{# ── 작업 로그 ───────────────────────────────────────────── #} +
+

작업 로그

최근 50건
+ {% if audit_logs %} +
+ + + + + + {% for log in audit_logs %} + + + + + + + + + {% endfor %} + +
시각작업자작업상품결과내용
{{ log.created_at }}{{ log.actor or '—' }}{{ log.action }}{{ log.product_no or '—' }}{{ log.result or '—' }}{{ log.detail or '' }}
+
+ {% else %} +

아직 기록된 작업이 없습니다.

+ {% endif %} +
+ +{# ── API 로그 ────────────────────────────────────────────── #} +
+
+

카페24 API 로그

+ 최근 50건 · 토큰/시크릿은 기록하지 않습니다 +
+ {% if api_logs %} +
+ + + + + + {% for log in api_logs %} + + + + + + + + + + + {% endfor %} + +
시각메서드엔드포인트상품상태결과소요오류
{{ log.created_at }}{{ log.method }}{{ log.endpoint }}{{ log.product_no or '—' }}{{ log.http_status or '—' }}{{ log.result }}{{ log.duration_ms }}ms{{ log.error_message or '' }}
+
+ {% else %} +

아직 API 호출 기록이 없습니다.

+ {% endif %} +
+{% endblock %} diff --git a/app/modules/cafe24/tests/__init__.py b/app/modules/cafe24/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/cafe24/tests/test_cafe24.py b/app/modules/cafe24/tests/test_cafe24.py new file mode 100644 index 0000000..119c252 --- /dev/null +++ b/app/modules/cafe24/tests/test_cafe24.py @@ -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() diff --git a/app/static/cafe24.css b/app/static/cafe24.css new file mode 100644 index 0000000..d76ac18 --- /dev/null +++ b/app/static/cafe24.css @@ -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); +} diff --git a/app/store.py b/app/store.py index 63e33e4..6c29fbf 100644 --- a/app/store.py +++ b/app/store.py @@ -31,6 +31,7 @@ MODULE_KEYS: tuple[str, ...] = ( "malaysia", "dispatch", "project", + "cafe24", "expense_approver", "vacation_approver", ) diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md new file mode 100644 index 0000000..cf275ab --- /dev/null +++ b/docs/CAFE24_MODULE.md @@ -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. 로그인 → 앱 생성 +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:@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= +``` + +### 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` 으로 돌린다. diff --git a/docs/DATABASES.md b/docs/DATABASES.md index 86392fd..8b9df31 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -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:@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`. + +--- + ## 백업 / 복구 (안전 절차) ### 백업 diff --git a/requirements.txt b/requirements.txt index 2da3ac2..7c35860 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/sql/cafe24_db_init.sql b/scripts/sql/cafe24_db_init.sql new file mode 100644 index 0000000..528165a --- /dev/null +++ b/scripts/sql/cafe24_db_init.sql @@ -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:@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;