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
+56
View File
@@ -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 (다른 모듈과 동일)
# ════════════════════════════════════════════════════════════
+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,
)
+84
View File
@@ -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>
<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>
</div>
<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" 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 %}
+54
View File
@@ -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
View File
@@ -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;