revert(cafe24): 상단 공통 홍보 숨기기 기능 제거

사용자 결정에 따라 없던 기능으로 되돌린다. 카페24 API 로 스킨 detail.html 의
상품번호 목록을 고칠 수 없다는 것이 확인된 뒤, 대안(상세설명 CSS 주입 / 스크립트
태그)을 모두 채택하지 않기로 정리했다.

제거 대상:
  store.py        HIDE_PROMO_* 상수, has_hidden_promo, set_promo_hidden
  routes_products 편집기 컨텍스트의 promo 상태, hide_promo 폼 필드,
                  적용 시 블록 주입, 분리 상품 모바일 블록 동기화, 감사로그 표기
  _editor.html    「상단 공통 홍보 숨기기」 체크박스
  cafe24.css      .cf24-check-inline
  tests           promo 관련 4건
  docs            2-3 절

적용 시 "변경 없음" 판정에 모바일 비교를 포함시킨 것은 남겼다. 공통 홍보와 무관하게
맞는 동작이다(PC 는 그대로여도 모바일이 PC 와 다르면 맞춰줘야 한다).

문서 정리도 함께 했다. 앞선 편집에서 3-1·2-2 절이 중복 삽입되고 절 순서가
뒤섞여 있던 것을 2 → 2-1 → 2-2 → 3 → 3-1 → 3-2 로 바로잡았다. 편집기 절에는
아직 이전 방식(크기 맞추기·sticky 줄번호)이 적혀 있었는데 실제 구현인 스크롤
동기화로 갱신하고, 크기 계산이 두 번 실패한 이유를 근거 수치와 함께 남겼다.

⚠️ 이미 이 기능으로 적용한 상품이 있다면 그 상품 상세설명에
   <style id="cf24-hide-common-promo"> 블록이 남아 있다. 편집기에서 그 줄을 지우고
   적용하면 된다.

