fix(cafe24): 앞 커밋에서 누락된 디자인파일 일반화 변경분 포함
config.py(Cafe24FtpConfig 일반화 + DESIGN_FILE_SPECS)·db.py(add/list_design_
revision)·router.py(design_files_router 연결)·_nav.html(탭 3개)·테스트·
.env.example·문서가 앞 커밋(8c2a9c4)에 함께 올라갔어야 하는데 스테이징 누락.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -110,7 +110,7 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# 카페24 재연결(재인증)이 필요하다.
|
# 카페24 재연결(재인증)이 필요하다.
|
||||||
# CAFE24_TOKEN_SECRET=
|
# CAFE24_TOKEN_SECRET=
|
||||||
#
|
#
|
||||||
# ── 모바일 스와이프(product-swiper.js) 편집용 — 디자인 보관함 FTP ──
|
# ── 디자인 보관함 FTP 편집(모바일 스와이프 / PC·모바일 상품상세 템플릿) ──
|
||||||
# Admin API(OAuth)로는 스킨 파일을 못 읽는다. 카페24 관리자 → 디자인 →
|
# Admin API(OAuth)로는 스킨 파일을 못 읽는다. 카페24 관리자 → 디자인 →
|
||||||
# 웹FTP 화면에 표시된 값을 그대로 쓴다(SSL/TLS 미사용, 평문 FTP, 포트 21).
|
# 웹FTP 화면에 표시된 값을 그대로 쓴다(SSL/TLS 미사용, 평문 FTP, 포트 21).
|
||||||
# 호스트 미설정 시 CAFE24_MALL_ID 기준 기본값({mall_id}.ftp.cafe24.com)을 쓴다.
|
# 호스트 미설정 시 CAFE24_MALL_ID 기준 기본값({mall_id}.ftp.cafe24.com)을 쓴다.
|
||||||
@@ -118,8 +118,11 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# CAFE24_FTP_PORT=21
|
# CAFE24_FTP_PORT=21
|
||||||
# CAFE24_FTP_USER=
|
# CAFE24_FTP_USER=
|
||||||
# CAFE24_FTP_PASSWORD=
|
# CAFE24_FTP_PASSWORD=
|
||||||
# 편집 대상 파일의 절대 경로(스킨 번호가 바뀌면 함께 바뀐다).
|
# 편집 대상 파일들의 절대 경로(스킨 번호가 바뀌면 함께 바뀐다). 각각 선택 —
|
||||||
|
# 미설정 시 아래 기본값(실물 확인된 미라스키친 mobile11/skin11 스킨)을 쓴다.
|
||||||
# CAFE24_SWIPER_FTP_PATH=/sde_design/mobile11/product-swiper/product-swiper.js
|
# CAFE24_SWIPER_FTP_PATH=/sde_design/mobile11/product-swiper/product-swiper.js
|
||||||
|
# CAFE24_MOBILE_DETAIL_FTP_PATH=/sde_design/mobile11/product/detail.html
|
||||||
|
# CAFE24_PC_DETAIL_FTP_PATH=/sde_design/skin11/product/detail.html
|
||||||
|
|
||||||
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ from . import design_ftp, products
|
|||||||
from .client import Cafe24Client
|
from .client import Cafe24Client
|
||||||
from .config import (
|
from .config import (
|
||||||
DEFAULT_SCOPES,
|
DEFAULT_SCOPES,
|
||||||
|
DESIGN_FILE_SPECS,
|
||||||
DESIGN_SCOPES,
|
DESIGN_SCOPES,
|
||||||
ORDER_SCOPES,
|
ORDER_SCOPES,
|
||||||
PRODUCT_SCOPES,
|
PRODUCT_SCOPES,
|
||||||
Cafe24Config,
|
Cafe24Config,
|
||||||
Cafe24FtpConfig,
|
Cafe24FtpConfig,
|
||||||
|
design_file_label,
|
||||||
|
design_file_path,
|
||||||
load_config,
|
load_config,
|
||||||
load_ftp_config,
|
load_ftp_config,
|
||||||
)
|
)
|
||||||
@@ -56,6 +59,9 @@ __all__ = [
|
|||||||
"Cafe24FtpConfig",
|
"Cafe24FtpConfig",
|
||||||
"load_ftp_config",
|
"load_ftp_config",
|
||||||
"design_ftp",
|
"design_ftp",
|
||||||
|
"DESIGN_FILE_SPECS",
|
||||||
|
"design_file_label",
|
||||||
|
"design_file_path",
|
||||||
"TokenService",
|
"TokenService",
|
||||||
"TokenBundle",
|
"TokenBundle",
|
||||||
"load_config",
|
"load_config",
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
|||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 디자인 보관함 FTP — 모바일 스와이프(product-swiper.js) 편집용
|
# 디자인 보관함 FTP — 상품 API 로 못 건드리는 스킨 파일 편집용
|
||||||
|
# (모바일 스와이프 product-swiper.js, PC/모바일 상품상세 템플릿 detail.html 등)
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 수 없다(위 설명 참고). 카페24가
|
# Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 수 없다(위 설명 참고). 카페24가
|
||||||
# 스킨 파일에 제공하는 유일한 프로그램적 접근은 "디자인 보관함" FTP 계정이다
|
# 스킨 파일에 제공하는 유일한 프로그램적 접근은 "디자인 보관함" FTP 계정이다
|
||||||
@@ -133,12 +134,10 @@ class Cafe24FtpConfig:
|
|||||||
port: int
|
port: int
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
# 편집 대상 파일의 절대 경로(디자인 보관함 기준). 스킨 번호가 바뀌면 함께 바뀐다.
|
|
||||||
swiper_path: str
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.host and self.user and self.password and self.swiper_path)
|
return bool(self.host and self.user and self.password)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def missing(self) -> list[str]:
|
def missing(self) -> list[str]:
|
||||||
@@ -146,7 +145,6 @@ class Cafe24FtpConfig:
|
|||||||
("CAFE24_FTP_HOST", self.host),
|
("CAFE24_FTP_HOST", self.host),
|
||||||
("CAFE24_FTP_USER", self.user),
|
("CAFE24_FTP_USER", self.user),
|
||||||
("CAFE24_FTP_PASSWORD", self.password),
|
("CAFE24_FTP_PASSWORD", self.password),
|
||||||
("CAFE24_SWIPER_FTP_PATH", self.swiper_path),
|
|
||||||
)
|
)
|
||||||
return [name for name, value in pairs if not value]
|
return [name for name, value in pairs if not value]
|
||||||
|
|
||||||
@@ -166,9 +164,35 @@ def load_ftp_config(*, mall_id: str = "") -> Cafe24FtpConfig:
|
|||||||
port=port,
|
port=port,
|
||||||
user=_env("CAFE24_FTP_USER"),
|
user=_env("CAFE24_FTP_USER"),
|
||||||
password=_env("CAFE24_FTP_PASSWORD"),
|
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"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# FTP 로 편집하는 디자인 파일들 — key: (표시 이름, 경로 환경변수 이름, 기본 경로).
|
||||||
|
# 기본 경로는 실물 확인된 값(미라스키친: 모바일 mobile11 / PC skin11 스킨)이다.
|
||||||
|
# 스킨을 바꾸면 각 CAFE24_*_FTP_PATH 로 덮어쓴다.
|
||||||
|
DESIGN_FILE_SPECS: dict[str, tuple[str, str, str]] = {
|
||||||
|
"swiper": (
|
||||||
|
"모바일 스와이프",
|
||||||
|
"CAFE24_SWIPER_FTP_PATH",
|
||||||
|
"/sde_design/mobile11/product-swiper/product-swiper.js",
|
||||||
|
),
|
||||||
|
"mobile_detail": (
|
||||||
|
"모바일 상품상세",
|
||||||
|
"CAFE24_MOBILE_DETAIL_FTP_PATH",
|
||||||
|
"/sde_design/mobile11/product/detail.html",
|
||||||
|
),
|
||||||
|
"pc_detail": (
|
||||||
|
"PC 상품상세",
|
||||||
|
"CAFE24_PC_DETAIL_FTP_PATH",
|
||||||
|
"/sde_design/skin11/product/detail.html",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def design_file_label(key: str) -> str:
|
||||||
|
return DESIGN_FILE_SPECS[key][0]
|
||||||
|
|
||||||
|
|
||||||
|
def design_file_path(key: str) -> str:
|
||||||
|
_, env_name, default = DESIGN_FILE_SPECS[key]
|
||||||
|
return _env(env_name, default)
|
||||||
|
|||||||
+12
-10
@@ -320,14 +320,16 @@ class Cafe24Store:
|
|||||||
return self._serialize(row)
|
return self._serialize(row)
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 모바일 스와이프(product-swiper.js) 버전 (append-only)
|
# 디자인 보관함 FTP 파일 버전 (append-only) — 모바일 스와이프
|
||||||
# 상품이 아니라 디자인 보관함 FTP 파일이라 별도 테이블을 쓴다
|
# (product-swiper.js), PC/모바일 상품상세 템플릿(detail.html) 등.
|
||||||
# (cafe24_product_revisions.product_no 는 NOT NULL).
|
# 상품이 아니라 FTP 파일이라 별도 테이블을 쓴다
|
||||||
|
# (cafe24_product_revisions.product_no 는 NOT NULL). file_key 로 파일을
|
||||||
|
# 구분한다 — "swiper" / "mobile_detail" / "pc_detail" (config.py 의
|
||||||
|
# DESIGN_FILE_SPECS 키와 일치해야 한다).
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
SWIPER_FILE_KEY = "product-swiper"
|
def add_design_revision(
|
||||||
|
self, *, file_key: str, content: str, revision_type: str,
|
||||||
def add_swiper_revision(
|
memo: str = "", created_by: str = "",
|
||||||
self, *, content: str, revision_type: str, memo: str = "", created_by: str = ""
|
|
||||||
) -> int:
|
) -> int:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -338,7 +340,7 @@ class Cafe24Store:
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
self.SWIPER_FILE_KEY,
|
file_key,
|
||||||
content or "",
|
content or "",
|
||||||
store.normalize_revision_type(revision_type),
|
store.normalize_revision_type(revision_type),
|
||||||
(memo or "")[:500],
|
(memo or "")[:500],
|
||||||
@@ -347,7 +349,7 @@ class Cafe24Store:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row["id"]) if row else 0
|
return int(row["id"]) if row else 0
|
||||||
|
|
||||||
def list_swiper_revisions(self, *, limit: int = 20) -> list[dict[str, Any]]:
|
def list_design_revisions(self, file_key: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
"""버전 목록. content 는 커질 수 있어 길이만 계산해서 준다."""
|
"""버전 목록. content 는 커질 수 있어 길이만 계산해서 준다."""
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -359,7 +361,7 @@ class Cafe24Store:
|
|||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(self.SWIPER_FILE_KEY, max(1, min(int(limit), 200))),
|
(file_key, max(1, min(int(limit), 200))),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [self._serialize(r) for r in rows]
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
|
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
|
||||||
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
|
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
|
||||||
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
routes_system 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
routes_swiper 모바일 스와이프(product-swiper.js) 편집 — 카페24 디자인
|
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
|
||||||
보관함 FTP (Admin API 가 아니다. app/integrations/cafe24/
|
카페24 디자인 보관함 FTP (Admin API 가 아니다.
|
||||||
design_ftp.py 참고)
|
app/integrations/cafe24/design_ftp.py 참고)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,9 +23,9 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .routes_design_files import design_files_router
|
||||||
from .routes_products import products_router
|
from .routes_products import products_router
|
||||||
from .routes_schedules import schedules_router
|
from .routes_schedules import schedules_router
|
||||||
from .routes_swiper import swiper_router
|
|
||||||
from .routes_system import system_router
|
from .routes_system import system_router
|
||||||
|
|
||||||
logger = logging.getLogger("cafe24.router")
|
logger = logging.getLogger("cafe24.router")
|
||||||
@@ -34,7 +34,7 @@ router = APIRouter(prefix="/cafe24", tags=["cafe24"])
|
|||||||
|
|
||||||
router.include_router(products_router)
|
router.include_router(products_router)
|
||||||
router.include_router(schedules_router)
|
router.include_router(schedules_router)
|
||||||
router.include_router(swiper_router)
|
router.include_router(design_files_router)
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
{# 카페24 모듈 공용 상단 탭. active_tab: products | swiper | schedules | system #}
|
{# 카페24 모듈 공용 상단 탭. active_tab: products | design:pc_detail | design:mobile_detail | design:swiper | schedules | system #}
|
||||||
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
<a class="erp-btn {% if active_tab=='products' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
href="/cafe24/">상품관리</a>
|
href="/cafe24/">상품관리</a>
|
||||||
<a class="erp-btn {% if active_tab=='swiper' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
<a class="erp-btn {% if active_tab=='design:pc_detail' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
href="/cafe24/swiper">모바일 스와이프</a>
|
href="/cafe24/design/pc_detail">PC 상품상세</a>
|
||||||
|
<a class="erp-btn {% if active_tab=='design:mobile_detail' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
href="/cafe24/design/mobile_detail">모바일 상품상세</a>
|
||||||
|
<a class="erp-btn {% if active_tab=='design:swiper' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
href="/cafe24/design/swiper">모바일 스와이프</a>
|
||||||
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
<a class="erp-btn {% if active_tab=='schedules' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
href="/cafe24/schedules">예약관리</a>
|
href="/cafe24/schedules">예약관리</a>
|
||||||
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
<a class="erp-btn {% if active_tab=='system' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}"
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ def test_update_product_sends_flags_and_html():
|
|||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 모바일 스와이프(product-swiper.js) — 디자인 보관함 FTP
|
# 디자인 보관함 FTP — 모바일 스와이프 / PC·모바일 상품상세 템플릿
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
def test_ftp_config_host_falls_back_to_mall_id():
|
def test_ftp_config_host_falls_back_to_mall_id():
|
||||||
"""CAFE24_FTP_HOST 미설정 시 카페24 규칙({mall_id}.ftp.cafe24.com)을 쓴다."""
|
"""CAFE24_FTP_HOST 미설정 시 카페24 규칙({mall_id}.ftp.cafe24.com)을 쓴다."""
|
||||||
@@ -680,16 +680,37 @@ def test_ftp_config_host_falls_back_to_mall_id():
|
|||||||
|
|
||||||
|
|
||||||
def test_ftp_config_missing_lists_absent_vars():
|
def test_ftp_config_missing_lists_absent_vars():
|
||||||
cfg = cfgmod.Cafe24FtpConfig(host="", port=21, user="", password="", swiper_path="")
|
cfg = cfgmod.Cafe24FtpConfig(host="", port=21, user="", password="")
|
||||||
assert cfg.configured is False
|
assert cfg.configured is False
|
||||||
assert cfg.missing == [
|
assert cfg.missing == ["CAFE24_FTP_HOST", "CAFE24_FTP_USER", "CAFE24_FTP_PASSWORD"]
|
||||||
"CAFE24_FTP_HOST", "CAFE24_FTP_USER", "CAFE24_FTP_PASSWORD", "CAFE24_SWIPER_FTP_PATH",
|
full = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p")
|
||||||
]
|
|
||||||
full = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p", swiper_path="/x.js")
|
|
||||||
assert full.configured is True
|
assert full.configured is True
|
||||||
assert full.missing == []
|
assert full.missing == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_file_specs_cover_all_three_files():
|
||||||
|
"""상단 탭 3개(모바일 스와이프/모바일 상품상세/PC 상품상세)가 모두 등록돼 있는가."""
|
||||||
|
assert set(cfgmod.DESIGN_FILE_SPECS) == {"swiper", "mobile_detail", "pc_detail"}
|
||||||
|
assert cfgmod.design_file_label("swiper") == "모바일 스와이프"
|
||||||
|
assert cfgmod.design_file_label("mobile_detail") == "모바일 상품상세"
|
||||||
|
assert cfgmod.design_file_label("pc_detail") == "PC 상품상세"
|
||||||
|
# 기본 경로(env 미설정 시) — 실물 확인된 값
|
||||||
|
assert cfgmod.design_file_path("mobile_detail") == "/sde_design/mobile11/product/detail.html"
|
||||||
|
assert cfgmod.design_file_path("pc_detail") == "/sde_design/skin11/product/detail.html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_file_path_env_override():
|
||||||
|
saved = os.environ.get("CAFE24_PC_DETAIL_FTP_PATH")
|
||||||
|
os.environ["CAFE24_PC_DETAIL_FTP_PATH"] = "/sde_design/skin99/product/detail.html"
|
||||||
|
try:
|
||||||
|
assert cfgmod.design_file_path("pc_detail") == "/sde_design/skin99/product/detail.html"
|
||||||
|
finally:
|
||||||
|
if saved is None:
|
||||||
|
os.environ.pop("CAFE24_PC_DETAIL_FTP_PATH", None)
|
||||||
|
else:
|
||||||
|
os.environ["CAFE24_PC_DETAIL_FTP_PATH"] = saved
|
||||||
|
|
||||||
|
|
||||||
class _FakeFTP:
|
class _FakeFTP:
|
||||||
"""ftplib.FTP 대역 — 실제 소켓 없이 RETR/STOR 명령만 흉내낸다."""
|
"""ftplib.FTP 대역 — 실제 소켓 없이 RETR/STOR 명령만 흉내낸다."""
|
||||||
|
|
||||||
@@ -724,19 +745,17 @@ class _FakeFTP:
|
|||||||
def test_design_ftp_read_and_write_roundtrip():
|
def test_design_ftp_read_and_write_roundtrip():
|
||||||
from app.integrations.cafe24 import design_ftp
|
from app.integrations.cafe24 import design_ftp
|
||||||
|
|
||||||
_FakeFTP.files = {"/sde_design/mobile11/product-swiper/product-swiper.js": "old".encode("utf-8")}
|
path = "/sde_design/mobile11/product-swiper/product-swiper.js"
|
||||||
|
_FakeFTP.files = {path: "old".encode("utf-8")}
|
||||||
saved = ftplib.FTP
|
saved = ftplib.FTP
|
||||||
ftplib.FTP = _FakeFTP
|
ftplib.FTP = _FakeFTP
|
||||||
try:
|
try:
|
||||||
cfg = cfgmod.Cafe24FtpConfig(
|
cfg = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p")
|
||||||
host="h", port=21, user="u", password="p",
|
content = design_ftp.read_text_file(cfg, path)
|
||||||
swiper_path="/sde_design/mobile11/product-swiper/product-swiper.js",
|
|
||||||
)
|
|
||||||
content = design_ftp.read_text_file(cfg, cfg.swiper_path)
|
|
||||||
assert content == "old"
|
assert content == "old"
|
||||||
|
|
||||||
design_ftp.write_text_file(cfg, cfg.swiper_path, "new content")
|
design_ftp.write_text_file(cfg, path, "new content")
|
||||||
assert design_ftp.read_text_file(cfg, cfg.swiper_path) == "new content"
|
assert design_ftp.read_text_file(cfg, path) == "new content"
|
||||||
finally:
|
finally:
|
||||||
ftplib.FTP = saved
|
ftplib.FTP = saved
|
||||||
|
|
||||||
@@ -749,9 +768,9 @@ def test_design_ftp_missing_file_raises_cafe24_error():
|
|||||||
saved = ftplib.FTP
|
saved = ftplib.FTP
|
||||||
ftplib.FTP = _FakeFTP
|
ftplib.FTP = _FakeFTP
|
||||||
try:
|
try:
|
||||||
cfg = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p", swiper_path="/missing.js")
|
cfg = cfgmod.Cafe24FtpConfig(host="h", port=21, user="u", password="p")
|
||||||
try:
|
try:
|
||||||
design_ftp.read_text_file(cfg, cfg.swiper_path)
|
design_ftp.read_text_file(cfg, "/missing.js")
|
||||||
raise AssertionError("Cafe24FtpError 가 났어야 한다")
|
raise AssertionError("Cafe24FtpError 가 났어야 한다")
|
||||||
except Cafe24FtpError:
|
except Cafe24FtpError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+36
-18
@@ -21,14 +21,15 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
|
|||||||
├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
|
├─ tokens.py TokenService — 저장·만료판정·자동갱신(행 잠금)
|
||||||
├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그
|
├─ client.py Cafe24Client — 전송·재시도·401/429/5xx·호출간격·API 로그
|
||||||
├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가)
|
├─ products.py 상품 엔드포인트 래퍼 (향후 orders.py 를 형제로 추가)
|
||||||
├─ design_ftp.py 디자인 보관함 FTP 읽기/쓰기 (product-swiper.js 등 스킨 파일)
|
├─ design_ftp.py 디자인 보관함 FTP 읽기/쓰기 (product-swiper.js·detail.html 등 스킨 파일)
|
||||||
└─ errors.py 공통 예외
|
└─ errors.py 공통 예외
|
||||||
|
|
||||||
app/modules/cafe24/ ← 상품관리 모듈
|
app/modules/cafe24/ ← 상품관리 모듈
|
||||||
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
|
||||||
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
|
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
|
||||||
├─ routes_schedules.py 예약 등록·목록·취소
|
├─ routes_schedules.py 예약 등록·목록·취소
|
||||||
├─ routes_swiper.py 모바일 스와이프(product-swiper.js) 편집·적용(FTP)
|
├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프·
|
||||||
|
│ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유)
|
||||||
├─ worker.py 예약 실행기 (compose 서비스 dbx-cafe24-worker)
|
├─ worker.py 예약 실행기 (compose 서비스 dbx-cafe24-worker)
|
||||||
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
|
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
|
||||||
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
|
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
|
||||||
@@ -36,7 +37,7 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
|
||||||
├─ tests/ DB/네트워크 없는 유닛테스트
|
├─ tests/ DB/네트워크 없는 유닛테스트
|
||||||
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
|
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
|
||||||
_editor.html(오른쪽 조각) · swiper.html ·
|
_editor.html(오른쪽 조각) · design_editor.html ·
|
||||||
schedules.html · system.html
|
schedules.html · system.html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,16 +68,27 @@ app/modules/cafe24/ ← 상품관리 모듈
|
|||||||
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
|
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
|
||||||
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
|
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
|
||||||
| `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** |
|
| `POST /cafe24/system/oauth/disconnect` | 저장된 토큰 삭제 | **admin** |
|
||||||
| `GET /cafe24/swiper` | 모바일 스와이프(product-swiper.js) 편집 화면 | `cafe24` |
|
| `GET /cafe24/design/{file_key}` | 디자인 보관함 파일 편집 화면(`swiper`\|`mobile_detail`\|`pc_detail`) | `cafe24` |
|
||||||
| `POST /cafe24/swiper/apply` | 편집한 JS 를 디자인 보관함(FTP)에 즉시 적용 | `cafe24` |
|
| `POST /cafe24/design/{file_key}/apply` | 편집한 내용을 디자인 보관함(FTP)에 즉시 적용 | `cafe24` |
|
||||||
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
|
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2-0. 모바일 스와이프(product-swiper.js) — 상품 API 와 완전히 다른 경로
|
### 2-0. 디자인 보관함 파일 편집 — 상품 API 와 완전히 다른 경로
|
||||||
|
|
||||||
|
세 파일이 이 방식이다 — 상품이 아니라 카페24 **"디자인 보관함"** 에 올라간
|
||||||
|
스킨 파일이라서다.
|
||||||
|
|
||||||
|
| `file_key` | 상단 탭 | 기본 경로 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `swiper` | 모바일 스와이프 | `/sde_design/mobile11/product-swiper/product-swiper.js` |
|
||||||
|
| `mobile_detail` | 모바일 상품상세 | `/sde_design/mobile11/product/detail.html` |
|
||||||
|
| `pc_detail` | PC 상품상세 | `/sde_design/skin11/product/detail.html` |
|
||||||
|
|
||||||
|
`mobile_detail`/`pc_detail` 은 **스킨 템플릿**이다 — 상품마다 다른
|
||||||
|
`description`(상세페이지 API, 2-3 절)과 달리 전 상품이 공유하는 레이아웃이므로
|
||||||
|
잘못 고치면 모든 상품 페이지에 영향을 준다.
|
||||||
|
|
||||||
`product-swiper.js` 는 상품이 아니라 카페24 **"디자인 보관함"** 에 올라간 스킨
|
|
||||||
파일이다(모바일 스킨 `mobile11` 아래 `/product-swiper/product-swiper.js`).
|
|
||||||
Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.py` 상단 주석의
|
Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.py` 상단 주석의
|
||||||
확인 내용 — themes/themes-pages 어디에도 파일 내용이 없다). 카페24가 스킨
|
확인 내용 — themes/themes-pages 어디에도 파일 내용이 없다). 카페24가 스킨
|
||||||
파일에 제공하는 유일한 프로그램적 접근은 **디자인 보관함 FTP 계정**이며, OAuth
|
파일에 제공하는 유일한 프로그램적 접근은 **디자인 보관함 FTP 계정**이며, OAuth
|
||||||
@@ -85,22 +97,26 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
|
|||||||
- 실물 확인(카페24 관리자 → 디자인 → 웹FTP 화면): 호스트 `{mall_id}.ftp.cafe24.com`,
|
- 실물 확인(카페24 관리자 → 디자인 → 웹FTP 화면): 호스트 `{mall_id}.ftp.cafe24.com`,
|
||||||
포트 21, **SSL/TLS 미사용(평문 FTP)**. `app/integrations/cafe24/design_ftp.py`
|
포트 21, **SSL/TLS 미사용(평문 FTP)**. `app/integrations/cafe24/design_ftp.py`
|
||||||
가 표준 라이브러리 `ftplib` 로 직접 붙는다(Cafe24Client/httpx 경로가 아니다).
|
가 표준 라이브러리 `ftplib` 로 직접 붙는다(Cafe24Client/httpx 경로가 아니다).
|
||||||
- 화면(`swiper.html`)은 상세페이지 편집기(`_editor.html`)와 **완전히 같은**
|
- 세 파일 모두 화면·로직이 완전히 같아 `routes_design_files.py` 하나가
|
||||||
문법강조·색상·단축키 JS 를 그대로 복사해 쓴다(요청사항). 다만 목록·진열/판매·
|
`/cafe24/design/{file_key}` 로 공유한다(경로만 `file_key` 로 갈라짐).
|
||||||
예약처럼 "상품" 전용 UI는 없다 — 편집·적용·버전 이력만 있다.
|
화면(`design_editor.html`)은 상세페이지 편집기(`_editor.html`)와 **완전히
|
||||||
|
같은** 문법강조·색상·단축키 JS 를 그대로 복사해 쓴다(요청사항). 다만
|
||||||
|
목록·진열/판매·예약처럼 "상품" 전용 UI는 없다 — 편집·적용·버전 이력만 있다.
|
||||||
- 적용 순서도 상세페이지와 같은 원칙: FTP 에서 현재 내용을 다시 읽는다(로컬 값을
|
- 적용 순서도 상세페이지와 같은 원칙: FTP 에서 현재 내용을 다시 읽는다(로컬 값을
|
||||||
현재값으로 가정하지 않는다) → **BACKUP** 버전 저장 → 지문 대조(편집 중 다른
|
현재값으로 가정하지 않는다) → **BACKUP** 버전 저장 → 지문 대조(편집 중 다른
|
||||||
경로로 파일이 바뀌었으면 거부) → FTP 로 쓴다 → **MANUAL** 버전 + 감사로그
|
경로로 파일이 바뀌었으면 거부) → FTP 로 쓴다 → **MANUAL** 버전 + 감사로그
|
||||||
(`apply_swiper`, `product_no` 는 NULL).
|
(`apply_design:{file_key}`, `product_no` 는 NULL).
|
||||||
- 버전 이력은 `cafe24_swiper_revisions` 전용 테이블에 쌓인다
|
- 버전 이력은 `cafe24_swiper_revisions` 전용 테이블에 `file_key` 로 구분해 쌓인다
|
||||||
(`cafe24_product_revisions.product_no` 가 NOT NULL 이라 재사용 불가 —
|
(`cafe24_product_revisions.product_no` 가 NOT NULL 이라 재사용 불가 —
|
||||||
`scripts/sql/cafe24_db_003_swiper_revisions.sql`).
|
`scripts/sql/cafe24_db_003_swiper_revisions.sql`. 테이블 이름은 처음 만들 때
|
||||||
|
기준인 스와이프에서 왔지만 `file_key` 컬럼으로 세 파일을 함께 담게 설계했다).
|
||||||
- 브라우저 `<textarea>` 는 줄바꿈을 CRLF 로 보낸다 — 적용 직전 LF 로 정규화한다
|
- 브라우저 `<textarea>` 는 줄바꿈을 CRLF 로 보낸다 — 적용 직전 LF 로 정규화한다
|
||||||
(안 하면 실수정 없이도 매번 파일 전체 줄바꿈이 바뀌어 지문 비교·"변경 없음"
|
(안 하면 실수정 없이도 매번 파일 전체 줄바꿈이 바뀌어 지문 비교·"변경 없음"
|
||||||
판정이 어긋난다).
|
판정이 어긋난다).
|
||||||
- env: `CAFE24_FTP_HOST`(미설정 시 `{mall_id}.ftp.cafe24.com`), `CAFE24_FTP_PORT`
|
- env: `CAFE24_FTP_HOST`(미설정 시 `{mall_id}.ftp.cafe24.com`), `CAFE24_FTP_PORT`
|
||||||
(기본 21), `CAFE24_FTP_USER`, `CAFE24_FTP_PASSWORD`, `CAFE24_SWIPER_FTP_PATH`
|
(기본 21), `CAFE24_FTP_USER`, `CAFE24_FTP_PASSWORD` — 세 파일이 계정을 공유한다.
|
||||||
(기본 `/sde_design/mobile11/product-swiper/product-swiper.js`).
|
경로는 파일별로 `CAFE24_SWIPER_FTP_PATH` / `CAFE24_MOBILE_DETAIL_FTP_PATH` /
|
||||||
|
`CAFE24_PC_DETAIL_FTP_PATH` (각각 위 표의 기본값을 쓴다).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -397,12 +413,14 @@ CAFE24_API_VERSION=2026-03-01
|
|||||||
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
||||||
CAFE24_SHOP_URL=https://www.miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
CAFE24_SHOP_URL=https://www.miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
||||||
|
|
||||||
# 모바일 스와이프(product-swiper.js) — 디자인 보관함 FTP (OAuth 와 별개 계정)
|
# 디자인 보관함 FTP (OAuth 와 별개 계정) — 모바일 스와이프 / PC·모바일 상품상세
|
||||||
CAFE24_FTP_HOST=miraskitchen.ftp.cafe24.com # 선택 — 미설정 시 {mall_id}.ftp.cafe24.com
|
CAFE24_FTP_HOST=miraskitchen.ftp.cafe24.com # 선택 — 미설정 시 {mall_id}.ftp.cafe24.com
|
||||||
CAFE24_FTP_PORT=21
|
CAFE24_FTP_PORT=21
|
||||||
CAFE24_FTP_USER=...
|
CAFE24_FTP_USER=...
|
||||||
CAFE24_FTP_PASSWORD=...
|
CAFE24_FTP_PASSWORD=...
|
||||||
CAFE24_SWIPER_FTP_PATH=/sde_design/mobile11/product-swiper/product-swiper.js
|
CAFE24_SWIPER_FTP_PATH=/sde_design/mobile11/product-swiper/product-swiper.js
|
||||||
|
CAFE24_MOBILE_DETAIL_FTP_PATH=/sde_design/mobile11/product/detail.html
|
||||||
|
CAFE24_PC_DETAIL_FTP_PATH=/sde_design/skin11/product/detail.html
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5-4. 재기동 + 권한 부여
|
### 5-4. 재기동 + 권한 부여
|
||||||
@@ -455,7 +473,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
|
|||||||
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
|
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
|
||||||
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
||||||
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
||||||
| 8 | 모바일 스와이프(product-swiper.js) 편집 — 디자인 보관함 FTP | ✅ 완료. 예약 적용은 없음(파일 1개, 상품 전용 개념이라 범위 밖) |
|
| 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) |
|
||||||
|
|
||||||
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
|
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
|
||||||
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
|
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user