fix(cafe24): 모바일 상세설명 '직접등록' 오적용·적용직후 읽기지연 수정

- PUT 시 mobile_description 필드를 더 이상 보내지 않는다. 그 필드를 보내는
  순간 카페24가 모바일 상세설명 설정을 "직접 등록"으로 바꿔버림을 실물로
  확인(separated_mobile_description 이 'T'가 됨). 대신
  separated_mobile_description="F" 만 지정해 "PC 상세설명과 동일"을 강제하고,
  카페24가 모바일 값을 PC 와 자동으로 맞추게 한다. 화면 적용(apply)과 예약
  적용(worker) 양쪽 다 수정.
- products.wait_for_description 추가 — 적용 직후 카페24 관리자 API 의 짧은
  읽기 지연(쓰기 직후 몇 초간 이전 값을 돌려줌 — 쇼핑몰 화면에는 바로 반영됨)을
  0.8초 간격 최대 3회 재확인으로 흡수. "쇼핑몰엔 반영됐는데 카페24 상품관리
  화면만 적용 안 된 것처럼 보이는" 증상 완화.
- CAFE24_SHOP_URL 예시를 www.miras.co.kr 로 갱신(.env.example) — 실제 값은
  운영 서버 .env 에서 직접 설정해야 함(코드는 그대로 이 값을 읽어 다이렉트
  주소를 만듦).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:49:05 +09:00
