c6fb8ed375
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를 편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통 Integration 계층, cafe24_db, OAuth 연결 화면까지다. 카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고 app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장 일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기 때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서 재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다. 토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와 연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다. 토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가 동시에 갱신하면 한쪽 토큰이 무효화된다. 기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트· 테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None 이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다. 가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를 include 하는 구조에서 순환 import 가 생기기 때문이다. 검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시 토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과. cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""카페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")),
|
|
}
|