feat(cafe24): 상품 목록·검색 + 현재 상세페이지 HTML 조회 (Phase 2)
상세설명 API 경로가 틀려 있던 것을 실물 확인으로 바로잡았다.
`/admin/products/{no}/description` 은 존재하지 않는다(운영몰 호출 결과
`No API found.`). 상세설명은 상품 리소스의 필드이므로 GET/PUT 을
`/admin/products/{no}` 로 옮겼고, PUT body 는 {"request": {...}} 다.
PC/모바일 상세설명이 별도 필드라는 것도 확인됐다. `separated_mobile_description`
('T'/'F') 이 분리 사용 여부이며, 미분리 상품을 수정할 때 description 만 바꾸면
모바일이 어긋난다. Descriptions 데이터클래스에 이 플래그와 불일치 여부를 담아
화면에서 경고로 노출한다.
목록 응답에는 description 이 없어(확인됨) 상세설명은 상품 1건씩 조회한다.
그래서 목록 화면에 미리보기를 뿌리지 않는다 — 상품 87개면 87호출이라 호출
제한에 걸린다.
화면은 읽기 전용이다(편집·적용은 Phase 3~4). 목록은 카페24를 매번 조회해
현재값을 보여주고, 결과를 cafe24_products 에 UPSERT 해둔다(예약·로그 화면에서
API 없이 상품명을 쓰기 위함).
상단 탭의 예약관리가 404 였으므로 Phase 5 안내 화면을 붙였다.
토큰 만료 시각이 화면에 +00:00 로 보이던 것도 고쳤다. 컬럼이 timestamptz 라
psycopg 가 UTC 로 돌려주는 값을 그대로 출력하고 있었다(시각 자체는 정확했다).
검증: 유닛테스트 23개 통과(신규 7개 — 상세설명 경로가 /description 으로
되돌아가지 않는지, PUT payload 모양, 미분리 플래그 파싱, 페이징 clamp).
라우트 8개 등록 확인. 실제 화면은 서버 배포 후 확인 필요.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""카페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)
|
||||
Reference in New Issue
Block a user