parent d6424c2272
commit 20f3f0f7df
6 changed files with 100 additions and 25 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# (예: https://miras.co.kr/product/detail.html?product_no=119). # (예: https://miras.co.kr/product/detail.html?product_no=119).
# 커스텀 도메인은 mall_id 로 알 수 없어 직접 지정해야 한다. # 커스텀 도메인은 mall_id 로 알 수 없어 직접 지정해야 한다.
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다. # 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
# CAFE24_SHOP_URL=https://miras.co.kr # CAFE24_SHOP_URL=https://www.miras.co.kr
# #
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키. # access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어 # openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
+48 -3
View File
@@ -17,6 +17,7 @@
from __future__ import annotations from __future__ import annotations
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -149,6 +150,34 @@ def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions:
return descriptions_from_product(get_product(client, product_no)) return descriptions_from_product(get_product(client, product_no))
def wait_for_description(
client: Cafe24Client,
product_no: int,
expected: str,
*,
attempts: int = 3,
delay: float = 0.8,
) -> bool:
"""PUT 직후 카페24 관리자 API 가 새 값을 돌려줄 때까지 짧게 재확인한다.
실물에서 관찰된 지연: PUT 이 성공하고 쇼핑몰 화면(고객이 보는 상세페이지)에는
바로 반영되는데도, 관리자 API(`GET /admin/products/{no}`)는 몇 초간 직전 값을
돌려줄 때가 있다. 그 상태에서 다른 상품을 봤다가 다시 돌아오면 우리 편집기가
"적용 안 된 것"처럼 보인다 — 우리 쪽 캐시 문제가 아니라 카페24 쪽 읽기 지연이다.
적용 직후 여기서 짧게 흡수해, 화면에 돌아왔을 때는 이미 새 값이 보이게 한다.
실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다.
"""
for _ in range(max(1, attempts)):
time.sleep(delay)
try:
current = fetch_descriptions(client, product_no)
except Exception: # noqa: BLE001 — 확인 실패는 무시(적용 자체는 이미 성공)
return False
if current.description == expected:
return True
return False
def _flag_value(flag: bool) -> str: def _flag_value(flag: bool) -> str:
"""카페24는 boolean 을 'T'/'F' 문자열로 받는다.""" """카페24는 boolean 을 'T'/'F' 문자열로 받는다."""
return "T" if flag else "F" return "T" if flag else "F"
@@ -158,6 +187,7 @@ def build_update_payload(
*, *,
description: str | None = None, description: str | None = None,
mobile_description: str | None = None, mobile_description: str | None = None,
separated_mobile_description: str | None = None,
product_name: str | None = None, product_name: str | None = None,
display: bool | None = None, display: bool | None = None,
selling: bool | None = None, selling: bool | None = None,
@@ -168,6 +198,11 @@ def build_update_payload(
`None` 인 항목은 payload 에 넣지 않는다 = 그 필드를 건드리지 않는다. `None` 인 항목은 payload 에 넣지 않는다 = 그 필드를 건드리지 않는다.
예약에서 "진열만 켜기"처럼 상세설명 없이 상태만 바꾸는 경우가 있으므로 예약에서 "진열만 켜기"처럼 상세설명 없이 상태만 바꾸는 경우가 있으므로
description 도 생략할 수 있다. description 도 생략할 수 있다.
⚠️ `mobile_description` 을 명시적으로 보내면 카페24가 `separated_mobile_description`
'T'(관리자 화면 "직접 등록")로 바꿔버린다(실물 확인). "PC 상세설명과 동일"
유지하려면 `mobile_description` 은 보내지 말고 `separated_mobile_description="F"`
만 지정할 것 — `update_descriptions` 가 이 방식을 쓴다.
""" """
request: dict[str, Any] = {} request: dict[str, Any] = {}
if description is not None: if description is not None:
@@ -176,6 +211,8 @@ def build_update_payload(
request["product_name"] = product_name request["product_name"] = product_name
if mobile_description is not None: if mobile_description is not None:
request["mobile_description"] = mobile_description request["mobile_description"] = mobile_description
if separated_mobile_description is not None:
request["separated_mobile_description"] = separated_mobile_description
if display is not None: if display is not None:
request["display"] = _flag_value(display) request["display"] = _flag_value(display)
if selling is not None: if selling is not None:
@@ -192,6 +229,7 @@ def update_product(
*, *,
description: str | None = None, description: str | None = None,
mobile_description: str | None = None, mobile_description: str | None = None,
separated_mobile_description: str | None = None,
product_name: str | None = None, product_name: str | None = None,
display: bool | None = None, display: bool | None = None,
selling: bool | None = None, selling: bool | None = None,
@@ -207,6 +245,7 @@ def update_product(
payload = build_update_payload( payload = build_update_payload(
description=description, description=description,
mobile_description=mobile_description, mobile_description=mobile_description,
separated_mobile_description=separated_mobile_description,
product_name=product_name, product_name=product_name,
display=display, display=display,
selling=selling, selling=selling,
@@ -225,15 +264,21 @@ def update_descriptions(
product_no: int, product_no: int,
*, *,
description: str, description: str,
mobile_description: str | None = None,
shop_no: int | None = None, shop_no: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""상세설명 교체하는 지름길. 실제 전송은 `update_product` 가 한다.""" """상세설명 교체 + "PC 상세설명과 동일" 강제.
`mobile_description` 필드는 보내지 않는다 — 보내는 순간 카페24 관리자
화면의 모바일 상세설명 설정이 "직접 등록"으로 바뀌어버리기 때문이다(실물
확인). 대신 `separated_mobile_description="F"` 만 지정하면 카페24가 모바일
값을 PC 와 자동으로 맞춰주면서 설정도 "PC 상세설명과 동일하게 사용"으로
유지된다.
"""
return update_product( return update_product(
client, client,
product_no, product_no,
description=description, description=description,
mobile_description=mobile_description, separated_mobile_description="F",
shop_no=shop_no, shop_no=shop_no,
) )
+16 -11
View File
@@ -17,8 +17,11 @@
MANUAL 버전 + 감사로그 MANUAL 버전 + 감사로그
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다. 로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
PC/모바일은 구분하지 않는다 — 적용 시 `description` 과 `mobile_description` 에 PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
같은 HTML 을 쓴다(운영 방침). 분리 사용 상품이어도 한쪽만 바뀌는 일이 없다. `separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
화면의 모바일 상세설명 설정이 "직접 등록"으로 바뀌어버리므로(실물 확인) 보내지
않는다.
이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는 이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는
`store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로 `store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로
@@ -448,16 +451,13 @@ def product_apply(
status_code=303, status_code=303,
) )
# PC/모바일을 구분하지 않는다 — 항상 같은 내용으로 함께 쓴다(운영 방침). if submitted == current.description:
# 분리 사용 상품이어도 모바일에 같은 HTML 을 넣으므로 한쪽만 바뀌는 일이 없다.
mobile_html = submitted
if submitted == current.description and mobile_html == current.mobile_description:
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
try: try:
products.update_descriptions( # mobile_description 은 보내지 않는다 — update_descriptions 가
api.client, product_no, description=submitted, mobile_description=mobile_html # separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
) # 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
products.update_descriptions(api.client, product_no, description=submitted)
except Cafe24Error as exc: except Cafe24Error as exc:
st.log_audit( st.log_audit(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
@@ -469,6 +469,11 @@ def product_apply(
status_code=303, status_code=303,
) )
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
products.wait_for_description(api.client, product_no, submitted)
revision_id = st.add_revision( revision_id = st.add_revision(
product_no=product_no, product_no=product_no,
html_content=submitted, html_content=submitted,
@@ -479,7 +484,7 @@ def product_apply(
st.log_audit( st.log_audit(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
revision_id=revision_id, result="SUCCESS", revision_id=revision_id, result="SUCCESS",
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, PC·모바일 동시 반영)", detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)",
) )
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
return RedirectResponse( return RedirectResponse(
+14 -3
View File
@@ -19,6 +19,12 @@ from app.timezone import now_kst
SECRET = "unit-test-secret" SECRET = "unit-test-secret"
# wait_for_description 은 실물 카페24 API 의 쓰기 직후 읽기 지연을 흡수하려고
# 실제로 sleep 한다. 가짜 클라이언트는 그 지연을 재현하지 않으므로(항상 같은
# 값을 돌려줌) 재시도 예산을 다 채우게 되는데, 테스트에서까지 그 시간을 그대로
# 기다릴 필요는 없다.
products.time.sleep = lambda *_a, **_k: None
_TEST_ENV = { _TEST_ENV = {
"CAFE24_MALL_ID": "testmall", "CAFE24_MALL_ID": "testmall",
"CAFE24_CLIENT_ID": "cid", "CAFE24_CLIENT_ID": "cid",
@@ -338,8 +344,10 @@ def test_update_descriptions_payload():
products.update_descriptions(client, 131, description="<p>NEW</p>") products.update_descriptions(client, 131, description="<p>NEW</p>")
call = client.calls[0] call = client.calls[0]
assert call["method"] == "PUT" and call["path"] == "/admin/products/131" assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
# 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다. # mobile_description 은 절대 보내지 않는다 — 보내면 카페24가 모바일 상세설명
assert call["json"] == {"request": {"description": "<p>NEW</p>"}} # 설정을 "직접 등록"으로 바꿔버린다(실물 확인). separated_mobile_description="F"
# 만 지정해 "PC 상세설명과 동일"을 강제한다.
assert call["json"] == {"request": {"description": "<p>NEW</p>", "separated_mobile_description": "F"}}
def test_update_payload_optional_fields(): def test_update_payload_optional_fields():
@@ -727,7 +735,10 @@ def test_worker_applies_html_and_flags():
# HTML 과 진열 상태를 한 번의 PUT 으로 보냈는가 # HTML 과 진열 상태를 한 번의 PUT 으로 보냈는가
put = [c for c in client.calls if c["method"] == "PUT"][0] put = [c for c in client.calls if c["method"] == "PUT"][0]
assert put["json"]["request"]["description"] == "<p>예약 내용</p>" assert put["json"]["request"]["description"] == "<p>예약 내용</p>"
assert put["json"]["request"]["mobile_description"] == "<p>예약 내용</p>" # mobile_description 은 보내지 않는다 — separated_mobile_description="F" 로
# "PC 상세설명과 동일"을 강제한다(직접 보내면 "직접 등록"으로 바뀌어버린다).
assert "mobile_description" not in put["json"]["request"]
assert put["json"]["request"]["separated_mobile_description"] == "F"
assert put["json"]["request"]["display"] == "T" assert put["json"]["request"]["display"] == "T"
assert "selling" not in put["json"]["request"] # 변경 없음이면 보내지 않는다 assert "selling" not in put["json"]["request"] # 변경 없음이면 보내지 않는다
assert st.finished == [ assert st.finished == [
+8 -4
View File
@@ -59,7 +59,6 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
# 상세설명을 바꿀 때는 쓰기 직전 현재값을 읽어 백업한다(로컬 값을 믿지 않는다). # 상세설명을 바꿀 때는 쓰기 직전 현재값을 읽어 백업한다(로컬 값을 믿지 않는다).
backup_id = 0 backup_id = 0
mobile_html: str | None = None
if html is not None: if html is not None:
current = products.fetch_descriptions(api.client, product_no) current = products.fetch_descriptions(api.client, product_no)
backup_id = store_db.add_revision( backup_id = store_db.add_revision(
@@ -77,17 +76,22 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
memo=f"예약 #{schedule_id} 적용 직전 자동 백업 (모바일)", memo=f"예약 #{schedule_id} 적용 직전 자동 백업 (모바일)",
created_by=ACTOR, created_by=ACTOR,
) )
# PC/모바일은 구분하지 않는다 — 화면 편집과 같은 방침.
mobile_html = html
# PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
products.update_product( products.update_product(
api.client, api.client,
product_no, product_no,
description=html, description=html,
mobile_description=mobile_html, separated_mobile_description="F" if html is not None else None,
display=set_display, display=set_display,
selling=set_selling, selling=set_selling,
) )
if html is not None:
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
products.wait_for_description(api.client, product_no, html)
summary = store.describe_schedule_action( summary = store.describe_schedule_action(
has_html=html is not None, set_display=set_display, set_selling=set_selling has_html=html is not None, set_display=set_display, set_selling=set_selling
+13 -3
View File
@@ -300,12 +300,22 @@ cafe24_oauth_tokens 저장
추가 규칙: 추가 규칙:
- 빈 내용은 거부한다(상세페이지 전체를 날리는 실수 방지). - 빈 내용은 거부한다(상세페이지 전체를 날리는 실수 방지).
- **PC/모바일을 구분하지 않는다.** `description` 과 `mobile_description` 에 항상 같은 - **PC/모바일을 구분하지 않는다.** 단, PUT 에 `mobile_description` 필드를 직접
HTML 을 쓴다(운영 방침). `separated_mobile_description` 값과 무관하며, 편집 화면에도 보내지 않는다 — 실물 확인 결과 그 필드를 보내는 순간 카페24가
모바일 소스를 따로 보여주지 않는다 — 한쪽만 바뀌어 어긋나는 사고가 없어진다. `separated_mobile_description` 을 `'T'`(관리자 화면 "직접 등록")로 바꿔버린다.
대신 `separated_mobile_description: "F"` 만 지정하면 카페24가 모바일 값을 PC 와
자동으로 맞춰주면서 설정도 **"PC 상세설명과 동일하게 사용"으로 유지**된다
(`products.update_descriptions`). 편집 화면에도 모바일 소스를 따로 보여주지
않는다 — 한쪽만 바뀌어 어긋나는 사고가 없어진다.
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다). revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다. - 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
- **적용 직후 짧게 재확인한다(`products.wait_for_description`).** 카페24 관리자
API(`GET /admin/products/{no}`)는 PUT 직후 몇 초간 이전 값을 돌려줄 때가
있다(쇼핑몰 화면에는 바로 반영됨 — 실물 관찰). 그 상태에서 다른 상품을 봤다가
돌아오면 우리 편집기만 "적용 안 된 것"처럼 보인다. 적용/예약 실행 직후
0.8초 간격으로 최대 3회 재조회해 새 값이 확인될 때까지 기다린 뒤 화면으로
돌아간다(실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다).
--- ---