diff --git a/.env.example b/.env.example index 64f6361..ec50a64 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,17 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어 # 카페24 재연결(재인증)이 필요하다. # CAFE24_TOKEN_SECRET= +# +# ── 모바일 스와이프(product-swiper.js) 편집용 — 디자인 보관함 FTP ── +# Admin API(OAuth)로는 스킨 파일을 못 읽는다. 카페24 관리자 → 디자인 → +# 웹FTP 화면에 표시된 값을 그대로 쓴다(SSL/TLS 미사용, 평문 FTP, 포트 21). +# 호스트 미설정 시 CAFE24_MALL_ID 기준 기본값({mall_id}.ftp.cafe24.com)을 쓴다. +# CAFE24_FTP_HOST=miraskitchen.ftp.cafe24.com +# CAFE24_FTP_PORT=21 +# CAFE24_FTP_USER= +# CAFE24_FTP_PASSWORD= +# 편집 대상 파일의 절대 경로(스킨 번호가 바뀌면 함께 바뀐다). +# CAFE24_SWIPER_FTP_PATH=/sde_design/mobile11/product-swiper/product-swiper.js # ─── 상품 검색 (itemcode_db 읽기 전용) ─── # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). diff --git a/app/integrations/cafe24/__init__.py b/app/integrations/cafe24/__init__.py index 3a00cd2..6cc1cc3 100644 --- a/app/integrations/cafe24/__init__.py +++ b/app/integrations/cafe24/__init__.py @@ -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", ] diff --git a/app/integrations/cafe24/config.py b/app/integrations/cafe24/config.py index 55878de..679a6ef 100644 --- a/app/integrations/cafe24/config.py +++ b/app/integrations/cafe24/config.py @@ -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" + ), + ) diff --git a/app/integrations/cafe24/design_ftp.py b/app/integrations/cafe24/design_ftp.py new file mode 100644 index 0000000..724ca56 --- /dev/null +++ b/app/integrations/cafe24/design_ftp.py @@ -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() diff --git a/app/integrations/cafe24/errors.py b/app/integrations/cafe24/errors.py index 77b380b..b82bd8c 100644 --- a/app/integrations/cafe24/errors.py +++ b/app/integrations/cafe24/errors.py @@ -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 로 재시도 가능 여부를 판단한다.""" diff --git a/app/modules/cafe24/db.py b/app/modules/cafe24/db.py index fbdc9fc..e7d9530 100644 --- a/app/modules/cafe24/db.py +++ b/app/modules/cafe24/db.py @@ -319,6 +319,50 @@ class Cafe24Store: ).fetchone() return self._serialize(row) + # ════════════════════════════════════════════════════════════ + # 모바일 스와이프(product-swiper.js) 버전 (append-only) + # 상품이 아니라 디자인 보관함 FTP 파일이라 별도 테이블을 쓴다 + # (cafe24_product_revisions.product_no 는 NOT NULL). + # ════════════════════════════════════════════════════════════ + SWIPER_FILE_KEY = "product-swiper" + + def add_swiper_revision( + self, *, content: str, revision_type: str, memo: str = "", created_by: str = "" + ) -> int: + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO cafe24_swiper_revisions + (file_key, content, revision_type, memo, created_by) + VALUES (%s,%s,%s,%s,%s) + RETURNING id + """, + ( + self.SWIPER_FILE_KEY, + content or "", + store.normalize_revision_type(revision_type), + (memo or "")[:500], + created_by or "", + ), + ).fetchone() + return int(row["id"]) if row else 0 + + def list_swiper_revisions(self, *, limit: int = 20) -> list[dict[str, Any]]: + """버전 목록. content 는 커질 수 있어 길이만 계산해서 준다.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT id, revision_type, memo, created_by, created_at, + length(content) AS html_length + FROM cafe24_swiper_revisions + WHERE file_key = %s + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (self.SWIPER_FILE_KEY, max(1, min(int(limit), 200))), + ).fetchall() + return [self._serialize(r) for r in rows] + # ════════════════════════════════════════════════════════════ # 예약 (지정 시각에 상세페이지·진열/판매 적용) # 되돌리기는 쓰지 않으므로 end_* 컬럼은 건드리지 않는다. diff --git a/app/modules/cafe24/router.py b/app/modules/cafe24/router.py index f53377a..be2283b 100644 --- a/app/modules/cafe24/router.py +++ b/app/modules/cafe24/router.py @@ -12,6 +12,9 @@ routes_products 상품 목록/검색 · 상세설명 조회·편집·적용 routes_schedules 예약 등록·목록·취소 (실행은 worker.py) routes_system 연결(OAuth)·상태·API 로그·작업 로그 + routes_swiper 모바일 스와이프(product-swiper.js) 편집 — 카페24 디자인 + 보관함 FTP (Admin API 가 아니다. app/integrations/cafe24/ + design_ftp.py 참고) """ from __future__ import annotations @@ -22,6 +25,7 @@ from fastapi import APIRouter from .routes_products import products_router from .routes_schedules import schedules_router +from .routes_swiper import swiper_router from .routes_system import system_router logger = logging.getLogger("cafe24.router") @@ -30,6 +34,7 @@ router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router.include_router(products_router) router.include_router(schedules_router) +router.include_router(swiper_router) router.include_router(system_router) diff --git a/app/modules/cafe24/routes_swiper.py b/app/modules/cafe24/routes_swiper.py new file mode 100644 index 0000000..3018fb9 --- /dev/null +++ b/app/modules/cafe24/routes_swiper.py @@ -0,0 +1,157 @@ +"""모바일 스와이프(product-swiper.js) 편집 화면 — 카페24 "디자인 보관함" FTP. + +상세페이지 편집기(routes_products.py)와 **같은** 문법강조 편집기·색상·단축키를 +그대로 쓴다(요청사항: 모든 편집 기능이 상세페이지 소스 수정 기능과 같아야 한다). +다만 대상이 상품이 아니라 파일 1개(FTP)라서 목록·진열/판매·예약 같은 상품 전용 +기능은 없다 — 편집·적용(백업 포함)·버전 이력만 있다. + +쓰기 순서는 상세페이지 적용과 동일한 원칙을 따른다: + FTP 에서 현재 내용을 다시 읽는다(로컬 값을 현재값으로 가정하지 않는다) + → BACKUP 버전 저장 → 지문 대조(충돌 시 거부) → FTP 에 쓴다 + → MANUAL 버전 + 감사로그 + +핸들러는 `async def` 가 아니라 `def`(동기)다 — FTP 호출이 블로킹이므로 +FastAPI 스레드풀에서 돌게 둔다(다른 cafe24 라우트와 동일한 규칙). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.integrations.cafe24 import Cafe24FtpError, design_ftp, load_config, load_ftp_config + +from . import store +from .common import base_ctx, guard +from .routes_products import _no_store + +logger = logging.getLogger("cafe24.swiper") + +swiper_router = APIRouter() + + +def _ftp_config(): + # FTP 호스트 기본값(`{mall_id}.ftp.cafe24.com`)을 만들기 위해 OAuth 쪽 + # mall_id 를 재사용한다 — FTP 자체는 OAuth 와 별개 인증이다. + return load_ftp_config(mall_id=load_config().mall_id) + + +@swiper_router.get("/swiper", response_class=HTMLResponse) +def swiper_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 + + ftp_config = _ftp_config() + content = "" + error = "" + if not ftp_config.configured: + error = "카페24 디자인 FTP 설정이 필요합니다: " + ", ".join(ftp_config.missing) + else: + try: + content = design_ftp.read_text_file(ftp_config, ftp_config.swiper_path) + except Cafe24FtpError as exc: + error = str(exc) + logger.warning("모바일 스와이프 파일 조회 실패: %s", exc) + + ctx = base_ctx(request, user, active_tab="swiper") + ctx.update( + { + "page_title": "모바일 스와이프", + "page_subtitle": "product-swiper.js (카페24 디자인 보관함)", + "swiper_path": ftp_config.swiper_path, + "content": content, + "fingerprint": store.fingerprint(content) if not error else "", + "error": error, + "revisions": st.list_swiper_revisions(limit=20), + "flash": request.query_params.get("msg", ""), + "flash_error": request.query_params.get("err", ""), + } + ) + return _no_store(render_template(request, "cafe24/swiper.html", ctx)) + + +@swiper_router.post("/swiper/apply") +def swiper_apply( + request: Request, + content: str = Form(...), + base_fingerprint: str = Form(""), + memo: str = Form(""), +) -> RedirectResponse: + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + actor = str(user.get("email") or "") + + ftp_config = _ftp_config() + if not ftp_config.configured: + return RedirectResponse( + url="/cafe24/swiper?err=" + "FTP 설정이 필요합니다: " + ", ".join(ftp_config.missing), + status_code=303, + ) + + # 브라우저 textarea 는 줄바꿈을 CRLF 로 보낸다 — 그대로 저장하면 실제 수정이 + # 없어도 매번 파일 전체의 줄바꿈이 바뀌어(지문 비교·"변경 없음" 판정이 어긋난다). + submitted = content.replace("\r\n", "\n") + if not submitted.strip(): + return RedirectResponse( + url="/cafe24/swiper?err=" + "내용이 비어 있습니다. 파일을 비우려면 FTP 로 직접 하세요.", + status_code=303, + ) + + try: + current = design_ftp.read_text_file(ftp_config, ftp_config.swiper_path) + except Cafe24FtpError as exc: + st.log_audit(actor=actor, action="apply_swiper", result="FAIL", detail=f"현재값 조회 실패: {exc}") + return RedirectResponse(url=f"/cafe24/swiper?err=현재 파일을 읽지 못해 중단했습니다: {exc}", status_code=303) + + backup_id = st.add_swiper_revision( + content=current, revision_type=store.REVISION_BACKUP, + memo="적용 직전 자동 백업", created_by=actor, + ) + + if base_fingerprint and base_fingerprint != store.fingerprint(current): + st.log_audit( + actor=actor, action="apply_swiper", revision_id=backup_id, + result="FAIL", detail="충돌 — 편집 중 파일이 변경됨", + ) + return RedirectResponse( + url="/cafe24/swiper?err=편집하는 동안 파일이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", + status_code=303, + ) + + if submitted == current: + return RedirectResponse(url="/cafe24/swiper?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) + + try: + design_ftp.write_text_file(ftp_config, ftp_config.swiper_path, submitted) + except Cafe24FtpError as exc: + st.log_audit( + actor=actor, action="apply_swiper", revision_id=backup_id, + result="FAIL", detail=str(exc), + ) + logger.warning("모바일 스와이프 파일 적용 실패: %s", exc) + return RedirectResponse( + url=f"/cafe24/swiper?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", + status_code=303, + ) + + revision_id = st.add_swiper_revision( + content=submitted, revision_type=store.REVISION_MANUAL, memo=memo, created_by=actor, + ) + st.log_audit( + actor=actor, action="apply_swiper", revision_id=revision_id, result="SUCCESS", + detail=f"{len(submitted)}자 적용 (백업 {backup_id})", + ) + logger.info("모바일 스와이프 파일 적용 (%s)", actor) + return RedirectResponse( + url=f"/cafe24/swiper?msg=적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", + status_code=303, + ) diff --git a/app/modules/cafe24/templates/cafe24/_nav.html b/app/modules/cafe24/templates/cafe24/_nav.html index 8d244f6..022c7b4 100644 --- a/app/modules/cafe24/templates/cafe24/_nav.html +++ b/app/modules/cafe24/templates/cafe24/_nav.html @@ -1,7 +1,9 @@ -{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #} +{# 카페24 모듈 공용 상단 탭. active_tab: products | swiper | schedules | system #}
{{ swiper_path }}
| 시각 | 유형 | 길이 | 작업자 | 메모 |
|---|---|---|---|---|
| {{ rev.created_at }} | +{{ rev.revision_type }} |
+ {{ rev.html_length }}자 | +{{ rev.created_by or '—' }} | +{{ rev.memo or '' }} | +
아직 변경 이력이 없습니다.
+ {% endif %} +새
", "display": "F"}} +# ════════════════════════════════════════════════════════════ +# 모바일 스와이프(product-swiper.js) — 디자인 보관함 FTP +# ════════════════════════════════════════════════════════════ +def test_ftp_config_host_falls_back_to_mall_id(): + """CAFE24_FTP_HOST 미설정 시 카페24 규칙({mall_id}.ftp.cafe24.com)을 쓴다.""" + saved = os.environ.pop("CAFE24_FTP_HOST", None) + try: + cfg = cfgmod.load_ftp_config(mall_id="testmall") + assert cfg.host == "testmall.ftp.cafe24.com" + assert cfg.port == cfgmod.DEFAULT_FTP_PORT + finally: + if saved is not None: + os.environ["CAFE24_FTP_HOST"] = saved + + +def test_ftp_config_missing_lists_absent_vars(): + cfg = cfgmod.Cafe24FtpConfig(host="", port=21, user="", password="", swiper_path="") + assert cfg.configured is False + assert cfg.missing == [ + "CAFE24_FTP_HOST", "CAFE24_FTP_USER", "CAFE24_FTP_PASSWORD", "CAFE24_SWIPER_FTP_PATH", + ] + full = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p", swiper_path="/x.js") + assert full.configured is True + assert full.missing == [] + + +class _FakeFTP: + """ftplib.FTP 대역 — 실제 소켓 없이 RETR/STOR 명령만 흉내낸다.""" + + files: dict[str, bytes] = {} + + def __init__(self): + self.calls = [] + + def connect(self, host, port, timeout=None): + self.calls.append(("connect", host, port)) + + def login(self, user, password): + self.calls.append(("login", user, password)) + + def set_pasv(self, value): + pass + + def retrbinary(self, cmd, callback): + path = cmd.split(" ", 1)[1] + if path not in self.files: + raise ftplib.error_perm("550 No such file") + callback(self.files[path]) + + def storbinary(self, cmd, fp): + path = cmd.split(" ", 1)[1] + self.files[path] = fp.read() + + def quit(self): + self.calls.append(("quit",)) + + +def test_design_ftp_read_and_write_roundtrip(): + from app.integrations.cafe24 import design_ftp + + _FakeFTP.files = {"/sde_design/mobile11/product-swiper/product-swiper.js": "old".encode("utf-8")} + saved = ftplib.FTP + ftplib.FTP = _FakeFTP + try: + cfg = cfgmod.Cafe24FtpConfig( + host="h", port=21, user="u", password="p", + swiper_path="/sde_design/mobile11/product-swiper/product-swiper.js", + ) + content = design_ftp.read_text_file(cfg, cfg.swiper_path) + assert content == "old" + + design_ftp.write_text_file(cfg, cfg.swiper_path, "new content") + assert design_ftp.read_text_file(cfg, cfg.swiper_path) == "new content" + finally: + ftplib.FTP = saved + + +def test_design_ftp_missing_file_raises_cafe24_error(): + from app.integrations.cafe24 import design_ftp + from app.integrations.cafe24.errors import Cafe24FtpError + + _FakeFTP.files = {} + saved = ftplib.FTP + ftplib.FTP = _FakeFTP + try: + cfg = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p", swiper_path="/missing.js") + try: + design_ftp.read_text_file(cfg, cfg.swiper_path) + raise AssertionError("Cafe24FtpError 가 났어야 한다") + except Cafe24FtpError: + pass + finally: + ftplib.FTP = saved + + # ════════════════════════════════════════════════════════════ # 예약 worker — 성공/재시도/최종실패 # ════════════════════════════════════════════════════════════ diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index 1bf62bb..44f0172 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -15,18 +15,20 @@ ``` app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유) -├─ config.py 환경변수 → Cafe24Config (하드코딩 금지) +├─ config.py 환경변수 → Cafe24Config / Cafe24FtpConfig (하드코딩 금지) ├─ crypto.py 토큰 Fernet 암복호화 ├─ oauth.py 인증 URL / code→token / refresh ├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금) ├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그 ├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가) +├─ design_ftp.py 디자인 보관함 FTP 읽기/쓰기 (product-swiper.js 등 스킨 파일) └─ errors.py 공통 예외 app/modules/cafe24/ ← 상품관리 모듈 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) ├─ routes_schedules.py 예약 등록·목록·취소 +├─ routes_swiper.py 모바일 스와이프(product-swiper.js) 편집·적용(FTP) ├─ worker.py 예약 실행기 (compose 서비스 dbx-cafe24-worker) ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) @@ -34,7 +36,7 @@ app/modules/cafe24/ ← 상품관리 모듈 ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ tests/ DB/네트워크 없는 유닛테스트 └─ templates/cafe24/ _nav.html · products.html(2분할) · - _editor.html(오른쪽 조각) · + _editor.html(오른쪽 조각) · swiper.html · schedules.html · system.html ``` @@ -65,10 +67,43 @@ app/modules/cafe24/ ← 상품관리 모듈 | `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** | | `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** | | `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** | +| `GET /cafe24/swiper` | 모바일 스와이프(product-swiper.js) 편집 화면 | `cafe24` | +| `POST /cafe24/swiper/apply` | 편집한 JS 를 디자인 보관함(FTP)에 즉시 적용 | `cafe24` | | `GET /cafe24/health` | 포털 카드 상태 점 | 없음 | --- +### 2-0. 모바일 스와이프(product-swiper.js) — 상품 API 와 완전히 다른 경로 + +`product-swiper.js` 는 상품이 아니라 카페24 **"디자인 보관함"** 에 올라간 스킨 +파일이다(모바일 스킨 `mobile11` 아래 `/product-swiper/product-swiper.js`). +Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.py` 상단 주석의 +확인 내용 — themes/themes-pages 어디에도 파일 내용이 없다). 카페24가 스킨 +파일에 제공하는 유일한 프로그램적 접근은 **디자인 보관함 FTP 계정**이며, OAuth +와는 완전히 별개의 인증(호스트/포트/아이디/비밀번호)이다. + +- 실물 확인(카페24 관리자 → 디자인 → 웹FTP 화면): 호스트 `{mall_id}.ftp.cafe24.com`, + 포트 21, **SSL/TLS 미사용(평문 FTP)**. `app/integrations/cafe24/design_ftp.py` + 가 표준 라이브러리 `ftplib` 로 직접 붙는다(Cafe24Client/httpx 경로가 아니다). +- 화면(`swiper.html`)은 상세페이지 편집기(`_editor.html`)와 **완전히 같은** + 문법강조·색상·단축키 JS 를 그대로 복사해 쓴다(요청사항). 다만 목록·진열/판매· + 예약처럼 "상품" 전용 UI는 없다 — 편집·적용·버전 이력만 있다. +- 적용 순서도 상세페이지와 같은 원칙: FTP 에서 현재 내용을 다시 읽는다(로컬 값을 + 현재값으로 가정하지 않는다) → **BACKUP** 버전 저장 → 지문 대조(편집 중 다른 + 경로로 파일이 바뀌었으면 거부) → FTP 로 쓴다 → **MANUAL** 버전 + 감사로그 + (`apply_swiper`, `product_no` 는 NULL). +- 버전 이력은 `cafe24_swiper_revisions` 전용 테이블에 쌓인다 + (`cafe24_product_revisions.product_no` 가 NOT NULL 이라 재사용 불가 — + `scripts/sql/cafe24_db_003_swiper_revisions.sql`). +- 브라우저 `