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 모양만 전송/재시도/인증은 Cafe24Client 가 담당하고, 여기서는 경로와 payload 모양만
안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다. 안다. 향후 주문관리는 같은 클라이언트로 `orders.py` 를 추가하면 된다.
⚠️ 상품 수정 payload 구조는 카페24 Admin API 버전에 따라 다를 수 있다. 상세설명은 **별도 리소스가 아니다.** 실제 쇼핑몰(miraskitchen)에 확인한 결과
실제 쇼핑몰에 반영하기 전 반드시 테스트 상품 1건으로 검증할 것. `/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 __future__ import annotations
from dataclasses import dataclass
from typing import Any from typing import Any
from .client import Cafe24Client from .client import Cafe24Client
@@ -17,6 +26,18 @@ from .client import Cafe24Client
PAGE_LIMIT = 100 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: def count_products(client: Cafe24Client, *, product_name: str = "") -> int:
params: dict[str, Any] = {} params: dict[str, Any] = {}
if product_name: if product_name:
@@ -51,58 +72,103 @@ def list_products(
def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]: def get_product(client: Cafe24Client, product_no: int) -> dict[str, Any]:
"""상품 1건 기본 정보 (상세설명은 별도 조회 — get_description).""" """상품 1건 상세. 이 응답에 상세설명 필드까지 들어 있다."""
payload = client.get(f"/admin/products/{int(product_no)}", product_no=int(product_no)) no = int(product_no)
payload = client.get(f"/admin/products/{no}", product_no=no)
product = payload.get("product") product = payload.get("product")
return product if isinstance(product, dict) else {} return product if isinstance(product, dict) else {}
def get_description(client: Cafe24Client, product_no: int) -> str: @dataclass(frozen=True)
"""상품의 현재 상세설명 HTML. class Descriptions:
"""상품 1건의 상세설명 묶음. 카페24가 언제나 source of truth 다."""
카페24는 상세설명을 별도 리소스로 제공한다. 이 값이 언제나 source of truth product_no: int
이며, 로컬 DB 의 마지막 버전을 현재값이라고 가정하지 않는다. 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) request: dict[str, Any] = {"description": description}
payload = client.get(f"/admin/products/{no}/description", product_no=no) if mobile_description is not None:
description = payload.get("description") request["mobile_description"] = mobile_description
if isinstance(description, dict): payload: dict[str, Any] = {"request": request}
return str(description.get("description") or "") if shop_no:
return "" payload["shop_no"] = int(shop_no)
return payload
def update_description(client: Cafe24Client, product_no: int, html: str) -> dict[str, Any]: def update_descriptions(
"""상세설명 HTML 전체 교체. 성공하면 카페24 응답 dict 를 돌려준다. 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) no = int(product_no)
payload = client.put( payload = client.put(
f"/admin/products/{no}/description", f"/admin/products/{no}",
json={"request": {"description": html}}, json=build_update_payload(
description=description,
mobile_description=mobile_description,
shop_no=shop_no,
),
product_no=no, product_no=no,
) )
description = payload.get("description") product = payload.get("product")
return description if isinstance(description, dict) else payload return product if isinstance(product, dict) else payload
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]: def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블 컬럼 모양으로 정규화. """카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
카페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
try: try:
product_no = int(raw.get("product_no") or 0) product_no = int(raw.get("product_no") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -112,6 +178,6 @@ def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"product_no": product_no, "product_no": product_no,
"product_code": str(raw.get("product_code") or ""), "product_code": str(raw.get("product_code") or ""),
"product_name": str(raw.get("product_name") or ""), "product_name": str(raw.get("product_name") or ""),
"display": flag(raw.get("display")), "display": _flag(raw.get("display")),
"selling": flag(raw.get("selling")), "selling": _flag(raw.get("selling")),
} }
+7 -2
View File
@@ -58,10 +58,15 @@ class TokenService:
# 조회 # 조회
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
def _aware(self, value: Any): def _aware(self, value: Any):
"""DB 에서 온 datetime 을 KST aware 로 정규화.""" """DB 에서 온 datetime 을 KST aware 로 정규화.
컬럼이 timestamptz 라 psycopg 는 UTC 로 돌려준다. 시각 자체는 같지만
화면에 `+00:00` 으로 보이므로 KST 로 변환해 다른 모듈과 표기를 맞춘다.
"""
if value is None: if value is None:
return 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]: def status(self) -> dict[str, Any]:
"""관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다.""" """관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다."""
+46
View File
@@ -217,6 +217,52 @@ class Cafe24Store:
).fetchall() ).fetchall()
return [self._serialize(r) for r in rows] return [self._serialize(r) for r in rows]
# ════════════════════════════════════════════════════════════
# 상품 캐시
# source of truth 는 언제나 카페24다. 이 표는 목록 조회 결과를 담아두는
# 곳이며, 예약·로그 화면에서 API 호출 없이 상품명을 보여줄 때 쓴다.
# 상세설명(HTML)은 여기 넣지 않는다(cafe24_product_revisions 담당).
# ════════════════════════════════════════════════════════════
def upsert_products(self, rows: list[dict[str, Any]]) -> int:
"""정규화된 상품 dict 목록(products.normalize_product 결과)을 UPSERT."""
valid = [r for r in rows if int(r.get("product_no") or 0) > 0]
if not valid:
return 0
with self._pool.connection() as conn:
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO cafe24_products
(product_no, product_code, product_name, display, selling, last_synced_at)
VALUES (%s,%s,%s,%s,%s, now())
ON CONFLICT (product_no) DO UPDATE SET
product_code = EXCLUDED.product_code,
product_name = EXCLUDED.product_name,
display = EXCLUDED.display,
selling = EXCLUDED.selling,
last_synced_at = now()
""",
[
(
int(r["product_no"]),
str(r.get("product_code") or ""),
str(r.get("product_name") or ""),
bool(r.get("display", True)),
bool(r.get("selling", True)),
)
for r in valid
],
)
return len(valid)
def get_cached_product(self, product_no: int) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM cafe24_products WHERE product_no = %s",
(int(product_no),),
).fetchone()
return self._serialize(row)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일) # 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
+14 -8
View File
@@ -9,8 +9,9 @@
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에 라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회
routes_system 연결(OAuth)·상태·API 로그·작업 로그 routes_system 연결(OAuth)·상태·API 로그·작업 로그
(Phase 2~) routes_products / routes_schedules (Phase 5) routes_schedules
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,12 +22,14 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from .common import base_ctx, guard from .common import base_ctx, guard
from .routes_products import products_router
from .routes_system import system_router from .routes_system import system_router
logger = logging.getLogger("cafe24.router") logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router)
router.include_router(system_router) router.include_router(system_router)
@@ -36,9 +39,12 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@router.get("/", response_class=HTMLResponse) @router.get("/schedules", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse: def schedules(request: Request) -> HTMLResponse:
"""상품 목록 (Phase 2 에서 구현). 지금은 연결 상태 안내만.""" """예약관리 안내. Phase 5 에서 routes_schedules.py 로 옮긴다.
상단 탭에 링크가 있으므로 404 를 내지 않고 안내 화면을 보여준다.
"""
from app.main import render_template # noqa: WPS433 from app.main import render_template # noqa: WPS433
checked = guard(request) checked = guard(request)
@@ -46,11 +52,11 @@ def index(request: Request) -> HTMLResponse:
return checked return checked
_st, user = checked _st, user = checked
ctx = base_ctx(request, user, active_tab="products") ctx = base_ctx(request, user, active_tab="schedules")
ctx.update( ctx.update(
{ {
"page_title": "카페24 상품관리", "page_title": "카페24 — 예약관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약", "page_subtitle": "예약 적용 · 자동 복원",
} }
) )
return render_template(request, "cafe24/index.html", ctx) return render_template(request, "cafe24/schedules.html", ctx)
+151
View File
@@ -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)
@@ -1,17 +0,0 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814b" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
<div class="erp-card cf24-empty">
<h3>상품 목록은 Phase 2 에서 열립니다.</h3>
<p>
먼저 <a href="/cafe24/system">시스템 → 카페24 연결</a>에서 인증을 완료하세요.
연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다.
</p>
</div>
{% endblock %}
@@ -0,0 +1,113 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
{# ── 기본 정보 ─────────────────────────────────────────────── #}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>{{ info.product_name or '상품' }}</h3>
<span class="cf24-muted">상품번호 {{ product_no }}</span>
</div>
<table class="erp-table cf24-kv">
<tbody>
<tr><th>상품코드</th><td><code>{{ info.product_code or '—' }}</code></td></tr>
<tr><th>판매가</th><td>{{ info.price or '—' }}</td></tr>
<tr>
<th>진열 / 판매</th>
<td>
{% if info.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
{% if info.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
</td>
</tr>
<tr><th>최근 수정</th><td>{{ info.updated_date or '—' }}</td></tr>
<tr><th>요약설명</th><td>{{ info.summary_description or '—' }}</td></tr>
</tbody>
</table>
<div class="cf24-actions">
<a class="erp-btn erp-btn-outline" href="/cafe24/">← 상품 목록</a>
</div>
</div>
{# ── 상세설명 HTML (읽기 전용) ─────────────────────────────── #}
{% if desc %}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>상세페이지 HTML</h3>
<span class="cf24-muted">카페24 현재값 · 읽기 전용 (편집·적용은 Phase 3~4)</span>
</div>
<table class="erp-table cf24-kv">
<tbody>
<tr><th>PC 상세설명</th><td>{{ desc.description | length }}자</td></tr>
<tr>
<th>모바일 상세설명</th>
<td>
{{ desc.mobile_description | length }}자 ·
{% if desc.separated_mobile %}
<span class="cf24-warn">PC와 분리 사용</span> — 수정 시 모바일도 따로 반영해야 합니다.
{% else %}
<span class="cf24-muted">PC와 동일 설정</span>
{% endif %}
{% if desc.mobile_differs %}<span class="cf24-warn">내용 불일치</span>{% endif %}
</td>
</tr>
</tbody>
</table>
<label class="cf24-label" for="cf24-html-pc">PC 상세설명 HTML</label>
<textarea id="cf24-html-pc" class="cf24-html" rows="16" readonly spellcheck="false"
>{{ desc.description }}</textarea>
<div class="cf24-actions">
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-pc">HTML 복사</button>
</div>
{% if desc.separated_mobile or desc.mobile_differs %}
<label class="cf24-label" for="cf24-html-mo">모바일 상세설명 HTML</label>
<textarea id="cf24-html-mo" class="cf24-html" rows="16" readonly spellcheck="false"
>{{ desc.mobile_description }}</textarea>
<div class="cf24-actions">
<button type="button" class="erp-btn erp-btn-outline" data-copy="cf24-html-mo">HTML 복사</button>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// HTML 복사 — clipboard API 가 막히면 textarea 선택으로 대체한다.
document.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = document.getElementById(btn.dataset.copy);
if (!box) return;
var done = function () {
var old = btn.textContent;
btn.textContent = "복사했습니다";
setTimeout(function () { btn.textContent = old; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.value).then(done, function () { box.select(); });
} else {
box.select();
try { document.execCommand("copy"); done(); } catch (e) { /* 사용자가 직접 복사 */ }
}
});
});
</script>
{% endblock %}
@@ -0,0 +1,87 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
{% if error %}
<div class="cf24-flash cf24-flash-err">
카페24 조회에 실패했습니다: {{ error }}<br />
<a href="/cafe24/system">시스템 화면에서 연결 상태를 확인하세요.</a>
</div>
{% endif %}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>상품 목록</h3>
<span class="cf24-muted">
{% if keyword %}“{{ keyword }}” 검색 결과 {{ total }}건{% else %}전체 {{ total }}건{% endif %}
· {{ page }} / {{ last_page }} 페이지
</span>
</div>
<form class="cf24-toolbar" method="get" action="/cafe24/">
<input class="cf24-search" type="search" name="q" value="{{ keyword }}"
placeholder="상품명으로 검색 (부분일치)" />
<button class="erp-btn erp-btn-primary" type="submit">검색</button>
{% if keyword %}<a class="erp-btn erp-btn-outline" href="/cafe24/">전체보기</a>{% endif %}
</form>
{% if rows %}
<div class="cf24-scroll">
<table class="erp-table">
<thead>
<tr>
<th>상품번호</th><th>상품코드</th><th>상품명</th>
<th>진열</th><th>판매</th><th>판매가</th><th>최근 수정</th><th></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="cf24-nowrap">{{ r.product_no }}</td>
<td class="cf24-nowrap"><code>{{ r.product_code }}</code></td>
<td>{{ r.product_name }}</td>
<td class="cf24-nowrap">
{% if r.display %}<span class="erp-badge cf24-badge-ok">진열</span>
{% else %}<span class="erp-badge cf24-badge-off">미진열</span>{% endif %}
</td>
<td class="cf24-nowrap">
{% if r.selling %}<span class="erp-badge cf24-badge-ok">판매</span>
{% else %}<span class="erp-badge cf24-badge-off">중지</span>{% endif %}
</td>
<td class="cf24-nowrap">{{ r.price }}</td>
<td class="cf24-nowrap">{{ r.updated_date }}</td>
<td class="cf24-nowrap">
<a class="erp-btn erp-btn-outline" href="/cafe24/products/{{ r.product_no }}">상세페이지</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if last_page > 1 %}
<div class="cf24-pager">
{% if page > 1 %}
<a class="erp-btn erp-btn-outline"
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page - 1 }}">← 이전</a>
{% endif %}
<span class="cf24-muted">{{ page }} / {{ last_page }}</span>
{% if page < last_page %}
<a class="erp-btn erp-btn-outline"
href="/cafe24/?q={{ keyword | urlencode }}&page={{ page + 1 }}">다음 →</a>
{% endif %}
</div>
{% endif %}
{% elif not error %}
<p class="cf24-muted">
{% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %}
</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
<div class="erp-card cf24-empty">
<h3>예약관리는 Phase 5 에서 열립니다.</h3>
<p>
지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
예약은 <code>dbx-cafe24-worker</code> 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
</p>
<p>
지금은 <a href="/cafe24/">상품 목록</a>에서 현재 상세페이지 HTML 을 확인할 수 있습니다.
</p>
</div>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814b" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260814c" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
+103 -1
View File
@@ -12,7 +12,7 @@ from contextlib import contextmanager
from datetime import timedelta from datetime import timedelta
from app.integrations.cafe24 import config as cfgmod from app.integrations.cafe24 import config as cfgmod
from app.integrations.cafe24 import crypto, oauth, tokens from app.integrations.cafe24 import crypto, oauth, products, tokens
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
from app.modules.cafe24 import store from app.modules.cafe24 import store
from app.timezone import now_kst from app.timezone import now_kst
@@ -240,6 +240,108 @@ def test_parse_product_no():
raise AssertionError(f"{bad!r} 는 거부해야 한다") raise AssertionError(f"{bad!r} 는 거부해야 한다")
# ════════════════════════════════════════════════════════════
# 상품 엔드포인트 래퍼
# 실제 쇼핑몰 확인 결과 /admin/products/{no}/description 은 존재하지 않는다
# (`No API found.`). 상세설명은 상품 리소스의 필드다 — 경로가 되돌아가지 않게
# 여기서 고정한다.
# ════════════════════════════════════════════════════════════
class _FakeClient:
"""Cafe24Client 의 get/put 만 흉내내고 호출을 기록한다."""
def __init__(self, payload=None):
self.payload = payload or {}
self.calls: list[dict] = []
def get(self, path, *, params=None, json=None, product_no=None):
self.calls.append({"method": "GET", "path": path, "params": params})
return self.payload
def put(self, path, *, params=None, json=None, product_no=None):
self.calls.append({"method": "PUT", "path": path, "json": json})
return self.payload
_PRODUCT = {
"product_no": 131,
"product_code": "P000000B",
"product_name": "빠져락 1개(사은품)",
"display": "T",
"selling": "F",
"description": "<p>PC</p>",
"mobile_description": "<p>PC</p>",
"separated_mobile_description": "F",
}
def test_descriptions_from_product():
desc = products.descriptions_from_product(_PRODUCT)
assert desc.product_no == 131
assert desc.description == "<p>PC</p>"
assert desc.separated_mobile is False
assert desc.mobile_differs is False
def test_descriptions_separated_mobile_and_diff():
desc = products.descriptions_from_product(
{**_PRODUCT, "separated_mobile_description": "T", "mobile_description": "<p>MO</p>"}
)
assert desc.separated_mobile is True
assert desc.mobile_differs is True
def test_fetch_descriptions_uses_product_resource():
client = _FakeClient({"product": _PRODUCT})
desc = products.fetch_descriptions(client, 131)
assert desc.description == "<p>PC</p>"
paths = [c["path"] for c in client.calls]
assert paths == ["/admin/products/131"], paths
assert not any(p.endswith("/description") for p in paths)
def test_update_descriptions_payload():
client = _FakeClient({"product": _PRODUCT})
products.update_descriptions(client, 131, description="<p>NEW</p>")
call = client.calls[0]
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
# 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다.
assert call["json"] == {"request": {"description": "<p>NEW</p>"}}
def test_update_payload_optional_fields():
both = products.build_update_payload(
description="<p>PC</p>", mobile_description="<p>MO</p>", shop_no=1
)
assert both == {"shop_no": 1, "request": {"description": "<p>PC</p>", "mobile_description": "<p>MO</p>"}}
# 빈 문자열은 "모바일을 비운다"는 뜻이므로 None 과 구분해 전달돼야 한다.
assert products.build_update_payload(description="x", mobile_description="")["request"] == {
"description": "x",
"mobile_description": "",
}
def test_normalize_product_flags():
row = products.normalize_product(_PRODUCT)
assert row == {
"product_no": 131,
"product_code": "P000000B",
"product_name": "빠져락 1개(사은품)",
"display": True,
"selling": False,
}
# 값이 없으면 기본 True(카페24 응답에 필드가 빠진 경우 진열 중으로 본다).
assert products.normalize_product({"product_no": "9"})["display"] is True
def test_list_products_clamps_paging():
client = _FakeClient({"products": []})
products.list_products(client, limit=999, offset=-5, product_name="")
params = client.calls[0]["params"]
assert params["limit"] == products.PAGE_LIMIT
assert params["offset"] == 0
assert params["product_name"] == ""
def _run_all(): def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: for fn in fns:
+55
View File
@@ -86,6 +86,61 @@
margin-top: var(--sp-12, 12px); margin-top: var(--sp-12, 12px);
} }
.cf24-warn {
color: var(--color-callout-red, #c22b10);
font-size: var(--text-caption, 12px);
font-weight: 500;
}
/* 검색 도구모음 / 페이지 이동 */
.cf24-toolbar {
display: flex;
gap: var(--sp-8, 8px);
align-items: center;
flex-wrap: wrap;
margin-bottom: var(--sp-12, 12px);
}
.cf24-search {
flex: 0 1 320px;
padding: var(--sp-8, 8px) var(--sp-12, 12px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
font-size: var(--text-body, 14px);
}
.cf24-pager {
display: flex;
gap: var(--sp-12, 12px);
align-items: center;
justify-content: center;
margin-top: var(--sp-16, 16px);
}
/* 상세설명 HTML 원문 */
.cf24-label {
display: block;
margin: var(--sp-16, 16px) 0 var(--sp-8, 8px);
font-size: var(--text-caption, 12px);
font-weight: 500;
color: var(--color-midtone-gray, #737373);
}
.cf24-html {
width: 100%;
box-sizing: border-box;
padding: var(--sp-12, 12px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
background: var(--color-ghost-gray, #f2f2f2);
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.6;
white-space: pre;
overflow: auto;
resize: vertical;
}
/* 넓은 로그 표는 카드 안에서만 가로 스크롤 */ /* 넓은 로그 표는 카드 안에서만 가로 스크롤 */
.cf24-scroll { .cf24-scroll {
overflow-x: auto; overflow-x: auto;
+41 -9
View File
@@ -24,13 +24,15 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
└─ errors.py 공통 예외 └─ errors.py 공통 예외
app/modules/cafe24/ ← 상품관리 모듈 app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ routes_products.py 상품 목록/검색 · 상세설명 조회 (읽기 전용)
├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ tests/ DB/네트워크 없는 유닛테스트 ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
─ templates/cafe24/ _nav.html · index.html · system.html ─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html · product.html ·
schedules.html · system.html
``` ```
**규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시 **규칙: 라우터에서 `httpx`/`requests` 를 직접 부르지 않는다.** 반드시
@@ -46,7 +48,9 @@ app/modules/cafe24/ ← 상품관리 모듈
| 경로 | 화면 | 권한 | | 경로 | 화면 | 권한 |
| --- | --- | --- | | --- | --- | --- |
| `GET /cafe24/` | 상품 목록 (Phase 2) | `cafe24` | | `GET /cafe24/` | 상품 목록·검색 (`q`, `page`) | `cafe24` |
| `GET /cafe24/products/{product_no}` | 상품 1건 + 현재 상세설명 HTML (읽기 전용) | `cafe24` |
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` | | `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** | | `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** | | `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
@@ -82,6 +86,34 @@ cafe24_oauth_tokens 저장
--- ---
## 3-1. 상세설명 API 사실 (실물 확인 결과 — 추측 금지)
운영 쇼핑몰(`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(`GET /admin/products`) 응답에는 `description` 이 없다.**
그래서 상세설명은 상품 1건씩 조회해야 하고, 목록 화면에 미리보기를 뿌리지
않는다(상품 87개 × 1호출 = 호출 제한 위험).
- **PC/모바일 상세설명이 분리되어 있다.** `separated_mobile_description`
(`'T'`/`'F'`) 이 분리 사용 여부다. `'F'`(미분리) 상품을 수정할 때는
`description` 과 `mobile_description` 을 같은 HTML 로 함께 맞춘다.
`'T'` 면 두 값을 따로 관리해야 한다.
- 그 밖에 상세 응답에만 있는 참고 필드: `translated_description`(다국어),
`summary_description`(요약설명), `simple_description`, `shop_no`(멀티쇼핑몰).
---
## 4. 보안 규칙 (반드시 지킬 것) ## 4. 보안 규칙 (반드시 지킬 것)
- `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`. - `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`.
@@ -151,7 +183,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| Phase | 내용 | 상태 | | Phase | 내용 | 상태 |
| --- | --- | --- | | --- | --- | --- |
| 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 | | 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 |
| 2 | 상품 목록·검색·현재 HTML 조회 | 예정 | | 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
| 3 | Monaco 편집 · 미리보기 · Diff · 초안 | 예정 | | 3 | Monaco 편집 · 미리보기 · Diff · 초안 | 예정 |
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | 예정 | | 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | 예정 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 | | 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |