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:
2026-08-14 12:05:19 +09:00
parent 4be7c7f580
commit 07626bcaf8
13 changed files with 742 additions and 76 deletions
+104 -38
View File
@@ -3,12 +3,21 @@
전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
⚠️ 상품 수정 payload 구조는 카페24 Admin API 버전에 따라 다를 수 있다.
실제 쇼핑몰에 반영하기 전 반드시 테스트 상품 1건으로 검증할 것.
상세설명은 **별도 리소스가 아니다.** 실제 쇼핑몰(miraskitchen)에 확인한 결과
`/admin/products/{no}/description` 은 존재하지 않는다(`No API found.`).
상세설명은 상품 리소스의 필드로 읽고 쓴다.
GET /admin/products/{no} → description · mobile_description ·
separated_mobile_description
PUT /admin/products/{no}{"request": {"description": ...}}
목록 API(`/admin/products`) 응답에는 description 이 **없다**. 그래서 상세설명은
상품 1건씩 조회해야 한다(목록 화면에서 미리보기를 뿌리지 않는 이유).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .client import Cafe24Client
@@ -17,6 +26,18 @@ from .client import Cafe24Client
PAGE_LIMIT = 100
def _flag(value: Any, *, default: bool = True) -> bool:
"""카페24는 boolean 을 'T'/'F' 문자열로 준다."""
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "Y", "1"):
return True
if text in ("F", "FALSE", "N", "0"):
return False
return default
def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
params: dict[str, Any] = {}
if product_name:
@@ -51,58 +72,103 @@ def list_products(
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
"""상품 1건 기본 정보 (상세설명은 별도 조회 — get_description)."""
payload = client.get(f"/admin/products/{int(product_no)}", product_no=int(product_no))
"""상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
no = int(product_no)
payload = client.get(f"/admin/products/{no}", product_no=no)
product = payload.get("product")
return product if isinstance(product, dict) else {}
def get_description(client: Cafe24Client, product_no: int) -> str:
"""상품의 현재 상세설명 HTML.
@dataclass(frozen=True)
class Descriptions:
"""상품 1건의 상세설명 묶음. 카페24가 언제나 source of truth 다."""
카페24는 상세설명을 별도 리소스로 제공한다. 이 값이 언제나 source of truth
이며, 로컬 DB 의 마지막 버전을 현재값이라고 가정하지 않는다.
product_no: int
product_name: str
description: str
mobile_description: str
# separated_mobile_description = 'T' 면 PC/모바일 상세설명을 따로 쓴다.
# 'F' 면 모바일도 PC 값을 쓰므로 수정 시 두 필드를 함께 맞춰야 한다.
separated_mobile: bool
@property
def mobile_differs(self) -> bool:
return self.mobile_description != self.description
def descriptions_from_product(raw: dict[str, Any]) -> Descriptions:
"""`get_product` 응답 dict → Descriptions."""
try:
product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError):
product_no = 0
return Descriptions(
product_no=product_no,
product_name=str(raw.get("product_name") or ""),
description=str(raw.get("description") or ""),
mobile_description=str(raw.get("mobile_description") or ""),
separated_mobile=_flag(raw.get("separated_mobile_description"), default=False),
)
def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions:
"""상품의 현재 상세설명. 로컬 DB 의 마지막 버전을 현재값으로 가정하지 않는다."""
return descriptions_from_product(get_product(client, product_no))
def build_update_payload(
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
`mobile_description=None` 이면 모바일 필드를 건드리지 않는다. PC/모바일
미분리(separated_mobile=False) 상품은 호출부가 같은 HTML 을 두 번 넘겨
두 필드를 함께 맞춘다.
"""
no = int(product_no)
payload = client.get(f"/admin/products/{no}/description", product_no=no)
description = payload.get("description")
if isinstance(description, dict):
return str(description.get("description") or "")
return ""
request: dict[str, Any] = {"description": description}
if mobile_description is not None:
request["mobile_description"] = mobile_description
payload: dict[str, Any] = {"request": request}
if shop_no:
payload["shop_no"] = int(shop_no)
return payload
def update_description(client: Cafe24Client, product_no: int, html: str) -> dict[str, Any]:
"""상세설명 HTML 전체 교체. 성공하면 카페24 응답 dict 를 돌려준다.
def update_descriptions(
client: Cafe24Client,
product_no: int,
*,
description: str,
mobile_description: str | None = None,
shop_no: int | None = None,
) -> dict[str, Any]:
"""상세설명 교체. 성공하면 카페24가 돌려준 상품 dict.
실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로, 호출부는 예외가 없을
실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로 호출부는 예외가 없을
때만 성공으로 처리하면 된다.
⚠️ 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 BACKUP revision 을 남길
것(`docs/CAFE24_MODULE.md` 보안 규칙). 이 함수는 백업을 하지 않는다.
"""
no = int(product_no)
payload = client.put(
f"/admin/products/{no}/description",
json={"request": {"description": html}},
f"/admin/products/{no}",
json=build_update_payload(
description=description,
mobile_description=mobile_description,
shop_no=shop_no,
),
product_no=no,
)
description = payload.get("description")
return description if isinstance(description, dict) else payload
product = payload.get("product")
return product if isinstance(product, dict) else payload
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블 컬럼 모양으로 정규화.
카페24는 boolean 을 'T'/'F' 문자열로 준다.
"""
def flag(value: Any, *, default: bool = True) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "Y", "1"):
return True
if text in ("F", "FALSE", "N", "0"):
return False
return default
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
try:
product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError):
@@ -112,6 +178,6 @@ def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"product_no": product_no,
"product_code": str(raw.get("product_code") or ""),
"product_name": str(raw.get("product_name") or ""),
"display": flag(raw.get("display")),
"selling": flag(raw.get("selling")),
"display": _flag(raw.get("display")),
"selling": _flag(raw.get("selling")),
}
+7 -2
View File
@@ -58,10 +58,15 @@ class TokenService:
# 조회
# ────────────────────────────────────────────────────────────
def _aware(self, value: Any):
"""DB 에서 온 datetime 을 KST aware 로 정규화."""
"""DB 에서 온 datetime 을 KST aware 로 정규화.
컬럼이 timestamptz 라 psycopg 는 UTC 로 돌려준다. 시각 자체는 같지만
화면에 `+00:00` 으로 보이므로 KST 로 변환해 다른 모듈과 표기를 맞춘다.
"""
if value is None:
return None
return value if value.tzinfo else value.replace(tzinfo=KST)
aware = value if value.tzinfo else value.replace(tzinfo=KST)
return aware.astimezone(KST)
def status(self) -> dict[str, Any]:
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""