feat(cafe24): 상세페이지 HTML 편집·적용 + 이미지 경로 한글 표시

1) 한글 파일명이 %EC%9A%A9… 으로 보이던 문제

카페24는 상세페이지 안 이미지 경로를 퍼센트 인코딩해서 저장한다. 화면에서는
읽을 수 없으므로 store.decode_html_urls 로 풀어 보여주고, 저장할 때
encode_html_urls 로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존된다 —
편집하지 않고 적용해도 카페24 저장값이 한 바이트도 달라지지 않는다.

깨뜨리지 않기 위한 두 가지 제약을 뒀다. 디코딩은 non-ASCII(%80~%FF)만 한다.
%20·%3C 를 풀면 URL·HTML 구조가 깨진다. 인코딩은 src/href/poster/data-src 와
CSS url() 안의 값만 한다. 본문 한글 텍스트를 인코딩하면 페이지가 망가진다.
UTF-8 로 해석되지 않는 이스케이프(EUC-KR 등)는 건드리지 않고 그대로 둔다.

2) 편집 후 적용

POST /cafe24/products/{no}/apply 는 이 순서를 지킨다.
  카페24 현재값 재조회 → BACKUP revision → 지문 대조 → PUT → MANUAL revision
현재값을 다시 읽는 것은 로컬 DB 의 마지막 버전이 지금 카페24에 올라간 값이라고
믿을 수 없기 때문이다(관리자 페이지에서 직접 고쳤을 수 있다). 지문(sha256 앞
32자)은 편집 중 남이 바꾼 내용을 조용히 덮어쓰는 것을 막는 낙관적 잠금이다.

미분리 상품(separated_mobile_description='F')은 모바일 필드도 같은 HTML 로
함께 쓴다. PC 만 바꾸면 모바일 상세가 어긋난다. 분리 상품은 모바일을 건드리지
않고 화면에 별도 반영 안내를 띄운다.

빈 내용은 거부한다(상세페이지를 통째로 날리는 실수 방지). 변경이 없으면 API 를
호출하지 않는다. 실패 시에도 BACKUP 은 남아 있으므로 오류 메시지에 버전 번호를
알려준다. 편집 중 페이지 이탈 경고도 넣었다.

버전 이력 표를 상세 화면에 붙였다(목록 조회는 html_content 를 제외하고 길이만
계산한다 — 수 MB 가 될 수 있다). 버전 선택 복원은 Phase 6.

검증: 유닛테스트 30개 통과(신규 7개 — 실제 파일명으로 왕복 동일성, ASCII
이스케이프 미변환, 본문 한글 보존, CSS url(), 잘못된 UTF-8 무시, 지문).
실제 쓰기(PUT)는 서버 배포 후 테스트 상품 1건으로 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:14:42 +09:00
parent 07626bcaf8
commit 7a2e933c16
10 changed files with 459 additions and 27 deletions
+123 -4
View File
@@ -1,4 +1,4 @@
"""카페24 상품 화면 — 목록/검색 · 현재 상세설명(HTML) 조회. Phase 2.
"""카페24 상품 화면 — 목록/검색 · 상세설명(HTML) 조회 · 편집 후 즉시 적용.
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다
@@ -8,7 +8,14 @@ API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다.
편집·적용은 Phase 3~4 다. 이 파일은 **읽기 전용**이며 카페24에 쓰지 않는다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
MANUAL 버전 + 감사로그
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는
`store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로
되돌린다(왕복 보존 — store.py 주석 참고).
핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
@@ -19,11 +26,12 @@ from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from . import store
from .common import base_ctx, guard
logger = logging.getLogger("cafe24.products")
@@ -145,7 +153,118 @@ def product_detail(request: Request, product_no: int) -> HTMLResponse:
"summary_description": product.get("summary_description") or "",
},
"desc": desc,
# 편집기에는 이미지 경로의 %EC%9A%A9… 을 한글로 풀어서 보여준다.
# 저장할 때 다시 인코딩하므로 카페24에 저장되는 값은 그대로다.
"html_pc": store.decode_html_urls(desc.description) if desc else "",
"html_mobile": store.decode_html_urls(desc.mobile_description) if desc else "",
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
"revisions": st.list_revisions(product_no, limit=20),
"error": error,
"flash": request.query_params.get("msg", ""),
"flash_error": request.query_params.get("err", ""),
}
)
return render_template(request, "cafe24/product.html", ctx)
@products_router.post("/products/{product_no}/apply")
def product_apply(
request: Request,
product_no: int,
html: str = Form(""),
base_fingerprint: str = Form(""),
memo: str = Form(""),
):
"""편집한 HTML 을 카페24에 즉시 적용한다.
순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
PC/모바일 미분리 상품은 모바일 필드도 같은 HTML 로 맞춘다. 분리 상품은
모바일을 건드리지 않는다(화면에 별도 반영 안내를 띄운다).
"""
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
actor = str(user.get("email") or "")
back = f"/cafe24/products/{product_no}"
submitted = store.encode_html_urls(html or "")
if not submitted.strip():
return RedirectResponse(
url=f"{back}?err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
status_code=303,
)
api = build_cafe24_api(st)
try:
current = products.fetch_descriptions(api.client, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}",
)
return RedirectResponse(url=f"{back}?err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
backup_id = st.add_revision(
product_no=product_no,
html_content=current.description,
revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업",
created_by=actor,
)
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
)
return RedirectResponse(
url=f"{back}?err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
status_code=303,
)
if submitted == current.description:
return RedirectResponse(url=f"{back}?msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
mobile_html = None if current.separated_mobile else submitted
try:
products.update_descriptions(
api.client, product_no, description=submitted, mobile_description=mobile_html
)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=backup_id, result="FAIL", detail=str(exc),
)
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
return RedirectResponse(
url=f"{back}?err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
status_code=303,
)
revision_id = st.add_revision(
product_no=product_no,
html_content=submitted,
revision_type=store.REVISION_MANUAL,
memo=memo,
created_by=actor,
)
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=revision_id, result="SUCCESS",
detail=f"{len(submitted)}자 적용 (백업 {backup_id}"
+ (", 모바일 동시 반영)" if mobile_html is not None else ")"),
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse(
url=f"{back}?msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
status_code=303,
)