feat(cafe24): 진열/판매 배지 클릭으로 상태 토글

편집기 오른쪽 위 배지를 눌러 진열·판매를 바로 바꾼다. 상태만 바꾸려고
카페24 관리자에 들어갈 필요가 없어진다.

- POST /cafe24/products/{no}/status (JSON) 추가. 상세설명은 건드리지 않아
  BACKUP revision 을 만들지 않는다 - 되돌릴 HTML 이 없고 다시 눌러 복구된다.
- 요청값을 낙관적으로 반영하지 않고 쓰기 후 카페24가 돌려준 실제 상태로
  화면을 다시 그린다. 실패해도 화면과 카페24가 어긋나지 않는다.
- 왼쪽 목록의 점과 정렬용 data-display/data-selling 도 함께 갱신(목록을
  다시 받지 않으므로).
- 카페24 조회 실패 시에는 현재 상태를 믿을 수 없어 배지를 버튼으로 만들지
  않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:38:20 +09:00
parent 67c28ae1c7
commit 5127600a3d
5 changed files with 171 additions and 4 deletions
+60 -2
View File
@@ -34,13 +34,13 @@ import logging
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
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 . import store
from .common import base_ctx, guard
from .common import base_ctx, guard, require_store
logger = logging.getLogger("cafe24.products")
@@ -229,6 +229,64 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
@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 = products.get_product(api.client, 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])
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):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""