"""카페24 상품 화면 — 목록/검색 · 현재 상세설명(HTML) 조회. Phase 2.
카페24를 언제나 source of truth 로 본다. 목록도 상세설명도 화면을 열 때마다
API 로 현재값을 읽고, 목록 결과는 `cafe24_products` 캐시에 UPSERT 한다
(예약·로그 화면에서 API 없이 상품명을 보여주기 위한 용도).
상세설명은 상품 리소스의 필드다(`/description` 서브리소스는 존재하지 않는다 —
app/integrations/cafe24/products.py 주석 참고). 목록 응답에는 상세설명이 없어
상품 1건씩 조회해야 하므로, 목록 화면에서는 미리보기를 뿌리지 않는다.
편집·적용은 Phase 3~4 다. 이 파일은 **읽기 전용**이며 카페24에 쓰지 않는다.
핸들러는 `async def` 가 아니라 `def`(동기)로 선언한다. 카페24 API·DB 호출이
블로킹이므로 FastAPI 스레드풀에서 돌게 두는 편이 이벤트 루프를 막지 않는다.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from .common import base_ctx, guard
logger = logging.getLogger("cafe24.products")
products_router = APIRouter()
# 한 화면에 보여줄 상품 수. 카페24 1회 조회 한도(100)를 넘지 않는다.
PAGE_SIZE = 50
def _page_param(raw: str | None) -> int:
try:
return max(1, int(raw or 1))
except ValueError:
return 1
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]:
"""목록 표에 쓸 필드만 골라낸다(응답 필드가 90개라 그대로 넘기지 않는다)."""
normalized = products.normalize_product(raw)
return {
**normalized,
"updated_date": _short_dt(raw.get("updated_date")),
"price": str(raw.get("price") or ""),
}
@products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse:
"""상품 목록/검색. 검색어는 상품명 부분일치(카페24 API 가 처리)."""
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()
page = _page_param(request.query_params.get("page"))
api = build_cafe24_api(st)
rows: list[dict[str, Any]] = []
total = 0
error = ""
try:
total = products.count_products(api.client, product_name=keyword)
raw_rows = products.list_products(
api.client,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
product_name=keyword,
)
rows = [_row_for_list(r) for r in raw_rows]
st.upsert_products([products.normalize_product(r) for r in raw_rows])
except Cafe24Error as exc:
# 미연결/토큰만료/호출제한 모두 여기로 온다. 화면은 살려두고 사유만 알린다.
error = str(exc)
logger.warning("카페24 상품 목록 조회 실패: %s", exc)
last_page = max(1, -(-total // PAGE_SIZE)) if total else 1
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
"rows": rows,
"keyword": keyword,
"page": page,
"last_page": last_page,
"total": total,
"error": error,
}
)
return render_template(request, "cafe24/products.html", ctx)
@products_router.get("/products/{product_no}", response_class=HTMLResponse)
def product_detail(request: Request, product_no: int) -> HTMLResponse:
"""상품 1건 — 기본정보 + 카페24에 지금 올라가 있는 상세설명 HTML."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
if not isinstance(checked, tuple):
return checked
st, user = checked
api = build_cafe24_api(st)
product: dict[str, Any] = {}
desc = None
error = ""
try:
product = products.get_product(api.client, product_no)
desc = products.descriptions_from_product(product)
st.upsert_products([products.normalize_product(product)])
except Cafe24Error as exc:
error = str(exc)
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
info = products.normalize_product(product) if product else {}
ctx = base_ctx(request, user, active_tab="products")
ctx.update(
{
"page_title": f"카페24 상품 {product_no}",
"page_subtitle": product.get("product_name") or "상품 상세페이지",
"product_no": product_no,
"info": {
**info,
"price": str(product.get("price") or ""),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
},
"desc": desc,
"error": error,
}
)
return render_template(request, "cafe24/product.html", ctx)