diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py
index 416e8d0..35b1a5c 100644
--- a/app/modules/cafe24/routes_products.py
+++ b/app/modules/cafe24/routes_products.py
@@ -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분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html
index 94fd1f8..1df77f5 100644
--- a/app/modules/cafe24/templates/cafe24/_editor.html
+++ b/app/modules/cafe24/templates/cafe24/_editor.html
@@ -21,11 +21,23 @@
{% if info.updated_date %}· 최근 수정 {{ info.updated_date }}{% endif %}
-
+ {# 배지 클릭 = 진열/판매 토글. 카페24 조회에 실패했을 때(desc 없음)는 현재 상태를
+ 믿을 수 없으므로 누를 수 없는 표시로만 둔다. 실제 전환은 products.html 의
+ JS 가 POST /products/{no}/status 로 처리하고 응답값으로 다시 그린다. #}
+