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
+124
View File
@@ -41,6 +41,18 @@ _TOKEN_FIELDS: tuple[str, ...] = (
)
def _flag_bool(value: Any, default: bool) -> bool:
"""카페24 'T'/'F' 또는 bool → bool."""
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "1"):
return True
if text in ("F", "FALSE", "0"):
return False
return default
class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
@@ -263,6 +275,118 @@ class Cafe24Store:
).fetchone()
return self._serialize(row)
# ── 쓰기 직후 스냅샷 (읽기 지연 보정용) ──
# 카페24 PUT 응답의 상품 값을 남겨 둔다. GET 이 아직 예전 레코드를 돌려주는
# 동안(updated_date 가 스냅샷보다 이전) 이 값으로 화면을 덮어씌운다.
# 마이그레이션 004 의 두 컬럼(last_write_snapshot, last_written_at)을 쓴다.
# 스냅샷 JSON 모양:
# {"product": {"data": {...store.SNAPSHOT_FIELDS...}, "written_at": ISO},
# "options": {"data": {...GET/PUT options 응답...}, "written_at": ISO},
# "variants": {"data": {variant_code: {...}}, "written_at": ISO}}
# 섹션별로 따로 갱신한다(가격만 바꿨는데 옵션 스냅샷이 사라지면 안 된다).
def save_write_snapshot(self, product_no: int, section: str, data: Any) -> None:
from psycopg.types.json import Jsonb # noqa: WPS433
no = int(product_no)
if no <= 0 or section not in ("product", "options", "variants"):
return
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s FOR UPDATE",
(no,),
).fetchone()
current = dict(row["last_write_snapshot"]) if row and row.get("last_write_snapshot") else {}
if section == "variants" and isinstance(data, dict):
# 품목은 코드별로 누적 병합 — 일부만 바꿔도 이전에 바꾼 것을 잃지 않게.
merged = dict((current.get("variants") or {}).get("data") or {})
merged.update(data)
data = merged
current[section] = {
"data": data,
"written_at": datetime.now(KST).isoformat(timespec="seconds"),
}
product = data if section == "product" and isinstance(data, dict) else {}
conn.execute(
"""
INSERT INTO cafe24_products
(product_no, product_code, product_name, display, selling,
last_synced_at, last_write_snapshot, last_written_at)
VALUES (%s, %s, %s, %s, %s, now(), %s, now())
ON CONFLICT (product_no) DO UPDATE SET
product_code = COALESCE(NULLIF(EXCLUDED.product_code, ''), cafe24_products.product_code),
product_name = COALESCE(NULLIF(EXCLUDED.product_name, ''), cafe24_products.product_name),
display = CASE WHEN %s THEN EXCLUDED.display ELSE cafe24_products.display END,
selling = CASE WHEN %s THEN EXCLUDED.selling ELSE cafe24_products.selling END,
last_synced_at = now(),
last_write_snapshot = EXCLUDED.last_write_snapshot,
last_written_at = now()
""",
(
no,
str(product.get("product_code") or ""),
str(product.get("product_name") or ""),
_flag_bool(product.get("display"), True),
_flag_bool(product.get("selling"), True),
Jsonb(current),
bool(product),
bool(product),
),
)
def get_write_snapshot(self, product_no: int) -> dict[str, Any]:
"""섹션별 스냅샷 dict. 없으면 {}."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s",
(int(product_no),),
).fetchone()
if not row or not row.get("last_write_snapshot"):
return {}
return dict(row["last_write_snapshot"])
# ── 읽기 지연 판정용 revision 조회 ──
def latest_write_revision(self, product_no: int, *, since: datetime) -> dict[str, Any] | None:
"""유예시간 안의 가장 최근 '쓰기' revision(MANUAL/SCHEDULED/ROLLBACK) — HTML 포함."""
with self._pool.connection() as conn:
row = conn.execute(
"""
SELECT id, product_no, revision_type, html_content, memo, created_by, created_at
FROM cafe24_product_revisions
WHERE product_no = %s
AND revision_type = ANY(%s)
AND created_at >= %s
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(int(product_no), list(store.WRITE_REVISION_TYPES), since),
).fetchone()
if not row:
return None
out = dict(row)
at = out.get("created_at")
if isinstance(at, datetime) and at.tzinfo is None:
out["created_at"] = at.replace(tzinfo=KST)
return out
def revision_digests(self, product_no: int, *, since: datetime) -> set[str]:
"""유예시간 안의 revision 들의 내용 해시(store.content_digest 와 같은 md5 hex).
내용을 통째로 옮기지 않고 DB 에서 해시만 계산한다(상세페이지는 수 MB 일 수 있다).
md5 를 쓰는 이유: 모든 PostgreSQL 버전에 있고, 여기서는 충돌 저항이 아니라
"같은 내용인가"만 필요하다.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT md5(html_content) AS digest
FROM cafe24_product_revisions
WHERE product_no = %s AND created_at >= %s
""",
(int(product_no), since),
).fetchall()
return {str(r["digest"]) for r in rows if r.get("digest")}
# ════════════════════════════════════════════════════════════
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.