검증: 유닛테스트 45개 통과, 라우트 10개 등록, 템플릿 컴파일 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:03:10 +09:00
parent e96c0636f5
commit 25ed369583
7 changed files with 76 additions and 236 deletions
+2 -3
View File
@@ -21,9 +21,8 @@ ORDER_SCOPES: tuple[str, ...] = ("mall.read_order", "mall.write_order")
# GET /admin/themes/pages → No API found. (버전 2026-03-01 에 존재하지 않음)
# 스킨 HTML 파일(product/detail.html 등)을 읽거나 쓰는 엔드포인트는 없다.
#
# 즉 스킨의 `const numbers = [...]`(상단 공통 홍보 제외 목록)을 API 로 고치는 방법은
# 없다. 상단 공통 홍보 숨김은 상품 상세설명 안 CSS 로 처리한다
# (app/modules/cafe24/store.py 의 set_promo_hidden).
# 즉 스킨에 박힌 값(예: 상단 공통 홍보 제외할 상품번호 목록)을 API 로 고치는
# 방법은 없다. 스킨은 카페24 관리자에서 직접 관리해야 한다.
#
# 페이지에 코드를 주입하는 유일한 수단은 스크립트 태그(/admin/scripttags)이며,
# 디자인이 아니라 **mall.write_store(상점)** 권한이 필요하고 인라인 코드가 아닌
+5 -19
View File
@@ -116,9 +116,6 @@ def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
),
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
# 상단 공통 홍보 숨김 상태(상세설명 안 CSS 블록으로 판단)
"promo_hidden": store.has_hidden_promo(desc.description) if desc else False,
"promo_hidden_mobile": store.has_hidden_promo(desc.mobile_description) if desc else False,
"revisions": st.list_revisions(product_no, limit=20),
"editor_error": error,
}
@@ -217,7 +214,6 @@ def product_apply(
base_fingerprint: str = Form(""),
memo: str = Form(""),
list_query: str = Form(""),
hide_promo: str = Form(""),
):
"""편집한 HTML 을 카페24에 즉시 적용한다.
@@ -242,10 +238,7 @@ def product_apply(
# 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는
# 원래의 퍼센트 인코딩으로 되돌린다.
want_hide_promo = bool(hide_promo)
submitted = store.format_html(
store.set_promo_hidden(store.encode_html_urls(html or ""), want_hide_promo)
)
submitted = store.format_html(store.encode_html_urls(html or ""))
if not submitted.strip():
return RedirectResponse(
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
@@ -280,14 +273,9 @@ def product_apply(
status_code=303,
)
if current.separated_mobile:
# 분리 상품은 모바일 본문을 건드리지 않는다. 다만 공통 홍보 숨김은 PC/모바일
# 양쪽에 걸어야 효과가 있으므로 그 블록만 모바일에도 맞춰준다.
mobile_target = store.set_promo_hidden(current.mobile_description, want_hide_promo)
mobile_html = mobile_target if mobile_target != current.mobile_description else None
else:
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
mobile_html = submitted
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
# 분리 상품은 모바일을 건드리지 않는다(화면에 별도 반영 안내를 띄운다).
mobile_html = None if current.separated_mobile else submitted
if submitted == current.description and (
mobile_html is None or mobile_html == current.mobile_description
@@ -319,9 +307,7 @@ def product_apply(
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 "")
+ (", 공통홍보 숨김" if want_hide_promo else "")
+ ")",
+ (", 모바일 동시 반영)" if mobile_html is not None else ")"),
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse(
-41
View File
@@ -368,47 +368,6 @@ def _collapse_short_blocks(lines: list[str]) -> list[str]:
return out
# ════════════════════════════════════════════════════════════
# 상단 공통 홍보 숨기기
#
# 스킨(detail.html)에는 공통 홍보를 지울 상품번호 목록이 박혀 있다.
# const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
# 그런데 카페24 Admin API 는 **스킨 파일을 읽거나 쓸 수 없다**(테마는 조회만).
# 그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다 — 상세설명은 우리가 쓸 수 있고,
# 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
#
# id 를 붙여 우리가 넣은 블록임을 표시한다. 사람이 쓴 <style> 은 건드리지 않는다.
# ════════════════════════════════════════════════════════════
HIDE_PROMO_ID = "cf24-hide-common-promo"
HIDE_PROMO_BLOCK = (
f'<style id="{HIDE_PROMO_ID}">/* DBX ERP: 상단 공통 홍보 숨김 */\n'
".edb-img-tag-w{display:none !important}\n"
"</style>"
)
_HIDE_PROMO_RE = re.compile(
r"[ \t]*<style[^>]*\bid\s*=\s*[\"']%s[\"'][^>]*>.*?</style>\s*" % re.escape(HIDE_PROMO_ID),
re.IGNORECASE | re.DOTALL,
)
def has_hidden_promo(html: str) -> bool:
"""이 상품의 상세설명에 공통 홍보 숨김 블록이 들어 있는가."""
return bool(_HIDE_PROMO_RE.search(html or ""))
def set_promo_hidden(html: str, hidden: bool) -> str:
"""숨김 블록을 넣거나 뺀다. 여러 번 호출해도 결과가 같다(멱등).
넣을 때는 맨 앞에 둔다 — 찾기 쉽고, 상세설명 어디에 있어도 CSS 효과는 같다.
"""
stripped = _HIDE_PROMO_RE.sub("", html or "")
if not hidden:
return stripped
if not stripped.strip():
return HIDE_PROMO_BLOCK
return HIDE_PROMO_BLOCK + "\n" + stripped.lstrip("\n")
def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
@@ -55,17 +55,7 @@
<input type="hidden" name="list_query" value="{{ list_query }}" />
<div class="cf24-editor-bar">
<span class="cf24-muted">
PC 상세설명 HTML · {{ desc.description | length }}자
<label class="cf24-check-inline" title="상세페이지 상단의 공통 홍보 영역(.edb-img-tag-w)을 이 상품에서만 숨깁니다. PC·모바일 모두 적용됩니다.">
<input type="checkbox" name="hide_promo" value="1"
{% if promo_hidden %}checked{% endif %} />
상단 공통 홍보 숨기기
{% if promo_hidden != promo_hidden_mobile %}
<span class="cf24-warn">(PC/모바일 상태 불일치 — 적용하면 맞춰집니다)</span>
{% endif %}
</label>
</span>
<span class="cf24-muted">PC 상세설명 HTML · {{ desc.description | length }}자</span>
<span class="cf24-editor-bar-right">
<input class="cf24-memo" type="text" name="memo" maxlength="200"
placeholder="변경 메모 (버전 이력에 남습니다)" />
-39
View File
@@ -539,45 +539,6 @@ def test_format_then_encode_roundtrip():
assert saved == raw
# ════════════════════════════════════════════════════════════
# 상단 공통 홍보 숨기기 (스킨 파일을 못 고치므로 상세설명 CSS 로 처리)
# ════════════════════════════════════════════════════════════
def test_promo_hidden_add_and_remove():
body = "<div><img src=\"a.gif\"></div>"
on = store.set_promo_hidden(body, True)
assert store.has_hidden_promo(on) is True
assert ".edb-img-tag-w{display:none !important}" in on
assert body in on # 원래 내용은 그대로 남는다
off = store.set_promo_hidden(on, False)
assert store.has_hidden_promo(off) is False
assert off.strip() == body
def test_promo_hidden_is_idempotent():
body = "<div>x</div>"
once = store.set_promo_hidden(body, True)
assert store.set_promo_hidden(once, True) == once
twice_off = store.set_promo_hidden(store.set_promo_hidden(once, False), False)
assert twice_off.strip() == body
def test_promo_hidden_survives_formatting():
"""정리(포맷)를 거쳐도 숨김 상태가 유지되고 인식된다."""
formatted = store.format_html(store.set_promo_hidden("<div><p>내용</p></div>", True))
assert store.has_hidden_promo(formatted) is True
assert store.has_hidden_promo(store.format_html(store.set_promo_hidden(formatted, False))) is False
def test_promo_hidden_keeps_other_style_blocks():
"""사람이 쓴 <style> 은 건드리지 않는다 — 우리 블록만 id 로 찾는다."""
body = "<style>.v{max-width:100%}</style><div>x</div>"
on = store.set_promo_hidden(body, True)
off = store.set_promo_hidden(on, False)
assert ".v{max-width:100%}" in off
assert off.strip() == body
def test_fingerprint_detects_change():
a = store.fingerprint("<p>A</p>")
assert a == store.fingerprint("<p>A</p>")
-9
View File
@@ -240,15 +240,6 @@
align-items: center;
}
.cf24-check-inline {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: var(--sp-12, 12px);
cursor: pointer;
color: var(--color-rich-black, #0a0a0a);
}
.cf24-memo {
width: 260px;
padding: 6px var(--sp-10, 10px);
+68 -114
View File
@@ -61,33 +61,6 @@ app/modules/cafe24/ ← 상품관리 모듈
---
## 3. OAuth 흐름
```
관리자 [카페24 연결]
↓ state 생성 → 세션 저장
GET /cafe24/system/oauth/start → 302 카페24 인증 페이지
↓ 사용자 승인
GET /cafe24/oauth/callback?code=&state=
↓ 세션 state 와 대조 (불일치 시 토큰 교환 거부 — CSRF 방어)
code → access/refresh token 교환
↓ Fernet 암호화
cafe24_oauth_tokens 저장
```
- scope 는 `app/integrations/cafe24/config.py``PRODUCT_SCOPES` =
`mall.read_product`, `mall.write_product`.
주문관리 추가 시 `ORDER_SCOPES` 를 합쳐 넘기고, 카페24 개발자센터 앱에서도
권한을 추가한 뒤 **재인증**하면 된다.
- access token 은 만료 2분 전부터 자동 갱신된다. 갱신은 토큰 행을
`SELECT ... FOR UPDATE` 로 잠근 채 수행 — web 컨테이너와 worker 컨테이너가
동시에 refresh 해서 한쪽 토큰이 무효화되는 것을 막는다(카페24는 refresh
token 을 회전시킨다).
- refresh token 이 만료되면 자동 복구가 불가능하므로 화면에 "재연결 필요"를
표시한다.
---
### 2-1. 상품관리 화면 구성 (2분할)
```
@@ -121,6 +94,74 @@ cafe24_oauth_tokens 저장
---
### 2-2. 편집기 (문법 강조 · 소스 정리)
**문법 강조** — 색칠된 `<pre>` 위에 **투명한 `<textarea>`** 를 정확히 겹쳐 놓는
방식이다. 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
- 두 층의 **폰트·글자크기·줄높이·padding·`white-space`·`tab-size` 가 완전히 같아야**
글자가 어긋나지 않는다. 줄 번호 칸(`.cf24-gutter`)도 같은 글꼴 지표를 써야 줄이
맞는다. `cafe24.css` 의 세 선택자를 항상 함께 수정할 것.
- **스크롤 주체는 `textarea` 다.** 색칠 층과 줄 번호를 `transform` 으로 같은 양만큼
이동시켜 맞춘다(`scroll` 이벤트에서 `translate(-scrollLeft, -scrollTop)`).
크기를 계산해 맞추는 방식은 두 번 실패했다 — ① textarea 의 `scrollHeight`
브라우저가 한두 줄 더 잡는다(실측 830 vs 792), ② flex 자식에서 `width: max-content`
가 기대대로 적용되지 않아 긴 줄이 있으면 textarea 만 내부 스크롤되고 색칠 층은
제자리에 남는다(실측 706 vs 686). 그 상태에서는 **커서를 둔 곳과 다른 위치에 글자가
입력된다.** 스크롤 동기화는 크기 계산이 없어 어긋날 여지가 없다.
- **줄바꿈하지 않고 가로로 스크롤한다**(`white-space: pre` + `wrap="off"`).
줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어 줄 번호가 맞지 않는다.
- 줄 번호 칸은 코드 영역 왼쪽의 **독립 박스**다(가로 스크롤과 무관하며 세로만 따라간다).
- 20만 자를 넘으면 강조를 끄고 평문으로 보여준다(타이핑마다 재색칠하면 느려짐).
- 색: 태그 초록 / 속성이름 갈색 / 속성값 남색 / 주석 회색 기울임 / 기호 회색.
**소스 정리**`store.format_html()`. 화면에 보여줄 때와 저장할 때 **같은 함수**를
쓰므로, 화면에서 본 소스가 그대로 카페24에 저장된다.
- 줄을 나누는 것은 **블록 요소 경계에서만** 한다. HTML 에서 공백은 의미가 있어서
인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 벌어지는
고전적인 사고). `img`·`br`·`span`·`a` 는 블록 목록에서 **의도적으로 제외**했다.
- **원문에 이미 있던 줄바꿈은 살리고, 각 줄을 현재 깊이로 들여쓴다.** 상세페이지는
`<img>` 를 한 줄에 하나씩 적어두는 경우가 많고 그 모양이 저자의 의도다. 줄 앞
공백은 렌더링에 영향이 없으므로 들여쓰기는 안전하다.
- 주석은 줄을 강제로 나누지 않는다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤
요소를 설명하는 주석이 많아, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.
- 구획용 **빈 줄은 한 줄까지 유지**한다(여러 줄은 하나로 줄인다).
- `<style>`·`<script>`·`<pre>`·`<textarea>` 안쪽은 한 글자도 건드리지 않는다.
- 내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다(`<td>1</td>`).
- **멱등**이다 — 편집하지 않고 다시 적용해도 저장값이 계속 바뀌지 않는다(테스트로 고정).
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
---
## 3. OAuth 흐름
```
관리자 [카페24 연결]
↓ state 생성 → 세션 저장
GET /cafe24/system/oauth/start → 302 카페24 인증 페이지
↓ 사용자 승인
GET /cafe24/oauth/callback?code=&state=
↓ 세션 state 와 대조 (불일치 시 토큰 교환 거부 — CSRF 방어)
code → access/refresh token 교환
↓ Fernet 암호화
cafe24_oauth_tokens 저장
```
- scope 는 `app/integrations/cafe24/config.py``PRODUCT_SCOPES` =
`mall.read_product`, `mall.write_product`.
주문관리 추가 시 `ORDER_SCOPES` 를 합쳐 넘기고, 카페24 개발자센터 앱에서도
권한을 추가한 뒤 **재인증**하면 된다.
- access token 은 만료 2분 전부터 자동 갱신된다. 갱신은 토큰 행을
`SELECT ... FOR UPDATE` 로 잠근 채 수행 — web 컨테이너와 worker 컨테이너가
동시에 refresh 해서 한쪽 토큰이 무효화되는 것을 막는다(카페24는 refresh
token 을 회전시킨다).
- refresh token 이 만료되면 자동 복구가 불가능하므로 화면에 "재연결 필요"를
표시한다.
---
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
운영 쇼핑몰(`miraskitchen`)에서 직접 확인한 내용이다. 문서에 없는 경로를
@@ -161,93 +202,6 @@ cafe24_oauth_tokens 저장
---
### 2-2. 편집기 (문법 강조 · 소스 정리)
**문법 강조** — 색칠된 `<pre>` 위에 **투명한 `<textarea>`** 를 정확히 겹쳐 놓는
방식이다. 외부 라이브러리를 쓰지 않는다(자체 호스팅 원칙).
- 두 층의 **폰트·글자크기·줄높이·padding·`white-space`·`tab-size` 가 완전히 같아야**
글자가 어긋나지 않는다. 줄 번호 칸(`.cf24-gutter`)도 같은 글꼴 지표를 써야 줄이
맞는다. `cafe24.css` 의 세 선택자를 항상 함께 수정할 것.
- **크기는 `<pre>` 가 정하고 textarea 가 `inset:0` 으로 그 위를 덮는다.** JS 로 높이를
계산하지 않는다(textarea 의 `scrollHeight` 는 브라우저가 한두 줄 더 잡아 어긋난다 —
실측 830 vs 792). 스크롤은 바깥 `.cf24-code` 가 담당한다.
- **줄바꿈하지 않고 가로로 스크롤한다**(`white-space: pre` + `wrap="off"`).
줄바꿈을 허용하면 한 논리 줄이 여러 행이 되어 줄 번호가 맞지 않는다.
- 줄 번호 칸은 `position: sticky; left: 0` 이라 가로로 스크롤해도 왼쪽에 남는다.
- 20만 자를 넘으면 강조를 끄고 평문으로 보여준다(타이핑마다 재색칠하면 느려짐).
- 색: 태그 초록 / 속성이름 갈색 / 속성값 남색 / 주석 회색 기울임 / 기호 회색.
**소스 정리** — `store.format_html()`. 화면에 보여줄 때와 저장할 때 **같은 함수**를
쓰므로, 화면에서 본 소스가 그대로 카페24에 저장된다.
- 줄을 나누는 것은 **블록 요소 경계에서만** 한다. HTML 에서 공백은 의미가 있어서
인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가 벌어지는
고전적인 사고). `img`·`br`·`span`·`a` 는 블록 목록에서 **의도적으로 제외**했다.
- **원문에 이미 있던 줄바꿈은 살리고, 각 줄을 현재 깊이로 들여쓴다.** 상세페이지는
`<img>` 를 한 줄에 하나씩 적어두는 경우가 많고 그 모양이 저자의 의도다. 줄 앞
공백은 렌더링에 영향이 없으므로 들여쓰기는 안전하다.
- 주석은 줄을 강제로 나누지 않는다. `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤
요소를 설명하는 주석이 많아, 나누면 라벨과 대상이 떨어져 오히려 읽기 나빠진다.
- 구획용 **빈 줄은 한 줄까지 유지**한다(여러 줄은 하나로 줄인다).
- `<style>`·`<script>`·`<pre>`·`<textarea>` 안쪽은 한 글자도 건드리지 않는다.
- 내용이 한 줄뿐인 짧은 블록은 다시 한 줄로 합친다(`<td>1</td>`).
- **멱등**이다 — 편집하지 않고 다시 적용해도 저장값이 계속 바뀌지 않는다(테스트로 고정).
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
### 2-3. 상단 공통 홍보 숨기기
스킨(`detail.html`)에는 공통 홍보를 지울 상품번호가 박혀 있다.
```js
const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
```
**카페24 Admin API 로는 이 파일을 고칠 수 없다.** 운영몰에서 직접 호출해 확인했다
(2026-08-14, API 버전 2026-03-01).
| 호출 | 결과 |
| --- | --- |
| `GET /admin/themes` | 디자인 권한이 있으면 조회 가능하나 **테마 목록뿐** — 파일 내용 없음 |
| `GET /admin/themes/pages` | **`No API found.`** — 이 버전에 존재하지 않는다 |
| 스킨 파일(`product/detail.html`) 읽기/쓰기 | **엔드포인트 자체가 없다** |
권한 문제가 아니라 기능이 없는 것이므로, 디자인 권한을 추가해도 달라지지 않는다.
그래서 `DEFAULT_SCOPES` 에 디자인 scope 를 **넣지 않는다**(쓰지 않는 권한은 요청하지
않는다 — `app/integrations/cafe24/config.py` 주석 참고).
페이지에 코드를 주입하는 유일한 수단은 스크립트 태그(`/admin/scripttags`)인데,
디자인이 아니라 **`mall.write_store`(상점)** 권한이며 **인라인 코드가 아닌 외부 HTTPS
URL** 만 받는다. 쓰려면 우리 서버에 공개 JS 엔드포인트를 두고 쇼핑몰이 그것을 불러가게
해야 한다.
그래서 같은 결과를 **상품 상세설명 안의 CSS** 로 낸다. 상세설명은 우리가 쓸 수 있고,
상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
```html
<style id="cf24-hide-common-promo">/* DBX ERP: 상단 공통 홍보 숨김 */
.edb-img-tag-w{display:none !important}
</style>
```
- 편집기 상단 **「상단 공통 홍보 숨기기」** 체크박스로 켜고 끈다. 적용할 때 반영된다.
- `id` 로 우리 블록만 찾는다 — 사람이 쓴 `<style>` 은 건드리지 않는다.
- **PC·모바일 모두 적용된다.** 미분리 상품은 같은 HTML 이 양쪽에 들어가고,
분리 상품은 모바일 본문은 그대로 두고 이 블록만 모바일에도 맞춘다(양쪽에 걸어야
효과가 있다).
- 넣기/빼기가 멱등이며 포맷을 거쳐도 상태가 유지된다(테스트로 고정).
- 스킨의 `numbers` 목록과는 독립이다. 이미 목록에 있는 상품은 그대로 두면 된다
(스킨은 요소를 제거하고, 이쪽은 CSS 로 숨긴다 — 결과는 같고 충돌하지 않는다).
> 스킨의 `numbers` 방식(요소 제거 + 목록 한 곳 관리)을 자동화하려면 `/admin/scripttags`
> 로 전역 스크립트를 주입하는 방법뿐이다. 필요한 것: **`mall.write_store` 권한 추가 +
> 재인증**, 그리고 **우리 서버의 공개 HTTPS JS 엔드포인트**(쇼핑몰 방문자 브라우저가
> 직접 불러가므로 인증을 걸 수 없다). 쇼핑몰이 우리 서버 가용성에 의존하게 되는 것도
> 감안해야 한다. 지금은 채택하지 않았다.
---
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
이 순서를 절대 바꾸지 않는다.