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:
@@ -263,6 +263,62 @@ class Cafe24Store:
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
|
||||
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def add_revision(
|
||||
self,
|
||||
*,
|
||||
product_no: int,
|
||||
html_content: str,
|
||||
revision_type: str,
|
||||
memo: str = "",
|
||||
created_by: str = "",
|
||||
) -> int:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cafe24_product_revisions
|
||||
(product_no, html_content, revision_type, memo, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
int(product_no),
|
||||
html_content or "",
|
||||
store.normalize_revision_type(revision_type),
|
||||
(memo or "")[:500],
|
||||
created_by or "",
|
||||
),
|
||||
).fetchone()
|
||||
return int(row["id"]) if row else 0
|
||||
|
||||
def list_revisions(self, product_no: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""버전 목록. html_content 는 수 MB 일 수 있어 길이만 계산해서 준다."""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, product_no, revision_type, memo, created_by, created_at,
|
||||
length(html_content) AS html_length
|
||||
FROM cafe24_product_revisions
|
||||
WHERE product_no = %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(product_no), max(1, min(int(limit), 200))),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_revision(self, revision_id: int) -> dict[str, Any]:
|
||||
"""버전 1건 전체(HTML 포함). 복원/비교용."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cafe24_product_revisions WHERE id = %s",
|
||||
(int(revision_id),),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
|
||||
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
|
||||
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
|
||||
@@ -104,3 +108,83 @@ def parse_product_no(value: object) -> int:
|
||||
if number <= 0:
|
||||
raise ValueError("상품번호는 1 이상이어야 합니다.")
|
||||
return number
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 이미지 URL 의 한글 파일명 표시 (%EC%9A%A9… ↔ 용기…)
|
||||
#
|
||||
# 카페24는 상세페이지 HTML 안 이미지 경로를 퍼센트 인코딩해서 저장한다.
|
||||
# src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
|
||||
# 사람이 읽을 수 없으니 화면에서는 한글로 풀어 보여주고, 카페24에 쓸 때는 다시
|
||||
# 원래 형식으로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존돼야 한다
|
||||
# (encode(decode(원본)) == 원본).
|
||||
#
|
||||
# 안전 규칙 두 가지:
|
||||
# 1) 디코딩은 **non-ASCII 바이트(%80~%FF)** 만 한다. %20·%3C·%26 같은 ASCII
|
||||
# 이스케이프를 풀면 HTML 구조나 쿼리스트링이 깨진다.
|
||||
# 2) 인코딩은 **URL 속성값 안의 non-ASCII** 만 한다. 본문 한글 텍스트를
|
||||
# 건드리면 페이지가 깨지므로 대상 범위를 정규식으로 좁힌다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# src="..." / href='...' 같은 URL 속성값
|
||||
_URL_ATTR_RE = re.compile(
|
||||
r"""(?P<head>\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P<q>["']))(?P<url>[^"']*)(?P=q)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# CSS 의 url(...) — 인라인 <style> 안 배경 이미지
|
||||
_CSS_URL_RE = re.compile(
|
||||
r"""(?P<head>url\(\s*(?P<q>["']?))(?P<url>[^"')]*)(?P<tail>(?P=q)\s*\))""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 연속된 %XX 중 첫 바이트가 0x80 이상인 구간(= UTF-8 멀티바이트 문자)
|
||||
_NON_ASCII_PCT_RUN = re.compile(r"(?:%[89A-Fa-f][0-9A-Fa-f])+")
|
||||
# 인코딩 대상에서 제외할 문자 = 모든 ASCII 출력문자.
|
||||
# 결과적으로 non-ASCII 와 공백만 %XX 로 바뀐다. 괄호·밑줄·마침표는 카페24
|
||||
# 원본에서도 인코딩되지 않은 채 쓰이므로 반드시 그대로 남겨야 한다.
|
||||
_ASCII_SAFE = "".join(chr(code) for code in range(0x21, 0x7F))
|
||||
|
||||
|
||||
def _decode_pct_run(match: re.Match[str]) -> str:
|
||||
text = match.group(0)
|
||||
try:
|
||||
raw = bytes(int(text[i + 1 : i + 3], 16) for i in range(0, len(text), 3))
|
||||
return raw.decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
# UTF-8 이 아니면(EUC-KR 등) 건드리지 않는다 — 깨뜨리는 것보다 낫다.
|
||||
return text
|
||||
|
||||
|
||||
def decode_url_value(value: str) -> str:
|
||||
return _NON_ASCII_PCT_RUN.sub(_decode_pct_run, value or "")
|
||||
|
||||
|
||||
def encode_url_value(value: str) -> str:
|
||||
return quote(value or "", safe=_ASCII_SAFE, encoding="utf-8")
|
||||
|
||||
|
||||
def _map_urls(html: str, transform) -> str:
|
||||
def attr(match: re.Match[str]) -> str:
|
||||
return f"{match.group('head')}{transform(match.group('url'))}{match.group('q')}"
|
||||
|
||||
def css(match: re.Match[str]) -> str:
|
||||
return f"{match.group('head')}{transform(match.group('url'))}{match.group('tail')}"
|
||||
|
||||
return _CSS_URL_RE.sub(css, _URL_ATTR_RE.sub(attr, html or ""))
|
||||
|
||||
|
||||
def decode_html_urls(html: str) -> str:
|
||||
"""화면 표시용 — URL 안 %XX(한글 등)를 원래 문자로 되돌린다."""
|
||||
return _map_urls(html, decode_url_value)
|
||||
|
||||
|
||||
def encode_html_urls(html: str) -> str:
|
||||
"""카페24 저장용 — URL 안 non-ASCII 를 퍼센트 인코딩으로 되돌린다."""
|
||||
return _map_urls(html, encode_url_value)
|
||||
|
||||
|
||||
def fingerprint(html: str) -> str:
|
||||
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
||||
|
||||
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
||||
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
||||
"""
|
||||
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "cafe24/_nav.html" %}
|
||||
|
||||
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
|
||||
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="cf24-flash cf24-flash-err">
|
||||
카페24 조회에 실패했습니다: {{ error }}<br />
|
||||
@@ -44,12 +47,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── 상세설명 HTML (읽기 전용) ─────────────────────────────── #}
|
||||
{# ── 상세설명 편집 ─────────────────────────────────────────── #}
|
||||
{% if desc %}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>상세페이지 HTML</h3>
|
||||
<span class="cf24-muted">카페24 현재값 · 읽기 전용 (편집·적용은 Phase 3~4)</span>
|
||||
<h3>상세페이지 HTML 편집</h3>
|
||||
<span class="cf24-muted">적용하면 쇼핑몰에 바로 반영됩니다</span>
|
||||
</div>
|
||||
|
||||
<table class="erp-table cf24-kv">
|
||||
@@ -60,32 +63,80 @@
|
||||
<td>
|
||||
{{ desc.mobile_description | length }}자 ·
|
||||
{% if desc.separated_mobile %}
|
||||
<span class="cf24-warn">PC와 분리 사용</span> — 수정 시 모바일도 따로 반영해야 합니다.
|
||||
<span class="cf24-warn">PC와 분리 사용</span> — 아래 적용은 PC 만 바꿉니다.
|
||||
모바일은 카페24 관리자에서 따로 반영해야 합니다.
|
||||
{% else %}
|
||||
<span class="cf24-muted">PC와 동일 설정</span>
|
||||
<span class="cf24-muted">PC와 동일 설정</span> — 적용 시 모바일도 같은 내용으로 함께 반영됩니다.
|
||||
{% endif %}
|
||||
{% if desc.mobile_differs %}<span class="cf24-warn">내용 불일치</span>{% endif %}
|
||||
{% if desc.mobile_differs %}<span class="cf24-warn">현재 내용 불일치</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="cf24-note">
|
||||
이미지 경로의 한글 파일명은 카페24에 <code>%EC%9A%A9…</code> 형태로 저장되어 있습니다.
|
||||
편집기에서는 읽기 쉽게 <strong>한글로 풀어서</strong> 보여주고, 적용할 때 원래 형식으로
|
||||
되돌려 저장합니다. 경로를 직접 고칠 때도 한글로 그냥 쓰시면 됩니다.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/cafe24/products/{{ product_no }}/apply"
|
||||
onsubmit="return confirm('카페24 쇼핑몰에 바로 반영됩니다. 적용할까요?\n\n직전 내용은 자동으로 백업되어 되돌릴 수 있습니다.');">
|
||||
<input type="hidden" name="base_fingerprint" value="{{ fingerprint }}" />
|
||||
|
||||
<label class="cf24-label" for="cf24-html-pc">PC 상세설명 HTML</label>
|
||||
<textarea id="cf24-html-pc" class="cf24-html" rows="16" readonly spellcheck="false"
|
||||
>{{ desc.description }}</textarea>
|
||||
<div class="cf24-actions">
|
||||
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-pc">HTML 복사</button>
|
||||
<textarea id="cf24-html-pc" class="cf24-html" name="html" rows="22"
|
||||
spellcheck="false">{{ html_pc }}</textarea>
|
||||
|
||||
<div class="cf24-toolbar" style="margin-top:12px;">
|
||||
<input class="cf24-search" style="flex:1 1 320px;" type="text" name="memo" maxlength="200"
|
||||
placeholder="변경 메모 (버전 이력에 남습니다 — 예: 8월 프로모션 배너 교체)" />
|
||||
<button class="erp-btn erp-btn-primary" type="submit">카페24에 적용</button>
|
||||
<button class="erp-btn erp-btn-outline" type="button" data-copy="cf24-html-pc">HTML 복사</button>
|
||||
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ product_no }}">되돌리기(새로고침)</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if desc.separated_mobile or desc.mobile_differs %}
|
||||
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML</label>
|
||||
<textarea id="cf24-html-mo" class="cf24-html" rows="16" readonly spellcheck="false"
|
||||
>{{ desc.mobile_description }}</textarea>
|
||||
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML (읽기 전용)</label>
|
||||
<textarea id="cf24-html-mo" class="cf24-html" rows="12" readonly spellcheck="false"
|
||||
>{{ html_mobile }}</textarea>
|
||||
<div class="cf24-actions">
|
||||
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">HTML 복사</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── 버전 이력 ─────────────────────────────────────────────── #}
|
||||
<div class="erp-card cf24-card">
|
||||
<div class="cf24-card-head">
|
||||
<h3>버전 이력</h3>
|
||||
<span class="cf24-muted">최근 20건 · 적용 직전 내용은 BACKUP 으로 자동 보관</span>
|
||||
</div>
|
||||
{% if revisions %}
|
||||
<div class="cf24-scroll">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>시각</th><th>유형</th><th>길이</th><th>작업자</th><th>메모</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rev in revisions %}
|
||||
<tr>
|
||||
<td class="cf24-nowrap">{{ rev.created_at }}</td>
|
||||
<td class="cf24-nowrap"><code>{{ rev.revision_type }}</code></td>
|
||||
<td class="cf24-nowrap">{{ rev.html_length }}자</td>
|
||||
<td class="cf24-nowrap">{{ rev.created_by or '—' }}</td>
|
||||
<td>{{ rev.memo or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cf24-muted">되돌리기(버전 선택 복원)는 Phase 6 에서 화면에 붙습니다. 지금도 내용은 모두 보관됩니다.</p>
|
||||
{% else %}
|
||||
<p class="cf24-muted">아직 이 상품의 변경 이력이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -109,5 +160,21 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 편집 중 실수로 페이지를 벗어나는 것 방지.
|
||||
(function () {
|
||||
var box = document.getElementById("cf24-html-pc");
|
||||
if (!box || box.readOnly) return;
|
||||
var initial = box.value;
|
||||
var form = box.form;
|
||||
var submitting = false;
|
||||
if (form) form.addEventListener("submit", function () { submitting = true; });
|
||||
window.addEventListener("beforeunload", function (e) {
|
||||
if (!submitting && box.value !== initial) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
|
||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814d" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -342,6 +342,60 @@ def test_list_products_clamps_paging():
|
||||
assert params["product_name"] == "락"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 이미지 URL 한글 파일명 표시 ↔ 저장 (왕복 보존이 핵심)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
_REAL_IMG = "%EC%9A%A9%EA%B8%B0%EB%83%84%EC%83%88%EC%B0%A8%EB%8B%A8(%ED%99%A9%ED%86%A0)_12.gif"
|
||||
_REAL_HTML = f'<img src="/web/product/big/{_REAL_IMG}" alt="용기 냄새차단">'
|
||||
|
||||
|
||||
def test_decode_shows_korean_filename():
|
||||
decoded = store.decode_html_urls(_REAL_HTML)
|
||||
assert "용기냄새차단(황토)_12.gif" in decoded
|
||||
assert "%EC%9A%A9" not in decoded
|
||||
# 괄호는 원본에서 인코딩돼 있지 않으므로 그대로 남아야 한다.
|
||||
assert "(황토)" in decoded
|
||||
|
||||
|
||||
def test_url_roundtrip_is_byte_identical():
|
||||
"""편집하지 않고 적용해도 카페24 저장값이 달라지면 안 된다."""
|
||||
assert store.encode_html_urls(store.decode_html_urls(_REAL_HTML)) == _REAL_HTML
|
||||
|
||||
|
||||
def test_ascii_escapes_are_not_decoded():
|
||||
"""%20·%3C 를 풀면 URL·HTML 구조가 깨진다 — 건드리지 않는다."""
|
||||
html = '<img src="/web/a%20b.png?x=1%3C2">'
|
||||
assert store.decode_html_urls(html) == html
|
||||
assert store.encode_html_urls(html) == html
|
||||
|
||||
|
||||
def test_encode_leaves_body_text_alone():
|
||||
"""본문 한글은 인코딩 대상이 아니다(URL 속성값만 바꾼다)."""
|
||||
html = '<p>여름 특가 안내</p><a href="/web/여름.html">보기</a>'
|
||||
encoded = store.encode_html_urls(html)
|
||||
assert "<p>여름 특가 안내</p>" in encoded
|
||||
assert 'href="/web/%EC%97%AC%EB%A6%84.html"' in encoded
|
||||
|
||||
|
||||
def test_css_url_is_handled():
|
||||
html = "<style>.a{background:url(/web/upload/%ED%99%A9%ED%86%A0.png)}</style>"
|
||||
assert "황토.png" in store.decode_html_urls(html)
|
||||
assert store.encode_html_urls(store.decode_html_urls(html)) == html
|
||||
|
||||
|
||||
def test_invalid_utf8_sequence_left_alone():
|
||||
"""EUC-KR 등 UTF-8 이 아닌 이스케이프는 깨뜨리지 않고 그대로 둔다."""
|
||||
html = '<img src="/web/%C7%CF%B3%AA.gif">'
|
||||
assert store.decode_html_urls(html) == html
|
||||
|
||||
|
||||
def test_fingerprint_detects_change():
|
||||
a = store.fingerprint("<p>A</p>")
|
||||
assert a == store.fingerprint("<p>A</p>")
|
||||
assert a != store.fingerprint("<p>B</p>")
|
||||
assert len(a) == 32
|
||||
|
||||
|
||||
def _run_all():
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
|
||||
+17
-1
@@ -132,7 +132,7 @@
|
||||
padding: var(--sp-12, 12px);
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
border-radius: var(--r-lg, 10px);
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
background: var(--color-canvas-white, #fff);
|
||||
font-family: var(--font-geist-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
@@ -141,6 +141,22 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* 읽기 전용은 배경으로 구분 — 편집 가능한 칸과 헷갈리지 않게 */
|
||||
.cf24-html[readonly] {
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
}
|
||||
|
||||
.cf24-note {
|
||||
margin: var(--sp-12, 12px) 0 0;
|
||||
padding: var(--sp-10, 10px) var(--sp-12, 12px);
|
||||
border-left: 3px solid var(--color-subtle-ash, #e5e5e5);
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
border-radius: var(--r-sm, 6px);
|
||||
color: var(--color-midtone-gray, #737373);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 넓은 로그 표는 카드 안에서만 가로 스크롤 */
|
||||
.cf24-scroll {
|
||||
overflow-x: auto;
|
||||
|
||||
+39
-3
@@ -49,7 +49,8 @@ app/modules/cafe24/ ← 상품관리 모듈
|
||||
| 경로 | 화면 | 권한 |
|
||||
| --- | --- | --- |
|
||||
| `GET /cafe24/` | 상품 목록·검색 (`q`, `page`) | `cafe24` |
|
||||
| `GET /cafe24/products/{product_no}` | 상품 1건 + 현재 상세설명 HTML (읽기 전용) | `cafe24` |
|
||||
| `GET /cafe24/products/{product_no}` | 상품 1건 + 상세설명 HTML 편집기 + 버전 이력 | `cafe24` |
|
||||
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
|
||||
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
|
||||
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
|
||||
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
|
||||
@@ -112,6 +113,41 @@ cafe24_oauth_tokens 저장
|
||||
- 그 밖에 상세 응답에만 있는 참고 필드: `translated_description`(다국어),
|
||||
`summary_description`(요약설명), `simple_description`, `shop_no`(멀티쇼핑몰).
|
||||
|
||||
- **이미지 경로의 한글은 퍼센트 인코딩되어 저장된다.**
|
||||
|
||||
```
|
||||
src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
|
||||
```
|
||||
|
||||
사람이 읽을 수 없으므로 화면에서는 `store.decode_html_urls` 로 풀어서 보여주고,
|
||||
저장할 때 `store.encode_html_urls` 로 되돌린다. 두 함수는 서로의 역이며
|
||||
**왕복이 보존된다**(편집하지 않고 적용해도 저장값이 바뀌지 않는다 — 테스트로 고정).
|
||||
안전 규칙: 디코딩은 non-ASCII(`%80`~`%FF`)만, 인코딩은 URL 속성값 안만.
|
||||
`%20`·`%3C` 를 풀거나 본문 한글을 인코딩하면 페이지가 깨진다.
|
||||
|
||||
---
|
||||
|
||||
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
|
||||
|
||||
이 순서를 절대 바꾸지 않는다.
|
||||
|
||||
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
|
||||
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
|
||||
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
|
||||
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
|
||||
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
|
||||
덮어쓰는 것을 막는 낙관적 잠금이다.
|
||||
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
|
||||
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`).
|
||||
|
||||
추가 규칙:
|
||||
|
||||
- 빈 내용은 거부한다(상세페이지 전체를 날리는 실수 방지).
|
||||
- **PC/모바일 미분리(`separated_mobile_description='F'`) 상품은 모바일 필드도
|
||||
같은 HTML 로 함께 쓴다.** PC 만 바꾸면 모바일이 어긋난다.
|
||||
분리(`'T'`) 상품은 모바일을 건드리지 않고, 화면에 "모바일은 따로 반영" 을 알린다.
|
||||
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 보안 규칙 (반드시 지킬 것)
|
||||
@@ -184,8 +220,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
|
||||
| --- | --- | --- |
|
||||
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
|
||||
| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
|
||||
| 3 | Monaco 편집 · 미리보기 · Diff · 초안 | 예정 |
|
||||
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | 예정 |
|
||||
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 편집기(textarea)만 완료. 미리보기·Diff·초안 예정 |
|
||||
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
|
||||
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
|
||||
| 6 | 자동 종료/복원 · 롤백 | 예정 |
|
||||
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | 예정 |
|
||||
|
||||
Reference in New Issue
Block a user