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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user