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:
@@ -1,22 +1,38 @@
|
||||
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
|
||||
"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널).
|
||||
|
||||
화면 구성
|
||||
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
||||
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
||||
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
|
||||
가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
|
||||
오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목.
|
||||
(JSON API 는 routes_product_info.py)
|
||||
|
||||
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
|
||||
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
|
||||
페이지에 있는 해당 상품이 빠진다.
|
||||
|
||||
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
|
||||
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
|
||||
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
|
||||
상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 두 조각을
|
||||
한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
|
||||
카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
|
||||
(`/cafe24/?selected=`)로 동작한다.
|
||||
|
||||
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
|
||||
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT →
|
||||
MANUAL 버전 + 감사로그
|
||||
카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 →
|
||||
지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그
|
||||
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
|
||||
|
||||
── 카페24 읽기 지연(read-after-write lag) ──
|
||||
카페24 관리자 API 는 PUT 직후 한동안 GET 에서 **예전 값**을 돌려준다(실물 관찰,
|
||||
몇 초에서 훨씬 길게). 우리 쪽 캐시 문제가 아니다(모든 응답 no-store). 그 값을 그대로
|
||||
믿으면 "적용했는데 예전 소스가 보이고" 그 예전 값으로 지문을 만들어 다음 적용 때
|
||||
충돌로 오판한다. 그래서 **마지막 쓰기가 권위**다:
|
||||
- 상세설명: 카페24 값이 우리가 최근(유예시간 안)에 남긴 revision 중 하나와 같으면
|
||||
"아직 예전 값" → 마지막 쓰기(MANUAL/SCHEDULED)를 보여주고 그 지문을 쓴다.
|
||||
우리가 모르는 값이면 관리자에서 직접 고친 것 → 카페24 값을 믿는다.
|
||||
(`store.resolve_description`, `_resolve_description`)
|
||||
- 상품명/가격/이미지/진열/판매: PUT 응답을 스냅샷으로 남기고, GET 의 updated_date
|
||||
가 스냅샷보다 이전이면 스냅샷으로 덮어씌운다(`store.overlay_recent_write`).
|
||||
유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값.
|
||||
|
||||
PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
|
||||
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
|
||||
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
|
||||
@@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
@@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
|
||||
from app.timezone import now_kst
|
||||
|
||||
from . import store
|
||||
from .common import base_ctx, guard, require_store
|
||||
from .common import base_ctx, guard, read_lag_grace_minutes, require_store
|
||||
|
||||
logger = logging.getLogger("cafe24.products")
|
||||
|
||||
@@ -84,6 +102,17 @@ def _price(value: Any) -> str:
|
||||
return f"{won:,}원"
|
||||
|
||||
|
||||
def _price_plain(value: Any) -> str:
|
||||
"""'6900.00' → '6900' (입력칸 초기값용)."""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
return str(int(Decimal(text)))
|
||||
except (ArithmeticError, ValueError):
|
||||
return text
|
||||
|
||||
|
||||
def _short_dt(value: Any) -> str:
|
||||
"""'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'."""
|
||||
text = str(value or "").strip()
|
||||
@@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str:
|
||||
return urlencode(params)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 읽기 지연 보정 — 상품 dict 와 상세설명에 각각 적용한다.
|
||||
# 다른 라우트(routes_product_info)도 같은 함수를 쓴다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def load_product(st: Any, api: Any, product_no: int) -> tuple[dict[str, Any], bool]:
|
||||
"""카페24 GET + 최근 쓰기 스냅샷 덮어씌우기. (상품 dict, 스칼라 지연 여부).
|
||||
|
||||
지연 판정은 카페24의 updated_date 끼리 비교한다 — GET 이 PUT 응답보다 이전
|
||||
레코드를 돌려주면 스냅샷 값(상품명·가격·이미지·진열/판매)을 쓴다.
|
||||
"""
|
||||
product = products.get_product(api.client, product_no)
|
||||
if not product:
|
||||
return product, False
|
||||
section = (st.get_write_snapshot(product_no) or {}).get("product") or {}
|
||||
merged, stale = store.overlay_recent_write(
|
||||
product,
|
||||
snapshot=section.get("data"),
|
||||
written_at=section.get("written_at"),
|
||||
grace_minutes=read_lag_grace_minutes(),
|
||||
)
|
||||
return merged, stale
|
||||
|
||||
|
||||
def resolve_description(
|
||||
st: Any, product_no: int, cafe24_html: str
|
||||
) -> tuple[str, str, dict[str, Any] | None]:
|
||||
"""(유효 HTML, 상태, 마지막 쓰기 revision). 상태는 store.SYNC_*."""
|
||||
grace = read_lag_grace_minutes()
|
||||
since = now_kst() - timedelta(minutes=grace)
|
||||
last_write = st.latest_write_revision(product_no, since=since)
|
||||
if not last_write:
|
||||
return cafe24_html, store.SYNC_NONE, None
|
||||
digests = st.revision_digests(product_no, since=since)
|
||||
effective, state = store.resolve_description(
|
||||
cafe24_html, last_write=last_write, known_digests=digests, grace_minutes=grace
|
||||
)
|
||||
return effective, state, last_write
|
||||
|
||||
|
||||
def remember_write(st: Any, product_no: int, updated: dict[str, Any]) -> dict[str, Any]:
|
||||
"""PUT 응답(상품 dict)을 캐시·스냅샷에 남긴다. 정규화된 캐시 행을 돌려준다."""
|
||||
info = products.normalize_product(updated) if updated else {}
|
||||
if info.get("product_no"):
|
||||
st.upsert_products([info])
|
||||
st.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
|
||||
return info
|
||||
|
||||
|
||||
def _info(product: dict[str, Any]) -> dict[str, Any]:
|
||||
"""편집기·정보 패널 공용 상품 요약."""
|
||||
info = products.normalize_product(product) if product else {}
|
||||
return {
|
||||
**info,
|
||||
"price": _price(product.get("price")),
|
||||
"price_plain": _price_plain(product.get("price")),
|
||||
"supply_price": _price(product.get("supply_price")),
|
||||
"supply_price_plain": _price_plain(product.get("supply_price")),
|
||||
"retail_price": _price(product.get("retail_price")),
|
||||
"retail_price_plain": _price_plain(product.get("retail_price")),
|
||||
"updated_date": _short_dt(product.get("updated_date")),
|
||||
"summary_description": product.get("summary_description") or "",
|
||||
"detail_image": str(product.get("detail_image") or ""),
|
||||
"list_image": str(product.get("list_image") or ""),
|
||||
"tiny_image": str(product.get("tiny_image") or ""),
|
||||
"small_image": str(product.get("small_image") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
||||
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
|
||||
"""편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회."""
|
||||
api = build_cafe24_api(st)
|
||||
product: dict[str, Any] = {}
|
||||
desc = None
|
||||
error = ""
|
||||
scalar_stale = False
|
||||
html_effective = ""
|
||||
sync_state = store.SYNC_NONE
|
||||
last_write: dict[str, Any] | None = None
|
||||
try:
|
||||
product = products.get_product(api.client, product_no)
|
||||
product, scalar_stale = load_product(st, api, product_no)
|
||||
desc = products.descriptions_from_product(product)
|
||||
st.upsert_products([products.normalize_product(product)])
|
||||
html_effective, sync_state, last_write = resolve_description(st, product_no, desc.description)
|
||||
except Cafe24Error as exc:
|
||||
error = str(exc)
|
||||
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
|
||||
|
||||
info = products.normalize_product(product) if product else {}
|
||||
return {
|
||||
"product_no": product_no,
|
||||
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
|
||||
"product_url": api.config.product_url(product_no),
|
||||
"info": {
|
||||
**info,
|
||||
"price": _price(product.get("price")),
|
||||
"updated_date": _short_dt(product.get("updated_date")),
|
||||
"summary_description": product.get("summary_description") or "",
|
||||
},
|
||||
"info": _info(product),
|
||||
"desc": desc,
|
||||
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
|
||||
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
|
||||
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
|
||||
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "",
|
||||
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
||||
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
||||
# 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다.
|
||||
"html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "",
|
||||
# 지문은 **인코딩된 유효 현재값**으로 만든다(적용 직전 같은 규칙으로 계산한
|
||||
# 유효 현재값과 비교하므로).
|
||||
"fingerprint": store.fingerprint(html_effective) if desc else "",
|
||||
"sync_state": sync_state,
|
||||
"sync_pending": sync_state == store.SYNC_PENDING,
|
||||
"sync_external": sync_state == store.SYNC_EXTERNAL,
|
||||
"scalar_stale": scalar_stale,
|
||||
"last_write_at": _short_dt(last_write.get("created_at").isoformat() if last_write and last_write.get("created_at") else ""),
|
||||
"last_write_by": (last_write or {}).get("created_by") or "",
|
||||
"revisions": st.list_revisions(product_no, limit=20),
|
||||
"editor_error": error,
|
||||
"option_display_types": store.OPTION_DISPLAY_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@products_router.get("/", response_class=HTMLResponse)
|
||||
def product_list(request: Request) -> HTMLResponse:
|
||||
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
|
||||
"""3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
checked = guard(request)
|
||||
@@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse:
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "카페24 상품관리",
|
||||
"page_subtitle": "상품 상세페이지 조회·편집·예약",
|
||||
"page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
|
||||
"rows": rows,
|
||||
"total": total,
|
||||
"shown": len(rows),
|
||||
@@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse:
|
||||
|
||||
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
|
||||
def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||
"""오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
|
||||
"""편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.
|
||||
|
||||
두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기
|
||||
위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다.
|
||||
"""
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
checked = guard(request)
|
||||
@@ -246,11 +355,11 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
|
||||
ctx = base_ctx(request, user, active_tab="products")
|
||||
ctx.update(_editor_ctx(st, product_no))
|
||||
ctx["list_query"] = _list_query(request)
|
||||
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
|
||||
return _no_store(render_template(request, "cafe24/_panes.html", ctx))
|
||||
|
||||
|
||||
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
|
||||
NAME_MAX = 250
|
||||
NAME_MAX = store.NAME_MAX
|
||||
|
||||
|
||||
@products_router.post("/products/{product_no}/name")
|
||||
@@ -263,7 +372,7 @@ def product_rename(
|
||||
|
||||
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
|
||||
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
|
||||
(되돌리려면 로그를 보고 다시 바꾼다).
|
||||
(되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
|
||||
"""
|
||||
st, user = require_store(request)
|
||||
actor = str(user.get("email") or "")
|
||||
@@ -276,7 +385,7 @@ def product_rename(
|
||||
|
||||
api = build_cafe24_api(st)
|
||||
try:
|
||||
current = products.get_product(api.client, product_no)
|
||||
current, _ = load_product(st, api, product_no)
|
||||
except Cafe24Error as exc:
|
||||
st.log_audit(
|
||||
actor=actor, action="rename_product", product_no=product_no,
|
||||
@@ -298,9 +407,7 @@ def product_rename(
|
||||
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
info = products.normalize_product(updated) if updated else {}
|
||||
if info.get("product_no"):
|
||||
st.upsert_products([info])
|
||||
info = remember_write(st, product_no, updated)
|
||||
after = str(info.get("product_name") or name)
|
||||
st.log_audit(
|
||||
actor=actor, action="rename_product", product_no=product_no,
|
||||
@@ -348,14 +455,12 @@ def product_status(
|
||||
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
|
||||
if "display" not in updated or "selling" not in updated:
|
||||
try:
|
||||
updated = products.get_product(api.client, product_no)
|
||||
updated, _ = load_product(st, api, product_no)
|
||||
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
|
||||
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
|
||||
updated = {}
|
||||
|
||||
info = products.normalize_product(updated) if updated else {}
|
||||
if info.get("product_no"):
|
||||
st.upsert_products([info])
|
||||
info = remember_write(st, product_no, updated)
|
||||
state = {
|
||||
"display": bool(info.get("display", want if field == "display" else True)),
|
||||
"selling": bool(info.get("selling", want if field == "selling" else True)),
|
||||
@@ -370,7 +475,7 @@ def product_status(
|
||||
|
||||
@products_router.get("/products/{product_no}")
|
||||
def product_redirect(request: Request, product_no: int):
|
||||
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||
"""옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
|
||||
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
|
||||
|
||||
|
||||
@@ -387,9 +492,11 @@ def product_apply(
|
||||
|
||||
순서를 지키는 것이 이 함수의 핵심이다.
|
||||
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
|
||||
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
|
||||
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
|
||||
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
|
||||
2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
|
||||
우리 마지막 쓰기가 현재값이다)
|
||||
3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
|
||||
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
|
||||
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
|
||||
|
||||
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
|
||||
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
|
||||
@@ -423,11 +530,15 @@ def product_apply(
|
||||
)
|
||||
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
|
||||
|
||||
# 읽기 지연 판정 — 카페24가 아직 예전 값을 돌려주면 우리 마지막 쓰기가 현재값이다.
|
||||
effective, sync_state, _ = resolve_description(st, product_no, current.description)
|
||||
pending = sync_state == store.SYNC_PENDING
|
||||
|
||||
backup_id = st.add_revision(
|
||||
product_no=product_no,
|
||||
html_content=current.description,
|
||||
html_content=effective,
|
||||
revision_type=store.REVISION_BACKUP,
|
||||
memo="적용 직전 자동 백업",
|
||||
memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
|
||||
created_by=actor,
|
||||
)
|
||||
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
|
||||
@@ -441,7 +552,7 @@ def product_apply(
|
||||
created_by=actor,
|
||||
)
|
||||
|
||||
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
|
||||
if base_fingerprint and base_fingerprint != store.fingerprint(effective):
|
||||
st.log_audit(
|
||||
actor=actor, action="apply_description", product_no=product_no,
|
||||
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
|
||||
@@ -451,13 +562,13 @@ def product_apply(
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
if submitted == current.description:
|
||||
if submitted == effective:
|
||||
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
||||
try:
|
||||
# mobile_description 은 보내지 않는다 — update_descriptions 가
|
||||
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
|
||||
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
|
||||
products.update_descriptions(api.client, product_no, description=submitted)
|
||||
updated = products.update_descriptions(api.client, product_no, description=submitted)
|
||||
except Cafe24Error as exc:
|
||||
st.log_audit(
|
||||
actor=actor, action="apply_description", product_no=product_no,
|
||||
@@ -469,11 +580,11 @@ def product_apply(
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
|
||||
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리
|
||||
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
|
||||
products.wait_for_description(api.client, product_no, submitted)
|
||||
# PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등).
|
||||
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
|
||||
|
||||
# MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이
|
||||
# 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다.
|
||||
revision_id = st.add_revision(
|
||||
product_no=product_no,
|
||||
html_content=submitted,
|
||||
@@ -481,13 +592,23 @@ def product_apply(
|
||||
memo=memo,
|
||||
created_by=actor,
|
||||
)
|
||||
|
||||
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
|
||||
# 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위
|
||||
# MANUAL revision 을 기준으로 그리므로 문제없다.
|
||||
confirmed = products.wait_for_description(api.client, product_no, submitted)
|
||||
|
||||
st.log_audit(
|
||||
actor=actor, action="apply_description", product_no=product_no,
|
||||
revision_id=revision_id, result="SUCCESS",
|
||||
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)",
|
||||
detail=(
|
||||
f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지, "
|
||||
f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})"
|
||||
),
|
||||
)
|
||||
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
||||
logger.info("카페24 상품 %s 상세설명 적용 (%s, 재조회 확인=%s)", product_no, actor, confirmed)
|
||||
note = "" if confirmed else " 카페24 관리자 API 반영은 잠시 늦을 수 있어 방금 적용한 내용을 표시합니다."
|
||||
return RedirectResponse(
|
||||
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
|
||||
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user