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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user