"""모바일 스와이프(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, )