Files
dbx-main/app/modules/cafe24/routes_products.py
T
king 40766d805d 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>
2026-09-18 18:07:42 +09:00

615 lines
28 KiB
Python

"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널).
화면 구성
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목.
(JSON API 는 routes_product_info.py)
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 두 조각을
한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페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 관리자
화면의 모바일 상세설명 설정이 "직접 등록"으로 바뀌어버리므로(실물 확인) 보내지
않는다.
이미지 경로의 한글은 카페24에 퍼센트 인코딩으로 저장돼 있다. 편집기에는
`store.decode_html_urls` 로 풀어서 보여주고, 저장할 때 `encode_html_urls` 로
되돌린다(왕복 보존 — store.py 주석 참고).
핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode
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, read_lag_grace_minutes, require_store
logger = logging.getLogger("cafe24.products")
products_router = APIRouter()
def _checked(request: Request, name: str) -> bool:
"""체크박스 → bool. 값이 무엇이든 파라미터가 있으면 체크된 것으로 본다."""
return request.query_params.get(name) is not None
def _no_store(response: Any) -> Any:
"""브라우저가 이 응답을 재사용하지 못하게 한다.
편집기는 카페24의 **현재** HTML 을 보여줘야 한다. 캐시된 화면이 다시 그려지면
카페24 관리자에서 값을 바꾼 뒤에도 예전 소스가 보이고, 그것을 그대로 편집하면
남의 수정을 덮어쓴다. 조각을 가져가는 fetch 에도 `cache: "no-store"` 를 건다.
"""
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
def _price(value: Any) -> str:
"""카페24 가격('6900.00') → 화면 표기('6,900원').
소수점 아래는 버린다 — 이 쇼핑몰은 원 단위라 언제나 .00 이고, 화면에 보일
이유가 없다. 숫자로 못 읽으면 받은 값을 그대로 보여준다.
"""
text = str(value or "").strip()
if not text:
return ""
try:
won = int(Decimal(text))
except (ArithmeticError, ValueError):
return text
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()
if not text:
return ""
return text.replace("T", " ")[:16]
def _row_for_list(raw: dict[str, Any]) -> dict[str, Any]:
"""왼쪽 목록에 쓸 필드만 — 상품번호·상품명·진열·판매·최근수정."""
normalized = products.normalize_product(raw)
return {
"product_no": normalized["product_no"],
"product_name": normalized["product_name"],
"display": normalized["display"],
"selling": normalized["selling"],
"updated_date": _short_dt(raw.get("updated_date")),
}
# 필터 폼이 제출됐음을 알리는 표식.
# 체크박스는 해제 상태면 아무 값도 보내지 않으므로, 이것 없이는 "첫 방문"과
# "사용자가 일부러 해제함"을 구분할 수 없다(기본값이 체크라서 해제가 무시된다).
_FILTER_MARK = "f"
def _filter_flags(request: Request) -> tuple[bool, bool]:
"""(진열중만, 판매중만). 첫 방문이면 둘 다 기본 체크로 본다."""
if request.query_params.get(_FILTER_MARK) is None:
return True, True
return _checked(request, "display"), _checked(request, "selling")
def _list_query(request: Request, *, selected: int | None = None) -> str:
"""현재 검색·필터를 유지한 목록 URL 쿼리스트링."""
params: list[tuple[str, str]] = []
keyword = (request.query_params.get("q") or "").strip()
if keyword:
params.append(("q", keyword))
if request.query_params.get(_FILTER_MARK) is not None:
# 해제 상태까지 그대로 이어지도록 표식을 함께 남긴다.
params.append((_FILTER_MARK, "1"))
for flag in ("display", "selling"):
if _checked(request, flag):
params.append((flag, "1"))
if selected:
params.append(("selected", str(selected)))
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, 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)
return {
"product_no": product_no,
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
"product_url": api.config.product_url(product_no),
"info": _info(product),
"desc": desc,
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
# 값은 카페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:
"""3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
keyword = (request.query_params.get("q") or "").strip()
only_display, only_selling = _filter_flags(request)
api = build_cafe24_api(st)
rows: list[dict[str, Any]] = []
total = 0
truncated = False
error = ""
try:
raw_rows, truncated = products.list_all_products(api.client, product_name=keyword)
st.upsert_products([products.normalize_product(r) for r in raw_rows])
total = len(raw_rows)
rows = [_row_for_list(r) for r in raw_rows]
# 필터는 전체를 받아온 뒤 적용한다(문서에 없는 API 파라미터에 기대지 않는다).
if only_display:
rows = [r for r in rows if r["display"]]
if only_selling:
rows = [r for r in rows if r["selling"]]
except Cafe24Error as exc:
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
try:
selected = int(request.query_params.get("selected") or 0)
except ValueError:
selected = 0
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
"rows": rows,
"total": total,
"shown": len(rows),
"truncated": truncated,
"keyword": keyword,
"only_display": only_display,
"only_selling": only_selling,
"selected": selected,
"list_query": _list_query(request),
"error": error,
"flash": request.query_params.get("msg", ""),
"flash_error": request.query_params.get("err", ""),
}
)
if selected:
ctx.update(_editor_ctx(st, selected))
return _no_store(render_template(request, "cafe24/products.html", ctx))
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
def product_pane(request: Request, product_no: int) -> HTMLResponse:
"""편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.
두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기
위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다.
"""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
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/_panes.html", ctx))
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
NAME_MAX = store.NAME_MAX
@products_router.post("/products/{product_no}/name")
def product_rename(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""상품명만 바꾼다 — 편집기 제목 옆 연필 버튼용(JSON API).
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
(되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
name = str(payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
if len(name) > NAME_MAX:
raise HTTPException(status_code=400, detail=f"상품명은 {NAME_MAX}자를 넘을 수 없습니다.")
api = build_cafe24_api(st)
try:
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}",
)
raise HTTPException(status_code=502, detail=f"카페24 현재값을 읽지 못했습니다: {exc}") from exc
before = str(current.get("product_name") or "")
if before == name:
return {"ok": True, "product_name": before, "changed": False}
try:
updated = products.update_product(api.client, product_no, product_name=name)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
result="FAIL", detail=f"'{before}''{name}' 실패: {exc}",
)
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
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,
result="SUCCESS", detail=f"'{before}''{after}'",
)
logger.info("카페24 상품 %s 이름 변경 (%s)", product_no, actor)
return {"ok": True, "product_name": after, "changed": True}
@products_router.post("/products/{product_no}/status")
def product_status(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""진열/판매 상태만 바꾼다 — 편집기 오른쪽 위 배지 클릭용(JSON API).
상세설명은 건드리지 않는다(`build_update_payload` 는 준 필드만 보낸다). 그래서
BACKUP revision 도 만들지 않는다 — 되돌릴 HTML 이 없고, 상태는 다시 눌러
되돌릴 수 있다.
`value` 는 클라이언트가 **원하는 결과값**이다(현재값을 뒤집지 않는다). 화면의
배지가 카페24와 어긋나 있어도 사용자가 누른 대로 되는 편이 예측 가능하다.
응답에는 쓰기 후 카페24가 돌려준 실제 상태를 담아 화면을 그것에 맞춘다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
field = str(payload.get("field") or "").strip()
if field not in ("display", "selling"):
raise HTTPException(status_code=400, detail="field 는 display 또는 selling 이어야 합니다.")
want = bool(payload.get("value"))
api = build_cafe24_api(st)
try:
updated = products.update_product(api.client, product_no, **{field: want})
except Cafe24Error as exc:
st.log_audit(
actor=actor, action=f"set_{field}", product_no=product_no,
result="FAIL", detail=f"{want} 설정 실패: {exc}",
)
logger.warning("카페24 상품 %s %s 변경 실패: %s", product_no, field, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
if "display" not in updated or "selling" not in updated:
try:
updated, _ = load_product(st, api, product_no)
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
updated = {}
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)),
}
st.log_audit(
actor=actor, action=f"set_{field}", product_no=product_no,
result="SUCCESS", detail=f"{field}={'T' if want else 'F'}",
)
logger.info("카페24 상품 %s %s=%s (%s)", product_no, field, want, actor)
return {"ok": True, **state}
@products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
@products_router.post("/products/{product_no}/apply")
def product_apply(
request: Request,
product_no: int,
html: str = Form(""),
base_fingerprint: str = Form(""),
memo: str = Form(""),
list_query: str = Form(""),
):
"""편집한 HTML 을 카페24에 즉시 적용한다.
순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
우리 마지막 쓰기가 현재값이다)
3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
"""
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
actor = str(user.get("email") or "")
# 적용 후에는 검색·필터를 유지한 채 같은 상품이 선택된 화면으로 돌아온다.
base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}"
back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}"
# 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는
# 원래의 퍼센트 인코딩으로 되돌린다.
submitted = store.format_html(store.encode_html_urls(html or ""))
if not submitted.strip():
return RedirectResponse(
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
status_code=303,
)
api = build_cafe24_api(st)
try:
current = products.fetch_descriptions(api.client, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}",
)
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=effective,
revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
created_by=actor,
)
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
# 덮어쓰므로, 백업하지 않으면 그 내용을 되찾을 방법이 없다.
if current.mobile_description and current.mobile_description != current.description:
st.add_revision(
product_no=product_no,
html_content=current.mobile_description,
revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업 (모바일 — PC와 달랐던 내용)",
created_by=actor,
)
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 값이 변경됨",
)
return RedirectResponse(
url=f"{back}&err=편집하는 동안 카페24 값이 변경되었습니다. 새로고침해 현재 내용을 확인한 뒤 다시 적용하세요.",
status_code=303,
)
if submitted == effective:
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
try:
# mobile_description 은 보내지 않는다 — update_descriptions 가
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
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,
revision_id=backup_id, result="FAIL", detail=str(exc),
)
logger.warning("카페24 상품 %s 적용 실패: %s", product_no, exc)
return RedirectResponse(
url=f"{back}&err=적용에 실패했습니다: {exc} (직전 내용은 버전 {backup_id} 로 보관됨)",
status_code=303,
)
# 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,
revision_type=store.REVISION_MANUAL,
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와 동일 유지, "
f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})"
),
)
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} 로 보관됩니다.{note}",
status_code=303,
)