From 8c2a9c47e10b261a7e2ca68c41bc59bb817de299 Mon Sep 17 00:00:00 2001 From: king Date: Tue, 15 Sep 2026 14:50:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20PC/=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=83=81=ED=92=88=EC=83=81=EC=84=B8=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=8E=B8=EC=A7=91=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=ED=8C=8C=EC=9D=BC=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 탭에 "PC 상품상세"(/sde_design/skin11/product/detail.html), "모바일 상품상세"(/sde_design/mobile11/product/detail.html) 버튼 추가. 모바일 스와이프와 마찬가지로 상품 API가 아닌 디자인 보관함 FTP 파일이며, 상세페이지 편집기와 완전히 같은 문법강조·색상·단축키를 그대로 재사용. - 세 파일(swiper/mobile_detail/pc_detail)이 화면·로직 100% 동일해 라우터를 routes_swiper.py → routes_design_files.py 로 일반화(/cafe24/design/{file_key} 하나로 공유). 템플릿도 swiper.html → design_editor.html 로 통합. db.py 의 add_swiper_revision/list_swiper_revisions 도 file_key 를 받는 add_design_revision/list_design_revisions 로 일반화(테이블은 처음부터 file_key 컬럼으로 여러 파일을 담게 설계돼 있어 마이그레이션 불필요). - PC/모바일 상품상세는 상품 1건이 아니라 전체 상품 페이지가 공유하는 템플릿이라 적용 확인 문구에 별도 경고 추가. - 신규 env: CAFE24_MOBILE_DETAIL_FTP_PATH, CAFE24_PC_DETAIL_FTP_PATH (FTP 호스트/계정은 기존 CAFE24_FTP_* 공유). .env.example·문서 갱신. - 유닛테스트 갱신/추가: DESIGN_FILE_SPECS 3종 등록 확인, 경로 env 오버라이드. Co-Authored-By: Claude Sonnet 5 --- app/modules/cafe24/routes_design_files.py | 201 ++++++++++++++++++ app/modules/cafe24/routes_swiper.py | 157 -------------- .../{swiper.html => design_editor.html} | 37 ++-- 3 files changed, 220 insertions(+), 175 deletions(-) create mode 100644 app/modules/cafe24/routes_design_files.py delete mode 100644 app/modules/cafe24/routes_swiper.py rename app/modules/cafe24/templates/cafe24/{swiper.html => design_editor.html} (91%) diff --git a/app/modules/cafe24/routes_design_files.py b/app/modules/cafe24/routes_design_files.py new file mode 100644 index 0000000..c049912 --- /dev/null +++ b/app/modules/cafe24/routes_design_files.py @@ -0,0 +1,201 @@ +"""디자인 보관함 FTP 파일 편집 화면 — 상품 API 로 못 건드리는 스킨 파일 전용. + +대상 파일(`app/integrations/cafe24/config.py` 의 `DESIGN_FILE_SPECS`): + swiper 모바일 스와이프 product-swiper.js + mobile_detail 모바일 상품상세 스킨 템플릿 detail.html + pc_detail PC 상품상세 스킨 템플릿 detail.html + +상세페이지 편집기(routes_products.py)와 **같은** 문법강조 편집기·색상·단축키를 +그대로 쓴다(요청사항: 모든 편집 기능이 상세페이지 소스 수정 기능과 같아야 한다). +다만 대상이 상품이 아니라 파일 1개(FTP)라서 목록·진열/판매·예약 같은 상품 전용 +기능은 없다 — 편집·적용(백업 포함)·버전 이력만 있다. 세 파일 모두 화면·로직이 +완전히 같아서 `file_key` 하나로 라우트를 공유한다(products.html 은 상품마다 +다른 데이터를 다루지만, 여기는 파일마다 경로만 다르고 나머지는 동일하다). + +쓰기 순서는 상세페이지 적용과 동일한 원칙을 따른다: + FTP 에서 현재 내용을 다시 읽는다(로컬 값을 현재값으로 가정하지 않는다) + → BACKUP 버전 저장 → 지문 대조(충돌 시 거부) → FTP 에 쓴다 + → MANUAL 버전 + 감사로그 + +핸들러는 `async def` 가 아니라 `def`(동기)다 — FTP 호출이 블로킹이므로 +FastAPI 스레드풀에서 돌게 둔다(다른 cafe24 라우트와 동일한 규칙). +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.integrations.cafe24 import ( + DESIGN_FILE_SPECS, + Cafe24FtpError, + design_file_label, + design_file_path, + 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.design_files") + +design_files_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) + + +def _require_known_key(file_key: str) -> None: + if file_key not in DESIGN_FILE_SPECS: + raise HTTPException(status_code=404, detail="알 수 없는 디자인 파일입니다.") + + +# 상품상세 템플릿(mobile_detail/pc_detail)은 상품 1건이 아니라 전체 상품 +# 페이지가 공유하는 레이아웃이다 — 잘못 고치면 모든 상품에 영향을 준다. +# 스와이프(개별 스크립트)와 위험도가 달라 확인 문구를 따로 둔다. +_TEMPLATE_KEYS = {"mobile_detail", "pc_detail"} + + +def _apply_confirm_text(file_key: str) -> str: + base = "카페24 디자인 보관함(FTP)에 바로 반영됩니다. 적용할까요?" + if file_key in _TEMPLATE_KEYS: + base = "⚠ 전체 상품 페이지가 공유하는 템플릿입니다. " + base + return base + "\n\n직전 내용은 자동으로 백업되어 되돌릴 수 있습니다." + + +@design_files_router.get("/design/{file_key}", response_class=HTMLResponse) +def design_file_page(request: Request, file_key: str) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + _require_known_key(file_key) + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + path = design_file_path(file_key) + 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, path) + except Cafe24FtpError as exc: + error = str(exc) + logger.warning("디자인 파일 조회 실패(%s): %s", file_key, exc) + + ctx = base_ctx(request, user, active_tab=f"design:{file_key}") + ctx.update( + { + "page_title": design_file_label(file_key), + "page_subtitle": f"{path} (카페24 디자인 보관함)", + "file_key": file_key, + "file_path": path, + "content": content, + "fingerprint": store.fingerprint(content) if not error else "", + "error": error, + "apply_confirm": _apply_confirm_text(file_key), + "revisions": st.list_design_revisions(file_key, limit=20), + "flash": request.query_params.get("msg", ""), + "flash_error": request.query_params.get("err", ""), + } + ) + return _no_store(render_template(request, "cafe24/design_editor.html", ctx)) + + +@design_files_router.post("/design/{file_key}/apply") +def design_file_apply( + request: Request, + file_key: str, + content: str = Form(...), + base_fingerprint: str = Form(""), + memo: str = Form(""), +) -> RedirectResponse: + _require_known_key(file_key) + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + actor = str(user.get("email") or "") + back = f"/cafe24/design/{file_key}" + + path = design_file_path(file_key) + ftp_config = _ftp_config() + if not ftp_config.configured: + return RedirectResponse( + url=f"{back}?err=" + "FTP 설정이 필요합니다: " + ", ".join(ftp_config.missing), + status_code=303, + ) + + # 브라우저 textarea 는 줄바꿈을 CRLF 로 보낸다 — 그대로 저장하면 실제 수정이 + # 없어도 매번 파일 전체의 줄바꿈이 바뀌어(지문 비교·"변경 없음" 판정이 어긋난다). + submitted = content.replace("\r\n", "\n") + if not submitted.strip(): + return RedirectResponse( + url=f"{back}?err=" + "내용이 비어 있습니다. 파일을 비우려면 FTP 로 직접 하세요.", + status_code=303, + ) + + try: + current = design_ftp.read_text_file(ftp_config, path) + except Cafe24FtpError as exc: + st.log_audit( + actor=actor, action=f"apply_design:{file_key}", result="FAIL", + detail=f"현재값 조회 실패: {exc}", + ) + return RedirectResponse(url=f"{back}?err=현재 파일을 읽지 못해 중단했습니다: {exc}", status_code=303) + + backup_id = st.add_design_revision( + file_key=file_key, 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=f"apply_design:{file_key}", revision_id=backup_id, + result="FAIL", detail="충돌 — 편집 중 파일이 변경됨", + ) + return RedirectResponse( + url=f"{back}?err=편집하는 동안 파일이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.", + status_code=303, + ) + + if submitted == current: + return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) + + try: + design_ftp.write_text_file(ftp_config, path, submitted) + except Cafe24FtpError as exc: + st.log_audit( + actor=actor, action=f"apply_design:{file_key}", revision_id=backup_id, + result="FAIL", detail=str(exc), + ) + logger.warning("디자인 파일 적용 실패(%s): %s", file_key, exc) + return RedirectResponse( + url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)", + status_code=303, + ) + + revision_id = st.add_design_revision( + file_key=file_key, content=submitted, revision_type=store.REVISION_MANUAL, + memo=memo, created_by=actor, + ) + st.log_audit( + actor=actor, action=f"apply_design:{file_key}", revision_id=revision_id, result="SUCCESS", + detail=f"{len(submitted)}자 적용 (백업 {backup_id})", + ) + logger.info("디자인 파일 적용(%s) (%s)", file_key, actor) + return RedirectResponse( + url=f"{back}?msg=적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", + status_code=303, + ) diff --git a/app/modules/cafe24/routes_swiper.py b/app/modules/cafe24/routes_swiper.py deleted file mode 100644 index 3018fb9..0000000 --- a/app/modules/cafe24/routes_swiper.py +++ /dev/null @@ -1,157 +0,0 @@ -"""모바일 스와이프(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/swiper.html b/app/modules/cafe24/templates/cafe24/design_editor.html similarity index 91% rename from app/modules/cafe24/templates/cafe24/swiper.html rename to app/modules/cafe24/templates/cafe24/design_editor.html index 605bc5a..c070756 100644 --- a/app/modules/cafe24/templates/cafe24/swiper.html +++ b/app/modules/cafe24/templates/cafe24/design_editor.html @@ -19,14 +19,15 @@ {# 상세페이지 편집기(_editor.html)와 완전히 같은 문법강조·단축키·버튼을 쓴다. 목록/진열·판매/예약처럼 "상품" 전용인 것만 뺐다 — 이건 상품이 아니라 - 디자인 보관함(FTP)의 파일 1개다. #} -
-

{{ swiper_path }}

+ 디자인 보관함(FTP)의 파일 1개다(모바일 스와이프 / PC·모바일 상품상세 + 템플릿이 file_key 만 다르고 화면·로직은 완전히 같다). #} +
+

{{ file_path }}

{% if not error %} -
+
@@ -34,20 +35,20 @@ - - +
{# 색칠된
 위에 투명한 
       
     
@@ -126,9 +127,9 @@
   }
 
   function setupCodeEditor() {
-    var ta = document.getElementById("cf24-swiper-code");
-    var hl = document.getElementById("cf24-swiper-hl");
-    var gutter = document.getElementById("cf24-swiper-gutter");
+    var ta = document.getElementById("cf24-design-code");
+    var hl = document.getElementById("cf24-design-hl");
+    var gutter = document.getElementById("cf24-design-gutter");
     if (!ta || !hl) return;
 
     var timer = null;
@@ -322,14 +323,14 @@
     });
   });
 
-  var reloadBtn = document.getElementById("cf24-swiper-reload");
+  var reloadBtn = document.getElementById("cf24-design-reload");
   if (reloadBtn) {
     reloadBtn.addEventListener("click", function () {
       confirmLeave().then(function (ok) { if (ok) location.reload(); });
     });
   }
 
-  var form = document.getElementById("cf24-swiper-form");
+  var form = document.getElementById("cf24-design-form");
   if (form) {
     form.addEventListener("submit", function (e) {
       e.preventDefault();