"""카페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)