93037411fb
- product-swiper.js는 상품이 아니라 카페24 "디자인 보관함" 스킨 파일이라
Admin API(OAuth)로는 접근 불가 — 실물 확인(스크린샷) 결과 디자인 보관함
FTP 계정(호스트/포트/ID/PW, OAuth와 별개)으로만 읽기/쓰기 가능.
app/integrations/cafe24/design_ftp.py 를 표준 ftplib 로 새로 추가.
- 상단 탭에 "모바일 스와이프" 버튼 추가. 편집 화면(swiper.html)은 상세페이지
편집기(_editor.html)와 완전히 같은 문법강조·색상·단축키 JS를 그대로 옮겨
씀(요청사항) — 상품 전용 UI(목록·진열/판매·예약)는 제외.
- 적용 순서도 상세페이지와 동일한 원칙: FTP에서 현재값 재조회 → BACKUP →
지문 대조(충돌 거부) → FTP 쓰기 → MANUAL 버전 + 감사로그(apply_swiper).
textarea의 CRLF는 적용 전 LF로 정규화(안 하면 매번 "변경됨"으로 오판).
- 버전 이력은 cafe24_product_revisions(product_no NOT NULL)에 넣을 수 없어
새 테이블 cafe24_swiper_revisions 추가(scripts/sql/cafe24_db_003_*.sql,
서버에서 별도 실행 필요).
- 신규 env: CAFE24_FTP_HOST(미설정 시 {mall_id}.ftp.cafe24.com)/PORT/USER/
PASSWORD, CAFE24_SWIPER_FTP_PATH. .env.example·문서 갱신.
- 유닛테스트 4건 추가(FTP config 기본값, 가짜 FTP로 읽기/쓰기 왕복·오류 처리).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
175 lines
7.4 KiB
Python
175 lines
7.4 KiB
Python
"""카페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")
|
|
|
|
# 디자인(테마) — **요청하지 않는다.** 아래 확인 결과 때문이다.
|
|
#
|
|
# 운영몰에서 직접 호출해 확인한 사실(2026-08-14):
|
|
# GET /admin/themes → 조회는 가능(디자인 권한 필요)하지만 테마 "목록"뿐이며
|
|
# 스킨 파일 내용은 응답에 없다.
|
|
# GET /admin/themes/pages → No API found. (버전 2026-03-01 에 존재하지 않음)
|
|
# 스킨 HTML 파일(product/detail.html 등)을 읽거나 쓰는 엔드포인트는 없다.
|
|
#
|
|
# 즉 스킨에 박힌 값(예: 상단 공통 홍보를 제외할 상품번호 목록)을 API 로 고치는
|
|
# 방법은 없다. 스킨은 카페24 관리자에서 직접 관리해야 한다.
|
|
#
|
|
# 페이지에 코드를 주입하는 유일한 수단은 스크립트 태그(/admin/scripttags)이며,
|
|
# 디자인이 아니라 **mall.write_store(상점)** 권한이 필요하고 인라인 코드가 아닌
|
|
# 외부 HTTPS URL 만 받는다. 쓰려면 우리 서버에 공개 JS 엔드포인트가 필요하다.
|
|
DESIGN_SCOPES: tuple[str, ...] = ("mall.read_design", "mall.write_design")
|
|
STORE_SCOPES: tuple[str, ...] = ("mall.read_store", "mall.write_store")
|
|
|
|
# 실제로 요청하는 scope 묶음. 개발자센터 앱에 등록된 권한과 어긋나면 인증이
|
|
# 거부되므로, 여기에 추가할 때는 앱 권한도 함께 확인해야 한다.
|
|
# 쓰지 않는 권한은 요청하지 않는다 — 토큰이 유출돼도 피해 범위를 좁히기 위함.
|
|
DEFAULT_SCOPES: tuple[str, ...] = PRODUCT_SCOPES
|
|
|
|
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, ...]
|
|
# 쇼핑몰 표시 주소(고객이 보는 도메인). 커스텀 도메인은 mall_id 로 알 수 없어
|
|
# 환경변수로 받는다. 미설정 시 카페24 기본 도메인으로 대체한다.
|
|
shop_url: 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)
|
|
|
|
@property
|
|
def shop_base(self) -> str:
|
|
"""고객이 보는 쇼핑몰 주소(끝 슬래시 없음).
|
|
|
|
`CAFE24_SHOP_URL` 이 없으면 카페24 기본 도메인을 쓴다 — 커스텀 도메인을
|
|
모르더라도 항상 유효한 주소가 나온다.
|
|
"""
|
|
url = (self.shop_url or "").strip().rstrip("/")
|
|
if url:
|
|
return url if "://" in url else f"https://{url}"
|
|
return f"https://{self.mall_id}.cafe24.com" if self.mall_id else ""
|
|
|
|
def product_url(self, product_no: int | str) -> str:
|
|
"""상품 상세페이지 다이렉트 주소."""
|
|
base = self.shop_base
|
|
return f"{base}/product/detail.html?product_no={product_no}" if base else ""
|
|
|
|
|
|
def load_config(*, scopes: tuple[str, ...] = DEFAULT_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,
|
|
shop_url=_env("CAFE24_SHOP_URL"),
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 디자인 보관함 FTP — 모바일 스와이프(product-swiper.js) 편집용
|
|
# ════════════════════════════════════════════════════════════
|
|
# Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 수 없다(위 설명 참고). 카페24가
|
|
# 스킨 파일에 제공하는 유일한 프로그램적 접근은 "디자인 보관함" FTP 계정이다
|
|
# (관리자와 무관한 별도 FTP 계정/비밀번호 — 실물 확인: 카페24 관리자 →
|
|
# 디자인 → 웹FTP 화면에 표시된 호스트/포트를 그대로 쓴다).
|
|
DEFAULT_FTP_PORT = 21
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Cafe24FtpConfig:
|
|
host: str
|
|
port: int
|
|
user: str
|
|
password: str
|
|
# 편집 대상 파일의 절대 경로(디자인 보관함 기준). 스킨 번호가 바뀌면 함께 바뀐다.
|
|
swiper_path: str
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self.host and self.user and self.password and self.swiper_path)
|
|
|
|
@property
|
|
def missing(self) -> list[str]:
|
|
pairs = (
|
|
("CAFE24_FTP_HOST", self.host),
|
|
("CAFE24_FTP_USER", self.user),
|
|
("CAFE24_FTP_PASSWORD", self.password),
|
|
("CAFE24_SWIPER_FTP_PATH", self.swiper_path),
|
|
)
|
|
return [name for name, value in pairs if not value]
|
|
|
|
|
|
def load_ftp_config(*, mall_id: str = "") -> Cafe24FtpConfig:
|
|
"""FTP 환경변수를 읽는다. `CAFE24_FTP_HOST` 미설정 시 카페24 기본 규칙
|
|
(`{mall_id}.ftp.cafe24.com`)으로 대체한다(실물 확인된 패턴)."""
|
|
host = _env("CAFE24_FTP_HOST")
|
|
if not host and mall_id:
|
|
host = f"{mall_id}.ftp.cafe24.com"
|
|
try:
|
|
port = int(_env("CAFE24_FTP_PORT", str(DEFAULT_FTP_PORT)))
|
|
except ValueError:
|
|
port = DEFAULT_FTP_PORT
|
|
return Cafe24FtpConfig(
|
|
host=host,
|
|
port=port,
|
|
user=_env("CAFE24_FTP_USER"),
|
|
password=_env("CAFE24_FTP_PASSWORD"),
|
|
# 실물 확인된 기본 경로(미라스키친 mobile11 스킨). 스킨을 바꾸면
|
|
# CAFE24_SWIPER_FTP_PATH 로 덮어쓴다.
|
|
swiper_path=_env(
|
|
"CAFE24_SWIPER_FTP_PATH", "/sde_design/mobile11/product-swiper/product-swiper.js"
|
|
),
|
|
)
|