diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index 7204a73..34c1958 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -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")), } diff --git a/app/integrations/cafe24/tokens.py b/app/integrations/cafe24/tokens.py index 39a8157..3a03ed1 100644 --- a/app/integrations/cafe24/tokens.py +++ b/app/integrations/cafe24/tokens.py @@ -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]: """관리자 화면용 연결 상태. 토큰 값 자체는 절대 넣지 않는다.""" diff --git a/app/modules/cafe24/db.py b/app/modules/cafe24/db.py index eae8fae..a7ed0d2 100644 --- a/app/modules/cafe24/db.py +++ b/app/modules/cafe24/db.py @@ -217,6 +217,52 @@ class Cafe24Store: ).fetchall() 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 (다른 모듈과 동일) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/cafe24/router.py b/app/modules/cafe24/router.py index a5a6b51..31b6bf8 100644 --- a/app/modules/cafe24/router.py +++ b/app/modules/cafe24/router.py @@ -9,8 +9,9 @@ 라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. + routes_products 상품 목록/검색 · 상세설명 조회 routes_system 연결(OAuth)·상태·API 로그·작업 로그 - (Phase 2~) routes_products / routes_schedules + (Phase 5) routes_schedules """ from __future__ import annotations @@ -21,12 +22,14 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from .common import base_ctx, guard +from .routes_products import products_router from .routes_system import system_router logger = logging.getLogger("cafe24.router") router = APIRouter(prefix="/cafe24", tags=["cafe24"]) +router.include_router(products_router) router.include_router(system_router) @@ -36,9 +39,12 @@ def health() -> dict[str, str]: return {"status": "ok"} -@router.get("/", response_class=HTMLResponse) -def index(request: Request) -> HTMLResponse: - """상품 목록 (Phase 2 에서 구현). 지금은 연결 상태 안내만.""" +@router.get("/schedules", response_class=HTMLResponse) +def schedules(request: Request) -> HTMLResponse: + """예약관리 안내. Phase 5 에서 routes_schedules.py 로 옮긴다. + + 상단 탭에 링크가 있으므로 404 를 내지 않고 안내 화면을 보여준다. + """ from app.main import render_template # noqa: WPS433 checked = guard(request) @@ -46,11 +52,11 @@ def index(request: Request) -> HTMLResponse: return checked _st, user = checked - ctx = base_ctx(request, user, active_tab="products") + ctx = base_ctx(request, user, active_tab="schedules") ctx.update( { - "page_title": "카페24 상품관리", - "page_subtitle": "상품 상세페이지 조회·편집·예약", + "page_title": "카페24 — 예약관리", + "page_subtitle": "예약 적용 · 자동 복원", } ) - return render_template(request, "cafe24/index.html", ctx) + return render_template(request, "cafe24/schedules.html", ctx) diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py new file mode 100644 index 0000000..0ada374 --- /dev/null +++ b/app/modules/cafe24/routes_products.py @@ -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) diff --git a/app/modules/cafe24/templates/cafe24/index.html b/app/modules/cafe24/templates/cafe24/index.html deleted file mode 100644 index ab6b237..0000000 --- a/app/modules/cafe24/templates/cafe24/index.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "erp_base.html" %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -{% include "cafe24/_nav.html" %} - -
- 먼저 시스템 → 카페24 연결에서 인증을 완료하세요. - 연결이 끝나면 이 화면에서 상품 조회·검색·상세페이지 편집을 할 수 있습니다. -
-| 상품코드 | {{ info.product_code or '—' }} |
|---|---|
| 판매가 | {{ info.price or '—' }} |
| 진열 / 판매 | ++ {% if info.display %}진열 + {% else %}미진열{% endif %} + {% if info.selling %}판매 + {% else %}중지{% endif %} + | +
| 최근 수정 | {{ info.updated_date or '—' }} |
| 요약설명 | {{ info.summary_description or '—' }} |
| PC 상세설명 | {{ desc.description | length }}자 |
|---|---|
| 모바일 상세설명 | ++ {{ desc.mobile_description | length }}자 · + {% if desc.separated_mobile %} + PC와 분리 사용 — 수정 시 모바일도 따로 반영해야 합니다. + {% else %} + PC와 동일 설정 + {% endif %} + {% if desc.mobile_differs %}내용 불일치{% endif %} + | +
| 상품번호 | 상품코드 | 상품명 | +진열 | 판매 | 판매가 | 최근 수정 | + |
|---|---|---|---|---|---|---|---|
| {{ r.product_no }} | +{{ r.product_code }} |
+ {{ r.product_name }} | ++ {% if r.display %}진열 + {% else %}미진열{% endif %} + | ++ {% if r.selling %}판매 + {% else %}중지{% endif %} + | +{{ r.price }} | +{{ r.updated_date }} | ++ 상세페이지 + | +
+ {% if keyword %}“{{ keyword }}” 로 찾은 상품이 없습니다.{% else %}표시할 상품이 없습니다.{% endif %} +
+ {% endif %} +
+ 지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
+ 예약은 dbx-cafe24-worker 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
+
+ 지금은 상품 목록에서 현재 상세페이지 HTML 을 확인할 수 있습니다. +
+PC
", + "mobile_description": "PC
", + "separated_mobile_description": "F", +} + + +def test_descriptions_from_product(): + desc = products.descriptions_from_product(_PRODUCT) + assert desc.product_no == 131 + assert desc.description == "PC
" + 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": "MO
"} + ) + 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 == "PC
" + 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="NEW
") + call = client.calls[0] + assert call["method"] == "PUT" and call["path"] == "/admin/products/131" + # 준 필드만 바뀌어야 한다 — 모바일을 지정하지 않으면 보내지 않는다. + assert call["json"] == {"request": {"description": "NEW
"}} + + +def test_update_payload_optional_fields(): + both = products.build_update_payload( + description="PC
", mobile_description="MO
", shop_no=1 + ) + assert both == {"shop_no": 1, "request": {"description": "PC
", "mobile_description": "MO
"}} + # 빈 문자열은 "모바일을 비운다"는 뜻이므로 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(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: diff --git a/app/static/cafe24.css b/app/static/cafe24.css index 1c09d32..a7161a2 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -86,6 +86,61 @@ 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 { overflow-x: auto; diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index cf275ab..607e2a3 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -24,13 +24,15 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 └─ errors.py 공통 예외 app/modules/cafe24/ ← 상품관리 모듈 -├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 -├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 -├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) -├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) -├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 -├─ tests/ DB/네트워크 없는 유닛테스트 -└─ templates/cafe24/ _nav.html · index.html · system.html +├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 +├─ routes_products.py 상품 목록/검색 · 상세설명 조회 (읽기 전용) +├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 +├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) +├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) +├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 +├─ tests/ DB/네트워크 없는 유닛테스트 +└─ templates/cafe24/ _nav.html · products.html · product.html · + schedules.html · system.html ``` **규칙: 라우터에서 `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/oauth/start` | 카페24 인증 시작 | **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. 보안 규칙 (반드시 지킬 것) - `client_secret`·토큰을 코드에 하드코딩하지 않는다. 전부 `.env`. @@ -151,7 +183,7 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노 | Phase | 내용 | 상태 | | --- | --- | --- | | 1 | 공통 Integration · cafe24_db · OAuth 연결 화면 | ✅ 완료 | -| 2 | 상품 목록·검색·현재 HTML 조회 | 예정 | +| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 | | 3 | Monaco 편집 · 미리보기 · Diff · 초안 | 예정 | | 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | 예정 | | 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |