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:
@@ -439,3 +439,352 @@ def fingerprint(html: str) -> str:
|
||||
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
||||
"""
|
||||
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 카페24 읽기 지연(read-after-write lag) 보정
|
||||
#
|
||||
# 실물 관찰: PUT 이 성공하고 쇼핑몰 화면에는 바로 반영되는데도, 관리자 API
|
||||
# (`GET /admin/products/{no}`)는 한동안 **직전 값**을 돌려준다. 몇 초로 끝날 때도
|
||||
# 있고 훨씬 길 때도 있다. 우리 쪽에는 캐시가 없으므로(no-store) 그 값을 그대로
|
||||
# 보여주면 "적용했는데 예전 소스가 보이는" 증상이 된다. 더 나쁜 것은 그 예전 값으로
|
||||
# 지문을 만들어 다음 적용 때 충돌로 오판하거나, 예전 값을 백업으로 남기는 것이다.
|
||||
#
|
||||
# 규칙: **마지막 쓰기가 권위다.**
|
||||
# - 카페24 값이 마지막 쓰기와 같다 → synced (따라잡음)
|
||||
# - 카페24 값이 우리가 아는 *과거 값* 중 하나 → pending (읽기 지연 — 마지막 쓰기를 보여준다)
|
||||
# (최근 유예시간 안의 BACKUP/MANUAL/... revision 해시와 대조)
|
||||
# - 카페24 값이 우리가 모르는 값 → external (관리자에서 직접 고침 — 카페24 값을 믿는다)
|
||||
# - 최근 쓰기가 없다 → none (카페24 값 그대로)
|
||||
# 유예시간(grace)이 지나면 무조건 카페24 값을 믿는다 — 지연은 영원하지 않고,
|
||||
# 우리가 영원히 로컬 값을 고집하면 그것이 또 다른 캐시가 된다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
SYNC_SYNCED = "synced"
|
||||
SYNC_PENDING = "pending"
|
||||
SYNC_EXTERNAL = "external"
|
||||
SYNC_NONE = "none"
|
||||
|
||||
# 쓰기 종류 — 이 revision 들은 "우리가 카페24에 올린 값"이다.
|
||||
WRITE_REVISION_TYPES: tuple[str, ...] = (REVISION_MANUAL, REVISION_SCHEDULED, REVISION_ROLLBACK)
|
||||
|
||||
# 읽기 지연 유예시간 기본값(분). 환경변수 CAFE24_READ_LAG_GRACE_MIN 로 조정.
|
||||
DEFAULT_READ_LAG_GRACE_MIN = 360
|
||||
|
||||
|
||||
def parse_grace_minutes(value: object, default: int = DEFAULT_READ_LAG_GRACE_MIN) -> int:
|
||||
try:
|
||||
minutes = int(str(value or "").strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return minutes if minutes > 0 else default
|
||||
|
||||
|
||||
def _as_aware(value: object) -> datetime | None:
|
||||
"""ISO 문자열/naive datetime → KST aware datetime. 못 읽으면 None."""
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=KST)
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
||||
|
||||
|
||||
def within_grace(written_at: object, *, grace_minutes: int, now: datetime | None = None) -> bool:
|
||||
"""쓰기 시각이 유예시간 안인지."""
|
||||
at = _as_aware(written_at)
|
||||
if at is None:
|
||||
return False
|
||||
current = now or datetime.now(KST)
|
||||
return current - at <= timedelta(minutes=max(1, int(grace_minutes)))
|
||||
|
||||
|
||||
def resolve_description(
|
||||
cafe24_html: str,
|
||||
*,
|
||||
last_write: dict | None,
|
||||
known_digests: set[str] | frozenset[str],
|
||||
grace_minutes: int,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""(화면·적용에 쓸 유효 HTML, 상태) 를 돌려준다.
|
||||
|
||||
last_write: 최근 쓰기 revision — {"html_content", "created_at"}.
|
||||
known_digests: 유예시간 안의 revision 들의 내용 해시(`content_digest()` 결과) 집합.
|
||||
"""
|
||||
if not last_write:
|
||||
return cafe24_html, SYNC_NONE
|
||||
if not within_grace(last_write.get("created_at"), grace_minutes=grace_minutes, now=now):
|
||||
return cafe24_html, SYNC_NONE
|
||||
|
||||
last_html = str(last_write.get("html_content") or "")
|
||||
if cafe24_html == last_html:
|
||||
return cafe24_html, SYNC_SYNCED
|
||||
if content_digest(cafe24_html) in known_digests:
|
||||
return last_html, SYNC_PENDING
|
||||
return cafe24_html, SYNC_EXTERNAL
|
||||
|
||||
|
||||
def content_digest(html: str) -> str:
|
||||
"""revision 내용 대조용 md5 hex — DB 의 `md5(html_content)` 와 같은 값.
|
||||
|
||||
(`fingerprint` 는 낙관적 잠금용 sha256 이고, 이것은 "우리가 아는 값인가" 대조용이다.
|
||||
md5 는 모든 PostgreSQL 에 내장돼 있어 DB 쪽에서 계산할 수 있다.)
|
||||
"""
|
||||
return hashlib.md5((html or "").encode("utf-8")).hexdigest() # noqa: S324 — 보안 목적 아님
|
||||
|
||||
|
||||
# 쓰기 직후 스냅샷에 보관하고, 지연 판정 시 덮어씌우는 스칼라 필드.
|
||||
# description 은 넣지 않는다(크기 — revision 이 담당).
|
||||
SNAPSHOT_FIELDS: tuple[str, ...] = (
|
||||
"product_name",
|
||||
"product_code",
|
||||
"price",
|
||||
"supply_price",
|
||||
"retail_price",
|
||||
"display",
|
||||
"selling",
|
||||
"detail_image",
|
||||
"list_image",
|
||||
"tiny_image",
|
||||
"small_image",
|
||||
"updated_date",
|
||||
"separated_mobile_description",
|
||||
)
|
||||
|
||||
|
||||
def product_snapshot(product: dict) -> dict:
|
||||
"""PUT 응답(상품 dict) → 스냅샷(JSONB 저장용). 값은 문자열/숫자/불리언만 남긴다."""
|
||||
out: dict = {}
|
||||
for key in SNAPSHOT_FIELDS:
|
||||
if key in product and product[key] is not None:
|
||||
value = product[key]
|
||||
out[key] = value if isinstance(value, (bool, int, float)) else str(value)
|
||||
return out
|
||||
|
||||
|
||||
def overlay_recent_write(
|
||||
fetched: dict,
|
||||
*,
|
||||
snapshot: dict | None,
|
||||
written_at: object,
|
||||
grace_minutes: int,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[dict, bool]:
|
||||
"""카페24 GET 결과가 우리 마지막 쓰기보다 오래됐으면 스냅샷 값을 덮어씌운다.
|
||||
|
||||
판정 근거는 카페24 자신의 `updated_date` 다 — PUT 응답의 updated_date(스냅샷)
|
||||
보다 GET 의 updated_date 가 **이전**이면 GET 이 아직 예전 레코드를 돌려주는
|
||||
것이다. 우리 서버 시계와 비교하지 않으므로 시계 차이에 영향받지 않는다.
|
||||
날짜가 없어 판정할 수 없으면 카페24 값을 그대로 둔다.
|
||||
반환: (병합된 상품 dict, 지연 여부)
|
||||
"""
|
||||
if not snapshot:
|
||||
return fetched, False
|
||||
if not within_grace(written_at, grace_minutes=grace_minutes, now=now):
|
||||
return fetched, False
|
||||
fetched_at = _as_aware(fetched.get("updated_date"))
|
||||
snap_at = _as_aware(snapshot.get("updated_date"))
|
||||
if fetched_at is None or snap_at is None or fetched_at >= snap_at:
|
||||
return fetched, False
|
||||
merged = dict(fetched)
|
||||
for key in SNAPSHOT_FIELDS:
|
||||
if key in snapshot and key != "updated_date":
|
||||
merged[key] = snapshot[key]
|
||||
merged["updated_date"] = snapshot.get("updated_date") or fetched.get("updated_date")
|
||||
return merged, True
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 기본 정보(상품명·가격) 입력 검증 — 오른쪽 정보 패널
|
||||
# ════════════════════════════════════════════════════════════
|
||||
NAME_MAX = 250
|
||||
PRICE_MAX = 2_147_483_647
|
||||
|
||||
|
||||
def parse_price(value: object, *, field: str = "가격") -> str | None:
|
||||
"""'6,900' / '6900.00' / 6900 → '6900.00'. 빈 값이면 None(변경 안 함)."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().replace(",", "").replace("원", "")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
number = int(round(float(text)))
|
||||
except ValueError:
|
||||
raise ValueError(f"{field}은(는) 숫자여야 합니다.") from None
|
||||
if number < 0 or number > PRICE_MAX:
|
||||
raise ValueError(f"{field}은(는) 0 이상 {PRICE_MAX:,} 이하여야 합니다.")
|
||||
return f"{number}.00"
|
||||
|
||||
|
||||
def price_equal(a: object, b: object) -> bool:
|
||||
"""카페24 '6900.00' 과 우리 '6900.00'/'6900' 을 같은 값으로 본다."""
|
||||
|
||||
def norm(v: object) -> str | None:
|
||||
try:
|
||||
return parse_price(v)
|
||||
except ValueError:
|
||||
return str(v)
|
||||
|
||||
return norm(a) == norm(b)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 옵션/품목 payload — 순수 변환(검증)만. 전송은 integrations.products 가 한다.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
OPTION_DISPLAY_TYPES: tuple[str, ...] = ("S", "P", "B", "R")
|
||||
OPTION_DISPLAY_LABELS: dict[str, str] = {
|
||||
"S": "셀렉트박스",
|
||||
"P": "미리보기(이미지)",
|
||||
"B": "텍스트버튼",
|
||||
"R": "라디오버튼",
|
||||
}
|
||||
VARIANT_CODE_RE = re.compile(r"^[A-Z0-9]{12}$")
|
||||
CUSTOM_CODE_MAX = 40
|
||||
ADDITIONAL_AMOUNT_MAX = 2_147_483_647
|
||||
|
||||
|
||||
def parse_option_values(raw: object) -> list[str]:
|
||||
"""'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
|
||||
text = str(raw or "")
|
||||
seen: list[str] = []
|
||||
for part in re.split(r"[,\n]", text):
|
||||
value = part.strip()
|
||||
if value and value not in seen:
|
||||
seen.append(value)
|
||||
return seen
|
||||
|
||||
|
||||
def build_create_options_request(
|
||||
option_name: str, values: list[str], *, display_type: str = "S"
|
||||
) -> dict:
|
||||
name = (option_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("옵션명을 입력하세요.")
|
||||
if not values:
|
||||
raise ValueError("옵션값을 하나 이상 입력하세요.")
|
||||
dtype = (display_type or "S").strip().upper()
|
||||
if dtype not in OPTION_DISPLAY_TYPES:
|
||||
dtype = "S"
|
||||
return {
|
||||
"has_option": "T",
|
||||
"option_type": "T", # 조합형
|
||||
"option_list_type": "S", # 조합 분리선택형
|
||||
"options": [
|
||||
{
|
||||
"option_name": name,
|
||||
"option_value": [{"option_text": v} for v in values],
|
||||
"option_display_type": dtype,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_update_options_request(
|
||||
original: list[dict], edited: list[dict], *, option_list_type: str = ""
|
||||
) -> dict:
|
||||
"""옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청.
|
||||
|
||||
카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를
|
||||
**같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로
|
||||
할 수 없으므로 개수가 다르면 거부한다.
|
||||
original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록.
|
||||
"""
|
||||
if len(original) != len(edited):
|
||||
raise ValueError("옵션 개수가 카페24와 다릅니다. 다시 읽은 뒤 수정하세요.")
|
||||
orig_out: list[dict] = []
|
||||
new_out: list[dict] = []
|
||||
for o, e in zip(original, edited):
|
||||
o_vals = list(o.get("option_value") or [])
|
||||
e_vals = list(e.get("option_value") or [])
|
||||
if len(o_vals) != len(e_vals):
|
||||
raise ValueError(
|
||||
f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. "
|
||||
"옵션값 추가/삭제는 이 API 로 할 수 없습니다."
|
||||
)
|
||||
name = str(e.get("option_name") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("옵션명은 비울 수 없습니다.")
|
||||
orig_entry: dict = {
|
||||
"option_name": str(o.get("option_name") or ""),
|
||||
"option_value": [],
|
||||
}
|
||||
new_entry: dict = {"option_name": name, "option_value": []}
|
||||
if o.get("option_code"):
|
||||
orig_entry["option_code"] = o["option_code"]
|
||||
new_entry["option_code"] = o["option_code"]
|
||||
dtype = str(
|
||||
e.get("option_display_type") or o.get("option_display_type") or ""
|
||||
).strip().upper()
|
||||
if dtype in OPTION_DISPLAY_TYPES:
|
||||
new_entry["option_display_type"] = dtype
|
||||
for ov, ev in zip(o_vals, e_vals):
|
||||
text = str(ev.get("option_text") or "").strip()
|
||||
if not text:
|
||||
raise ValueError("옵션값 이름은 비울 수 없습니다.")
|
||||
o_item: dict = {"option_text": str(ov.get("option_text") or "")}
|
||||
n_item: dict = {"option_text": text}
|
||||
if ov.get("value_no") is not None:
|
||||
o_item["value_no"] = ov["value_no"]
|
||||
n_item["value_no"] = ov["value_no"]
|
||||
for key in ("option_image_file", "option_link_image", "option_color"):
|
||||
value = ev.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
value = str(value).strip()
|
||||
if value:
|
||||
n_item[key] = value
|
||||
orig_entry["option_value"].append(o_item)
|
||||
new_entry["option_value"].append(n_item)
|
||||
orig_out.append(orig_entry)
|
||||
new_out.append(new_entry)
|
||||
request: dict = {"original_options": orig_out, "options": new_out}
|
||||
list_type = (option_list_type or "").strip().upper()
|
||||
if list_type in ("S", "C"):
|
||||
request["option_list_type"] = list_type
|
||||
return request
|
||||
|
||||
|
||||
def parse_additional_amount(value: object) -> str | None:
|
||||
"""'1,000' / '-500' → '1000.00'. 빈 값은 None(변경 안 함)."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().replace(",", "").replace("원", "")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
number = int(round(float(text)))
|
||||
except ValueError:
|
||||
raise ValueError("추가금액은 숫자여야 합니다.") from None
|
||||
if abs(number) > ADDITIONAL_AMOUNT_MAX:
|
||||
raise ValueError("추가금액 범위를 벗어났습니다.")
|
||||
return f"{number}.00"
|
||||
|
||||
|
||||
def build_variant_updates(rows: list[dict]) -> list[dict]:
|
||||
"""화면 행 목록 → PUT /variants `requests` 배열. 바뀔 것이 없는 행은 뺀다."""
|
||||
out: list[dict] = []
|
||||
for row in rows:
|
||||
code = str(row.get("variant_code") or "").strip().upper()
|
||||
if not VARIANT_CODE_RE.match(code):
|
||||
raise ValueError(f"품목코드 형식이 올바르지 않습니다: {code or '(빈 값)'}")
|
||||
item: dict = {"variant_code": code}
|
||||
if row.get("custom_variant_code") is not None:
|
||||
custom = str(row["custom_variant_code"]).strip()
|
||||
if len(custom) > CUSTOM_CODE_MAX:
|
||||
raise ValueError(f"자체 품목코드는 {CUSTOM_CODE_MAX}자를 넘을 수 없습니다.")
|
||||
item["custom_variant_code"] = custom
|
||||
if "additional_amount" in row:
|
||||
amount = parse_additional_amount(row["additional_amount"])
|
||||
if amount is not None:
|
||||
item["additional_amount"] = amount
|
||||
for flag in ("display", "selling"):
|
||||
if row.get(flag) is not None:
|
||||
item[flag] = "T" if parse_tristate(row[flag]) else "F"
|
||||
if len(item) > 1:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user