feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널

증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야
반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT
뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만
기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값
백업이 생겼다.

- 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이
  유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막
  쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external).
  store.resolve_description / db.revision_digests(md5) / 배너 2종.
- 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회
  확인 결과는 감사로그에만 남긴다.
- 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products.
  last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date
  가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷.
- 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을
  한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/
  소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image
  + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목
  자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다.
- client.delete/timeout, products.upload_images·options·variants 래퍼.
- 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES,
  .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:07:42 +09:00
parent 8ea0db9245
commit 40766d805d
21 changed files with 2782 additions and 99 deletions
+9 -2
View File
@@ -148,8 +148,12 @@ class Cafe24Client:
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
product_no: int | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.
timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값.
"""
if not self._config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
@@ -167,7 +171,7 @@ class Cafe24Client:
status: int | None = None
try:
access_token = self._tokens.get_access_token()
with httpx.Client(timeout=self._timeout) as client:
with httpx.Client(timeout=timeout or self._timeout) as client:
response = client.request(
method,
url,
@@ -256,3 +260,6 @@ class Cafe24Client:
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("POST", path, **kwargs)
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("DELETE", path, **kwargs)
+143 -1
View File
@@ -192,6 +192,11 @@ def build_update_payload(
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
price: str | None = None,
supply_price: str | None = None,
retail_price: str | None = None,
detail_image: str | None = None,
image_upload_type: str | None = None,
) -> dict[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
@@ -217,6 +222,19 @@ def build_update_payload(
request["display"] = _flag_value(display)
if selling is not None:
request["selling"] = _flag_value(selling)
# 가격은 카페24 예제 형식('11000.00') 문자열 그대로 보낸다.
if price is not None:
request["price"] = price
if supply_price is not None:
request["supply_price"] = supply_price
if retail_price is not None:
request["retail_price"] = retail_price
# 대표 이미지: /admin/products/images 로 먼저 올린 경로를 detail_image 에 넣고
# image_upload_type="A"(대표이미지등록) 로 목록/작은목록/축소 이미지를 카페24가
# 리사이징하게 한다. (문서: A 대표이미지등록 / B 개별이미지등록 / C 웹FTP)
if detail_image is not None:
request["detail_image"] = detail_image
request["image_upload_type"] = image_upload_type or "A"
payload: dict[str, Any] = {"request": request}
if shop_no:
payload["shop_no"] = int(shop_no)
@@ -234,10 +252,18 @@ def update_product(
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
price: str | None = None,
supply_price: str | None = None,
retail_price: str | None = None,
detail_image: str | None = None,
image_upload_type: str | None = None,
) -> dict[str, Any]:
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
"""상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로.
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을
돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로
남겨 화면을 맞춘다(`store.product_snapshot`).
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
@@ -250,6 +276,11 @@ def update_product(
display=display,
selling=selling,
shop_no=shop_no,
price=price,
supply_price=supply_price,
retail_price=retail_price,
detail_image=detail_image,
image_upload_type=image_upload_type,
)
if not payload["request"]:
return {}
@@ -283,6 +314,117 @@ def update_descriptions(
)
# ════════════════════════════════════════════════════════════
# 이미지 업로드 — POST /admin/products/images
# 문서: base64 인코딩 이미지, 1건 10MB, 1호출 30MB, 1회 20장.
# 응답 {"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]} 의 path 를
# 상품 detail_image / 옵션 option_image_file 등에 그대로 넣는다.
# ════════════════════════════════════════════════════════════
IMAGE_MAX_BYTES = 10 * 1024 * 1024
UPLOAD_TIMEOUT = 120.0
def upload_images(client: Cafe24Client, images_b64: list[str]) -> list[str]:
"""base64 문자열 목록 → 업로드된 경로 목록(입력 순서 유지)."""
if not images_b64:
return []
payload = {"requests": [{"image": b64} for b64 in images_b64[:20]]}
response = client.post("/admin/products/images", json=payload, timeout=UPLOAD_TIMEOUT)
images = response.get("images")
if not isinstance(images, list):
return []
return [str(item.get("path") or "") for item in images if isinstance(item, dict)]
def upload_image_bytes(client: Cafe24Client, data: bytes) -> str:
"""이미지 1장(바이트) 업로드 → 경로. 비어 있거나 응답이 이상하면 빈 문자열."""
import base64 # noqa: WPS433
if not data:
return ""
paths = upload_images(client, [base64.b64encode(data).decode("ascii")])
return paths[0] if paths else ""
def set_main_image(client: Cafe24Client, product_no: int, image_path: str) -> dict[str, Any]:
"""대표이미지 교체 — 목록/작은목록/축소 이미지는 카페24가 리사이징(A 타입)."""
return update_product(client, product_no, detail_image=image_path, image_upload_type="A")
# ════════════════════════════════════════════════════════════
# 옵션 — /admin/products/{no}/options
# GET → {"option": {has_option, option_type, option_list_type, options:[...]}}
# POST → {"request": {has_option:"T", option_type:"T", options:[...]}} (품목 자동 생성)
# PUT → {"request": {original_options:[...], options:[...]}} (이름/값/이미지만 수정)
# DELETE → 옵션 사용안함 + 품목 전부 삭제(주의)
# ════════════════════════════════════════════════════════════
def get_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
no = int(product_no)
payload = client.get(f"/admin/products/{no}/options", product_no=no)
option = payload.get("option")
return option if isinstance(option, dict) else {}
def create_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
no = int(product_no)
payload = client.post(
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
)
option = payload.get("option")
return option if isinstance(option, dict) else payload
def update_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
no = int(product_no)
payload = client.put(
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
)
option = payload.get("option")
return option if isinstance(option, dict) else payload
def delete_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
no = int(product_no)
return client.delete(f"/admin/products/{no}/options", product_no=no)
# ════════════════════════════════════════════════════════════
# 품목(variants) — /admin/products/{no}/variants
# GET → {"variants":[{variant_code, options:[{name,value}], custom_variant_code,
# display, selling, additional_amount, quantity, image?…}]}
# PUT (여러 건) → {"shop_no":1, "requests":[{variant_code, display, selling,
# custom_variant_code, additional_amount, …}]}
# ════════════════════════════════════════════════════════════
def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]:
no = int(product_no)
payload = client.get(f"/admin/products/{no}/variants", product_no=no)
variants = payload.get("variants")
return variants if isinstance(variants, list) else []
def update_variants(
client: Cafe24Client, product_no: int, requests: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""여러 품목 부분 수정. 100건씩 나눠 보낸다(문서상 1회 100건 제한)."""
no = int(product_no)
out: list[dict[str, Any]] = []
for start in range(0, len(requests), 100):
chunk = requests[start : start + 100]
if not chunk:
continue
payload = client.put(
f"/admin/products/{no}/variants", json={"shop_no": 1, "requests": chunk}, product_no=no
)
result = payload.get("variants") if isinstance(payload, dict) else None
if isinstance(result, list):
out.extend(result)
elif isinstance(result, dict):
out.append(result)
elif isinstance(payload.get("variant"), dict):
out.append(payload["variant"])
return out
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
try: