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