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:
@@ -109,6 +109,17 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
|
||||||
# 카페24 재연결(재인증)이 필요하다.
|
# 카페24 재연결(재인증)이 필요하다.
|
||||||
# CAFE24_TOKEN_SECRET=
|
# 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 읽기 전용) ───
|
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import products
|
from . import design_ftp, products
|
||||||
from .client import Cafe24Client
|
from .client import Cafe24Client
|
||||||
from .config import (
|
from .config import (
|
||||||
DEFAULT_SCOPES,
|
DEFAULT_SCOPES,
|
||||||
@@ -33,13 +33,16 @@ from .config import (
|
|||||||
ORDER_SCOPES,
|
ORDER_SCOPES,
|
||||||
PRODUCT_SCOPES,
|
PRODUCT_SCOPES,
|
||||||
Cafe24Config,
|
Cafe24Config,
|
||||||
|
Cafe24FtpConfig,
|
||||||
load_config,
|
load_config,
|
||||||
|
load_ftp_config,
|
||||||
)
|
)
|
||||||
from .errors import (
|
from .errors import (
|
||||||
Cafe24ApiError,
|
Cafe24ApiError,
|
||||||
Cafe24AuthError,
|
Cafe24AuthError,
|
||||||
Cafe24ConfigError,
|
Cafe24ConfigError,
|
||||||
Cafe24Error,
|
Cafe24Error,
|
||||||
|
Cafe24FtpError,
|
||||||
Cafe24RateLimitError,
|
Cafe24RateLimitError,
|
||||||
)
|
)
|
||||||
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
|
from .oauth import TokenBundle, build_authorize_url, exchange_code, new_state, refresh_tokens
|
||||||
@@ -50,6 +53,9 @@ __all__ = [
|
|||||||
"build_cafe24_api",
|
"build_cafe24_api",
|
||||||
"Cafe24Client",
|
"Cafe24Client",
|
||||||
"Cafe24Config",
|
"Cafe24Config",
|
||||||
|
"Cafe24FtpConfig",
|
||||||
|
"load_ftp_config",
|
||||||
|
"design_ftp",
|
||||||
"TokenService",
|
"TokenService",
|
||||||
"TokenBundle",
|
"TokenBundle",
|
||||||
"load_config",
|
"load_config",
|
||||||
@@ -67,6 +73,7 @@ __all__ = [
|
|||||||
"Cafe24AuthError",
|
"Cafe24AuthError",
|
||||||
"Cafe24RateLimitError",
|
"Cafe24RateLimitError",
|
||||||
"Cafe24ApiError",
|
"Cafe24ApiError",
|
||||||
|
"Cafe24FtpError",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,3 +115,60 @@ def load_config(*, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> Cafe24Config:
|
|||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
shop_url=_env("CAFE24_SHOP_URL"),
|
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
|
self.retry_after = retry_after
|
||||||
|
|
||||||
|
|
||||||
|
class Cafe24FtpError(Cafe24Error):
|
||||||
|
"""디자인 보관함 FTP 연결/전송 실패 (product-swiper.js 편집 등)."""
|
||||||
|
|
||||||
|
|
||||||
class Cafe24ApiError(Cafe24Error):
|
class Cafe24ApiError(Cafe24Error):
|
||||||
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
|
"""그 외 API 오류(4xx/5xx). status 로 재시도 가능 여부를 판단한다."""
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,50 @@ class Cafe24Store:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return self._serialize(row)
|
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_* 컬럼은 건드리지 않는다.
|
# 되돌리기는 쓰지 않으므로 end_* 컬럼은 건드리지 않는다.
|
||||||
|
|||||||
@@ -12,6 +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 디자인
|
||||||
|
보관함 FTP (Admin API 가 아니다. app/integrations/cafe24/
|
||||||
|
design_ftp.py 참고)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +25,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
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")
|
||||||
@@ -30,6 +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(system_router)
|
router.include_router(system_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{# 카페24 모듈 공용 상단 탭. active_tab: products | schedules | system #}
|
{# 카페24 모듈 공용 상단 탭. active_tab: products | 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 %}"
|
||||||
|
href="/cafe24/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 %}"
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "cafe24/_nav.html" %}
|
||||||
|
|
||||||
|
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||||
|
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="cf24-flash cf24-flash-err">
|
||||||
|
파일을 불러오지 못했습니다: {{ error }}<br />
|
||||||
|
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# 상세페이지 편집기(_editor.html)와 완전히 같은 문법강조·단축키·버튼을 쓴다.
|
||||||
|
목록/진열·판매/예약처럼 "상품" 전용인 것만 뺐다 — 이건 상품이 아니라
|
||||||
|
디자인 보관함(FTP)의 파일 1개다. #}
|
||||||
|
<section class="erp-card cf24-pane-editor" id="cf24-swiper-pane">
|
||||||
|
<p class="cf24-editor-sub"><code>{{ swiper_path }}</code></p>
|
||||||
|
|
||||||
|
{% if not error %}
|
||||||
|
<form class="cf24-editor-form" id="cf24-swiper-form" method="post"
|
||||||
|
action="/cafe24/swiper/apply"
|
||||||
|
data-confirm="카페24 디자인 보관함(FTP)에 바로 반영됩니다. 적용할까요? 직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.">
|
||||||
|
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
||||||
|
|
||||||
|
<div class="cf24-editor-bar">
|
||||||
|
<span class="cf24-shortcuts">단축키: 주석토글[Ctrl+/] · 줄 복사[Alt+Shift+↑↓] · 줄 이동[Alt+↑↓] · 줄 삭제[Shift+Del]</span>
|
||||||
|
<span class="cf24-editor-bar-right">
|
||||||
|
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
||||||
|
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-swiper-code">복사</button>
|
||||||
|
<button class="erp-btn erp-btn-outline" type="button" id="cf24-swiper-reload"
|
||||||
|
title="카페24 디자인 보관함(FTP)에서 현재 파일을 다시 읽어옵니다.">다시 읽기</button>
|
||||||
|
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# 색칠된 <pre> 위에 투명한 <textarea> 를 겹쳐 문법 강조를 만든다 —
|
||||||
|
상세페이지 편집기와 동일한 방식(store.md 2-2 절 참고). #}
|
||||||
|
<div class="cf24-code" id="cf24-swiper-code-box">
|
||||||
|
<div class="cf24-gutter" aria-hidden="true"><div class="cf24-gutter-inner" id="cf24-swiper-gutter"></div></div>
|
||||||
|
<div class="cf24-code-body">
|
||||||
|
<pre class="cf24-code-hl" id="cf24-swiper-hl" aria-hidden="true"></pre>
|
||||||
|
<textarea id="cf24-swiper-code" class="cf24-code-input" name="content" wrap="off"
|
||||||
|
spellcheck="false" autocapitalize="off" autocorrect="off">{{ content }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="cf24-details">
|
||||||
|
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
|
||||||
|
{% if revisions %}
|
||||||
|
<div class="cf24-scroll">
|
||||||
|
<table class="erp-table cf24-compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rev in revisions %}
|
||||||
|
<tr>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
||||||
|
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
||||||
|
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
||||||
|
<td>{{ rev.memo or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="cf24-muted">아직 변경 이력이 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
/* ── 문법 강조 — 상세페이지 편집기와 완전히 같은 함수/색상(cafe24.css 의
|
||||||
|
cf24-t-* 클래스를 그대로 쓴다). 외부 라이브러리를 쓰지 않는다. */
|
||||||
|
var TOKEN_RE = /(<!--[\s\S]*?-->)|(<![^>]*>)|(<\/?)([a-zA-Z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)(>)/g;
|
||||||
|
var ATTR_RE = /([\w:.-]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
|
||||||
|
var HL_LIMIT = 200000;
|
||||||
|
|
||||||
|
function esc(text) {
|
||||||
|
return text.replace(/[&<>]/g, function (c) {
|
||||||
|
return c === "&" ? "&" : c === "<" ? "<" : ">";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintAttrs(text) {
|
||||||
|
return text.replace(ATTR_RE, function (whole, name, eq, val) {
|
||||||
|
if (!name) return esc(whole);
|
||||||
|
var out = '<span class="cf24-t-attr">' + esc(name) + "</span>";
|
||||||
|
if (eq) out += '<span class="cf24-t-pun">' + esc(eq) + "</span>";
|
||||||
|
if (val) out += '<span class="cf24-t-val">' + esc(val) + "</span>";
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintHtml(src) {
|
||||||
|
var out = "", last = 0, m;
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
while ((m = TOKEN_RE.exec(src)) !== null) {
|
||||||
|
out += esc(src.slice(last, m.index));
|
||||||
|
last = TOKEN_RE.lastIndex;
|
||||||
|
if (m[1]) { out += '<span class="cf24-t-com">' + esc(m[1]) + "</span>"; continue; }
|
||||||
|
if (m[2]) { out += '<span class="cf24-t-doc">' + esc(m[2]) + "</span>"; continue; }
|
||||||
|
out += '<span class="cf24-t-pun">' + esc(m[3]) + "</span>" +
|
||||||
|
'<span class="cf24-t-tag">' + esc(m[4]) + "</span>" +
|
||||||
|
paintAttrs(m[5]) +
|
||||||
|
'<span class="cf24-t-pun">' + esc(m[6]) + "</span>";
|
||||||
|
}
|
||||||
|
return out + esc(src.slice(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCodeEditor() {
|
||||||
|
var ta = document.getElementById("cf24-swiper-code");
|
||||||
|
var hl = document.getElementById("cf24-swiper-hl");
|
||||||
|
var gutter = document.getElementById("cf24-swiper-gutter");
|
||||||
|
if (!ta || !hl) return;
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function renderGutter(count) {
|
||||||
|
if (!gutter || gutter.dataset.lines === String(count)) return;
|
||||||
|
var out = "";
|
||||||
|
for (var i = 1; i <= count; i++) out += i + "\n";
|
||||||
|
gutter.textContent = out;
|
||||||
|
gutter.dataset.lines = String(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var x = ta.scrollLeft, y = ta.scrollTop;
|
||||||
|
hl.style.transform = "translate(" + -x + "px," + -y + "px)";
|
||||||
|
if (gutter) gutter.style.transform = "translateY(" + -y + "px)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function repaint() {
|
||||||
|
if (ta.value.length > HL_LIMIT) hl.textContent = ta.value + "\n";
|
||||||
|
else hl.innerHTML = paintHtml(ta.value) + "\n";
|
||||||
|
renderGutter(ta.value.split("\n").length);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (ta.value.length < 50000) { repaint(); return; }
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(repaint, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 편집 단축키 공용 헬퍼(상세페이지 편집기와 동일) ── */
|
||||||
|
|
||||||
|
function lineRange() {
|
||||||
|
var value = ta.value;
|
||||||
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
if (end > start && value.charAt(end - 1) === "\n") end -= 1;
|
||||||
|
var from = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
var to = value.indexOf("\n", end);
|
||||||
|
if (to === -1) to = value.length;
|
||||||
|
return { value: value, start: start, end: end, from: from, to: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEdit(from, to, text, selStart, selEnd) {
|
||||||
|
var value = ta.value;
|
||||||
|
ta.selectionStart = from;
|
||||||
|
ta.selectionEnd = to;
|
||||||
|
var inserted = false;
|
||||||
|
try {
|
||||||
|
inserted = text === ""
|
||||||
|
? document.execCommand("delete")
|
||||||
|
: document.execCommand("insertText", false, text);
|
||||||
|
} catch (err) { inserted = false; }
|
||||||
|
if (!inserted) ta.value = value.slice(0, from) + text + value.slice(to);
|
||||||
|
ta.selectionStart = selStart;
|
||||||
|
ta.selectionEnd = selEnd;
|
||||||
|
dirty = true;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
var COMMENT_MARK = /<!--[ \t]?|[ \t]?-->/g;
|
||||||
|
|
||||||
|
function toggleComment() {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
if (!block.trim()) return;
|
||||||
|
|
||||||
|
var caretIn = r.start - r.from;
|
||||||
|
var shift = 0, out;
|
||||||
|
|
||||||
|
COMMENT_MARK.lastIndex = 0;
|
||||||
|
if (COMMENT_MARK.test(block)) {
|
||||||
|
COMMENT_MARK.lastIndex = 0;
|
||||||
|
out = block.replace(COMMENT_MARK, function (mark, offset) {
|
||||||
|
if (offset < caretIn) shift -= mark.length;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var indent = block.match(/^[ \t]*/)[0];
|
||||||
|
out = indent + "<!-- " + block.slice(indent.length) + " -->";
|
||||||
|
shift = caretIn >= indent.length ? 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.start === r.end) {
|
||||||
|
var pos = r.from + Math.min(out.length, Math.max(0, caretIn + shift));
|
||||||
|
applyEdit(r.from, r.to, out, pos, pos);
|
||||||
|
} else {
|
||||||
|
applyEdit(r.from, r.to, out, r.from, r.from + out.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLines(down) {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
if (!down) {
|
||||||
|
if (r.from === 0) return;
|
||||||
|
var prevFrom = r.value.lastIndexOf("\n", r.from - 2) + 1;
|
||||||
|
var prev = r.value.slice(prevFrom, r.from - 1);
|
||||||
|
var up = -(prev.length + 1);
|
||||||
|
applyEdit(prevFrom, r.to, block + "\n" + prev, r.start + up, r.end + up);
|
||||||
|
} else {
|
||||||
|
if (r.to >= r.value.length) return;
|
||||||
|
var nextTo = r.value.indexOf("\n", r.to + 1);
|
||||||
|
if (nextTo === -1) nextTo = r.value.length;
|
||||||
|
var next = r.value.slice(r.to + 1, nextTo);
|
||||||
|
var dn = next.length + 1;
|
||||||
|
applyEdit(r.from, nextTo, next + "\n" + block, r.start + dn, r.end + dn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLines(down) {
|
||||||
|
var r = lineRange();
|
||||||
|
var block = r.value.slice(r.from, r.to);
|
||||||
|
var text = block + "\n" + block;
|
||||||
|
var shift = down ? block.length + 1 : 0;
|
||||||
|
applyEdit(r.from, r.to, text, r.start + shift, r.end + shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLines() {
|
||||||
|
var r = lineRange();
|
||||||
|
var from = r.from, to = r.to;
|
||||||
|
if (to < r.value.length) to += 1;
|
||||||
|
else if (from > 0) from -= 1;
|
||||||
|
if (from === to) return;
|
||||||
|
|
||||||
|
var rest = r.value.slice(0, from) + r.value.slice(to);
|
||||||
|
var lineFrom = rest.lastIndexOf("\n", from - 1) + 1;
|
||||||
|
var lineTo = rest.indexOf("\n", lineFrom);
|
||||||
|
if (lineTo === -1) lineTo = rest.length;
|
||||||
|
var col = Math.min(Math.max(0, r.start - r.from), lineTo - lineFrom);
|
||||||
|
applyEdit(from, to, "", lineFrom + col, lineFrom + col);
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener("scroll", sync);
|
||||||
|
ta.addEventListener("input", function () { dirty = true; refresh(); });
|
||||||
|
ta.addEventListener("keydown", function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.altKey &&
|
||||||
|
(e.key === "/" || e.code === "Slash" || e.code === "NumpadDivide")) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleComment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var isUp = e.key === "ArrowUp" || e.code === "ArrowUp";
|
||||||
|
var isDown = e.key === "ArrowDown" || e.code === "ArrowDown";
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.metaKey && (isUp || isDown)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) duplicateLines(isDown);
|
||||||
|
else moveLines(isDown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey &&
|
||||||
|
(e.key === "Delete" || e.code === "Delete")) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteLines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
e.preventDefault();
|
||||||
|
var start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
ta.value = ta.value.slice(0, start) + " " + ta.value.slice(end);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
dirty = true;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLeave() {
|
||||||
|
if (!dirty) return Promise.resolve(true);
|
||||||
|
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCodeEditor();
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-copy]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var box = document.getElementById(btn.dataset.copy);
|
||||||
|
if (!box) return;
|
||||||
|
var done = function () {
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.textContent = "복사됨";
|
||||||
|
setTimeout(function () { btn.textContent = old; }, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
|
||||||
|
} else {
|
||||||
|
box.select();
|
||||||
|
try { document.execCommand("copy"); done(); } catch (e) { /* 직접 복사 */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var reloadBtn = document.getElementById("cf24-swiper-reload");
|
||||||
|
if (reloadBtn) {
|
||||||
|
reloadBtn.addEventListener("click", function () {
|
||||||
|
confirmLeave().then(function (ok) { if (ok) location.reload(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = document.getElementById("cf24-swiper-form");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.erpConfirm(form.dataset.confirm).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
dirty = false;
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", function (e) {
|
||||||
|
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,6 +7,7 @@ DB/네트워크 없이 검증한다(가짜 저장소 + refresh 함수 주입).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ftplib
|
||||||
import os
|
import os
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -663,6 +664,101 @@ def test_update_product_sends_flags_and_html():
|
|||||||
assert call["json"] == {"request": {"description": "<p>새</p>", "display": "F"}}
|
assert call["json"] == {"request": {"description": "<p>새</p>", "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 — 성공/재시도/최종실패
|
# 예약 worker — 성공/재시도/최종실패
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+52
-3
@@ -15,18 +15,20 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유)
|
app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 공유)
|
||||||
├─ config.py 환경변수 → Cafe24Config (하드코딩 금지)
|
├─ config.py 환경변수 → Cafe24Config / Cafe24FtpConfig (하드코딩 금지)
|
||||||
├─ crypto.py 토큰 Fernet 암복호화
|
├─ crypto.py 토큰 Fernet 암복호화
|
||||||
├─ oauth.py 인증 URL / code→token / refresh
|
├─ oauth.py 인증 URL / code→token / refresh
|
||||||
├─ 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 등 스킨 파일)
|
||||||
└─ 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)
|
||||||
├─ 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 방지로 분리)
|
||||||
@@ -34,7 +36,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(오른쪽 조각) ·
|
_editor.html(오른쪽 조각) · swiper.html ·
|
||||||
schedules.html · system.html
|
schedules.html · system.html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,10 +67,43 @@ 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` |
|
||||||
|
| `POST /cafe24/swiper/apply` | 편집한 JS 를 디자인 보관함(FTP)에 즉시 적용 | `cafe24` |
|
||||||
| `GET /cafe24/health` | 포털 카드 상태 점 | 없음 |
|
| `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`).
|
||||||
|
- 브라우저 `<textarea>` 는 줄바꿈을 CRLF 로 보낸다 — 적용 직전 LF 로 정규화한다
|
||||||
|
(안 하면 실수정 없이도 매번 파일 전체 줄바꿈이 바뀌어 지문 비교·"변경 없음"
|
||||||
|
판정이 어긋난다).
|
||||||
|
- env: `CAFE24_FTP_HOST`(미설정 시 `{mall_id}.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`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 2-1. 상품관리 화면 구성 (2분할)
|
### 2-1. 상품관리 화면 구성 (2분할)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -360,7 +395,14 @@ CAFE24_CLIENT_SECRET=...
|
|||||||
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
CAFE24_REDIRECT_URI=https://dbx.no1king.freeddns.org/cafe24/oauth/callback
|
||||||
CAFE24_API_VERSION=2026-03-01
|
CAFE24_API_VERSION=2026-03-01
|
||||||
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
|
||||||
CAFE24_SHOP_URL=https://miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
CAFE24_SHOP_URL=https://www.miras.co.kr # 선택 — 다이렉트 주소용 커스텀 도메인
|
||||||
|
|
||||||
|
# 모바일 스와이프(product-swiper.js) — 디자인 보관함 FTP (OAuth 와 별개 계정)
|
||||||
|
CAFE24_FTP_HOST=miraskitchen.ftp.cafe24.com # 선택 — 미설정 시 {mall_id}.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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5-4. 재기동 + 권한 부여
|
### 5-4. 재기동 + 권한 부여
|
||||||
@@ -375,6 +417,12 @@ cd /opt/www/main && docker compose up -d --build web cafe24-worker
|
|||||||
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_002_schedule_flags.sql
|
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_002_schedule_flags.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
모바일 스와이프(버전 이력) 기능을 쓰려면 마이그레이션 003 도 적용해야 한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_003_swiper_revisions.sql
|
||||||
|
```
|
||||||
|
|
||||||
worker 가 도는지 확인:
|
worker 가 도는지 확인:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -407,6 +455,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
|
|||||||
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
|
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
|
||||||
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
|
||||||
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
|
||||||
|
| 8 | 모바일 스와이프(product-swiper.js) 편집 — 디자인 보관함 FTP | ✅ 완료. 예약 적용은 없음(파일 1개, 상품 전용 개념이라 범위 밖) |
|
||||||
|
|
||||||
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/` 에 두면 이미지에 포함되지 않는다.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- cafe24_db 003 — 모바일 스와이프(product-swiper.js) 편집 버전 이력
|
||||||
|
-- =====================================================================
|
||||||
|
-- 멱등(idempotent): 여러 번 실행해도 안전. DROP/TRUNCATE 없음.
|
||||||
|
--
|
||||||
|
-- 실행:
|
||||||
|
-- docker exec -i postgres-db psql -U postgres -d cafe24_db \
|
||||||
|
-- < scripts/sql/cafe24_db_003_swiper_revisions.sql
|
||||||
|
--
|
||||||
|
-- 배경:
|
||||||
|
-- product-swiper.js 는 상품이 아니라 카페24 "디자인 보관함" FTP 파일이라
|
||||||
|
-- cafe24_product_revisions(product_no NOT NULL)에 넣을 수 없다. 같은 백업/
|
||||||
|
-- 버전이력 방식을 그대로 쓰기 위한 전용 테이블. file_key 는 앞으로 비슷한
|
||||||
|
-- 디자인 파일 편집기가 더 생길 경우를 대비한 구분자다(현재는 한 값만 쓴다).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cafe24_swiper_revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
file_key TEXT NOT NULL DEFAULT 'product-swiper',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
revision_type TEXT NOT NULL DEFAULT 'MANUAL',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_cafe24_swiper_revision_type CHECK (
|
||||||
|
revision_type IN ('SYNC','DRAFT','BACKUP','MANUAL','SCHEDULED','ROLLBACK')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cafe24_swiper_revisions_key
|
||||||
|
ON cafe24_swiper_revisions (file_key, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON cafe24_swiper_revisions TO cafe24_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cafe24_app;
|
||||||
|
|
||||||
|
SELECT 'cafe24_db 003 applied' AS status;
|
||||||
Reference in New Issue
Block a user