feat(cafe24): 모바일 스와이프(product-swiper.js) 편집 화면 추가 (FTP)
- 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>
This commit is contained in:
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from . import products
|
||||
from . import design_ftp, products
|
||||
from .client import Cafe24Client
|
||||
from .config import (
|
||||
DEFAULT_SCOPES,
|
||||
@@ -33,13 +33,16 @@ from .config import (
|
||||
ORDER_SCOPES,
|
||||
PRODUCT_SCOPES,
|
||||
Cafe24Config,
|
||||
Cafe24FtpConfig,
|
||||
load_config,
|
||||
load_ftp_config,
|
||||
)
|
||||
from .errors import (
|
||||
Cafe24ApiError,
|
||||
Cafe24AuthError,
|
||||
Cafe24ConfigError,
|
||||
Cafe24Error,
|
||||
Cafe24FtpError,
|
||||
Cafe24RateLimitError,
|
||||
)
|
||||
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
|
||||
@@ -50,6 +53,9 @@ __all__ = [
|
||||
"build_cafe24_api",
|
||||
"Cafe24Client",
|
||||
"Cafe24Config",
|
||||
"Cafe24FtpConfig",
|
||||
"load_ftp_config",
|
||||
"design_ftp",
|
||||
"TokenService",
|
||||
"TokenBundle",
|
||||
"load_config",
|
||||
@@ -67,6 +73,7 @@ __all__ = [
|
||||
"Cafe24AuthError",
|
||||
"Cafe24RateLimitError",
|
||||
"Cafe24ApiError",
|
||||
"Cafe24FtpError",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -115,3 +115,60 @@ def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""카페24 디자인 보관함 FTP — 스킨에 올라간 개별 파일(예: product-swiper.js) 읽기/쓰기.
|
||||
|
||||
Admin API(OAuth)로는 스킨 파일을 다루지 못한다(`config.py` 상단 설명 참고). 카페24가
|
||||
스킨 파일에 제공하는 유일한 프로그램적 접근은 "디자인 보관함" FTP 계정이며, Admin API
|
||||
와는 별개의 인증(호스트/포트/아이디/비밀번호)이다. 그래서 여기는 `Cafe24Client`
|
||||
(httpx 기반)를 쓰지 않고 표준 라이브러리 `ftplib` 를 직접 쓴다.
|
||||
|
||||
실물 확인(스크린샷): 호스트 `{mall_id}.ftp.cafe24.com`, 포트 21, SSL/TLS 미사용
|
||||
(평문 FTP). 파일은 텍스트(UTF-8)로 다룬다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ftplib
|
||||
import io
|
||||
|
||||
from .config import Cafe24FtpConfig
|
||||
from .errors import Cafe24FtpError
|
||||
|
||||
# 응답이 없을 때 무한 대기하지 않도록 — 관리 화면 요청 안에서 동기 호출되므로
|
||||
# 너무 길면 안 되지만, 파일 하나(수십 KB) 전송에는 충분히 넉넉해야 한다.
|
||||
_TIMEOUT_SECONDS = 20
|
||||
|
||||
|
||||
def _connect(config: Cafe24FtpConfig) -> ftplib.FTP:
|
||||
try:
|
||||
ftp = ftplib.FTP()
|
||||
ftp.connect(config.host, config.port, timeout=_TIMEOUT_SECONDS)
|
||||
ftp.login(config.user, config.password)
|
||||
ftp.set_pasv(True)
|
||||
return ftp
|
||||
except ftplib.all_errors as exc: # OSError 포함 — 연결 실패도 여기서 잡힌다
|
||||
# 비밀번호가 예외 메시지에 섞여 나오지 않는다 — ftplib 오류는 서버 응답
|
||||
# 문자열이며 우리가 보낸 자격증명을 되풀이하지 않는다.
|
||||
raise Cafe24FtpError(f"FTP 연결/로그인에 실패했습니다: {exc}") from exc
|
||||
|
||||
|
||||
def read_text_file(config: Cafe24FtpConfig, path: str) -> str:
|
||||
"""디자인 보관함의 파일 1개를 읽어 텍스트로 돌려준다."""
|
||||
ftp = _connect(config)
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
try:
|
||||
ftp.retrbinary(f"RETR {path}", buf.write)
|
||||
except ftplib.all_errors as exc:
|
||||
raise Cafe24FtpError(f"파일을 읽지 못했습니다({path}): {exc}") from exc
|
||||
try:
|
||||
return buf.getvalue().decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise Cafe24FtpError(f"파일 인코딩을 UTF-8로 해석하지 못했습니다({path}): {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception: # noqa: BLE001 — 정리 실패는 무시
|
||||
ftp.close()
|
||||
|
||||
|
||||
def write_text_file(config: Cafe24FtpConfig, path: str, content: str) -> None:
|
||||
"""디자인 보관함의 파일 1개를 통째로 덮어쓴다."""
|
||||
ftp = _connect(config)
|
||||
try:
|
||||
buf = io.BytesIO(content.encode("utf-8"))
|
||||
try:
|
||||
ftp.storbinary(f"STOR {path}", buf)
|
||||
except ftplib.all_errors as exc:
|
||||
raise Cafe24FtpError(f"파일을 쓰지 못했습니다({path}): {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception: # noqa: BLE001 — 정리 실패는 무시
|
||||
ftp.close()
|
||||
@@ -31,6 +31,10 @@ class Cafe24RateLimitError(Cafe24Error):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Cafe24FtpError(Cafe24Error):
|
||||
"""디자인 보관함 FTP 연결/전송 실패 (product-swiper.js 편집 등)."""
|
||||
|
||||
|
||||
class Cafe24ApiError(Cafe24Error):
|
||||
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user